@seedgrid/fe-billing-user 2026.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +78 -0
  2. package/dist/billing.d.ts +234 -0
  3. package/dist/billing.d.ts.map +1 -0
  4. package/dist/billing.js +4 -0
  5. package/dist/index.d.ts +4 -0
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/index.js +5 -0
  8. package/dist/messages.d.ts +348 -0
  9. package/dist/messages.d.ts.map +1 -0
  10. package/dist/messages.js +148 -0
  11. package/dist/module.d.ts +3 -0
  12. package/dist/module.d.ts.map +1 -0
  13. package/dist/module.js +13 -0
  14. package/dist/module.json +15 -0
  15. package/dist/paths.d.ts +29 -0
  16. package/dist/paths.d.ts.map +1 -0
  17. package/dist/paths.js +35 -0
  18. package/dist/server/create-billing-user-server.d.ts +34 -0
  19. package/dist/server/create-billing-user-server.d.ts.map +1 -0
  20. package/dist/server/create-billing-user-server.js +33 -0
  21. package/dist/server/index.d.ts +2 -0
  22. package/dist/server/index.d.ts.map +1 -0
  23. package/dist/server/index.js +2 -0
  24. package/package.json +43 -0
  25. package/scaffold/README.md +72 -0
  26. package/scaffold/src/app/api/billing/cancel/route.ts +10 -0
  27. package/scaffold/src/app/api/billing/cancel-scheduled-change/route.ts +10 -0
  28. package/scaffold/src/app/api/billing/change-plan/route.ts +14 -0
  29. package/scaffold/src/app/api/billing/change-preview/route.ts +13 -0
  30. package/scaffold/src/app/api/billing/checkout/route.ts +14 -0
  31. package/scaffold/src/app/api/billing/invoices/route.ts +14 -0
  32. package/scaffold/src/app/api/billing/me/route.ts +15 -0
  33. package/scaffold/src/app/api/billing/plans/route.ts +10 -0
  34. package/scaffold/src/app/api/billing/portal/route.ts +10 -0
  35. package/scaffold/src/app/api/billing/reactivate/route.ts +10 -0
  36. package/scaffold/src/app/api/billing/upcoming/route.ts +10 -0
  37. package/scaffold/src/app/billing/invoices/page.tsx +213 -0
  38. package/scaffold/src/app/billing/page.tsx +273 -0
  39. package/scaffold/src/app/billing/plans/page.tsx +248 -0
  40. package/scaffold/src/modules/billing-user/config.ts +7 -0
  41. package/scaffold/src/modules/billing-user/index.ts +17 -0
  42. package/scaffold/src/modules/billing-user/server.ts.hbs +34 -0
  43. package/scaffold/src/modules/billing-user/useBillingUserAppShellSections.tsx +59 -0
