773 lines
25 KiB
TypeScript
773 lines
25 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useMemo, useState } from "react";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import type { SubscriptionTierSummary } from "@/lib/prisma/queries/subscription-tiers";
|
|
|
|
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<SubscriptionTierSummary[]>(initialTiers);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [feedback, setFeedback] = useState<FeedbackState>(null);
|
|
const [createForm, setCreateForm] = useState<TierFormState>({
|
|
name: "",
|
|
slug: "",
|
|
description: "",
|
|
level: 0,
|
|
priceMonthly: "",
|
|
priceYearly: "",
|
|
isActive: true,
|
|
features: "",
|
|
});
|
|
const [editForms, setEditForms] = useState<Record<number, TierFormState>>(
|
|
() =>
|
|
Object.fromEntries(
|
|
initialTiers.map((tier) => [
|
|
tier.subscription_tier_id,
|
|
mapTierToFormState(tier),
|
|
]),
|
|
),
|
|
);
|
|
const [assignUserId, setAssignUserId] = useState("");
|
|
const [assignTierId, setAssignTierId] = useState<string>("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 (
|
|
<div className="space-y-8">
|
|
{feedback && (
|
|
<div
|
|
className={`rounded-md border px-4 py-3 text-sm ${
|
|
feedback.type === "success"
|
|
? "border-emerald-300 bg-emerald-100 text-emerald-900"
|
|
: "border-destructive/40 bg-destructive/10 text-destructive"
|
|
}`}
|
|
>
|
|
{feedback.message}
|
|
</div>
|
|
)}
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Adicionar novo plano</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="tier-name">
|
|
Nome
|
|
</label>
|
|
<Input
|
|
id="tier-name"
|
|
placeholder="Ex.: Ultra"
|
|
value={createForm.name}
|
|
onChange={(event) =>
|
|
setCreateForm((prev) => ({
|
|
...prev,
|
|
name: event.target.value,
|
|
}))
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="tier-slug">
|
|
Slug
|
|
</label>
|
|
<Input
|
|
id="tier-slug"
|
|
placeholder="ex.: ultra"
|
|
value={createForm.slug}
|
|
onChange={(event) =>
|
|
setCreateForm((prev) => ({
|
|
...prev,
|
|
slug: event.target.value,
|
|
}))
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="tier-level">
|
|
Nível (herança)
|
|
</label>
|
|
<Input
|
|
id="tier-level"
|
|
type="number"
|
|
min={0}
|
|
value={createForm.level}
|
|
onChange={(event) =>
|
|
setCreateForm((prev) => ({
|
|
...prev,
|
|
level: Number.parseInt(event.target.value || "0", 10),
|
|
}))
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="tier-status">
|
|
Status
|
|
</label>
|
|
<Button
|
|
id="tier-status"
|
|
variant={createForm.isActive ? "default" : "outline"}
|
|
type="button"
|
|
onClick={() =>
|
|
setCreateForm((prev) => ({
|
|
...prev,
|
|
isActive: !prev.isActive,
|
|
}))
|
|
}
|
|
>
|
|
{createForm.isActive ? "Ativo" : "Inativo"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<label
|
|
className="text-sm font-medium"
|
|
htmlFor="tier-price-monthly"
|
|
>
|
|
Preço mensal (opcional)
|
|
</label>
|
|
<Input
|
|
id="tier-price-monthly"
|
|
type="number"
|
|
min={0}
|
|
step="0.01"
|
|
value={createForm.priceMonthly}
|
|
onChange={(event) =>
|
|
setCreateForm((prev) => ({
|
|
...prev,
|
|
priceMonthly: event.target.value,
|
|
}))
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label
|
|
className="text-sm font-medium"
|
|
htmlFor="tier-price-yearly"
|
|
>
|
|
Preço anual (opcional)
|
|
</label>
|
|
<Input
|
|
id="tier-price-yearly"
|
|
type="number"
|
|
min={0}
|
|
step="0.01"
|
|
value={createForm.priceYearly}
|
|
onChange={(event) =>
|
|
setCreateForm((prev) => ({
|
|
...prev,
|
|
priceYearly: event.target.value,
|
|
}))
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="tier-description">
|
|
Descrição
|
|
</label>
|
|
<Textarea
|
|
id="tier-description"
|
|
rows={3}
|
|
value={createForm.description}
|
|
onChange={(event) =>
|
|
setCreateForm((prev) => ({
|
|
...prev,
|
|
description: event.target.value,
|
|
}))
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="tier-features">
|
|
Recursos (um por linha: featureKey|Label|Descrição)
|
|
</label>
|
|
<Textarea
|
|
id="tier-features"
|
|
rows={4}
|
|
value={createForm.features}
|
|
onChange={(event) =>
|
|
setCreateForm((prev) => ({
|
|
...prev,
|
|
features: event.target.value,
|
|
}))
|
|
}
|
|
placeholder="premium.analytics|Analytics avançadas|Painéis detalhados"
|
|
/>
|
|
</div>
|
|
|
|
<Button
|
|
type="button"
|
|
onClick={handleCreateTier}
|
|
disabled={isLoading}
|
|
className="w-full md:w-auto"
|
|
>
|
|
Criar plano
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Planos cadastrados</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
{orderedTiers.map((tier) => {
|
|
const formState =
|
|
editForms[tier.subscription_tier_id] ?? mapTierToFormState(tier);
|
|
|
|
return (
|
|
<Card key={tier.subscription_tier_id} className="border-muted">
|
|
<CardHeader className="flex flex-row items-center justify-between gap-4">
|
|
<div className="space-y-1">
|
|
<CardTitle className="flex items-center gap-2 text-xl">
|
|
{tier.name}
|
|
<Badge variant="secondary">{tier.slug}</Badge>
|
|
<Badge
|
|
variant={tier.is_active ? "default" : "outline"}
|
|
className="uppercase"
|
|
>
|
|
{tier.is_active ? "Ativo" : "Inativo"}
|
|
</Badge>
|
|
</CardTitle>
|
|
<p className="text-sm text-muted-foreground">
|
|
Nível {tier.level} · ID #{tier.subscription_tier_id}
|
|
</p>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">Nome</label>
|
|
<Input
|
|
value={formState.name}
|
|
onChange={(event) =>
|
|
setEditForms((prev) => ({
|
|
...prev,
|
|
[tier.subscription_tier_id]: {
|
|
...prev[tier.subscription_tier_id],
|
|
name: event.target.value,
|
|
},
|
|
}))
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">Slug</label>
|
|
<Input
|
|
value={formState.slug}
|
|
onChange={(event) =>
|
|
setEditForms((prev) => ({
|
|
...prev,
|
|
[tier.subscription_tier_id]: {
|
|
...prev[tier.subscription_tier_id],
|
|
slug: event.target.value,
|
|
},
|
|
}))
|
|
}
|
|
disabled={tier.slug === "free"}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">Nível</label>
|
|
<Input
|
|
type="number"
|
|
min={0}
|
|
value={formState.level}
|
|
onChange={(event) =>
|
|
setEditForms((prev) => ({
|
|
...prev,
|
|
[tier.subscription_tier_id]: {
|
|
...prev[tier.subscription_tier_id],
|
|
level: Number.parseInt(
|
|
event.target.value || "0",
|
|
10,
|
|
),
|
|
},
|
|
}))
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">Status</label>
|
|
<Button
|
|
variant={formState.isActive ? "default" : "outline"}
|
|
type="button"
|
|
onClick={() =>
|
|
setEditForms((prev) => ({
|
|
...prev,
|
|
[tier.subscription_tier_id]: {
|
|
...prev[tier.subscription_tier_id],
|
|
isActive:
|
|
!prev[tier.subscription_tier_id].isActive,
|
|
},
|
|
}))
|
|
}
|
|
>
|
|
{formState.isActive ? "Ativo" : "Inativo"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">
|
|
Preço mensal
|
|
</label>
|
|
<Input
|
|
type="number"
|
|
min={0}
|
|
step="0.01"
|
|
value={formState.priceMonthly}
|
|
onChange={(event) =>
|
|
setEditForms((prev) => ({
|
|
...prev,
|
|
[tier.subscription_tier_id]: {
|
|
...prev[tier.subscription_tier_id],
|
|
priceMonthly: event.target.value,
|
|
},
|
|
}))
|
|
}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">Preço anual</label>
|
|
<Input
|
|
type="number"
|
|
min={0}
|
|
step="0.01"
|
|
value={formState.priceYearly}
|
|
onChange={(event) =>
|
|
setEditForms((prev) => ({
|
|
...prev,
|
|
[tier.subscription_tier_id]: {
|
|
...prev[tier.subscription_tier_id],
|
|
priceYearly: event.target.value,
|
|
},
|
|
}))
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">Descrição</label>
|
|
<Textarea
|
|
rows={3}
|
|
value={formState.description}
|
|
onChange={(event) =>
|
|
setEditForms((prev) => ({
|
|
...prev,
|
|
[tier.subscription_tier_id]: {
|
|
...prev[tier.subscription_tier_id],
|
|
description: event.target.value,
|
|
},
|
|
}))
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium">
|
|
Recursos (um por linha: featureKey|Label|Descrição)
|
|
</label>
|
|
<Textarea
|
|
rows={4}
|
|
value={formState.features}
|
|
onChange={(event) =>
|
|
setEditForms((prev) => ({
|
|
...prev,
|
|
[tier.subscription_tier_id]: {
|
|
...prev[tier.subscription_tier_id],
|
|
features: event.target.value,
|
|
},
|
|
}))
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<Button
|
|
type="button"
|
|
onClick={() =>
|
|
handleUpdateTier(tier.subscription_tier_id)
|
|
}
|
|
disabled={isLoading}
|
|
>
|
|
Salvar alterações
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() =>
|
|
setEditForms((prev) => ({
|
|
...prev,
|
|
[tier.subscription_tier_id]: mapTierToFormState(tier),
|
|
}))
|
|
}
|
|
disabled={isLoading}
|
|
>
|
|
Descartar mudanças
|
|
</Button>
|
|
{tier.slug !== "free" && (
|
|
<Button
|
|
type="button"
|
|
variant="destructive"
|
|
onClick={() =>
|
|
handleDeleteTier(tier.subscription_tier_id, tier.slug)
|
|
}
|
|
disabled={isLoading}
|
|
>
|
|
Remover plano
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
})}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Vincular plano a usuário</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="assign-user-id">
|
|
ID do usuário
|
|
</label>
|
|
<Input
|
|
id="assign-user-id"
|
|
type="number"
|
|
min={1}
|
|
value={assignUserId}
|
|
onChange={(event) => setAssignUserId(event.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium" htmlFor="assign-tier">
|
|
Plano de destino
|
|
</label>
|
|
<select
|
|
id="assign-tier"
|
|
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
|
value={assignTierId}
|
|
onChange={(event) => setAssignTierId(event.target.value)}
|
|
>
|
|
{orderedTiers.map((tier) => (
|
|
<option key={tier.subscription_tier_id} value={tier.slug}>
|
|
{tier.name} ({tier.slug})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<Button type="button" onClick={handleAssignTier} disabled={isLoading}>
|
|
Atualizar plano do usuário
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|