@meterbox/customer-portal 0.1.7 → 0.1.9
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 +36 -0
- package/package.json +2 -2
- package/src/CancelSubscription.js +29 -28
- package/src/CustomerPortal.js +86 -33
- package/src/pages/AccountPage.js +37 -20
- package/src/pages/PlansPage.js +14 -9
package/README.md
CHANGED
|
@@ -30,11 +30,45 @@ function handlePlanSelect(plan, billingInterval) {
|
|
|
30
30
|
customerId="cus_42"
|
|
31
31
|
customerToken={customerJwt}
|
|
32
32
|
initialPage="account"
|
|
33
|
+
locale="en"
|
|
33
34
|
onPlanSelect={handlePlanSelect}
|
|
34
35
|
onTopUp={(balance) => /* mint top-up session */}
|
|
35
36
|
/>
|
|
36
37
|
```
|
|
37
38
|
|
|
39
|
+
## Deep links
|
|
40
|
+
|
|
41
|
+
`initialAction` opens the portal on an action rather than a page, for links
|
|
42
|
+
like "cancel" or "upgrade to Pro" in your emails or app:
|
|
43
|
+
|
|
44
|
+
```jsx
|
|
45
|
+
<CustomerPortal {...props} initialAction={{ type: "cancel" }} />
|
|
46
|
+
<CustomerPortal {...props} initialAction={{ type: "change_plan", planId: "pro" }} />
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
`cancel` opens the cancel flow on the Account page, at its "Why are you
|
|
50
|
+
cancelling?" step. `change_plan` opens the Plans page with that plan picked:
|
|
51
|
+
its proration preview and the "Change now" / "Schedule for end of period"
|
|
52
|
+
buttons. A plan that's the customer's current one, or that isn't published,
|
|
53
|
+
picks nothing. The action applies once; moving to another tab drops it.
|
|
54
|
+
|
|
55
|
+
## Language
|
|
56
|
+
|
|
57
|
+
`locale` shows the portal in your customer's language, with dates and amounts
|
|
58
|
+
formatted for it: English (the default), Spanish (`es`), French (`fr`) and
|
|
59
|
+
German (`de`) are built in. `messages` overrides any string by its key, or
|
|
60
|
+
translates them all for another language (anything missing stays in
|
|
61
|
+
English):
|
|
62
|
+
|
|
63
|
+
```jsx
|
|
64
|
+
<CustomerPortal {...props} locale={navigator.language} />
|
|
65
|
+
<CustomerPortal {...props} locale="it" messages={{ "portal.account": "Conto" }} />
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The keys are listed in
|
|
69
|
+
[`@meterbox/react`'s `src/i18n.js`](https://www.npmjs.com/package/@meterbox/react).
|
|
70
|
+
Plan names, features and retention offers are shown as you entered them.
|
|
71
|
+
|
|
38
72
|
## Branding
|
|
39
73
|
|
|
40
74
|
The portal takes your branding from Settings → Branding
|
|
@@ -45,6 +79,8 @@ override it.
|
|
|
45
79
|
## Pages
|
|
46
80
|
|
|
47
81
|
- **Account**: name, email, current plan, status (`GET /v1/customer-portal/:id/me`),
|
|
82
|
+
a button to update the payment method and billing details on Stripe's own
|
|
83
|
+
portal (`<BillingDetailsButton>`, when available),
|
|
48
84
|
and **Cancel subscription**: the customer gives a reason, sees your plan's
|
|
49
85
|
retention offers (a discount, a longer trial, or a downgrade) and either takes
|
|
50
86
|
one or cancels at the end of the period, keeping access until then
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meterbox/customer-portal",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "Drop-in multi-page React customer portal for Meterbox: Account, Plans, Credits. Built on @meterbox/react.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://meterbox.ai",
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"access": "public"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@meterbox/react": "0.1.
|
|
20
|
+
"@meterbox/react": "0.1.7"
|
|
21
21
|
},
|
|
22
22
|
"peerDependencies": {
|
|
23
23
|
"react": ">=17",
|
|
@@ -3,10 +3,13 @@
|
|
|
3
3
|
* reason, see the plan's retention offers, and either take one and stay or
|
|
4
4
|
* cancel at the end of the period (keeping access until then). Uses the
|
|
5
5
|
* /v1/customer-portal/:id/cancellation endpoints with the customer token.
|
|
6
|
+
*
|
|
7
|
+
* `initialStep="reason"` opens straight on the reason step (the portal's
|
|
8
|
+
* cancel deep link).
|
|
6
9
|
*/
|
|
7
10
|
|
|
8
11
|
const React = require("react");
|
|
9
|
-
const { theme } = require("@meterbox/react");
|
|
12
|
+
const { theme, useI18n } = require("@meterbox/react");
|
|
10
13
|
|
|
11
14
|
const REASONS = {
|
|
12
15
|
too_expensive: "It's too expensive",
|
|
@@ -46,10 +49,6 @@ const styles = {
|
|
|
46
49
|
muted: { opacity: 0.7 },
|
|
47
50
|
};
|
|
48
51
|
|
|
49
|
-
function formatDate(iso) {
|
|
50
|
-
return iso ? String(iso).slice(0, 10) : "the end of the period";
|
|
51
|
-
}
|
|
52
|
-
|
|
53
52
|
function button(label, onClick, extra = {}, disabled = false) {
|
|
54
53
|
return React.createElement(
|
|
55
54
|
"button",
|
|
@@ -65,9 +64,12 @@ function CancelSubscription({
|
|
|
65
64
|
fetch: fetchImpl,
|
|
66
65
|
status,
|
|
67
66
|
cancelAt,
|
|
67
|
+
initialStep = "idle",
|
|
68
68
|
onChange,
|
|
69
69
|
}) {
|
|
70
|
-
const
|
|
70
|
+
const { t, date } = useI18n();
|
|
71
|
+
// idle → reason → offers → done
|
|
72
|
+
const [step, setStep] = React.useState(initialStep === "reason" ? "reason" : "idle");
|
|
71
73
|
const [reason, setReason] = React.useState("too_expensive");
|
|
72
74
|
const [detail, setDetail] = React.useState("");
|
|
73
75
|
const [offers, setOffers] = React.useState([]);
|
|
@@ -75,6 +77,10 @@ function CancelSubscription({
|
|
|
75
77
|
const [error, setError] = React.useState(null);
|
|
76
78
|
const [busy, setBusy] = React.useState(false);
|
|
77
79
|
|
|
80
|
+
function endsOn(iso) {
|
|
81
|
+
return iso ? t("cancel.endsOn", { date: date(iso) }) : t("cancel.endsAtPeriodEnd");
|
|
82
|
+
}
|
|
83
|
+
|
|
78
84
|
async function call(method, sub, body) {
|
|
79
85
|
const url = `${String(baseUrl).replace(/\/+$/, "")}/v1/customer-portal/${encodeURIComponent(
|
|
80
86
|
customerId,
|
|
@@ -95,7 +101,7 @@ function CancelSubscription({
|
|
|
95
101
|
try {
|
|
96
102
|
await fn();
|
|
97
103
|
} catch (err) {
|
|
98
|
-
setError(err.message || "
|
|
104
|
+
setError(err.message || t("cancel.error"));
|
|
99
105
|
} finally {
|
|
100
106
|
setBusy(false);
|
|
101
107
|
}
|
|
@@ -113,14 +119,14 @@ function CancelSubscription({
|
|
|
113
119
|
const accept = (offer) =>
|
|
114
120
|
run(async () => {
|
|
115
121
|
await call("POST", "/accept-offer", { offer_id: offer.id });
|
|
116
|
-
setDone(
|
|
122
|
+
setDone(t("cancel.offerTaken", { offer: offer.label }));
|
|
117
123
|
setStep("done");
|
|
118
124
|
if (onChange) onChange();
|
|
119
125
|
});
|
|
120
126
|
const confirm = () =>
|
|
121
127
|
run(async () => {
|
|
122
128
|
const r = await call("POST", "/confirm");
|
|
123
|
-
setDone(
|
|
129
|
+
setDone(endsOn(r.cancel_at));
|
|
124
130
|
setStep("done");
|
|
125
131
|
if (onChange) onChange();
|
|
126
132
|
});
|
|
@@ -131,66 +137,61 @@ function CancelSubscription({
|
|
|
131
137
|
});
|
|
132
138
|
|
|
133
139
|
if (status === "cancelling") {
|
|
134
|
-
return React.createElement(
|
|
135
|
-
"div",
|
|
136
|
-
{ style: styles.box },
|
|
137
|
-
`Your subscription ends on ${formatDate(cancelAt)}. You keep access until then.`,
|
|
138
|
-
);
|
|
140
|
+
return React.createElement("div", { style: styles.box }, endsOn(cancelAt));
|
|
139
141
|
}
|
|
140
142
|
if (status === "cancelled") return null;
|
|
141
143
|
|
|
142
144
|
let content;
|
|
143
145
|
if (step === "idle") {
|
|
144
|
-
content = button("
|
|
146
|
+
content = button(t("cancel.button"), () => setStep("reason"), styles.danger);
|
|
145
147
|
} else if (step === "reason") {
|
|
146
148
|
content = React.createElement(
|
|
147
149
|
React.Fragment,
|
|
148
150
|
null,
|
|
149
|
-
React.createElement("strong", null, "
|
|
151
|
+
React.createElement("strong", null, t("cancel.why")),
|
|
150
152
|
React.createElement(
|
|
151
153
|
"select",
|
|
152
154
|
{
|
|
153
155
|
value: reason,
|
|
154
156
|
onChange: (e) => setReason(e.target.value),
|
|
155
|
-
"aria-label": "
|
|
157
|
+
"aria-label": t("cancel.reasonLabel"),
|
|
156
158
|
},
|
|
157
|
-
Object.
|
|
158
|
-
React.createElement("option", { key: value, value },
|
|
159
|
+
Object.keys(REASONS).map((value) =>
|
|
160
|
+
React.createElement("option", { key: value, value }, t(`cancel.reason.${value}`)),
|
|
159
161
|
),
|
|
160
162
|
),
|
|
161
163
|
React.createElement("textarea", {
|
|
162
164
|
value: detail,
|
|
163
165
|
onChange: (e) => setDetail(e.target.value),
|
|
164
|
-
placeholder: "
|
|
165
|
-
"aria-label": "
|
|
166
|
+
placeholder: t("cancel.detailPlaceholder"),
|
|
167
|
+
"aria-label": t("cancel.detailLabel"),
|
|
166
168
|
rows: 3,
|
|
167
169
|
}),
|
|
168
170
|
React.createElement(
|
|
169
171
|
"div",
|
|
170
172
|
{ style: styles.row },
|
|
171
|
-
button("
|
|
172
|
-
button("
|
|
173
|
+
button(t("cancel.continue"), start, styles.primary, busy),
|
|
174
|
+
button(t("cancel.keep"), () => setStep("idle"), {}, busy),
|
|
173
175
|
),
|
|
174
176
|
);
|
|
175
177
|
} else if (step === "offers") {
|
|
176
178
|
content = React.createElement(
|
|
177
179
|
React.Fragment,
|
|
178
180
|
null,
|
|
179
|
-
offers.length > 0 &&
|
|
180
|
-
React.createElement("strong", null, "Before you go, would one of these help?"),
|
|
181
|
+
offers.length > 0 && React.createElement("strong", null, t("cancel.offersTitle")),
|
|
181
182
|
...offers.map((offer) =>
|
|
182
183
|
React.createElement(
|
|
183
184
|
"div",
|
|
184
185
|
{ key: offer.id, style: styles.offer },
|
|
185
186
|
React.createElement("span", null, offer.label),
|
|
186
|
-
button("
|
|
187
|
+
button(t("cancel.accept"), () => accept(offer), styles.primary, busy),
|
|
187
188
|
),
|
|
188
189
|
),
|
|
189
190
|
React.createElement(
|
|
190
191
|
"div",
|
|
191
192
|
{ style: styles.row },
|
|
192
|
-
button("
|
|
193
|
-
button("
|
|
193
|
+
button(t("cancel.confirm"), confirm, styles.danger, busy),
|
|
194
|
+
button(t("cancel.keep"), keep, {}, busy),
|
|
194
195
|
),
|
|
195
196
|
);
|
|
196
197
|
} else {
|
package/src/CustomerPortal.js
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
* customerId="cus_42"
|
|
7
7
|
* customerToken={jwt}
|
|
8
8
|
* initialPage="account" // "account" (default) | "plans" | "credits"
|
|
9
|
+
* initialAction={{ type: "cancel" }} // (optional) deep link, see below
|
|
10
|
+
* locale="fr" // (optional) language + date/amount format
|
|
11
|
+
* messages={{ "cancel.button": "..." }} // (optional) override any string
|
|
9
12
|
* onPageChange={(page) => ...} // (optional) consumer wires deep-linking
|
|
10
13
|
* onPlanSelect={(plan) => ...} // (optional) host app routes to checkout
|
|
11
14
|
* onTopUp={(balance) => ...} // (optional) host app mints top-up
|
|
@@ -22,16 +25,28 @@
|
|
|
22
25
|
* No router dependency — page state is internal. Hosts that want
|
|
23
26
|
* browser-history nav can drive `initialPage` from their own router and
|
|
24
27
|
* observe `onPageChange`.
|
|
28
|
+
*
|
|
29
|
+
* Deep links: `initialAction` opens straight on an action rather than a
|
|
30
|
+
* page: `{ type: "cancel" }` opens the cancel flow on the Account page, at
|
|
31
|
+
* its reason step; `{ type: "change_plan", planId: "pro" }` opens the Plans
|
|
32
|
+
* page with that plan picked (its proration preview and Change now /
|
|
33
|
+
* Schedule buttons). The action applies once; moving to another tab drops it.
|
|
34
|
+
*
|
|
35
|
+
* Language: `locale` translates every string (English, Spanish, French and
|
|
36
|
+
* German built in, through @meterbox/react's <LocaleProvider>) and formats
|
|
37
|
+
* dates and amounts for it; `messages` overrides any string by its key, or
|
|
38
|
+
* translates them all for another language. English when unset.
|
|
25
39
|
*/
|
|
26
40
|
|
|
27
41
|
const React = require("react");
|
|
28
|
-
const { BrandingProvider, logoUrl, theme } = require("@meterbox/react");
|
|
42
|
+
const { BrandingProvider, LocaleProvider, useI18n, logoUrl, theme } = require("@meterbox/react");
|
|
29
43
|
const AccountPage = require("./pages/AccountPage");
|
|
30
44
|
const PlansPage = require("./pages/PlansPage");
|
|
31
45
|
const CreditsPage = require("./pages/CreditsPage");
|
|
32
46
|
|
|
33
47
|
const PAGES = ["account", "plans", "credits"];
|
|
34
|
-
|
|
48
|
+
// The page each deep link opens on.
|
|
49
|
+
const ACTION_PAGES = { cancel: "account", change_plan: "plans" };
|
|
35
50
|
|
|
36
51
|
const styles = {
|
|
37
52
|
shell: {
|
|
@@ -78,11 +93,36 @@ async function fetchBranding({ baseUrl, customerId, customerToken, fetchImpl })
|
|
|
78
93
|
return res.ok ? res.json() : null;
|
|
79
94
|
}
|
|
80
95
|
|
|
96
|
+
function Tabs({ page, go }) {
|
|
97
|
+
const { t } = useI18n();
|
|
98
|
+
return React.createElement(
|
|
99
|
+
"nav",
|
|
100
|
+
{ style: styles.nav, role: "tablist" },
|
|
101
|
+
PAGES.map((p) =>
|
|
102
|
+
React.createElement(
|
|
103
|
+
"button",
|
|
104
|
+
{
|
|
105
|
+
key: p,
|
|
106
|
+
type: "button",
|
|
107
|
+
role: "tab",
|
|
108
|
+
"aria-selected": page === p,
|
|
109
|
+
style: page === p ? { ...styles.tab, ...styles.tabActive } : styles.tab,
|
|
110
|
+
onClick: () => go(p),
|
|
111
|
+
},
|
|
112
|
+
t(`portal.${p}`),
|
|
113
|
+
),
|
|
114
|
+
),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
81
118
|
function CustomerPortal({
|
|
82
119
|
baseUrl,
|
|
83
120
|
customerId,
|
|
84
121
|
customerToken,
|
|
85
122
|
initialPage = "account",
|
|
123
|
+
initialAction,
|
|
124
|
+
locale,
|
|
125
|
+
messages,
|
|
86
126
|
onPageChange,
|
|
87
127
|
onPlanSelect,
|
|
88
128
|
onTopUp,
|
|
@@ -92,7 +132,11 @@ function CustomerPortal({
|
|
|
92
132
|
branding,
|
|
93
133
|
fetch: fetchImpl,
|
|
94
134
|
}) {
|
|
95
|
-
const
|
|
135
|
+
const actionType = ACTION_PAGES[initialAction?.type] ? initialAction.type : null;
|
|
136
|
+
const actionPlanId = initialAction?.planId || null;
|
|
137
|
+
const [action, setAction] = React.useState(actionType);
|
|
138
|
+
const initial =
|
|
139
|
+
ACTION_PAGES[actionType] || (PAGES.includes(initialPage) ? initialPage : "account");
|
|
96
140
|
const [page, setPage] = React.useState(initial);
|
|
97
141
|
const [tenant, setTenant] = React.useState(null);
|
|
98
142
|
|
|
@@ -113,19 +157,37 @@ function CustomerPortal({
|
|
|
113
157
|
|
|
114
158
|
// Honor external nav changes via initialPage prop.
|
|
115
159
|
React.useEffect(() => {
|
|
116
|
-
if (PAGES.includes(initialPage))
|
|
160
|
+
if (!PAGES.includes(initialPage)) return;
|
|
161
|
+
setAction(null);
|
|
162
|
+
setPage(initialPage);
|
|
117
163
|
}, [initialPage]);
|
|
118
164
|
|
|
165
|
+
// ...and a new deep link (after the above, so on mount the deep link wins).
|
|
166
|
+
React.useEffect(() => {
|
|
167
|
+
if (!actionType) return;
|
|
168
|
+
setAction(actionType);
|
|
169
|
+
setPage(ACTION_PAGES[actionType]);
|
|
170
|
+
}, [actionType, actionPlanId]);
|
|
171
|
+
|
|
119
172
|
const go = (next) => {
|
|
173
|
+
setAction(null);
|
|
120
174
|
setPage(next);
|
|
121
175
|
if (typeof onPageChange === "function") onPageChange(next);
|
|
122
176
|
};
|
|
123
177
|
|
|
124
178
|
const common = { baseUrl, customerId, customerToken, fetch: fetchImpl };
|
|
125
179
|
|
|
180
|
+
// Keyed by the deep link, so a new one remounts the page and applies.
|
|
181
|
+
const key = action ? `${action}:${actionPlanId || ""}` : "page";
|
|
126
182
|
let body;
|
|
127
183
|
if (page === "plans") {
|
|
128
|
-
body = React.createElement(PlansPage, {
|
|
184
|
+
body = React.createElement(PlansPage, {
|
|
185
|
+
...common,
|
|
186
|
+
key,
|
|
187
|
+
currency,
|
|
188
|
+
onPlanSelect,
|
|
189
|
+
initialPlanId: action === "change_plan" ? actionPlanId : undefined,
|
|
190
|
+
});
|
|
129
191
|
} else if (page === "credits") {
|
|
130
192
|
body = React.createElement(CreditsPage, {
|
|
131
193
|
...common,
|
|
@@ -134,42 +196,33 @@ function CustomerPortal({
|
|
|
134
196
|
granularity,
|
|
135
197
|
});
|
|
136
198
|
} else {
|
|
137
|
-
body = React.createElement(AccountPage,
|
|
199
|
+
body = React.createElement(AccountPage, {
|
|
200
|
+
...common,
|
|
201
|
+
key,
|
|
202
|
+
startCancel: action === "cancel",
|
|
203
|
+
});
|
|
138
204
|
}
|
|
139
205
|
|
|
140
206
|
return React.createElement(
|
|
141
|
-
|
|
142
|
-
{
|
|
207
|
+
LocaleProvider,
|
|
208
|
+
{ locale, messages },
|
|
143
209
|
React.createElement(
|
|
144
|
-
|
|
145
|
-
{
|
|
146
|
-
(logo || companyName) &&
|
|
147
|
-
React.createElement(
|
|
148
|
-
"header",
|
|
149
|
-
{ style: styles.header },
|
|
150
|
-
logo &&
|
|
151
|
-
React.createElement("img", { src: logo, alt: companyName || "", style: styles.logo }),
|
|
152
|
-
companyName && React.createElement("span", { style: styles.company }, companyName),
|
|
153
|
-
),
|
|
210
|
+
BrandingProvider,
|
|
211
|
+
{ branding: brand },
|
|
154
212
|
React.createElement(
|
|
155
|
-
"
|
|
156
|
-
{
|
|
157
|
-
|
|
213
|
+
"div",
|
|
214
|
+
{ className: "meterbox-customer-portal", style: styles.shell },
|
|
215
|
+
(logo || companyName) &&
|
|
158
216
|
React.createElement(
|
|
159
|
-
"
|
|
160
|
-
{
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
"aria-selected": page === p,
|
|
165
|
-
style: page === p ? { ...styles.tab, ...styles.tabActive } : styles.tab,
|
|
166
|
-
onClick: () => go(p),
|
|
167
|
-
},
|
|
168
|
-
LABELS[p],
|
|
217
|
+
"header",
|
|
218
|
+
{ style: styles.header },
|
|
219
|
+
logo &&
|
|
220
|
+
React.createElement("img", { src: logo, alt: companyName || "", style: styles.logo }),
|
|
221
|
+
companyName && React.createElement("span", { style: styles.company }, companyName),
|
|
169
222
|
),
|
|
170
|
-
),
|
|
223
|
+
React.createElement(Tabs, { page, go }),
|
|
224
|
+
body,
|
|
171
225
|
),
|
|
172
|
-
body,
|
|
173
226
|
),
|
|
174
227
|
);
|
|
175
228
|
}
|
package/src/pages/AccountPage.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* <AccountPage /> — fetches GET /v1/customer-portal/:id/me and renders the
|
|
3
|
-
* customer's own profile + current plan,
|
|
4
|
-
*
|
|
3
|
+
* customer's own profile + current plan, a button to update their payment
|
|
4
|
+
* method and billing details on Stripe's portal (when available), the way to
|
|
5
|
+
* cancel, and their invoices with PDFs (`<InvoiceHistory>` from
|
|
6
|
+
* @meterbox/react). `startCancel` opens the cancel flow on its reason step
|
|
7
|
+
* (the portal's cancel deep link).
|
|
5
8
|
*/
|
|
6
9
|
|
|
7
10
|
const React = require("react");
|
|
8
|
-
const { InvoiceHistory, theme } = require("@meterbox/react");
|
|
11
|
+
const { InvoiceHistory, BillingDetailsButton, theme, useI18n } = require("@meterbox/react");
|
|
9
12
|
const CancelSubscription = require("../CancelSubscription");
|
|
10
13
|
|
|
11
14
|
const styles = {
|
|
@@ -52,13 +55,20 @@ async function fetchMe({ baseUrl, customerId, customerToken, fetchImpl }) {
|
|
|
52
55
|
return json;
|
|
53
56
|
}
|
|
54
57
|
|
|
55
|
-
function AccountPage({ baseUrl, customerId, customerToken, fetch: fetchImpl }) {
|
|
58
|
+
function AccountPage({ baseUrl, customerId, customerToken, startCancel, fetch: fetchImpl }) {
|
|
59
|
+
const { t, date } = useI18n();
|
|
56
60
|
const [me, setMe] = React.useState(null);
|
|
57
61
|
const [error, setError] = React.useState(null);
|
|
58
62
|
const [loading, setLoading] = React.useState(true);
|
|
63
|
+
const [billingError, setBillingError] = React.useState(null);
|
|
59
64
|
// Bumped after a cancellation step, to show the new status.
|
|
60
65
|
const [version, setVersion] = React.useState(0);
|
|
61
|
-
|
|
66
|
+
// The cancel deep link opens the flow once, not again on each reload.
|
|
67
|
+
const [openCancel, setOpenCancel] = React.useState(Boolean(startCancel));
|
|
68
|
+
const reload = React.useCallback(() => {
|
|
69
|
+
setOpenCancel(false);
|
|
70
|
+
setVersion((v) => v + 1);
|
|
71
|
+
}, []);
|
|
62
72
|
|
|
63
73
|
React.useEffect(() => {
|
|
64
74
|
let cancelled = false;
|
|
@@ -78,7 +88,7 @@ function AccountPage({ baseUrl, customerId, customerToken, fetch: fetchImpl }) {
|
|
|
78
88
|
})
|
|
79
89
|
.catch((err) => {
|
|
80
90
|
if (!cancelled) {
|
|
81
|
-
setError(err.message || "
|
|
91
|
+
setError(err.message || t("account.error"));
|
|
82
92
|
setLoading(false);
|
|
83
93
|
}
|
|
84
94
|
});
|
|
@@ -91,7 +101,7 @@ function AccountPage({ baseUrl, customerId, customerToken, fetch: fetchImpl }) {
|
|
|
91
101
|
return React.createElement(
|
|
92
102
|
"div",
|
|
93
103
|
{ style: { opacity: 0.6, fontFamily: styles.card.fontFamily } },
|
|
94
|
-
"
|
|
104
|
+
t("account.loading"),
|
|
95
105
|
);
|
|
96
106
|
}
|
|
97
107
|
if (error) {
|
|
@@ -111,46 +121,52 @@ function AccountPage({ baseUrl, customerId, customerToken, fetch: fetchImpl }) {
|
|
|
111
121
|
React.createElement(
|
|
112
122
|
"div",
|
|
113
123
|
{ style: styles.row },
|
|
114
|
-
React.createElement("span", { style: styles.label }, "
|
|
124
|
+
React.createElement("span", { style: styles.label }, t("account.name")),
|
|
115
125
|
React.createElement("span", { style: styles.value }, me.name),
|
|
116
126
|
),
|
|
117
127
|
me.email &&
|
|
118
128
|
React.createElement(
|
|
119
129
|
"div",
|
|
120
130
|
{ style: styles.row },
|
|
121
|
-
React.createElement("span", { style: styles.label }, "
|
|
131
|
+
React.createElement("span", { style: styles.label }, t("account.email")),
|
|
122
132
|
React.createElement("span", { style: styles.value }, me.email),
|
|
123
133
|
),
|
|
124
134
|
React.createElement(
|
|
125
135
|
"div",
|
|
126
136
|
{ style: styles.row },
|
|
127
|
-
React.createElement("span", { style: styles.label }, "
|
|
137
|
+
React.createElement("span", { style: styles.label }, t("account.plan")),
|
|
128
138
|
React.createElement("span", { style: styles.value }, me.plan_name || me.plan_id),
|
|
129
139
|
),
|
|
130
140
|
React.createElement(
|
|
131
141
|
"div",
|
|
132
142
|
{ style: styles.row },
|
|
133
|
-
React.createElement("span", { style: styles.label }, "
|
|
134
|
-
React.createElement("span", { style: statusPill }, me.status),
|
|
143
|
+
React.createElement("span", { style: styles.label }, t("account.status")),
|
|
144
|
+
React.createElement("span", { style: statusPill }, t(`status.${me.status}`, null, me.status)),
|
|
135
145
|
),
|
|
136
146
|
me.current_period_end &&
|
|
137
147
|
React.createElement(
|
|
138
148
|
"div",
|
|
139
149
|
{ style: styles.row },
|
|
140
|
-
React.createElement("span", { style: styles.label }, "
|
|
141
|
-
React.createElement(
|
|
142
|
-
"span",
|
|
143
|
-
{ style: styles.value },
|
|
144
|
-
String(me.current_period_end).slice(0, 10),
|
|
145
|
-
),
|
|
150
|
+
React.createElement("span", { style: styles.label }, t("account.periodEnds")),
|
|
151
|
+
React.createElement("span", { style: styles.value }, date(me.current_period_end)),
|
|
146
152
|
),
|
|
147
153
|
me.cancel_at &&
|
|
148
154
|
React.createElement(
|
|
149
155
|
"div",
|
|
150
156
|
{ style: styles.row },
|
|
151
|
-
React.createElement("span", { style: styles.label }, "
|
|
152
|
-
React.createElement("span", { style: styles.value },
|
|
157
|
+
React.createElement("span", { style: styles.label }, t("account.endsOn")),
|
|
158
|
+
React.createElement("span", { style: styles.value }, date(me.cancel_at)),
|
|
153
159
|
),
|
|
160
|
+
me.billing_details_available &&
|
|
161
|
+
React.createElement(BillingDetailsButton, {
|
|
162
|
+
baseUrl,
|
|
163
|
+
customerId,
|
|
164
|
+
customerToken,
|
|
165
|
+
fetch: fetchImpl,
|
|
166
|
+
onError: (err) => setBillingError(err.message),
|
|
167
|
+
}),
|
|
168
|
+
billingError &&
|
|
169
|
+
React.createElement("span", { role: "alert", style: { color: "#dc2626" } }, billingError),
|
|
154
170
|
);
|
|
155
171
|
return React.createElement(
|
|
156
172
|
"div",
|
|
@@ -163,6 +179,7 @@ function AccountPage({ baseUrl, customerId, customerToken, fetch: fetchImpl }) {
|
|
|
163
179
|
fetch: fetchImpl,
|
|
164
180
|
status: me.status,
|
|
165
181
|
cancelAt: me.cancel_at,
|
|
182
|
+
initialStep: openCancel ? "reason" : "idle",
|
|
166
183
|
onChange: reload,
|
|
167
184
|
}),
|
|
168
185
|
React.createElement(InvoiceHistory, { baseUrl, customerId, customerToken, fetch: fetchImpl }),
|
package/src/pages/PlansPage.js
CHANGED
|
@@ -16,10 +16,13 @@
|
|
|
16
16
|
* path (`<CheckoutButton billingInterval="annual">`, a new Stripe Checkout
|
|
17
17
|
* session with its own annual price) — a short note stands in here instead
|
|
18
18
|
* of a silently wrong in-place change.
|
|
19
|
+
*
|
|
20
|
+
* `initialPlanId` picks that plan once the plans load (the portal's "change
|
|
21
|
+
* to plan X" deep link), unless it's the current plan or not offered.
|
|
19
22
|
*/
|
|
20
23
|
|
|
21
24
|
const React = require("react");
|
|
22
|
-
const { PricingTable, PlanChangeCard } = require("@meterbox/react");
|
|
25
|
+
const { PricingTable, PlanChangeCard, useI18n } = require("@meterbox/react");
|
|
23
26
|
|
|
24
27
|
const styles = {
|
|
25
28
|
wrap: { display: "grid", gap: 16 },
|
|
@@ -41,9 +44,11 @@ function PlansPage({
|
|
|
41
44
|
customerId,
|
|
42
45
|
customerToken,
|
|
43
46
|
currency = "USD",
|
|
47
|
+
initialPlanId,
|
|
44
48
|
onPlanSelect,
|
|
45
49
|
fetch: fetchImpl,
|
|
46
50
|
}) {
|
|
51
|
+
const { t } = useI18n();
|
|
47
52
|
const fetcher = fetchImpl || globalThis.fetch;
|
|
48
53
|
const root = String(baseUrl).replace(/\/+$/, "");
|
|
49
54
|
const [plans, setPlans] = React.useState(null);
|
|
@@ -78,14 +83,18 @@ function PlansPage({
|
|
|
78
83
|
])
|
|
79
84
|
.then(([catalog, me]) => {
|
|
80
85
|
if (cancelled) return;
|
|
81
|
-
|
|
86
|
+
const list = catalog.plans || [];
|
|
87
|
+
setPlans(list);
|
|
82
88
|
setCurrentPlanId(me.plan_id || null);
|
|
83
89
|
setScheduledChange(me.scheduled_change || null);
|
|
90
|
+
if (initialPlanId !== me.plan_id && list.some((p) => p.id === initialPlanId)) {
|
|
91
|
+
setPicked(initialPlanId);
|
|
92
|
+
}
|
|
84
93
|
setLoading(false);
|
|
85
94
|
})
|
|
86
95
|
.catch((err) => {
|
|
87
96
|
if (!cancelled) {
|
|
88
|
-
setError(err.message || "
|
|
97
|
+
setError(err.message || t("plans.error"));
|
|
89
98
|
setLoading(false);
|
|
90
99
|
}
|
|
91
100
|
});
|
|
@@ -109,7 +118,7 @@ function PlansPage({
|
|
|
109
118
|
}
|
|
110
119
|
|
|
111
120
|
if (loading) {
|
|
112
|
-
return React.createElement("div", { style: { opacity: 0.6 } }, "
|
|
121
|
+
return React.createElement("div", { style: { opacity: 0.6 } }, t("plans.loading"));
|
|
113
122
|
}
|
|
114
123
|
if (error) {
|
|
115
124
|
return React.createElement("div", { style: { color: "#dc2626" } }, error);
|
|
@@ -135,11 +144,7 @@ function PlansPage({
|
|
|
135
144
|
// currentPlanId to match what was just picked.
|
|
136
145
|
picked &&
|
|
137
146
|
(pickedInterval === "annual"
|
|
138
|
-
? React.createElement(
|
|
139
|
-
"p",
|
|
140
|
-
{ style: { fontSize: 13, opacity: 0.7 } },
|
|
141
|
-
"Cost preview isn't available for annual billing yet — the amount due will be confirmed at checkout.",
|
|
142
|
-
)
|
|
147
|
+
? React.createElement("p", { style: { fontSize: 13, opacity: 0.7 } }, t("plans.annualNote"))
|
|
143
148
|
: React.createElement(PlanChangeCard, {
|
|
144
149
|
baseUrl,
|
|
145
150
|
customerId,
|