@meterbox/customer-portal 0.1.6 → 0.1.8
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 +9 -0
- package/package.json +2 -2
- package/src/CancelSubscription.js +8 -6
- package/src/CustomerPortal.js +70 -20
- package/src/pages/AccountPage.js +19 -6
package/README.md
CHANGED
|
@@ -35,9 +35,18 @@ function handlePlanSelect(plan, billingInterval) {
|
|
|
35
35
|
/>
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
+
## Branding
|
|
39
|
+
|
|
40
|
+
The portal takes your branding from Settings → Branding
|
|
41
|
+
(`GET /v1/customer-portal/:id/branding`): your logo and company name in its
|
|
42
|
+
header, and your colors, font and buttons on every page. Pass `branding` to
|
|
43
|
+
override it.
|
|
44
|
+
|
|
38
45
|
## Pages
|
|
39
46
|
|
|
40
47
|
- **Account**: name, email, current plan, status (`GET /v1/customer-portal/:id/me`),
|
|
48
|
+
a button to update the payment method and billing details on Stripe's own
|
|
49
|
+
portal (`<BillingDetailsButton>`, when available),
|
|
41
50
|
and **Cancel subscription**: the customer gives a reason, sees your plan's
|
|
42
51
|
retention offers (a discount, a longer trial, or a downgrade) and either takes
|
|
43
52
|
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.8",
|
|
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.6"
|
|
21
21
|
},
|
|
22
22
|
"peerDependencies": {
|
|
23
23
|
"react": ">=17",
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
const React = require("react");
|
|
9
|
+
const { theme } = require("@meterbox/react");
|
|
9
10
|
|
|
10
11
|
const REASONS = {
|
|
11
12
|
too_expensive: "It's too expensive",
|
|
@@ -17,12 +18,12 @@ const REASONS = {
|
|
|
17
18
|
|
|
18
19
|
const styles = {
|
|
19
20
|
box: {
|
|
20
|
-
fontFamily:
|
|
21
|
-
color:
|
|
21
|
+
fontFamily: theme.font,
|
|
22
|
+
color: theme.text,
|
|
22
23
|
border: "1px solid #e5e7eb",
|
|
23
24
|
borderRadius: 12,
|
|
24
25
|
padding: 16,
|
|
25
|
-
background:
|
|
26
|
+
background: theme.card,
|
|
26
27
|
display: "grid",
|
|
27
28
|
gap: 10,
|
|
28
29
|
maxWidth: 480,
|
|
@@ -31,13 +32,14 @@ const styles = {
|
|
|
31
32
|
row: { display: "flex", gap: 8, flexWrap: "wrap" },
|
|
32
33
|
button: {
|
|
33
34
|
padding: "6px 12px",
|
|
34
|
-
borderRadius: 8,
|
|
35
|
+
borderRadius: theme.radius(8),
|
|
35
36
|
border: "1px solid #d1d5db",
|
|
36
|
-
background:
|
|
37
|
+
background: theme.card,
|
|
38
|
+
color: theme.text,
|
|
37
39
|
cursor: "pointer",
|
|
38
40
|
fontSize: 13,
|
|
39
41
|
},
|
|
40
|
-
primary:
|
|
42
|
+
primary: theme.button,
|
|
41
43
|
danger: { color: "#b91c1c", borderColor: "#fecaca" },
|
|
42
44
|
offer: { border: "1px solid #e5e7eb", borderRadius: 8, padding: 10, display: "grid", gap: 6 },
|
|
43
45
|
error: { color: "#dc2626" },
|
package/src/CustomerPortal.js
CHANGED
|
@@ -12,14 +12,20 @@
|
|
|
12
12
|
* threshold={10} // (optional) low-balance trigger
|
|
13
13
|
* currency="USD" // (optional)
|
|
14
14
|
* granularity="day" // (optional)
|
|
15
|
+
* branding={{ accent_color }} // (optional) instead of the tenant's
|
|
15
16
|
* />
|
|
16
17
|
*
|
|
18
|
+
* Branded with the tenant's branding (Settings → Branding, fetched from
|
|
19
|
+
* GET /v1/customer-portal/:id/branding): logo and company name in the
|
|
20
|
+
* header, and every page and component themed through <BrandingProvider>.
|
|
21
|
+
*
|
|
17
22
|
* No router dependency — page state is internal. Hosts that want
|
|
18
23
|
* browser-history nav can drive `initialPage` from their own router and
|
|
19
24
|
* observe `onPageChange`.
|
|
20
25
|
*/
|
|
21
26
|
|
|
22
27
|
const React = require("react");
|
|
28
|
+
const { BrandingProvider, logoUrl, theme } = require("@meterbox/react");
|
|
23
29
|
const AccountPage = require("./pages/AccountPage");
|
|
24
30
|
const PlansPage = require("./pages/PlansPage");
|
|
25
31
|
const CreditsPage = require("./pages/CreditsPage");
|
|
@@ -29,8 +35,8 @@ const LABELS = { account: "Account", plans: "Plans", credits: "Credits" };
|
|
|
29
35
|
|
|
30
36
|
const styles = {
|
|
31
37
|
shell: {
|
|
32
|
-
fontFamily:
|
|
33
|
-
color:
|
|
38
|
+
fontFamily: theme.font,
|
|
39
|
+
color: theme.text,
|
|
34
40
|
display: "grid",
|
|
35
41
|
gap: 16,
|
|
36
42
|
maxWidth: 760,
|
|
@@ -52,11 +58,26 @@ const styles = {
|
|
|
52
58
|
color: "#374151",
|
|
53
59
|
},
|
|
54
60
|
tabActive: {
|
|
55
|
-
color:
|
|
56
|
-
borderBottom:
|
|
61
|
+
color: theme.accent,
|
|
62
|
+
borderBottom: `2px solid ${theme.accent}`,
|
|
57
63
|
},
|
|
64
|
+
header: { display: "flex", alignItems: "center", gap: 10 },
|
|
65
|
+
logo: { maxHeight: 32, maxWidth: 160 },
|
|
66
|
+
company: { fontSize: 16, fontWeight: 700 },
|
|
58
67
|
};
|
|
59
68
|
|
|
69
|
+
// The tenant's branding and company name; none (unbranded) if it can't load.
|
|
70
|
+
async function fetchBranding({ baseUrl, customerId, customerToken, fetchImpl }) {
|
|
71
|
+
const url = `${String(baseUrl).replace(/\/+$/, "")}/v1/customer-portal/${encodeURIComponent(
|
|
72
|
+
customerId,
|
|
73
|
+
)}/branding`;
|
|
74
|
+
const res = await fetchImpl(url, {
|
|
75
|
+
method: "GET",
|
|
76
|
+
headers: { Authorization: `Bearer ${customerToken}` },
|
|
77
|
+
});
|
|
78
|
+
return res.ok ? res.json() : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
60
81
|
function CustomerPortal({
|
|
61
82
|
baseUrl,
|
|
62
83
|
customerId,
|
|
@@ -68,10 +89,27 @@ function CustomerPortal({
|
|
|
68
89
|
threshold,
|
|
69
90
|
currency,
|
|
70
91
|
granularity,
|
|
92
|
+
branding,
|
|
71
93
|
fetch: fetchImpl,
|
|
72
94
|
}) {
|
|
73
95
|
const initial = PAGES.includes(initialPage) ? initialPage : "account";
|
|
74
96
|
const [page, setPage] = React.useState(initial);
|
|
97
|
+
const [tenant, setTenant] = React.useState(null);
|
|
98
|
+
|
|
99
|
+
React.useEffect(() => {
|
|
100
|
+
let alive = true;
|
|
101
|
+
fetchBranding({ baseUrl, customerId, customerToken, fetchImpl: fetchImpl || globalThis.fetch })
|
|
102
|
+
.then((body) => {
|
|
103
|
+
if (alive) setTenant(body);
|
|
104
|
+
})
|
|
105
|
+
.catch(() => {});
|
|
106
|
+
return () => {
|
|
107
|
+
alive = false;
|
|
108
|
+
};
|
|
109
|
+
}, [baseUrl, customerId, customerToken, fetchImpl]);
|
|
110
|
+
const brand = branding || tenant?.branding || null;
|
|
111
|
+
const logo = logoUrl(brand);
|
|
112
|
+
const companyName = tenant?.company_name || null;
|
|
75
113
|
|
|
76
114
|
// Honor external nav changes via initialPage prop.
|
|
77
115
|
React.useEffect(() => {
|
|
@@ -100,27 +138,39 @@ function CustomerPortal({
|
|
|
100
138
|
}
|
|
101
139
|
|
|
102
140
|
return React.createElement(
|
|
103
|
-
|
|
104
|
-
{
|
|
141
|
+
BrandingProvider,
|
|
142
|
+
{ branding: brand },
|
|
105
143
|
React.createElement(
|
|
106
|
-
"
|
|
107
|
-
{
|
|
108
|
-
|
|
144
|
+
"div",
|
|
145
|
+
{ className: "meterbox-customer-portal", style: styles.shell },
|
|
146
|
+
(logo || companyName) &&
|
|
109
147
|
React.createElement(
|
|
110
|
-
"
|
|
111
|
-
{
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
+
),
|
|
154
|
+
React.createElement(
|
|
155
|
+
"nav",
|
|
156
|
+
{ style: styles.nav, role: "tablist" },
|
|
157
|
+
PAGES.map((p) =>
|
|
158
|
+
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],
|
|
169
|
+
),
|
|
120
170
|
),
|
|
121
171
|
),
|
|
172
|
+
body,
|
|
122
173
|
),
|
|
123
|
-
body,
|
|
124
174
|
);
|
|
125
175
|
}
|
|
126
176
|
|
package/src/pages/AccountPage.js
CHANGED
|
@@ -1,21 +1,23 @@
|
|
|
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).
|
|
5
7
|
*/
|
|
6
8
|
|
|
7
9
|
const React = require("react");
|
|
8
|
-
const { InvoiceHistory } = require("@meterbox/react");
|
|
10
|
+
const { InvoiceHistory, BillingDetailsButton, theme } = require("@meterbox/react");
|
|
9
11
|
const CancelSubscription = require("../CancelSubscription");
|
|
10
12
|
|
|
11
13
|
const styles = {
|
|
12
14
|
card: {
|
|
13
|
-
fontFamily:
|
|
14
|
-
color:
|
|
15
|
+
fontFamily: theme.font,
|
|
16
|
+
color: theme.text,
|
|
15
17
|
border: "1px solid #e5e7eb",
|
|
16
18
|
borderRadius: 12,
|
|
17
19
|
padding: 20,
|
|
18
|
-
background:
|
|
20
|
+
background: theme.card,
|
|
19
21
|
display: "grid",
|
|
20
22
|
gap: 12,
|
|
21
23
|
maxWidth: 480,
|
|
@@ -56,6 +58,7 @@ function AccountPage({ baseUrl, customerId, customerToken, fetch: fetchImpl }) {
|
|
|
56
58
|
const [me, setMe] = React.useState(null);
|
|
57
59
|
const [error, setError] = React.useState(null);
|
|
58
60
|
const [loading, setLoading] = React.useState(true);
|
|
61
|
+
const [billingError, setBillingError] = React.useState(null);
|
|
59
62
|
// Bumped after a cancellation step, to show the new status.
|
|
60
63
|
const [version, setVersion] = React.useState(0);
|
|
61
64
|
const reload = React.useCallback(() => setVersion((v) => v + 1), []);
|
|
@@ -151,6 +154,16 @@ function AccountPage({ baseUrl, customerId, customerToken, fetch: fetchImpl }) {
|
|
|
151
154
|
React.createElement("span", { style: styles.label }, "Ends on"),
|
|
152
155
|
React.createElement("span", { style: styles.value }, String(me.cancel_at).slice(0, 10)),
|
|
153
156
|
),
|
|
157
|
+
me.billing_details_available &&
|
|
158
|
+
React.createElement(BillingDetailsButton, {
|
|
159
|
+
baseUrl,
|
|
160
|
+
customerId,
|
|
161
|
+
customerToken,
|
|
162
|
+
fetch: fetchImpl,
|
|
163
|
+
onError: (err) => setBillingError(err.message),
|
|
164
|
+
}),
|
|
165
|
+
billingError &&
|
|
166
|
+
React.createElement("span", { role: "alert", style: { color: "#dc2626" } }, billingError),
|
|
154
167
|
);
|
|
155
168
|
return React.createElement(
|
|
156
169
|
"div",
|