From 00434a1326281e9fb17604944f4e49f7c3c24c35 Mon Sep 17 00:00:00 2001 From: JsCodeDevlopment Date: Mon, 4 May 2026 22:22:10 -0300 Subject: [PATCH 01/21] feat: enhance security headers in Next.js config, add new dependencies for better authentication and database management, and update sitemap with new legal pages and pricing information --- app/api/auth/[...all]/route.ts | 4 + app/api/billing/status/route.ts | 16 + app/api/checkout/route.ts | 56 + app/api/progress/merge/route.ts | 81 ++ app/api/progress/route.ts | 27 + app/api/webhooks/stripe/route.ts | 124 ++ app/auth/sign-in/page.tsx | 34 + app/auth/sign-up/page.tsx | 34 + app/layout.tsx | 2 + app/legal/cookies/page.tsx | 32 + app/legal/privacy/page.tsx | 41 + app/legal/refunds/page.tsx | 34 + app/legal/terms/page.tsx | 34 + app/pricing/checkout-success-analytics.tsx | 24 + app/pricing/page.tsx | 102 ++ app/problems/[slug]/[solution]/page.tsx | 21 + app/problems/[slug]/page.tsx | 21 +- app/sitemap.ts | 5 + components/auth/sign-in-form.tsx | 77 ++ components/auth/sign-up-form.tsx | 96 ++ components/billing/checkout-button.tsx | 37 + components/billing/paywall-analytics.tsx | 12 + components/billing/pricing-analytics.tsx | 12 + components/billing/progress-sync.tsx | 39 + components/billing/upgrade-prompt.tsx | 28 + .../catalog/problems-catalog-client.tsx | 11 +- components/layout/session-nav.tsx | 50 + components/layout/site-footer.tsx | 76 +- components/layout/site-header.tsx | 2 + content/changelog.md | 9 +- .../best-time-to-buy-and-sell-stock/meta.json | 1 + content/problems/contains-duplicate/meta.json | 1 + .../intersection-of-two-arrays-ii/meta.json | 1 + content/problems/maximum-subarray/meta.json | 1 + content/problems/merge-sorted-array/meta.json | 1 + content/problems/move-zeroes/meta.json | 1 + .../squares-of-a-sorted-array/meta.json | 1 + content/problems/two-sum/meta.json | 1 + content/problems/valid-anagram/meta.json | 1 + content/problems/valid-palindrome/meta.json | 1 + docs/product-tiering.md | 27 + docs/seguranca.md | 44 + drizzle.config.ts | 10 + lib/auth-client.ts | 6 + lib/auth.ts | 31 + lib/billing/entitlements.ts | 23 + lib/billing/pricing-env.ts | 44 + lib/billing/stripe-subscription.ts | 9 + lib/billing/stripe.ts | 7 + lib/billing/tiering.ts | 24 + lib/catalog/problem-card-model.ts | 3 + lib/content/schemas.ts | 6 + lib/db/index.ts | 16 + lib/db/schema.ts | 80 ++ lib/progress/local-progress-schema.ts | 15 +- lib/progress/local-progress.ts | 6 +- lib/progress/merge-blobs.ts | 39 + lib/security/rate-limit.ts | 22 + next.config.ts | 21 + package.json | 7 + pnpm-lock.yaml | 1121 ++++++++++++++++- 61 files changed, 2665 insertions(+), 47 deletions(-) create mode 100644 app/api/auth/[...all]/route.ts create mode 100644 app/api/billing/status/route.ts create mode 100644 app/api/checkout/route.ts create mode 100644 app/api/progress/merge/route.ts create mode 100644 app/api/progress/route.ts create mode 100644 app/api/webhooks/stripe/route.ts create mode 100644 app/auth/sign-in/page.tsx create mode 100644 app/auth/sign-up/page.tsx create mode 100644 app/legal/cookies/page.tsx create mode 100644 app/legal/privacy/page.tsx create mode 100644 app/legal/refunds/page.tsx create mode 100644 app/legal/terms/page.tsx create mode 100644 app/pricing/checkout-success-analytics.tsx create mode 100644 app/pricing/page.tsx create mode 100644 components/auth/sign-in-form.tsx create mode 100644 components/auth/sign-up-form.tsx create mode 100644 components/billing/checkout-button.tsx create mode 100644 components/billing/paywall-analytics.tsx create mode 100644 components/billing/pricing-analytics.tsx create mode 100644 components/billing/progress-sync.tsx create mode 100644 components/billing/upgrade-prompt.tsx create mode 100644 components/layout/session-nav.tsx create mode 100644 docs/product-tiering.md create mode 100644 docs/seguranca.md create mode 100644 drizzle.config.ts create mode 100644 lib/auth-client.ts create mode 100644 lib/auth.ts create mode 100644 lib/billing/entitlements.ts create mode 100644 lib/billing/pricing-env.ts create mode 100644 lib/billing/stripe-subscription.ts create mode 100644 lib/billing/stripe.ts create mode 100644 lib/billing/tiering.ts create mode 100644 lib/db/index.ts create mode 100644 lib/db/schema.ts create mode 100644 lib/progress/merge-blobs.ts create mode 100644 lib/security/rate-limit.ts 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..f47cbcc --- /dev/null +++ b/app/api/checkout/route.ts @@ -0,0 +1,56 @@ +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; + + 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 }); +} 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..1dced2b --- /dev/null +++ b/app/auth/sign-in/page.tsx @@ -0,0 +1,34 @@ +import Link from 'next/link'; +import type { Metadata } from 'next'; + +import { SignInForm } from '@/components/auth/sign-in-form'; +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 ( +
+
+ + ← Início + +
+

Conta

+

Iniciar sessão

+

+ Usa o mesmo email e palavra-passe que definiste no registo. +

+
+
+ +
+
+
+ ); +} diff --git a/app/auth/sign-up/page.tsx b/app/auth/sign-up/page.tsx new file mode 100644 index 0000000..6c40588 --- /dev/null +++ b/app/auth/sign-up/page.tsx @@ -0,0 +1,34 @@ +import Link from 'next/link'; +import type { Metadata } from 'next'; + +import { SignUpForm } from '@/components/auth/sign-up-form'; +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 ( +
+
+ + ← Início + +
+

Conta

+

Criar conta

+

+ O progresso local será fundido com a conta no primeiro login (ver política de privacidade). +

+
+
+ +
+
+
+ ); +} diff --git a/app/layout.tsx b/app/layout.tsx index 3e1ee23..bcbfd76 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -4,6 +4,7 @@ import { Geist, Geist_Mono } from 'next/font/google'; import './globals.css'; import { AlgoriaPostHogProvider } from '@/components/analytics/posthog-provider'; +import { ProgressSyncOnLogin } from '@/components/billing/progress-sync'; import { JsonLdScript } from '@/components/seo/json-ld'; import { ThemeProvider } from '@/components/theme-provider'; import { SiteFooter } from '@/components/layout/site-footer'; @@ -69,6 +70,7 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac +
{children}
diff --git a/app/legal/cookies/page.tsx b/app/legal/cookies/page.tsx new file mode 100644 index 0000000..bd44423 --- /dev/null +++ b/app/legal/cookies/page.tsx @@ -0,0 +1,32 @@ +import Link from 'next/link'; +import type { Metadata } from 'next'; + +import { buildPublicMetadata } from '@/lib/seo/build-metadata'; + +export const metadata: Metadata = buildPublicMetadata({ + title: 'Cookies', + description: 'Uso de cookies e consentimento no site Algoria.', + pathname: '/legal/cookies', + keywords: ['cookies', 'Algoria'], +}); + +export default function CookiesPage() { + return ( +
+ + ← Início + +

Cookies

+

Última actualização: Maio de 2026

+
+

+ Utilizamos cookies essenciais de sessão para login e segurança (Better Auth). Opcionalmente, PostHog pode definir + cookies ou usar armazenamento local para analytics se configurado no ambiente. +

+

+ Podes bloquear cookies de terceiros no browser; funcionalidades como sessão persistente podem deixar de funcionar. +

+
+
+ ); +} diff --git a/app/legal/privacy/page.tsx b/app/legal/privacy/page.tsx new file mode 100644 index 0000000..d7473c8 --- /dev/null +++ b/app/legal/privacy/page.tsx @@ -0,0 +1,41 @@ +import Link from 'next/link'; +import type { Metadata } from 'next'; + +import { buildPublicMetadata } from '@/lib/seo/build-metadata'; + +export const metadata: Metadata = buildPublicMetadata({ + title: 'Privacidade', + description: 'Como a Algoria trata dados pessoais, cookies e analytics.', + pathname: '/legal/privacy', + keywords: ['privacidade', 'RGPD', 'Algoria'], +}); + +export default function PrivacyPage() { + return ( +
+ + ← Início + +

Política de privacidade

+

Última actualização: Maio de 2026

+
+

+ Conta. Email e credenciais são usados para autenticação (Better Auth) e ligação à subscrição + Stripe. Podes pedir apagamento de conta; parte dos dados pode ser retida por obrigação legal (facturação). +

+

+ Progresso. Dados de estudo locais (localStorage) podem ser fundidos com a conta após login; + o JSON agregado é armazenado em base de dados associada ao teu utilizador. +

+

+ Analytics. Se NEXT_PUBLIC_POSTHOG_KEY estiver activo, eventos + de produto (páginas, player) podem ser enviados de forma anónima ou pseudo-anónima. +

+

+ Contacto. Usa o canal de suporte indicado no site para exercer direitos de acesso, rectificação + ou apagamento (RGPD), sujeito a verificação de identidade. +

+
+
+ ); +} diff --git a/app/legal/refunds/page.tsx b/app/legal/refunds/page.tsx new file mode 100644 index 0000000..152303c --- /dev/null +++ b/app/legal/refunds/page.tsx @@ -0,0 +1,34 @@ +import Link from 'next/link'; +import type { Metadata } from 'next'; + +import { buildPublicMetadata } from '@/lib/seo/build-metadata'; + +export const metadata: Metadata = buildPublicMetadata({ + title: 'Reembolsos', + description: 'Política de reembolso da subscrição Algoria Pro.', + pathname: '/legal/refunds', + keywords: ['reembolso', 'Stripe', 'Algoria'], +}); + +export default function RefundsPage() { + return ( +
+ + ← Início + +

Política de reembolso

+

Última actualização: Maio de 2026

+
+

+ Pagamentos são processados pelo Stripe. Se um cobranço não autorizado ocorrer ou se precisares de reembolso por + erro técnico que impeça o uso do serviço durante vários dias, contacta o suporte com o email da conta e o ID da + factura Stripe. +

+

+ Para subscrições mensais/anuais, aplicam-se as políticas de cancelamento do Stripe e da lei do consumidor na tua + jurisdição; tratamos cada caso manualmente num período razoável de dias úteis. +

+
+
+ ); +} diff --git a/app/legal/terms/page.tsx b/app/legal/terms/page.tsx new file mode 100644 index 0000000..a60f5bb --- /dev/null +++ b/app/legal/terms/page.tsx @@ -0,0 +1,34 @@ +import Link from 'next/link'; +import type { Metadata } from 'next'; + +import { buildPublicMetadata } from '@/lib/seo/build-metadata'; + +export const metadata: Metadata = buildPublicMetadata({ + title: 'Termos de uso', + description: 'Termos de utilização do serviço Algoria.', + pathname: '/legal/terms', + keywords: ['termos', 'Algoria'], +}); + +export default function TermsPage() { + return ( +
+ + ← Início + +

Termos de utilização

+

Última actualização: Maio de 2026

+
+

+ A Algoria oferece conteúdo educativo online. Ao utilizares o site, concordas em não abusar de contas, em não + copiar repositórios de conteúdo para serviços concorrentes sem autorização, e em cumprir as leis aplicáveis. +

+

+ O acesso a conteúdo Pro depende de pagamento válido no Stripe. Os preços e funcionalidades podem evoluir; ajustes + serão comunicados no changelog. +

+

Para questões: contacta o suporte listado no rodapé quando estiver disponível.

+
+
+ ); +} 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..2a5f6f8 --- /dev/null +++ b/app/pricing/page.tsx @@ -0,0 +1,102 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; + +import { CheckoutButton } from '@/components/billing/checkout-button'; +import { PricingPageAnalytics } from '@/components/billing/pricing-analytics'; +import { CheckoutSuccessAnalytics } from './checkout-success-analytics'; +import { Button } from '@/components/ui/button'; +import { + checkoutAvailable, + formatFreeTierPrice, + formatPricingDisplay, +} from '@/lib/billing/pricing-env'; +import { buildPublicMetadata } from '@/lib/seo/build-metadata'; + +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 function PricingPage() { + const { monthly, yearly, yearlyNote } = formatPricingDisplay(); + const canPay = checkoutAvailable(); + + return ( +
+ + +
+ + ← Início + +
+

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

+

+ Catálogo limitado a 10 problemas hero (marcados como «Free» no catálogo), progresso no browser, todas as rotas públicas de conceitos e changelog. +

+

{formatFreeTierPrice()}

+ +
+ +
+

Pro

+

+ Todo o catálogo (problemas marcados «Pro»), player e traces onde existirem, merge de progresso na conta e futuras funcionalidades premium. +

+
    +
  • · Pagamento seguro via Stripe
  • +
  • · Cancelamento na conta Stripe / portal (em breve)
  • +
  • · {yearlyNote}
  • +
+

{monthly}

+

{yearly}

+
+ {canPay ? ( + + ) : ( +

+ Checkout indisponível: configura STRIPE_SECRET_KEY e{' '} + STRIPE_PRICE_PRO_MONTHLY no ambiente. +

+ )} + +
+
+
+ +
+

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..e9403d0 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,17 @@ 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 ( +
+ + ← {problem.meta.title} + + +
+ ); + } + return (
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 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); @@ -201,7 +211,16 @@ export default async function ProblemPage({ params }: { params: Promise - + + ) : ( + strategies + ) + } + />
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/components/auth/sign-in-form.tsx b/components/auth/sign-in-form.tsx new file mode 100644 index 0000000..78e0e08 --- /dev/null +++ b/components/auth/sign-in-form.tsx @@ -0,0 +1,77 @@ +'use client'; + +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; + +import { authClient } from '@/lib/auth-client'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; + +export function SignInForm() { + const router = useRouter(); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + const [loading, setLoading] = 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); + } + } + + return ( +
void onSubmit(e)} className="max-w-sm space-y-4"> + {error ?

{error}

: null} +
+ + setEmail(e.target.value)} + type="email" + autoComplete="email" + required + className="mt-1 rounded-none" + /> +
+
+ + setPassword(e.target.value)} + type="password" + autoComplete="current-password" + required + className="mt-1 rounded-none" + /> +
+ +

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

