@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.
- package/README.md +78 -0
- package/dist/billing.d.ts +234 -0
- package/dist/billing.d.ts.map +1 -0
- package/dist/billing.js +4 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/messages.d.ts +348 -0
- package/dist/messages.d.ts.map +1 -0
- package/dist/messages.js +148 -0
- package/dist/module.d.ts +3 -0
- package/dist/module.d.ts.map +1 -0
- package/dist/module.js +13 -0
- package/dist/module.json +15 -0
- package/dist/paths.d.ts +29 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +35 -0
- package/dist/server/create-billing-user-server.d.ts +34 -0
- package/dist/server/create-billing-user-server.d.ts.map +1 -0
- package/dist/server/create-billing-user-server.js +33 -0
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +2 -0
- package/package.json +43 -0
- package/scaffold/README.md +72 -0
- package/scaffold/src/app/api/billing/cancel/route.ts +10 -0
- package/scaffold/src/app/api/billing/cancel-scheduled-change/route.ts +10 -0
- package/scaffold/src/app/api/billing/change-plan/route.ts +14 -0
- package/scaffold/src/app/api/billing/change-preview/route.ts +13 -0
- package/scaffold/src/app/api/billing/checkout/route.ts +14 -0
- package/scaffold/src/app/api/billing/invoices/route.ts +14 -0
- package/scaffold/src/app/api/billing/me/route.ts +15 -0
- package/scaffold/src/app/api/billing/plans/route.ts +10 -0
- package/scaffold/src/app/api/billing/portal/route.ts +10 -0
- package/scaffold/src/app/api/billing/reactivate/route.ts +10 -0
- package/scaffold/src/app/api/billing/upcoming/route.ts +10 -0
- package/scaffold/src/app/billing/invoices/page.tsx +213 -0
- package/scaffold/src/app/billing/page.tsx +273 -0
- package/scaffold/src/app/billing/plans/page.tsx +248 -0
- package/scaffold/src/modules/billing-user/config.ts +7 -0
- package/scaffold/src/modules/billing-user/index.ts +17 -0
- package/scaffold/src/modules/billing-user/server.ts.hbs +34 -0
- 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,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
|
+
}
|