feat(auth): include subscription tier in token and session payload
- Adds subscription tier info to access/refresh tokens. - Stores tier details in session Redis record. - Returns tier data in login/register response. - Uses default tier when user has none.
This commit is contained in:
1 parent
7f42def478
commit
d27526e026
20 files changed
+2133
-40
No files matched your search
@@ -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<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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user