diff --git a/README.md b/README.md index 3aca87f..56e4150 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # Algoria +![](https://img.shields.io/badge/Versão-1.0.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).

@@ -14,6 +16,9 @@ Plataforma em português para estudar **algoritmos e decisões em código** atra - 🎓 **Curso guiado** — trilha modular com exemplos, MCQs e certificado por capítulo (progresso no browser) - 🌍 **Interview English** — hub `/interview-en` com vocabulário e scripts 100% em inglês para entrevistas - 💼 **Engenharia no trabalho** — guias didáticos `/engineering-work` (frontend, backend, DevOps) +- 🧪 **Testes Técnicos** — hub `/tests` com simulados reais (escolha múltipla + live coding) por trilha e nível +- 👤 **Perfis Profissionais** — portfólio público `/profile` com experiência detalhada, projetos e tecnologias +- 🔍 **Explorador de Talentos** — hub `/explorer` para descobrir e conectar-te com outros engenheiros da comunidade - 🌓 **Tema claro/escuro** (`next-themes`) - 📊 **Analytics opcional** — PostHog quando `NEXT_PUBLIC_POSTHOG_KEY` está definido - 🔍 **SEO** — `sitemap.ts` e `robots.ts` com gate por ambiente (`NEXT_PUBLIC_ENVIRONMENT` + `NODE_ENV`) @@ -33,7 +38,10 @@ algoria/ │ ├── concepts/ # Índice + página por conceito │ ├── curso/ # Programas e módulos guiados │ ├── interview-en/ # Hub EN + artigos -│ └── engenharia-trabalho/ # Hub + guias por slug +│ ├── engenharia-trabalho/ # Hub + guias por slug +│ ├── tests/ # Hub de testes + execução por slug +│ ├── explorer/ # Explorador de talentos / engenheiros +│ └── profile/ # Perfil profissional (pessoal e público) ├── components/ │ ├── ui/ # Button, Card, Tabs, Badge, … │ ├── layout/ # Site header / footer @@ -43,6 +51,9 @@ algoria/ │ ├── problem/ # Tabs / barras de estudo │ ├── solution/ # Seletor de linguagem, trackers │ ├── course/ # Runner do curso, MCQ, certificado +│ ├── tests/ # Motor de testes técnicos, avaliação de código +│ ├── profile/ # Componentes de portfólio e editor de perfil +│ ├── explorer/ # Filtros e cards de talentos │ ├── concepts/ # Tracker de visitas │ ├── complexity/ # Badges Big O │ ├── analytics/ # PostHog provider diff --git a/app/api/auth/[...all]/route.ts b/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000..237e14c --- /dev/null +++ b/app/api/auth/[...all]/route.ts @@ -0,0 +1,4 @@ +import { auth } from '@/lib/auth'; +import { toNextJsHandler } from 'better-auth/next-js'; + +export const { GET, POST, PATCH, PUT, DELETE } = toNextJsHandler(auth); diff --git a/app/api/billing/status/route.ts b/app/api/billing/status/route.ts new file mode 100644 index 0000000..977d931 --- /dev/null +++ b/app/api/billing/status/route.ts @@ -0,0 +1,16 @@ +import { headers } from 'next/headers'; + +import { auth } from '@/lib/auth'; +import { userHasPro } from '@/lib/billing/entitlements'; + +export async function GET() { + const session = await auth.api.getSession({ headers: await headers() }); + const uid = session?.user?.id; + const pro = await userHasPro(uid); + return Response.json({ + authenticated: Boolean(uid), + pro, + email: session?.user?.email ?? null, + userId: uid ?? null, + }); +} diff --git a/app/api/checkout/route.ts b/app/api/checkout/route.ts new file mode 100644 index 0000000..8496eca --- /dev/null +++ b/app/api/checkout/route.ts @@ -0,0 +1,65 @@ +import { headers } from 'next/headers'; + +import { auth } from '@/lib/auth'; +import { getStripe } from '@/lib/billing/stripe'; +import { rateLimit } from '@/lib/security/rate-limit'; + +const CHECKOUT_LIMIT = 15; +const CHECKOUT_WINDOW_MS = 900_000; + +export async function POST(req: Request) { + const stripe = getStripe(); + if (!stripe) { + return Response.json({ error: 'Stripe não configurado.' }, { status: 501 }); + } + const priceId = process.env.STRIPE_PRICE_PRO_MONTHLY; + if (!priceId) { + return Response.json({ error: 'STRIPE_PRICE_PRO_MONTHLY em falta.' }, { status: 501 }); + } + + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.user?.id) { + return Response.json({ error: 'Inicia sessão para continuar.' }, { status: 401 }); + } + + if ( + !rateLimit( + `checkout:${session.user.id}`, + CHECKOUT_LIMIT, + CHECKOUT_WINDOW_MS, + ) + ) { + return Response.json( + { error: 'Demasiadas tentativas de checkout. Aguarda alguns minutos.' }, + { status: 429 }, + ); + } + + const origin = + process.env.NEXT_PUBLIC_APP_URL?.replace(/\/$/, '') || new URL(req.url).origin; + + try { + const checkoutSession = await stripe.checkout.sessions.create({ + mode: 'subscription', + customer_email: session.user.email ?? undefined, + line_items: [{ price: priceId, quantity: 1 }], + success_url: `${origin}/pricing?checkout=success`, + cancel_url: `${origin}/pricing?checkout=cancel`, + metadata: { userId: session.user.id }, + client_reference_id: session.user.id, + }); + + if (!checkoutSession.url) { + return Response.json({ error: 'URL de checkout indisponível.' }, { status: 500 }); + } + + return Response.json({ url: checkoutSession.url }); + } catch (err) { + const error = err as Error; + console.error('Checkout error:', error); + return Response.json( + { error: error.message ?? 'Erro ao criar sessão de checkout.' }, + { status: 500 }, + ); + } +} diff --git a/app/api/customer-portal/route.ts b/app/api/customer-portal/route.ts new file mode 100644 index 0000000..0809abc --- /dev/null +++ b/app/api/customer-portal/route.ts @@ -0,0 +1,58 @@ +import { headers } from 'next/headers'; +import { eq } from 'drizzle-orm'; + +import { auth } from '@/lib/auth'; +import { getStripe } from '@/lib/billing/stripe'; +import { db } from '@/lib/db'; +import { subscription } from '@/lib/db/schema'; + +export async function POST(req: Request) { + const stripe = getStripe(); + if (!stripe) { + return Response.json({ error: 'Stripe não configurado.' }, { status: 501 }); + } + + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.user?.id) { + return Response.json({ error: 'Inicia sessão para continuar.' }, { status: 401 }); + } + + // Busca o stripeCustomerId do banco + const subRows = await db + .select({ stripeCustomerId: subscription.stripeCustomerId }) + .from(subscription) + .where(eq(subscription.userId, session.user.id)) + .limit(1); + + const sub = subRows[0]; + if (!sub) { + return Response.json({ error: 'Não tens uma assinatura ativa.' }, { status: 404 }); + } + + // Verifica se é um ID fictício do script de dev + if (sub.stripeCustomerId.startsWith('cus_mock_')) { + return Response.json( + { error: 'Não é possível abrir o portal com um ID fictício. Faz um checkout real para testar.' }, + { status: 400 } + ); + } + + const origin = + process.env.NEXT_PUBLIC_APP_URL?.replace(/\/$/, '') || new URL(req.url).origin; + + try { + const portalSession = await stripe.billingPortal.sessions.create({ + customer: sub.stripeCustomerId, + return_url: `${origin}/pricing`, + }); + + return Response.json({ url: portalSession.url }); + } catch (err) { + const error = err as Error; + console.error('Portal error:', error); + return Response.json( + { error: error.message ?? 'Erro ao abrir o portal de gestão.' }, + { status: 500 }, + ); + } +} diff --git a/app/api/progress/merge/route.ts b/app/api/progress/merge/route.ts new file mode 100644 index 0000000..4b073be --- /dev/null +++ b/app/api/progress/merge/route.ts @@ -0,0 +1,81 @@ +import { eq } from 'drizzle-orm'; +import { headers } from 'next/headers'; + +import { auth } from '@/lib/auth'; +import { db } from '@/lib/db'; +import { userProgress } from '@/lib/db/schema'; +import { ProgressBlobSchema, type ProgressBlob } from '@/lib/progress/local-progress-schema'; +import { mergeProgressBlobs } from '@/lib/progress/merge-blobs'; +import { rateLimit } from '@/lib/security/rate-limit'; + +const MAX_BODY_BYTES = 512 * 1024; +const MERGE_LIMIT = 40; +const MERGE_WINDOW_MS = 60_000; + +export async function POST(req: Request) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.user?.id) { + return Response.json({ error: 'Não autenticado.' }, { status: 401 }); + } + + if ( + !rateLimit(`progress-merge:${session.user.id}`, MERGE_LIMIT, MERGE_WINDOW_MS) + ) { + return Response.json( + { error: 'Demasiados pedidos. Tenta daqui a um minuto.' }, + { status: 429 }, + ); + } + + const len = req.headers.get('content-length'); + if (len && Number(len) > MAX_BODY_BYTES) { + return Response.json({ error: 'Payload demasiado grande.' }, { status: 413 }); + } + + const raw = await req.text(); + if (raw.length > MAX_BODY_BYTES) { + return Response.json({ error: 'Payload demasiado grande.' }, { status: 413 }); + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw) as unknown; + } catch { + return Response.json({ error: 'JSON inválido.' }, { status: 400 }); + } + + const body = parsed as { blob?: unknown }; + const local = ProgressBlobSchema.parse(body.blob ?? { version: 1, problems: {} }); + + const rows = await db + .select() + .from(userProgress) + .where(eq(userProgress.userId, session.user.id)) + .limit(1); + + let server: ProgressBlob = { version: 1, problems: {} }; + if (rows[0]) { + server = ProgressBlobSchema.parse(JSON.parse(rows[0].data)); + } + + const merged = mergeProgressBlobs(local, server); + const now = new Date(); + + if (rows[0]) { + await db + .update(userProgress) + .set({ + data: JSON.stringify(merged), + updatedAt: now, + }) + .where(eq(userProgress.userId, session.user.id)); + } else { + await db.insert(userProgress).values({ + userId: session.user.id, + data: JSON.stringify(merged), + updatedAt: now, + }); + } + + return Response.json({ blob: merged }); +} diff --git a/app/api/progress/route.ts b/app/api/progress/route.ts new file mode 100644 index 0000000..76b6ac0 --- /dev/null +++ b/app/api/progress/route.ts @@ -0,0 +1,27 @@ +import { eq } from 'drizzle-orm'; +import { headers } from 'next/headers'; + +import { auth } from '@/lib/auth'; +import { db } from '@/lib/db'; +import { userProgress } from '@/lib/db/schema'; +import { ProgressBlobSchema } from '@/lib/progress/local-progress-schema'; + +export async function GET() { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.user?.id) { + return Response.json({ error: 'Não autenticado.' }, { status: 401 }); + } + + const rows = await db + .select() + .from(userProgress) + .where(eq(userProgress.userId, session.user.id)) + .limit(1); + + if (!rows[0]) { + return Response.json({ blob: { version: 1 as const, problems: {} } }); + } + + const blob = ProgressBlobSchema.parse(JSON.parse(rows[0].data)); + return Response.json({ blob }); +} diff --git a/app/api/webhooks/stripe/route.ts b/app/api/webhooks/stripe/route.ts new file mode 100644 index 0000000..adf88a6 --- /dev/null +++ b/app/api/webhooks/stripe/route.ts @@ -0,0 +1,124 @@ +import { randomUUID } from 'node:crypto'; + +import { eq } from 'drizzle-orm'; +import type Stripe from 'stripe'; + +import { db } from '@/lib/db'; +import { subscription } from '@/lib/db/schema'; +import { getStripe } from '@/lib/billing/stripe'; +import { subscriptionPeriodEnd } from '@/lib/billing/stripe-subscription'; + +export async function POST(req: Request) { + const stripe = getStripe(); + const whSecret = process.env.STRIPE_WEBHOOK_SECRET; + if (!stripe || !whSecret) { + return new Response('Stripe webhook não configurado.', { status: 501 }); + } + + const sig = req.headers.get('stripe-signature'); + if (!sig) return new Response('Sem assinatura', { status: 400 }); + + const raw = await req.text(); + + let event: Stripe.Event; + try { + event = stripe.webhooks.constructEvent(raw, sig, whSecret); + } catch { + return new Response('Assinatura inválida', { status: 400 }); + } + + try { + switch (event.type) { + case 'checkout.session.completed': { + const sess = event.data.object as Stripe.Checkout.Session; + const userId = sess.metadata?.userId ?? sess.client_reference_id; + const customerId = typeof sess.customer === 'string' ? sess.customer : sess.customer?.id; + const subId = + typeof sess.subscription === 'string' ? sess.subscription : sess.subscription?.id; + if (!userId || !customerId || !subId) break; + + const sub = await stripe.subscriptions.retrieve(subId); + + await upsertSubscription({ + userId, + stripeCustomerId: customerId, + stripeSubscriptionId: subId, + status: sub.status, + currentPeriodEnd: subscriptionPeriodEnd(sub), + }); + + break; + } + case 'customer.subscription.updated': + case 'customer.subscription.deleted': { + const sub = event.data.object as Stripe.Subscription; + const customerId = typeof sub.customer === 'string' ? sub.customer : sub.customer?.id; + if (!customerId) break; + const rows = await db + .select({ userId: subscription.userId }) + .from(subscription) + .where(eq(subscription.stripeCustomerId, customerId)) + .limit(1); + const userId = rows[0]?.userId; + if (!userId) break; + const periodEnd = subscriptionPeriodEnd(sub); + await db + .update(subscription) + .set({ + status: sub.status, + stripeSubscriptionId: sub.id, + currentPeriodEnd: periodEnd, + updatedAt: new Date(), + }) + .where(eq(subscription.stripeCustomerId, customerId)); + break; + } + default: + break; + } + } catch (e) { + console.error('[stripe webhook]', e); + return new Response('Erro ao processar evento', { status: 500 }); + } + + return new Response(JSON.stringify({ received: true }), { status: 200 }); +} + +async function upsertSubscription(params: { + userId: string; + stripeCustomerId: string; + stripeSubscriptionId: string; + status: string; + currentPeriodEnd: Date | null; +}) { + const existing = await db + .select({ id: subscription.id }) + .from(subscription) + .where(eq(subscription.userId, params.userId)) + .limit(1); + + if (existing[0]) { + await db + .update(subscription) + .set({ + stripeCustomerId: params.stripeCustomerId, + stripeSubscriptionId: params.stripeSubscriptionId, + status: params.status, + currentPeriodEnd: params.currentPeriodEnd, + updatedAt: new Date(), + }) + .where(eq(subscription.userId, params.userId)); + return; + } + + await db.insert(subscription).values({ + id: randomUUID(), + userId: params.userId, + stripeCustomerId: params.stripeCustomerId, + stripeSubscriptionId: params.stripeSubscriptionId, + status: params.status, + currentPeriodEnd: params.currentPeriodEnd, + createdAt: new Date(), + updatedAt: new Date(), + }); +} diff --git a/app/auth/sign-in/page.tsx b/app/auth/sign-in/page.tsx new file mode 100644 index 0000000..f174921 --- /dev/null +++ b/app/auth/sign-in/page.tsx @@ -0,0 +1,82 @@ +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 { buildPublicMetadata } from '@/lib/seo/build-metadata'; + +export const metadata: Metadata = buildPublicMetadata({ + title: 'Iniciar sessão', + description: 'Entra na tua conta Algoria para sincronizar progresso e gerir a subscrição Pro.', + pathname: '/auth/sign-in', + keywords: ['login', 'conta', 'Algoria'], +}); + +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 +
+ +
+
+
+
+
+
+ ); +} diff --git a/app/auth/sign-up/page.tsx b/app/auth/sign-up/page.tsx new file mode 100644 index 0000000..ac9f48e --- /dev/null +++ b/app/auth/sign-up/page.tsx @@ -0,0 +1,82 @@ +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 { buildPublicMetadata } from '@/lib/seo/build-metadata'; + +export const metadata: Metadata = buildPublicMetadata({ + title: 'Criar conta', + description: 'Cria uma conta Algoria para guardar o progresso na nuvem e subscrever o catálogo Pro.', + pathname: '/auth/sign-up', + keywords: ['registo', 'conta', 'Algoria'], +}); + +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 +
+ +
+
+
+
+
+
+ ); +} diff --git a/app/changelog/page.tsx b/app/changelog/page.tsx index d3833db..ef2efe0 100644 --- a/app/changelog/page.tsx +++ b/app/changelog/page.tsx @@ -1,6 +1,9 @@ +import { ArrowLeft } from "lucide-react"; import type { Metadata } from "next"; import Link from "next/link"; +import { Button } from "@/components/ui/button"; + import { getChangelogHtml } from "@/lib/content/loader"; import { buildPublicMetadata } from "@/lib/seo/build-metadata"; @@ -17,13 +20,17 @@ export default async function ChangelogPage() { return (
-
- +

@@ -33,8 +40,8 @@ export default async function ChangelogPage() { Novidades

- Este registo complementa o repositório Git — útil para saberes o - que mudou sem ler commits. + Descobre as últimas melhorias, novos conteúdos e funcionalidades + desenhadas para levar o teu conhecimento técnico ao próximo nível.

diff --git a/app/concepts/[slug]/page.tsx b/app/concepts/[slug]/page.tsx index 13782c2..5315264 100644 --- a/app/concepts/[slug]/page.tsx +++ b/app/concepts/[slug]/page.tsx @@ -1,13 +1,20 @@ import Link from 'next/link'; import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; -import { Clock } from 'lucide-react'; +import { headers } from 'next/headers'; +import { ArrowLeft, Clock } from 'lucide-react'; +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 { getAllConceptSlugs, getConcept } from '@/lib/content/loader'; +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 { buildPublicMetadata } from '@/lib/seo/build-metadata'; import { learningResourceJsonLd } from '@/lib/seo/structured-data'; @@ -52,9 +59,16 @@ export default async function ConceptPage({ const { slug } = await params; const concept = await getConcept(slug); if (!concept) notFound(); + + const session = await auth.api.getSession({ headers: await headers() }); + const hasPro = await userHasPro(session?.user?.id); + const access = getConceptAccess(concept.meta); + const isLocked = !isContentUnlockedForUser(access, hasPro); + const q = (await searchParams) ?? {}; const courseSlug = q.course ?? q.curso; const moduleId = q.module ?? q.modulo; + const adjacent = await getAdjacentConcepts(slug); return (
@@ -67,9 +81,9 @@ export default async function ConceptPage({ /> - - ← Todos os conceitos - + {courseSlug && moduleId ? (
@@ -99,14 +113,26 @@ export default async function ConceptPage({

{concept.meta.title}

{concept.meta.summary}

-
+ +
+ ) : ( +
+ )} + +
); diff --git a/app/concepts/page.tsx b/app/concepts/page.tsx index da95fe7..51a904e 100644 --- a/app/concepts/page.tsx +++ b/app/concepts/page.tsx @@ -30,6 +30,7 @@ export default async function ConceptsPage() { category: c.meta.category, estimatedMinutes: c.meta.estimatedMinutes, difficulty: c.meta.difficulty, + access: c.meta.access, })); return ( diff --git a/app/course/[slug]/module/[moduleId]/page.tsx b/app/course/[slug]/module/[moduleId]/page.tsx index 3d1edf5..d32df49 100644 --- a/app/course/[slug]/module/[moduleId]/page.tsx +++ b/app/course/[slug]/module/[moduleId]/page.tsx @@ -1,8 +1,10 @@ import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; import Link from 'next/link'; +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 { getCoursePackHydrated, listCourseSlugs } from '@/lib/courses/hydrate-course-pack'; import { buildPublicMetadata } from '@/lib/seo/build-metadata'; @@ -70,9 +72,9 @@ export default async function CourseModulePage({ params }: { params: Promise
- - ← Índice do programa - +
diff --git a/app/course/page.tsx b/app/course/page.tsx index a81fffb..295a518 100644 --- a/app/course/page.tsx +++ b/app/course/page.tsx @@ -1,6 +1,8 @@ -import Link from 'next/link'; +import { ArrowRight, Code2 } from 'lucide-react'; import type { Metadata } from 'next'; +import Link from 'next/link'; +import { Button } from '@/components/ui/button'; import { getCoursePackHydrated, listCourseSlugs } from '@/lib/courses/hydrate-course-pack'; import { buildPublicMetadata } from '@/lib/seo/build-metadata'; @@ -17,42 +19,66 @@ export default async function CoursesIndexPage() { const packs = await Promise.all(slugs.map((s) => getCoursePackHydrated(s))); return ( -
-
-

Algoria.curriculum

-

Cursos

-

- Cada curso combina leitura + exercícios escritos no browser e emite certificado modular quando concluíres a - prova final correspondente. -

-
-
    - {packs.map((p) => - p ? ( -
  • -
    -

    {p.title}

    -

    {p.subtitle}

    -

    - {p.modules.length} módulos · progresso apenas local +

    +
    +
    +
    +
    +
    + +
    +
    +

    + Algoria.curriculum +

    +

    + Cursos Guiados +

    +

    + Cada curso combina leitura, exemplos práticos e exercícios no browser. + Obtém certificados modulares ao concluir as avaliações de cada capítulo.

    - +
    +
    + +
    + {packs.map((p) => + p ? ( +
    - Abrir programa - -
  • - ) : null, - )} -
-
- O catálogo cresce por fases: fundamentos técnicos e trilhos de comunicação para entrevistas em inglês. -
+
+
+

{p.title}

+

{p.subtitle}

+
+ + {p.modules.length} Módulos + + + Progresso Local + +
+
+ + +
+
+ ) : null, + )} + + +
+ O catálogo cresce por fases: fundamentos técnicos e trilhos de comunicação para entrevistas em inglês. +
+ ); } diff --git a/app/engineering-work/[slug]/page.tsx b/app/engineering-work/[slug]/page.tsx index 6f21a94..b60550d 100644 --- a/app/engineering-work/[slug]/page.tsx +++ b/app/engineering-work/[slug]/page.tsx @@ -1,24 +1,34 @@ -import Link from 'next/link'; -import type { Metadata } from 'next'; -import { notFound } from 'next/navigation'; -import { Clock } from 'lucide-react'; +import { ArrowLeft, Clock } from "lucide-react"; +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; -import { Badge } from '@/components/ui/badge'; -import { EngineeringGuideArticle } from '@/components/engenharia-trabalho/engineering-guide-article'; -import { JsonLdScript } from '@/components/seo/json-ld'; -import { getAllEngineeringWorkSlugs, getEngineeringWorkGuide } from '@/lib/content/loader'; -import type { EngineeringWorkPillar } from '@/lib/content/schemas'; -import { buildPublicMetadata } from '@/lib/seo/build-metadata'; -import { articleJsonLd } from '@/lib/seo/structured-data'; +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 { + getAdjacentEngineeringWork, + getAllEngineeringWorkSlugs, + getEngineeringWorkGuide, +} from "@/lib/content/loader"; +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"; interface Params { slug: string; } const PILLAR_LABEL: Record = { - frontend: 'Frontend e produto', - backend: 'Backend e APIs', - devops: 'DevOps e sistema', + frontend: "Frontend e produto", + backend: "Backend e APIs", + devops: "DevOps e sistema", }; export async function generateStaticParams(): Promise { @@ -26,7 +36,11 @@ export async function generateStaticParams(): Promise { return slugs.map((slug) => ({ slug })); } -export async function generateMetadata({ params }: { params: Promise }): Promise { +export async function generateMetadata({ + params, +}: { + params: Promise; +}): Promise { const { slug } = await params; const guide = await getEngineeringWorkGuide(slug); if (!guide) return {}; @@ -35,15 +49,36 @@ export async function generateMetadata({ params }: { params: Promise }): title: `${guide.meta.title} · Engenharia no trabalho`, description: guide.meta.summary, pathname: `/engineering-work/${slug}`, - keywords: [guide.meta.title, pillar, 'engenharia software', 'boas práticas produção', 'Algoria guia'], - openGraphType: 'article', + keywords: [ + guide.meta.title, + pillar, + "engenharia software", + "boas práticas produção", + "Algoria guia", + ], + openGraphType: "article", }); } -export default async function EngineeringWorkGuidePage({ params }: { params: Promise }) { +export default async function EngineeringWorkGuidePage({ + params, +}: { + params: Promise; +}) { const { slug } = await params; const guide = await getEngineeringWorkGuide(slug); if (!guide) notFound(); + 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({ + where: eq(user.name, "Jonatas Silva"), + })) ?? null; + } catch { + console.warn("Could not fetch author data from DB during build. Using fallback."); + } return (
@@ -54,23 +89,64 @@ export default async function EngineeringWorkGuidePage({ params }: { params: Pro pathname: `/engineering-work/${slug}`, })} /> - - ← Engenharia no trabalho - +
{PILLAR_LABEL[guide.meta.pillar]} - ~{guide.meta.estimatedMinutes} min + ~ + {guide.meta.estimatedMinutes} min
-

{guide.meta.title}

-

{guide.meta.summary}

+

+ {guide.meta.title} +

+

+ {guide.meta.summary} +

+ + + +
); } diff --git a/app/explorer/page.tsx b/app/explorer/page.tsx new file mode 100644 index 0000000..5df9dde --- /dev/null +++ b/app/explorer/page.tsx @@ -0,0 +1,157 @@ +import { Pagination } from "@/components/explorer/pagination"; +import { SearchBar } from "@/components/explorer/search-bar"; +import { TechFilter } from "@/components/explorer/tech-filter"; +import { UserCard } from "@/components/explorer/user-card"; +import { db } from "@/lib/db"; +import { user, userProfile } from "@/lib/db/schema"; +import { buildPublicMetadata } from "@/lib/seo/build-metadata"; +import { and, eq, ilike, or, sql } from "drizzle-orm"; +import { Filter, Users } from "lucide-react"; + +interface ExplorerPageProps { + searchParams: Promise<{ + q?: string; + tech?: string; + page?: string; + }>; +} + +export const metadata = buildPublicMetadata({ + title: "Explorar Desenvolvedores", + description: + "Encontra outros desenvolvedores na Algoria, filtra por tecnologias e descobre perfis profissionais.", + pathname: "/explorer", +}); + +const ITEMS_PER_PAGE = 12; + +export default async function ExplorerPage({ + searchParams, +}: ExplorerPageProps) { + const params = await searchParams; + const query = params.q || ""; + const techFilters = params.tech?.split(",").filter(Boolean) || []; + const currentPage = Number(params.page) || 1; + const offset = (currentPage - 1) * ITEMS_PER_PAGE; + + const conditions = []; + if (query) { + conditions.push( + or( + ilike(user.name, `%${query}%`), + ilike(userProfile.headline, `%${query}%`), + ), + ); + } + + if (techFilters.length > 0) { + techFilters.forEach((tech) => { + conditions.push( + sql`${userProfile.technologies} @> ARRAY[${tech}]::text[]`, + ); + }); + } + + const [countResult] = await db + .select({ count: sql`cast(count(*) as int)` }) + .from(user) + .leftJoin(userProfile, eq(user.id, userProfile.userId)) + .where(and(...conditions)); + + const totalUsers = countResult?.count || 0; + const totalPages = Math.ceil(totalUsers / ITEMS_PER_PAGE); + + const users = await db + .select({ + id: user.id, + name: user.name, + image: user.image, + headline: userProfile.headline, + technologies: userProfile.technologies, + experiences: userProfile.experiences, + }) + .from(user) + .leftJoin(userProfile, eq(user.id, userProfile.userId)) + .where(and(...conditions)) + .limit(ITEMS_PER_PAGE) + .offset(offset) + .orderBy(query || techFilters.length > 0 ? user.name : sql`RANDOM()`); + + return ( +
+
+
+
+
+ +
+

+ Comunidade Algoria +

+
+

+ Explorar Talentos +

+

+ Conecta-te com outros engenheiros da plataforma. Filtra por stack + tecnológica, percurso profissional ou procura diretamente por nomes + e especialidades. +

+
+ +
+ + +
+ + + {users.length > 0 ? ( + <> +
+ {users.map((u) => ( + + ))} +
+ + + ) : ( +
+
+ +
+

+ Nenhum utilizador encontrado +

+

+ Tenta ajustar os teus filtros ou limpar a pesquisa para ver + mais resultados. +

+
+ )} +
+
+
+
+ ); +} diff --git a/app/globals.css b/app/globals.css index b02e5f6..a7efa89 100644 --- a/app/globals.css +++ b/app/globals.css @@ -4,6 +4,7 @@ @custom-variant dark (&:where(.dark, .dark *)); :root { + --sidebar-width: 48px; --background: #ffffff; --foreground: #020617; @@ -23,6 +24,9 @@ --input: #e2e8f0; --ring: #4f46e5; + --destructive: #dc2626; + --destructive-foreground: #ffffff; + --shiki-light: #24292e; --shiki-light-bg: #ffffff; } @@ -47,6 +51,9 @@ --input: #1e293b; --ring: #818cf8; + --destructive: #f87171; + --destructive-foreground: #020617; + --shiki-light: var(--shiki-dark); --shiki-light-bg: var(--shiki-dark-bg, #020617); } @@ -66,6 +73,8 @@ --color-border: var(--border); --color-input: var(--input); --color-ring: var(--ring); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); --font-sans: var(--font-geist-sans); --font-mono: var(--font-geist-mono); diff --git a/app/interview-en/[slug]/page.tsx b/app/interview-en/[slug]/page.tsx index b7f96b4..3b04716 100644 --- a/app/interview-en/[slug]/page.tsx +++ b/app/interview-en/[slug]/page.tsx @@ -1,11 +1,13 @@ import Link from 'next/link'; import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; -import { Clock } from 'lucide-react'; +import { ArrowLeft, Clock } from 'lucide-react'; 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 { getAllInterviewEnglishSlugs, getInterviewEnglishTopic } from '@/lib/content/loader'; +import { getAllInterviewEnglishSlugs, getInterviewEnglishTopic, getAdjacentInterviewEnglish } from '@/lib/content/loader'; import type { InterviewEnglishTrack } from '@/lib/content/schemas'; import { buildPublicMetadata } from '@/lib/seo/build-metadata'; import { learningResourceJsonLd } from '@/lib/seo/structured-data'; @@ -52,6 +54,7 @@ export default async function InterviewEnglishTopicPage({ params }: { params: Pr const { slug } = await params; const topic = await getInterviewEnglishTopic(slug); if (!topic) notFound(); + const adjacent = await getAdjacentInterviewEnglish(slug); return (
@@ -63,9 +66,9 @@ export default async function InterviewEnglishTopicPage({ params }: { params: Pr inLanguage: 'en', })} /> - - ← Interview English hub - +
@@ -90,6 +93,12 @@ export default async function InterviewEnglishTopicPage({ params }: { params: Pr prose-table:text-sm" dangerouslySetInnerHTML={{ __html: topic.bodyHtml }} /> + +
); } diff --git a/app/interview-en/page.tsx b/app/interview-en/page.tsx index 420fa3c..ad8cb41 100644 --- a/app/interview-en/page.tsx +++ b/app/interview-en/page.tsx @@ -1,9 +1,7 @@ -import Link from 'next/link'; import type { Metadata } from 'next'; -import { Clock, Languages } from 'lucide-react'; +import { Languages } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { InterviewCatalogClient } from '@/components/interview-en/interview-catalog-client'; import { INTERVIEW_EN_TRACKS, type InterviewEnglishTrack } from '@/lib/content/schemas'; import { getAllInterviewEnglishTopics } from '@/lib/content/loader'; @@ -30,13 +28,6 @@ const TRACK_ORDER = new Map( INTERVIEW_EN_TRACKS.map((t, i) => [t, i]), ); -const TRACK_BADGE: Record = { - vocabulary: 'Vocabulary', - communication: 'Live coding talk track', - behavioral: 'Behavioral', - 'system-design': 'System design', -}; - export default async function InterviewEnglishIndexPage() { const topics = await getAllInterviewEnglishTopics(); topics.sort((a, b) => { diff --git a/app/layout.tsx b/app/layout.tsx index 3e1ee23..376ace9 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,77 +1,102 @@ -import type { Metadata } from 'next'; -import { Geist, Geist_Mono } from 'next/font/google'; +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; -import './globals.css'; +import "./globals.css"; -import { AlgoriaPostHogProvider } from '@/components/analytics/posthog-provider'; -import { JsonLdScript } from '@/components/seo/json-ld'; -import { ThemeProvider } from '@/components/theme-provider'; -import { SiteFooter } from '@/components/layout/site-footer'; -import { SiteHeader } from '@/components/layout/site-header'; -import { getMetadataBase, getSiteOrigin } from '@/lib/seo/site'; +import { AlgoriaPostHogProvider } from "@/components/analytics/posthog-provider"; +import { ProgressSyncOnLogin } from "@/components/billing/progress-sync"; +import { Sidebar } from "@/components/layout/sidebar"; +import { SidebarProvider } from "@/components/layout/sidebar-context"; +import { SiteFooter } from "@/components/layout/site-footer"; +import { SiteHeader } from "@/components/layout/site-header"; +import { JsonLdScript } from "@/components/seo/json-ld"; +import { ThemeProvider } from "@/components/theme-provider"; +import { ToastContainer } from "@/components/ui/toast"; +import { getMetadataBase, getSiteOrigin } from "@/lib/seo/site"; -const geistSans = Geist({ variable: '--font-geist-sans', subsets: ['latin'] }); -const geistMono = Geist_Mono({ variable: '--font-geist-mono', subsets: ['latin'] }); +const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] }); +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin"], +}); export const metadata: Metadata = { title: { - default: 'Algoria — aprende algoritmos lendo código, não escrevendo', - template: '%s · Algoria', + default: "Algoria — aprende algoritmos lendo código, não escrevendo", + template: "%s · Algoria", }, description: - 'Plataforma onde aprendes problemas clássicos de algoritmos linha-a-linha, com explicações em três níveis de profundidade, comparação brute-force vs óptima, e mini-cursos de Big O.', + "Plataforma onde aprendes problemas clássicos de algoritmos linha-a-linha, com explicações em três níveis de profundidade, comparação brute-force vs óptima, e mini-cursos de Big O.", keywords: [ - 'algoritmos', - 'estruturas de dados', - 'leetcode', - 'big o', - 'preparação de entrevistas', - 'data structures', - 'aprender algoritmos', + "algoritmos", + "estruturas de dados", + "leetcode", + "big o", + "preparação de entrevistas", + "data structures", + "aprender algoritmos", ], metadataBase: getMetadataBase(), openGraph: { - title: 'Algoria — aprende algoritmos lendo código', + title: "Algoria — aprende algoritmos lendo código", description: - 'Linha por linha, com 3 níveis de profundidade. Foco em entender, não em decorar.', - type: 'website', - locale: 'pt_BR', + "Linha por linha, com 3 níveis de profundidade. Foco em entender, não em decorar.", + type: "website", + locale: "pt_BR", }, }; -export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { +export default function RootLayout({ + children, +}: Readonly<{ children: React.ReactNode }>) { const origin = getSiteOrigin(); const structuredData = { - '@context': 'https://schema.org', - '@graph': [ + "@context": "https://schema.org", + "@graph": [ { - '@type': 'EducationalOrganization', - '@id': `${origin}/#organization`, - name: 'Algoria', + "@type": "EducationalOrganization", + "@id": `${origin}/#organization`, + name: "Algoria", url: origin, description: - 'Plataforma para aprender algoritmos e estruturas de dados com leitura guiada de código, preparação para entrevistas e guias de engenharia aplicada.', + "Plataforma para aprender algoritmos e estruturas de dados com leitura guiada de código, preparação para entrevistas e guias de engenharia aplicada.", }, { - '@type': 'WebSite', - '@id': `${origin}/#website`, + "@type": "WebSite", + "@id": `${origin}/#website`, url: origin, - name: 'Algoria', - inLanguage: 'pt-BR', - publisher: { '@id': `${origin}/#organization` }, + name: "Algoria", + inLanguage: "pt-BR", + publisher: { "@id": `${origin}/#organization` }, }, ], }; return ( - + - + + -
{children}
+ + +
+ {children} +
+
+
diff --git a/app/legal/cookies/page.tsx b/app/legal/cookies/page.tsx new file mode 100644 index 0000000..c27a863 --- /dev/null +++ b/app/legal/cookies/page.tsx @@ -0,0 +1,167 @@ +import { ArrowLeft } from "lucide-react"; +import type { Metadata } from "next"; +import Link from "next/link"; + +import { Button } from "@/components/ui/button"; +import { buildPublicMetadata } from "@/lib/seo/build-metadata"; + +export const metadata: Metadata = buildPublicMetadata({ + title: "Cookies", + description: "Como a Algoria utiliza cookies para melhorar sua experiência.", + pathname: "/legal/cookies", + keywords: ["cookies", "privacidade", "segurança", "Algoria"], +}); + +export default function CookiesPage() { + return ( +
+ + +
+

+ Política de Cookies +

+

+ Última atualização: 07 de Maio de 2026 +

+
+ +
+
+

1. O que são Cookies?

+

+ Cookies são pequenos arquivos de texto enviados pelo nosso servidor + para o seu navegador e armazenados no seu dispositivo (computador, + smartphone, tablet). Eles permitem que a plataforma "lembre" de suas + ações ou preferências ao longo do tempo, garantindo uma navegação + mais eficiente e segura. +

+
+ +
+

2. Como Utilizamos os Cookies

+

A Algoria utiliza cookies para as seguintes finalidades:

+
    +
  • + Cookies Essenciais (Estritamente Necessários):{" "} + São fundamentais para o funcionamento da plataforma. Eles permitem + que você faça login de forma segura através do{" "} + Better Auth, navegue entre as páginas e acesse áreas + protegidas (como o conteúdo Algoria Pro). Sem esses cookies, o + serviço não pode ser prestado corretamente. +
  • +
  • + Cookies de Funcionalidade: Usados para reconhecer + você quando retorna à nossa plataforma. Isso nos permite + personalizar nosso conteúdo para você e lembrar suas preferências + (como idioma ou configurações de interface). +
  • +
  • + Cookies Analíticos e de Desempenho: Utilizamos o{" "} + PostHog para entender como os visitantes + interagem com a plataforma. Isso nos ajuda a identificar quais + páginas são mais populares e se ocorrem erros. Todos os dados são + coletados de forma a proteger sua identidade. +
  • +
+
+ +
+

3. Cookies de Terceiros

+

+ Alguns cookies são definidos por serviços de terceiros que aparecem + em nossas páginas: +

+
    +
  • + Stripe: Define cookies necessários para processar + pagamentos e prevenir fraudes durante o checkout. +
  • +
  • + PostHog: Define cookies para rastrear métricas de + uso e comportamento do usuário dentro da aplicação. +
  • +
+
+ +
+

4. Como Controlar os Cookies

+

+ Você pode gerenciar ou desativar cookies diretamente nas + configurações do seu navegador. No entanto, observe que a + desativação de cookies essenciais pode impedir o acesso a + funcionalidades críticas da plataforma, como a manutenção da sua + sessão de usuário ativa. +

+

+ Para mais informações sobre como gerenciar cookies nos principais + navegadores: +

+ +
+ +
+

Mais Informações

+

+ Se você tiver dúvidas sobre nossa política de cookies, consulte + também nossa{" "} + + Política de Privacidade + {" "} + ou entre em contato conosco. +

+
+
+
+ ); +} diff --git a/app/legal/privacy/page.tsx b/app/legal/privacy/page.tsx new file mode 100644 index 0000000..50fb658 --- /dev/null +++ b/app/legal/privacy/page.tsx @@ -0,0 +1,116 @@ +import Link from 'next/link'; +import type { Metadata } from 'next'; +import { ArrowLeft } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { buildPublicMetadata } from '@/lib/seo/build-metadata'; + +export const metadata: Metadata = buildPublicMetadata({ + title: 'Privacidade', + description: 'Política de Privacidade e proteção de dados da Algoria.', + pathname: '/legal/privacy', + keywords: ['privacidade', 'proteção de dados', 'LGPD', 'RGPD', 'Algoria'], +}); + +export default function PrivacyPage() { + return ( +
+ + +
+

+ Política de Privacidade +

+

Última atualização: 07 de Maio de 2026

+
+ +
+
+

1. Introdução

+

+ A Algoria está comprometida em proteger a sua privacidade. Esta Política de Privacidade explica como coletamos, usamos, processamos e protegemos suas informações pessoais ao utilizar nossa plataforma. Operamos em conformidade com a Lei Geral de Proteção de Dados (LGPD) no Brasil e o Regulamento Geral sobre a Proteção de Dados (RGPD) na União Europeia. +

+
+ +
+

2. Informações que Coletamos

+

Coletamos informações para fornecer melhores serviços a todos os nossos usuários:

+
    +
  • + Informações de Conta: Quando você se registra, coletamos seu nome, endereço de e-mail e credenciais de autenticação através do serviço Better Auth. +
  • +
  • + Informações de Pagamento: Para assinaturas Pro, os pagamentos são processados pelo Stripe. A Algoria não armazena dados sensíveis de cartão de crédito; apenas recebemos a confirmação do pagamento e o ID da transação. +
  • +
  • + Dados de Uso e Progresso: Coletamos dados sobre seu progresso nos cursos, problemas resolvidos e interações com a plataforma para personalizar sua experiência de aprendizado. +
  • +
  • + Logs e Metadados: Endereço IP, tipo de navegador, sistema operacional e páginas visitadas podem ser coletados para fins de segurança e diagnóstico. +
  • +
+
+ +
+

3. Como Usamos seus Dados

+

Utilizamos as informações coletadas para as seguintes finalidades:

+
    +
  • Prover, operar e manter nossa plataforma.
  • +
  • Processar transações e gerenciar sua assinatura Pro.
  • +
  • Melhorar e personalizar sua experiência de aprendizado.
  • +
  • Comunicar atualizações de produtos, novidades e suporte técnico.
  • +
  • Prevenir fraudes e garantir a segurança do sistema.
  • +
+
+ +
+

4. Compartilhamento de Informações

+

+ Não vendemos seus dados pessoais a terceiros. Compartilhamos informações apenas com provedores de serviços essenciais: +

+
    +
  • Stripe: Para processamento de pagamentos e gestão de faturamento.
  • +
  • PostHog: Para análise de uso da plataforma (analytics), visando melhorias na interface e experiência do usuário.
  • +
  • Provedores de Infraestrutura: Serviços de hospedagem de banco de dados e servidores necessários para o funcionamento da aplicação.
  • +
+
+ +
+

5. Seus Direitos (LGPD/RGPD)

+

Como titular dos dados, você possui direitos garantidos por lei, incluindo:

+
    +
  • Acesso: Solicitar uma cópia dos seus dados pessoais que processamos.
  • +
  • Retificação: Corrigir dados incompletos, inexatos ou desatualizados.
  • +
  • Exclusão: Solicitar a eliminação dos seus dados pessoais (sujeito a retenções legais obrigatórias, como dados fiscais).
  • +
  • Portabilidade: Solicitar a transferência dos seus dados para outro fornecedor de serviço.
  • +
  • Revogação do Consentimento: Retirar seu consentimento para o processamento de dados a qualquer momento.
  • +
+
+ +
+

6. Segurança dos Dados

+

+ Implementamos medidas técnicas e organizacionais avançadas para proteger seus dados contra acesso não autorizado, alteração, divulgação ou destruição. Isso inclui criptografia de dados em repouso e em trânsito (SSL/TLS). +

+
+ +
+

7. Retenção de Dados

+

+ Mantemos seus dados pessoais apenas pelo tempo necessário para cumprir as finalidades para as quais foram coletados, inclusive para fins de cumprimento de obrigações legais, contratuais, de prestação de contas ou requisição de autoridades competentes. +

+
+ +
+

Contato de Privacidade

+

+ Para exercer seus direitos ou tirar dúvidas sobre como tratamos seus dados, entre em contato com nosso Encarregado de Proteção de Dados através do suporte oficial da plataforma. +

+
+
+
+ ); +} + diff --git a/app/legal/refunds/page.tsx b/app/legal/refunds/page.tsx new file mode 100644 index 0000000..1645abc --- /dev/null +++ b/app/legal/refunds/page.tsx @@ -0,0 +1,87 @@ +import Link from 'next/link'; +import type { Metadata } from 'next'; +import { ArrowLeft } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { buildPublicMetadata } from '@/lib/seo/build-metadata'; + +export const metadata: Metadata = buildPublicMetadata({ + title: 'Reembolsos', + description: 'Política de cancelamento e reembolso da Algoria.', + pathname: '/legal/refunds', + keywords: ['reembolso', 'estorno', 'cancelamento', 'Stripe', 'Algoria'], +}); + +export default function RefundsPage() { + return ( +
+ + +
+

+ Política de Reembolso +

+

Última atualização: 07 de Maio de 2026

+
+ +
+
+

1. Direito de Arrependimento

+

+ De acordo com o Código de Defesa do Consumidor (Brasil), você tem o direito de desistir da sua assinatura no prazo de 7 (sete) dias corridos a partir da data da contratação. Caso o pedido de cancelamento seja feito dentro deste prazo, o valor integral pago será reembolsado. +

+
+ +
+

2. Cancelamento de Assinatura

+

+ As assinaturas da Algoria Pro podem ser canceladas a qualquer momento através do painel de controle do usuário (gerenciado via Stripe Customer Portal). +

+
    +
  • Ao cancelar, você continuará tendo acesso ao conteúdo Pro até o final do período de faturamento atual.
  • +
  • Não oferecemos reembolsos proporcionais para cancelamentos feitos após o período de 7 dias de arrependimento, exceto em casos de falhas técnicas graves comprovadas.
  • +
+
+ +
+

3. Reembolsos por Falhas Técnicas

+

+ Se você enfrentar problemas técnicos persistentes que impeçam totalmente o uso da plataforma por um período superior a 48 horas úteis, e nossa equipe de suporte não conseguir resolver o problema, você poderá ter direito a um reembolso parcial ou total, analisado caso a caso. +

+
+ +
+

4. Processo de Reembolso

+

+ Para solicitar um reembolso dentro das condições acima, você deve enviar um e-mail para o suporte oficial informando: +

+
    +
  • O endereço de e-mail associado à sua conta Algoria.
  • +
  • O ID da transação ou número da fatura enviada pelo Stripe.
  • +
  • O motivo detalhado da solicitação.
  • +
+

+ Após a aprovação, o estorno será processado pelo Stripe e o crédito aparecerá na sua fatura de cartão de crédito de acordo com os prazos da sua operadora (geralmente entre 5 a 10 dias úteis). +

+
+ +
+

5. Cobranças Indevidas

+

+ Caso identifique qualquer cobrança que considere indevida, entre em contato conosco imediatamente antes de abrir uma disputa (chargeback) junto ao seu cartão de crédito. Disputas abertas sem contato prévio resultam no bloqueio imediato e permanente da conta na plataforma por questões de segurança e prevenção de fraude. +

+
+ +
+

Dúvidas sobre Faturamento?

+

+ Nossa equipe está disponível para ajudar com qualquer questão financeira. Use o link de suporte no rodapé para iniciar uma conversa. +

+
+
+
+ ); +} + diff --git a/app/legal/terms/page.tsx b/app/legal/terms/page.tsx new file mode 100644 index 0000000..7c16593 --- /dev/null +++ b/app/legal/terms/page.tsx @@ -0,0 +1,207 @@ +import { ArrowLeft } from "lucide-react"; +import type { Metadata } from "next"; +import Link from "next/link"; + +import { Button } from "@/components/ui/button"; +import { buildPublicMetadata } from "@/lib/seo/build-metadata"; + +export const metadata: Metadata = buildPublicMetadata({ + title: "Termos de Uso", + description: "Termos e condições de uso da plataforma Algoria.", + pathname: "/legal/terms", + keywords: ["termos de uso", "condições", "legal", "Algoria"], +}); + +export default function TermsPage() { + return ( +
+ + +
+

+ Termos de Uso +

+

+ Última atualização: 07 de Maio de 2026 +

+
+ +
+
+

1. Aceitação dos Termos

+

+ Ao acessar e utilizar a plataforma Algoria ("Serviço"), você + concorda em cumprir e estar vinculado aos seguintes Termos de Uso. + Se você não concordar com qualquer parte destes termos, não deverá + utilizar o Serviço. Estes termos aplicam-se a todos os visitantes, + usuários e outros que acessam ou usam o Serviço. +

+
+ +
+

2. Elegibilidade e Conta

+

+ Para utilizar certas funcionalidades da Algoria, você deve criar uma + conta. Ao fazê-lo, você garante que: +

+
    +
  • As informações fornecidas são precisas, completas e atuais.
  • +
  • + Você é responsável por manter a confidencialidade da sua senha e + conta. +
  • +
  • + Você notificará imediatamente a Algoria sobre qualquer violação de + segurança ou uso não autorizado de sua conta. +
  • +
+

+ Reservamo-nos o direito de recusar serviço, encerrar contas ou + remover conteúdo a nosso critério exclusivo. +

+
+ +
+

3. Propriedade Intelectual

+

+ O Serviço e seu conteúdo original (excluindo conteúdo fornecido + pelos usuários), recursos e funcionalidades são e continuarão sendo + propriedade exclusiva da Algoria e de seus licenciadores. O conteúdo + é protegido por direitos autorais, marcas registradas e outras leis + de propriedade intelectual. +

+

+ É terminantemente proibido: +

+
    +
  • + Copiar, modificar ou distribuir o conteúdo da plataforma para fins + comerciais ou recreativos fora do escopo do aprendizado pessoal. +
  • +
  • + Realizar engenharia reversa, descompilar ou tentar extrair o + código-fonte do Serviço. +
  • +
  • + Utilizar "web scraping", "crawlers" ou qualquer método + automatizado para extrair dados ou conteúdo de forma massiva. +
  • +
+
+ +
+

4. Assinaturas e Pagamentos

+

+ Algumas partes do Serviço são faturadas em base de assinatura + ("Algoria Pro"). Você será faturado antecipadamente em uma base + recorrente e periódica (como mensal ou anual). +

+
    +
  • + Os pagamentos são processados via Stripe. Não + armazenamos os dados do seu cartão de crédito em nossos + servidores. +
  • +
  • + A renovação ocorre automaticamente, a menos que o cancelamento + seja solicitado antes da data de renovação. +
  • +
  • + Reservamo-nos o direito de alterar as taxas de assinatura a + qualquer momento, mediante aviso prévio razoável através da + plataforma ou e-mail. +
  • +
+
+ +
+

+ 5. Conduta do Usuário e Segurança +

+

+ Você concorda em não usar o Serviço para qualquer finalidade ilegal + ou proibida por estes Termos. Você não deve: +

+
    +
  • + Tentar obter acesso não autorizado a qualquer parte do Serviço ou + sistemas relacionados. +
  • +
  • + Interferir na segurança ou abusar dos recursos do sistema, rede ou + serviços da plataforma. +
  • +
  • + Compartilhar sua conta com terceiros ("compartilhamento de + login"), o que resultará no banimento imediato da conta sem + direito a reembolso. +
  • +
+
+ +
+

6. Isenção de Garantias

+

+ O uso do Serviço é por sua conta e risco. O Serviço é fornecido + "COMO ESTÁ" e "CONFORME DISPONÍVEL". A Algoria não garante que (i) o + Serviço funcionará de forma ininterrupta, segura ou disponível em + qualquer momento ou local específico; (ii) quaisquer erros ou + defeitos serão corrigidos; ou (iii) os resultados do uso do Serviço + atenderão aos seus requisitos profissionais ou acadêmicos. +

+
+ +
+

+ 7. Limitação de Responsabilidade +

+

+ Em nenhum caso a Algoria, seus diretores ou funcionários serão + responsáveis por quaisquer danos indiretos, incidentais, especiais, + consequenciais ou punitivos, incluindo, sem limitação, perda de + lucros, dados, uso, fundo de comércio ou outras perdas intangíveis, + resultantes do seu acesso ou uso do Serviço. +

+
+ +
+

8. Alterações nos Termos

+

+ Reservamo-nos o direito, a nosso exclusivo critério, de modificar ou + substituir estes Termos a qualquer momento. Se uma revisão for + material, tentaremos fornecer um aviso com pelo menos 30 dias de + antecedência antes que quaisquer novos termos entrem em vigor. +

+
+ +
+

9. Legislação Aplicável e Foro

+

+ Estes Termos serão regidos e interpretados de acordo com as leis do + Brasil, sem levar em conta suas disposições sobre conflitos de leis. + Qualquer disputa decorrente destes termos será resolvida no foro da + comarca da sede da empresa administradora da Algoria. +

+
+ +
+

Dúvidas?

+

+ Se você tiver alguma dúvida sobre estes Termos, entre em contato + através do nosso canal de suporte oficial ou pelo e-mail listado no + rodapé da aplicação. +

+
+
+
+ ); +} diff --git a/app/pricing/checkout-success-analytics.tsx b/app/pricing/checkout-success-analytics.tsx new file mode 100644 index 0000000..e068034 --- /dev/null +++ b/app/pricing/checkout-success-analytics.tsx @@ -0,0 +1,24 @@ +'use client'; + +import { useSearchParams } from 'next/navigation'; +import { Suspense, useEffect } from 'react'; + +import { analyticsCapture } from '@/components/analytics/posthog-provider'; + +function Inner() { + const sp = useSearchParams(); + useEffect(() => { + if (sp.get('checkout') === 'success') { + analyticsCapture('subscription_active'); + } + }, [sp]); + return null; +} + +export function CheckoutSuccessAnalytics() { + return ( + + + + ); +} diff --git a/app/pricing/page.tsx b/app/pricing/page.tsx new file mode 100644 index 0000000..b540045 --- /dev/null +++ b/app/pricing/page.tsx @@ -0,0 +1,232 @@ +import { ArrowLeft } from "lucide-react"; +import type { Metadata } from "next"; +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 { Button } from "@/components/ui/button"; +import { auth } from "@/lib/auth"; +import { userHasPro } from "@/lib/billing/entitlements"; +import { + checkoutAvailable, + formatFreeTierPrice, + formatPricingDisplay, +} from "@/lib/billing/pricing-env"; +import { buildPublicMetadata } from "@/lib/seo/build-metadata"; +import { headers } from "next/headers"; +import { CheckoutSuccessAnalytics } from "./checkout-success-analytics"; + +export const metadata: Metadata = buildPublicMetadata({ + title: "Preços e Algoria Pro", + description: + "Compara o plano gratuito com a subscrição Pro: catálogo completo, sync de progresso e traces de execução.", + pathname: "/pricing", + keywords: ["preços", "Pro", "subscrição", "Algoria"], +}); + +export default async function PricingPage() { + const { monthly, yearlyNote } = formatPricingDisplay(); + const canPay = checkoutAvailable(); + const session = await auth.api.getSession({ headers: await headers() }); + const hasPro = await userHasPro(session?.user?.id); + + return ( +
+ + +
+ +
+

+ Monetização transparente +

+

+ Planos +

+

+ Dez problemas e funcionalidades essenciais gratuitos. Pro + desbloqueia o catálogo completo, sincronização de progresso e + investimento contínuo em conteúdo. +

+
+ +
+
+
+

+ Free +

+

+ {formatFreeTierPrice().replace(",00", "")} +

+

+ Acesso Vitalício +

+
+ +
+

+ Ideal para experimentares o método Algoria e dominares os + fundamentos. +

+
    + {[ + "10 Problemas Hero (Free)", + "Conceitos de Engenharia Públicos", + "Progresso Local (Browser)", + "Acesso ao Changelog", + ].map((perk) => ( +
  • +
    {perk} +
  • + ))} +
+
+ + +
+ +
+
+ Recomendado +
+ +
+

+ Pro +

+
+

+ {monthly.replace("/mês", "")} +

+ + / mês + +
+

+ {yearlyNote} +

+
+ +
+

+ Desbloqueia a experiência completa e acelera a tua progressão + técnica. +

+
    + {[ + "Todo o Catálogo (Problemas Pro)", + "Player Interativo Linha-a-Linha", + "Traces de Execução e Estado", + "Sincronização de Conta & Cloud", + "Acesso Antecipado a Novos Cursos", + ].map((perk) => ( +
  • +
    +
    +
    + {perk} +
  • + ))} +
+
+ +
+ {hasPro ? ( +
+
+ + + + + Assinatura Ativa +
+ +
+ ) : canPay ? ( +
+ {session ? ( + + ) : ( + + )} +

+ Pagamento processado de forma segura e encriptada +

+
+ ) : ( +
+

+ Módulo de Pagamentos em Manutenção +

+
+ )} +
+
+
+ +
+

+ Perguntas rápidas +

+
+
+
+ O que acontece ao meu progresso local? +
+
+ Ao iniciar sessão, o site tenta fundir o que tens no browser com + o que está na conta (última linha lida, problemas visitados). + Faz backup ocasional em JSON pelo catálogo. +
+
+
+
+ Posso pedir reembolso? +
+
+ Vê a página{" "} + + Política de reembolso + + . +
+
+
+
+
+
+ ); +} diff --git a/app/problems/[slug]/[solution]/page.tsx b/app/problems/[slug]/[solution]/page.tsx index be91600..846b18d 100644 --- a/app/problems/[slug]/[solution]/page.tsx +++ b/app/problems/[slug]/[solution]/page.tsx @@ -1,9 +1,11 @@ import Link from 'next/link'; import type { Metadata } from 'next'; +import { headers } from 'next/headers'; import { notFound } from 'next/navigation'; import { Suspense } from 'react'; import { ArrowLeft, ArrowRight } from 'lucide-react'; +import { UpgradePrompt } from '@/components/billing/upgrade-prompt'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; @@ -14,6 +16,9 @@ import { DifficultyBadge } from '@/components/catalog/difficulty-badge'; import { JsonLdScript } from '@/components/seo/json-ld'; import { SolutionLanguageSelect } from '@/components/solution/solution-language-select'; import { SolutionVisitTracker } from '@/components/solution/solution-visit-tracker'; +import { auth } from '@/lib/auth'; +import { userHasPro } from '@/lib/billing/entitlements'; +import { getProblemAccess, isProblemUnlockedForUser } from '@/lib/billing/tiering'; import { getAllProblems, getConcept, getProblem } from '@/lib/content/loader'; import { LANGUAGE_LABEL_PT, @@ -85,6 +90,11 @@ export default async function SolutionPage({ const solution = problem.solutions.find((s) => s.meta.slug === solutionSlug); if (!solution) notFound(); + const session = await auth.api.getSession({ headers: await headers() }); + const hasPro = await userHasPro(session?.user?.id); + const access = getProblemAccess(problem.meta); + const unlocked = isProblemUnlockedForUser(access, hasPro); + const availableLanguages = LANGUAGE_ORDER_FOR_UI.filter( (l) => Boolean(solution.codeByLanguage[l]?.trim()), ) as Language[]; @@ -127,6 +137,21 @@ export default async function SolutionPage({ introPlain || `Solução com explicação linha-a-linha para «${problem.meta.title}» (${solution.meta.name}). Complexidade ${solution.meta.complexity.time} tempo, ${solution.meta.complexity.space} espaço.`; + if (!unlocked) { + return ( +
+ + +
+ ); + } + return (
- - {problem.meta.title} - +

{solution.meta.name}

diff --git a/app/problems/[slug]/page.tsx b/app/problems/[slug]/page.tsx index 204f2cd..3030230 100644 --- a/app/problems/[slug]/page.tsx +++ b/app/problems/[slug]/page.tsx @@ -1,9 +1,12 @@ import Link from 'next/link'; import type { Metadata } from 'next'; +import { headers } from 'next/headers'; import { notFound } from 'next/navigation'; -import { ArrowRight, BookOpen } from 'lucide-react'; +import { ArrowLeft, ArrowRight, BookOpen } from 'lucide-react'; +import { UpgradePrompt } from '@/components/billing/upgrade-prompt'; import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Separator } from '@/components/ui/separator'; import { JsonLdScript } from '@/components/seo/json-ld'; @@ -11,8 +14,12 @@ import { DifficultyBadge } from '@/components/catalog/difficulty-badge'; import { ComplexityBadge } from '@/components/complexity/complexity-badge'; import { ProblemStudyCompletionBar } from '@/components/problem/problem-study-completion-bar'; import { ProblemStudyTabs } from '@/components/problem/problem-study-tabs'; +import { ContentNavigation } from '@/components/layout/content-navigation'; import { ProblemVisitTracker } from '@/components/problem/problem-visit-tracker'; -import { getAllProblemSlugs, getProblem, getConcept } from '@/lib/content/loader'; +import { auth } from '@/lib/auth'; +import { userHasPro } from '@/lib/billing/entitlements'; +import { getProblemAccess, isProblemUnlockedForUser } from '@/lib/billing/tiering'; +import { getAllProblemSlugs, getProblem, getConcept, getAdjacentProblems } from '@/lib/content/loader'; import { buildPublicMetadata } from '@/lib/seo/build-metadata'; import { learningResourceJsonLd } from '@/lib/seo/structured-data'; import { stripHtmlLoose } from '@/lib/seo/strip-html'; @@ -59,6 +66,12 @@ export default async function ProblemPage({ params }: { params: Promise const problem = await getProblem(slug); if (!problem) notFound(); + const session = await auth.api.getSession({ headers: await headers() }); + const hasPro = await userHasPro(session?.user?.id); + const access = getProblemAccess(problem.meta); + const strategiesLocked = !isProblemUnlockedForUser(access, hasPro); + const adjacent = await getAdjacentProblems(slug); + const prereqs = ( await Promise.all(problem.meta.prerequisites.map((s) => getConcept(s).then((c) => (c ? { slug: s, title: c.meta.title } : null)))) ).filter((c): c is { slug: string; title: string } => c !== null); @@ -184,9 +197,9 @@ export default async function ProblemPage({ params }: { params: Promise /> - - ← Todos os problemas - +
@@ -201,9 +214,28 @@ export default async function ProblemPage({ params }: { params: Promise - + + ) : ( + strategies + ) + } + /> + +
); } diff --git a/app/profile/actions.ts b/app/profile/actions.ts new file mode 100644 index 0000000..395e4ba --- /dev/null +++ b/app/profile/actions.ts @@ -0,0 +1,72 @@ +'use server'; + +import { auth } from '@/lib/auth'; +import { db } from '@/lib/db'; +import { user, userProfile } from '@/lib/db/schema'; +import { eq } from 'drizzle-orm'; +import { revalidatePath } from 'next/cache'; +import { headers } from 'next/headers'; +import { redirect } from 'next/navigation'; + +export async function deleteAccount() { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.user) { + throw new Error('Não autenticado'); + } + + // A cascata no schema irá apagar sessões, progresso, subscrições, etc. + await db.delete(user).where(eq(user.id, session.user.id)); + + revalidatePath('/', 'layout'); + redirect('/'); +} + +export async function updateProfile(formData: FormData) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.user) { + throw new Error('Não autenticado'); + } + + const userId = session.user.id; + const headline = formData.get('headline') as string; + const bio = formData.get('bio') as string; + const githubUrl = formData.get('githubUrl') as string; + const linkedinUrl = formData.get('linkedinUrl') as string; + + const techString = formData.get('technologies') as string; + const technologies = techString ? techString.split(',').map(s => s.trim()).filter(Boolean) : []; + + const experiences = formData.get('experiences') as string; + const projects = formData.get('projects') as string; + + const existingProfile = await db.select().from(userProfile).where(eq(userProfile.userId, userId)).limit(1); + + if (existingProfile.length > 0) { + await db.update(userProfile) + .set({ + headline, + bio, + githubUrl, + linkedinUrl, + technologies, + experiences, + projects, + updatedAt: new Date() + }) + .where(eq(userProfile.userId, userId)); + } else { + await db.insert(userProfile).values({ + userId, + headline, + bio, + githubUrl, + linkedinUrl, + technologies, + experiences, + projects, + }); + } + + revalidatePath('/profile'); + revalidatePath(`/user/${userId}`); +} diff --git a/app/profile/github-actions.ts b/app/profile/github-actions.ts new file mode 100644 index 0000000..b9e2d26 --- /dev/null +++ b/app/profile/github-actions.ts @@ -0,0 +1,31 @@ +"use server"; + +import { fetchGithubRepos } from "@/lib/github"; +import { auth } from "@/lib/auth"; +import { headers } from "next/headers"; + +export async function getGithubProjectsAction(githubUrl: string) { + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.user) throw new Error("Não autenticado"); + + const username = githubUrl.split("/").pop(); + if (!username) throw new Error("URL do GitHub inválido"); + + try { + const repos = await fetchGithubRepos(username); + return repos.map(repo => ({ + title: repo.name, + description: repo.description || "", + deployUrl: repo.homepage || "", + githubUrl: repo.html_url, + technologies: Array.from(new Set([ + ...(repo.language ? [repo.language] : []), + ...(repo.topics || []) + ])), + imageUrl: "", + })); + } catch (err) { + const error = err as Error; + throw new Error(error.message || "Erro ao importar do GitHub"); + } +} diff --git a/app/profile/page.tsx b/app/profile/page.tsx new file mode 100644 index 0000000..662ad51 --- /dev/null +++ b/app/profile/page.tsx @@ -0,0 +1,366 @@ +import { eq } from "drizzle-orm"; +import { ArrowLeft, Trash2, User } from "lucide-react"; +import { headers } from "next/headers"; +import Link from "next/link"; +import { redirect } from "next/navigation"; + +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 { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { auth } from "@/lib/auth"; +import { db } from "@/lib/db"; +import { technicalAssessmentResults, subscription, userProfile, userProgress } from "@/lib/db/schema"; +import { desc } from "drizzle-orm"; + +import { ProgressBlobSchema } from "@/lib/progress/local-progress-schema"; +import { buildPublicMetadata } from "@/lib/seo/build-metadata"; +import Image from "next/image"; +import { ProfileActionsClient } from "@/components/profile/profile-actions-client"; +import { ProfileAssessmentVisibilityToggle } from "@/components/profile/profile-assessment-visibility-toggle"; +import { AssessmentCard } from "@/components/profile/public/assessment-card"; +import { cn } from "@/lib/utils"; + + +export const metadata = buildPublicMetadata({ + title: "Meu Perfil", + description: + "Gerencia o teu perfil, progresso de estudo e subscrições na Algoria.", + pathname: "/profile", +}); + +export default async function ProfilePage() { + const session = await auth.api.getSession({ headers: await headers() }); + + if (!session?.user) { + redirect("/auth/sign-in"); + } + + const { user } = session; + + // Buscar progressos e subscrição + const [progressRows, subRows, profileRows, assessmentRows] = await Promise.all([ + db + .select() + .from(userProgress) + .where(eq(userProgress.userId, user.id)) + .limit(1), + db + .select() + .from(subscription) + .where(eq(subscription.userId, user.id)) + .limit(1), + db + .select() + .from(userProfile) + .where(eq(userProfile.userId, user.id)) + .limit(1), + db + .select() + .from(technicalAssessmentResults) + .where(eq(technicalAssessmentResults.userId, user.id)) + .orderBy(desc(technicalAssessmentResults.completedAt)), + ]); + + + const activeSub = subRows[0]; + const isPro = activeSub?.status === "active"; + + // Processar o progresso + let completedProblems = 0; + let inProgressProblems = 0; + let solutionsOpened = 0; + + if (progressRows[0]) { + try { + const data = JSON.parse(progressRows[0].data); + const blob = ProgressBlobSchema.parse(data); + const problems = Object.values(blob.problems); + + completedProblems = problems.filter((p) => !!p.markedCompleteAt).length; + inProgressProblems = problems.filter((p) => !p.markedCompleteAt).length; + solutionsOpened = problems.reduce( + (acc, p) => acc + (p.openedSolutions?.length || 0), + 0, + ); + } catch { + // Ignorar erros de parse se houver + } + } + + const initials = + user.name?.substring(0, 2).toUpperCase() || + user.email?.substring(0, 2).toUpperCase() || + "U"; + + return ( +
+
+
+
+
+ +
+ + +
+
+
+
+
+ {user.image ? ( + {user.name} + ) : ( + initials + )} +
+
+
+

+ {user.name} +

+
+ + {user.email} + + + + Desde {new Date(user.createdAt).getFullYear()} + +
+
+ + {isPro ? "ALGORIA PRO" : "ALGORIA FREE"} + + + +
+
+
+
+ +
+ +
+
+
+ + {/* METRICS DASHBOARD */} +
+
+ + Problemas Resolvidos + +
+ + {completedProblems} + + + Unidades + +
+
+
+
+
+
+ + Em Progresso + + + {inProgressProblems} + +
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ ))} +
+
+
+ + Soluções Lidas + + + {solutionsOpened} + + + Insights de Engenharia + +
+
+
+ + Plano Atual + +

+ {isPro ? "PRO ACCESS" : "FREE TIER"} +

+
+ + {isPro ? "Ver Benefícios" : "Upgrade para Pro →"} + +
+
+ + {/* TECHNICAL ASSESSMENTS SECTION */} +
+
+
+

Resultados de Assessments

+
+ + {assessmentRows.length > 0 ? ( +
+ {assessmentRows.map((result) => ( +
+
+
+ +
+
+ +
+ +
+
+ + + ))} +
+ ) : ( +
+

+ Ainda não completaste nenhum assessment técnico. +

+ +
+ )} +
+ + {/* MAIN PROFILE FORM */} + +
+ + +
+
+ +
+
+ + Personalizar Perfil Profissional + + + Este formulário controla como o mundo te vê na Algoria. + +
+
+
+ + + +
+ + {/* Danger Zone */} + + + + Danger Zone + + + Ações irreversiveis sobre a tua conta. + + + +
+

Excluir Conta

+

+ Ao excluir a conta, o teu progresso, subscrição ativa e acesso + serão perdidos permanentemente. Esta ação não pode ser + desfeita. +

+
+ +
+
+
+
+
+ ); +} diff --git a/app/sitemap.ts b/app/sitemap.ts index a29be3b..30a3a5f 100644 --- a/app/sitemap.ts +++ b/app/sitemap.ts @@ -25,6 +25,11 @@ export default async function sitemap(): Promise { { url: `${BASE_URL}/problems`, lastModified: now, priority: 0.9, changeFrequency: 'daily' }, { url: `${BASE_URL}/tracks`, lastModified: now, priority: 0.88, changeFrequency: 'weekly' }, { url: `${BASE_URL}/changelog`, lastModified: now, priority: 0.72, changeFrequency: 'weekly' }, + { url: `${BASE_URL}/pricing`, lastModified: now, priority: 0.86, changeFrequency: 'weekly' }, + { url: `${BASE_URL}/legal/terms`, lastModified: now, priority: 0.45, changeFrequency: 'yearly' }, + { url: `${BASE_URL}/legal/privacy`, lastModified: now, priority: 0.45, changeFrequency: 'yearly' }, + { url: `${BASE_URL}/legal/cookies`, lastModified: now, priority: 0.4, changeFrequency: 'yearly' }, + { url: `${BASE_URL}/legal/refunds`, lastModified: now, priority: 0.4, changeFrequency: 'yearly' }, { url: `${BASE_URL}/concepts`, lastModified: now, priority: 0.85, changeFrequency: 'weekly' }, { url: `${BASE_URL}/interview-en`, lastModified: now, priority: 0.82, changeFrequency: 'weekly' }, { url: `${BASE_URL}/engineering-work`, lastModified: now, priority: 0.81, changeFrequency: 'weekly' }, diff --git a/app/tests/[track]/[slug]/page.tsx b/app/tests/[track]/[slug]/page.tsx new file mode 100644 index 0000000..4b71888 --- /dev/null +++ b/app/tests/[track]/[slug]/page.tsx @@ -0,0 +1,48 @@ +import { notFound } from "next/navigation"; +import type { Metadata } from "next"; + +import { TestClient } from "@/components/tests/test-client"; +import { getTestBySlug } from "@/lib/content/tests-data"; +import { buildPublicMetadata } from "@/lib/seo/build-metadata"; + +interface Params { + track: string; + slug: string; +} + +export async function generateMetadata({ + params, +}: { + params: Promise; +}): Promise { + const { slug } = await params; + const test = getTestBySlug(slug); + + if (!test) return {}; + + return buildPublicMetadata({ + title: `${test.title} · Testes Técnicos`, + description: test.description, + pathname: `/tests/run/${slug}`, + }); +} + +export default async function TestExecutionPage({ + params, +}: { + params: Promise; +}) { + const { slug } = await params; + + const test = getTestBySlug(slug); + + if (!test) { + notFound(); + } + + return ( +
+ +
+ ); +} diff --git a/app/tests/[track]/page.tsx b/app/tests/[track]/page.tsx new file mode 100644 index 0000000..08a3c66 --- /dev/null +++ b/app/tests/[track]/page.tsx @@ -0,0 +1,262 @@ +import { + ArrowRight, + ChevronLeft, + Clock, + Filter, + GraduationCap, +} from "lucide-react"; +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { getTestsByTrack, Track } from "@/lib/content/tests-data"; +import { buildPublicMetadata } from "@/lib/seo/build-metadata"; +import { cn } from "@/lib/utils"; + +interface Params { + track: string; +} + +interface SearchParams { + level?: string; + topic?: string; + difficulty?: string; +} + +export async function generateMetadata({ + params, +}: { + params: Promise; +}): Promise { + const { track } = await params; + const title = track.charAt(0).toUpperCase() + track.slice(1); + + return buildPublicMetadata({ + title: `Simulados ${title} · Testes Técnicos`, + description: `Lista de testes técnicos práticos para a trilha de ${title}.`, + pathname: `/tests/${track}`, + }); +} + +export default async function TrackTestsPage({ + params, + searchParams, +}: { + params: Promise; + searchParams: Promise; +}) { + const { track } = await params; + const { level, topic, difficulty } = await searchParams; + const validTracks: Track[] = ["frontend", "backend", "devops"]; + + if (!validTracks.includes(track as Track)) { + notFound(); + } + + let tests = getTestsByTrack(track as Track); + + // Extract unique levels, topics and difficulties for the track + const availableLevels = Array.from(new Set(tests.map((t) => t.level))); + const availableTopics = Array.from(new Set(tests.map((t) => t.topic))); + const availableDifficulties = Array.from(new Set(tests.map((t) => t.difficulty))); + + // Filter tests based on searchParams + if (level) { + tests = tests.filter((t) => t.level === level); + } + if (topic) { + tests = tests.filter((t) => t.topic === topic); + } + if (difficulty) { + tests = tests.filter((t) => t.difficulty === difficulty); + } + + const trackTitle = track.charAt(0).toUpperCase() + track.slice(1); + + return ( +
+
+
+ +
+ +
+

+ Simulados {trackTitle} +

+

+ Explora a nossa coleção de testes técnicos para {trackTitle}. Cada + simulado foca-se em áreas específicas da engenharia moderna para + garantir uma avaliação completa. +

+
+ + {/* FILTERS UI */} +
+
+ Filtros: +
+ +
+ {/* Level Filter */} +
+ + Sénioridade: + +
+ + Todos + + {availableLevels.map((l) => ( + + {l} + + ))} +
+
+ + {/* Topic Filter */} +
+ + Tópico: + +
+ + Todos + + {availableTopics.map((t) => ( + + {t} + + ))} +
+
+ + {/* Difficulty Filter */} +
+ + Dificuldade: + +
+ + Todos + + {availableDifficulties.map((d) => ( + + {d} + + ))} +
+
+
+ + {(level || topic || difficulty) && ( + + Limpar Filtros + + )} +
+ + + {tests.length > 0 ? ( +
+ {tests.map((test) => ( +
+
+
+
+ + {test.level} + + + {test.difficulty} + +
+ +
+ {test.timeLimitMinutes}m +
+
+ +

+ {test.title} +

+ +
+ Tópico: {test.topic} +
+ +

+ {test.description} +

+
+ + +
+ ))} +
+ ) : ( +
+

+ Nenhum simulado encontrado +

+

+ Tenta ajustar os teus filtros para encontrar o que procuras. +

+
+ )} +
+
+ ); +} diff --git a/app/tests/page.tsx b/app/tests/page.tsx new file mode 100644 index 0000000..fb4c9d6 --- /dev/null +++ b/app/tests/page.tsx @@ -0,0 +1,89 @@ +import { ArrowRight, Code2, Layout, Database, Server } from "lucide-react"; +import type { Metadata } from "next"; +import Link from "next/link"; + +import { buildPublicMetadata } from "@/lib/seo/build-metadata"; + +export const metadata: Metadata = buildPublicMetadata({ + title: "Testes Técnicos de Engenharia", + description: + "Simulados reais para vagas de Frontend, Backend e DevOps. Escolhe a tua trilha e começa a tua avaliação.", + pathname: "/tests", +}); + +export default function TechnicalTestsIndexPage() { + const tracks = [ + { + id: "frontend", + title: "Frontend", + description: "React, Next.js, Performance, Acessibilidade e Ecossistema Web.", + icon: Layout, + }, + { + id: "backend", + title: "Backend", + description: "Node.js, APIs, Bases de Dados, Segurança e Arquitetura.", + icon: Database, + }, + { + id: "devops", + title: "DevOps", + description: "Docker, CI/CD, Cloud, Monitorização e Infraestrutura.", + icon: Server, + }, + ]; + + return ( +
+
+
+
+
+
+ +
+
+

+ Assessment +

+

+ Escolhe a tua Trilha +

+

+ Seleciona a tua área de especialização para ver os simulados disponíveis. + Cada trilha contém testes de diferentes níveis e tópicos específicos. +

+
+
+
+
+ +
+ {tracks.map((track) => ( + +
+
+ +
+

+ {track.title} +

+

+ {track.description} +

+
+ +
+ Ver Simulados +
+ + ))} +
+
+
+ ); +} diff --git a/app/tracks/[slug]/page.tsx b/app/tracks/[slug]/page.tsx index a97b110..693dd5c 100644 --- a/app/tracks/[slug]/page.tsx +++ b/app/tracks/[slug]/page.tsx @@ -1,12 +1,13 @@ import Link from 'next/link'; import type { Metadata } from 'next'; import { notFound } from 'next/navigation'; -import { Clock } from 'lucide-react'; +import { ArrowLeft, Clock } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { DifficultyBadge } from '@/components/catalog/difficulty-badge'; import { ProblemStatusBadge } from '@/components/catalog/problem-status-badge'; +import { Button } from '@/components/ui/button'; import { catalogModelsFromProblems } from '@/lib/catalog/problem-card-model'; import { categoryLabelPt } from '@/lib/catalog/category-labels'; import { getAllProblems } from '@/lib/content/loader'; @@ -48,12 +49,9 @@ export default async function TrackDetailPage({ params }: { params: Promise
- - ← Trilhos - +

{track.title}

diff --git a/app/user/[id]/page.tsx b/app/user/[id]/page.tsx new file mode 100644 index 0000000..09ceb65 --- /dev/null +++ b/app/user/[id]/page.tsx @@ -0,0 +1,171 @@ +import { and, desc, eq } from "drizzle-orm"; +import { ArrowLeft } from "lucide-react"; +import Link from "next/link"; +import { notFound } from "next/navigation"; + +import type { + Experience, + Project, +} from "@/components/profile/profile-sections"; +import { ExperienceSection } from "@/components/profile/public/experience-section"; +import { ProfileDashboard } from "@/components/profile/public/profile-dashboard"; +import { ProfileHeader } from "@/components/profile/public/profile-header"; +import { ProjectsSection } from "@/components/profile/public/projects-section"; +import { Button } from "@/components/ui/button"; +import { db } from "@/lib/db"; +import { user, userProfile, userProgress, technicalAssessmentResults } from "@/lib/db/schema"; +import { + calculateTotalExperienceMonths, + formatExperienceString, + processUserProgress, +} from "@/lib/profile/profile-utils"; +import { buildPublicMetadata } from "@/lib/seo/build-metadata"; +import { AssessmentCard } from "@/components/profile/public/assessment-card"; + + +interface PublicProfileProps { + params: Promise<{ id: string }>; +} + +export async function generateMetadata({ params }: PublicProfileProps) { + const { id } = await params; + const userData = await db.select().from(user).where(eq(user.id, id)).limit(1); + + if (!userData[0]) { + return buildPublicMetadata({ + title: "Perfil não encontrado", + description: "Perfil de utilizador não encontrado.", + pathname: `/user/${id}`, + }); + } + + return buildPublicMetadata({ + title: `${userData[0].name} | Perfil Algoria`, + description: `Vê o perfil público, tecnologias e progresso de ${userData[0].name} na Algoria.`, + pathname: `/user/${id}`, + }); +} + +export default async function PublicProfilePage({ + params, +}: PublicProfileProps) { + const { id } = await params; + + const [userRows, profileRows, progressRows, assessmentRows] = await Promise.all([ + db.select().from(user).where(eq(user.id, id)).limit(1), + db.select().from(userProfile).where(eq(userProfile.userId, id)).limit(1), + db.select().from(userProgress).where(eq(userProgress.userId, id)).limit(1), + db.select().from(technicalAssessmentResults).where( + and( + eq(technicalAssessmentResults.userId, id), + eq(technicalAssessmentResults.isPublic, true) + ) + ).orderBy(desc(technicalAssessmentResults.completedAt)), + ]); + + + const userData = userRows[0]; + if (!userData) { + notFound(); + } + + const profile = profileRows[0]; + const experiences = profile?.experiences + ? (JSON.parse(profile.experiences as string) as Experience[]) + : []; + const externalProjects = profile?.projects + ? (JSON.parse(profile.projects as string) as Project[]) + : []; + + const { completedProblems, solutionsOpened } = processUserProgress( + progressRows[0]?.data || null, + ); + + const totalMonths = calculateTotalExperienceMonths(experiences); + const experienceString = + totalMonths > 0 ? formatExperienceString(totalMonths) : ""; + + const initials = + userData.name?.substring(0, 2).toUpperCase() || + userData.email?.substring(0, 2).toUpperCase() || + "U"; + + return ( +
+
+
+
+
+ +
+ + + + + + + + + + + {assessmentRows.length > 0 && ( +
+
+
+

Avaliações Técnicas

+
+ +
+ {assessmentRows.map((result) => ( + + + + ))} +
+ +
+ )} +
+ +
+ ); +} diff --git a/components/auth/delete-account-form.tsx b/components/auth/delete-account-form.tsx new file mode 100644 index 0000000..6d17ad1 --- /dev/null +++ b/components/auth/delete-account-form.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { useState } from 'react'; +import { Loader2, Trash2 } from 'lucide-react'; + +import { Button } from '@/components/ui/button'; +import { deleteAccount } from '@/app/profile/actions'; + +export function DeleteAccountForm() { + const [loading, setLoading] = 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(); + } catch (err) { + console.error(err); + alert('Ocorreu um erro ao excluir a conta.'); + setLoading(false); + } + } + + return ( + + ); +} diff --git a/components/auth/sign-in-form.tsx b/components/auth/sign-in-form.tsx new file mode 100644 index 0000000..00bba11 --- /dev/null +++ b/components/auth/sign-in-form.tsx @@ -0,0 +1,185 @@ +'use client'; + +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 { cn } from '@/lib/utils'; + +function GoogleIcon({ className }: { className?: string }) { + return ( + + + + + + + ); +} + +export function SignInForm() { + const router = useRouter(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [googleLoading, setGoogleLoading] = useState(false); + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setLoading(true); + try { + const res = await authClient.signIn.email({ email, password }); + if (res.error) { + setError(res.error.message ?? 'Não foi possível iniciar sessão.'); + return; + } + router.push('/problems'); + router.refresh(); + } finally { + setLoading(false); + } + } + + async function onGoogleSignIn() { + setError(null); + setGoogleLoading(true); + try { + await authClient.signIn.social({ + provider: 'google', + callbackURL: '/', + }); + } catch { + setError('Não foi possível iniciar sessão com Google.'); + setGoogleLoading(false); + } + } + + return ( +
+ {/* Google Sign-In */} + + + {/* Divider */} +
+
+ + ou com email + +
+
+ + {/* Error */} + {error && ( +
+

{error}

+
+ )} + + {/* Email Form */} +
void onSubmit(e)} className="space-y-5"> +
+ + setEmail(e.target.value)} + type="email" + autoComplete="email" + required + placeholder="tu@exemplo.com" + className="h-12 rounded-none border-2 border-border bg-background/60 px-4 text-sm transition-colors focus:border-primary focus:bg-background" + /> +
+
+ +
+ setPassword(e.target.value)} + type={showPassword ? 'text' : 'password'} + autoComplete="current-password" + required + placeholder="••••••••" + className="h-12 rounded-none border-2 border-border bg-background/60 px-4 pr-12 text-sm transition-colors focus:border-primary focus:bg-background" + /> + +
+
+ +
+ + {/* Footer link */} +

+ Ainda sem conta?{' '} + + Criar conta gratuitamente + +

+
+ ); +} diff --git a/components/auth/sign-out-button.tsx b/components/auth/sign-out-button.tsx new file mode 100644 index 0000000..0367a52 --- /dev/null +++ b/components/auth/sign-out-button.tsx @@ -0,0 +1,33 @@ +'use client'; + +import { LogOut, Loader2 } from 'lucide-react'; +import { useState } from 'react'; + +import { authClient } from '@/lib/auth-client'; +import { Button } from '@/components/ui/button'; +import { useRouter } from 'next/navigation'; + +export function SignOutButton() { + const router = useRouter(); + const [loading, setLoading] = useState(false); + + async function handleSignOut() { + setLoading(true); + await authClient.signOut(); + router.push('/auth/sign-in'); + router.refresh(); + } + + return ( + + ); +} diff --git a/components/auth/sign-up-form.tsx b/components/auth/sign-up-form.tsx new file mode 100644 index 0000000..0295459 --- /dev/null +++ b/components/auth/sign-up-form.tsx @@ -0,0 +1,217 @@ +'use client'; + +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 { cn } from '@/lib/utils'; + +function GoogleIcon({ className }: { className?: string }) { + return ( + + + + + + + ); +} + +export function SignUpForm() { + const router = useRouter(); + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const [googleLoading, setGoogleLoading] = useState(false); + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setLoading(true); + try { + const res = await authClient.signUp.email({ + email, + password, + name: name.trim() || email.split('@')[0] || 'Utilizador', + }); + if (res.error) { + setError(res.error.message ?? 'Não foi possível criar a conta.'); + return; + } + router.push('/problems'); + router.refresh(); + } finally { + setLoading(false); + } + } + + async function onGoogleSignUp() { + setError(null); + setGoogleLoading(true); + try { + await authClient.signIn.social({ + provider: 'google', + callbackURL: '/', + }); + } catch { + setError('Não foi possível criar conta com Google.'); + setGoogleLoading(false); + } + } + + return ( +
+ {/* Google Sign-Up */} + + + {/* Divider */} +
+
+ + ou com email + +
+
+ + {/* Error */} + {error && ( +
+

{error}

+
+ )} + + {/* Email Form */} +
void onSubmit(e)} className="space-y-5"> +
+ + setName(e.target.value)} + type="text" + autoComplete="name" + placeholder="Como queres ser chamado?" + className="h-12 rounded-none border-2 border-border bg-background/60 px-4 text-sm transition-colors focus:border-primary focus:bg-background" + /> +
+
+ + setEmail(e.target.value)} + type="email" + autoComplete="email" + required + placeholder="tu@exemplo.com" + className="h-12 rounded-none border-2 border-border bg-background/60 px-4 text-sm transition-colors focus:border-primary focus:bg-background" + /> +
+
+ +
+ setPassword(e.target.value)} + type={showPassword ? 'text' : 'password'} + autoComplete="new-password" + required + minLength={8} + placeholder="Mínimo 8 caracteres" + className="h-12 rounded-none border-2 border-border bg-background/60 px-4 pr-12 text-sm transition-colors focus:border-primary focus:bg-background" + /> + +
+

+ Usa pelo menos 8 caracteres com letras e números. +

+
+ +
+ + {/* Footer link */} +
+

+ Já tens conta?{' '} + + Entrar agora + +

+

+ Ao criar conta, concordas com os{' '} + Termos de Serviço + {' '}e{' '} + Política de Privacidade. +

+
+
+ ); +} diff --git a/components/billing/checkout-button.tsx b/components/billing/checkout-button.tsx new file mode 100644 index 0000000..69a396b --- /dev/null +++ b/components/billing/checkout-button.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useState } from "react"; + +import { analyticsCapture } from "@/components/analytics/posthog-provider"; +import { Button } from "@/components/ui/button"; +import { useToastStore } from "@/lib/store/use-toast-store"; + +export function CheckoutButton({ disabled }: { disabled?: boolean }) { + const [loading, setLoading] = useState(false); + + const addToast = useToastStore((s) => s.addToast); + + async function onClick() { + setLoading(true); + analyticsCapture("checkout_start"); + try { + const res = await fetch("/api/checkout", { + method: "POST", + credentials: "include", + }); + + if (res.status === 401) { + window.location.href = "/auth/sign-in?callbackUrl=/pricing"; + return; + } + + const data = (await res.json().catch(() => ({}))) as { + url?: string; + error?: string; + }; + + if (!res.ok) { + addToast(data.error ?? "Erro no servidor ao iniciar o checkout.", "error"); + return; + } + + if (data.url) window.location.href = data.url; + } finally { + setLoading(false); + } + } + + return ( + + ); +} diff --git a/components/billing/manage-subscription-button.tsx b/components/billing/manage-subscription-button.tsx new file mode 100644 index 0000000..15d0e9c --- /dev/null +++ b/components/billing/manage-subscription-button.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { useState } from "react"; +import { Settings } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useToastStore } from "@/lib/store/use-toast-store"; + +export function ManageSubscriptionButton() { + const [loading, setLoading] = useState(false); + const addToast = useToastStore((s) => s.addToast); + + async function onClick() { + setLoading(true); + try { + const res = await fetch("/api/customer-portal", { + method: "POST", + credentials: "include", + }); + + const data = (await res.json().catch(() => ({}))) as { + url?: string; + error?: string; + }; + + if (!res.ok) { + addToast(data.error ?? "Erro ao abrir o portal de gestão.", "error"); + return; + } + + if (data.url) { + window.location.href = data.url; + } + } catch { + addToast("Erro de rede ao tentar aceder ao portal.", "error"); + } finally { + setLoading(false); + } + } + + return ( + + ); +} diff --git a/components/billing/paywall-analytics.tsx b/components/billing/paywall-analytics.tsx new file mode 100644 index 0000000..cbc7d8a --- /dev/null +++ b/components/billing/paywall-analytics.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { useEffect } from "react"; + +import { analyticsCapture } from "@/components/analytics/posthog-provider"; + +export function PaywallAnalytics({ + problemSlug, + conceptSlug, +}: { + problemSlug?: string; + conceptSlug?: string; +}) { + useEffect(() => { + analyticsCapture("paywall_hit", { + problem_slug: problemSlug, + concept_slug: conceptSlug, + }); + }, [problemSlug, conceptSlug]); + return null; +} diff --git a/components/billing/pricing-analytics.tsx b/components/billing/pricing-analytics.tsx new file mode 100644 index 0000000..0905287 --- /dev/null +++ b/components/billing/pricing-analytics.tsx @@ -0,0 +1,12 @@ +'use client'; + +import { useEffect } from 'react'; + +import { analyticsCapture } from '@/components/analytics/posthog-provider'; + +export function PricingPageAnalytics() { + useEffect(() => { + analyticsCapture('pricing_view'); + }, []); + return null; +} diff --git a/components/billing/progress-sync.tsx b/components/billing/progress-sync.tsx new file mode 100644 index 0000000..6024d6c --- /dev/null +++ b/components/billing/progress-sync.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { useEffect } from 'react'; + +import { authClient } from '@/lib/auth-client'; +import { ProgressBlobSchema } from '@/lib/progress/local-progress-schema'; +import { loadProgressBlob, saveProgressBlob } from '@/lib/progress/local-progress'; + +/** Envia o progresso local ao servidor após login e guarda o merge no browser. */ +export function ProgressSyncOnLogin() { + const { data: session, isPending } = authClient.useSession(); + + useEffect(() => { + if (isPending || !session?.user?.id) return; + let cancelled = false; + void (async () => { + const local = loadProgressBlob(); + try { + const res = await fetch('/api/progress/merge', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ blob: local }), + }); + if (!res.ok || cancelled) return; + const json = (await res.json()) as { blob: unknown }; + const merged = ProgressBlobSchema.parse(json.blob); + saveProgressBlob(merged); + } catch { + /* rede offline */ + } + })(); + return () => { + cancelled = true; + }; + }, [session?.user?.id, isPending]); + + return null; +} diff --git a/components/billing/upgrade-prompt.tsx b/components/billing/upgrade-prompt.tsx new file mode 100644 index 0000000..972a4df --- /dev/null +++ b/components/billing/upgrade-prompt.tsx @@ -0,0 +1,138 @@ +import { Check, Lock, Zap } from "lucide-react"; +import Link from "next/link"; + +import { PaywallAnalytics } from "@/components/billing/paywall-analytics"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; + +export function UpgradePrompt({ + context, + problemSlug, + conceptSlug, + hideLogin, +}: { + context?: string; + problemSlug?: string; + conceptSlug?: string; + hideLogin?: boolean; +}) { + const isConcept = !!conceptSlug; + + const perks = isConcept + ? [ + "Acesso a todos os conceitos avançados", + "Padrões de desenho e arquitetura", + "Guias de engenharia aplicada", + "Sincronização de progresso", + ] + : [ + "Player linha-a-linha interativo", + "Traces de execução visual", + "Múltiplas linguagens (JS, TS, Python...)", + "Soluções ótimas e alternativas", + ]; + + return ( +
+ {/* Industrial Header Bar - Mirroring Technical Section in Homepage */} +
+
+

+ Acesso Restrito +

+

+ Funcionalidade Pro Necessária +

+
+
+ +
+
+ + {/* Grid Pattern Body */} +
+ {problemSlug || conceptSlug ? ( + + ) : null} + +
+
+
+

+ {context ?? (isConcept ? "Conceito Pro" : "Conteúdo Pro")} +

+
+
+ + Licença Exclusiva + +
+ +

+ {isConcept ? ( + <> + Este guia técnico faz parte do catálogo{" "} + Algoria Pro. Utilizamos o mesmo método de + leitura crítica para temas de arquitetura e sistemas reais. + + ) : ( + <> + Domina este problema com o player linha-a-linha. Vê como cada + decisão impacta a complexidade e o estado do sistema através de + traces interativos. + + )} +

+ + {/* Perks Grid - Mirroring Platform Features Pattern */} +
+ {perks.map((perk, i) => ( +
+
+ +
+ + {perk} + +
+ ))} +
+ +
+ + {!hideLogin && ( + + )} +
+
+
+
+ ); +} diff --git a/components/catalog/problems-catalog-client.tsx b/components/catalog/problems-catalog-client.tsx index e2e5857..a0bd59e 100644 --- a/components/catalog/problems-catalog-client.tsx +++ b/components/catalog/problems-catalog-client.tsx @@ -56,7 +56,7 @@ export function ProblemsCatalogClient({ problems }: Props) {

Filtra por dificuldade e categoria, pesquisa por título e segue a ordem recomendada de aprendizagem. - O progresso fica no teu browser (localStorage). + O progresso fica no teu browser (localStorage); com conta Pro podes sincronizar na nuvem após iniciar sessão.

+ {p.access === 'pro' ? ( + + Pro + + ) : ( + + Free + + )} {p.categories.slice(0, 2).map((c) => ( diff --git a/components/code-player/explanation-panel.tsx b/components/code-player/explanation-panel.tsx index 0ce0e01..efc83e9 100644 --- a/components/code-player/explanation-panel.tsx +++ b/components/code-player/explanation-panel.tsx @@ -58,7 +58,7 @@ export function ExplanationPanel({ annotations, conceptTitles }: Props) { {LEVEL_DESCRIPTION[level]}
- +
{ +function availability(): Record { return { 1: true, 2: true, diff --git a/components/concepts/concepts-catalog-client.tsx b/components/concepts/concepts-catalog-client.tsx index efb1bfc..7ea655e 100644 --- a/components/concepts/concepts-catalog-client.tsx +++ b/components/concepts/concepts-catalog-client.tsx @@ -9,7 +9,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { DifficultyBadge } from '@/components/catalog/difficulty-badge'; import { Input } from '@/components/ui/input'; import { DIFFICULTY_LABEL_PT } from '@/lib/catalog/problem-filters'; -import type { Difficulty } from '@/lib/content/schemas'; +import type { Difficulty, ContentAccess } from '@/lib/content/schemas'; export interface ConceptCatalogItem { slug: string; @@ -18,6 +18,7 @@ export interface ConceptCatalogItem { category: string; estimatedMinutes: number; difficulty: Difficulty; + access: ContentAccess; } type SortMode = 'title_az' | 'difficulty_asc'; @@ -146,8 +147,13 @@ export function ConceptsCatalogClient({ concepts }: Props) { {c.category.replace('-', '_')}
- + {c.title} + {c.access === 'pro' && ( + + Pro + + )} {c.estimatedMinutes}m Reading diff --git a/components/course/course-module-runner.tsx b/components/course/course-module-runner.tsx index 69c5d98..b143943 100644 --- a/components/course/course-module-runner.tsx +++ b/components/course/course-module-runner.tsx @@ -1,8 +1,9 @@ 'use client'; import Link from 'next/link'; -import { BookOpenCheck, ChevronRight, Lock, Trophy } from 'lucide-react'; +import { ArrowRight, BookOpenCheck, CheckCircle2, ChevronRight, Lock, Trophy } from 'lucide-react'; +import { Button } from '@/components/ui/button'; import { analyticsCapture } from '@/components/analytics/posthog-provider'; import type { CourseModuleHydrated, CoursePackHydrated } from '@/lib/content/schemas'; import { Badge } from '@/components/ui/badge'; @@ -83,117 +84,143 @@ export function CourseModuleRunner({ pack, module, previousModuleCertificateTitl : `/concepts/${module.linkedConceptSlug}?course=${encodeURIComponent(pack.slug)}&module=${encodeURIComponent(module.id)}`; return ( -
-
-
- - Capítulo {moduleIndex + 1}/{pack.modules.length} - - Progresso local {pct}% -
-

- {module.certificateTitle.replace(/^Certificado — /, '')} -

- {module.certificateTagline ? ( -

{module.certificateTagline}

- ) : null} -

{module.conceptSummary}

-
- -
-
- -
-

Passo 1 — ler o conceito

-

- O texto longo está no catálogo de conceitos. Abre quando quiseres, depois confirma aqui para registar o teu progresso só neste dispositivo. -

+
+
+
+
+ + Capítulo {moduleIndex + 1}/{pack.modules.length} + + + Progresso Local {pct}% +
-
-
- - {module.linkedResourceKind === 'interview-en' ? 'Abrir artigo base (Interview EN)' : 'Abrir página do conceito'} - - - -
-
+

+ {module.certificateTitle.replace(/^Certificado — /, '')} +

+ {module.certificateTagline && ( +

{module.certificateTagline}

+ )} +

{module.conceptSummary}

+ + +
+
+
+ +
+
+
+

Passo 1 — Ler o Conceito

+

+ O texto teórico completo está disponível no catálogo de conceitos. Estuda ao teu ritmo e depois marca a leitura abaixo. +

+
+ +
+ + + +
+
+
+
-
-

Passo 2 — exemplos guiados em duas densidades

-

- Começa no separador simples e só depois abre o profundo quando tiveres carga cognitiva livre. -

-
- {module.examples.map((ex) => ( - - ))} -
-
- -
-

Passo 3 — exercícios rápidos de fixação

-
- {module.exercises.map((exercise) => ( - markExercise(exercise.id)} - /> - ))} -
-
- -
-
- -
-

Passo 4 — prova deste capítulo liberta o certificado

-

- Depois da resposta correcta aparece aqui mesmo o certificado — guardado apenas localmente até mudares navegador/máquina. -

+
+
+
+

Passo 2 — Exemplos Guiados

-
- - {capOk ? ( -
- - Ver certificado - - - - ← Índice do curso - +
+ {module.examples.map((ex) => ( + + ))} +
+
+ +
+
+
+

Passo 3 — Fixação de Conhecimento

+
+
+ {module.exercises.map((exercise) => ( + markExercise(exercise.id)} + /> + ))}
- ) : ( -

- Esta prova fecha o ciclo antes de poderes iniciar oficialmente o módulo seguinte desta série. -

- )} -
+
+ +
+
+
+ +
+
+

Passo 4 — Prova Final

+

+ Supera este último desafio para desbloqueares o certificado oficial deste capítulo. +

+
+
+ + + + {capOk && ( +
+ + + {pack.modules[moduleIndex + 1] && ( + + )} + + +
+ )} +
+
); } diff --git a/components/course/course-programs-index.tsx b/components/course/course-programs-index.tsx index 9daa2e7..1f20712 100644 --- a/components/course/course-programs-index.tsx +++ b/components/course/course-programs-index.tsx @@ -5,6 +5,7 @@ import { CheckCircle2, Lock, PenLine } from 'lucide-react'; import type { CoursePackHydrated } from '@/lib/content/schemas'; import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; import { useCourseProgressStore } from '@/lib/stores/course-progress-store'; import { defaultModuleSlice, @@ -60,53 +61,58 @@ export function CourseProgramsIndex({ pack }: Props) { const statusLabel = capPassed ? 'Completo • certificado desbloqueado' : pct === 0 ? 'Não iniciado' : 'Em progresso'; return ( -
-
-
- - #{idx + 1} - - {!unlock ? : null} - {statusLabel} +
+
+
+ + Módulo {idx + 1} + + {!unlock && } + {statusLabel}
-

{module.certificateTitle.replace(/^Certificado — /, '')}

-

{module.conceptSummary}

+ +

+ {module.certificateTitle.replace(/^Certificado — /, '')} +

+ +

{module.conceptSummary}

{!unlock ? ( -

- Fica bloqueado até passares pela prova do módulo anterior — mantém-te honesto relativamente ritmo progressivo. +

+ Bloqueado: completa o módulo anterior para aceder.

) : ( - <> -
-
+
+
+
+
+
+ Progresso + {pct}% • {earned}/{total} UN
-

- Linha temporal local {pct}% · {earned}/{total} unidades -

- +
)}
-
+ +
{unlock ? ( - - Abrir módulo - + ) : ( - Bloqueado +
+ Bloqueado +
)} - {capPassed ? ( - - Certificado deste capítulo - - ) : ( -   + + {capPassed && ( + )}
@@ -116,42 +122,56 @@ export function CourseProgramsIndex({ pack }: Props) { const overallPct = denom ? Math.round((sum / denom) * 100) : 0; return ( -
-
- - Curso local · progresso no browser apenas - -

{pack.title}

-

{pack.subtitle}

-
- -
-
- -
-

- Como os certificados são emitidos no teu dispositivo, escreve o nome que pretendes aparecer na folha oficial - (podes gravar sempre que quiseres): -

- setLearner(e.target.value)} - placeholder="Nome completo ..." - className="w-full max-w-md rounded-lg border border-input bg-background px-3 py-2 text-sm" - /> +
+
+
+
+ + Curso Local + + + {overallPct}% Concluído +
-
-
- Ocupação média atual do curso - {overallPct}% -
-
+

{pack.title}

+

{pack.subtitle}

+ + +
+
+
+ +
+
+
+

Identificação no Certificado

+

+ Os certificados são emitidos localmente. O nome que definires abaixo será o que aparecerá no documento final. +

+
+ setLearner(e.target.value)} + placeholder="Nome completo para o certificado ..." + className="w-full max-w-md rounded-none border-2 border-input bg-background px-4 py-2 text-sm font-bold focus:border-primary focus:outline-none transition-colors" + /> +
+
+
+ +
+

+
Programa do Curso +

+
+ {rows} +
+
-
{rows}
-
- Aviso importante: quando limpares dados do site/perfil do navegador o progresso e certificados desaparecem — faz - captura de écran ou imprime assim que ficares feliz como teu método de arquivo. -
+
+ Aviso importante: como o progresso é guardado apenas no teu navegador, limpar os dados do site removerá os teus certificados. +
+
); } diff --git a/components/course/example-dual-depth.tsx b/components/course/example-dual-depth.tsx index 4b369fa..87bbc50 100644 --- a/components/course/example-dual-depth.tsx +++ b/components/course/example-dual-depth.tsx @@ -12,35 +12,46 @@ interface Props { /** Exemplo com duas densidades narrativas: introdutório vs intenção “curso premium”. */ export function ExampleDualDepth({ title, simpleHtml, deepHtml, code }: Props) { return ( -
-

{title}

+
+

{title}

- - - Leitura simples + + + Leitura Simples - - Explicação profunda + + Explicação Profunda - +
- +
- {code ? ( -
-          {code}
-        
- ) : null} + {code && ( +
+
+
Referência em Código +
+
+            {code}
+          
+
+ )}
); } diff --git a/components/course/mcq-lesson.tsx b/components/course/mcq-lesson.tsx index 18b4ee7..21f7ad3 100644 --- a/components/course/mcq-lesson.tsx +++ b/components/course/mcq-lesson.tsx @@ -37,32 +37,32 @@ export function McqLesson({ exercise, alreadySolved, onCorrect, variant = 'pract const frameClasses = useMemo(() => { if (variant === 'capstone') { - return 'border-amber-300/70 bg-gradient-to-br from-amber-50 to-white dark:border-amber-400/40 dark:from-zinc-900 dark:to-zinc-950'; + return 'border-amber-500/50 bg-amber-500/5'; } - return 'border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-950'; + return 'border-border bg-background'; }, [variant]); return ( -
-
- {variant === 'capstone' ? 'Avaliação final' : 'Exercício'} +
+
+ {variant === 'capstone' ? 'Avaliação Final' : 'Exercício de Fixação'} {showExplain ? ( - - Resposta certa + + Resposta Correta ) : null}
-

{exercise.stem}

+

{exercise.stem}

-
+
{exercise.choices.map((c, idx) => { const sel = choice === idx; const locked = solved || hasSucceededLocally; - let ring = 'border-transparent bg-zinc-100/80 hover:bg-zinc-100 dark:bg-zinc-900 dark:hover:bg-zinc-900/70'; + let ring = 'border-border bg-muted/20 hover:border-primary/50 hover:bg-muted/40'; if (locked && idx === exercise.correctIndex) { ring = 'border-emerald-500 bg-emerald-500/10'; } else if (!locked && sel && wrongPick) { - ring = 'border-red-400 bg-red-500/10'; + ring = 'border-destructive bg-destructive/10'; } else if (!locked && sel) { ring = 'border-primary bg-primary/5'; } @@ -73,9 +73,9 @@ export function McqLesson({ exercise, alreadySolved, onCorrect, variant = 'pract type="button" disabled={locked} onClick={() => pick(idx)} - className={`w-full text-left px-4 py-3 rounded-lg border-2 transition-colors text-sm leading-snug cursor-pointer ${ring} disabled:cursor-default`} + className={`w-full text-left px-6 py-4 border-2 transition-all text-sm font-bold leading-snug cursor-pointer rounded-none ${ring} disabled:cursor-default`} > - {String.fromCharCode(65 + idx)}. + {String.fromCharCode(65 + idx)}. {c} ); @@ -83,38 +83,45 @@ export function McqLesson({ exercise, alreadySolved, onCorrect, variant = 'pract
{!showExplain && wrongPick ? ( -
- +
+

- Ainda não está certo — pensa no que esse enunciado exige antes de clicar na opção seguinte (podes trocar - quantas vezes precisares). + Ainda não está certo. Analisa melhor as opções antes de tentar novamente.

) : null} {showExplain ? ( - - - - Porquê · simples - - - Porquê · a fundo - - - -
- - -
- - +
+ + + + Porquê · Simples + + + Porquê · A Fundo + + + +
+ + +
+ + +
) : null}
); diff --git a/components/engenharia-trabalho/author-info.tsx b/components/engenharia-trabalho/author-info.tsx new file mode 100644 index 0000000..a69a24f --- /dev/null +++ b/components/engenharia-trabalho/author-info.tsx @@ -0,0 +1,50 @@ +import Image from "next/image"; +import Link from "next/link"; + +interface AuthorInfoProps { + name: string; + role: string; + href: string; + image?: string; +} + +export function AuthorInfo({ name, role, href, image }: AuthorInfoProps) { + const initials = name + .split(" ") + .map((n) => n[0]) + .join("") + .toUpperCase(); + + return ( +
+
+ {image ? ( + {name} + ) : ( +
+ {initials} +
+ )} +
+
+ + Autor + + + {name} + + + {role} + +
+
+ ); +} diff --git a/components/explorer/pagination.tsx b/components/explorer/pagination.tsx new file mode 100644 index 0000000..3d35b86 --- /dev/null +++ b/components/explorer/pagination.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { ChevronLeft, ChevronRight } from "lucide-react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useTransition } from "react"; + +interface PaginationProps { + currentPage: number; + totalPages: number; +} + +export function Pagination({ currentPage, totalPages }: PaginationProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + const [isPending, startTransition] = useTransition(); + + const handlePageChange = (page: number) => { + if (page < 1 || page > totalPages) return; + + const params = new URLSearchParams(searchParams); + params.set("page", page.toString()); + + startTransition(() => { + router.push(`/explorer?${params.toString()}`); + }); + }; + + if (totalPages <= 1) return null; + + return ( +
+ + +
+ + Página + +
+ {currentPage} +
+ + de {totalPages} + +
+ + +
+ ); +} diff --git a/components/explorer/search-bar.tsx b/components/explorer/search-bar.tsx new file mode 100644 index 0000000..e8851a5 --- /dev/null +++ b/components/explorer/search-bar.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { Input } from "@/components/ui/input"; +import { useDebounce } from "@/hooks/use-debounce"; // I'll check if this exists or create it +import { Search, X } from "lucide-react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useEffect, useState, useTransition } from "react"; + +export function SearchBar() { + const router = useRouter(); + const searchParams = useSearchParams(); + const [isPending, startTransition] = useTransition(); + + const [query, setQuery] = useState(searchParams.get("q") || ""); + const debouncedQuery = useDebounce(query, 300); + + useEffect(() => { + const currentQuery = searchParams.get("q") || ""; + if (debouncedQuery === currentQuery) return; + + const params = new URLSearchParams(searchParams); + if (debouncedQuery) { + params.set("q", debouncedQuery); + } else { + params.delete("q"); + } + params.set("page", "1"); + + startTransition(() => { + router.push(`/explorer?${params.toString()}`); + }); + }, [debouncedQuery, router, searchParams]); + + return ( +
+
+ +
+ setQuery(e.target.value)} + className="h-14 pl-12 pr-12 rounded-none border-2 border-border bg-background/50 font-black uppercase tracking-widest text-sm focus-visible:ring-0 focus-visible:border-primary transition-all shadow-[4px_4px_0_0_rgba(0,0,0,0.05)]" + /> + {query && ( + + )} + {isPending && ( +
+ )} +
+ ); +} diff --git a/components/explorer/tech-filter.tsx b/components/explorer/tech-filter.tsx new file mode 100644 index 0000000..dc576fd --- /dev/null +++ b/components/explorer/tech-filter.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { AVAILABLE_TECHS } from "@/lib/technologies"; +import { Search, X } from "lucide-react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useState, useTransition } from "react"; + +export function TechFilter() { + const router = useRouter(); + const searchParams = useSearchParams(); + const [isPending, startTransition] = useTransition(); + const [search, setSearch] = useState(""); + + const selectedTechs = searchParams.get("tech")?.split(",").filter(Boolean) || []; + + const filteredTechs = search + ? AVAILABLE_TECHS.filter(t => t.toLowerCase().includes(search.toLowerCase())) + : AVAILABLE_TECHS.slice(0, 12); // Show some defaults when not searching + + const toggleTech = (tech: string) => { + const params = new URLSearchParams(searchParams); + let newTechs = [...selectedTechs]; + + if (newTechs.includes(tech)) { + newTechs = newTechs.filter(t => t !== tech); + } else { + newTechs.push(tech); + } + + if (newTechs.length > 0) { + params.set("tech", newTechs.join(",")); + } else { + params.delete("tech"); + } + params.set("page", "1"); + + startTransition(() => { + router.push(`/explorer?${params.toString()}`); + }); + }; + + return ( +
+
+
+ Tecnologias + {isPending && } +
+ +
+ + setSearch(e.target.value)} + className="h-9 pl-9 text-[11px] font-bold uppercase tracking-widest bg-background/40 border-border focus:border-primary/50 transition-all rounded-none" + /> + {search && ( + + )} +
+
+ +
+ {/* Always show selected techs first */} + {selectedTechs.map((tech) => ( + toggleTech(tech)} + variant="default" + className="cursor-pointer rounded-none bg-primary text-primary-foreground px-2.5 py-0.5 text-[10px] font-black uppercase tracking-widest hover:bg-primary/90 transition-all ring-2 ring-primary/20" + > + {tech} + + + ))} + + {/* Show filtered techs that are NOT selected */} + {filteredTechs + .filter(t => !selectedTechs.includes(t)) + .map((tech) => ( + toggleTech(tech)} + variant="outline" + className="cursor-pointer rounded-none border-border bg-muted/20 px-2.5 py-0.5 text-[10px] font-black uppercase tracking-widest text-muted-foreground hover:border-primary/50 hover:text-primary hover:bg-primary/5 transition-all" + > + {tech} + + ))} +
+ + {!search && filteredTechs.length < AVAILABLE_TECHS.length && ( +

+ Escreve para encontrar mais de {AVAILABLE_TECHS.length} stacks... +

+ )} +
+ ); +} diff --git a/components/explorer/user-card.tsx b/components/explorer/user-card.tsx new file mode 100644 index 0000000..18eafd7 --- /dev/null +++ b/components/explorer/user-card.tsx @@ -0,0 +1,121 @@ +import { Badge } from "@/components/ui/badge"; +import { Card } from "@/components/ui/card"; +import { + calculateTotalExperienceMonths, + formatExperienceString, +} from "@/lib/profile/profile-utils"; +import { Calendar, Code2, ExternalLink } from "lucide-react"; +import Image from "next/image"; +import Link from "next/link"; +import { memo } from "react"; + +interface UserCardProps { + user: { + id: string; + name: string; + image: string | null; + headline: string | null; + technologies: string[] | null; + experiences: string | null; + }; +} + +export const UserCard = memo(function UserCard({ user }: UserCardProps) { + const initials = user.name + .split(" ") + .map((n) => n[0]) + .join("") + .substring(0, 2) + .toUpperCase(); + + const parsedExperiences = user.experiences + ? JSON.parse(user.experiences) + : []; + const totalMonths = calculateTotalExperienceMonths(parsedExperiences); + const expString = + totalMonths > 0 ? formatExperienceString(totalMonths) : null; + + return ( + + +
+ +
+ +
+
+
+
+ +
+ {user.image ? ( + {user.name} + ) : ( + + {initials} + + )} +
+
+
+

+ {user.name} +

+ {expString && ( +
+ + + {expString} + +
+ )} +
+
+ +
+

+ {user.headline || "Explorador Algoria"} +

+
+ +
+ +
+ {user.technologies && user.technologies.length > 0 ? ( + user.technologies.slice(0, 6).map((tech) => ( + + {tech} + + )) + ) : ( + + Nenhuma tecnologia listada + + )} + {user.technologies && user.technologies.length > 6 && ( + + +{user.technologies.length - 6} + + )} +
+ +
+
+ Ver Perfil + +
+
+
+ + + ); +}); diff --git a/components/interview-en/interview-catalog-client.tsx b/components/interview-en/interview-catalog-client.tsx index 7503365..b41b7b9 100644 --- a/components/interview-en/interview-catalog-client.tsx +++ b/components/interview-en/interview-catalog-client.tsx @@ -8,7 +8,6 @@ import { Badge } from '@/components/ui/badge'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { DifficultyBadge } from '@/components/catalog/difficulty-badge'; import { Input } from '@/components/ui/input'; -import { DIFFICULTY_LABEL_PT } from '@/lib/catalog/problem-filters'; import type { Difficulty, InterviewEnglishTrack } from '@/lib/content/schemas'; export interface InterviewCatalogItem { diff --git a/components/layout/content-navigation.tsx b/components/layout/content-navigation.tsx new file mode 100644 index 0000000..8adc6e8 --- /dev/null +++ b/components/layout/content-navigation.tsx @@ -0,0 +1,107 @@ +import Link from 'next/link'; +import { ArrowLeft, ArrowRight } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +export interface ContentNavItem { + slug: string; + title: string; + /** Full href path — e.g. `/concepts/two-pointers` */ + href: string; + /** Optional short description */ + description?: string; +} + +interface ContentNavigationProps { + /** Label shown above the nav, e.g. "Próximo conceito" */ + sectionLabel: string; + /** Previous item in the list (if any) */ + prev?: ContentNavItem | null; + /** Next item in the list (if any) */ + next?: ContentNavItem | null; + className?: string; +} + +/** + * Renders a premium "next / previous" navigation card at the bottom of content pages. + * Supports problems, concepts, interview english, engineering work, and courses. + */ +export function ContentNavigation({ sectionLabel, prev, next, className }: ContentNavigationProps) { + if (!prev && !next) return null; + + return ( +
+

{sectionLabel}

+ +
+ {prev && ( + + + + + + + + Anterior + + + {prev.title} + + {prev.description && ( + + {prev.description} + + )} + + + )} + + {next && ( + + + + + Próximo + + + {next.title} + + {next.description && ( + + {next.description} + + )} + + + + + + )} +
+
+ ); +} diff --git a/components/layout/nav-links.tsx b/components/layout/nav-links.tsx new file mode 100644 index 0000000..9e26ed5 --- /dev/null +++ b/components/layout/nav-links.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { ArrowUpRight } from "lucide-react"; +import Link from "next/link"; +import { cn } from "@/lib/utils"; +import { NAVIGATION_ITEMS } from "@/lib/navigation-data"; + +interface NavLinksProps { + onNavigate?: () => void; + className?: string; +} + +export function NavLinks({ onNavigate, className }: NavLinksProps) { + return ( +
    + {NAVIGATION_ITEMS.map(({ href, label, description, Icon }) => ( +
  • + + + + + + + {label} + + + {description} + + + + + +
  • + ))} +
+ ); +} diff --git a/components/layout/session-nav.tsx b/components/layout/session-nav.tsx new file mode 100644 index 0000000..c670cd7 --- /dev/null +++ b/components/layout/session-nav.tsx @@ -0,0 +1,121 @@ +"use client"; + +import { LogOut, User as UserIcon } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useEffect, useRef, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { authClient } from "@/lib/auth-client"; +import Image from "next/image"; + +export function SessionNav() { + const { data, isPending } = authClient.useSession(); + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + const router = useRouter(); + + useEffect(() => { + if (!isOpen) return; + const handleOutsideClick = (e: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(e.target as Node) + ) { + setIsOpen(false); + } + }; + document.addEventListener("mousedown", handleOutsideClick); + return () => document.removeEventListener("mousedown", handleOutsideClick); + }, [isOpen]); + + if (isPending) { + return ( + + … + + ); + } + + if (!data?.user) { + return ( + + ); + } + + const user = data.user; + const initials = + user.name?.substring(0, 2).toUpperCase() || + user.email?.substring(0, 2).toUpperCase() || + "U"; + + const handleSignOut = async () => { + setIsOpen(false); + await authClient.signOut(); + router.push("/auth/sign-in"); + router.refresh(); + }; + + return ( +
+ + + {isOpen && ( +
+
+

{user.name}

+

+ {user.email} +

+
+
+ setIsOpen(false)} + className="flex items-center gap-2 rounded-sm px-2 py-1.5 text-xs font-medium hover:bg-primary/10 hover:text-primary transition-colors" + > + + Meu Perfil + + +
+
+ )} +
+ ); +} diff --git a/components/layout/sidebar-context.tsx b/components/layout/sidebar-context.tsx new file mode 100644 index 0000000..0f56023 --- /dev/null +++ b/components/layout/sidebar-context.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { + createContext, + ReactNode, + useContext, + useEffect, + useState, +} from "react"; + +const SIDEBAR_STORAGE_KEY = "algoria-sidebar-collapsed"; +const SIDEBAR_WIDTH_COLLAPSED = 48; +const SIDEBAR_WIDTH_EXPANDED = 260; + +interface SidebarContextType { + width: number; + collapsed: boolean; + toggle: () => void; +} + +const SidebarContext = createContext(undefined); + +export function SidebarProvider({ children }: { children: ReactNode }) { + const [collapsed, setCollapsed] = useState(true); + + useEffect(() => { + try { + const stored = localStorage.getItem(SIDEBAR_STORAGE_KEY); + if (stored !== null) { + const isCollapsed = stored === "true"; + if (collapsed !== isCollapsed) { + queueMicrotask(() => { + setCollapsed(isCollapsed); + }); + } + } + } catch { + /* noop */ + } + }, [collapsed]); + + useEffect(() => { + const w = collapsed ? SIDEBAR_WIDTH_COLLAPSED : SIDEBAR_WIDTH_EXPANDED; + document.documentElement.style.setProperty("--sidebar-width", `${w}px`); + }, [collapsed]); + + const toggle = () => { + setCollapsed((prev) => { + const next = !prev; + try { + localStorage.setItem(SIDEBAR_STORAGE_KEY, String(next)); + } catch { + /* noop */ + } + return next; + }); + }; + + const width = collapsed ? SIDEBAR_WIDTH_COLLAPSED : SIDEBAR_WIDTH_EXPANDED; + + return ( + + {children} + + ); +} + +export function useSidebar() { + const context = useContext(SidebarContext); + if (context === undefined) { + throw new Error("useSidebar must be used within a SidebarProvider"); + } + return context; +} + +export function useSidebarWidth() { + return useSidebar().width; +} diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx new file mode 100644 index 0000000..ee20649 --- /dev/null +++ b/components/layout/sidebar.tsx @@ -0,0 +1,137 @@ +"use client"; + +import { ArrowUpRight, ChevronLeft, ChevronRight } from "lucide-react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +import { NAVIGATION_ITEMS } from "@/lib/navigation-data"; +import { cn } from "@/lib/utils"; +import { useSidebar } from "./sidebar-context"; + +export function Sidebar() { + const { collapsed, toggle } = useSidebar(); + const pathname = usePathname(); + + const isActive = (href: string) => { + if (href === "/") return pathname === "/"; + return pathname === href || pathname.startsWith(href + "/"); + }; + + return ( + <> + + + + + ); +} diff --git a/components/layout/site-footer.tsx b/components/layout/site-footer.tsx index 9f19b41..de76fde 100644 --- a/components/layout/site-footer.tsx +++ b/components/layout/site-footer.tsx @@ -6,6 +6,7 @@ import { AlgoriaBrand } from "@/components/branding/algoria-logo"; const explore = [ { href: "/problems", label: "Catálogo de problemas" }, + { href: "/pricing", label: "Preços e Pro" }, { href: "/tracks", label: "Trilhos curados" }, { href: "/changelog", label: "Novidades" }, { href: "/concepts", label: "Conceitos algorítmicos" }, @@ -90,27 +91,62 @@ export function SiteFooter() {
-
- - © {new Date().getFullYear()} Algoria. Conteúdo educativo. - -
- - - Criador e idealizador:  - +
+
+

+ © {new Date().getFullYear()} Algoria. Conteúdo educativo. +

+ +
+
+ + + + Criador e idealizador:  + + Jonatas Silva + +  — Senior Fullstack Software Engineer + + +
diff --git a/components/layout/site-header.tsx b/components/layout/site-header.tsx index 50d8a22..05f9a26 100644 --- a/components/layout/site-header.tsx +++ b/components/layout/site-header.tsx @@ -1,82 +1,14 @@ -'use client'; +"use client"; -import { - ArrowUpRight, - BookOpen, - Briefcase, - Code2, - GraduationCap, - Languages, - Map, - Menu, - Sparkles, - Target, - X, -} from 'lucide-react'; -import Link from 'next/link'; -import { useEffect, useId, useRef, useState } from 'react'; +import { Menu, X } from "lucide-react"; +import { useEffect, useId, useRef, useState } from "react"; -import { AlgoriaBrand } from '@/components/branding/algoria-logo'; -import { ThemeToggle } from '@/components/theme-toggle'; -import { Button } from '@/components/ui/button'; -import { cn } from '@/lib/utils'; - -const NAV = [ - { href: '/problems', label: 'Problemas', description: 'Catálogo e code player', Icon: Code2 }, - { href: '/tracks', label: 'Trilhos', description: 'Percursos recomendados', Icon: Map }, - { href: '/concepts', label: 'Conceitos', description: 'Resumos algorítmicos', Icon: BookOpen }, - { href: '/interview-en', label: 'Inglês entrevistas', description: 'Preparação EN', Icon: Languages }, - { href: '/course', label: 'Curso', description: 'Fundamentos guiados', Icon: GraduationCap }, - { href: '/#technical-job-tests', label: 'Testes técnicos', description: 'Preparação para vagas', Icon: Target }, - { href: '/engineering-work', label: 'Engenharia no trabalho', description: 'Roadmap em produção', Icon: Briefcase }, - { href: '/changelog', label: 'Novidades', description: 'Alterações da plataforma', Icon: Sparkles }, -] as const; - -function NavLinks({ onNavigate }: { onNavigate?: () => void }) { - return ( -
    - {NAV.map(({ href, label, description, Icon }) => ( -
  • - - - - - - - {label} - - - {description} - - - - - -
  • - ))} -
- ); -} +import { AlgoriaBrand } from "@/components/branding/algoria-logo"; +import { NavLinks } from "@/components/layout/nav-links"; +import { SessionNav } from "@/components/layout/session-nav"; +import { ThemeToggle } from "@/components/theme-toggle"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; export function SiteHeader() { const [open, setOpen] = useState(false); @@ -87,7 +19,7 @@ export function SiteHeader() { useEffect(() => { if (!open) return; const prev = document.body.style.overflow; - document.body.style.overflow = 'hidden'; + document.body.style.overflow = "hidden"; return () => { document.body.style.overflow = prev; }; @@ -96,10 +28,10 @@ export function SiteHeader() { useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') setOpen(false); + if (e.key === "Escape") setOpen(false); }; - document.addEventListener('keydown', onKey); - return () => document.removeEventListener('keydown', onKey); + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); }, [open]); useEffect(() => { @@ -110,8 +42,9 @@ export function SiteHeader() { if (menuButtonRef.current?.contains(t)) return; setOpen(false); }; - document.addEventListener('pointerdown', onPointerDownCapture, true); - return () => document.removeEventListener('pointerdown', onPointerDownCapture, true); + document.addEventListener("pointerdown", onPointerDownCapture, true); + return () => + document.removeEventListener("pointerdown", onPointerDownCapture, true); }, [open]); const close = () => setOpen(false); @@ -119,8 +52,8 @@ export function SiteHeader() { return (
{open && ( @@ -136,29 +69,23 @@ export function SiteHeader() {
- +
@@ -168,23 +95,34 @@ export function SiteHeader() { ref={drawerRef} id={navId} className={cn( - 'fixed top-16 right-0 z-105 flex max-h-[calc(100dvh-4rem)] w-[min(calc(100vw-0.75rem),24rem)] flex-col overflow-hidden border-l-2 border-primary/35 shadow-2xl animate-in slide-in-from-right-4 duration-200', - 'bg-grid-pattern bg-background', + "fixed top-16 right-0 z-105 flex max-h-[calc(100dvh-4rem)] w-[min(calc(100vw-0.75rem),24rem)] flex-col overflow-hidden border-l-2 border-primary/35 shadow-2xl animate-in slide-in-from-right-4 duration-200 xl:hidden", + "bg-grid-pattern bg-background", )} role="dialog" aria-modal="true" aria-label="Navegação principal" >
-
-

Navegar

-

Explora a Algoria

+
+

+ Navegar +

+

+ Explora a Algoria +

- Atalhos para estudo, curso e conteúdo de carreira — o mesmo visual técnico do resto do site. + Atalhos para estudo, curso e conteúdo de carreira — o mesmo visual + técnico do resto do site.

-