@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 +3 -3
- package/scaffold/src/app/billing/page.tsx +273 -273
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@seedgrid/fe-billing-user",
|
|
3
|
-
"version": "2026.7.
|
|
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.
|
|
35
|
-
"@seedgrid/fe-security": "2026.7.
|
|
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
|
+
}
|