@meterbox/customer-portal 0.1.8 → 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 +34 -0
- package/package.json +2 -2
- package/src/CancelSubscription.js +29 -28
- package/src/CustomerPortal.js +86 -33
- package/src/pages/AccountPage.js +23 -19
- 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
|
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
|
@@ -3,11 +3,12 @@
|
|
|
3
3
|
* customer's own profile + current plan, a button to update their payment
|
|
4
4
|
* method and billing details on Stripe's portal (when available), the way to
|
|
5
5
|
* cancel, and their invoices with PDFs (`<InvoiceHistory>` from
|
|
6
|
-
* @meterbox/react).
|
|
6
|
+
* @meterbox/react). `startCancel` opens the cancel flow on its reason step
|
|
7
|
+
* (the portal's cancel deep link).
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
const React = require("react");
|
|
10
|
-
const { InvoiceHistory, BillingDetailsButton, theme } = require("@meterbox/react");
|
|
11
|
+
const { InvoiceHistory, BillingDetailsButton, theme, useI18n } = require("@meterbox/react");
|
|
11
12
|
const CancelSubscription = require("../CancelSubscription");
|
|
12
13
|
|
|
13
14
|
const styles = {
|
|
@@ -54,14 +55,20 @@ async function fetchMe({ baseUrl, customerId, customerToken, fetchImpl }) {
|
|
|
54
55
|
return json;
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
function AccountPage({ baseUrl, customerId, customerToken, fetch: fetchImpl }) {
|
|
58
|
+
function AccountPage({ baseUrl, customerId, customerToken, startCancel, fetch: fetchImpl }) {
|
|
59
|
+
const { t, date } = useI18n();
|
|
58
60
|
const [me, setMe] = React.useState(null);
|
|
59
61
|
const [error, setError] = React.useState(null);
|
|
60
62
|
const [loading, setLoading] = React.useState(true);
|
|
61
63
|
const [billingError, setBillingError] = React.useState(null);
|
|
62
64
|
// Bumped after a cancellation step, to show the new status.
|
|
63
65
|
const [version, setVersion] = React.useState(0);
|
|
64
|
-
|
|
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
|
+
}, []);
|
|
65
72
|
|
|
66
73
|
React.useEffect(() => {
|
|
67
74
|
let cancelled = false;
|
|
@@ -81,7 +88,7 @@ function AccountPage({ baseUrl, customerId, customerToken, fetch: fetchImpl }) {
|
|
|
81
88
|
})
|
|
82
89
|
.catch((err) => {
|
|
83
90
|
if (!cancelled) {
|
|
84
|
-
setError(err.message || "
|
|
91
|
+
setError(err.message || t("account.error"));
|
|
85
92
|
setLoading(false);
|
|
86
93
|
}
|
|
87
94
|
});
|
|
@@ -94,7 +101,7 @@ function AccountPage({ baseUrl, customerId, customerToken, fetch: fetchImpl }) {
|
|
|
94
101
|
return React.createElement(
|
|
95
102
|
"div",
|
|
96
103
|
{ style: { opacity: 0.6, fontFamily: styles.card.fontFamily } },
|
|
97
|
-
"
|
|
104
|
+
t("account.loading"),
|
|
98
105
|
);
|
|
99
106
|
}
|
|
100
107
|
if (error) {
|
|
@@ -114,45 +121,41 @@ function AccountPage({ baseUrl, customerId, customerToken, fetch: fetchImpl }) {
|
|
|
114
121
|
React.createElement(
|
|
115
122
|
"div",
|
|
116
123
|
{ style: styles.row },
|
|
117
|
-
React.createElement("span", { style: styles.label }, "
|
|
124
|
+
React.createElement("span", { style: styles.label }, t("account.name")),
|
|
118
125
|
React.createElement("span", { style: styles.value }, me.name),
|
|
119
126
|
),
|
|
120
127
|
me.email &&
|
|
121
128
|
React.createElement(
|
|
122
129
|
"div",
|
|
123
130
|
{ style: styles.row },
|
|
124
|
-
React.createElement("span", { style: styles.label }, "
|
|
131
|
+
React.createElement("span", { style: styles.label }, t("account.email")),
|
|
125
132
|
React.createElement("span", { style: styles.value }, me.email),
|
|
126
133
|
),
|
|
127
134
|
React.createElement(
|
|
128
135
|
"div",
|
|
129
136
|
{ style: styles.row },
|
|
130
|
-
React.createElement("span", { style: styles.label }, "
|
|
137
|
+
React.createElement("span", { style: styles.label }, t("account.plan")),
|
|
131
138
|
React.createElement("span", { style: styles.value }, me.plan_name || me.plan_id),
|
|
132
139
|
),
|
|
133
140
|
React.createElement(
|
|
134
141
|
"div",
|
|
135
142
|
{ style: styles.row },
|
|
136
|
-
React.createElement("span", { style: styles.label }, "
|
|
137
|
-
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)),
|
|
138
145
|
),
|
|
139
146
|
me.current_period_end &&
|
|
140
147
|
React.createElement(
|
|
141
148
|
"div",
|
|
142
149
|
{ style: styles.row },
|
|
143
|
-
React.createElement("span", { style: styles.label }, "
|
|
144
|
-
React.createElement(
|
|
145
|
-
"span",
|
|
146
|
-
{ style: styles.value },
|
|
147
|
-
String(me.current_period_end).slice(0, 10),
|
|
148
|
-
),
|
|
150
|
+
React.createElement("span", { style: styles.label }, t("account.periodEnds")),
|
|
151
|
+
React.createElement("span", { style: styles.value }, date(me.current_period_end)),
|
|
149
152
|
),
|
|
150
153
|
me.cancel_at &&
|
|
151
154
|
React.createElement(
|
|
152
155
|
"div",
|
|
153
156
|
{ style: styles.row },
|
|
154
|
-
React.createElement("span", { style: styles.label }, "
|
|
155
|
-
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)),
|
|
156
159
|
),
|
|
157
160
|
me.billing_details_available &&
|
|
158
161
|
React.createElement(BillingDetailsButton, {
|
|
@@ -176,6 +179,7 @@ function AccountPage({ baseUrl, customerId, customerToken, fetch: fetchImpl }) {
|
|
|
176
179
|
fetch: fetchImpl,
|
|
177
180
|
status: me.status,
|
|
178
181
|
cancelAt: me.cancel_at,
|
|
182
|
+
initialStep: openCancel ? "reason" : "idle",
|
|
179
183
|
onChange: reload,
|
|
180
184
|
}),
|
|
181
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,
|