Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# ── Supabase ──────────────────────────────────────────────────────────────────
NEXT_PUBLIC_SUPABASE_URL=your-supabase-url
NEXT_PUBLIC_SUPABASE_URL=https://hmsjbjhjlpcbixkhlvks.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-supabase-anon-key
SUPABASE_SERVICE_ROLE_KEY=your-supabase-service-role-key # PRIVATE — server-only

Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,7 @@ audit/
*.log
*.tmp
.cache/

# Agent specific files
.agents/
skills-lock.json
2 changes: 2 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@
"canvas-confetti": "^1.9.4",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"framer-motion": "^12.38.0",
"isomorphic-dompurify": "2.36.0",
"lucide-react": "^1.8.0",
"next": "^14.2.0",
"next-intl": "^3.22.0",
"next-sanity": "^12.1.0",
Expand Down
177 changes: 177 additions & 0 deletions apps/web/src/app/[locale]/(platform)/games/conexo/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
"use client";

import { motion, AnimatePresence } from "framer-motion";
import { useTranslations } from "next-intl";
import { useEffect } from "react";
import { useWallet } from "@solana/wallet-adapter-react";
import { Button } from "@/components/ui/button";
import { GameHeader } from "@/components/games/GameHeader";
import { useConexo } from "@/hooks/useConexo";
import { learningProgressService } from "@/services/learningProgress";

export default function ConexoPage() {
const t = useTranslations();
const { publicKey } = useWallet();
const {
boardTiles,
solvedGroups,
selectedIds,
mistakesRemaining,
status,
isShaking,
toggleSelection,
submitGuess,
shuffleTiles,
} = useConexo();

useEffect(() => {
if (status === "won" && publicKey) {
learningProgressService.completeArcadeGame({
wallet: publicKey.toString(),
gameId: "conexo",
xp: 150,
});
}
}, [status, publicKey]);

return (
<main className="mx-auto max-w-3xl px-4 py-8">
<GameHeader
title="Conexo.sol"
description={t("arcade.conexo.desc")}
rules={
<>
<p>{t("arcade.conexo.rules.1")}</p>
<p>{t("arcade.conexo.rules.2")}</p>
<p>
<strong>{t("arcade.conexo.rules.3")}</strong>
</p>
</>
}
/>

<div className="mt-6 flex select-none flex-col items-center">
{status === "won" && (
<div className="mb-6 rounded-lg border border-green-500/30 bg-green-500/20 px-6 py-3 text-center font-semibold text-green-400 animate-in fade-in zoom-in">
{t("arcade.win")}
</div>
)}
{status === "lost" && (
<div className="mb-6 rounded-lg border border-red-500/30 bg-red-500/20 px-6 py-3 text-center font-semibold text-red-400 animate-in fade-in zoom-in">
{t("arcade.lose")}
</div>
)}

{/* Solved Groups Board */}
<div className="mb-2 flex w-full flex-col gap-2">
<AnimatePresence>
{solvedGroups.map((group) => (
<motion.div
layout
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
key={group.category}
className={`relative w-full overflow-hidden rounded-md border p-4 text-center shadow-lg ${group.color.replace("bg-", "bg-").replace("500", "500/20")} border-${group.color.split("-")[1]}-500/40`}
>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-r from-transparent via-white/5 to-transparent" />
<h3
className={`mb-1 text-lg font-bold uppercase tracking-wider text-${group.color.split("-")[1]}-400`}
>
{group.category.replace(/-/g, " ")}
</h3>
<p className="truncate px-2 font-semibold text-foreground">
{group.terms.map((t) => t.term).join(", ")}
</p>
</motion.div>
))}
</AnimatePresence>
</div>

{/* Unsolved Grid */}
<div className="grid w-full grid-cols-4 gap-2">
<AnimatePresence>
{boardTiles.map((tile) => {
const isSelected = selectedIds.includes(tile.id);
return (
<motion.div
layout
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.5 }}
key={tile.id}
onClick={() => toggleSelection(tile.id)}
className={`flex aspect-[4/3] cursor-pointer items-center justify-center rounded-md border p-2 text-center text-[13px] font-semibold shadow-sm transition-colors duration-200 sm:aspect-[2/1] sm:text-base ${isSelected ? "border-primary bg-primary text-black" : "border-border/50 bg-black/30 text-foreground hover:bg-black/50"} ${isShaking && isSelected ? "animate-shake border-destructive bg-destructive text-destructive-foreground" : ""} `}
>
<span className="line-clamp-3 break-words">{tile.term}</span>
</motion.div>
);
})}
</AnimatePresence>
</div>

{/* Action Bar */}
{status === "playing" && (
<div className="mt-8 flex w-full max-w-sm flex-col items-center gap-6">
<div className="flex items-center gap-2">
<span className="mr-2 text-sm font-medium text-muted-foreground">
{t("arcade.mistakesRemaining")}
</span>
{[...Array(4)].map((_, i) => (
<div
key={i}
className={`h-3 w-3 rounded-full transition-colors duration-300 ${
i < mistakesRemaining
? "bg-primary shadow-[0_0_8px_rgba(var(--primary),0.5)]"
: "bg-destructive/30"
}`}
/>
))}
</div>

<div className="flex w-full justify-center gap-3">
<Button
variant="outline"
onClick={shuffleTiles}
className="border-border/50 bg-black/20 hover:bg-black/40"
>
{t("arcade.shuffle")}
</Button>
<Button
onClick={submitGuess}
disabled={selectedIds.length !== 4}
className={`transition-all ${selectedIds.length === 4 ? "shadow-[0_0_15px_rgba(var(--primary),0.4)]" : ""}`}
>
{t("arcade.submit")}
</Button>
</div>
</div>
)}
</div>

