@meterbox/customer-portal 0.1.8 → 0.1.10

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 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.8",
3
+ "version": "0.1.10",
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.6"
20
+ "@meterbox/react": "0.1.8"
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 [step, setStep] = React.useState("idle"); // idle → reason → offers → done
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 || "Something went wrong.");
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(`Done: ${offer.label}. Your subscription continues.`);
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(`Your subscription ends on ${formatDate(r.cancel_at)}. You keep access until then.`);
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("Cancel subscription", () => setStep("reason"), styles.danger);
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, "Why are you cancelling?"),
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": "Reason for cancelling",
157
+ "aria-label": t("cancel.reasonLabel"),
156
158
  },
157
- Object.entries(REASONS).map(([value, label]) =>
158
- React.createElement("option", { key: value, value }, label),
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: "Anything else we should know? (optional)",
165
- "aria-label": "More about why",
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("Continue", start, styles.primary, busy),
172
- button("Keep my subscription", () => setStep("idle"), {}, busy),
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("Accept", () => accept(offer), styles.primary, busy),
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("Cancel at the end of the period", confirm, styles.danger, busy),
193
- button("Keep my subscription", keep, {}, busy),
193
+ button(t("cancel.confirm"), confirm, styles.danger, busy),
194
+ button(t("cancel.keep"), keep, {}, busy),
194
195
  ),
195
196
  );
196
197
  } else {
@@ -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
- const LABELS = { account: "Account", plans: "Plans", credits: "Credits" };
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 initial = PAGES.includes(initialPage) ? initialPage : "account";
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)) setPage(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, { ...common, currency, onPlanSelect });
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, common);
199
+ body = React.createElement(AccountPage, {
200
+ ...common,
201
+ key,
202
+ startCancel: action === "cancel",
203
+ });
138
204
  }
139
205
 
140
206
  return React.createElement(
141
- BrandingProvider,
142
- { branding: brand },
207
+ LocaleProvider,
208
+ { locale, messages },
143
209
  React.createElement(
144
- "div",
145
- { className: "meterbox-customer-portal", style: styles.shell },
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
- "nav",
156
- { style: styles.nav, role: "tablist" },
157
- PAGES.map((p) =>
213
+ "div",
214
+ { className: "meterbox-customer-portal", style: styles.shell },
215
+ (logo || companyName) &&
158
216
  React.createElement(
159
- "button",
160
- {
161
- key: p,
162
- type: "button",
163
- role: "tab",
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
  }
@@ -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
- const reload = React.useCallback(() => setVersion((v) => v + 1), []);
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 || "Could not load account.");
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
- "Loading account…",
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 }, "Name"),
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 }, "Email"),
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 }, "Plan"),
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 }, "Status"),
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 }, "Current period ends"),
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 }, "Ends on"),
155
- React.createElement("span", { style: styles.value }, String(me.cancel_at).slice(0, 10)),
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 }),
@@ -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
- setPlans(catalog.plans || []);
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 || "Could not load plans.");
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 } }, "Loading plans…");
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,