@seedgrid/fe-billing-user 2026.7.0 → 2026.7.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seedgrid/fe-billing-user",
3
- "version": "2026.7.0",
3
+ "version": "2026.7.2",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -31,8 +31,8 @@
31
31
  "@seedgrid/fe-security": "*"
32
32
  },
33
33
  "devDependencies": {
34
- "@seedgrid/fe-core": "2026.7.1",
35
- "@seedgrid/fe-security": "2026.7.2"
34
+ "@seedgrid/fe-core": "2026.7.8",
35
+ "@seedgrid/fe-security": "2026.7.3"
36
36
  },
37
37
  "scripts": {
38
38
  "build": "tsc -p tsconfig.json",
@@ -1,273 +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
- }
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
+ }