diff --git a/AGENTS.md b/AGENTS.md index 8bd0e39..3de87c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,3 +3,72 @@ This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. + +# Algoria Project Rules & Design System + +This document outlines the design system, architecture rules, and coding standards for the Algoria project. All AI agents working on this codebase must adhere strictly to these guidelines. + +## 1. Design System & Aesthetics + +### Typography +- **Primary Font**: Geist Sans (sans-serif) for body text and general interface. +- **Monospace Font**: Geist Mono / system monospace for code, metrics, and technical data. +- **Digital LCD Font**: `Share Tech Mono` (imported from Google Fonts). Used for stopwatch numbers and digital display readouts (`.font-digital`). +- **Sci-Fi Display Font**: `Orbitron` (imported from Google Fonts). Used for headers and technical badges (`.font-orbitron`). + +### Border Radius & Shapes +- **Zero Rounding Rule**: **DO NOT use rounded corners on any UI elements.** All buttons, inputs, dialogs, cards, progress bars, and badges must have sharp, square corners. + - Use `rounded-none` or omit the `rounded` utility class entirely. + - Avoid `rounded-sm`, `rounded-md`, `rounded-lg`, etc. + +### Colors & Themes +- **Dark Mode (Default)**: + - Background: `#020617` (Deep space slate/black) + - Foreground: `#f8fafc` + - Secondary/Cards: `#0f172a` + - Border: `#1e293b` +- **Light Mode**: + - Background: `#ffffff` + - Foreground: `#020617` + - Secondary/Cards: `#f1f5f9` + - Border: `#e2e8f0` +- **Brand Colors**: + - **Primary**: Indigo (`#4f46e5` for light, `#818cf8` for dark) + - **Gamification Accent**: Orange/Amber (`#f97316` / `#f59e0b`) for active rewards and challenges. + - **Success/Complete Accent**: Emerald/Green (`#10b981` / `#22c55e`) for completed items. + +### Animations & Motion +- **No Idle Pulsing**: Avoid looping animations such as `animate-pulse` or `animate-ping` on active status badges or pills to prevent visual clutter. +- **Transitions**: Keep page and state transitions subtle using Framer Motion (e.g. slight Y-axis slide or opacity transitions). + +--- + +## 2. Architecture & Code Structure + +### Clean Separation of Concerns +- **Logic vs View**: Do not bundle complex states, timers, event listeners, or storage reads/writes directly in UI view components. + - Move state, timing, and progress checks to **custom React hooks** (e.g., `useDailyChallenge`). + - Move storage keys, static constants, formatting helpers, and pure functions to **utility files** (e.g., `daily-challenge-utils.ts`). + - Keep `.tsx` UI files focused purely on layout, styling, and rendering. + +--- + +## 3. Product Rules & Features + +### Authentication & Gating +- **Full Gating**: The user must be logged in to view any primary problem solving content or dashboard data. Use `RequireAuth` to wrap protected views. +- **Auth Dialog**: Trigger the global `AuthDialog` via `AuthDialogProvider` to prompt user login/registration when trying to view protected pages, and redirect the user to their target pathname upon successful authentication. +- **Account Deletion**: When a user profile is deleted: + - Delete all records and progress associated with that user from the database. + - Perform `localStorage.clear()` on the client side to wipe all local progress caches. + +### Daily Challenge Mechanic +- **Timer Details**: + - Requires **3 minutes (180 seconds)** of active page visibility to complete. + - Must pause automatically if the user switches browser tabs (`document.hidden`). Use sub-second (50ms) intervals to render a fluid stopwatch (`HH:MM:SS:CC`) in the UI. +- **Checklist Requirements**: + - Daily challenges are completed only if: + 1. The problem is unlocked/accessible for the user. + 2. The statement page is visited. + 3. Both optimal and brute-force solutions (if both exist) have been visited. + 4. The 3-minute active timer has finished. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 43c994c..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -@AGENTS.md diff --git a/README.md b/README.md index d6915c1..9304c24 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Algoria -![](https://img.shields.io/badge/Versão-1.5.0-black?style=for-the-badge) +![](https://img.shields.io/badge/Versão-1.6.0-black?style=for-the-badge) Plataforma em português para estudar **algoritmos e decisões em código** através de leitura guiada: catálogo de problemas com várias soluções (brute-force, óptima, alternativa), **code player** linha-a-linha com três níveis de explicação, mini-guias em **Conceitos**, **curso modular** com avaliações locais, hub de **inglês técnico para entrevistas** (conteúdo em inglês) e guias de **engenharia aplicada** (front, back, DevOps, SoftSkills e IA). diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..c210963 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,25 @@ +# Notas de Release (v1.6.0) + +### 🚀 Novas Funcionalidades +- **Controle de Autenticação Estrito (Full Gating & Auth Dialog)**: Implementado o bloqueio completo de qualquer conteúdo de estudo (problemas, soluções comentadas, conceitos e guias de carreira) para utilizadores não autenticados. O acesso é interceptado reativamente através do componente `RequireAuth` que ativa o `AuthDialog` global e redireciona automaticamente o utilizador para a página pretendida após o sucesso do login. +- **Exclusão de Conta com Limpeza Total (Wipe)**: Criada uma caixa de diálogo de confirmação para a exclusão de conta no perfil do utilizador. A confirmação remove definitivamente todos os registros e históricos de progresso do banco de dados (Postgres) e limpa completamente o cache local do cliente através de `localStorage.clear()`. +- **Desafio Diário Aprimorado (Checklist e Validação)**: O sistema de Desafio Diário agora exige a validação estrita de três tarefas antes de considerar o desafio completo: 1) o problema deve ser acessível ao utilizador, 2) o enunciado deve ser visitado, 3) todas as soluções disponíveis do problema devem ser lidas, e 4) o utilizador deve passar pelo menos 3 minutos (180 segundos) de tempo ativo na página. + +### 🛠️ Melhorias Técnicas & UI +- **Cronómetro Digital LCD/LED (Glowing Clock)**: Adicionado um widget de cronómetro baseado no modelo digital de sete segmentos (`HH:MM:SS:CC`) com renderização em tempo real atualizada a cada 50ms para exibição fluida de centésimos de segundo. O contador usa uma caixa preta de alto contraste com fontes personalizadas e sombras de texto luminescentes (*glow*) brancas e verdes. +- **Estrutura Modular (Separação de Conceitos)**: O rastreador do desafio foi dividido em três partes organizadas: os utilitários de armazenamento e formatação (`daily-challenge-utils.ts`), o hook de controle de efeitos e tempo ativo (`use-daily-challenge.ts`) e o componente de interface pura (`daily-challenge-tracker.tsx`). +- **Alinhamento com o Design System Estrito**: Removidas todas as bordas arredondadas da interface do banner (`rounded-none` absoluto) e limpos os efeitos de pulsação ociosa (`animate-pulse` / `animate-ping`) para garantir sobriedade visual e consistência técnica. +- **Layout Inteligente Flutuante (Sticky)**: O banner agora acompanha o scroll da página de forma fixa (`sticky top-16`) mantendo exatamente a mesma largura útil do conteúdo principal (`max-w-7xl px-6`) com efeito translúcido no fundo e zero impacto na tela quando inativo. + +--- + +# Changelog + +## 2026-05-29 — Strict Auth Gating, Account Wipe, and High-Fidelity Stopwatch Tracker +Esta versão foca-se na segurança de dados, privacidade do utilizador e fidelidade visual da interface de desafios, trazendo alinhamentos estritos com o design system da plataforma (zero cantos arredondados) e melhorias na estrutura do código de gamificação. + +- **Controle de Acesso Total**: Gating estrito de todo o conteúdo educativo da plataforma, exigindo autenticação via modal global integrado antes do redirecionamento ao destino. +- **Apagamento Completo de Dados**: Funcionalidade de exclusão de conta no perfil que limpa as tabelas relacionais em nuvem e esvazia o armazenamento local do navegador em um único passo. +- **Cronómetro Centesimal com Glow**: Novo design de cronómetro digital com efeito neon de 8 dígitos, medindo o tempo de engajamento do utilizador com suporte a pausas de visibilidade da aba do browser. +- **Status Dashboard Monospace**: Exibição simplificada de requisitos em formato de terminal técnico, oferecendo maior clareza visual sobre as tarefas cumpridas (acesso, leitura e soluções visitadas) sem poluição por animações. +- **Arquitetura Modular em React**: Separação de utilitários de storage e custom hooks de timer, simplificando o componente de visualização. diff --git a/app/auth/sign-in/page.tsx b/app/auth/sign-in/page.tsx index f174921..44515ed 100644 --- a/app/auth/sign-in/page.tsx +++ b/app/auth/sign-in/page.tsx @@ -1,10 +1,6 @@ -import Link from 'next/link'; import type { Metadata } from 'next'; -import { ArrowLeft } from 'lucide-react'; -import { SignInForm } from '@/components/auth/sign-in-form'; -import { AlgoriaMark } from '@/components/branding/algoria-logo'; -import { Button } from '@/components/ui/button'; +import { SignInRedirector } from '@/components/auth/sign-in-redirector'; import { buildPublicMetadata } from '@/lib/seo/build-metadata'; export const metadata: Metadata = buildPublicMetadata({ @@ -15,68 +11,5 @@ export const metadata: Metadata = buildPublicMetadata({ }); export default function SignInPage() { - return ( -
- {/* Decorative gradients */} -
-
-
-
- -
- - -
- {/* Left — branding + value props */} -
-
-