@@ -0,0 +1,273 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useState } from "react";
4
+ import { useRouter } from "next/navigation";
5
+
6
+ import {
7
+ SgButton,
8
+ SgCard,
9
+ SgConfirmationDialog,
10
+ SgSkeleton,
11
+ SgStack,
12
+ sgWhistle,
13
+ } from "@seedgrid/fe-components";
14
+
15
+ import { useI18n } from "@/i18n";
16
+ import { PageFrame, RouteGuard } from "@/modules/security";
17
+ import { billingUserConfig } from "@/modules/billing-user";
18
+
19
+ import type { SubscriptionResponse, UsageSummaryResponse } from "@seedgrid/fe-billing-user";
20
+
21
+ type BillingMeResponse = {
22
+ subscription: SubscriptionResponse | null;
23
+ usage: UsageSummaryResponse | null;
24
+ };
25
+
26
+ function formatCurrency(value: number | string | null | undefined) {
27
+ if (value == null) return "-";
28
+ const numeric = typeof value === "number" ? value : Number.parseFloat(value);
29
+ if (!Number.isFinite(numeric)) return "-";
30
+ return new Intl.NumberFormat(undefined, { style: "currency", currency: "BRL" }).format(numeric);
31
+ }
32
+
33
+ export default function BillingPage() {
34
+ return (
35
+ <RouteGuard permissions={["BILLING_READ"]}>
36
+ <BillingScreen />
37
+ </RouteGuard>
38
+ );
39
+ }
40
+
41
+ function BillingScreen() {
42
+ const router = useRouter();
43
+ const { t } = useI18n();
44
+ const [data, setData] = useState<BillingMeResponse | null>(null);
45
+ const [loading, setLoading] = useState(true);
46
+ const [openingPortal, setOpeningPortal] = useState(false);
47
+ const [busyAction, setBusyAction] = useState(false);
48
+ const [confirmCancel, setConfirmCancel] = useState(false);
49
+
50
+ const load = useCallback(async () => {
51
+ setLoading(true);
52
+ try {
53
+ const response = await fetch("/api/billing/me", { cache: "no-store" });
54
+ const payload = (await response.json()) as BillingMeResponse;
55
+ setData(payload);
56
+ } catch {
57
+ sgWhistle.error({ message: t("billing.user.load_error") });
58
+ } finally {
59
+ setLoading(false);
60
+ }
61
+ }, [t]);
62
+
63
+ useEffect(() => {
64
+ void load();
65
+ }, [load]);
66
+
67
+ async function handleOpenPortal() {
68
+ setOpeningPortal(true);
69
+ try {
70
+ const response = await fetch("/api/billing/portal", { method: "POST" });
71
+ const payload = (await response.json()) as { portalUrl: string };
72
+ window.location.assign(payload.portalUrl);
73
+ } catch {
74
+ sgWhistle.error({ message: t("billing.user.portal_error") });
75
+ setOpeningPortal(false);
76
+ }
77
+ }
78
+
79
+ // Recebe as mensagens JÁ resolvidas (t() é chamado com literais nos call
80
+ // sites): passar a chave crua como `string` faria o `t()` recusar (a união
81
+ // de chaves é estrita).
82
+ async function runAction(path: string, successMessage: string, errorMessage: string) {
83
+ setBusyAction(true);
84
+ try {
85
+ const response = await fetch(path, { method: "POST" });
86
+ if (!response.ok) throw new Error("action failed");
87
+ sgWhistle.success({ message: successMessage });
88
+ await load();
89
+ } catch {
90
+ sgWhistle.error({ message: errorMessage });
91
+ } finally {
92
+ setBusyAction(false);
93
+ }
94
+ }
95
+
96
+ const subscription = data?.subscription ?? null;
97
+ const usage = data?.usage ?? null;
98
+ const usedPercent =
99
+ usage && usage.cycle.includedRequests > 0
100
+ ? Math.min(100, Math.round((usage.cycle.usedRequests / usage.cycle.includedRequests) * 100))
101
+ : 0;
102
+
103
+ return (
104
+ <PageFrame
105
+ title={t("billing.user.title")}
106
+ description={t("billing.user.description")}
107
+ actions={
108
+ <SgStack direction="row" gap={8} wrap>
109
+ <SgButton
110
+ appearance="outline"
111
+ severity="secondary"
112
+ shape="rounded"
113
+ label={t("billing.user.view_plans")}
114
+ onClick={() => router.push(billingUserConfig.routes.plans)}
115
+ />
116
+ <SgButton
117
+ appearance="solid"
118
+ severity="primary"
119
+ shape="rounded"
120
+ label={t("billing.user.manage_action")}
121
+ loading={openingPortal}
122
+ disabled={loading || !subscription}
123
+ onClick={() => void handleOpenPortal()}
124
+ />
125
+ </SgStack>
126
+ }
127
+ >
128
+ {loading ? (
129
+ <SgStack gap={16}>
130
+ <SgSkeleton height={120} />
131
+ <SgSkeleton height={120} />
132
+ </SgStack>
133
+ ) : !subscription ? (
134
+ <SgCard cardStyle="outlined" title={t("billing.user.no_subscription.title")}>
135
+ <SgStack gap={12}>
136
+ <span>{t("billing.user.no_subscription.description")}</span>
137
+ <div>
138
+ <SgButton
139
+ appearance="solid"
140
+ severity="primary"
141
+ shape="rounded"
142
+ label={t("billing.user.no_subscription.action")}
143
+ onClick={() => router.push(billingUserConfig.routes.plans)}
144
+ />
145
+ </div>
146
+ </SgStack>
147
+ </SgCard>
148
+ ) : (
149
+ <SgStack gap={16}>
150
+ <SgCard cardStyle="elevated" title={t("billing.user.plan.title")}>
151
+ <SgStack gap={12}>
152
+ <SgStack gap={8}>
153
+ <strong>{subscription.plan?.name ?? "-"}</strong>
154
+ <span>
155
+ {t("billing.user.plan.price", {
156
+ price: formatCurrency(subscription.planTerm?.monthlyPrice),
157
+ })}
158
+ </span>
159
+ {subscription.pendingChange ? (
160
+ <span className="text-sm text-slate-500">
161
+ {t("billing.user.plan.pending_change", {
162
+ planName: subscription.pendingChange.planName ?? "-",
163
+ })}
164
+ </span>
165
+ ) : null}
166
+ {subscription.cancelAtPeriodEnd ? (
167
+ <span className="text-sm text-amber-600">
168
+ {t("billing.user.plan.cancel_scheduled")}
169
+ </span>
170
+ ) : null}
171
+ </SgStack>
172
+
173
+ <SgStack direction="row" gap={8} wrap>
174
+ {subscription.pendingChange ? (
175
+ <SgButton
176
+ appearance="outline"
177
+ severity="secondary"
178
+ shape="rounded"
179
+ label={t("billing.user.actions.cancel_scheduled_change")}
180
+ loading={busyAction}
181
+ disabled={busyAction}
182
+ onClick={() =>
183
+ void runAction(
184
+ "/api/billing/cancel-scheduled-change",
185
+ t("billing.user.actions.cancel_scheduled_change_success"),
186
+ t("billing.user.actions.cancel_scheduled_change_error"),
187
+ )
188
+ }
189
+ />
190
+ ) : null}
191
+
192
+ {subscription.cancelAtPeriodEnd ? (
193
+ <SgButton
194
+ appearance="solid"
195
+ severity="success"
196
+ shape="rounded"
197
+ label={t("billing.user.actions.reactivate")}
198
+ loading={busyAction}
199
+ disabled={busyAction}
200
+ onClick={() =>
201
+ void runAction(
202
+ "/api/billing/reactivate",
203
+ t("billing.user.actions.reactivate_success"),
204
+ t("billing.user.actions.reactivate_error"),
205
+ )
206
+ }
207
+ />
208
+ ) : (
209
+ <SgButton
210
+ appearance="outline"
211
+ severity="danger"
212
+ shape="rounded"
213
+ label={t("billing.user.actions.cancel")}
214
+ disabled={busyAction}
215
+ onClick={() => setConfirmCancel(true)}
216
+ />
217
+ )}
218
+ </SgStack>
219
+ </SgStack>
220
+ </SgCard>
221
+
222
+ {usage ? (
223
+ <SgCard cardStyle="elevated" title={t("billing.user.usage.title")}>
224
+ <SgStack gap={12}>
225
+ {/* Barra de progresso inline (o fe-components nao expoe um
226
+ SgProgressBar hoje): trilho + preenchimento por width%. */}
227
+ <div
228
+ className="h-2 w-full overflow-hidden rounded-full bg-slate-200"
229
+ role="progressbar"
230
+ aria-valuenow={usedPercent}
231
+ aria-valuemin={0}
232
+ aria-valuemax={100}
233
+ >
234
+ <div
235
+ className="h-full rounded-full bg-emerald-600 transition-all"
236
+ style={{ width: `${Math.min(100, Math.max(0, usedPercent))}%` }}
237
+ />
238
+ </div>
239
+ <span className="text-sm text-slate-600">
240
+ {t("billing.user.usage.summary", {
241
+ used: usage.cycle.usedRequests,
242
+ included: usage.cycle.includedRequests,
243
+ })}
244
+ </span>
245
+ {usage.cycle.overageRequests > 0 ? (
246
+ <span className="text-sm text-amber-600">
247
+ {t("billing.user.usage.overage", { overage: usage.cycle.overageRequests })}
248
+ </span>
249
+ ) : null}
250
+ </SgStack>
251
+ </SgCard>
252
+ ) : null}
253
+ </SgStack>
254
+ )}
255
+
256
+ <SgConfirmationDialog
257
+ open={confirmCancel}
258
+ severity="danger"
259
+ title={t("billing.user.actions.cancel_title")}
260
+ message={t("billing.user.actions.cancel_message")}
261
+ onCancel={() => setConfirmCancel(false)}
262
+ onConfirm={() => {
263
+ setConfirmCancel(false);
264
+ void runAction(
265
+ "/api/billing/cancel",
266
+ t("billing.user.actions.cancel_success"),
267
+ t("billing.user.actions.cancel_error"),
268
+ );
269
+ }}
270
+ />
271
+ </PageFrame>
272
+ );
273
+ }
@@ -0,0 +1,248 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useState } from "react";
4
+
5
+ import {
6
+ SgBadge,
7
+ SgButton,
8
+ SgCard,
9
+ SgConfirmationDialog,
10
+ SgGrid,
11
+ SgSkeleton,
12
+ SgStack,
13
+ sgWhistle,
14
+ } from "@seedgrid/fe-components";
15
+
16
+ import { useI18n } from "@/i18n";
17
+ import { PageFrame, RouteGuard } from "@/modules/security";
18
+ import { billingUserConfig } from "@/modules/billing-user";
19
+ import type {
20
+ ChangePreviewResponse,
21
+ PlanCatalogItem,
22
+ PlansCatalogResponse,
23
+ } from "@seedgrid/fe-billing-user";
24
+
25
+ export default function PlansCatalogPage() {
26
+ return (
27
+ <RouteGuard permissions={["BILLING_READ"]}>
28
+ <PlansCatalogScreen />
29
+ </RouteGuard>
30
+ );
31
+ }
32
+
33
+ function formatCurrency(value: number | string | null | undefined) {
34
+ if (value == null) return "-";
35
+ const numeric = typeof value === "number" ? value : Number.parseFloat(value);
36
+ if (!Number.isFinite(numeric)) return "-";
37
+ return new Intl.NumberFormat(undefined, { style: "currency", currency: "BRL" }).format(numeric);
38
+ }
39
+
40
+ function PlansCatalogScreen() {
41
+ const { t } = useI18n();
42
+ const [catalog, setCatalog] = useState<PlansCatalogResponse | null>(null);
43
+ const [loading, setLoading] = useState(true);
44
+ const [busyPlanId, setBusyPlanId] = useState<string | null>(null);
45
+ const [pendingChange, setPendingChange] = useState<{
46
+ item: PlanCatalogItem;
47
+ preview: ChangePreviewResponse;
48
+ } | null>(null);
49
+
50
+ const load = useCallback(async () => {
51
+ setLoading(true);
52
+ try {
53
+ const response = await fetch("/api/billing/plans", { cache: "no-store" });
54
+ if (!response.ok) throw new Error("load failed");
55
+ setCatalog((await response.json()) as PlansCatalogResponse);
56
+ } catch {
57
+ sgWhistle.error({ message: t("billing.user.plans_catalog.load_error") });
58
+ } finally {
59
+ setLoading(false);
60
+ }
61
+ }, [t]);
62
+
63
+ useEffect(() => {
64
+ void load();
65
+ }, [load]);
66
+
67
+ const items = catalog?.items ?? [];
68
+ const hasSubscription = items.some((item) => item.current);
69
+
70
+ async function handleSubscribe(item: PlanCatalogItem) {
71
+ setBusyPlanId(item.planId);
72
+ try {
73
+ const response = await fetch("/api/billing/checkout", {
74
+ method: "POST",
75
+ headers: { "Content-Type": "application/json" },
76
+ body: JSON.stringify({ planId: item.planId }),
77
+ });
78
+ if (!response.ok) throw new Error("checkout failed");
79
+ const payload = (await response.json()) as { checkoutUrl: string };
80
+ window.location.assign(payload.checkoutUrl);
81
+ } catch {
82
+ sgWhistle.error({ message: t("billing.user.plans_catalog.checkout_error") });
83
+ setBusyPlanId(null);
84
+ }
85
+ }
86
+
87
+ async function handleRequestChange(item: PlanCatalogItem) {
88
+ setBusyPlanId(item.planId);
89
+ try {
90
+ const response = await fetch(
91
+ `/api/billing/change-preview?planId=${encodeURIComponent(item.planId)}`,
92
+ { cache: "no-store" },
93
+ );
94
+ if (!response.ok) throw new Error("preview failed");
95
+ const preview = (await response.json()) as ChangePreviewResponse;
96
+ setPendingChange({ item, preview });
97
+ } catch {
98
+ sgWhistle.error({ message: t("billing.user.plans_catalog.preview_error") });
99
+ } finally {
100
+ setBusyPlanId(null);
101
+ }
102
+ }
103
+
104
+ async function confirmChange() {
105
+ if (!pendingChange) return;
106
+ const { item } = pendingChange;
107
+ setBusyPlanId(item.planId);
108
+ setPendingChange(null);
109
+ try {
110
+ const response = await fetch("/api/billing/change-plan", {
111
+ method: "POST",
112
+ headers: { "Content-Type": "application/json" },
113
+ body: JSON.stringify({ planId: item.planId }),
114
+ });
115
+ if (!response.ok) throw new Error("change failed");
116
+ sgWhistle.success({ message: t("billing.user.plans_catalog.change_success") });
117
+ await load();
118
+ } catch {
119
+ sgWhistle.error({ message: t("billing.user.plans_catalog.change_error") });
120
+ } finally {
121
+ setBusyPlanId(null);
122
+ }
123
+ }
124
+
125
+ const changeMessage = pendingChange
126
+ ? pendingChange.preview.downgrade
127
+ ? t("billing.user.plans_catalog.confirm.downgrade")
128
+ : pendingChange.preview.immediate
129
+ ? t("billing.user.plans_catalog.confirm.immediate", {
130
+ amount: formatCurrency(pendingChange.preview.amountDue),
131
+ })
132
+ : t("billing.user.plans_catalog.confirm.upgrade")
133
+ : "";
134
+
135
+ return (
136
+ <PageFrame
137
+ title={t("billing.user.plans_catalog.title")}
138
+ description={t("billing.user.plans_catalog.description")}
139
+ >
140
+ {loading ? (
141
+ <SgGrid minItemWidth={280} gap={16}>
142
+ <SgSkeleton height={220} />
143
+ <SgSkeleton height={220} />
144
+ <SgSkeleton height={220} />
145
+ </SgGrid>
146
+ ) : (
147
+ <SgStack gap={16}>
148
+ {catalog?.pendingChange ? (
149
+ <SgCard cardStyle="outlined" title={t("billing.user.plans_catalog.pending.title")}>
150
+ {t("billing.user.plans_catalog.pending.description", {
151
+ planName: catalog.pendingChange.planName,
152
+ })}
153
+ </SgCard>
154
+ ) : null}
155
+
156
+ <SgGrid minItemWidth={280} gap={16}>
157
+ {items.map((item) => (
158
+ <SgCard
159
+ key={item.planId}
160
+ cardStyle={item.current ? "elevated" : "outlined"}
161
+ title={item.name}
162
+ >
163
+ <SgStack gap={12}>
164
+ {item.current ? (
165
+ <SgBadge
166
+ value={t("billing.user.plans_catalog.current")}
167
+ severity="success"
168
+ badgeStyle="soft"
169
+ size="sm"
170
+ />
171
+ ) : null}
172
+ <div className="text-2xl font-semibold text-slate-900">
173
+ {formatCurrency(item.monthlyPrice)}
174
+ <span className="text-sm font-normal text-slate-500">
175
+ {" "}
176
+ {t("billing.user.plans_catalog.per_month")}
177
+ </span>
178
+ </div>
179
+ {item.description ? (
180
+ <p className="text-sm text-slate-600">{item.description}</p>
181
+ ) : null}
182
+ <p className="text-sm text-slate-600">
183
+ {t("billing.user.plans_catalog.requests", {
184
+ requests: item.monthlyRequests.toLocaleString(),
185
+ })}
186
+ </p>
187
+
188
+ {item.current ? (
189
+ <SgButton
190
+ appearance="outline"
191
+ severity="secondary"
192
+ shape="rounded"
193
+ label={t("billing.user.plans_catalog.current_action")}
194
+ disabled
195
+ />
196
+ ) : !item.checkoutEnabled ? (
197
+ <SgButton
198
+ appearance="outline"
199
+ severity="secondary"
200
+ shape="rounded"
201
+ label={t("billing.user.plans_catalog.unavailable")}
202
+ disabled
203
+ />
204
+ ) : hasSubscription ? (
205
+ <SgButton
206
+ appearance="solid"
207
+ severity="primary"
208
+ shape="rounded"
209
+ label={t("billing.user.plans_catalog.change_action")}
210
+ loading={busyPlanId === item.planId}
211
+ disabled={busyPlanId !== null}
212
+ onClick={() => void handleRequestChange(item)}
213
+ />
214
+ ) : (
215
+ <SgButton
216
+ appearance="solid"
217
+ severity="primary"
218
+ shape="rounded"
219
+ label={t("billing.user.plans_catalog.subscribe_action")}
220
+ loading={busyPlanId === item.planId}
221
+ disabled={busyPlanId !== null}
222
+ onClick={() => void handleSubscribe(item)}
223
+ />
224
+ )}
225
+ </SgStack>
226
+ </SgCard>
227
+ ))}
228
+ </SgGrid>
229
+
230
+ {!items.length ? (
231
+ <SgCard cardStyle="outlined" title={t("billing.user.plans_catalog.empty_title")}>
232
+ {t("billing.user.plans_catalog.empty")}
233
+ </SgCard>
234
+ ) : null}
235
+ </SgStack>
236
+ )}
237
+
238
+ <SgConfirmationDialog
239
+ open={Boolean(pendingChange)}
240
+ severity="primary"
241
+ title={t("billing.user.plans_catalog.confirm.title")}
242
+ message={changeMessage}
243
+ onCancel={() => setPendingChange(null)}
244
+ onConfirm={confirmChange}
245
+ />
246
+ </PageFrame>
247
+ );
248
+ }
@@ -0,0 +1,7 @@
1
+ export const billingUserConfig = {
2
+ routes: {
3
+ overview: "/billing",
4
+ plans: "/billing/plans",
5
+ invoices: "/billing/invoices",
6
+ },
7
+ } as const;
@@ -0,0 +1,17 @@
1
+ // Barrel do módulo billing-user no app gerado. Config local + nav hook +
2
+ // reexport dos tipos da lib (@seedgrid/fe-billing-user).
3
+
4
+ export { billingUserConfig } from "./config";
5
+ export { useBillingUserAppShellSections } from "./useBillingUserAppShellSections";
6
+
7
+ export type {
8
+ SubscriptionResponse,
9
+ UsageSummaryResponse,
10
+ PlanCatalogItem,
11
+ PlansCatalogResponse,
12
+ CheckoutSessionResponse,
13
+ ChangePlanResponse,
14
+ ChangePreviewResponse,
15
+ UpcomingInvoiceResponse,
16
+ BillingInvoiceEnvelope,
17
+ } from "@seedgrid/fe-billing-user";
@@ -0,0 +1,34 @@
1
+ "use server";
2
+
3
+ // Bootstrap do engine de billing do usuário: reaproveita o mesmo motor de
4
+ // sessão do fe-security (createSecurityServer) e compõe com
5
+ // createBillingUserServer por cima. Não existe uma fonte histórica pra essa
6
+ // composição (pacote novo, sem tela anterior — ver scaffold/README.md), mas
7
+ // os nomes de cookie seguem a mesma convenção já usada em
8
+ // modules/security/server/server-api.ts, pra funcionar com a MESMA sessão.
9
+
10
+ import { createSecurityServer } from "@seedgrid/fe-security/server";
11
+ import { createBillingUserServer } from "@seedgrid/fe-billing-user/server";
12
+
13
+ import { resolvePublicApiBaseUrl } from "@/modules/security/server/public-api-base-url";
14
+
15
+ const ACCESS_COOKIE_NAME =
16
+ process.env.SEEDGRID_SECURITY_ACCESS_COOKIE_NAME?.trim() ||
17
+ "sg_{{project_slug}}_access_token";
18
+ const REFRESH_COOKIE_NAME =
19
+ process.env.SEEDGRID_SECURITY_REFRESH_COOKIE_NAME?.trim() ||
20
+ "sg_{{project_slug}}_refresh_token";
21
+ const PASSWORD_CHANGE_REQUIRED_COOKIE_NAME = "sg_{{project_slug}}_password_change_required";
22
+
23
+ export async function getBillingUserServer() {
24
+ const session = createSecurityServer({
25
+ apiBaseUrl: await resolvePublicApiBaseUrl(),
26
+ cookies: {
27
+ token: ACCESS_COOKIE_NAME,
28
+ refresh: REFRESH_COOKIE_NAME,
29
+ passwordChangeRequired: PASSWORD_CHANGE_REQUIRED_COOKIE_NAME,
30
+ },
31
+ });
32
+
33
+ return createBillingUserServer(session);
34
+ }
@@ -0,0 +1,59 @@
1
+ "use client";
2
+
3
+ import { CreditCard, FileText, LayoutGrid, Receipt } from "lucide-react";
4
+
5
+ import type { AppShellSection } from "@/components/layout/app-shell-navigation";
6
+ import { useI18n } from "@/i18n";
7
+ import { usePermission } from "@/modules/security";
8
+
9
+ import { billingUserConfig } from "./config";
10
+
11
+ // Seção de navegação da assinatura do usuário. Gated por BILLING_READ (mesma
12
+ // permissão que as telas usam no RouteGuard).
13
+ export function useBillingUserAppShellSections(): AppShellSection[] {
14
+ const { t } = useI18n();
15
+ const canRead = usePermission("BILLING_READ");
16
+
17
+ if (!canRead) {
18
+ return [];
19
+ }
20
+
21
+ return [
22
+ {
23
+ id: "billing",
24
+ label: t("billing.user.nav.group"),
25
+ order: 30,
26
+ items: [
27
+ {
28
+ id: "billing-user",
29
+ label: t("billing.user.nav.group"),
30
+ icon: <CreditCard size={18} />,
31
+ order: 10,
32
+ children: [
33
+ {
34
+ id: "billing-overview",
35
+ label: t("billing.user.nav.overview"),
36
+ href: billingUserConfig.routes.overview,
37
+ icon: <LayoutGrid size={16} />,
38
+ order: 10,
39
+ },
40
+ {
41
+ id: "billing-plans",
42
+ label: t("billing.user.nav.plans"),
43
+ href: billingUserConfig.routes.plans,
44
+ icon: <FileText size={16} />,
45
+ order: 20,
46
+ },
47
+ {
48
+ id: "billing-invoices",
49
+ label: t("billing.user.nav.invoices"),
50
+ href: billingUserConfig.routes.invoices,
51
+ icon: <Receipt size={16} />,
52
+ order: 30,
53
+ },
54
+ ],
55
+ },
56
+ ],
57
+ },
58
+ ];
59
+ }