@meterbox/customer-portal 0.1.1 → 0.1.3
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 +8 -1
- package/package.json +2 -2
- package/src/CancelSubscription.js +207 -0
- package/src/index.js +2 -0
- package/src/pages/AccountPage.js +28 -5
package/README.md
CHANGED
|
@@ -37,7 +37,14 @@ function handlePlanSelect(plan, billingInterval) {
|
|
|
37
37
|
|
|
38
38
|
## Pages
|
|
39
39
|
|
|
40
|
-
- **Account**: name, email, current plan, status (`GET /v1/customer-portal/:id/me`)
|
|
40
|
+
- **Account**: name, email, current plan, status (`GET /v1/customer-portal/:id/me`),
|
|
41
|
+
and **Cancel subscription**: the customer gives a reason, sees your plan's
|
|
42
|
+
retention offers (a discount, a longer trial, or a downgrade) and either takes
|
|
43
|
+
one or cancels at the end of the period, keeping access until then
|
|
44
|
+
(`/v1/customer-portal/:id/cancellation`). Also exported on its own as
|
|
45
|
+
`<CancelSubscription>`. Each step reaches you as a webhook
|
|
46
|
+
(`customer.cancel.initiated`, `customer.win-back.accepted`,
|
|
47
|
+
`customer.cancel.confirmed`).
|
|
41
48
|
- **Plans**: the published plans (`GET /v1/customer-portal/:id/plans`) in a
|
|
42
49
|
`<PricingTable>` (monthly/annual toggle included) + inline
|
|
43
50
|
`<PlanChangeCard>` for a monthly pick: proration preview plus "Change now"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meterbox/customer-portal",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
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.1"
|
|
21
21
|
},
|
|
22
22
|
"peerDependencies": {
|
|
23
23
|
"react": ">=17",
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* <CancelSubscription /> — the customer cancels from the portal: they give a
|
|
3
|
+
* reason, see the plan's retention offers, and either take one and stay or
|
|
4
|
+
* cancel at the end of the period (keeping access until then). Uses the
|
|
5
|
+
* /v1/customer-portal/:id/cancellation endpoints with the customer token.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const React = require("react");
|
|
9
|
+
|
|
10
|
+
const REASONS = {
|
|
11
|
+
too_expensive: "It's too expensive",
|
|
12
|
+
missing_features: "It's missing features I need",
|
|
13
|
+
switched_to_competitor: "I'm switching to another product",
|
|
14
|
+
not_using: "I'm not using it enough",
|
|
15
|
+
other: "Something else",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const styles = {
|
|
19
|
+
box: {
|
|
20
|
+
fontFamily: "system-ui, -apple-system, sans-serif",
|
|
21
|
+
color: "#111",
|
|
22
|
+
border: "1px solid #e5e7eb",
|
|
23
|
+
borderRadius: 12,
|
|
24
|
+
padding: 16,
|
|
25
|
+
background: "#fff",
|
|
26
|
+
display: "grid",
|
|
27
|
+
gap: 10,
|
|
28
|
+
maxWidth: 480,
|
|
29
|
+
fontSize: 14,
|
|
30
|
+
},
|
|
31
|
+
row: { display: "flex", gap: 8, flexWrap: "wrap" },
|
|
32
|
+
button: {
|
|
33
|
+
padding: "6px 12px",
|
|
34
|
+
borderRadius: 8,
|
|
35
|
+
border: "1px solid #d1d5db",
|
|
36
|
+
background: "#fff",
|
|
37
|
+
cursor: "pointer",
|
|
38
|
+
fontSize: 13,
|
|
39
|
+
},
|
|
40
|
+
primary: { background: "#111", color: "#fff", borderColor: "#111" },
|
|
41
|
+
danger: { color: "#b91c1c", borderColor: "#fecaca" },
|
|
42
|
+
offer: { border: "1px solid #e5e7eb", borderRadius: 8, padding: 10, display: "grid", gap: 6 },
|
|
43
|
+
error: { color: "#dc2626" },
|
|
44
|
+
muted: { opacity: 0.7 },
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function formatDate(iso) {
|
|
48
|
+
return iso ? String(iso).slice(0, 10) : "the end of the period";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function button(label, onClick, extra = {}, disabled = false) {
|
|
52
|
+
return React.createElement(
|
|
53
|
+
"button",
|
|
54
|
+
{ type: "button", onClick, disabled, style: { ...styles.button, ...extra } },
|
|
55
|
+
label,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function CancelSubscription({
|
|
60
|
+
baseUrl,
|
|
61
|
+
customerId,
|
|
62
|
+
customerToken,
|
|
63
|
+
fetch: fetchImpl,
|
|
64
|
+
status,
|
|
65
|
+
cancelAt,
|
|
66
|
+
onChange,
|
|
67
|
+
}) {
|
|
68
|
+
const [step, setStep] = React.useState("idle"); // idle → reason → offers → done
|
|
69
|
+
const [reason, setReason] = React.useState("too_expensive");
|
|
70
|
+
const [detail, setDetail] = React.useState("");
|
|
71
|
+
const [offers, setOffers] = React.useState([]);
|
|
72
|
+
const [done, setDone] = React.useState(null);
|
|
73
|
+
const [error, setError] = React.useState(null);
|
|
74
|
+
const [busy, setBusy] = React.useState(false);
|
|
75
|
+
|
|
76
|
+
async function call(method, sub, body) {
|
|
77
|
+
const url = `${String(baseUrl).replace(/\/+$/, "")}/v1/customer-portal/${encodeURIComponent(
|
|
78
|
+
customerId,
|
|
79
|
+
)}/cancellation${sub}`;
|
|
80
|
+
const res = await (fetchImpl || globalThis.fetch)(url, {
|
|
81
|
+
method,
|
|
82
|
+
headers: { Authorization: `Bearer ${customerToken}`, "Content-Type": "application/json" },
|
|
83
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
84
|
+
});
|
|
85
|
+
const json = res.status === 204 ? null : await res.json();
|
|
86
|
+
if (!res.ok) throw new Error(json?.error || `HTTP ${res.status}`);
|
|
87
|
+
return json;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function run(fn) {
|
|
91
|
+
setError(null);
|
|
92
|
+
setBusy(true);
|
|
93
|
+
try {
|
|
94
|
+
await fn();
|
|
95
|
+
} catch (err) {
|
|
96
|
+
setError(err.message || "Something went wrong.");
|
|
97
|
+
} finally {
|
|
98
|
+
setBusy(false);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const start = () =>
|
|
103
|
+
run(async () => {
|
|
104
|
+
const r = await call("POST", "", {
|
|
105
|
+
reason_code: reason,
|
|
106
|
+
...(detail.trim() ? { reason_text: detail.trim() } : {}),
|
|
107
|
+
});
|
|
108
|
+
setOffers(r.offers || []);
|
|
109
|
+
setStep("offers");
|
|
110
|
+
});
|
|
111
|
+
const accept = (offer) =>
|
|
112
|
+
run(async () => {
|
|
113
|
+
await call("POST", "/accept-offer", { offer_id: offer.id });
|
|
114
|
+
setDone(`Done: ${offer.label}. Your subscription continues.`);
|
|
115
|
+
setStep("done");
|
|
116
|
+
if (onChange) onChange();
|
|
117
|
+
});
|
|
118
|
+
const confirm = () =>
|
|
119
|
+
run(async () => {
|
|
120
|
+
const r = await call("POST", "/confirm");
|
|
121
|
+
setDone(`Your subscription ends on ${formatDate(r.cancel_at)}. You keep access until then.`);
|
|
122
|
+
setStep("done");
|
|
123
|
+
if (onChange) onChange();
|
|
124
|
+
});
|
|
125
|
+
const keep = () =>
|
|
126
|
+
run(async () => {
|
|
127
|
+
if (step === "offers") await call("DELETE", "");
|
|
128
|
+
setStep("idle");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
if (status === "cancelling") {
|
|
132
|
+
return React.createElement(
|
|
133
|
+
"div",
|
|
134
|
+
{ style: styles.box },
|
|
135
|
+
`Your subscription ends on ${formatDate(cancelAt)}. You keep access until then.`,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
if (status === "cancelled") return null;
|
|
139
|
+
|
|
140
|
+
let content;
|
|
141
|
+
if (step === "idle") {
|
|
142
|
+
content = button("Cancel subscription", () => setStep("reason"), styles.danger);
|
|
143
|
+
} else if (step === "reason") {
|
|
144
|
+
content = React.createElement(
|
|
145
|
+
React.Fragment,
|
|
146
|
+
null,
|
|
147
|
+
React.createElement("strong", null, "Why are you cancelling?"),
|
|
148
|
+
React.createElement(
|
|
149
|
+
"select",
|
|
150
|
+
{
|
|
151
|
+
value: reason,
|
|
152
|
+
onChange: (e) => setReason(e.target.value),
|
|
153
|
+
"aria-label": "Reason for cancelling",
|
|
154
|
+
},
|
|
155
|
+
Object.entries(REASONS).map(([value, label]) =>
|
|
156
|
+
React.createElement("option", { key: value, value }, label),
|
|
157
|
+
),
|
|
158
|
+
),
|
|
159
|
+
React.createElement("textarea", {
|
|
160
|
+
value: detail,
|
|
161
|
+
onChange: (e) => setDetail(e.target.value),
|
|
162
|
+
placeholder: "Anything else we should know? (optional)",
|
|
163
|
+
"aria-label": "More about why",
|
|
164
|
+
rows: 3,
|
|
165
|
+
}),
|
|
166
|
+
React.createElement(
|
|
167
|
+
"div",
|
|
168
|
+
{ style: styles.row },
|
|
169
|
+
button("Continue", start, styles.primary, busy),
|
|
170
|
+
button("Keep my subscription", () => setStep("idle"), {}, busy),
|
|
171
|
+
),
|
|
172
|
+
);
|
|
173
|
+
} else if (step === "offers") {
|
|
174
|
+
content = React.createElement(
|
|
175
|
+
React.Fragment,
|
|
176
|
+
null,
|
|
177
|
+
offers.length > 0 &&
|
|
178
|
+
React.createElement("strong", null, "Before you go, would one of these help?"),
|
|
179
|
+
...offers.map((offer) =>
|
|
180
|
+
React.createElement(
|
|
181
|
+
"div",
|
|
182
|
+
{ key: offer.id, style: styles.offer },
|
|
183
|
+
React.createElement("span", null, offer.label),
|
|
184
|
+
button("Accept", () => accept(offer), styles.primary, busy),
|
|
185
|
+
),
|
|
186
|
+
),
|
|
187
|
+
React.createElement(
|
|
188
|
+
"div",
|
|
189
|
+
{ style: styles.row },
|
|
190
|
+
button("Cancel at the end of the period", confirm, styles.danger, busy),
|
|
191
|
+
button("Keep my subscription", keep, {}, busy),
|
|
192
|
+
),
|
|
193
|
+
);
|
|
194
|
+
} else {
|
|
195
|
+
content = React.createElement("span", { role: "status" }, done);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return React.createElement(
|
|
199
|
+
"div",
|
|
200
|
+
{ className: "meterbox-cancel-subscription", style: styles.box },
|
|
201
|
+
content,
|
|
202
|
+
error && React.createElement("span", { role: "alert", style: styles.error }, error),
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
CancelSubscription.REASONS = REASONS;
|
|
207
|
+
module.exports = CancelSubscription;
|
package/src/index.js
CHANGED
|
@@ -10,6 +10,7 @@ const CustomerPortal = require("./CustomerPortal");
|
|
|
10
10
|
const AccountPage = require("./pages/AccountPage");
|
|
11
11
|
const PlansPage = require("./pages/PlansPage");
|
|
12
12
|
const CreditsPage = require("./pages/CreditsPage");
|
|
13
|
+
const CancelSubscription = require("./CancelSubscription");
|
|
13
14
|
|
|
14
15
|
module.exports = CustomerPortal;
|
|
15
16
|
module.exports.default = CustomerPortal;
|
|
@@ -17,3 +18,4 @@ module.exports.CustomerPortal = CustomerPortal;
|
|
|
17
18
|
module.exports.AccountPage = AccountPage;
|
|
18
19
|
module.exports.PlansPage = PlansPage;
|
|
19
20
|
module.exports.CreditsPage = CreditsPage;
|
|
21
|
+
module.exports.CancelSubscription = CancelSubscription;
|
package/src/pages/AccountPage.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
const React = require("react");
|
|
7
|
+
const CancelSubscription = require("../CancelSubscription");
|
|
7
8
|
|
|
8
9
|
const styles = {
|
|
9
10
|
card: {
|
|
@@ -53,6 +54,9 @@ function AccountPage({ baseUrl, customerId, customerToken, fetch: fetchImpl }) {
|
|
|
53
54
|
const [me, setMe] = React.useState(null);
|
|
54
55
|
const [error, setError] = React.useState(null);
|
|
55
56
|
const [loading, setLoading] = React.useState(true);
|
|
57
|
+
// Bumped after a cancellation step, to show the new status.
|
|
58
|
+
const [version, setVersion] = React.useState(0);
|
|
59
|
+
const reload = React.useCallback(() => setVersion((v) => v + 1), []);
|
|
56
60
|
|
|
57
61
|
React.useEffect(() => {
|
|
58
62
|
let cancelled = false;
|
|
@@ -79,7 +83,7 @@ function AccountPage({ baseUrl, customerId, customerToken, fetch: fetchImpl }) {
|
|
|
79
83
|
return () => {
|
|
80
84
|
cancelled = true;
|
|
81
85
|
};
|
|
82
|
-
}, [baseUrl, customerId, customerToken, fetchImpl]);
|
|
86
|
+
}, [baseUrl, customerId, customerToken, fetchImpl, version]);
|
|
83
87
|
|
|
84
88
|
if (loading) {
|
|
85
89
|
return React.createElement(
|
|
@@ -97,10 +101,8 @@ function AccountPage({ baseUrl, customerId, customerToken, fetch: fetchImpl }) {
|
|
|
97
101
|
}
|
|
98
102
|
if (!me) return null;
|
|
99
103
|
const statusPill =
|
|
100
|
-
me.status === "active"
|
|
101
|
-
|
|
102
|
-
: { ...styles.pill, ...styles.pillInactive };
|
|
103
|
-
return React.createElement(
|
|
104
|
+
me.status === "active" ? styles.pill : { ...styles.pill, ...styles.pillInactive };
|
|
105
|
+
const account = React.createElement(
|
|
104
106
|
"div",
|
|
105
107
|
{ className: "meterbox-account-page", style: styles.card },
|
|
106
108
|
me.name &&
|
|
@@ -140,6 +142,27 @@ function AccountPage({ baseUrl, customerId, customerToken, fetch: fetchImpl }) {
|
|
|
140
142
|
String(me.current_period_end).slice(0, 10),
|
|
141
143
|
),
|
|
142
144
|
),
|
|
145
|
+
me.cancel_at &&
|
|
146
|
+
React.createElement(
|
|
147
|
+
"div",
|
|
148
|
+
{ style: styles.row },
|
|
149
|
+
React.createElement("span", { style: styles.label }, "Ends on"),
|
|
150
|
+
React.createElement("span", { style: styles.value }, String(me.cancel_at).slice(0, 10)),
|
|
151
|
+
),
|
|
152
|
+
);
|
|
153
|
+
return React.createElement(
|
|
154
|
+
"div",
|
|
155
|
+
{ style: { display: "grid", gap: 12 } },
|
|
156
|
+
account,
|
|
157
|
+
React.createElement(CancelSubscription, {
|
|
158
|
+
baseUrl,
|
|
159
|
+
customerId,
|
|
160
|
+
customerToken,
|
|
161
|
+
fetch: fetchImpl,
|
|
162
|
+
status: me.status,
|
|
163
|
+
cancelAt: me.cancel_at,
|
|
164
|
+
onChange: reload,
|
|
165
|
+
}),
|
|
143
166
|
);
|
|
144
167
|
}
|
|
145
168
|
|