Conta

-

- Iniciar sessão -

-

- Sincroniza o teu progresso, acede a todo o conteúdo Pro e retoma onde paraste. -

-
- -
-
- {[ - { label: 'Progresso na nuvem', desc: 'Continua o estudo em qualquer dispositivo sem perder o histórico.' }, - { label: 'Catálogo completo', desc: 'Acesso a todos os problemas, soluções comentadas e code player.' }, - { label: 'Curso guiado', desc: 'Módulos com avaliações e certificado gravado no browser.' }, - ].map((item) => ( -
- - - -
-

{item.label}

-

{item.desc}

-
-
- ))} -
-
-
- - {/* Right — form card */} -
-
-
-
-
- - Algoria -
- -
-
-
-
-
-
- ); + return ; } diff --git a/app/auth/sign-up/page.tsx b/app/auth/sign-up/page.tsx index ac9f48e..029783f 100644 --- a/app/auth/sign-up/page.tsx +++ b/app/auth/sign-up/page.tsx @@ -1,10 +1,6 @@ -import Link from 'next/link'; import type { Metadata } from 'next'; -import { ArrowLeft } from 'lucide-react'; -import { SignUpForm } from '@/components/auth/sign-up-form'; -import { AlgoriaMark } from '@/components/branding/algoria-logo'; -import { Button } from '@/components/ui/button'; +import { SignUpRedirector } from '@/components/auth/sign-up-redirector'; import { buildPublicMetadata } from '@/lib/seo/build-metadata'; export const metadata: Metadata = buildPublicMetadata({ @@ -15,68 +11,5 @@ export const metadata: Metadata = buildPublicMetadata({ }); export default function SignUpPage() { - return ( -
- {/* Decorative gradients */} -
-
-
-
- -
- - -
- {/* Left — branding + value props */} -
-
-

Nova conta

-

- Criar conta -

-

- Cria a tua conta gratuita e começa a estudar algoritmos com leitura guiada de código. -

-
- -
-
- {[ - { label: 'Gratuito para começar', desc: 'Acesso a problemas free, conceitos, inglês técnico e guias de engenharia.' }, - { label: 'Progresso sincronizado', desc: 'O teu progresso local será fundido com a conta no primeiro login.' }, - { label: 'Upgrade quando quiseres', desc: 'Plano Pro desbloqueia o catálogo completo de soluções e o code player.' }, - ].map((item) => ( -
- - - -
-

{item.label}

-

{item.desc}

-
-
- ))} -
-
-
- - {/* Right — form card */} -
-
-
-
-
- - Algoria -
- -
-
-
-
-
-
- ); + return ; } diff --git a/app/concepts/[slug]/page.tsx b/app/concepts/[slug]/page.tsx index 51efcf8..0483151 100644 --- a/app/concepts/[slug]/page.tsx +++ b/app/concepts/[slug]/page.tsx @@ -1,20 +1,21 @@ -import Link from 'next/link'; +import { ArrowLeft, Clock } from 'lucide-react'; import type { Metadata } from 'next'; -import { notFound } from 'next/navigation'; import { headers } from 'next/headers'; -import { ArrowLeft, Clock } from 'lucide-react'; +import Link from 'next/link'; +import { notFound } from 'next/navigation'; +import { RequireAuth } from '@/components/auth/require-auth'; import { UpgradePrompt } from '@/components/billing/upgrade-prompt'; -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; import { DifficultyBadge } from '@/components/catalog/difficulty-badge'; import { ConceptVisitTracker } from '@/components/concepts/concept-visit-tracker'; import { ContentNavigation } from '@/components/layout/content-navigation'; import { JsonLdScript } from '@/components/seo/json-ld'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; import { auth } from '@/lib/auth'; import { userHasPro } from '@/lib/billing/entitlements'; import { getConceptAccess, isContentUnlockedForUser } from '@/lib/billing/tiering'; -import { getAllConceptSlugs, getConcept, getAdjacentConcepts } from '@/lib/content/loader'; +import { getAdjacentConcepts, getAllConceptSlugs, getConcept } from '@/lib/content/loader'; import { buildPublicMetadata } from '@/lib/seo/build-metadata'; import { learningResourceJsonLd } from '@/lib/seo/structured-data'; @@ -71,8 +72,9 @@ export default async function ConceptPage({ const adjacent = await getAdjacentConcepts(slug); return ( -
- +
+
+ ); } diff --git a/app/course/[slug]/module/[moduleId]/certificate/page.tsx b/app/course/[slug]/module/[moduleId]/certificate/page.tsx index f6e7bcd..30d6895 100644 --- a/app/course/[slug]/module/[moduleId]/certificate/page.tsx +++ b/app/course/[slug]/module/[moduleId]/certificate/page.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; +import { RequireAuth } from "@/components/auth/require-auth"; import { ModuleCertificatePageContent } from "@/components/course/module-certificate-page-content"; import { buildCertificateMetadata, @@ -25,5 +26,9 @@ export default async function ModuleCertificatePage({ params: Promise; }) { const { slug, moduleId } = await params; - return ; + return ( + + + + ); } diff --git a/app/course/[slug]/module/[moduleId]/page.tsx b/app/course/[slug]/module/[moduleId]/page.tsx index d32df49..04e352d 100644 --- a/app/course/[slug]/module/[moduleId]/page.tsx +++ b/app/course/[slug]/module/[moduleId]/page.tsx @@ -6,6 +6,7 @@ import { ArrowLeft } from 'lucide-react'; import { CourseModuleRunner } from '@/components/course/course-module-runner'; import { Button } from '@/components/ui/button'; import { JsonLdScript } from '@/components/seo/json-ld'; +import { RequireAuth } from '@/components/auth/require-auth'; import { getCoursePackHydrated, listCourseSlugs } from '@/lib/courses/hydrate-course-pack'; import { buildPublicMetadata } from '@/lib/seo/build-metadata'; import { learningResourceJsonLd } from '@/lib/seo/structured-data'; @@ -63,8 +64,9 @@ export default async function CourseModulePage({ params }: { params: Promise - +
+ Índice do programa
- -
+ +
+ ); } diff --git a/app/engineering-work/[slug]/page.tsx b/app/engineering-work/[slug]/page.tsx index 54f3145..7ad46ab 100644 --- a/app/engineering-work/[slug]/page.tsx +++ b/app/engineering-work/[slug]/page.tsx @@ -3,28 +3,29 @@ import type { Metadata } from "next"; import Link from "next/link"; import { notFound } from "next/navigation"; +import { RequireAuth } from "@/components/auth/require-auth"; +import { UpgradePrompt } from "@/components/billing/upgrade-prompt"; import { AuthorInfo } from "@/components/engenharia-trabalho/author-info"; import { EngineeringGuideArticle } from "@/components/engenharia-trabalho/engineering-guide-article"; import { ContentNavigation } from "@/components/layout/content-navigation"; import { JsonLdScript } from "@/components/seo/json-ld"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { auth } from "@/lib/auth"; +import { userHasPro } from "@/lib/billing/entitlements"; +import { isContentUnlockedForUser } from "@/lib/billing/tiering"; import { getAdjacentEngineeringWork, getAllEngineeringWorkSlugs, getEngineeringWorkGuide, } from "@/lib/content/loader"; -import { auth } from "@/lib/auth"; -import { userHasPro } from "@/lib/billing/entitlements"; -import { isContentUnlockedForUser } from "@/lib/billing/tiering"; -import { UpgradePrompt } from "@/components/billing/upgrade-prompt"; -import { headers } from "next/headers"; import type { EngineeringWorkPillar } from "@/lib/content/schemas"; import { db } from "@/lib/db"; import { user } from "@/lib/db/schema"; import { buildPublicMetadata } from "@/lib/seo/build-metadata"; import { articleJsonLd } from "@/lib/seo/structured-data"; import { eq } from "drizzle-orm"; +import { headers } from "next/headers"; interface Params { slug: string; @@ -83,7 +84,6 @@ export default async function EngineeringWorkGuidePage({ const adjacent = await getAdjacentEngineeringWork(slug); - // Busca o ID do Jonatas para o link do perfil let authorData = null; try { authorData = (await db.query.user.findFirst({ @@ -94,8 +94,9 @@ export default async function EngineeringWorkGuidePage({ } return ( -
- +
+
+ ); } diff --git a/app/globals.css b/app/globals.css index 15b2d41..139ecfa 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,3 +1,4 @@ +@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;700;900&family=Share+Tech+Mono&display=swap'); @import "tailwindcss"; @plugin "@tailwindcss/typography"; @@ -197,4 +198,28 @@ body { --shiki-dark-bg: #020617; } +/* Digital LED/LCD Timer Styles */ +.font-digital { + font-family: 'Share Tech Mono', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +.font-orbitron { + font-family: 'Orbitron', var(--font-sans), system-ui, sans-serif; +} + +.glow-timer { + color: #ffffff; + text-shadow: 0 0 8px rgba(255, 255, 255, 0.95), 0 0 20px rgba(255, 255, 255, 0.45); +} + +.glow-orange { + color: #f97316; + text-shadow: 0 0 8px rgba(249, 115, 22, 0.8), 0 0 20px rgba(249, 115, 22, 0.35); +} + +.glow-green { + color: #22c55e; + text-shadow: 0 0 8px rgba(34, 197, 94, 0.8), 0 0 20px rgba(34, 197, 94, 0.35); +} + diff --git a/app/interview-en/[slug]/page.tsx b/app/interview-en/[slug]/page.tsx index 3ddde78..5cdbc2c 100644 --- a/app/interview-en/[slug]/page.tsx +++ b/app/interview-en/[slug]/page.tsx @@ -7,6 +7,7 @@ import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { ContentNavigation } from '@/components/layout/content-navigation'; import { JsonLdScript } from '@/components/seo/json-ld'; +import { RequireAuth } from '@/components/auth/require-auth'; import { getAllInterviewEnglishSlugs, getInterviewEnglishTopic, getAdjacentInterviewEnglish } from '@/lib/content/loader'; import type { InterviewEnglishTrack } from '@/lib/content/schemas'; import { buildPublicMetadata } from '@/lib/seo/build-metadata'; @@ -77,8 +78,9 @@ export default async function InterviewEnglishTopicPage({ const adjacent = await getAdjacentInterviewEnglish(slug); return ( -
- +
+
+ ); } diff --git a/app/layout.tsx b/app/layout.tsx index 7a5d761..a6ef42b 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -5,6 +5,8 @@ import "./globals.css"; import { AlgoriaPostHogProvider } from "@/components/analytics/posthog-provider"; import { CookieBanner } from "@/components/analytics/cookie-banner"; +import { AuthDialogProvider } from "@/components/auth/auth-dialog-context"; +import { AuthDialog } from "@/components/auth/auth-dialog"; import { ProgressSyncOnLogin } from "@/components/billing/progress-sync"; import { Sidebar } from "@/components/layout/sidebar"; import { SidebarProvider } from "@/components/layout/sidebar-context"; @@ -92,17 +94,20 @@ export default function RootLayout({ disableTransitionOnChange > - - - - -
- {children} -
-
- - - + + + + + +
+ {children} +
+
+ + + + +
diff --git a/app/leaderboard/leaderboard-client.tsx b/app/leaderboard/leaderboard-client.tsx index bef45ce..fd0ec04 100644 --- a/app/leaderboard/leaderboard-client.tsx +++ b/app/leaderboard/leaderboard-client.tsx @@ -4,6 +4,7 @@ import { Globe, Users } from "lucide-react"; import Link from "next/link"; import { useState } from "react"; +import { useAuthDialog } from "@/components/auth/auth-dialog-context"; import { LeaderboardTable } from "@/components/gamification/leaderboard-table"; import { Button } from "@/components/ui/button"; @@ -30,6 +31,7 @@ export function LeaderboardClient({ isLoggedIn, }: LeaderboardClientProps) { const [scope, setScope] = useState<"global" | "following">("global"); + const { openAuthDialog } = useAuthDialog(); const entries = scope === "global" ? globalEntries : followingEntries; @@ -69,11 +71,11 @@ export function LeaderboardClient({ Faz login para veres o ranking dos teus amigos.

)} diff --git a/app/pricing/page.tsx b/app/pricing/page.tsx index 0621eb9..b0144fe 100644 --- a/app/pricing/page.tsx +++ b/app/pricing/page.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { CheckoutButton } from "@/components/billing/checkout-button"; import { ManageSubscriptionButton } from "@/components/billing/manage-subscription-button"; import { PricingPageAnalytics } from "@/components/billing/pricing-analytics"; +import { AuthDialogTriggerButton } from "@/components/auth/auth-dialog-trigger"; import { getPricingPlans, getPricingFeatures, getPricingInventory } from "@/lib/actions/admin"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -180,14 +181,11 @@ export default async function PricingPage() { {session ? ( ) : ( - + Subscrever Agora + )}

Pagamento processado de forma segura e encriptada diff --git a/app/problems/[slug]/[solution]/page.tsx b/app/problems/[slug]/[solution]/page.tsx index 22fb2c1..2a238db 100644 --- a/app/problems/[slug]/[solution]/page.tsx +++ b/app/problems/[slug]/[solution]/page.tsx @@ -5,10 +5,12 @@ import Link from "next/link"; import { notFound } from "next/navigation"; import { Suspense } from "react"; +import { RequireAuth } from "@/components/auth/require-auth"; import { UpgradePrompt } from "@/components/billing/upgrade-prompt"; import { DifficultyBadge } from "@/components/catalog/difficulty-badge"; import { DynamicPlayerWrapper } from "@/components/code-player/dynamic-player-wrapper"; import { ComplexityBadge } from "@/components/complexity/complexity-badge"; +import { DailyChallengeTabVisit } from "@/components/gamification/daily-challenge-tab-visit"; import { JsonLdScript } from "@/components/seo/json-ld"; import { SolutionLanguageSelect } from "@/components/solution/solution-language-select"; import { SolutionVisitTracker } from "@/components/solution/solution-visit-tracker"; @@ -155,28 +157,32 @@ export default async function SolutionPage({ if (!unlocked) { return ( -

- - -
+ +
+ + +
+
); } return ( -
+ + +
+ ); } diff --git a/app/problems/[slug]/layout.tsx b/app/problems/[slug]/layout.tsx new file mode 100644 index 0000000..19b478d --- /dev/null +++ b/app/problems/[slug]/layout.tsx @@ -0,0 +1,39 @@ +import { headers } from "next/headers"; +import { notFound } from "next/navigation"; + +import { DailyChallengeTracker } from "@/components/gamification/daily-challenge-tracker"; +import { auth } from "@/lib/auth"; +import { userHasPro } from "@/lib/billing/entitlements"; +import { isContentUnlockedForUser } from "@/lib/billing/tiering"; +import { getProblem } from "@/lib/content/loader"; + +interface ProblemLayoutProps { + children: React.ReactNode; + params: Promise<{ slug: string }>; +} + +export default async function ProblemLayout({ children, params }: ProblemLayoutProps) { + const { slug } = await params; + const problem = await getProblem(slug); + + if (!problem) { + return <>{children}; + } + + const session = await auth.api.getSession({ headers: await headers() }); + const hasPro = await userHasPro(session?.user?.id); + const isAccessible = isContentUnlockedForUser(problem.meta.access || 'pro', hasPro); + + const solutionSlugs = problem.solutions.map((s) => s.meta.slug); + + return ( + <> + + {children} + + ); +} diff --git a/app/problems/[slug]/page.tsx b/app/problems/[slug]/page.tsx index 6443078..ff88a50 100644 --- a/app/problems/[slug]/page.tsx +++ b/app/problems/[slug]/page.tsx @@ -12,8 +12,10 @@ import { Separator } from '@/components/ui/separator'; import { JsonLdScript } from '@/components/seo/json-ld'; import { DifficultyBadge } from '@/components/catalog/difficulty-badge'; import { ComplexityBadge } from '@/components/complexity/complexity-badge'; +import { RequireAuth } from '@/components/auth/require-auth'; import { ProblemStudyCompletionBar } from '@/components/problem/problem-study-completion-bar'; import { ProblemStudyTabs } from '@/components/problem/problem-study-tabs'; +import { DailyChallengeTabVisit } from '@/components/gamification/daily-challenge-tab-visit'; import { ContentNavigation } from '@/components/layout/content-navigation'; import { ProblemVisitTracker } from '@/components/problem/problem-visit-tracker'; import { auth } from '@/lib/auth'; @@ -187,8 +189,9 @@ export default async function ProblemPage({ params }: { params: Promise ); return ( -
- +
+ />
) : ( - + <> + + + )} @@ -237,6 +243,7 @@ export default async function ProblemPage({ params }: { params: Promise next={adjacent.next ? { slug: adjacent.next.slug, title: adjacent.next.title, href: `/problems/${adjacent.next.slug}` } : null} />
+ ); } diff --git a/app/profile/actions.ts b/app/profile/actions.ts index 72df483..937f6b4 100644 --- a/app/profile/actions.ts +++ b/app/profile/actions.ts @@ -41,7 +41,7 @@ export async function deleteAccount() { await db.delete(user).where(eq(user.id, userId)); revalidatePath('/', 'layout'); - redirect('/'); + return { success: true }; } export async function updateProfile(formData: FormData) { diff --git a/app/profile/page.tsx b/app/profile/page.tsx index 73c8892..5226d4e 100644 --- a/app/profile/page.tsx +++ b/app/profile/page.tsx @@ -9,6 +9,7 @@ import { EditProfileForm } from "@/components/profile/edit-profile-form"; import { DeleteAccountForm } from "@/components/auth/delete-account-form"; import { SignOutButton } from "@/components/auth/sign-out-button"; +import { RequireAuth } from "@/components/auth/require-auth"; import { BecomeCreatorButton } from "@/components/profile/become-creator-button"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -60,7 +61,11 @@ export default async function ProfilePage() { const session = await auth.api.getSession({ headers: await headers() }); if (!session?.user) { - redirect("/auth/sign-in"); + return ( + +
+ + ); } const { user: sessionUser } = session; diff --git a/app/tests/[track]/[slug]/page.tsx b/app/tests/[track]/[slug]/page.tsx index 2604ddf..e1e0861 100644 --- a/app/tests/[track]/[slug]/page.tsx +++ b/app/tests/[track]/[slug]/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; +import { RequireAuth } from "@/components/auth/require-auth"; import { UpgradePrompt } from "@/components/billing/upgrade-prompt"; import { TestClient } from "@/components/tests/test-client"; import { auth } from "@/lib/auth"; @@ -50,12 +51,14 @@ export default async function TestExecutionPage({ const isLocked = !isContentUnlockedForUser(test.access || 'pro', hasPro); return ( -
- {isLocked ? ( - - ) : ( - - )} -
+ +
+ {isLocked ? ( + + ) : ( + + )} +
+
); } diff --git a/components/auth/auth-dialog-context.tsx b/components/auth/auth-dialog-context.tsx new file mode 100644 index 0000000..c2cb54f --- /dev/null +++ b/components/auth/auth-dialog-context.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { createContext, useCallback, useContext, useMemo, useState } from 'react'; + +type AuthMode = 'sign-in' | 'sign-up'; + +interface AuthDialogState { + isOpen: boolean; + mode: AuthMode; + redirectTo: string | null; +} + +interface AuthDialogContextValue extends AuthDialogState { + openAuthDialog: (opts?: { mode?: AuthMode; redirectTo?: string }) => void; + closeAuthDialog: () => void; + setMode: (mode: AuthMode) => void; +} + +const AuthDialogContext = createContext(null); + +export function AuthDialogProvider({ children }: { children: React.ReactNode }) { + const [state, setState] = useState({ + isOpen: false, + mode: 'sign-in', + redirectTo: null, + }); + + const openAuthDialog = useCallback( + (opts?: { mode?: AuthMode; redirectTo?: string }) => { + setState({ + isOpen: true, + mode: opts?.mode ?? 'sign-in', + redirectTo: opts?.redirectTo ?? null, + }); + }, + [], + ); + + const closeAuthDialog = useCallback(() => { + setState((prev) => ({ ...prev, isOpen: false, redirectTo: null })); + }, []); + + const setMode = useCallback((mode: AuthMode) => { + setState((prev) => ({ ...prev, mode })); + }, []); + + const value = useMemo( + () => ({ ...state, openAuthDialog, closeAuthDialog, setMode }), + [state, openAuthDialog, closeAuthDialog, setMode], + ); + + return ( + + {children} + + ); +} + +export function useAuthDialog(): AuthDialogContextValue { + const ctx = useContext(AuthDialogContext); + if (!ctx) { + throw new Error('useAuthDialog must be used within an AuthDialogProvider'); + } + return ctx; +} diff --git a/components/auth/auth-dialog-trigger.tsx b/components/auth/auth-dialog-trigger.tsx new file mode 100644 index 0000000..9ecd686 --- /dev/null +++ b/components/auth/auth-dialog-trigger.tsx @@ -0,0 +1,34 @@ +'use client'; + +import { useAuthDialog } from '@/components/auth/auth-dialog-context'; +import { Button } from '@/components/ui/button'; + +/** + * Client-side button that opens the auth dialog. + * Used in server components that need auth login functionality. + */ +export function AuthDialogTriggerButton({ + children, + className, + variant = 'outline', + size = 'default', +}: { + children: React.ReactNode; + className?: string; + variant?: 'default' | 'outline' | 'secondary' | 'destructive' | 'ghost' | 'link'; + size?: 'default' | 'sm' | 'lg' | 'xl' | 'icon'; +}) { + const { openAuthDialog } = useAuthDialog(); + + return ( + + ); +} diff --git a/components/auth/auth-dialog.tsx b/components/auth/auth-dialog.tsx new file mode 100644 index 0000000..740c6c3 --- /dev/null +++ b/components/auth/auth-dialog.tsx @@ -0,0 +1,94 @@ +'use client'; + +import { useRouter } from 'next/navigation'; + +import { useAuthDialog } from '@/components/auth/auth-dialog-context'; +import { SignInForm } from '@/components/auth/sign-in-form'; +import { SignUpForm } from '@/components/auth/sign-up-form'; +import { AlgoriaMark } from '@/components/branding/algoria-logo'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; + +export function AuthDialog() { + const { isOpen, mode, redirectTo, closeAuthDialog, setMode } = useAuthDialog(); + const router = useRouter(); + + const handleSuccess = () => { + closeAuthDialog(); + if (redirectTo) { + router.push(redirectTo); + } + router.refresh(); + }; + + return ( + { if (!open) closeAuthDialog(); }}> + +
+ +
+ +
+ +
+ + {mode === 'sign-in' ? 'Iniciar sessão' : 'Criar conta'} + + + {mode === 'sign-in' + ? 'Entra na tua conta para sincronizar progresso e aceder ao conteúdo.' + : 'Cria a tua conta gratuita e começa a estudar algoritmos.'} + +
+ +
+ + +
+ +
+ {mode === 'sign-in' ? ( + setMode('sign-up')} + /> + ) : ( + setMode('sign-in')} + /> + )} +
+
+ +
+ ); +} diff --git a/components/auth/delete-account-form.tsx b/components/auth/delete-account-form.tsx index 6d17ad1..25e9086 100644 --- a/components/auth/delete-account-form.tsx +++ b/components/auth/delete-account-form.tsx @@ -1,39 +1,84 @@ 'use client'; -import { useState } from 'react'; import { Loader2, Trash2 } from 'lucide-react'; +import { useState } from 'react'; -import { Button } from '@/components/ui/button'; import { deleteAccount } from '@/app/profile/actions'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; export function DeleteAccountForm() { const [loading, setLoading] = useState(false); + const [open, setOpen] = useState(false); + async function handleDelete() { - if (!window.confirm('Tens a certeza absoluta que queres excluir a tua conta? Esta ação é irreversível.')) { - return; - } - setLoading(true); try { await deleteAccount(); + localStorage.clear(); + window.location.href = '/'; } catch (err) { console.error(err); alert('Ocorreu um erro ao excluir a conta.'); setLoading(false); + setOpen(false); } } return ( - + + + + + + + + Excluir Conta + + + Tens a certeza absoluta que queres excluir a tua conta? +

+ Esta ação é irreversível. Todo o teu progresso, resultados de testes, assinatura ativa e dados do perfil serão apagados permanentemente dos nossos servidores e do teu dispositivo. +
+
+ + + + +
+
); } diff --git a/components/auth/require-auth.tsx b/components/auth/require-auth.tsx new file mode 100644 index 0000000..168931a --- /dev/null +++ b/components/auth/require-auth.tsx @@ -0,0 +1,62 @@ +'use client'; + +import { usePathname } from 'next/navigation'; +import { useEffect } from 'react'; + +import { useAuthDialog } from '@/components/auth/auth-dialog-context'; +import { authClient } from '@/lib/auth-client'; + +/** + * Client-side auth guard. + * + * Wraps protected page content. When the user has no session it + * automatically opens the auth dialog (with the current pathname as + * redirect target) and renders a skeleton instead. + * + * Once the user authenticates, the dialog closes and the content is revealed. + */ +export function RequireAuth({ children }: { children: React.ReactNode }) { + const { data, isPending } = authClient.useSession(); + const { openAuthDialog, isOpen } = useAuthDialog(); + const pathname = usePathname(); + + useEffect(() => { + if (isPending) return; + if (!data?.user) { + openAuthDialog({ redirectTo: pathname }); + } + }, [data?.user, isPending, pathname]); + + if (isPending) { + return ( +
+
+
+ + A verificar sessão… + +
+
+ ); + } + + if (!data?.user) { + return ( +
+
+
+
+
+

+ Autenticação necessária +

+

+ Faz login ou cria uma conta para aceder a este conteúdo. +

+
+
+ ); + } + + return <>{children}; +} diff --git a/components/auth/sign-in-form.tsx b/components/auth/sign-in-form.tsx index 00bba11..fd6deda 100644 --- a/components/auth/sign-in-form.tsx +++ b/components/auth/sign-in-form.tsx @@ -1,15 +1,20 @@ 'use client'; +import { ArrowRight, Eye, EyeOff, Loader2, Lock, Mail } from 'lucide-react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useState } from 'react'; -import { Mail, Lock, Eye, EyeOff, ArrowRight, Loader2 } from 'lucide-react'; -import { authClient } from '@/lib/auth-client'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +import { authClient } from '@/lib/auth-client'; import { cn } from '@/lib/utils'; +interface SignInFormProps { + onSuccess?: () => void; + onSwitchToSignUp?: () => void; +} + function GoogleIcon({ className }: { className?: string }) { return ( @@ -33,7 +38,7 @@ function GoogleIcon({ className }: { className?: string }) { ); } -export function SignInForm() { +export function SignInForm({ onSuccess, onSwitchToSignUp }: SignInFormProps = {}) { const router = useRouter(); const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); @@ -52,8 +57,12 @@ export function SignInForm() { setError(res.error.message ?? 'Não foi possível iniciar sessão.'); return; } - router.push('/problems'); - router.refresh(); + if (onSuccess) { + onSuccess(); + } else { + router.push('/problems'); + router.refresh(); + } } finally { setLoading(false); } @@ -75,7 +84,6 @@ export function SignInForm() { return (
- {/* Google Sign-In */} - {/* Footer link */}

Ainda sem conta?{' '} - - Criar conta gratuitamente - + {onSwitchToSignUp ? ( + + ) : ( + + Criar conta gratuitamente + + )}

); diff --git a/components/auth/sign-in-redirector.tsx b/components/auth/sign-in-redirector.tsx new file mode 100644 index 0000000..eb29ce1 --- /dev/null +++ b/components/auth/sign-in-redirector.tsx @@ -0,0 +1,34 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useEffect } from 'react'; + +import { useAuthDialog } from '@/components/auth/auth-dialog-context'; +import { authClient } from '@/lib/auth-client'; + +/** + * Redirector component for the legacy /auth/sign-in page. + * If already logged in → go to /problems. + * Otherwise → open auth dialog and navigate to home. + */ +export function SignInRedirector() { + const { data, isPending } = authClient.useSession(); + const { openAuthDialog } = useAuthDialog(); + const router = useRouter(); + + useEffect(() => { + if (isPending) return; + if (data?.user) { + router.replace('/problems'); + } else { + openAuthDialog({ mode: 'sign-in' }); + router.replace('/'); + } + }, [data, isPending, openAuthDialog, router]); + + return ( +
+
+
+ ); +} diff --git a/components/auth/sign-out-button.tsx b/components/auth/sign-out-button.tsx index 0367a52..e8b7694 100644 --- a/components/auth/sign-out-button.tsx +++ b/components/auth/sign-out-button.tsx @@ -14,7 +14,7 @@ export function SignOutButton() { async function handleSignOut() { setLoading(true); await authClient.signOut(); - router.push('/auth/sign-in'); + router.push('/'); router.refresh(); } diff --git a/components/auth/sign-up-form.tsx b/components/auth/sign-up-form.tsx index 0295459..72a9561 100644 --- a/components/auth/sign-up-form.tsx +++ b/components/auth/sign-up-form.tsx @@ -1,15 +1,20 @@ 'use client'; +import { ArrowRight, Eye, EyeOff, Loader2, Lock, Mail, User } from 'lucide-react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useState } from 'react'; -import { Mail, Lock, Eye, EyeOff, ArrowRight, Loader2, User } from 'lucide-react'; -import { authClient } from '@/lib/auth-client'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +import { authClient } from '@/lib/auth-client'; import { cn } from '@/lib/utils'; +interface SignUpFormProps { + onSuccess?: () => void; + onSwitchToSignIn?: () => void; +} + function GoogleIcon({ className }: { className?: string }) { return ( @@ -33,7 +38,7 @@ function GoogleIcon({ className }: { className?: string }) { ); } -export function SignUpForm() { +export function SignUpForm({ onSuccess, onSwitchToSignIn }: SignUpFormProps = {}) { const router = useRouter(); const [name, setName] = useState(''); const [email, setEmail] = useState(''); @@ -57,8 +62,12 @@ export function SignUpForm() { setError(res.error.message ?? 'Não foi possível criar a conta.'); return; } - router.push('/problems'); - router.refresh(); + if (onSuccess) { + onSuccess(); + } else { + router.push('/problems'); + router.refresh(); + } } finally { setLoading(false); } @@ -80,7 +89,6 @@ export function SignUpForm() { return (
- {/* Google Sign-Up */} - {/* Footer link */}

Já tens conta?{' '} - - Entrar agora - + {onSwitchToSignIn ? ( + + ) : ( + + Entrar agora + + )}

Ao criar conta, concordas com os{' '} diff --git a/components/auth/sign-up-redirector.tsx b/components/auth/sign-up-redirector.tsx new file mode 100644 index 0000000..c65a32c --- /dev/null +++ b/components/auth/sign-up-redirector.tsx @@ -0,0 +1,34 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useEffect } from 'react'; + +import { useAuthDialog } from '@/components/auth/auth-dialog-context'; +import { authClient } from '@/lib/auth-client'; + +/** + * Redirector component for the legacy /auth/sign-up page. + * If already logged in → go to /problems. + * Otherwise → open auth dialog in sign-up mode and navigate to home. + */ +export function SignUpRedirector() { + const { data, isPending } = authClient.useSession(); + const { openAuthDialog } = useAuthDialog(); + const router = useRouter(); + + useEffect(() => { + if (isPending) return; + if (data?.user) { + router.replace('/problems'); + } else { + openAuthDialog({ mode: 'sign-up' }); + router.replace('/'); + } + }, [data, isPending, openAuthDialog, router]); + + return ( +

+
+
+ ); +} diff --git a/components/billing/checkout-button.tsx b/components/billing/checkout-button.tsx index 69a396b..7804576 100644 --- a/components/billing/checkout-button.tsx +++ b/components/billing/checkout-button.tsx @@ -21,7 +21,7 @@ export function CheckoutButton({ disabled }: { disabled?: boolean }) { }); if (res.status === 401) { - window.location.href = "/auth/sign-in?callbackUrl=/pricing"; + window.location.href = "/"; return; } diff --git a/components/billing/upgrade-prompt.tsx b/components/billing/upgrade-prompt.tsx index 972a4df..d411498 100644 --- a/components/billing/upgrade-prompt.tsx +++ b/components/billing/upgrade-prompt.tsx @@ -1,6 +1,7 @@ import { Check, Lock, Zap } from "lucide-react"; import Link from "next/link"; +import { AuthDialogTriggerButton } from "@/components/auth/auth-dialog-trigger"; import { PaywallAnalytics } from "@/components/billing/paywall-analytics"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -34,7 +35,6 @@ export function UpgradePrompt({ return (
- {/* Industrial Header Bar - Mirroring Technical Section in Homepage */}

@@ -49,7 +49,6 @@ export function UpgradePrompt({

- {/* Grid Pattern Body */}
{problemSlug || conceptSlug ? ( - {/* Perks Grid - Mirroring Platform Features Pattern */}
{perks.map((perk, i) => (
{!hideLogin && ( - + Entrar na conta + )}
diff --git a/components/gamification/daily-challenge-banner.tsx b/components/gamification/daily-challenge-banner.tsx index e3d6e5f..b992437 100644 --- a/components/gamification/daily-challenge-banner.tsx +++ b/components/gamification/daily-challenge-banner.tsx @@ -6,8 +6,8 @@ import Link from "next/link"; import { useEffect, useState } from "react"; import { Badge } from "@/components/ui/badge"; -import { completeDailyChallenge, isDailyChallengeCompleted } from "@/lib/gamification/xp-engine"; -import { loadProgressBlob, saveProgressBlob } from "@/lib/progress/local-progress"; +import { isDailyChallengeCompleted } from "@/lib/gamification/xp-engine"; +import { loadProgressBlob } from "@/lib/progress/local-progress"; interface DailyChallengeBannerProps { slug: string; @@ -22,6 +22,18 @@ const DIFFICULTY_COLORS: Record = { hard: "text-red-500 border-red-500/30 bg-red-500/10", }; +/** + * Guarda o slug do desafio diário activo no sessionStorage para que a + * página do problema saiba que é um desafio em andamento. + */ +function setActiveDailyChallenge(slug: string, dateKey: string) { + if (typeof window === 'undefined') return; + sessionStorage.setItem( + 'algoria:daily-challenge-active', + JSON.stringify({ slug, dateKey }), + ); +} + export function DailyChallengeBanner({ slug, title, @@ -45,14 +57,6 @@ export function DailyChallengeBanner({ }; }, []); - const handleComplete = () => { - if (completed) return; - let blob = loadProgressBlob(); - blob = completeDailyChallenge(blob); - saveProgressBlob(blob); - setCompleted(true); - }; - return ( setActiveDailyChallenge(slug, dateKey)} className="group flex items-center gap-2 text-lg font-black uppercase tracking-tight transition-colors hover:text-primary" > {title} @@ -123,7 +128,7 @@ export function DailyChallengeBanner({ ) : ( setActiveDailyChallenge(slug, dateKey)} className="flex items-center gap-2 border-2 border-orange-500 bg-orange-500 px-5 py-2.5 text-[10px] font-black uppercase tracking-[0.2em] text-white transition-all hover:bg-orange-600 active:scale-95" > Aceitar Desafio diff --git a/components/gamification/daily-challenge-tab-visit.tsx b/components/gamification/daily-challenge-tab-visit.tsx new file mode 100644 index 0000000..efd48ae --- /dev/null +++ b/components/gamification/daily-challenge-tab-visit.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { useEffect } from 'react'; + +export function DailyChallengeTabVisit({ tab }: { tab: string }) { + useEffect(() => { + window.dispatchEvent( + new CustomEvent('algoria-daily-tab-visit', { detail: tab }), + ); + }, [tab]); + + return null; +} diff --git a/components/gamification/daily-challenge-tracker.tsx b/components/gamification/daily-challenge-tracker.tsx new file mode 100644 index 0000000..9525af8 --- /dev/null +++ b/components/gamification/daily-challenge-tracker.tsx @@ -0,0 +1,173 @@ +'use client'; + +import { AnimatePresence, motion } from 'framer-motion'; +import { CalendarDays, CheckCircle2, EyeOff, Flame } from 'lucide-react'; + +import { formatDigitalTime, formatTime } from './daily-challenge-utils'; +import { useDailyChallenge } from './use-daily-challenge'; + +interface DailyChallengeTrackerProps { + problemSlug: string; + isAccessible: boolean; + solutionSlugs: string[]; +} + +export function DailyChallengeTracker({ + problemSlug, + isAccessible, + solutionSlugs, +}: DailyChallengeTrackerProps) { + const { + isActive, + completed, + elapsedMs, + isTabVisible, + visitedTabs, + visitedAllSolutions, + timeReached, + remainingSeconds, + progressPercent, + visitedSolutionCount, + totalSolutions, + } = useDailyChallenge({ problemSlug, isAccessible, solutionSlugs }); + + if (!isActive) return null; + + if (completed) { + return ( +
+
+
+
+
+ +
+
+
+ + Desafio Diário Concluído + + + + +50 XP + +
+

+ SISTEMA DE XP: ATUALIZADO. Mantém a tua streak amanhã! +

+
+
+ +
+ + 00:03:00:00 + +
+
+ +
+
+
+ ); + } + + return ( +
+
+
+ +
+
+ +
+
+
+ + Desafio do Dia em Progresso + + + {!isTabVisible && ( + + + Pausado + + )} + +
+ +
+
+ + {formatDigitalTime(elapsedMs)} + +
+ +
+ META: 00:03:00:00 + PASSO A PASSO +
+
+
+
+ +
+
+ 01. ACESSO + {isAccessible ? 'DESBLOQUEADO' : 'BLOQUEADO'} +
+ +
+ 02. ENUNCIADO + {visitedTabs.has('statement') ? 'LIDO' : 'PENDENTE'} +
+ +
+ 03. SOLUÇÕES + {visitedAllSolutions ? 'CONCLUÍDO' : `${visitedSolutionCount}/${totalSolutions}`} +
+ +
+ 04. TEMPO (3M) + + {timeReached ? 'ATINGIDO' : `${formatTime(remainingSeconds)} REST.`} + +
+
+ +
+ +
+ +
+
+
+ ); +} diff --git a/components/gamification/daily-challenge-utils.ts b/components/gamification/daily-challenge-utils.ts new file mode 100644 index 0000000..5a926d3 --- /dev/null +++ b/components/gamification/daily-challenge-utils.ts @@ -0,0 +1,63 @@ +export const REQUIRED_SECONDS = 180; // 3 minutes +export const SESSION_KEY_PREFIX = 'algoria:daily-challenge-time:'; + +export interface ActiveChallenge { + slug: string; + dateKey: string; +} + +/** + * Lê o desafio diário activo do sessionStorage (definido pelo banner). + */ +export function getActiveDailyChallenge(): ActiveChallenge | null { + if (typeof window === 'undefined') return null; + try { + const raw = sessionStorage.getItem('algoria:daily-challenge-active'); + if (!raw) return null; + return JSON.parse(raw) as ActiveChallenge; + } catch { + return null; + } +} + +/** + * Lê o tempo acumulado do sessionStorage para o slug actual. + */ +export function getAccumulatedTime(slug: string): number { + if (typeof window === 'undefined') return 0; + try { + const raw = sessionStorage.getItem(`${SESSION_KEY_PREFIX}${slug}`); + return raw ? parseInt(raw, 10) : 0; + } catch { + return 0; + } +} + +/** + * Salva o tempo acumulado no sessionStorage. + */ +export function saveAccumulatedTime(slug: string, seconds: number): void { + if (typeof window === 'undefined') return; + sessionStorage.setItem(`${SESSION_KEY_PREFIX}${slug}`, String(seconds)); +} + +export function formatTime(seconds: number): string { + const m = Math.floor(seconds / 60); + const s = seconds % 60; + return `${m}:${s.toString().padStart(2, '0')}`; +} + +export function formatDigitalTime(ms: number): string { + const totalSeconds = Math.floor(ms / 1000); + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + const centiseconds = Math.floor((ms % 1000) / 10); + + const hh = hours.toString().padStart(2, '0'); + const mm = minutes.toString().padStart(2, '0'); + const ss = seconds.toString().padStart(2, '0'); + const cc = centiseconds.toString().padStart(2, '0'); + + return `${hh}:${mm}:${ss}:${cc}`; +} diff --git a/components/gamification/use-daily-challenge.ts b/components/gamification/use-daily-challenge.ts new file mode 100644 index 0000000..63e031b --- /dev/null +++ b/components/gamification/use-daily-challenge.ts @@ -0,0 +1,147 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { completeDailyChallenge, isDailyChallengeCompleted } from '@/lib/gamification/xp-engine'; +import { loadProgressBlob, saveProgressBlob } from '@/lib/progress/local-progress'; +import { + getAccumulatedTime, + getActiveDailyChallenge, + REQUIRED_SECONDS, + saveAccumulatedTime, + SESSION_KEY_PREFIX, +} from './daily-challenge-utils'; + +export interface UseDailyChallengeProps { + problemSlug: string; + isAccessible: boolean; + solutionSlugs: string[]; +} + +export function useDailyChallenge({ + problemSlug, + isAccessible, + solutionSlugs, +}: UseDailyChallengeProps) { + const [isActive, setIsActive] = useState(false); + const [completed, setCompleted] = useState(false); + const [elapsedMs, setElapsedMs] = useState(0); + const [isTabVisible, setIsTabVisible] = useState(true); + const [visitedTabs, setVisitedTabs] = useState>(() => new Set()); + + const elapsedMsRef = useRef(0); + const completedRef = useRef(false); + + useEffect(() => { + elapsedMsRef.current = elapsedMs; + }, [elapsedMs]); + + useEffect(() => { + const active = getActiveDailyChallenge(); + if (!active || active.slug !== problemSlug) { + setIsActive(false); + return; + } + + const blob = loadProgressBlob(); + if (isDailyChallengeCompleted(blob)) { + setCompleted(true); + completedRef.current = true; + setIsActive(true); + return; + } + + setIsActive(true); + const secs = getAccumulatedTime(problemSlug); + setElapsedMs(secs * 1000); + elapsedMsRef.current = secs * 1000; + }, [problemSlug]); + + useEffect(() => { + if (!isActive || completed) return; + + const handleVisibilityChange = () => { + setIsTabVisible(!document.hidden); + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [isActive, completed]); + + useEffect(() => { + if (!isActive || completed || !isTabVisible || !isAccessible) return; + + const startTime = Date.now() - elapsedMsRef.current; + + const timer = setInterval(() => { + const now = Date.now(); + const currentElapsed = now - startTime; + + setElapsedMs(currentElapsed); + + const currentSeconds = Math.floor(currentElapsed / 1000); + saveAccumulatedTime(problemSlug, currentSeconds); + }, 50); + + return () => { + clearInterval(timer); + }; + }, [isActive, completed, isTabVisible, isAccessible, problemSlug]); + + const visitedAllSolutions = solutionSlugs.every(slug => visitedTabs.has(`solution:${slug}`)); + const allTabsVisited = visitedTabs.has('statement') && visitedAllSolutions; + const elapsedSeconds = Math.floor(elapsedMs / 1000); + const timeReached = elapsedSeconds >= REQUIRED_SECONDS; + const canComplete = isAccessible && allTabsVisited && timeReached; + + useEffect(() => { + if (!canComplete || completedRef.current) return; + completedRef.current = true; + + let blob = loadProgressBlob(); + blob = completeDailyChallenge(blob); + saveProgressBlob(blob); + setCompleted(true); + + sessionStorage.removeItem(`${SESSION_KEY_PREFIX}${problemSlug}`); + }, [canComplete, problemSlug]); + + const registerTabVisit = useCallback((tab: string) => { + setVisitedTabs((prev) => { + const next = new Set(prev); + next.add(tab); + return next; + }); + }, []); + + useEffect(() => { + if (!isActive) return; + + const handler = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (detail) registerTabVisit(detail); + }; + + window.addEventListener('algoria-daily-tab-visit', handler); + return () => window.removeEventListener('algoria-daily-tab-visit', handler); + }, [isActive, registerTabVisit]); + + const remainingSeconds = Math.max(0, REQUIRED_SECONDS - elapsedSeconds); + const progressPercent = Math.min(100, (elapsedMs / (REQUIRED_SECONDS * 1000)) * 100); + const visitedSolutionCount = solutionSlugs.filter(slug => visitedTabs.has(`solution:${slug}`)).length; + const totalSolutions = solutionSlugs.length; + + return { + isActive, + completed, + elapsedMs, + isTabVisible, + visitedTabs, + visitedAllSolutions, + timeReached, + remainingSeconds, + progressPercent, + visitedSolutionCount, + totalSolutions, + }; +} diff --git a/components/layout/session-nav.tsx b/components/layout/session-nav.tsx index cbc89f0..fc07018 100644 --- a/components/layout/session-nav.tsx +++ b/components/layout/session-nav.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react"; +import { useAuthDialog } from "@/components/auth/auth-dialog-context"; import { StreakFlame } from "@/components/gamification/streak-flame"; import { Button } from "@/components/ui/button"; import { authClient } from "@/lib/auth-client"; @@ -16,6 +17,7 @@ export function SessionNav() { const [mounted, setMounted] = useState(false); const dropdownRef = useRef(null); const router = useRouter(); + const { openAuthDialog } = useAuthDialog(); useEffect(() => { const timer = setTimeout(() => { @@ -49,12 +51,12 @@ export function SessionNav() { if (!data?.user) { return ( ); } @@ -68,7 +70,7 @@ export function SessionNav() { const handleSignOut = async () => { setIsOpen(false); await authClient.signOut(); - router.push("/auth/sign-in"); + router.push('/'); router.refresh(); }; diff --git a/components/problem/problem-study-tabs.tsx b/components/problem/problem-study-tabs.tsx index 6208935..80683f9 100644 --- a/components/problem/problem-study-tabs.tsx +++ b/components/problem/problem-study-tabs.tsx @@ -1,16 +1,28 @@ 'use client'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { useState } from 'react'; interface Props { statement: React.ReactNode; strategies: React.ReactNode; + onTabVisited?: (tab: string) => void; } -/** Enunciado + soluções em separadores (fluxo §7 do plano de produto). */ -export function ProblemStudyTabs({ statement, strategies }: Props) { +export function ProblemStudyTabs({ statement, strategies, onTabVisited }: Props) { + const [, setVisited] = useState>(new Set(['statement'])); + + const handleValueChange = (value: string) => { + setVisited((prev) => { + const next = new Set(prev); + next.add(value); + return next; + }); + onTabVisited?.(value); + }; + return ( - + Enunciado diff --git a/components/profile/public/follow-button.tsx b/components/profile/public/follow-button.tsx index 183818b..973ef6a 100644 --- a/components/profile/public/follow-button.tsx +++ b/components/profile/public/follow-button.tsx @@ -1,9 +1,9 @@ 'use client'; +import { useAuthDialog } from '@/components/auth/auth-dialog-context'; import { Button } from '@/components/ui/button'; import { toggleFollowUser } from '@/lib/actions/follow'; import { Loader2, UserCheck, UserPlus } from 'lucide-react'; -import { useRouter } from 'next/navigation'; import { useState, useTransition } from 'react'; interface FollowButtonProps { @@ -17,13 +17,13 @@ export function FollowButton({ initialIsFollowing, isLoggedIn, }: FollowButtonProps) { - const router = useRouter(); const [isFollowing, setIsFollowing] = useState(initialIsFollowing); const [isPending, startTransition] = useTransition(); + const { openAuthDialog } = useAuthDialog(); const handleFollowClick = () => { if (!isLoggedIn) { - router.push('/auth/sign-in'); + openAuthDialog(); return; } diff --git a/lib/gamification/daily-challenge.ts b/lib/gamification/daily-challenge.ts index a03f434..8e13ed2 100644 --- a/lib/gamification/daily-challenge.ts +++ b/lib/gamification/daily-challenge.ts @@ -1,9 +1,9 @@ /** * daily-challenge.ts — Selecciona deterministicamente o "Desafio do Dia". * - * Usa um hash simples da data (YYYY-MM-DD) para indexar a lista de - * problemas publicados, garantindo que todos os utilizadores vêem - * o mesmo desafio no mesmo dia. + * Usa um hash da data (YYYY-MM-DD) combinado com um salt mensal para + * indexar a lista de problemas publicados. Inclui mecanismo anti-repetição + * que garante variação mesmo com listas pequenas de problemas. */ import { toDateKey } from './xp-engine'; @@ -20,6 +20,18 @@ function hashString(str: string): number { return Math.abs(hash); } +/** + * Second hash function (FNV-1a) for double hashing anti-collision. + */ +function hashStringFNV(str: string): number { + let hash = 0x811c9dc5; + for (let i = 0; i < str.length; i++) { + hash ^= str.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + return Math.abs(hash); +} + export interface DailyChallengeInfo { /** Slug do problema seleccionado para hoje. */ slug: string; @@ -31,9 +43,35 @@ export interface DailyChallengeInfo { dateKey: string; } +/** + * Gera o índice dos últimos N dias para evitar repetições. + * Retorna um Set com os slugs que foram usados recentemente. + */ +function getRecentChallenges( + sorted: string[], + now: Date, + lookbackDays: number, +): Set { + const recent = new Set(); + if (sorted.length <= 1) return recent; + + for (let d = 1; d <= lookbackDays; d++) { + const pastDate = new Date(now); + pastDate.setDate(pastDate.getDate() - d); + const pastKey = toDateKey(pastDate); + const pastIdx = hashString(`algoria-daily-v2-${pastKey}`) % sorted.length; + recent.add(sorted[pastIdx]!); + } + return recent; +} + /** * Retorna o problema do dia de forma determinística. * + * Usa um algoritmo de seleção com anti-repetição: se o hash primário + * gerar um problema que foi usado nos últimos N dias, avança ciclicamente + * usando um segundo hash até encontrar um problema não repetido. + * * @param problemSlugs Lista de slugs disponíveis (deve estar ordenada para consistência). * @param problemMeta Map de slug → { title, difficulty } para enriquecer a resposta. * @param now Data actual (opcional, para testes). @@ -47,10 +85,62 @@ export function getDailyChallenge( const dateKey = toDateKey(now); const sorted = [...problemSlugs].sort(); - const idx = hashString(`algoria-daily-${dateKey}`) % sorted.length; - const slug = sorted[idx]!; - const meta = problemMeta.get(slug); + const n = sorted.length; + + // Salt includes year-month for extra variation across months + const yearMonth = dateKey.slice(0, 7); // YYYY-MM + const seed = `algoria-daily-v2-${dateKey}-${yearMonth}`; + + const primaryIdx = hashString(seed) % n; + + // For small lists, limit lookback to avoid exhausting all options + const lookbackDays = Math.min(n - 1, 30); + + if (lookbackDays <= 0 || n <= 1) { + // Only 1 problem, no alternative + const slug = sorted[primaryIdx]!; + const meta = problemMeta.get(slug); + return { + slug, + title: meta?.title ?? slug, + difficulty: meta?.difficulty ?? 'medium', + dateKey, + }; + } + const recentSlugs = getRecentChallenges(sorted, now, lookbackDays); + + // Try primary index first + let slug = sorted[primaryIdx]!; + if (!recentSlugs.has(slug)) { + const meta = problemMeta.get(slug); + return { + slug, + title: meta?.title ?? slug, + difficulty: meta?.difficulty ?? 'medium', + dateKey, + }; + } + + // Double hashing: step forward using a second hash + const step = (hashStringFNV(seed) % (n - 1)) + 1; + for (let attempt = 1; attempt < n; attempt++) { + const idx = (primaryIdx + step * attempt) % n; + slug = sorted[idx]!; + if (!recentSlugs.has(slug)) { + const meta = problemMeta.get(slug); + return { + slug, + title: meta?.title ?? slug, + difficulty: meta?.difficulty ?? 'medium', + dateKey, + }; + } + } + + // Fallback: all exhausted (shouldn't happen with lookback < n), use primary + slug = sorted[primaryIdx]!; + const meta = problemMeta.get(slug); return { slug, title: meta?.title ?? slug, diff --git a/lib/progress/review.test.ts b/lib/progress/review.test.ts index 27555cf..f07b1f0 100644 --- a/lib/progress/review.test.ts +++ b/lib/progress/review.test.ts @@ -8,6 +8,10 @@ describe('getProblemSlugsDueForReview', () => { const now = Date.parse('2026-05-20T12:00:00.000Z'); const blob: ProgressBlob = { version: 1, + xp: 0, + streakCount: 0, + longestStreak: 0, + dailyChallengesCompleted: [], problems: { fresh: { openedSolutions: [], @@ -28,7 +32,7 @@ describe('getProblemSlugsDueForReview', () => { }); it('devolve vazio quando minDays é zero ou negativo', () => { - const blob: ProgressBlob = { version: 1, problems: {} }; + const blob: ProgressBlob = { version: 1, problems: {}, xp: 0, streakCount: 0, longestStreak: 0, dailyChallengesCompleted: [] }; expect(getProblemSlugsDueForReview(blob, 0)).toEqual([]); expect(getProblemSlugsDueForReview(blob, -5)).toEqual([]); });