<style jsx global>{`
@keyframes shake {
0%,
100% {
transform: translateX(0);
}
10%,
30%,
50%,
70%,
90% {
transform: translateX(-5px);
}
20%,
40%,
60%,
80% {
transform: translateX(5px);
}
}
.animate-shake {
animation: shake 0.6s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
}
`}</style>
</main>
);
}
176 changes: 176 additions & 0 deletions apps/web/src/app/[locale]/(platform)/games/contexto/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
"use client";

import { Send } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
import { useTranslations } from "next-intl";
import { useWallet } from "@solana/wallet-adapter-react";
import { useEffect } from "react";
import { useContexto } from "@/hooks/useContexto";
import { GameHeader } from "@/components/games/GameHeader";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { learningProgressService } from "@/services/learningProgress";

function getColorBySimilarity(similarity: number, rank: number) {
if (rank === 1)
return "bg-green-500 text-black shadow-[0_0_15px_rgba(34,197,94,0.6)]";
if (similarity > 80)
return "bg-emerald-500/20 text-emerald-400 border border-emerald-500/50";
if (similarity > 50)
return "bg-yellow-500/20 text-yellow-400 border border-yellow-500/50";
if (similarity > 20)
return "bg-orange-500/20 text-orange-400 border border-orange-500/50";
return "bg-zinc-800/50 text-zinc-400 border border-zinc-700/50";
}

function getFillColorBySimilarity(similarity: number, rank: number) {
if (rank === 1) return "bg-green-500";
if (similarity > 80) return "bg-emerald-500";
if (similarity > 50) return "bg-yellow-500";
if (similarity > 20) return "bg-orange-500";
return "bg-zinc-600";
}

export default function ContextoPage() {
const t = useTranslations();
const { publicKey } = useWallet();
const {
targetTerm,
guesses,
currentGuess,
setCurrentGuess,
submitGuess,
status,
isInvalid,
} = useContexto();

useEffect(() => {
if (status === "won" && publicKey) {
learningProgressService.completeArcadeGame({
wallet: publicKey.toString(),
gameId: "contexto",
xp: 100,
});
}
}, [status, publicKey]);

return (
<main className="mx-auto flex min-h-[calc(100vh-80px)] max-w-2xl flex-col px-4 py-8">
<GameHeader
title="Contexto.sol"
description={t("arcade.contexto.desc")}
rules={
<>
<p>{t("arcade.contexto.rules.1")}</p>
<p>{t("arcade.contexto.rules.2")}</p>
<p>{t("arcade.contexto.rules.3")}</p>
</>
}
/>

<div className="relative flex w-full flex-1 flex-col">
{/* Input Fixed at Top */}
<div className="bg-background/80 sticky top-0 z-10 w-full pb-4 pt-2 backdrop-blur-xl">
{status === "won" && (
<div className="mb-4 rounded-xl border border-green-500/50 bg-green-500/20 p-4 text-center animate-in fade-in slide-in-from-top-2">
<h2 className="text-xl font-bold text-green-400">
{t("arcade.win")}
</h2>
<p className="mt-1 text-sm text-green-200">{targetTerm?.term}</p>
</div>
)}

<div
className={`flex w-full gap-2 ${isInvalid ? "animate-shake" : ""}`}
>
<Input
placeholder={t("arcade.contexto.placeholder")}
value={currentGuess}
onChange={(e) => setCurrentGuess(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") submitGuess();
}}
className="border-border/50 h-14 bg-black/40 text-lg sm:h-16 sm:text-xl"
disabled={status === "won"}
autoFocus
/>
<Button
onClick={submitGuess}
disabled={status === "won" || !currentGuess.trim()}
className="hover:bg-primary/80 h-14 w-14 flex-shrink-0 bg-primary text-black sm:h-16 sm:w-16"
>
<Send className="h-6 w-6" />
</Button>
</div>
{isInvalid && (
<p className="absolute -bottom-5 left-2 mt-2 text-sm text-destructive">
Termo inválido ou não presente no glossário.
</p>
)}
</div>

{/* Guesses List */}
<div className="mt-8 flex w-full flex-col gap-3 pb-10">
<AnimatePresence initial={false}>
{guesses.map((guess) => (
<motion.div
key={guess.term.id}
layout
initial={{ opacity: 0, y: -20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.3 }}
className={`relative flex items-center justify-between overflow-hidden rounded-lg p-4 sm:p-5 ${getColorBySimilarity(guess.similarity, guess.rank)}`}
>
{/* Background Progress Bar */}
<div
className={`absolute bottom-0 left-0 top-0 opacity-20 ${getFillColorBySimilarity(guess.similarity, guess.rank)} transition-all duration-1000 ease-out`}
style={{ width: `${guess.similarity}%` }}
/>

<span className="z-10 text-lg font-bold sm:text-xl">
{guess.term.term}
</span>
<span className="z-10 flex items-center gap-2 font-mono font-medium">
<span className="text-xs uppercase tracking-tighter opacity-70">
{t("arcade.contexto.rank")}
</span>
{guess.rank}
</span>
</motion.div>
))}
</AnimatePresence>

{guesses.length === 0 && status === "playing" && (
<div className="border-border/50 flex h-40 items-center justify-center rounded-xl border-2 border-dashed text-muted-foreground opacity-50">
Seu histórico de palpites aparecerá aqui
</div>
)}
</div>
</div>
<style jsx global>{`
@keyframes shake {
0%,
100% {
transform: translateX(0);
}
10%,
30%,
50%,
70%,
90% {
transform: translateX(-4px);
}
20%,
40%,
60%,
80% {
transform: translateX(4px);
}
}
.animate-shake {
animation: shake 0.4s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
}
`}</style>
</main>
);
}
Loading