From c8d819366a09e8e9ed141a7a3802296a86b5fd1e Mon Sep 17 00:00:00 2001 From: JsCodeDevlopment Date: Wed, 13 May 2026 13:13:35 -0300 Subject: [PATCH 01/10] feat: implement content management architecture and schemas for problems, concepts, and technical tracks --- app/admin/_components/admin-nav.tsx | 71 +++++++ .../_components/dashboard/dashboard-table.tsx | 136 +++++++++--- .../forms/engineering-work-form.tsx | 11 +- .../_components/forms/interview-en-form.tsx | 9 +- .../_components/forms/pricing-copy-form.tsx | 157 ++++++++++++++ .../technical-test/general-info-section.tsx | 10 +- app/admin/content/_components/types.ts | 12 +- .../content/content-dashboard-client.tsx | 115 ++++++++-- app/admin/content/content-editor.tsx | 3 + app/admin/layout.tsx | 196 ++++++++---------- app/pricing/page.tsx | 62 ++++-- .../concepts/concepts-catalog-client.tsx | 22 +- lib/actions/admin.ts | 41 ++++ lib/content/content-repository.ts | 28 ++- lib/content/schemas.ts | 23 ++ lib/db/schema.ts | 3 + scratch/check-access.ts | 26 +++ scripts/migrate-access.ts | 59 ++++++ 18 files changed, 796 insertions(+), 188 deletions(-) create mode 100644 app/admin/_components/admin-nav.tsx create mode 100644 app/admin/content/_components/forms/pricing-copy-form.tsx create mode 100644 scratch/check-access.ts create mode 100644 scripts/migrate-access.ts diff --git a/app/admin/_components/admin-nav.tsx b/app/admin/_components/admin-nav.tsx new file mode 100644 index 0000000..82ac0c7 --- /dev/null +++ b/app/admin/_components/admin-nav.tsx @@ -0,0 +1,71 @@ +"use client"; + +import Link from "next/link"; +import { usePathname, useSearchParams } from "next/navigation"; + +export function AdminNav({ + navItems, + mobile, +}: { + navItems: any[]; + mobile?: boolean; +}) { + const pathname = usePathname(); + const searchParams = useSearchParams(); + + function checkActive(href: string) { + if (href.includes("?")) { + const [path, query] = href.split("?"); + const params = new URLSearchParams(query); + // Verifica se o path bate E se TODOS os query params do item estão presentes na URL atual + return ( + pathname === path && + Array.from(params.entries()).every( + ([k, v]) => searchParams.get(k) === v, + ) + ); + } + + // Caso especial: se estamos na página de conteúdos mas com filtro de acesso, + // o link geral de "Conteúdos" não deve ficar ativo. + if (href === "/admin/content" && searchParams.get("access")) { + return false; + } + + return pathname === href; + } + + return ( + + ); +} diff --git a/app/admin/content/_components/dashboard/dashboard-table.tsx b/app/admin/content/_components/dashboard/dashboard-table.tsx index 9e79cee..c84e6b9 100644 --- a/app/admin/content/_components/dashboard/dashboard-table.tsx +++ b/app/admin/content/_components/dashboard/dashboard-table.tsx @@ -1,8 +1,7 @@ -'use client'; +"use client"; -import Link from 'next/link'; -import Image from 'next/image'; -import { cn } from "@/lib/utils"; +import Image from "next/image"; +import Link from "next/link"; import { ContentRow, ContentStatus } from "../types"; import { STATUS_BADGES } from "./dashboard-types"; @@ -10,29 +9,55 @@ interface DashboardTableProps { rows: ContentRow[]; isPending: boolean; onStatusUpdate: (id: string, status: ContentStatus) => void; + onAccessUpdate: (id: string, access: "free" | "pro") => void; + accessFilter?: string; isAdmin: boolean; } -export function DashboardTable({ rows, isPending, onStatusUpdate, isAdmin }: DashboardTableProps) { +export function DashboardTable({ + rows, + isPending, + onStatusUpdate, + onAccessUpdate, + accessFilter, + isAdmin, +}: DashboardTableProps) { return (
- - - - - - - + + + + + + + {isPending && rows.length === 0 ? ( - + - + diff --git a/app/admin/content/_components/forms/engineering-work-form.tsx b/app/admin/content/_components/forms/engineering-work-form.tsx index 3acfa1a..d999542 100644 --- a/app/admin/content/_components/forms/engineering-work-form.tsx +++ b/app/admin/content/_components/forms/engineering-work-form.tsx @@ -1,6 +1,6 @@ "use client"; -import { FormProps, ENGINEERING_PILLARS } from "../types"; +import { FormProps, ENGINEERING_PILLARS, ACCESS_OPTIONS } from "../types"; import { FormField, TextInput, SelectInput, NumberInput } from "../form-elements"; import { MarkdownEditor } from "../markdown-editor"; import { MetadataPreview } from "../metadata-preview"; @@ -45,7 +45,7 @@ export function EngineeringWorkForm({ -
+
+ + setMeta({ ...meta, access: v })} + options={ACCESS_OPTIONS} + /> + + + setMeta({ ...meta, access: v })} + options={ACCESS_OPTIONS} + /> +
{ + const list = [...((meta[key] as string[]) || [])]; + list[index] = value; + setMeta({ ...meta, [key]: list }); + }; + + const handleAddPerk = (key: "freePerks" | "proPerks") => { + const list = [...((meta[key] as string[]) || [])]; + list.push(""); + setMeta({ ...meta, [key]: list }); + }; + + const handleRemovePerk = (key: "freePerks" | "proPerks", index: number) => { + const list = [...((meta[key] as string[]) || [])]; + list.splice(index, 1); + setMeta({ ...meta, [key]: list }); + }; + + return ( +
+
+ + + + + + +
+ + + + + +
+ {/* Free Perks */} +
+
+

+ Vantagens Plano FREE +

+ +
+
+ {freePerks.map((perk, i) => ( +
+ handleUpdatePerks("freePerks", i, v)} + placeholder="ex: 10 Problemas Hero" + /> + +
+ ))} + {freePerks.length === 0 && ( +

Nenhuma vantagem adicionada.

+ )} +
+
+ + {/* Pro Perks */} +
+
+

+ Vantagens Plano PRO +

+ +
+
+ {proPerks.map((perk, i) => ( +
+ handleUpdatePerks("proPerks", i, v)} + placeholder="ex: Todo o catálogo" + /> + +
+ ))} + {proPerks.length === 0 && ( +

Nenhuma vantagem adicionada.

+ )} +
+
+
+ +
+ + setMeta({ ...meta, monthlyPrice: v })} + placeholder="ex: 19€" + /> + + + setMeta({ ...meta, yearlyNote: v })} + placeholder="ex: Ou 190€/ano (2 meses grátis)" + /> + +
+
+ ); +} diff --git a/app/admin/content/_components/forms/technical-test/general-info-section.tsx b/app/admin/content/_components/forms/technical-test/general-info-section.tsx index 9e3c24a..2622831 100644 --- a/app/admin/content/_components/forms/technical-test/general-info-section.tsx +++ b/app/admin/content/_components/forms/technical-test/general-info-section.tsx @@ -1,6 +1,7 @@ "use client"; import { FormField, NumberInput, SelectInput, TextInput } from "../../form-elements"; +import { ACCESS_OPTIONS } from "../../types"; import { TopicSelector } from "./topic-selector"; const TRACKS = [ @@ -72,7 +73,7 @@ export function GeneralInfoSection({
-
+
+ + setMeta({ ...meta, access: v })} + options={ACCESS_OPTIONS} + /> +
diff --git a/app/admin/content/_components/types.ts b/app/admin/content/_components/types.ts index d85793a..0e0f452 100644 --- a/app/admin/content/_components/types.ts +++ b/app/admin/content/_components/types.ts @@ -52,6 +52,7 @@ export interface ContentRow { id: string; slug: string; type: string; + access: "free" | "pro"; title: string; status: ContentStatus; version: number; @@ -114,8 +115,9 @@ export const DEFAULT_META: Record> = { difficulty: "easy", estimatedMinutes: 12, summary: "", + access: "pro", }, - "engineering-work": { pillar: "frontend", estimatedMinutes: 15, summary: "" }, + "engineering-work": { pillar: "frontend", estimatedMinutes: 15, summary: "", access: "pro" }, problem: { difficulty: "easy", categories: [], @@ -134,5 +136,11 @@ export const DEFAULT_META: Record> = { }, course: { subtitle: "", moduleCount: 0, moduleIds: [] }, changelog: {}, - "technical-test": {}, + "technical-test": { access: "pro" }, + "pricing-copy": { + freePerks: [], + proPerks: [], + monthlyPrice: "", + yearlyNote: "", + }, }; diff --git a/app/admin/content/content-dashboard-client.tsx b/app/admin/content/content-dashboard-client.tsx index adc886d..d808d84 100644 --- a/app/admin/content/content-dashboard-client.tsx +++ b/app/admin/content/content-dashboard-client.tsx @@ -1,6 +1,6 @@ 'use client'; -import { listContents, updateContentStatus } from '@/lib/actions/admin'; +import { listContents, updateContentStatus, updateContentAccess } from '@/lib/actions/admin'; import { useCallback, useEffect, useState, useTransition } from 'react'; import { useSearchParams } from 'next/navigation'; @@ -21,12 +21,54 @@ export default function ContentDashboardClient({ isAdmin }: { isAdmin: boolean } ); const [rows, setRows] = useState([]); const [total, setTotal] = useState(0); - const [page, setPage] = useState(1); - const [search, setSearch] = useState(''); + const [page, setPage] = useState(parseInt(searchParams.get('page') || '1')); + const [search, setSearch] = useState(searchParams.get('search') ?? ''); const [typeFilter, setTypeFilter] = useState(searchParams.get('type') ?? ''); const [statusFilter, setStatusFilter] = useState(searchParams.get('status') ?? ''); + const [accessFilter, setAccessFilter] = useState(searchParams.get('access') ?? ''); const [feedback, setFeedback] = useState<{ type: 'success' | 'error'; msg: string } | null>(null); + // Helper to update URL params without a full refresh + const updateUrl = useCallback((updates: Record) => { + const params = new URLSearchParams(window.location.search); + Object.entries(updates).forEach(([key, value]) => { + if (value === null || value === '') { + params.delete(key); + } else { + params.set(key, value); + } + }); + window.history.pushState(null, '', `?${params.toString()}`); + }, []); + + // Sync state with URL when it changes (for sidebar navigation and back/forward) + useEffect(() => { + const urlType = searchParams.get('type') ?? ''; + const urlTab = searchParams.get('tab') ?? ''; + const urlStatus = searchParams.get('status') ?? ''; + const urlSearch = searchParams.get('search') ?? ''; + const urlAccess = searchParams.get('access') ?? ''; + const urlPage = parseInt(searchParams.get('page') || '1'); + + // Se houver um tipo específico na URL, verificamos se ele é de sistema + const SYSTEM_TYPES_LIST = ['changelog', 'legal-page', 'landing-section', 'pricing-copy', 'navigation', 'taxonomy']; + const isSystemType = SYSTEM_TYPES_LIST.includes(urlType); + + if (isSystemType && tab !== 'sistema' && isAdmin) { + setTab('sistema'); + } else if (urlTab === 'sistema' && tab !== 'sistema' && isAdmin) { + setTab('sistema'); + } else if (urlTab === 'editorial' && tab !== 'editorial') { + setTab('editorial'); + } + + setTypeFilter(urlType); + setStatusFilter(urlStatus); + setSearch(urlSearch); + setAccessFilter(urlAccess); + setPage(urlPage); + }, [searchParams, isAdmin, tab]); + const load = useCallback(async (p: number, s: string) => { const result = await listContents({ page: p, @@ -34,20 +76,21 @@ export default function ContentDashboardClient({ isAdmin }: { isAdmin: boolean } type: typeFilter || undefined, status: statusFilter || undefined, tab, + access: accessFilter || undefined, }); if (!result.error) { setRows(result.contents as unknown as ContentRow[]); setTotal(result.total); } - }, [typeFilter, statusFilter, tab]); + }, [typeFilter, statusFilter, tab, accessFilter]); useEffect(() => { const timer = setTimeout(() => { load(page, search); }, 300); return () => clearTimeout(timer); - }, [page, typeFilter, statusFilter, search, tab, load]); + }, [page, typeFilter, statusFilter, search, tab, accessFilter, load]); function handleTabChange(newTab: 'editorial' | 'sistema') { setTab(newTab); @@ -55,6 +98,15 @@ export default function ContentDashboardClient({ isAdmin }: { isAdmin: boolean } setTypeFilter(''); setStatusFilter(''); setSearch(''); + + // Update URL + const params = new URLSearchParams(searchParams.toString()); + params.set('tab', newTab); + params.delete('type'); + params.delete('status'); + params.delete('search'); + params.set('page', '1'); + window.history.pushState(null, '', `?${params.toString()}`); } function handleStatusUpdate(id: string, status: ContentStatus) { @@ -70,6 +122,19 @@ export default function ContentDashboardClient({ isAdmin }: { isAdmin: boolean } }); } + function handleAccessUpdate(id: string, access: 'free' | 'pro') { + startTransition(async () => { + const res = await updateContentAccess(id, access); + if (res.success) { + setFeedback({ type: 'success', msg: `Conteúdo agora é ${access === 'free' ? 'Gratuito' : 'Pro'}` }); + load(page, search); + } else if (res.error) { + setFeedback({ type: 'error', msg: res.error }); + } + setTimeout(() => setFeedback(null), 3000); + }); + } + function clearFilters() { setSearch(''); setTypeFilter(''); @@ -79,10 +144,16 @@ export default function ContentDashboardClient({ isAdmin }: { isAdmin: boolean } return (
- +
- + {!accessFilter && ( + + )} {tab === 'sistema' && isAdmin && (
@@ -111,18 +182,35 @@ export default function ContentDashboardClient({ isAdmin }: { isAdmin: boolean } { + setSearch(v); + setPage(1); + updateUrl({ search: v, page: '1' }); + }} typeFilter={typeFilter} - onTypeFilterChange={setTypeFilter} + onTypeFilterChange={(v) => { + setTypeFilter(v); + setPage(1); + updateUrl({ type: v, page: '1' }); + }} statusFilter={statusFilter} - onStatusFilterChange={setStatusFilter} - onClearFilters={clearFilters} + onStatusFilterChange={(v) => { + setStatusFilter(v); + setPage(1); + updateUrl({ status: v, page: '1' }); + }} + onClearFilters={() => { + clearFilters(); + updateUrl({ type: null, status: null, search: null, page: '1' }); + }} /> @@ -130,7 +218,10 @@ export default function ContentDashboardClient({ isAdmin }: { isAdmin: boolean } page={page} total={total} pageSize={20} - onPageChange={setPage} + onPageChange={(p) => { + setPage(p); + updateUrl({ page: p.toString() }); + }} />
diff --git a/app/admin/content/content-editor.tsx b/app/admin/content/content-editor.tsx index 031e0a3..ed69884 100644 --- a/app/admin/content/content-editor.tsx +++ b/app/admin/content/content-editor.tsx @@ -11,6 +11,7 @@ import { GenericForm } from "./_components/forms/generic-form"; import { InterviewEnForm } from "./_components/forms/interview-en-form"; import { ProblemForm } from "./_components/forms/problem-form"; import { TechnicalTestForm } from "./_components/forms/technical-test-form"; +import { PricingCopyForm } from "./_components/forms/pricing-copy-form"; import { ContentEditorProps, DEFAULT_META, @@ -106,6 +107,8 @@ export function ContentEditor({ mode, initialData }: ContentEditorProps) { return ; case "technical-test": return ; + case "pricing-copy": + return ; default: return ; } diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index 9a63b1b..bc27753 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -1,19 +1,44 @@ -import type { Metadata } from 'next'; -import Link from 'next/link'; -import Image from 'next/image'; +import type { Metadata } from "next"; +import Image from "next/image"; -import { requireContributor, isAdminRole } from '@/lib/admin/auth-guard'; +import { isAdminRole, requireContributor } from "@/lib/admin/auth-guard"; +import { AdminNav } from "./_components/admin-nav"; export const metadata: Metadata = { - title: 'Admin Panel', + title: "Admin Panel", robots: { index: false, follow: false }, }; const ALL_NAV_ITEMS = [ - { href: '/admin', label: 'Dashboard', icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6', adminOnly: true }, - { href: '/admin/users', label: 'Utilizadores', icon: 'M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z', adminOnly: true }, - { href: '/admin/categories', label: 'Categorias', icon: 'M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z', adminOnly: true }, - { href: '/admin/content', label: 'Conteúdos', icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z' }, + { + href: "/admin", + label: "Dashboard", + icon: "M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6", + adminOnly: true, + }, + { + href: "/admin/users", + label: "Utilizadores", + icon: "M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z", + adminOnly: true, + }, + { + href: "/admin/categories", + label: "Categorias", + icon: "M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z", + adminOnly: true, + }, + { + href: "/admin/content?access=pro", + label: "Planos & Preços", + icon: "M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z", + adminOnly: true, + }, + { + href: "/admin/content", + label: "Conteúdos", + icon: "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z", + }, ]; export default async function AdminLayout({ @@ -23,122 +48,77 @@ export default async function AdminLayout({ }) { const session = await requireContributor(); const isAdmin = isAdminRole(session.role); - const navItems = ALL_NAV_ITEMS.filter(item => !item.adminOnly || isAdmin); + const navItems = ALL_NAV_ITEMS.filter((item) => !item.adminOnly || isAdmin); return ( -
- {/* Sidebar */} -
+
+

Algoria Admin

+

+ Management Panel +

+
+
- {/* User footer */} -
-
- {session.image ? ( - - ) : ( -
- {session.name.charAt(0).toUpperCase()} +
+
+
+

+ {session.name} +

+

+ {session.role} +

- )} -
-

- {session.name} -

-

- {session.role} -

+ {session.image ? ( + + ) : ( +
+ {session.name.charAt(0).toUpperCase()} +
+ )}
- - {/* Mobile header */} -
-
-
- - - + {/* Row 2: Horizontal Navigation */} +
+
+
- Admin Panel - -
- - {/* Main content */} -
- {children}
-
+ + + {/* Main content */} +
+ {children} +
); } diff --git a/app/pricing/page.tsx b/app/pricing/page.tsx index 5fa810f..2970c07 100644 --- a/app/pricing/page.tsx +++ b/app/pricing/page.tsx @@ -9,11 +9,8 @@ import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { auth } from "@/lib/auth"; import { userHasPro } from "@/lib/billing/entitlements"; -import { - checkoutAvailable, - formatFreeTierPrice, - formatPricingDisplay, -} from "@/lib/billing/pricing-env"; +import { checkoutAvailable, formatFreeTierPrice, formatPricingDisplay } from "@/lib/billing/pricing-env"; +import { getContentRepository } from "@/lib/content/content-repository"; import { buildPublicMetadata } from "@/lib/seo/build-metadata"; import { headers } from "next/headers"; import { CheckoutSuccessAnalytics } from "./checkout-success-analytics"; @@ -32,6 +29,37 @@ export default async function PricingPage() { const session = await auth.api.getSession({ headers: await headers() }); const hasPro = await userHasPro(session?.user?.id); + const repo = getContentRepository(); + const pricingData = await repo.getPricingCopy("main-pricing"); + + // Fallbacks if DB content doesn't exist yet + const title = pricingData?.title || "Planos e Preços"; + const description = + pricingData?.body || + "Compara o plano gratuito com a subscrição Pro: desbloqueia o catálogo completo, sincronização de progresso e investimento contínuo em conteúdo."; + + const freePerks = pricingData?.meta?.freePerks?.length + ? pricingData.meta.freePerks + : [ + "10 Problemas Hero (Free)", + "Conceitos de Engenharia Públicos", + "Progresso Local (Browser)", + "Acesso ao Changelog", + ]; + + const proPerks = pricingData?.meta?.proPerks?.length + ? pricingData.meta.proPerks + : [ + "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", + ]; + + const monthlyDisplay = pricingData?.meta?.monthlyPrice || monthly; + const yearlyNoteDisplay = pricingData?.meta?.yearlyNote || yearlyNote; + return (
@@ -55,11 +83,10 @@ export default async function PricingPage() { Monetização Transparente

- Planos e Preços + {title}

- Compara o plano gratuito com a subscrição Pro: desbloqueia o catálogo completo, - sincronização de progresso e investimento contínuo em conteúdo. + {description}

@@ -83,12 +110,7 @@ export default async function PricingPage() { fundamentos.

    - {[ - "10 Problemas Hero (Free)", - "Conceitos de Engenharia Públicos", - "Progresso Local (Browser)", - "Acesso ao Changelog", - ].map((perk) => ( + {freePerks.map((perk) => (
  • - {monthly.replace("/mês", "")} + {monthlyDisplay.replace("/mês", "")}

    / mês

    - {yearlyNote} + {yearlyNoteDisplay}

@@ -136,13 +158,7 @@ export default async function PricingPage() { 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) => ( + {proPerks.map((perk) => (
  • + {c.access === "pro" ? ( + + Pro + + ) : ( + + Free + + )}
    - + {c.title} - {c.access === "pro" && ( - - Pro - - )} {" "} diff --git a/lib/actions/admin.ts b/lib/actions/admin.ts index 6d83680..cd73207 100644 --- a/lib/actions/admin.ts +++ b/lib/actions/admin.ts @@ -17,6 +17,7 @@ import { and, count, desc, eq, ilike, sql } from 'drizzle-orm'; import { revalidatePath } from 'next/cache'; import { headers } from 'next/headers'; import { createHash } from 'node:crypto'; +import { requireAdmin, requireContributor } from '@/lib/admin/auth-guard'; type UserRole = (typeof userRoleEnum.enumValues)[number]; type ContentStatus = (typeof contentStatusEnum.enumValues)[number]; @@ -174,6 +175,7 @@ export async function listContents(params: { status?: string; search?: string; tab?: 'editorial' | 'sistema'; + access?: string; }) { const admin = await getAuthenticatedStaff(); if (!admin) return { error: 'Não autorizado', contents: [], total: 0 }; @@ -195,6 +197,10 @@ export async function listContents(params: { } } + if (params.access) { + conditions.push(eq(contents.access, params.access as never)); + } + if (params.status) conditions.push(eq(contents.status, params.status as never)); if (params.search) { conditions.push( @@ -219,6 +225,7 @@ export async function listContents(params: { id: contents.id, slug: contents.slug, type: contents.type, + access: contents.access, title: contents.title, status: contents.status, version: contents.version, @@ -391,6 +398,7 @@ export async function createContent(params: { title: params.title, body: params.body, metadata: params.metadata, + access: (params.metadata.access as any) || 'pro', status: params.publish ? 'PUBLISHED' : 'DRAFT', contentHash: hash, authorId: user.id, @@ -453,6 +461,7 @@ export async function updateContent( title: params.title, body: params.body, metadata: params.metadata, + access: (params.metadata.access as any) || existing.access, status: newStatus, contentHash: hash, version: existing.version + 1, @@ -686,3 +695,35 @@ export async function getTechnicalTestTopics() { return []; } } + +export async function updateContentAccess(id: string, access: 'free' | 'pro') { + const admin = await requireAdmin(); + if (!admin) return { error: 'Não autorizado' }; + + try { + const [existing] = await db.select().from(contents).where(eq(contents.id, id)).limit(1); + if (!existing) return { error: 'Conteúdo não encontrado' }; + + // Update both column and metadata JSON for consistency + const newMetadata = { + ...(existing.metadata as Record), + access, + }; + + await db + .update(contents) + .set({ + access, + metadata: newMetadata, + updatedBy: admin.id, + updatedAt: new Date(), + }) + .where(eq(contents.id, id)); + + revalidatePath('/admin/content'); + return { success: true }; + } catch (error) { + console.error('Error updating content access:', error); + return { error: 'Erro ao atualizar acesso' }; + } +} diff --git a/lib/content/content-repository.ts b/lib/content/content-repository.ts index c296fe9..62f3850 100644 --- a/lib/content/content-repository.ts +++ b/lib/content/content-repository.ts @@ -25,6 +25,7 @@ import type { TestTrack, TestLevel, TestDifficulty, + PricingCopy, } from './schemas'; import type { StudyTrackFile } from './track-schema'; @@ -54,6 +55,8 @@ export interface ContentRepository { getAllCourses(): Promise; getCourse(slug: string): Promise; + + getPricingCopy(slug: string): Promise; } import { renderMarkdown } from './markdown'; @@ -204,6 +207,15 @@ class DbContentRepository implements ContentRepository { return row ? JSON.parse(row.body) : null; } + async getPricingCopy(slug: string): Promise { + const [row] = await db + .select() + .from(contents) + .where(and(eq(contents.slug, slug), eq(contents.type, 'pricing-copy'), eq(contents.status, 'PUBLISHED'))) + .limit(1); + return row ? this.hydratePricingCopy(row) : null; + } + private hydrateTechnicalTest(row: ContentRow): TechnicalTest { const metadata = row.metadata as Record; const body = JSON.parse(row.body); @@ -216,6 +228,7 @@ class DbContentRepository implements ContentRepository { level: (metadata.level || body.level) as TestLevel, difficulty: (metadata.difficulty || body.difficulty) as TestDifficulty, topic: (metadata.topic || body.topic) as string, + access: row.access as ContentAccess, } as unknown as TechnicalTest; } @@ -233,7 +246,7 @@ class DbContentRepository implements ContentRepository { tags: (meta.tags as string[]) ?? [], estimatedMinutes: (meta.estimatedMinutes as number) ?? 15, recommendedOrder: meta.recommendedOrder as number | undefined, - access: (meta.access as ContentAccess) ?? 'pro', + access: row.access as ContentAccess, }, descriptionHtml: renderMarkdown(row.body), solutions: ((meta.solutions as Record[]) ?? []).map((s) => { @@ -262,6 +275,7 @@ class DbContentRepository implements ContentRepository { ...(row.metadata as Record), slug: row.slug, title: row.title, + access: row.access as ContentAccess, } as Concept['meta'], bodyHtml: renderMarkdown(row.body), }; @@ -273,6 +287,7 @@ class DbContentRepository implements ContentRepository { ...(row.metadata as Record), slug: row.slug, title: row.title, + access: row.access as ContentAccess, } as InterviewEnglishTopic['meta'], bodyHtml: renderMarkdown(row.body), }; @@ -284,10 +299,21 @@ class DbContentRepository implements ContentRepository { ...(row.metadata as Record), slug: row.slug, title: row.title, + access: row.access as ContentAccess, } as EngineeringWorkGuide['meta'], bodyHtml: renderMarkdown(row.body, { didacticBlocks: true }), }; } + + private hydratePricingCopy(row: ContentRow): PricingCopy { + return { + id: row.id, + slug: row.slug, + title: row.title, + body: row.body, + meta: row.metadata as PricingCopy['meta'], + }; + } } /* ── Factory (feature flag) ───────────────────────────────────── */ diff --git a/lib/content/schemas.ts b/lib/content/schemas.ts index 051d3ee..95e856f 100644 --- a/lib/content/schemas.ts +++ b/lib/content/schemas.ts @@ -208,6 +208,8 @@ export const InterviewEnglishMeta = z.object({ estimatedMinutes: z.number().int().positive().default(12), track: InterviewEnglishTrack, difficulty: Difficulty.default('easy'), + /** `pro` = requer assinatura. Omisso = `pro`. */ + access: ContentAccess.default('pro'), }); export type InterviewEnglishMeta = z.infer; @@ -228,6 +230,8 @@ export const EngineeringWorkMeta = z.object({ estimatedMinutes: z.number().int().positive().default(15), pillar: EngineeringWorkPillar, image: z.string().optional(), + /** `pro` = requer assinatura. Omisso = `pro`. */ + access: ContentAccess.default('pro'), }); export type EngineeringWorkMeta = z.infer; @@ -386,4 +390,23 @@ export interface TechnicalTest { questions: QuizQuestion[]; challenge: CodeChallenge; solutions?: TestSolution[]; + access?: ContentAccess; +} + +/* ── Pricing Copy ────────────────────────────────────────────────── */ + +export const PricingCopyMeta = z.object({ + freePerks: z.array(z.string()).default([]), + proPerks: z.array(z.string()).default([]), + monthlyPrice: z.string().optional(), + yearlyNote: z.string().optional(), +}); +export type PricingCopyMeta = z.infer; + +export interface PricingCopy { + id: string; + slug: string; + title: string; + body: string; + meta: PricingCopyMeta; } diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 2a21e31..310a2bb 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -13,6 +13,8 @@ export const contentStatusEnum = pgEnum('content_status', [ 'REJECTED', ]); +export const contentAccessEnum = pgEnum('content_access', ['free', 'pro']); + export const creatorRequestStatusEnum = pgEnum('creator_request_status', [ 'NONE', 'PENDING', @@ -167,6 +169,7 @@ export const contents = pgTable( body: text('body').notNull().default(''), /** Metadados estruturados por tipo (ex: difficulty, categories, examples). */ metadata: json('metadata').$type>().default({}), + access: contentAccessEnum('access').notNull().default('free'), status: contentStatusEnum('status').notNull().default('DRAFT'), version: integer('version').notNull().default(1), authorId: text('authorId').references(() => user.id, { onDelete: 'set null' }), diff --git a/scratch/check-access.ts b/scratch/check-access.ts new file mode 100644 index 0000000..684912a --- /dev/null +++ b/scratch/check-access.ts @@ -0,0 +1,26 @@ +import { db } from './lib/db'; +import { contents } from './lib/db/schema'; + +async function check() { + const all = await db.select({ + id: contents.id, + slug: contents.slug, + type: contents.type, + metadata: contents.metadata + }).from(contents); + + console.log(`Total contents: ${all.length}`); + const types = {}; + all.forEach(c => { + types[c.type] = (types[c.type] || 0) + 1; + const meta = c.metadata as any; + if (meta?.access) { + console.log(`[${c.type}] ${c.slug}: access=${meta.access}`); + } else { + console.log(`[${c.type}] ${c.slug}: access=MISSING`); + } + }); + console.log('Types distribution:', types); +} + +check().catch(console.error); diff --git a/scripts/migrate-access.ts b/scripts/migrate-access.ts new file mode 100644 index 0000000..efa3b2d --- /dev/null +++ b/scripts/migrate-access.ts @@ -0,0 +1,59 @@ +import { db } from '../lib/db'; +import { contents } from '../lib/db/schema'; +import { eq } from 'drizzle-orm'; + +const SYSTEM_TYPES = [ + 'changelog', + 'legal-page', + 'landing-section', + 'pricing-copy', + 'navigation', + 'taxonomy', +]; + +async function migrate() { + console.log('Starting selective migration of access field...'); + + const allContents = await db.select({ + id: contents.id, + slug: contents.slug, + type: contents.type, + metadata: contents.metadata + }).from(contents); + + console.log(`Found ${allContents.length} items to process.`); + + let updatedCount = 0; + + for (const item of allContents) { + const meta = item.metadata as any; + let accessValue: 'free' | 'pro' = 'pro'; + + // Regra 1: Conteúdos de sistema são sempre GRATUITOS + if (SYSTEM_TYPES.includes(item.type)) { + accessValue = 'free'; + } + // Regra 2: Respeitar o que já estiver no metadata + else if (meta?.access === 'free' || meta?.access === 'pro') { + accessValue = meta.access; + } + // Regra 3: Se for um tipo editorial e não tiver info, padrão é PRO + else { + accessValue = 'pro'; + } + + await db.update(contents) + .set({ access: accessValue }) + .where(eq(contents.id, item.id)); + + updatedCount++; + } + + console.log(`Migration completed! Total processed: ${updatedCount}`); + process.exit(0); +} + +migrate().catch(err => { + console.error('Migration failed:', err); + process.exit(1); +}); From de0d245a16d073c7f5931aff503453769e64e2b9 Mon Sep 17 00:00:00 2001 From: JsCodeDevlopment Date: Wed, 13 May 2026 13:20:07 -0300 Subject: [PATCH 02/10] feat: implement dynamic content pages for engineering work, interview English, problems, and technical tests --- app/engineering-work/[slug]/page.tsx | 18 +++++++++++++- app/interview-en/[slug]/page.tsx | 36 ++++++++++++++++++++-------- app/problems/[slug]/page.tsx | 28 +++++++++++----------- app/tests/[track]/[slug]/page.tsx | 23 ++++++++++++++---- 4 files changed, 75 insertions(+), 30 deletions(-) diff --git a/app/engineering-work/[slug]/page.tsx b/app/engineering-work/[slug]/page.tsx index e7e4e60..8d38a9b 100644 --- a/app/engineering-work/[slug]/page.tsx +++ b/app/engineering-work/[slug]/page.tsx @@ -14,6 +14,11 @@ import { getAllEngineeringWorkSlugs, getEngineeringWorkGuide, } from "@/lib/content/loader"; +import { auth } from "@/lib/auth"; +import { userHasPro } from "@/lib/billing/entitlements"; +import { isContentUnlockedForUser } from "@/lib/billing/tiering"; +import { UpgradePrompt } from "@/components/billing/upgrade-prompt"; +import { headers } from "next/headers"; import type { EngineeringWorkPillar } from "@/lib/content/schemas"; import { db } from "@/lib/db"; import { user } from "@/lib/db/schema"; @@ -69,6 +74,11 @@ export default async function EngineeringWorkGuidePage({ const { slug } = await params; const guide = await getEngineeringWorkGuide(slug); if (!guide) notFound(); + + const session = await auth.api.getSession({ headers: await headers() }); + const hasPro = await userHasPro(session?.user?.id); + const isLocked = !isContentUnlockedForUser(guide.meta.access, hasPro); + const adjacent = await getAdjacentEngineeringWork(slug); // Busca o ID do Jonatas para o link do perfil @@ -125,7 +135,13 @@ export default async function EngineeringWorkGuidePage({ href={authorData ? `/user/${authorData.id}` : "#"} /> - + {isLocked ? ( +
    + +
    + ) : ( + + )} {topic.meta.title}

    {topic.meta.summary}

    -
    + {isLocked ? ( +
    + +
    + ) : ( +
    + )} - - ) : ( - strategies - ) - } - /> + {strategiesLocked ? ( +
    + +
    + ) : ( + + )} diff --git a/app/tests/[track]/[slug]/page.tsx b/app/tests/[track]/[slug]/page.tsx index e4eca7d..2d13440 100644 --- a/app/tests/[track]/[slug]/page.tsx +++ b/app/tests/[track]/[slug]/page.tsx @@ -1,9 +1,14 @@ -import { notFound } from "next/navigation"; import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { UpgradePrompt } from "@/components/billing/upgrade-prompt"; import { TestClient } from "@/components/tests/test-client"; +import { auth } from "@/lib/auth"; +import { userHasPro } from "@/lib/billing/entitlements"; +import { isContentUnlockedForUser } from "@/lib/billing/tiering"; import { getContentRepository } from "@/lib/content/content-repository"; import { buildPublicMetadata } from "@/lib/seo/build-metadata"; +import { headers } from "next/headers"; interface Params { track: string; @@ -17,7 +22,7 @@ export async function generateMetadata({ }): Promise { const { slug } = await params; const test = await getContentRepository().getTechnicalTest(slug); - + if (!test) return {}; return buildPublicMetadata({ @@ -33,16 +38,24 @@ export default async function TestExecutionPage({ params: Promise; }) { const { slug } = await params; - + const test = await getContentRepository().getTechnicalTest(slug); if (!test) { notFound(); } + const session = await auth.api.getSession({ headers: await headers() }); + const hasPro = await userHasPro(session?.user?.id); + const isLocked = !isContentUnlockedForUser(test.access, hasPro); + return ( -
    - +
    + {isLocked ? ( + + ) : ( + + )}
    ); } From 2fb33b45bfd8d2149694531f125c1857a2da1a34 Mon Sep 17 00:00:00 2001 From: JsCodeDevlopment Date: Wed, 13 May 2026 13:31:20 -0300 Subject: [PATCH 03/10] feat: add UI components and implement filtered problems and concepts catalog pages --- app/concepts/[slug]/page.tsx | 4 +- app/concepts/page.tsx | 2 +- .../[track]/_components/tests-filters.tsx | 2 +- app/tracks/page.tsx | 2 +- components/catalog/catalog-review-section.tsx | 60 +++++++++++-------- .../catalog/problems-catalog-client.tsx | 8 +-- .../catalog/progress-backup-controls.tsx | 39 ++++++++---- .../concepts/concepts-catalog-client.tsx | 6 +- .../interview-en/interview-catalog-client.tsx | 2 +- components/ui/badge.tsx | 2 +- components/ui/input.tsx | 2 +- components/ui/tabs.tsx | 4 +- 12 files changed, 80 insertions(+), 53 deletions(-) diff --git a/app/concepts/[slug]/page.tsx b/app/concepts/[slug]/page.tsx index 5315264..3b70146 100644 --- a/app/concepts/[slug]/page.tsx +++ b/app/concepts/[slug]/page.tsx @@ -86,14 +86,14 @@ export default async function ConceptPage({ {courseSlug && moduleId ? ( -
    +
    Estás dentro do curso guiado. Quando terminares a página, volta ao módulo atual para marcar a leitura e continuar os exercícios. Voltar ao módulo diff --git a/app/concepts/page.tsx b/app/concepts/page.tsx index a42366c..4f5b31a 100644 --- a/app/concepts/page.tsx +++ b/app/concepts/page.tsx @@ -53,7 +53,7 @@ export default async function ConceptsPage() {

    -
    +

    Nova trilha diff --git a/app/tests/[track]/_components/tests-filters.tsx b/app/tests/[track]/_components/tests-filters.tsx index fadaba6..2571a3c 100644 --- a/app/tests/[track]/_components/tests-filters.tsx +++ b/app/tests/[track]/_components/tests-filters.tsx @@ -168,7 +168,7 @@ export function TestsFilters({ {isPending && (

    -
    Atualizando +
    Atualizando resultados...
    )} diff --git a/app/tracks/page.tsx b/app/tracks/page.tsx index 6c18242..d20da1e 100644 --- a/app/tracks/page.tsx +++ b/app/tracks/page.tsx @@ -53,7 +53,7 @@ export default async function TracksIndexPage() { href={`/tracks/${t.slug}`} className="group block" > - + {t.title} diff --git a/components/catalog/catalog-review-section.tsx b/components/catalog/catalog-review-section.tsx index 3b20133..89792d9 100644 --- a/components/catalog/catalog-review-section.tsx +++ b/components/catalog/catalog-review-section.tsx @@ -1,12 +1,12 @@ -'use client'; +"use client"; -import Link from 'next/link'; -import { useEffect, useMemo, useState } from 'react'; -import { RotateCcw } from 'lucide-react'; +import { RotateCcw } from "lucide-react"; +import Link from "next/link"; +import { useEffect, useMemo, useState } from "react"; -import type { ProblemsCatalogProblem } from '@/lib/catalog/problem-card-model'; -import { loadProgressBlob } from '@/lib/progress/local-progress'; -import { getProblemSlugsDueForReview } from '@/lib/progress/review'; +import type { ProblemsCatalogProblem } from "@/lib/catalog/problem-card-model"; +import { loadProgressBlob } from "@/lib/progress/local-progress"; +import { getProblemSlugsDueForReview } from "@/lib/progress/review"; const DAY_OPTIONS = [7, 14, 30] as const; @@ -20,8 +20,8 @@ export function CatalogReviewSection({ problems }: Props) { useEffect(() => { const onProg = () => setRevision((x) => x + 1); - window.addEventListener('algoria-progress', onProg); - return () => window.removeEventListener('algoria-progress', onProg); + window.addEventListener("algoria-progress", onProg); + return () => window.removeEventListener("algoria-progress", onProg); }, []); const reviewItems = useMemo(() => { @@ -33,14 +33,21 @@ export function CatalogReviewSection({ problems }: Props) { if (reviewItems.length === 0) { return ( -
    +
    - +
    -

    Modo revisão

    +

    + Modo revisão +

    - Quando marcares problemas como concluídos no teu perfil local, aparecem aqui sugestões para rever passados{' '} - {minDays} dias ou mais. Escolhe o intervalo: + Quando marcares problemas como concluídos no teu perfil local, + aparecem aqui sugestões para rever passados{" "} + {minDays} dias ou mais. + Escolhe o intervalo:

    {DAY_OPTIONS.map((d) => ( @@ -50,8 +57,8 @@ export function CatalogReviewSection({ problems }: Props) { onClick={() => setMinDays(d)} className={ minDays === d - ? 'rounded-md border-2 border-primary bg-primary/10 px-3 py-1.5 text-xs font-bold uppercase tracking-wide' - : 'rounded-md border border-border px-3 py-1.5 text-xs font-bold uppercase tracking-wide text-muted-foreground hover:border-primary/40' + ? "rounded-md border-2 border-primary bg-primary/10 px-3 py-1.5 text-xs font-bold uppercase tracking-wide" + : "rounded-md border border-border px-3 py-1.5 text-xs font-bold uppercase tracking-wide text-muted-foreground hover:border-primary/40" } > {d} dias @@ -65,16 +72,21 @@ export function CatalogReviewSection({ problems }: Props) { } return ( -
    +
    - +
    -

    Revisão sugerida

    +

    + Revisão sugerida +

    - Estes problemas foram marcados como concluídos há pelo menos{' '} - {minDays} dias — vale refrescar o enunciado ou uma solução - alternativa. + Estes problemas foram marcados como concluídos há pelo menos{" "} + {minDays} dias — + vale refrescar o enunciado ou uma solução alternativa.

    @@ -86,8 +98,8 @@ export function CatalogReviewSection({ problems }: Props) { onClick={() => setMinDays(d)} className={ minDays === d - ? 'rounded-md border-2 border-primary bg-background px-3 py-1.5 text-[10px] font-bold uppercase tracking-wide' - : 'rounded-md border border-border bg-background/60 px-3 py-1.5 text-[10px] font-bold uppercase tracking-wide text-muted-foreground hover:border-primary/40' + ? "rounded-md border-2 border-primary bg-background px-3 py-1.5 text-[10px] font-bold uppercase tracking-wide" + : "rounded-md border border-border bg-background/60 px-3 py-1.5 text-[10px] font-bold uppercase tracking-wide text-muted-foreground hover:border-primary/40" } > {d} d diff --git a/components/catalog/problems-catalog-client.tsx b/components/catalog/problems-catalog-client.tsx index d3343fe..dbeda61 100644 --- a/components/catalog/problems-catalog-client.tsx +++ b/components/catalog/problems-catalog-client.tsx @@ -91,7 +91,7 @@ export function ProblemsCatalogClient({ problems }: Props) { -
    +
TítuloTipoAutorStatusVersãoAtualizadoAções + Título + + Tipo + + Autor + + Status + + Versão + + Atualizado + + Ações +
+
Carregando... @@ -44,14 +69,28 @@ export function DashboardTable({ rows, isPending, onStatusUpdate, isAdmin }: Das
- - + +
-

Nenhum conteúdo encontrado

+

+ Nenhum conteúdo encontrado +

- {isAdmin ? 'Os conteúdos aparecerão aqui após serem migrados.' : 'Ainda não criaste nenhum conteúdo.'} + {isAdmin + ? "Os conteúdos aparecerão aqui após serem migrados." + : "Ainda não criaste nenhum conteúdo."}

@@ -61,11 +100,18 @@ export function DashboardTable({ rows, isPending, onStatusUpdate, isAdmin }: Das rows.map((row) => { const badge = STATUS_BADGES[row.status]; return ( -
-

{row.title}

-

{row.slug}

+

+ {row.title} +

+

+ {row.slug} +

@@ -77,33 +123,41 @@ export function DashboardTable({ rows, isPending, onStatusUpdate, isAdmin }: Das
{row.authorImage ? ( - {row.authorName ) : (
- {row.authorName?.[0] || '?'} + {row.authorName?.[0] || "?"}
)}
-

{row.authorName || 'Sistema'}

-

{row.authorId?.slice(0, 8)}

+

+ {row.authorName || "Sistema"} +

+

+ {row.authorId?.slice(0, 8)} +

- + {badge.label} v{row.version} - {new Date(row.updatedAt).toLocaleDateString('pt-BR')} + v{row.version} + + {new Date(row.updatedAt).toLocaleDateString("pt-BR")}
@@ -121,15 +175,33 @@ export function DashboardTable({ rows, isPending, onStatusUpdate, isAdmin }: Das Revisar )} - {isAdmin && row.status === 'PENDING_REVIEW' && ( + {isAdmin && row.status === "PENDING_REVIEW" && ( )} + {isAdmin && row.access === "pro" && ( + + )} + {isAdmin && row.access === "free" && ( + + )}