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:
Evandro Fonseca Junior committed 2025-11-07 21:03:19 -03:00
1 parent 7f42def478
commit d27526e026
20 files changed
+2133 -40

No files matched your search

+15 -2
View File
@@ -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 */}
<NavigationMenu className="hidden md:flex">
<NavigationMenuList>
{links.map(({ href, label, highlight }) => (
{navLinks.map(({ href, label, highlight }) => (
<NavigationMenuItem key={href}>
<NavigationMenuLink asChild>
<Link
@@ -103,6 +111,11 @@ export default function Header() {
{/* Notifications */}
{mounted && isAuthenticated && <NotificationBell />}
{mounted && isAuthenticated && user?.subscriptionTier && (
<Badge variant="outline" className="uppercase">
{user.subscriptionTier.slug}
</Badge>
)}
{/* Auth Section */}
{!mounted ? (
@@ -172,7 +185,7 @@ export default function Header() {
{/* Mobile Menu */}
<div className="md:hidden">
<MobileMenu links={links} />
<MobileMenu links={navLinks} />
</div>
</div>
+701
View File
@@ -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>
);
}
+60 -2
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { Bell } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
@@ -33,10 +33,32 @@ export function NotificationBell({ userId, className }: NotificationBellProps) {
const [notifications, setNotifications] = useState<Notification[]>([]);
const [isOpen, setIsOpen] = useState(false);
const [loading, setLoading] = useState(false);
const rawUnreadRef = useRef(0);
const baselineUnreadRef = useRef(0);
const baselineLoadedRef = useRef(false);
const storageKeyRef = useRef<string | null>(null);
const effectiveUserId = userId || user?.userId;
// Buscar contagem de não lidas
const persistBaseline = (value: number) => {
baselineUnreadRef.current = value;
if (typeof window !== "undefined" && storageKeyRef.current) {
window.localStorage.setItem(storageKeyRef.current, String(value));
}
};
const loadBaselineFromStorage = () => {
if (typeof window === "undefined" || !storageKeyRef.current) {
return undefined;
}
const stored = window.localStorage.getItem(storageKeyRef.current);
if (!stored) return undefined;
const parsed = Number.parseInt(stored, 10);
if (Number.isNaN(parsed)) return undefined;
return parsed;
};
const fetchUnreadCount = async () => {
if (!effectiveUserId) return;
@@ -45,7 +67,20 @@ export function NotificationBell({ userId, className }: NotificationBellProps) {
if (res.ok) {
const data = await res.json();
if (data.success) {
setUnreadCount(data.count);
rawUnreadRef.current = data.count;
if (!baselineLoadedRef.current) {
const storedBaseline = loadBaselineFromStorage();
if (storedBaseline !== undefined) {
const normalized = Math.min(storedBaseline, data.count);
persistBaseline(normalized);
} else {
persistBaseline(data.count);
}
baselineLoadedRef.current = true;
} else if (data.count < baselineUnreadRef.current) {
persistBaseline(data.count);
}
setUnreadCount(Math.max(0, data.count - baselineUnreadRef.current));
}
}
} catch (error) {
@@ -74,6 +109,21 @@ export function NotificationBell({ userId, className }: NotificationBellProps) {
};
// Polling automático a cada 30 segundos
useEffect(() => {
if (!effectiveUserId) {
return;
}
storageKeyRef.current = `notifications:lastBaseline:${effectiveUserId}`;
const storedBaseline = loadBaselineFromStorage();
if (storedBaseline !== undefined) {
baselineUnreadRef.current = storedBaseline;
baselineLoadedRef.current = true;
} else {
baselineUnreadRef.current = 0;
baselineLoadedRef.current = false;
}
}, [effectiveUserId]);
useEffect(() => {
if (!effectiveUserId) return;
@@ -94,6 +144,8 @@ export function NotificationBell({ userId, className }: NotificationBellProps) {
useEffect(() => {
if (isOpen) {
fetchNotifications();
persistBaseline(rawUnreadRef.current);
setUnreadCount(0);
}
}, [isOpen]);
@@ -111,6 +163,12 @@ export function NotificationBell({ userId, className }: NotificationBellProps) {
)
);
// Atualizar contagem
rawUnreadRef.current = Math.max(0, rawUnreadRef.current - 1);
const newBaseline = Math.min(
baselineUnreadRef.current,
rawUnreadRef.current
);
persistBaseline(newBaseline);
setUnreadCount((prev) => Math.max(0, prev - 1));
}
} catch (error) {