From 0a1b23525e67b1f40ebacc5901f76237a5452f64 Mon Sep 17 00:00:00 2001 From: JsCodeDevlopment Date: Wed, 13 May 2026 18:27:56 -0300 Subject: [PATCH] feat: initialize database schema and implement admin content management and pricing configuration modules --- .../content/_components/form-elements.tsx | 7 +- .../_components/forms/pricing-copy-form.tsx | 157 ------------- app/admin/content/content-editor.tsx | 3 - app/admin/layout.tsx | 2 +- .../pricing/_components/feature-manager.tsx | 102 +++++++++ .../pricing/_components/inventory-summary.tsx | 61 +++++ .../pricing/_components/inventory-table.tsx | 210 +++++++++++++++++ .../pricing/_components/plan-config-card.tsx | 92 ++++++++ app/admin/pricing/page.tsx | 25 ++ app/admin/pricing/pricing-manager-client.tsx | 195 ++++++++++++++++ app/pricing/page.tsx | 49 ++-- lib/actions/admin.ts | 216 +++++++++++++++++- lib/content/content-repository.ts | 33 ++- lib/db/schema.ts | 35 +++ 14 files changed, 995 insertions(+), 192 deletions(-) delete mode 100644 app/admin/content/_components/forms/pricing-copy-form.tsx create mode 100644 app/admin/pricing/_components/feature-manager.tsx create mode 100644 app/admin/pricing/_components/inventory-summary.tsx create mode 100644 app/admin/pricing/_components/inventory-table.tsx create mode 100644 app/admin/pricing/_components/plan-config-card.tsx create mode 100644 app/admin/pricing/page.tsx create mode 100644 app/admin/pricing/pricing-manager-client.tsx diff --git a/app/admin/content/_components/form-elements.tsx b/app/admin/content/_components/form-elements.tsx index 4bd3bea..7ae6060 100644 --- a/app/admin/content/_components/form-elements.tsx +++ b/app/admin/content/_components/form-elements.tsx @@ -30,12 +30,16 @@ export function TextInput({ placeholder, disabled, mono, + className, + autoFocus, }: { value: string; onChange: (v: string) => void; placeholder?: string; disabled?: boolean; mono?: boolean; + className?: string; + autoFocus?: boolean; }) { return ( onChange(e.target.value)} placeholder={placeholder} disabled={disabled} - className={`h-10 w-full rounded-lg border border-border bg-background px-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30 disabled:opacity-50 ${mono ? "font-mono" : ""}`} + autoFocus={autoFocus} + className={`h-10 w-full rounded-lg border border-border bg-background px-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/30 disabled:opacity-50 ${mono ? "font-mono" : ""} ${className || ""}`} /> ); } diff --git a/app/admin/content/_components/forms/pricing-copy-form.tsx b/app/admin/content/_components/forms/pricing-copy-form.tsx deleted file mode 100644 index d917200..0000000 --- a/app/admin/content/_components/forms/pricing-copy-form.tsx +++ /dev/null @@ -1,157 +0,0 @@ -"use client"; - -import { Plus, Trash2 } from "lucide-react"; -import { FormField, TextInput } from "../form-elements"; -import { FormProps } from "../types"; -import { MarkdownEditor } from "../markdown-editor"; - -export function PricingCopyForm({ - slug, - setSlug, - title, - setTitle, - body, - setBody, - meta, - setMeta, -}: FormProps) { - const freePerks = (meta.freePerks as string[]) || []; - const proPerks = (meta.proPerks as string[]) || []; - - const handleUpdatePerks = (key: "freePerks" | "proPerks", index: number, value: string) => { - 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/content-editor.tsx b/app/admin/content/content-editor.tsx index ed69884..031e0a3 100644 --- a/app/admin/content/content-editor.tsx +++ b/app/admin/content/content-editor.tsx @@ -11,7 +11,6 @@ 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, @@ -107,8 +106,6 @@ 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 bc27753..aca43ec 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -29,7 +29,7 @@ const ALL_NAV_ITEMS = [ adminOnly: true, }, { - href: "/admin/content?access=pro", + href: "/admin/pricing", 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, diff --git a/app/admin/pricing/_components/feature-manager.tsx b/app/admin/pricing/_components/feature-manager.tsx new file mode 100644 index 0000000..6c0d7d9 --- /dev/null +++ b/app/admin/pricing/_components/feature-manager.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { useState } from "react"; +import { Plus, Trash2, Edit2, Check } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { TextInput } from "../../content/_components/form-elements"; + +interface Feature { + id: string; + label: string; + planId: string; +} + +interface FeatureManagerProps { + planId: string; + features: Feature[]; + onAdd: (planId: string, label: string) => Promise; + onRemove: (id: string, planId: string) => Promise; + onUpdate: (id: string, label: string, planId: string) => Promise; +} + +export function FeatureManager({ + planId, + features, + onAdd, + onRemove, + onUpdate +}: FeatureManagerProps) { + const [newLabel, setNewLabel] = useState(""); + const [editingId, setEditingId] = useState(null); + + async function handleAdd() { + if (!newLabel.trim()) return; + await onAdd(planId, newLabel.trim()); + setNewLabel(""); + } + + return ( +
+

Vantagens Manuais

+ +
+ {features.map((f) => ( +
+
+ {editingId === f.id ? ( +
+ onUpdate(f.id, v, planId)} + className="h-8 text-xs" + autoFocus + /> + +
+ ) : ( +
+
+ {f.label} +
+ )} +
+ + {!editingId && ( +
+ + +
+ )} +
+ ))} +
+ +
+ + +
+
+ ); +} diff --git a/app/admin/pricing/_components/inventory-summary.tsx b/app/admin/pricing/_components/inventory-summary.tsx new file mode 100644 index 0000000..a046133 --- /dev/null +++ b/app/admin/pricing/_components/inventory-summary.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { Package } from "lucide-react"; + +interface SummaryItem { + category?: string; + label?: string; + count: number; +} + +interface InventorySummaryProps { + pro: SummaryItem[]; + free: SummaryItem[]; +} + +export function InventorySummary({ pro, free }: InventorySummaryProps) { + if (pro.length === 0 && free.length === 0) return null; + + return ( +
+
+ +

Resumo do Conteúdo (Referência)

+
+ +
+ {/* PRO */} +
+
Plano Pro
+
+ {pro.map((s) => ( +
+ {s.count} + {s.category} +
+ ))} + {pro.length === 0 && Nenhum conteúdo Pro} +
+
+ + {/* FREE */} +
+
Plano Free
+
+ {free.map((s) => ( +
+ {s.count} + {s.label} +
+ ))} + {free.length === 0 && Nenhum conteúdo Free} +
+
+
+ +

+ Use estes números como base para escrever as vantagens manuais abaixo. +

+
+ ); +} diff --git a/app/admin/pricing/_components/inventory-table.tsx b/app/admin/pricing/_components/inventory-table.tsx new file mode 100644 index 0000000..fc2e075 --- /dev/null +++ b/app/admin/pricing/_components/inventory-table.tsx @@ -0,0 +1,210 @@ +"use client"; + +import { useState } from "react"; +import { Edit2, Check, ExternalLink, RefreshCw, ChevronLeft, ChevronRight, Unlock } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { TextInput } from "../../content/_components/form-elements"; + +interface InventoryItem { + id: string; + contentId: string; + pricingCategory: string; + contentTitle: string; + contentType: string; + contentSlug: string; +} + +interface InventoryTableProps { + items: InventoryItem[]; + isPending: boolean; + onUpdateCategory: (id: string, category: string) => Promise; + onSync: () => Promise; + onMakeFree: (contentId: string) => Promise; +} + +const ITEMS_PER_PAGE = 10; + +export function InventoryTable({ + items, + isPending, + onUpdateCategory, + onSync, + onMakeFree, +}: InventoryTableProps) { + const [editingId, setEditingId] = useState(null); + const [tempCategory, setTempCategory] = useState(""); + const [currentPage, setCurrentPage] = useState(1); + + // Pagination logic + const totalPages = Math.ceil(items.length / ITEMS_PER_PAGE); + const startIndex = (currentPage - 1) * ITEMS_PER_PAGE; + const paginatedItems = items.slice(startIndex, startIndex + ITEMS_PER_PAGE); + + async function handleSave(id: string) { + if (!tempCategory.trim()) return; + await onUpdateCategory(id, tempCategory.trim()); + setEditingId(null); + } + + return ( +
+
+

+ Catálogo de Conteúdo Pago +

+ +
+ +
+ + + + + + + + + + + {paginatedItems.map((item) => ( + + + + + + + ))} + {items.length === 0 && ( + + + + )} + +
+ Conteúdo + + Tipo + + Categoria de Pricing + Ações
+
+ {item.contentTitle} +
+
+ {item.contentSlug} +
+
+ + {item.contentType} + + + {editingId === item.id ? ( +
+ + +
+ ) : ( +
+ + {item.pricingCategory} + + +
+ )} +
+
+ + +
+
+ Nenhum conteúdo Pro encontrado. Marque conteúdos como Pro na + lista de conteúdos. +
+
+ + {/* Pagination Controls */} + {totalPages > 1 && ( +
+
+ Mostrando {startIndex + 1} a{" "} + {Math.min(startIndex + ITEMS_PER_PAGE, items.length)} de{" "} + {items.length} itens +
+
+ +
+ Página {currentPage} de {totalPages} +
+ +
+
+ )} +
+ ); +} diff --git a/app/admin/pricing/_components/plan-config-card.tsx b/app/admin/pricing/_components/plan-config-card.tsx new file mode 100644 index 0000000..2c8e179 --- /dev/null +++ b/app/admin/pricing/_components/plan-config-card.tsx @@ -0,0 +1,92 @@ +"use client"; + +import { FormField, TextInput } from "../../content/_components/form-elements"; +import { FeatureManager } from "./feature-manager"; + +interface Plan { + id: string; + title: string; + description: string | null; + priceDisplay: string | null; + yearlyNote: string | null; +} + +interface Feature { + id: string; + label: string; + planId: string; +} + +interface PlanConfigCardProps { + plan: Plan; + features: Feature[]; + onUpdate: (id: string, field: keyof Plan, value: string) => Promise; + onAddFeature: (planId: string, label: string) => Promise; + onRemoveFeature: (id: string, planId: string) => Promise; + onUpdateFeature: (id: string, label: string, planId: string) => Promise; +} + +export function PlanConfigCard({ + plan, + features, + onUpdate, + onAddFeature, + onRemoveFeature, + onUpdateFeature +}: PlanConfigCardProps) { + return ( +
+
+
+
+ {plan.id.toUpperCase()} +
+

Configurações {plan.id === 'pro' ? 'Pro' : 'Free'}

+
+ + + onUpdate(plan.id, "title", v)} + /> + + + + onUpdate(plan.id, "description", v)} + /> + + + {plan.id === 'pro' && ( + <> + + onUpdate(plan.id, "priceDisplay", v)} + placeholder="ex: 19€" + /> + + + onUpdate(plan.id, "yearlyNote", v)} + placeholder="ex: Ou 190€/ano" + /> + + + )} +
+ + +
+ ); +} diff --git a/app/admin/pricing/page.tsx b/app/admin/pricing/page.tsx new file mode 100644 index 0000000..dacd23b --- /dev/null +++ b/app/admin/pricing/page.tsx @@ -0,0 +1,25 @@ +import { getPricingPlans, getPricingInventory } from "@/lib/actions/admin"; +import { PricingManagerClient } from "./pricing-manager-client"; + +export default async function AdminPricingPage() { + const plans = await getPricingPlans(); + const inventory = await getPricingInventory(); + + return ( +
+
+

+ Planos & Preços +

+

+ Gerencie os textos dos planos, benefícios manuais e o catálogo de conteúdos Pro. +

+
+ + +
+ ); +} diff --git a/app/admin/pricing/pricing-manager-client.tsx b/app/admin/pricing/pricing-manager-client.tsx new file mode 100644 index 0000000..a31fcb3 --- /dev/null +++ b/app/admin/pricing/pricing-manager-client.tsx @@ -0,0 +1,195 @@ +"use client"; + +import { useState, useTransition, useEffect } from "react"; +import { updatePricingPlan, updateInventoryCategory, syncAllProContent, addPricingFeature, getPricingFeatures, removePricingFeature, updatePricingFeature, getPricingSummary, updateContentAccess } from "@/lib/actions/admin"; +import { InventorySummary } from "./_components/inventory-summary"; +import { PlanConfigCard } from "./_components/plan-config-card"; +import { InventoryTable } from "./_components/inventory-table"; + +interface Plan { + id: string; + title: string; + description: string | null; + priceDisplay: string | null; + yearlyNote: string | null; +} + +interface InventoryItem { + id: string; + contentId: string; + pricingCategory: string; + contentTitle: string; + contentType: string; + contentSlug: string; +} + +interface Feature { + id: string; + label: string; + planId: string; +} + +interface PricingSummary { + pro: { category: string; count: number }[]; + free: { label: string; count: number }[]; +} + +interface PricingManagerClientProps { + initialPlans: Plan[]; + initialInventory: InventoryItem[]; +} + +export function PricingManagerClient({ + initialPlans, + initialInventory +}: PricingManagerClientProps) { + const [activeTab, setActiveTab] = useState<"plans" | "inventory">("plans"); + const [plans, setPlans] = useState(initialPlans); + const [inventory, setInventory] = useState(initialInventory); + const [isPending, startTransition] = useTransition(); + + const [freeFeatures, setFreeFeatures] = useState([]); + const [proFeatures, setProFeatures] = useState([]); + const [summary, setSummary] = useState({ pro: [], free: [] }); + + // Load data on mount + useEffect(() => { + async function loadData() { + const [free, pro, summ] = await Promise.all([ + getPricingFeatures("free"), + getPricingFeatures("pro"), + getPricingSummary() + ]); + setFreeFeatures(free as Feature[]); + setProFeatures(pro as Feature[]); + setSummary(summ as PricingSummary); + } + loadData(); + }, []); + + async function handleAddFeature(planId: string, label: string) { + startTransition(async () => { + await addPricingFeature(planId, 'manual', label); + const updated = await getPricingFeatures(planId); + if (planId === 'free') setFreeFeatures(updated as Feature[]); + else setProFeatures(updated as Feature[]); + }); + } + + async function handleRemoveFeature(id: string, planId: string) { + startTransition(async () => { + await removePricingFeature(id); + const updated = await getPricingFeatures(planId); + if (planId === 'free') setFreeFeatures(updated as Feature[]); + else setProFeatures(updated as Feature[]); + }); + } + + async function handleUpdateFeature(id: string, label: string, planId: string) { + // Update local state first for instant feedback + if (planId === 'free') { + setFreeFeatures(freeFeatures.map(f => f.id === id ? { ...f, label } : f)); + } else { + setProFeatures(proFeatures.map(f => f.id === id ? { ...f, label } : f)); + } + + startTransition(async () => { + await updatePricingFeature(id, label); + }); + } + + async function handleUpdatePlan(id: string, field: keyof Plan, value: string) { + const updatedPlans = plans.map(p => p.id === id ? { ...p, [field]: value } : p); + setPlans(updatedPlans); + + startTransition(async () => { + await updatePricingPlan(id, { [field]: value }); + }); + } + + async function handleUpdateCategory(id: string, category: string) { + startTransition(async () => { + await updateInventoryCategory(id, category); + setInventory(inventory.map(item => item.id === id ? { ...item, pricingCategory: category } : item)); + // Refresh summary + const summ = await getPricingSummary(); + setSummary(summ as PricingSummary); + }); + } + + async function handleMakeFree(contentId: string) { + startTransition(async () => { + await updateContentAccess(contentId, 'free'); + // Update local state: remove from inventory + setInventory(inventory.filter(item => item.contentId !== contentId)); + // Refresh summary + const summ = await getPricingSummary(); + setSummary(summ as PricingSummary); + }); + } + + async function handleSync() { + startTransition(async () => { + const res = await syncAllProContent(); + if (res.success) { + window.location.reload(); + } + }); + } + + return ( +
+ {/* Tabs */} +
+ + +
+ + {activeTab === "plans" ? ( +
+ + +
+ {plans.map((plan) => ( + + ))} +
+
+ ) : ( + + )} +
+ ); +} diff --git a/app/pricing/page.tsx b/app/pricing/page.tsx index 2970c07..ffec840 100644 --- a/app/pricing/page.tsx +++ b/app/pricing/page.tsx @@ -5,6 +5,7 @@ import Link from "next/link"; import { CheckoutButton } from "@/components/billing/checkout-button"; import { ManageSubscriptionButton } from "@/components/billing/manage-subscription-button"; import { PricingPageAnalytics } from "@/components/billing/pricing-analytics"; +import { getPricingPlans, getPricingFeatures, getPricingInventory } from "@/lib/actions/admin"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { auth } from "@/lib/auth"; @@ -30,35 +31,29 @@ export default async function PricingPage() { const hasPro = await userHasPro(session?.user?.id); const repo = getContentRepository(); - const pricingData = await repo.getPricingCopy("main-pricing"); + const plans = await getPricingPlans(); + const proPlan = plans.find((p) => p.id === "pro"); - // Fallbacks if DB content doesn't exist yet - const title = pricingData?.title || "Planos e Preços"; + const freeFeatures = await getPricingFeatures("free"); + const proFeatures = await getPricingFeatures("pro"); + const inventory = await getPricingInventory(); + + // Agrupar inventário por categoria de pricing + const inventoryGroups = inventory.reduce((acc, item) => { + acc[item.pricingCategory] = (acc[item.pricingCategory] || 0) + 1; + return acc; + }, {} as Record); + + const title = proPlan?.title || "Planos e Preços"; const description = - pricingData?.body || + proPlan?.description || "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; + const finalFreePerks = freeFeatures.map((f) => f.label || ""); + const finalProPerks = proFeatures.map((f) => f.label || ""); + + const monthlyDisplay = proPlan?.priceDisplay || monthly; + const yearlyNoteDisplay = proPlan?.yearlyNote || yearlyNote; return (
@@ -110,7 +105,7 @@ export default async function PricingPage() { fundamentos.

    - {freePerks.map((perk) => ( + {finalFreePerks.map((perk) => (
    • - {proPerks.map((perk) => ( + {finalProPerks.map((perk) => (
    • ) { + const admin = await requireAdmin(); + if (!admin) return { error: 'Não autorizado' }; + + await db.update(pricingPlans) + .set({ ...data, updatedAt: new Date() }) + .where(eq(pricingPlans.id, id)); + + revalidatePath('/pricing'); + revalidatePath('/admin/pricing'); + return { success: true }; +} + +export async function getPricingFeatures(planId: string) { + return db.select().from(pricingFeatures).where(eq(pricingFeatures.planId, planId)).orderBy(pricingFeatures.order); +} + +export async function addPricingFeature(planId: string, type: 'manual' | 'automatic', label?: string, categoryName?: string) { + const admin = await requireAdmin(); + if (!admin) return { error: 'Não autorizado' }; + + await db.insert(pricingFeatures).values({ + planId, + type, + label, + categoryName, + order: 0 + }); + + revalidatePath('/pricing'); + revalidatePath('/admin/pricing'); + return { success: true }; +} + +export async function updatePricingFeature(id: string, label: string) { + const admin = await requireAdmin(); + if (!admin) return { error: 'Não autorizado' }; + + await db.update(pricingFeatures) + .set({ label }) + .where(eq(pricingFeatures.id, id)); + + revalidatePath('/pricing'); + return { success: true }; +} + +export async function removePricingFeature(id: string) { + const admin = await requireAdmin(); + if (!admin) return { error: 'Não autorizado' }; + + await db.delete(pricingFeatures).where(eq(pricingFeatures.id, id)); + + revalidatePath('/pricing'); + revalidatePath('/admin/pricing'); + return { success: true }; +} + +export async function getPricingInventory() { + const staff = await getAuthenticatedStaff(); + if (!staff) return []; + + return db.select({ + id: pricingInventory.id, + contentId: pricingInventory.contentId, + pricingCategory: pricingInventory.pricingCategory, + createdAt: pricingInventory.createdAt, + contentTitle: contents.title, + contentType: contents.type, + contentSlug: contents.slug + }) + .from(pricingInventory) + .innerJoin(contents, eq(pricingInventory.contentId, contents.id)) + .orderBy(desc(pricingInventory.createdAt)); +} + +export async function updateInventoryCategory(id: string, category: string) { + const admin = await requireAdmin(); + if (!admin) return { error: 'Não autorizado' }; + + await db.update(pricingInventory) + .set({ pricingCategory: category }) + .where(eq(pricingInventory.id, id)); + + revalidatePath('/pricing'); + return { success: true }; +} + +export async function getPricingSummary() { + const staff = await getAuthenticatedStaff(); + if (!staff) return { pro: [], free: [] }; + + // Sumário PRO (do Inventário de Marketing) + const proRows = await db.select({ + category: pricingInventory.pricingCategory, + count: count() + }) + .from(pricingInventory) + .groupBy(pricingInventory.pricingCategory); + + // Sumário FREE (do Conteúdo Geral) + const freeRows = await db.select({ + type: contents.type, + count: count() + }) + .from(contents) + .where(eq(contents.access, 'free')) + .groupBy(contents.type); + + // Mapear tipos de conteúdo para nomes amigáveis para o Free + const typeLabels: Record = { + 'problem': 'Problemas', + 'concept': 'Guias Teóricos', + 'technical-test': 'Simulados', + 'interview-en': 'Inglês', + 'engineering-work': 'Engenharia' + }; + + return { + pro: proRows, + free: freeRows.map(r => ({ + label: typeLabels[r.type] || r.type, + count: r.count + })) + }; +} + +/** + * Utilitário para forçar a sincronização de tudo que já é PRO hoje. + */ +export async function syncAllProContent() { + const admin = await requireAdmin(); + if (!admin) return { error: 'Não autorizado' }; + + const proContents = await db.select().from(contents).where(eq(contents.access, 'pro')); + + for (const c of proContents) { + await syncPricingInventorySync(c.id, 'pro', c.type); + } + + return { success: true, count: proContents.length }; +} diff --git a/lib/content/content-repository.ts b/lib/content/content-repository.ts index 62f3850..0910247 100644 --- a/lib/content/content-repository.ts +++ b/lib/content/content-repository.ts @@ -29,6 +29,11 @@ import type { } from './schemas'; import type { StudyTrackFile } from './track-schema'; +export interface ContentAccessCounts { + free: Record; + pro: Record; +} + /* ── Interface pública ──────────────────────────────────────────── */ export interface ContentRepository { @@ -57,12 +62,13 @@ export interface ContentRepository { getCourse(slug: string): Promise; getPricingCopy(slug: string): Promise; + getContentCountsByAccess(): Promise; } import { renderMarkdown } from './markdown'; import { db } from '@/lib/db'; import { contents } from '@/lib/db/schema'; -import { and, eq } from 'drizzle-orm'; +import { and, eq, count } from 'drizzle-orm'; import type { CoursePackParsed, TechnicalTest } from './schemas'; type ContentRow = typeof contents.$inferSelect; @@ -216,6 +222,31 @@ class DbContentRepository implements ContentRepository { return row ? this.hydratePricingCopy(row) : null; } + async getContentCountsByAccess(): Promise { + const rows = await db + .select({ + type: contents.type, + access: contents.access, + total: count(), + }) + .from(contents) + .where(eq(contents.status, 'PUBLISHED')) + .groupBy(contents.type, contents.access); + + const result: ContentAccessCounts = { + free: {}, + pro: {}, + }; + + rows.forEach((r) => { + const access = r.access as 'free' | 'pro'; + const type = r.type as string; + result[access][type] = Number(r.total); + }); + + return result; + } + private hydrateTechnicalTest(row: ContentRow): TechnicalTest { const metadata = row.metadata as Record; const body = JSON.parse(row.body); diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 310a2bb..847a680 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -204,3 +204,38 @@ export const contentCategories = pgTable('content_categories', { createdAt: timestamp('createdAt', { mode: 'date', withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updatedAt', { mode: 'date', withTimezone: true }).notNull().defaultNow(), }); + +/* ── Pricing & Plans ─────────────────────────────────────────────── */ + +export const pricingPlans = pgTable('pricing_plans', { + id: text('id').primaryKey(), // 'free', 'pro' + title: text('title').notNull(), + description: text('description'), + priceDisplay: text('priceDisplay'), // e.g. "19€" + yearlyNote: text('yearlyNote'), // e.g. "Ou 190€/ano" + updatedAt: timestamp('updatedAt', { mode: 'date', withTimezone: true }).notNull().defaultNow(), +}); + +export const pricingFeatures = pgTable('pricing_features', { + id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()), + planId: text('planId').notNull().references(() => pricingPlans.id, { onDelete: 'cascade' }), + type: text('type').notNull().default('manual'), // 'manual' | 'automatic' + label: text('label'), // For manual perks + categoryName: text('categoryName'), // For automatic counts grouping + order: integer('order').notNull().default(0), +}); + +/** + * Tabela que espelha os conteúdos PRO para fins de marketing e precificação. + * Sincronizada automaticamente com a tabela contents. + */ +export const pricingInventory = pgTable('pricing_inventory', { + id: text('id').primaryKey().$defaultFn(() => crypto.randomUUID()), + contentId: text('contentId') + .notNull() + .unique() + .references(() => contents.id, { onDelete: 'cascade' }), + /** Categoria de exibição amigável no pricing (ex: "Estruturas de Dados") */ + pricingCategory: text('pricingCategory').notNull(), + createdAt: timestamp('createdAt', { mode: 'date', withTimezone: true }).notNull().defaultNow(), +});