@meterbox/customer-portal 0.1.0
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/LICENSE +21 -0
- package/README.md +50 -0
- package/package.json +33 -0
- package/src/CustomerPortal.js +127 -0
- package/src/index.js +19 -0
- package/src/pages/AccountPage.js +146 -0
- package/src/pages/CreditsPage.js +37 -0
- package/src/pages/PlansPage.js +147 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Meterbox Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# @meterbox/customer-portal
|
|
2
|
+
|
|
3
|
+
Drop-in multi-page React customer portal for Meterbox. One component, three
|
|
4
|
+
pages (Account, Plans, Credits), composed from the widgets shipped in
|
|
5
|
+
[`@meterbox/react`](https://www.npmjs.com/package/@meterbox/react). All data is
|
|
6
|
+
fetched via the `/v1/customer-portal/*` endpoints using a short-lived
|
|
7
|
+
customer-scoped JWT that your backend mints with `POST /v1/tokens/:id`, so no
|
|
8
|
+
API key ever reaches the browser.
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
npm install @meterbox/customer-portal react react-dom
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
```jsx
|
|
15
|
+
import CustomerPortal from "@meterbox/customer-portal";
|
|
16
|
+
|
|
17
|
+
function handlePlanSelect(plan, billingInterval) {
|
|
18
|
+
// Fires alongside the built-in preview + apply/schedule flow below.
|
|
19
|
+
// Optional, e.g. for your own analytics. You don't need to build a
|
|
20
|
+
// checkout redirect for a monthly pick; that's what the Plans page
|
|
21
|
+
// already does. An annual pick still has no in-place apply path (see
|
|
22
|
+
// below), so route those through your own <CheckoutButton> instead.
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
<CustomerPortal
|
|
26
|
+
baseUrl="https://meterbox.ai"
|
|
27
|
+
customerId="cus_42"
|
|
28
|
+
customerToken={customerJwt}
|
|
29
|
+
initialPage="account"
|
|
30
|
+
onPlanSelect={handlePlanSelect}
|
|
31
|
+
onTopUp={(balance) => /* mint top-up session */}
|
|
32
|
+
/>
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Pages
|
|
36
|
+
|
|
37
|
+
- **Account**: name, email, current plan, status (`GET /v1/customer-portal/:id/me`)
|
|
38
|
+
- **Plans**: `<PricingTable>` (monthly/annual toggle included) + inline
|
|
39
|
+
`<PlanChangeCard>` for a monthly pick: proration preview plus "Change now"
|
|
40
|
+
/ "Schedule for end of period" actions, so picking a plan actually applies
|
|
41
|
+
it, not just previews it. `onPlanSelect` fires with `(plan,
|
|
42
|
+
billingInterval)` either way. The inline card only covers monthly:
|
|
43
|
+
`subscription/preview` and `subscription/change` don't prorate an annual
|
|
44
|
+
commitment yet, so an annual pick shows a short note instead of
|
|
45
|
+
previewing/applying a misleading number; annual stays on the checkout
|
|
46
|
+
path (a new Stripe Checkout session with its own annual price).
|
|
47
|
+
- **Credits**: `<CreditBalance>` + `<CreditGrantsTable>` + `<CreditUtilization>`
|
|
48
|
+
|
|
49
|
+
No router dependency. Page state is internal; wire deep-linking yourself via
|
|
50
|
+
`initialPage` if you want browser-history navigation.
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@meterbox/customer-portal",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Drop-in multi-page React customer portal for Meterbox: Account, Plans, Credits. Built on @meterbox/react.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://meterbox.ai",
|
|
7
|
+
"main": "src/index.js",
|
|
8
|
+
"files": [
|
|
9
|
+
"src/",
|
|
10
|
+
"README.md",
|
|
11
|
+
"LICENSE"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=18"
|
|
15
|
+
},
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@meterbox/react": "0.1.0"
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"react": ">=17",
|
|
24
|
+
"react-dom": ">=17"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"meterbox",
|
|
28
|
+
"react",
|
|
29
|
+
"customer-portal",
|
|
30
|
+
"subscription",
|
|
31
|
+
"credits"
|
|
32
|
+
]
|
|
33
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <CustomerPortal /> — drop-in multi-page portal.
|
|
3
|
+
*
|
|
4
|
+
* <CustomerPortal
|
|
5
|
+
* baseUrl="https://meterbox.ai"
|
|
6
|
+
* customerId="cus_42"
|
|
7
|
+
* customerToken={jwt}
|
|
8
|
+
* initialPage="account" // "account" (default) | "plans" | "credits"
|
|
9
|
+
* onPageChange={(page) => ...} // (optional) consumer wires deep-linking
|
|
10
|
+
* onPlanSelect={(plan) => ...} // (optional) host app routes to checkout
|
|
11
|
+
* onTopUp={(balance) => ...} // (optional) host app mints top-up
|
|
12
|
+
* threshold={10} // (optional) low-balance trigger
|
|
13
|
+
* currency="USD" // (optional)
|
|
14
|
+
* granularity="day" // (optional)
|
|
15
|
+
* />
|
|
16
|
+
*
|
|
17
|
+
* No router dependency — page state is internal. Hosts that want
|
|
18
|
+
* browser-history nav can drive `initialPage` from their own router and
|
|
19
|
+
* observe `onPageChange`.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const React = require("react");
|
|
23
|
+
const AccountPage = require("./pages/AccountPage");
|
|
24
|
+
const PlansPage = require("./pages/PlansPage");
|
|
25
|
+
const CreditsPage = require("./pages/CreditsPage");
|
|
26
|
+
|
|
27
|
+
const PAGES = ["account", "plans", "credits"];
|
|
28
|
+
const LABELS = { account: "Account", plans: "Plans", credits: "Credits" };
|
|
29
|
+
|
|
30
|
+
const styles = {
|
|
31
|
+
shell: {
|
|
32
|
+
fontFamily: "system-ui, -apple-system, sans-serif",
|
|
33
|
+
color: "#111",
|
|
34
|
+
display: "grid",
|
|
35
|
+
gap: 16,
|
|
36
|
+
maxWidth: 760,
|
|
37
|
+
},
|
|
38
|
+
nav: {
|
|
39
|
+
display: "flex",
|
|
40
|
+
gap: 4,
|
|
41
|
+
borderBottom: "1px solid #e5e7eb",
|
|
42
|
+
paddingBottom: 0,
|
|
43
|
+
},
|
|
44
|
+
tab: {
|
|
45
|
+
background: "transparent",
|
|
46
|
+
border: "none",
|
|
47
|
+
borderBottom: "2px solid transparent",
|
|
48
|
+
padding: "8px 14px",
|
|
49
|
+
fontSize: 14,
|
|
50
|
+
fontWeight: 600,
|
|
51
|
+
cursor: "pointer",
|
|
52
|
+
color: "#374151",
|
|
53
|
+
},
|
|
54
|
+
tabActive: {
|
|
55
|
+
color: "#5045e8",
|
|
56
|
+
borderBottom: "2px solid #5045e8",
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
function CustomerPortal({
|
|
61
|
+
baseUrl,
|
|
62
|
+
customerId,
|
|
63
|
+
customerToken,
|
|
64
|
+
initialPage = "account",
|
|
65
|
+
onPageChange,
|
|
66
|
+
onPlanSelect,
|
|
67
|
+
onTopUp,
|
|
68
|
+
threshold,
|
|
69
|
+
currency,
|
|
70
|
+
granularity,
|
|
71
|
+
fetch: fetchImpl,
|
|
72
|
+
}) {
|
|
73
|
+
const initial = PAGES.includes(initialPage) ? initialPage : "account";
|
|
74
|
+
const [page, setPage] = React.useState(initial);
|
|
75
|
+
|
|
76
|
+
// Honor external nav changes via initialPage prop.
|
|
77
|
+
React.useEffect(() => {
|
|
78
|
+
if (PAGES.includes(initialPage)) setPage(initialPage);
|
|
79
|
+
}, [initialPage]);
|
|
80
|
+
|
|
81
|
+
const go = (next) => {
|
|
82
|
+
setPage(next);
|
|
83
|
+
if (typeof onPageChange === "function") onPageChange(next);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const common = { baseUrl, customerId, customerToken, fetch: fetchImpl };
|
|
87
|
+
|
|
88
|
+
let body;
|
|
89
|
+
if (page === "plans") {
|
|
90
|
+
body = React.createElement(PlansPage, { ...common, currency, onPlanSelect });
|
|
91
|
+
} else if (page === "credits") {
|
|
92
|
+
body = React.createElement(CreditsPage, {
|
|
93
|
+
...common,
|
|
94
|
+
threshold,
|
|
95
|
+
onTopUp,
|
|
96
|
+
granularity,
|
|
97
|
+
});
|
|
98
|
+
} else {
|
|
99
|
+
body = React.createElement(AccountPage, common);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return React.createElement(
|
|
103
|
+
"div",
|
|
104
|
+
{ className: "meterbox-customer-portal", style: styles.shell },
|
|
105
|
+
React.createElement(
|
|
106
|
+
"nav",
|
|
107
|
+
{ style: styles.nav, role: "tablist" },
|
|
108
|
+
PAGES.map((p) =>
|
|
109
|
+
React.createElement(
|
|
110
|
+
"button",
|
|
111
|
+
{
|
|
112
|
+
key: p,
|
|
113
|
+
type: "button",
|
|
114
|
+
role: "tab",
|
|
115
|
+
"aria-selected": page === p,
|
|
116
|
+
style: page === p ? { ...styles.tab, ...styles.tabActive } : styles.tab,
|
|
117
|
+
onClick: () => go(p),
|
|
118
|
+
},
|
|
119
|
+
LABELS[p],
|
|
120
|
+
),
|
|
121
|
+
),
|
|
122
|
+
),
|
|
123
|
+
body,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
module.exports = CustomerPortal;
|
package/src/index.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @meterbox/customer-portal — drop-in multi-page React customer portal.
|
|
3
|
+
*
|
|
4
|
+
* Default export is `<CustomerPortal>`. Individual pages are also exported
|
|
5
|
+
* so hosts that want to embed just one (e.g. an existing Account page in
|
|
6
|
+
* their app) can pull it directly.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const CustomerPortal = require("./CustomerPortal");
|
|
10
|
+
const AccountPage = require("./pages/AccountPage");
|
|
11
|
+
const PlansPage = require("./pages/PlansPage");
|
|
12
|
+
const CreditsPage = require("./pages/CreditsPage");
|
|
13
|
+
|
|
14
|
+
module.exports = CustomerPortal;
|
|
15
|
+
module.exports.default = CustomerPortal;
|
|
16
|
+
module.exports.CustomerPortal = CustomerPortal;
|
|
17
|
+
module.exports.AccountPage = AccountPage;
|
|
18
|
+
module.exports.PlansPage = PlansPage;
|
|
19
|
+
module.exports.CreditsPage = CreditsPage;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <AccountPage /> — fetches GET /v1/customer-portal/:id/me and renders the
|
|
3
|
+
* customer's own profile + current plan.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const React = require("react");
|
|
7
|
+
|
|
8
|
+
const styles = {
|
|
9
|
+
card: {
|
|
10
|
+
fontFamily: "system-ui, -apple-system, sans-serif",
|
|
11
|
+
color: "#111",
|
|
12
|
+
border: "1px solid #e5e7eb",
|
|
13
|
+
borderRadius: 12,
|
|
14
|
+
padding: 20,
|
|
15
|
+
background: "#fff",
|
|
16
|
+
display: "grid",
|
|
17
|
+
gap: 12,
|
|
18
|
+
maxWidth: 480,
|
|
19
|
+
},
|
|
20
|
+
row: { display: "flex", justifyContent: "space-between", fontSize: 14 },
|
|
21
|
+
label: { opacity: 0.65 },
|
|
22
|
+
value: { fontWeight: 600 },
|
|
23
|
+
pill: {
|
|
24
|
+
display: "inline-block",
|
|
25
|
+
padding: "1px 8px",
|
|
26
|
+
borderRadius: 999,
|
|
27
|
+
fontSize: 11,
|
|
28
|
+
fontWeight: 600,
|
|
29
|
+
background: "#ecfdf5",
|
|
30
|
+
color: "#047857",
|
|
31
|
+
},
|
|
32
|
+
pillInactive: { background: "#fef2f2", color: "#b91c1c" },
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
async function fetchMe({ baseUrl, customerId, customerToken, fetchImpl }) {
|
|
36
|
+
const url = `${String(baseUrl).replace(/\/+$/, "")}/v1/customer-portal/${encodeURIComponent(
|
|
37
|
+
customerId,
|
|
38
|
+
)}/me`;
|
|
39
|
+
const res = await fetchImpl(url, {
|
|
40
|
+
method: "GET",
|
|
41
|
+
headers: { Authorization: `Bearer ${customerToken}` },
|
|
42
|
+
});
|
|
43
|
+
const json = await res.json();
|
|
44
|
+
if (!res.ok) {
|
|
45
|
+
const err = new Error(json?.error || `HTTP ${res.status}`);
|
|
46
|
+
err.status = res.status;
|
|
47
|
+
throw err;
|
|
48
|
+
}
|
|
49
|
+
return json;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function AccountPage({ baseUrl, customerId, customerToken, fetch: fetchImpl }) {
|
|
53
|
+
const [me, setMe] = React.useState(null);
|
|
54
|
+
const [error, setError] = React.useState(null);
|
|
55
|
+
const [loading, setLoading] = React.useState(true);
|
|
56
|
+
|
|
57
|
+
React.useEffect(() => {
|
|
58
|
+
let cancelled = false;
|
|
59
|
+
setLoading(true);
|
|
60
|
+
setError(null);
|
|
61
|
+
fetchMe({
|
|
62
|
+
baseUrl,
|
|
63
|
+
customerId,
|
|
64
|
+
customerToken,
|
|
65
|
+
fetchImpl: fetchImpl || globalThis.fetch,
|
|
66
|
+
})
|
|
67
|
+
.then((body) => {
|
|
68
|
+
if (!cancelled) {
|
|
69
|
+
setMe(body);
|
|
70
|
+
setLoading(false);
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
.catch((err) => {
|
|
74
|
+
if (!cancelled) {
|
|
75
|
+
setError(err.message || "Could not load account.");
|
|
76
|
+
setLoading(false);
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
return () => {
|
|
80
|
+
cancelled = true;
|
|
81
|
+
};
|
|
82
|
+
}, [baseUrl, customerId, customerToken, fetchImpl]);
|
|
83
|
+
|
|
84
|
+
if (loading) {
|
|
85
|
+
return React.createElement(
|
|
86
|
+
"div",
|
|
87
|
+
{ style: { opacity: 0.6, fontFamily: styles.card.fontFamily } },
|
|
88
|
+
"Loading account…",
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
if (error) {
|
|
92
|
+
return React.createElement(
|
|
93
|
+
"div",
|
|
94
|
+
{ style: { color: "#dc2626", fontFamily: styles.card.fontFamily } },
|
|
95
|
+
error,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
if (!me) return null;
|
|
99
|
+
const statusPill =
|
|
100
|
+
me.status === "active"
|
|
101
|
+
? styles.pill
|
|
102
|
+
: { ...styles.pill, ...styles.pillInactive };
|
|
103
|
+
return React.createElement(
|
|
104
|
+
"div",
|
|
105
|
+
{ className: "meterbox-account-page", style: styles.card },
|
|
106
|
+
me.name &&
|
|
107
|
+
React.createElement(
|
|
108
|
+
"div",
|
|
109
|
+
{ style: styles.row },
|
|
110
|
+
React.createElement("span", { style: styles.label }, "Name"),
|
|
111
|
+
React.createElement("span", { style: styles.value }, me.name),
|
|
112
|
+
),
|
|
113
|
+
me.email &&
|
|
114
|
+
React.createElement(
|
|
115
|
+
"div",
|
|
116
|
+
{ style: styles.row },
|
|
117
|
+
React.createElement("span", { style: styles.label }, "Email"),
|
|
118
|
+
React.createElement("span", { style: styles.value }, me.email),
|
|
119
|
+
),
|
|
120
|
+
React.createElement(
|
|
121
|
+
"div",
|
|
122
|
+
{ style: styles.row },
|
|
123
|
+
React.createElement("span", { style: styles.label }, "Plan"),
|
|
124
|
+
React.createElement("span", { style: styles.value }, me.plan_name || me.plan_id),
|
|
125
|
+
),
|
|
126
|
+
React.createElement(
|
|
127
|
+
"div",
|
|
128
|
+
{ style: styles.row },
|
|
129
|
+
React.createElement("span", { style: styles.label }, "Status"),
|
|
130
|
+
React.createElement("span", { style: statusPill }, me.status),
|
|
131
|
+
),
|
|
132
|
+
me.current_period_end &&
|
|
133
|
+
React.createElement(
|
|
134
|
+
"div",
|
|
135
|
+
{ style: styles.row },
|
|
136
|
+
React.createElement("span", { style: styles.label }, "Current period ends"),
|
|
137
|
+
React.createElement(
|
|
138
|
+
"span",
|
|
139
|
+
{ style: styles.value },
|
|
140
|
+
String(me.current_period_end).slice(0, 10),
|
|
141
|
+
),
|
|
142
|
+
),
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
module.exports = AccountPage;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <CreditsPage /> — composes the three credit widgets in self-fetch mode.
|
|
3
|
+
* `threshold` + `onTopUp` are passed through to `<CreditBalance>`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const React = require("react");
|
|
7
|
+
const { CreditBalance, CreditGrantsTable, CreditUtilization } = require("@meterbox/react");
|
|
8
|
+
|
|
9
|
+
const styles = {
|
|
10
|
+
wrap: { display: "grid", gap: 16 },
|
|
11
|
+
row: { display: "flex", gap: 16, flexWrap: "wrap" },
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function CreditsPage({
|
|
15
|
+
baseUrl,
|
|
16
|
+
customerId,
|
|
17
|
+
customerToken,
|
|
18
|
+
threshold,
|
|
19
|
+
onTopUp,
|
|
20
|
+
granularity = "day",
|
|
21
|
+
fetch: fetchImpl,
|
|
22
|
+
}) {
|
|
23
|
+
const common = { baseUrl, customerId, customerToken, fetch: fetchImpl };
|
|
24
|
+
return React.createElement(
|
|
25
|
+
"div",
|
|
26
|
+
{ className: "meterbox-credits-page", style: styles.wrap },
|
|
27
|
+
React.createElement(
|
|
28
|
+
"div",
|
|
29
|
+
{ style: styles.row },
|
|
30
|
+
React.createElement(CreditBalance, { ...common, threshold, onTopUp }),
|
|
31
|
+
React.createElement(CreditUtilization, { ...common, granularity }),
|
|
32
|
+
),
|
|
33
|
+
React.createElement(CreditGrantsTable, common),
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = CreditsPage;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <PlansPage /> — fetches the public plan catalog + the customer's current
|
|
3
|
+
* plan, renders `<PricingTable>`; on plan pick, renders an inline
|
|
4
|
+
* `<PlanChangeCard>` for that plan — the proration preview plus "Change
|
|
5
|
+
* now" / "Schedule for end of period" actions, so picking a plan is no
|
|
6
|
+
* longer a dead end the host app has to build its own apply flow for.
|
|
7
|
+
* `onPlanSelect(plan, billingInterval)` (if provided) still fires with the
|
|
8
|
+
* chosen plan and interval, e.g. for the host's own analytics.
|
|
9
|
+
*
|
|
10
|
+
* The inline panel only covers a monthly pick — `POST .../subscription/
|
|
11
|
+
* preview` (and `/subscription/change`) prorate against the plan's monthly
|
|
12
|
+
* price and have no concept of an annual commitment yet, so showing it for
|
|
13
|
+
* an annual pick would preview/apply a number that doesn't match what the
|
|
14
|
+
* customer is actually about to be charged. Annual stays on the checkout
|
|
15
|
+
* path (`<CheckoutButton billingInterval="annual">`, a new Stripe Checkout
|
|
16
|
+
* session with its own annual price) — a short note stands in here instead
|
|
17
|
+
* of a silently wrong in-place change.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const React = require("react");
|
|
21
|
+
const { PricingTable, PlanChangeCard } = require("@meterbox/react");
|
|
22
|
+
|
|
23
|
+
const styles = {
|
|
24
|
+
wrap: { display: "grid", gap: 16 },
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
async function fetchJson(url, headers, fetchImpl) {
|
|
28
|
+
const res = await fetchImpl(url, { method: "GET", headers });
|
|
29
|
+
const json = await res.json();
|
|
30
|
+
if (!res.ok) {
|
|
31
|
+
const err = new Error(json?.error || `HTTP ${res.status}`);
|
|
32
|
+
err.status = res.status;
|
|
33
|
+
throw err;
|
|
34
|
+
}
|
|
35
|
+
return json;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function PlansPage({
|
|
39
|
+
baseUrl,
|
|
40
|
+
customerId,
|
|
41
|
+
customerToken,
|
|
42
|
+
currency = "USD",
|
|
43
|
+
onPlanSelect,
|
|
44
|
+
fetch: fetchImpl,
|
|
45
|
+
}) {
|
|
46
|
+
const fetcher = fetchImpl || globalThis.fetch;
|
|
47
|
+
const root = String(baseUrl).replace(/\/+$/, "");
|
|
48
|
+
const [plans, setPlans] = React.useState(null);
|
|
49
|
+
const [currentPlanId, setCurrentPlanId] = React.useState(null);
|
|
50
|
+
const [scheduledChange, setScheduledChange] = React.useState(null);
|
|
51
|
+
const [picked, setPicked] = React.useState(null);
|
|
52
|
+
const [pickedInterval, setPickedInterval] = React.useState("monthly");
|
|
53
|
+
const [error, setError] = React.useState(null);
|
|
54
|
+
const [loading, setLoading] = React.useState(true);
|
|
55
|
+
|
|
56
|
+
const fetchMe = React.useCallback(
|
|
57
|
+
() =>
|
|
58
|
+
fetchJson(
|
|
59
|
+
`${root}/v1/customer-portal/${encodeURIComponent(customerId)}/me`,
|
|
60
|
+
{ Authorization: `Bearer ${customerToken}` },
|
|
61
|
+
fetcher,
|
|
62
|
+
),
|
|
63
|
+
[root, customerId, customerToken, fetcher],
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
React.useEffect(() => {
|
|
67
|
+
let cancelled = false;
|
|
68
|
+
setLoading(true);
|
|
69
|
+
setError(null);
|
|
70
|
+
Promise.all([fetchJson(`${root}/v1/plans`, {}, fetcher), fetchMe()])
|
|
71
|
+
.then(([catalog, me]) => {
|
|
72
|
+
if (cancelled) return;
|
|
73
|
+
setPlans(catalog.plans || []);
|
|
74
|
+
setCurrentPlanId(me.plan_id || null);
|
|
75
|
+
setScheduledChange(me.scheduled_change || null);
|
|
76
|
+
setLoading(false);
|
|
77
|
+
})
|
|
78
|
+
.catch((err) => {
|
|
79
|
+
if (!cancelled) {
|
|
80
|
+
setError(err.message || "Could not load plans.");
|
|
81
|
+
setLoading(false);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
return () => {
|
|
85
|
+
cancelled = true;
|
|
86
|
+
};
|
|
87
|
+
}, [root, fetcher, fetchMe]);
|
|
88
|
+
|
|
89
|
+
// After a confirm/cancel, re-fetch /me so currentPlanId + scheduledChange
|
|
90
|
+
// reflect what actually landed (not just an optimistic guess).
|
|
91
|
+
function onChanged() {
|
|
92
|
+
fetchMe()
|
|
93
|
+
.then((me) => {
|
|
94
|
+
setCurrentPlanId(me.plan_id || null);
|
|
95
|
+
setScheduledChange(me.scheduled_change || null);
|
|
96
|
+
})
|
|
97
|
+
.catch(() => {
|
|
98
|
+
/* the panel already surfaced its own error; a stale currentPlanId
|
|
99
|
+
here just means the highlight doesn't update until next load */
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (loading) {
|
|
104
|
+
return React.createElement("div", { style: { opacity: 0.6 } }, "Loading plans…");
|
|
105
|
+
}
|
|
106
|
+
if (error) {
|
|
107
|
+
return React.createElement("div", { style: { color: "#dc2626" } }, error);
|
|
108
|
+
}
|
|
109
|
+
return React.createElement(
|
|
110
|
+
"div",
|
|
111
|
+
{ className: "meterbox-plans-page", style: styles.wrap },
|
|
112
|
+
React.createElement(PricingTable, {
|
|
113
|
+
plans,
|
|
114
|
+
currentPlanId,
|
|
115
|
+
currency,
|
|
116
|
+
onSelect: (plan, billingInterval) => {
|
|
117
|
+
setPicked(plan.id);
|
|
118
|
+
setPickedInterval(billingInterval || "monthly");
|
|
119
|
+
if (typeof onPlanSelect === "function") onPlanSelect(plan, billingInterval);
|
|
120
|
+
},
|
|
121
|
+
}),
|
|
122
|
+
// Deliberately NOT also gated on `picked !== currentPlanId`: PricingTable
|
|
123
|
+
// already disables the current plan's own CTA, so a real click can't
|
|
124
|
+
// set `picked` to it in the first place. Re-checking against
|
|
125
|
+
// currentPlanId here would unmount the panel — and its just-rendered
|
|
126
|
+
// success message — the instant a successful change updates
|
|
127
|
+
// currentPlanId to match what was just picked.
|
|
128
|
+
picked &&
|
|
129
|
+
(pickedInterval === "annual"
|
|
130
|
+
? React.createElement(
|
|
131
|
+
"p",
|
|
132
|
+
{ style: { fontSize: 13, opacity: 0.7 } },
|
|
133
|
+
"Cost preview isn't available for annual billing yet — the amount due will be confirmed at checkout.",
|
|
134
|
+
)
|
|
135
|
+
: React.createElement(PlanChangeCard, {
|
|
136
|
+
baseUrl,
|
|
137
|
+
customerId,
|
|
138
|
+
customerToken,
|
|
139
|
+
planId: picked,
|
|
140
|
+
scheduledChange,
|
|
141
|
+
fetch: fetchImpl,
|
|
142
|
+
onChanged,
|
|
143
|
+
})),
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
module.exports = PlansPage;
|