diff --git a/app/[locale]/admin/subscriptions/page.tsx b/app/[locale]/admin/subscriptions/page.tsx new file mode 100644 index 0000000..1a3ee98 --- /dev/null +++ b/app/[locale]/admin/subscriptions/page.tsx @@ -0,0 +1,64 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; +import { cookies } from "next/headers"; +import Header from "@/components/Header"; +import { getSession } from "@/lib/redis/session"; +import { getAllSubscriptionTiers } from "@/lib/prisma/queries/subscription-tiers"; +import { AdminSubscriptionDashboard } from "@/components/admin/subscription-dashboard"; +import { getUserById } from "@/lib/prisma/queries/users"; + +export async function generateMetadata(): Promise { + return { + title: "Assinaturas e Permissões", + description: + "Gerencie os planos de assinatura, recursos avançados e permissões dos usuários.", + }; +} + +export default async function AdminSubscriptionsPage({ + params, +}: { + params: Promise<{ locale: string }>; +}) { + const { locale } = await params; + const cookieStore = await cookies(); + const sessionId = cookieStore.get("sessionId")?.value; + + if (!sessionId) { + redirect(`/${locale}`); + } + + const session = await getSession(sessionId); + + if (!session || session.userId !== 1) { + redirect(`/${locale}`); + } + + const tiers = await getAllSubscriptionTiers(true); + const adminUser = await getUserById(session.userId); + + return ( +
+
+
+
+

Assinaturas & Permissões

+

+ Configure planos, recursos e vincule usuários às assinaturas disponíveis. +

+ {adminUser?.subscription_tier && ( +

+ Seu plano atual:{" "} + + {adminUser.subscription_tier.name} ({adminUser.subscription_tier.slug}) + +

+ )} +
+ + +
+
+ ); +} + diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts index 979e5a4..4bb0ff9 100644 --- a/app/api/auth/login/route.ts +++ b/app/api/auth/login/route.ts @@ -18,6 +18,10 @@ import { } from "@/lib/prisma/queries/users"; import { setSession } from "@/lib/redis/session"; import { randomUUID } from "crypto"; +import { + getDefaultSubscriptionTier, + getSubscriptionTierById, +} from "@/lib/prisma/queries/subscription-tiers"; export async function POST(request: NextRequest) { try { @@ -125,8 +129,20 @@ export async function POST(request: NextRequest) { await updateLastLogin(user.user_id); // Gerar tokens - const accessToken = generateAccessToken(user.user_id, user.username); - const refreshToken = generateRefreshToken(user.user_id, user.username); + const tierInfo = + user.subscription_tier ?? + (user.subscription_tier_id + ? await getSubscriptionTierById(user.subscription_tier_id) + : await getDefaultSubscriptionTier()); + + const accessToken = generateAccessToken(user.user_id, user.username, { + tierSlug: tierInfo?.slug ?? undefined, + tierLevel: tierInfo?.level ?? undefined, + }); + const refreshToken = generateRefreshToken(user.user_id, user.username, { + tierSlug: tierInfo?.slug ?? undefined, + tierLevel: tierInfo?.level ?? undefined, + }); // Criar sessão no Redis const sessionId = randomUUID(); @@ -136,6 +152,10 @@ export async function POST(request: NextRequest) { email: user.email, accessToken, createdAt: Date.now(), + subscriptionTierId: tierInfo?.subscription_tier_id ?? null, + subscriptionTierSlug: tierInfo?.slug ?? null, + subscriptionTierName: tierInfo?.name ?? null, + subscriptionTierLevel: tierInfo?.level ?? null, }); // Criar resposta @@ -150,6 +170,14 @@ export async function POST(request: NextRequest) { lastName: user.last_name, accountLevel: user.account_level, hasDonated: user.has_donated, + subscriptionTier: tierInfo + ? { + id: tierInfo.subscription_tier_id, + name: tierInfo.name, + slug: tierInfo.slug, + level: tierInfo.level, + } + : null, }, accessToken, }, diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts index 5ff4087..d2b584f 100644 --- a/app/api/auth/register/route.ts +++ b/app/api/auth/register/route.ts @@ -23,6 +23,7 @@ import { setSession } from "@/lib/redis/session"; import { randomUUID } from "crypto"; import { createUser } from "@/lib/prisma/queries/users"; import { createCharacter } from "@/lib/prisma/queries/characters"; +import { getDefaultSubscriptionTier } from "@/lib/prisma/queries/subscription-tiers"; export async function POST(request: NextRequest) { try { @@ -186,10 +187,19 @@ export async function POST(request: NextRequest) { }); // Gerar tokens - const accessToken = generateAccessToken(newUser.userId, newUser.username); + const defaultTier = await getDefaultSubscriptionTier(); + + const accessToken = generateAccessToken(newUser.userId, newUser.username, { + tierSlug: defaultTier?.slug ?? undefined, + tierLevel: defaultTier?.level ?? undefined, + }); const refreshToken = generateRefreshToken( newUser.userId, - newUser.username + newUser.username, + { + tierSlug: defaultTier?.slug ?? undefined, + tierLevel: defaultTier?.level ?? undefined, + } ); // Criar sessão no Redis @@ -200,6 +210,10 @@ export async function POST(request: NextRequest) { email: newUser.email, accessToken, createdAt: Date.now(), + subscriptionTierId: defaultTier?.subscription_tier_id ?? null, + subscriptionTierSlug: defaultTier?.slug ?? null, + subscriptionTierName: defaultTier?.name ?? null, + subscriptionTierLevel: defaultTier?.level ?? null, }); // Criar resposta @@ -210,6 +224,14 @@ export async function POST(request: NextRequest) { userId: newUser.userId, username: newUser.username, email: newUser.email, + subscriptionTier: defaultTier + ? { + id: defaultTier.subscription_tier_id, + name: defaultTier.name, + slug: defaultTier.slug, + level: defaultTier.level, + } + : null, }, accessToken, }, diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts index 1cb4af3..8fe5f55 100644 --- a/app/api/auth/session/route.ts +++ b/app/api/auth/session/route.ts @@ -1,9 +1,11 @@ import { NextRequest, NextResponse } from "next/server"; import { cookies } from "next/headers"; import { secureLogger } from "@/lib/secure-logger"; -import { getSession, refreshSession, deleteSession } from "@/lib/redis/session"; +import { getSession, refreshSession, deleteSession, setSession } from "@/lib/redis/session"; import { verifyAccessToken } from "@/lib/auth/jwt"; import { getMainCharacter } from "@/lib/prisma/queries/characters"; +import { getUserById } from "@/lib/prisma/queries/users"; +import { getSubscriptionTierById, getDefaultSubscriptionTier } from "@/lib/prisma/queries/subscription-tiers"; /** * GET /api/auth/session @@ -27,7 +29,7 @@ export async function GET(request: NextRequest) { } // Buscar sessão no Redis - const session = await getSession(sessionId); + let session = await getSession(sessionId); if (!session) { // Limpar cookie se sessão não existe @@ -63,6 +65,55 @@ export async function GET(request: NextRequest) { return response; } + let tierInfo: + | { + subscription_tier_id: number | null; + name: string | null; + slug: string | null; + level: number | null; + } + | null = { + subscription_tier_id: session.subscriptionTierId, + name: session.subscriptionTierName, + slug: session.subscriptionTierSlug, + level: session.subscriptionTierLevel, + }; + + if ( + tierInfo.subscription_tier_id === undefined || + tierInfo.subscription_tier_id === null || + tierInfo.slug === null + ) { + const dbUser = await getUserById(session.userId); + const fetchedTier = dbUser?.subscription_tier + ? await getSubscriptionTierById(dbUser.subscription_tier.subscription_tier_id) + : await getDefaultSubscriptionTier(); + + tierInfo = fetchedTier + ? { + subscription_tier_id: fetchedTier.subscription_tier_id, + name: fetchedTier.name, + slug: fetchedTier.slug, + level: fetchedTier.level, + } + : { + subscription_tier_id: null, + name: null, + slug: null, + level: null, + }; + + session = { + ...session, + subscriptionTierId: tierInfo.subscription_tier_id, + subscriptionTierName: tierInfo.name, + subscriptionTierSlug: tierInfo.slug, + subscriptionTierLevel: tierInfo.level, + }; + + await setSession(sessionId, session); + } + // Atualizar TTL da sessão await refreshSession(sessionId); @@ -78,6 +129,14 @@ export async function GET(request: NextRequest) { username: session.username, email: session.email, characterName: mainCharacter?.character_name || null, + subscriptionTier: tierInfo + ? { + id: tierInfo.subscription_tier_id, + name: tierInfo.name, + slug: tierInfo.slug, + level: tierInfo.level, + } + : null, }, }, { status: 200 } diff --git a/app/api/subscription-tiers/[id]/route.ts b/app/api/subscription-tiers/[id]/route.ts new file mode 100644 index 0000000..f993a74 --- /dev/null +++ b/app/api/subscription-tiers/[id]/route.ts @@ -0,0 +1,210 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireAdminUser } from "@/lib/auth/middleware"; +import { + getSubscriptionTierById, + updateSubscriptionTier, + deleteSubscriptionTier, + countUsersInSubscriptionTier, +} from "@/lib/prisma/queries/subscription-tiers"; +import { updateSubscriptionTierSchema } from "@/lib/validation-schemas"; +import { secureLogger } from "@/lib/secure-logger"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await requireAdminUser(request); + if (auth instanceof NextResponse) { + return auth; + } + + try { + const { id } = await params; + const tierId = Number.parseInt(id, 10); + + if (Number.isNaN(tierId) || tierId <= 0) { + return NextResponse.json( + { success: false, error: "ID de tier inválido" }, + { status: 400 } + ); + } + + const tier = await getSubscriptionTierById(tierId, true); + + if (!tier) { + return NextResponse.json( + { success: false, error: "Plano não encontrado" }, + { status: 404 } + ); + } + + return NextResponse.json( + { + success: true, + tier, + }, + { status: 200 } + ); + } catch (error) { + secureLogger.error("❌ Erro ao consultar subscription tier", { + error: error instanceof Error ? error.message : "Erro desconhecido", + }); + + return NextResponse.json( + { + success: false, + error: "Erro ao consultar plano de assinatura", + }, + { status: 500 } + ); + } +} + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await requireAdminUser(request); + if (auth instanceof NextResponse) { + return auth; + } + + try { + const { id } = await params; + const tierId = Number.parseInt(id, 10); + + if (Number.isNaN(tierId) || tierId <= 0) { + return NextResponse.json( + { success: false, error: "ID de tier inválido" }, + { status: 400 } + ); + } + + const body = await request.json(); + const parsed = updateSubscriptionTierSchema.parse(body); + + const updatedTier = await updateSubscriptionTier(tierId, { + name: parsed.name, + slug: parsed.slug, + description: parsed.description ?? undefined, + level: parsed.level, + price_monthly: parsed.priceMonthly ?? undefined, + price_yearly: parsed.priceYearly ?? undefined, + is_active: parsed.isActive, + features: parsed.features?.map((feature, index) => ({ + feature_key: feature.featureKey, + label: feature.label, + description: feature.description ?? null, + sort_order: feature.sortOrder ?? index, + _delete: feature._delete ?? false, + })), + }); + + return NextResponse.json( + { + success: true, + tier: updatedTier, + }, + { status: 200 } + ); + } catch (error) { + if (error instanceof Error && "issues" in error) { + return NextResponse.json( + { + success: false, + error: "Dados inválidos", + details: (error as any).issues, + }, + { status: 400 } + ); + } + + secureLogger.error("❌ Erro ao atualizar subscription tier", { + error: error instanceof Error ? error.message : "Erro desconhecido", + }); + + return NextResponse.json( + { + success: false, + error: "Erro ao atualizar plano de assinatura", + }, + { status: 500 } + ); + } +} + +export async function DELETE( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + const auth = await requireAdminUser(request); + if (auth instanceof NextResponse) { + return auth; + } + + try { + const { id } = await params; + const tierId = Number.parseInt(id, 10); + + if (Number.isNaN(tierId) || tierId <= 0) { + return NextResponse.json( + { success: false, error: "ID de tier inválido" }, + { status: 400 } + ); + } + + const tier = await getSubscriptionTierById(tierId, false); + + if (!tier) { + return NextResponse.json( + { success: false, error: "Plano não encontrado" }, + { status: 404 } + ); + } + + if (tier.slug === "free") { + return NextResponse.json( + { + success: false, + error: "O plano padrão 'free' não pode ser removido.", + }, + { status: 400 } + ); + } + + const userCount = await countUsersInSubscriptionTier(tierId); + + if (userCount > 0) { + return NextResponse.json( + { + success: false, + error: "Plano não pode ser removido enquanto houver usuários associados.", + users: userCount, + }, + { status: 409 } + ); + } + + await deleteSubscriptionTier(tierId); + + return NextResponse.json( + { + success: true, + }, + { status: 200 } + ); + } catch (error) { + secureLogger.error("❌ Erro ao remover subscription tier", { + error: error instanceof Error ? error.message : "Erro desconhecido", + }); + + return NextResponse.json( + { + success: false, + error: "Erro ao remover plano de assinatura", + }, + { status: 500 } + ); + } +} + diff --git a/app/api/subscription-tiers/assign/route.ts b/app/api/subscription-tiers/assign/route.ts new file mode 100644 index 0000000..564627f --- /dev/null +++ b/app/api/subscription-tiers/assign/route.ts @@ -0,0 +1,136 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireAdminUser } from "@/lib/auth/middleware"; +import { + assignUserToSubscriptionTier, + getSubscriptionTierById, + getSubscriptionTierBySlug, + getDefaultSubscriptionTier, +} from "@/lib/prisma/queries/subscription-tiers"; +import { assignSubscriptionTierSchema } from "@/lib/validation-schemas"; +import { secureLogger } from "@/lib/secure-logger"; +import { getUserById } from "@/lib/prisma/queries/users"; + +export async function POST(request: NextRequest) { + const auth = await requireAdminUser(request); + if (auth instanceof NextResponse) { + return auth; + } + + try { + const body = await request.json(); + const parsed = assignSubscriptionTierSchema.parse(body); + + const user = await getUserById(parsed.userId); + if (!user) { + return NextResponse.json( + { + success: false, + error: "Usuário não encontrado.", + }, + { status: 404 } + ); + } + + let targetTierId: number | null = null; + let targetTier: + | { + subscription_tier_id: number; + name: string; + slug: string; + level: number; + } + | null = null; + + if (parsed.tierId !== undefined) { + if (parsed.tierId === null) { + const defaultTier = await getDefaultSubscriptionTier(); + if (!defaultTier) { + return NextResponse.json( + { + success: false, + error: "Plano padrão não encontrado.", + }, + { status: 500 } + ); + } + targetTier = defaultTier; + } else { + const tierById = await getSubscriptionTierById(parsed.tierId, false); + if (!tierById) { + return NextResponse.json( + { + success: false, + error: "Plano informado não existe.", + }, + { status: 404 } + ); + } + targetTier = tierById; + } + } else if (parsed.tierSlug) { + const tierBySlug = await getSubscriptionTierBySlug(parsed.tierSlug, false); + if (!tierBySlug) { + return NextResponse.json( + { + success: false, + error: "Plano informado não existe.", + }, + { status: 404 } + ); + } + targetTier = tierBySlug; + } + + if (!targetTier) { + return NextResponse.json( + { + success: false, + error: "Plano de assinatura não identificado.", + }, + { status: 400 } + ); + } + + targetTierId = targetTier.subscription_tier_id; + + await assignUserToSubscriptionTier(parsed.userId, targetTierId); + + return NextResponse.json( + { + success: true, + userId: parsed.userId, + tier: { + id: targetTier.subscription_tier_id, + name: targetTier.name, + slug: targetTier.slug, + level: targetTier.level, + }, + }, + { status: 200 } + ); + } catch (error) { + if (error instanceof Error && "issues" in error) { + return NextResponse.json( + { + success: false, + error: "Dados inválidos", + details: (error as any).issues, + }, + { status: 400 } + ); + } + + secureLogger.error("❌ Erro ao atribuir subscription tier", { + error: error instanceof Error ? error.message : "Erro desconhecido", + }); + + return NextResponse.json( + { + success: false, + error: "Erro ao atribuir plano de assinatura ao usuário", + }, + { status: 500 } + ); + } +} + diff --git a/app/api/subscription-tiers/route.ts b/app/api/subscription-tiers/route.ts new file mode 100644 index 0000000..598e037 --- /dev/null +++ b/app/api/subscription-tiers/route.ts @@ -0,0 +1,102 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireAdminUser } from "@/lib/auth/middleware"; +import { + getAllSubscriptionTiers, + createSubscriptionTier, +} from "@/lib/prisma/queries/subscription-tiers"; +import { createSubscriptionTierSchema } from "@/lib/validation-schemas"; +import { secureLogger } from "@/lib/secure-logger"; + +export async function GET(request: NextRequest) { + const auth = await requireAdminUser(request); + if (auth instanceof NextResponse) { + return auth; + } + + try { + const tiers = await getAllSubscriptionTiers(true); + return NextResponse.json( + { + success: true, + tiers, + }, + { status: 200 } + ); + } catch (error) { + secureLogger.error("❌ Erro ao listar subscription tiers", { + error: error instanceof Error ? error.message : "Erro desconhecido", + }); + + return NextResponse.json( + { + success: false, + error: "Erro ao listar planos de assinatura", + }, + { status: 500 } + ); + } +} + +export async function POST(request: NextRequest) { + const auth = await requireAdminUser(request); + if (auth instanceof NextResponse) { + return auth; + } + + try { + const body = await request.json(); + const parsed = createSubscriptionTierSchema.parse(body); + + const tier = await createSubscriptionTier({ + name: parsed.name, + slug: parsed.slug, + description: parsed.description ?? null, + level: parsed.level ?? 0, + price_monthly: parsed.priceMonthly ?? null, + price_yearly: parsed.priceYearly ?? null, + is_active: parsed.isActive ?? true, + features: parsed.features?.map((feature, index) => ({ + feature_key: feature.featureKey, + label: feature.label, + description: feature.description ?? null, + sort_order: feature.sortOrder ?? index, + })), + }); + + return NextResponse.json( + { + success: true, + tier, + }, + { status: 201 } + ); + } catch (error) { + if (error instanceof NextResponse) { + throw error; + } + + if (error instanceof Error && "issues" in error) { + return NextResponse.json( + { + success: false, + error: "Dados inválidos", + details: (error as any).issues, + }, + { status: 400 } + ); + } + + secureLogger.error("❌ Erro ao criar subscription tier", { + error: error instanceof Error ? error.message : "Erro desconhecido", + }); + + return NextResponse.json( + { + success: false, + error: "Erro ao criar plano de assinatura", + }, + { status: 500 } + ); + } +} + diff --git a/components/Header.tsx b/components/Header.tsx index 05c9e26..d7c3ce7 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -31,6 +31,7 @@ import { useSession } from "@/lib/hooks/use-session"; import Link from "next/link"; import { useState, useEffect } from "react"; import { LogOut, User } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; export default function Header() { const t = useTranslations("header.nav"); @@ -53,6 +54,13 @@ export default function Header() { { href: `/${locale}/apoiar`, label: t("support"), highlight: true }, ]; + const navLinks = [ + ...links, + ...(isAuthenticated && user?.userId === 1 + ? [{ href: `/${locale}/admin/subscriptions`, label: "Admin" }] + : []), + ]; + const handleLogout = async () => { await logout(); }; @@ -73,7 +81,7 @@ export default function Header() { {/* Desktop Navigation */} - {links.map(({ href, label, highlight }) => ( + {navLinks.map(({ href, label, highlight }) => ( } + {mounted && isAuthenticated && user?.subscriptionTier && ( + + {user.subscriptionTier.slug} + + )} {/* Auth Section */} {!mounted ? ( @@ -172,7 +185,7 @@ export default function Header() { {/* Mobile Menu */}
- +
diff --git a/components/admin/subscription-dashboard.tsx b/components/admin/subscription-dashboard.tsx new file mode 100644 index 0000000..3dc9a39 --- /dev/null +++ b/components/admin/subscription-dashboard.tsx @@ -0,0 +1,701 @@ +"use client"; + +import { useCallback, useMemo, useState } from "react"; +import type { SubscriptionTierSummary } from "@/lib/prisma/queries/subscription-tiers"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Badge } from "@/components/ui/badge"; + +type FeedbackState = + | { type: "success"; message: string } + | { type: "error"; message: string } + | null; + +interface AdminSubscriptionDashboardProps { + initialTiers: SubscriptionTierSummary[]; +} + +interface TierFormState { + name: string; + slug: string; + description: string; + level: number; + priceMonthly: string; + priceYearly: string; + isActive: boolean; + features: string; +} + +function mapTierToFormState(tier: SubscriptionTierSummary): TierFormState { + return { + name: tier.name, + slug: tier.slug, + description: tier.description ?? "", + level: tier.level, + priceMonthly: tier.price_monthly ? tier.price_monthly.toString() : "", + priceYearly: tier.price_yearly ? tier.price_yearly.toString() : "", + isActive: tier.is_active, + features: (tier.features ?? []) + .map((feature) => `${feature.feature_key}|${feature.label}|${feature.description ?? ""}`) + .join("\n"), + }; +} + +function parseFeatures(text: string) { + if (!text.trim()) { + return []; + } + + return text + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .map((line, index) => { + const [featureKey, label, description] = line.split("|").map((part) => part?.trim() ?? ""); + return { + feature_key: featureKey, + label: label || featureKey, + description: description || null, + sort_order: index, + }; + }); +} + +export function AdminSubscriptionDashboard({ + initialTiers, +}: AdminSubscriptionDashboardProps) { + const [tiers, setTiers] = useState(initialTiers); + const [isLoading, setIsLoading] = useState(false); + const [feedback, setFeedback] = useState(null); + const [createForm, setCreateForm] = useState({ + name: "", + slug: "", + description: "", + level: 0, + priceMonthly: "", + priceYearly: "", + isActive: true, + features: "", + }); + const [editForms, setEditForms] = useState>(() => + Object.fromEntries(initialTiers.map((tier) => [tier.subscription_tier_id, mapTierToFormState(tier)])) + ); + const [assignUserId, setAssignUserId] = useState(""); + const [assignTierId, setAssignTierId] = useState("free"); + + const resetFeedback = () => setFeedback(null); + + const refreshTiers = useCallback(async () => { + const response = await fetch("/api/subscription-tiers", { + method: "GET", + credentials: "include", + cache: "no-store", + }); + + if (!response.ok) { + throw new Error("Erro ao atualizar lista de planos."); + } + + const data = await response.json(); + setTiers(data.tiers); + setEditForms( + Object.fromEntries( + data.tiers.map((tier: SubscriptionTierSummary) => [ + tier.subscription_tier_id, + mapTierToFormState(tier), + ]) + ) + ); + }, []); + + const handleCreateTier = async () => { + resetFeedback(); + setIsLoading(true); + try { + const payload = { + name: createForm.name, + slug: createForm.slug, + description: createForm.description || null, + level: Number.parseInt(createForm.level.toString(), 10), + priceMonthly: createForm.priceMonthly ? Number.parseFloat(createForm.priceMonthly) : null, + priceYearly: createForm.priceYearly ? Number.parseFloat(createForm.priceYearly) : null, + isActive: createForm.isActive, + features: parseFeatures(createForm.features), + }; + + const response = await fetch("/api/subscription-tiers", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error(data.error || "Erro ao criar plano."); + } + + setCreateForm({ + name: "", + slug: "", + description: "", + level: 0, + priceMonthly: "", + priceYearly: "", + isActive: true, + features: "", + }); + + await refreshTiers(); + setFeedback({ type: "success", message: "Plano criado com sucesso." }); + } catch (error) { + setFeedback({ + type: "error", + message: + error instanceof Error ? error.message : "Não foi possível criar o plano.", + }); + } finally { + setIsLoading(false); + } + }; + + const handleUpdateTier = async (tierId: number) => { + resetFeedback(); + setIsLoading(true); + try { + const form = editForms[tierId]; + const payload = { + name: form.name, + slug: form.slug, + description: form.description || null, + level: Number.parseInt(form.level.toString(), 10), + priceMonthly: form.priceMonthly ? Number.parseFloat(form.priceMonthly) : null, + priceYearly: form.priceYearly ? Number.parseFloat(form.priceYearly) : null, + isActive: form.isActive, + features: parseFeatures(form.features), + }; + + const response = await fetch(`/api/subscription-tiers/${tierId}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error(data.error || "Erro ao atualizar plano."); + } + + await refreshTiers(); + setFeedback({ type: "success", message: "Plano atualizado com sucesso." }); + } catch (error) { + setFeedback({ + type: "error", + message: + error instanceof Error ? error.message : "Não foi possível atualizar o plano.", + }); + } finally { + setIsLoading(false); + } + }; + + const handleDeleteTier = async (tierId: number, slug: string) => { + resetFeedback(); + + if (!confirm(`Deseja realmente remover o plano "${slug}"?`)) { + return; + } + + setIsLoading(true); + try { + const response = await fetch(`/api/subscription-tiers/${tierId}`, { + method: "DELETE", + credentials: "include", + }); + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error(data.error || "Erro ao remover plano."); + } + + await refreshTiers(); + setFeedback({ type: "success", message: "Plano removido com sucesso." }); + } catch (error) { + setFeedback({ + type: "error", + message: + error instanceof Error ? error.message : "Não foi possível remover o plano.", + }); + } finally { + setIsLoading(false); + } + }; + + const handleAssignTier = async () => { + resetFeedback(); + + if (!assignUserId.trim()) { + setFeedback({ type: "error", message: "Informe um ID de usuário válido." }); + return; + } + + setIsLoading(true); + try { + const payload = { + userId: Number.parseInt(assignUserId, 10), + tierSlug: assignTierId, + }; + + const response = await fetch("/api/subscription-tiers/assign", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + credentials: "include", + body: JSON.stringify(payload), + }); + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error(data.error || "Erro ao atualizar plano do usuário."); + } + + setAssignUserId(""); + setFeedback({ type: "success", message: "Plano do usuário atualizado." }); + } catch (error) { + setFeedback({ + type: "error", + message: + error instanceof Error + ? error.message + : "Não foi possível atualizar o plano do usuário.", + }); + } finally { + setIsLoading(false); + } + }; + + const orderedTiers = useMemo( + () => + [...tiers].sort((a, b) => { + if (a.level === b.level) { + return a.subscription_tier_id - b.subscription_tier_id; + } + return a.level - b.level; + }), + [tiers] + ); + + return ( +
+ {feedback && ( +
+ {feedback.message} +
+ )} + + + + Adicionar novo plano + + +
+
+ + + setCreateForm((prev) => ({ ...prev, name: event.target.value })) + } + /> +
+
+ + + setCreateForm((prev) => ({ ...prev, slug: event.target.value })) + } + /> +
+
+ + + setCreateForm((prev) => ({ + ...prev, + level: Number.parseInt(event.target.value || "0", 10), + })) + } + /> +
+
+ + +
+
+ +
+
+ + + setCreateForm((prev) => ({ ...prev, priceMonthly: event.target.value })) + } + /> +
+
+ + + setCreateForm((prev) => ({ ...prev, priceYearly: event.target.value })) + } + /> +
+
+ +
+ +