+
+ ); +} diff --git a/components/auth/sign-up-form.tsx b/components/auth/sign-up-form.tsx new file mode 100644 index 0000000..be564b3 --- /dev/null +++ b/components/auth/sign-up-form.tsx @@ -0,0 +1,96 @@ +'use client'; + +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; + +import { authClient } from '@/lib/auth-client'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; + +export function SignUpForm() { + const router = useRouter(); + const [name, setName] = useState(''); + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(null); + const [loading, setLoading] = 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); + } + } + + return ( +
void onSubmit(e)} className="max-w-sm space-y-4"> + {error ?

{error}

: null} +
+ + setName(e.target.value)} + type="text" + autoComplete="name" + className="mt-1 rounded-none" + /> +
+
+ + setEmail(e.target.value)} + type="email" + autoComplete="email" + required + className="mt-1 rounded-none" + /> +
+
+ + setPassword(e.target.value)} + type="password" + autoComplete="new-password" + required + minLength={8} + className="mt-1 rounded-none" + /> +
+ +

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

+
+ ); +} diff --git a/components/billing/checkout-button.tsx b/components/billing/checkout-button.tsx new file mode 100644 index 0000000..876da05 --- /dev/null +++ b/components/billing/checkout-button.tsx @@ -0,0 +1,37 @@ +'use client'; + +import { useState } from 'react'; + +import { Button } from '@/components/ui/button'; +import { analyticsCapture } from '@/components/analytics/posthog-provider'; + +export function CheckoutButton({ disabled }: { disabled?: boolean }) { + const [loading, setLoading] = useState(false); + + async function onClick() { + setLoading(true); + analyticsCapture('checkout_start'); + try { + const res = await fetch('/api/checkout', { method: 'POST', credentials: 'include' }); + const data = (await res.json()) as { url?: string; error?: string }; + if (!res.ok) { + alert(data.error ?? 'Não foi possível iniciar o checkout.'); + return; + } + if (data.url) window.location.href = data.url; + } finally { + setLoading(false); + } + } + + return ( + + ); +} diff --git a/components/billing/paywall-analytics.tsx b/components/billing/paywall-analytics.tsx new file mode 100644 index 0000000..d055f05 --- /dev/null +++ b/components/billing/paywall-analytics.tsx @@ -0,0 +1,12 @@ +'use client'; + +import { useEffect } from 'react'; + +import { analyticsCapture } from '@/components/analytics/posthog-provider'; + +export function PaywallAnalytics({ problemSlug }: { problemSlug: string }) { + useEffect(() => { + analyticsCapture('paywall_hit', { problem_slug: problemSlug }); + }, [problemSlug]); + 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..43789d6 --- /dev/null +++ b/components/billing/upgrade-prompt.tsx @@ -0,0 +1,28 @@ +import Link from 'next/link'; + +import { PaywallAnalytics } from '@/components/billing/paywall-analytics'; +import { Button } from '@/components/ui/button'; + +export function UpgradePrompt({ context, problemSlug }: { context?: string; problemSlug?: string }) { + return ( +
+ {problemSlug ? : null} +

Algoria Pro

+

+ {context ?? 'Conteúdo exclusivo Pro'} +

+

+ Este problema faz parte do catálogo pago. Assina para aceder ao player linha-a-linha, traces de execução quando + disponíveis e sincronização de progresso entre dispositivos. +

+
+ + +
+
+ ); +} 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/layout/session-nav.tsx b/components/layout/session-nav.tsx new file mode 100644 index 0000000..38843d7 --- /dev/null +++ b/components/layout/session-nav.tsx @@ -0,0 +1,50 @@ +'use client'; + +import Link from 'next/link'; + +import { authClient } from '@/lib/auth-client'; +import { Button } from '@/components/ui/button'; + +export function SessionNav() { + const { data, isPending } = authClient.useSession(); + + if (isPending) { + return ; + } + + if (!data?.user) { + return ( +
+ + Preços + + + Entrar + +
+ ); + } + + return ( +
+ + {data.user.email} + + +
+ ); +} 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..34fe150 100644 --- a/components/layout/site-header.tsx +++ b/components/layout/site-header.tsx @@ -17,6 +17,7 @@ import Link from 'next/link'; import { useEffect, useId, useRef, useState } from 'react'; import { AlgoriaBrand } from '@/components/branding/algoria-logo'; +import { SessionNav } from '@/components/layout/session-nav'; import { ThemeToggle } from '@/components/theme-toggle'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; @@ -136,6 +137,7 @@ export function SiteHeader() {
+ ); } diff --git a/components/layout/site-header.tsx b/components/layout/site-header.tsx index 34fe150..e2d6429 100644 --- a/components/layout/site-header.tsx +++ b/components/layout/site-header.tsx @@ -4,6 +4,7 @@ import { ArrowUpRight, BookOpen, Briefcase, + CircleDollarSign, Code2, GraduationCap, Languages, @@ -23,7 +24,8 @@ 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: '/problems', label: 'Abrir catálogo', description: 'Catálogo e code player', Icon: Code2 }, + { href: '/pricing', label: 'Preços', description: 'Planos Free e Pro', Icon: CircleDollarSign }, { 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 }, @@ -138,17 +140,6 @@ export function SiteHeader() {
- + +
+ {/* 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 index 6c40588..ac9f48e 100644 --- a/app/auth/sign-up/page.tsx +++ b/app/auth/sign-up/page.tsx @@ -1,7 +1,10 @@ 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({ @@ -13,20 +16,65 @@ export const metadata: Metadata = buildPublicMetadata({ export default function SignUpPage() { return ( -
-
- - ← Início - -
-

Conta

-

Criar conta

-

- O progresso local será fundido com a conta no primeiro login (ver política de privacidade). -

-
-
- +
+ {/* 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..e8fab15 100644 --- a/app/changelog/page.tsx +++ b/app/changelog/page.tsx @@ -1,5 +1,8 @@ import type { Metadata } from "next"; import Link from "next/link"; +import { ArrowLeft } from "lucide-react"; + +import { Button } from "@/components/ui/button"; import { getChangelogHtml } from "@/lib/content/loader"; import { buildPublicMetadata } from "@/lib/seo/build-metadata"; @@ -18,12 +21,9 @@ export default async function ChangelogPage() { return (
- - ← Início - +

diff --git a/app/concepts/[slug]/page.tsx b/app/concepts/[slug]/page.tsx index 13782c2..d2b87db 100644 --- a/app/concepts/[slug]/page.tsx +++ b/app/concepts/[slug]/page.tsx @@ -1,13 +1,15 @@ 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 { 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 { getAllConceptSlugs, getConcept, getAdjacentConcepts } from '@/lib/content/loader'; import { buildPublicMetadata } from '@/lib/seo/build-metadata'; import { learningResourceJsonLd } from '@/lib/seo/structured-data'; @@ -55,6 +57,7 @@ export default async function ConceptPage({ const q = (await searchParams) ?? {}; const courseSlug = q.course ?? q.curso; const moduleId = q.module ?? q.modulo; + const adjacent = await getAdjacentConcepts(slug); return (

@@ -67,9 +70,9 @@ export default async function ConceptPage({ /> - - ← Todos os conceitos - + {courseSlug && moduleId ? (
@@ -108,6 +111,12 @@ export default async function ConceptPage({ prose-pre:bg-zinc-900 prose-pre:text-zinc-100" dangerouslySetInnerHTML={{ __html: concept.bodyHtml }} /> + +
); } 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/engineering-work/[slug]/page.tsx b/app/engineering-work/[slug]/page.tsx index 6f21a94..374c50b 100644 --- a/app/engineering-work/[slug]/page.tsx +++ b/app/engineering-work/[slug]/page.tsx @@ -1,12 +1,14 @@ 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 { EngineeringGuideArticle } from '@/components/engenharia-trabalho/engineering-guide-article'; +import { ContentNavigation } from '@/components/layout/content-navigation'; import { JsonLdScript } from '@/components/seo/json-ld'; -import { getAllEngineeringWorkSlugs, getEngineeringWorkGuide } from '@/lib/content/loader'; +import { getAllEngineeringWorkSlugs, getEngineeringWorkGuide, getAdjacentEngineeringWork } 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'; @@ -44,6 +46,7 @@ export default async function EngineeringWorkGuidePage({ params }: { params: Pro const { slug } = await params; const guide = await getEngineeringWorkGuide(slug); if (!guide) notFound(); + const adjacent = await getAdjacentEngineeringWork(slug); return (
@@ -54,9 +57,9 @@ export default async function EngineeringWorkGuidePage({ params }: { params: Pro pathname: `/engineering-work/${slug}`, })} /> - - ← Engenharia no trabalho - +
@@ -71,6 +74,12 @@ export default async function EngineeringWorkGuidePage({ params }: { params: Pro

{guide.meta.summary}

+ +
); } 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/layout.tsx b/app/layout.tsx index bcbfd76..4853e17 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -9,6 +9,7 @@ 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 { SidebarProvider } from '@/components/layout/sidebar'; import { getMetadataBase, getSiteOrigin } from '@/lib/seo/site'; const geistSans = Geist({ variable: '--font-geist-sans', subsets: ['latin'] }); @@ -72,7 +73,11 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac -
{children}
+ +
+ {children} +
+
diff --git a/app/legal/cookies/page.tsx b/app/legal/cookies/page.tsx index bd44423..f5aa8c6 100644 --- a/app/legal/cookies/page.tsx +++ b/app/legal/cookies/page.tsx @@ -1,5 +1,8 @@ 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'; @@ -13,9 +16,9 @@ export const metadata: Metadata = buildPublicMetadata({ export default function CookiesPage() { return (
- - ← Início - +

Cookies

Última actualização: Maio de 2026

diff --git a/app/legal/privacy/page.tsx b/app/legal/privacy/page.tsx index d7473c8..cbe28e7 100644 --- a/app/legal/privacy/page.tsx +++ b/app/legal/privacy/page.tsx @@ -1,5 +1,8 @@ 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'; @@ -13,9 +16,9 @@ export const metadata: Metadata = buildPublicMetadata({ export default function PrivacyPage() { return (
- - ← Início - +

Política de privacidade

Última actualização: Maio de 2026

diff --git a/app/legal/refunds/page.tsx b/app/legal/refunds/page.tsx index 152303c..4c2c0e9 100644 --- a/app/legal/refunds/page.tsx +++ b/app/legal/refunds/page.tsx @@ -1,5 +1,8 @@ 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'; @@ -13,9 +16,9 @@ export const metadata: Metadata = buildPublicMetadata({ export default function RefundsPage() { return (
- - ← Início - +

Política de reembolso

Última actualização: Maio de 2026

diff --git a/app/legal/terms/page.tsx b/app/legal/terms/page.tsx index a60f5bb..f0c1b54 100644 --- a/app/legal/terms/page.tsx +++ b/app/legal/terms/page.tsx @@ -1,5 +1,8 @@ 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'; @@ -13,9 +16,9 @@ export const metadata: Metadata = buildPublicMetadata({ export default function TermsPage() { return (
- - ← Início - +

Termos de utilização

Última actualização: Maio de 2026

diff --git a/app/pricing/page.tsx b/app/pricing/page.tsx index 2a5f6f8..645432a 100644 --- a/app/pricing/page.tsx +++ b/app/pricing/page.tsx @@ -1,5 +1,6 @@ import type { Metadata } from 'next'; import Link from 'next/link'; +import { ArrowLeft } from 'lucide-react'; import { CheckoutButton } from '@/components/billing/checkout-button'; import { PricingPageAnalytics } from '@/components/billing/pricing-analytics'; @@ -28,9 +29,9 @@ export default function PricingPage() {
- - ← Início - +

Monetização transparente

Planos

diff --git a/app/problems/[slug]/[solution]/page.tsx b/app/problems/[slug]/[solution]/page.tsx index e9403d0..4abcca0 100644 --- a/app/problems/[slug]/[solution]/page.tsx +++ b/app/problems/[slug]/[solution]/page.tsx @@ -140,9 +140,9 @@ export default async function SolutionPage({ if (!unlocked) { return (
- - ← {problem.meta.title} - +
); @@ -159,12 +159,9 @@ export default async function SolutionPage({ /> - - {problem.meta.title} - +

{solution.meta.name}

diff --git a/app/problems/[slug]/page.tsx b/app/problems/[slug]/page.tsx index 267d5ce..b798ec3 100644 --- a/app/problems/[slug]/page.tsx +++ b/app/problems/[slug]/page.tsx @@ -2,10 +2,11 @@ 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'; @@ -13,11 +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 { auth } from '@/lib/auth'; import { userHasPro } from '@/lib/billing/entitlements'; import { getProblemAccess, isProblemUnlockedForUser } from '@/lib/billing/tiering'; -import { getAllProblemSlugs, getProblem, getConcept } from '@/lib/content/loader'; +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'; @@ -68,6 +70,7 @@ export default async function ProblemPage({ params }: { params: Promise 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)))) @@ -194,9 +197,9 @@ export default async function ProblemPage({ params }: { params: Promise /> - - ← Todos os problemas - +
@@ -223,6 +226,12 @@ export default async function ProblemPage({ params }: { params: Promise /> + +
); } 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/components/auth/sign-in-form.tsx b/components/auth/sign-in-form.tsx index 78e0e08..4a619aa 100644 --- a/components/auth/sign-in-form.tsx +++ b/components/auth/sign-in-form.tsx @@ -3,17 +3,44 @@ 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(); @@ -32,46 +59,127 @@ export function SignInForm() { } } + async function onGoogleSignIn() { + setError(null); + setGoogleLoading(true); + try { + await authClient.signIn.social({ + provider: 'google', + callbackURL: '/problems', + }); + } catch { + setError('Não foi possível iniciar sessão com Google.'); + setGoogleLoading(false); + } + } + return ( -
void onSubmit(e)} className="max-w-sm space-y-4"> - {error ?

{error}

: null} -
- - setEmail(e.target.value)} - type="email" - autoComplete="email" - required - className="mt-1 rounded-none" - /> -
-
- - setPassword(e.target.value)} - type="password" - autoComplete="current-password" - required - className="mt-1 rounded-none" - /> -
- -

+ + {/* 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 + + Criar conta gratuitamente

- +
); } diff --git a/components/auth/sign-up-form.tsx b/components/auth/sign-up-form.tsx index be564b3..9fa15ea 100644 --- a/components/auth/sign-up-form.tsx +++ b/components/auth/sign-up-form.tsx @@ -3,18 +3,45 @@ 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(); @@ -37,60 +64,154 @@ export function SignUpForm() { } } + async function onGoogleSignUp() { + setError(null); + setGoogleLoading(true); + try { + await authClient.signIn.social({ + provider: 'google', + callbackURL: '/problems', + }); + } catch { + setError('Não foi possível criar conta com Google.'); + setGoogleLoading(false); + } + } + return ( -
void onSubmit(e)} className="max-w-sm space-y-4"> - {error ?

{error}

: null} -
- - setName(e.target.value)} - type="text" - autoComplete="name" - className="mt-1 rounded-none" - /> -
-
- - setEmail(e.target.value)} - type="email" - autoComplete="email" - required - className="mt-1 rounded-none" - /> +
+ {/* Google Sign-Up */} + + + {/* Divider */} +
+
+ + ou com email + +
-
- - setPassword(e.target.value)} - type="password" - autoComplete="new-password" - required - minLength={8} - className="mt-1 rounded-none" - /> + + {/* 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. +

- -

- Já tens conta?{' '} - - Entrar - -

- +
); } 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/sidebar.tsx b/components/layout/sidebar.tsx new file mode 100644 index 0000000..0c00210 --- /dev/null +++ b/components/layout/sidebar.tsx @@ -0,0 +1,189 @@ +'use client'; + +import { + ArrowUpRight, + BookOpen, + Briefcase, + ChevronLeft, + ChevronRight, + CircleDollarSign, + Code2, + GraduationCap, + Languages, + Map, + Sparkles, + Target, +} from 'lucide-react'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { createContext, useContext, useEffect, useState } from 'react'; + +import { cn } from '@/lib/utils'; + +const SIDEBAR_NAV = [ + { href: '/problems', label: 'Catálogo', description: 'Problemas & player', Icon: Code2 }, + { href: '/pricing', label: 'Preços', description: 'Planos Free e Pro', Icon: CircleDollarSign }, + { href: '/tracks', label: 'Trilhos', description: 'Percursos curados', Icon: Map }, + { href: '/concepts', label: 'Conceitos', description: 'Resumos algoritmos', Icon: BookOpen }, + { href: '/interview-en', label: 'Inglês EN', description: 'Preparação entrevista', Icon: Languages }, + { href: '/course', label: 'Curso', description: 'Fundamentos guiados', Icon: GraduationCap }, + { href: '/#technical-job-tests', label: 'Testes técnicos', description: 'Preparação vagas', Icon: Target }, + { href: '/engineering-work', label: 'Engenharia', description: 'Roadmap produção', Icon: Briefcase }, + { href: '/changelog', label: 'Novidades', description: 'Últimas alterações', Icon: Sparkles }, +] as const; + +const SIDEBAR_STORAGE_KEY = 'algoria-sidebar-collapsed'; +const SIDEBAR_WIDTH_COLLAPSED = 48; +const SIDEBAR_WIDTH_EXPANDED = 260; + +/* ── Context to share sidebar width with the layout ── */ + +const SidebarContext = createContext<{ width: number }>({ width: SIDEBAR_WIDTH_COLLAPSED }); + +export function useSidebarWidth() { + return useContext(SidebarContext).width; +} + +export function SidebarProvider({ children }: { children: React.ReactNode }) { + const [collapsed, setCollapsed] = useState(true); + + useEffect(() => { + try { + const stored = localStorage.getItem(SIDEBAR_STORAGE_KEY); + if (stored !== null) setCollapsed(stored === 'true'); + } catch { + /* noop */ + } + }, []); + + 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; + }); + }; + + return ( + + + {children} + + ); +} + +function SidebarInner({ collapsed, onToggle }: { collapsed: boolean; onToggle: () => void }) { + const pathname = usePathname(); + + const isActive = (href: string) => { + if (href === '/') return pathname === '/'; + return pathname === href || pathname.startsWith(href + '/'); + }; + + return ( + <> + {/* Toggle button — outside the sidebar, at the top-right edge */} + + + {/* Sidebar */} + + + ); +} diff --git a/components/layout/site-header.tsx b/components/layout/site-header.tsx index e2d6429..f30a88d 100644 --- a/components/layout/site-header.tsx +++ b/components/layout/site-header.tsx @@ -140,12 +140,13 @@ export function SiteHeader() {
+ {/* Menu button: hidden on xl+ where the sidebar takes over */} + +
+
+
+ {user.image ? ( + {user.name} + ) : ( + initials + )} +
+
+

+ {user.name} +

+

+ {user.email} +

+
+ + {isPro ? "Plano Pro" : "Plano Free"} + + + Membro desde{" "} + {new Date(user.createdAt).toLocaleDateString("pt-PT")} + + +
+
+
+ +
+ +
+ {/* Card de Edição de Perfil */} + + + + Personalizar Perfil Público + + + Adiciona informações sobre a tua carreira como developer para o teu perfil. + + + + + + + + {/* Card de Desempenho */} + + + + Desempenho e Progresso + + + +
+ + Problemas Resolvidos + + + {completedProblems} + +
+
+ + Em Progresso + + + {inProgressProblems} + +
+
+ + Soluções Lidas + + + {solutionsOpened} + +
+
+
+ + {/* Card de Subscrição */} + + + + O Teu Plano + + + +
+

+ {isPro ? "Pro" : "Free"} +

+

+ {isPro + ? "Tens acesso completo ao catálogo, code player passo-a-passo e funcionalidades premium." + : "Acesso a rotas públicas, changelog e problemas limitados (10 marcados como hero)."} +

+
+ + {!isPro && ( + + )} +
+
+ + {/* 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/user/[id]/page.tsx b/app/user/[id]/page.tsx new file mode 100644 index 0000000..7f9870b --- /dev/null +++ b/app/user/[id]/page.tsx @@ -0,0 +1,197 @@ +import { eq } from 'drizzle-orm'; +import { notFound } from 'next/navigation'; +import { ArrowLeft, BookOpen, Code2, Award, Link2 } from 'lucide-react'; +import Link from 'next/link'; + +import { db } from '@/lib/db'; +import { user, userProfile, userProgress } from '@/lib/db/schema'; +import { ProgressBlobSchema } from '@/lib/progress/local-progress-schema'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { buildPublicMetadata } from '@/lib/seo/build-metadata'; + +interface PublicProfileProps { + params: Promise<{ id: string }>; +} + +export async function generateMetadata({ params }: PublicProfileProps) { + const resolvedParams = await params; + const userData = await db.select().from(user).where(eq(user.id, resolvedParams.id)).limit(1); + if (!userData[0]) return buildPublicMetadata({ title: 'Perfil não encontrado', description: 'Perfil de utilizador não encontrado.', pathname: `/user/${resolvedParams.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/${resolvedParams.id}`, + }); +} + +export default async function PublicProfilePage({ params }: PublicProfileProps) { + const resolvedParams = await params; + const id = resolvedParams.id; + + const [userRows, profileRows, progressRows] = 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), + ]); + + const userData = userRows[0]; + if (!userData) { + notFound(); + } + + const profile = profileRows[0]; + + // Processar progresso + let completedProblems = 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; + solutionsOpened = problems.reduce((acc, p) => acc + (p.openedSolutions?.length || 0), 0); + } catch { + // ignore + } + } + + const initials = userData.name?.substring(0, 2).toUpperCase() || userData.email?.substring(0, 2).toUpperCase() || 'U'; + + return ( +
+
+
+
+
+ +
+ + +
+
+ {userData.image ? ( + {userData.name} + ) : ( + initials + )} +
+
+
+

+ {userData.name} +

+ {profile?.headline && ( +

+ {profile.headline} +

+ )} +
+ +
+ + Membro desde {new Date(userData.createdAt).getFullYear()} + + {profile?.githubUrl && ( + + GitHub + + )} + {profile?.linkedinUrl && ( + + LinkedIn + + )} +
+ + {profile?.bio && ( +

+ {profile.bio} +

+ )} +
+
+ +
+
+ + + + Skills & Tecnologias + + + + {profile?.technologies && profile.technologies.length > 0 ? ( +
+ {profile.technologies.map(tech => ( + + {tech} + + ))} +
+ ) : ( +

Nenhuma tecnologia listada.

+ )} +
+
+ + + + + Desempenho + + + +
+ Problemas Resolvidos + {completedProblems} +
+
+ Soluções Lidas + {solutionsOpened} +
+
+
+
+ +
+ + + + Certificados Algoria + + + + {/* Aqui poderemos no futuro listar os certificados reais do utilizador com base no progresso completo */} +
+ +

+ Os certificados obtidos por este utilizador irão aparecer aqui. +

+
+
+
+
+
+
+
+ ); +} 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-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/layout/session-nav.tsx b/components/layout/session-nav.tsx index 75d2566..b7c965e 100644 --- a/components/layout/session-nav.tsx +++ b/components/layout/session-nav.tsx @@ -1,12 +1,29 @@ 'use client'; import Link from 'next/link'; +import { useState, useRef, useEffect } from 'react'; +import { LogOut, User as UserIcon } from 'lucide-react'; +import { useRouter } from 'next/navigation'; import { authClient } from '@/lib/auth-client'; import { Button } from '@/components/ui/button'; 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 ; @@ -25,20 +42,61 @@ export function SessionNav() { ); } + 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 ( -
- - {data.user.email} - - +
+ {user.image ? ( + {user.name} + ) : ( + initials + )} +
+ + {user.name || user.email?.split('@')[0]} + + + + {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/profile/edit-profile-form.tsx b/components/profile/edit-profile-form.tsx new file mode 100644 index 0000000..af55c4a --- /dev/null +++ b/components/profile/edit-profile-form.tsx @@ -0,0 +1,93 @@ +'use client'; + +import { useState } from 'react'; +import { Loader2, Save } from 'lucide-react'; +import { updateProfile } from '@/app/profile/actions'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; + +export function EditProfileForm({ profile }: { profile: any }) { + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + const formData = new FormData(e.currentTarget); + try { + await updateProfile(formData); + } catch (err) { + console.error(err); + alert('Erro ao atualizar perfil.'); + } finally { + setLoading(false); + } + } + + return ( +
void handleSubmit(e)} className="space-y-4"> +
+ + +
+ +
+ +