diff --git a/README.md b/README.md
index 3aca87f..56e4150 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,7 @@
# Algoria
+
+
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 (
- 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.
+ 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/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:
+
+ 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
-
+
+ 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.
+
+ {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.
+ >
+ )}
+
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.
{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.
- 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.