@startsimpli/billing 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/README.md +145 -0
- package/package.json +50 -0
- package/src/components/ManageSubscription.test.tsx +140 -0
- package/src/components/ManageSubscription.tsx +53 -0
- package/src/components/PricingDetailPage.integration.test.tsx +244 -0
- package/src/components/PricingDetailPage.test.tsx +404 -0
- package/src/components/PricingDetailPage.tsx +295 -0
- package/src/components/PricingPage.test.tsx +278 -0
- package/src/components/PricingPage.tsx +153 -0
- package/src/components/PricingSection.test.tsx +319 -0
- package/src/components/PricingSection.tsx +154 -0
- package/src/components/SubscriptionManager.test.tsx +498 -0
- package/src/components/SubscriptionManager.tsx +270 -0
- package/src/components/UpgradeModal.test.tsx +152 -0
- package/src/components/UpgradeModal.tsx +195 -0
- package/src/components/index.ts +12 -0
- package/src/hooks/BillingProvider.test.tsx +125 -0
- package/src/hooks/BillingProvider.tsx +52 -0
- package/src/hooks/index.ts +6 -0
- package/src/hooks/useCheckout.test.tsx +232 -0
- package/src/hooks/useCheckout.ts +75 -0
- package/src/hooks/usePortal.test.tsx +189 -0
- package/src/hooks/usePortal.ts +43 -0
- package/src/hooks/useProduct.test.tsx +155 -0
- package/src/hooks/useProduct.ts +43 -0
- package/src/hooks/useSubscription.test.tsx +167 -0
- package/src/hooks/useSubscription.ts +40 -0
- package/src/index.ts +47 -0
- package/src/server/index.ts +2 -0
- package/src/server/proxy.ts +89 -0
- package/src/types/index.ts +78 -0
- package/src/utils/api.test.ts +129 -0
- package/src/utils/api.ts +123 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SubscriptionManager — Interactive subscription management widget.
|
|
3
|
+
*
|
|
4
|
+
* Displays the current subscription, available plan options for
|
|
5
|
+
* upgrade/downgrade, and a portal button for billing management.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* <SubscriptionManager
|
|
9
|
+
* productId="raise-simpli"
|
|
10
|
+
* returnUrl="https://app.example.com/settings"
|
|
11
|
+
* onPlanChange={(offer) => console.log(offer)}
|
|
12
|
+
* />
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import React from "react";
|
|
16
|
+
import { useSubscription } from "../hooks/useSubscription";
|
|
17
|
+
import { useProduct } from "../hooks/useProduct";
|
|
18
|
+
import { usePortal } from "../hooks/usePortal";
|
|
19
|
+
import type { ProductOffer } from "../types";
|
|
20
|
+
|
|
21
|
+
export interface SubscriptionManagerProps {
|
|
22
|
+
/** Product slug (e.g., "raise-simpli") */
|
|
23
|
+
productId: string;
|
|
24
|
+
|
|
25
|
+
/** URL to return to after portal session */
|
|
26
|
+
returnUrl: string;
|
|
27
|
+
|
|
28
|
+
/** Called when user clicks an available plan's CTA button */
|
|
29
|
+
onPlanChange?: (offer: ProductOffer) => void;
|
|
30
|
+
|
|
31
|
+
/** Optional status message to display (e.g., after checkout redirect) */
|
|
32
|
+
statusMessage?: "success" | "cancelled" | "activating" | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function formatDate(iso: string): string {
|
|
36
|
+
const d = new Date(iso);
|
|
37
|
+
const year = d.getUTCFullYear();
|
|
38
|
+
const month = String(d.getUTCMonth() + 1).padStart(2, "0");
|
|
39
|
+
const day = String(d.getUTCDate()).padStart(2, "0");
|
|
40
|
+
return `${year}-${month}-${day}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function StatusBadge({ status, trialEnd }: { status: string; trialEnd: string | null }) {
|
|
44
|
+
let className = "inline-block text-xs font-semibold px-3 py-1 rounded-full ";
|
|
45
|
+
switch (status) {
|
|
46
|
+
case "active":
|
|
47
|
+
className += "bg-green-50 text-green-700";
|
|
48
|
+
break;
|
|
49
|
+
case "trialing":
|
|
50
|
+
className += "bg-blue-50 text-blue-700";
|
|
51
|
+
break;
|
|
52
|
+
case "past_due":
|
|
53
|
+
className += "bg-yellow-50 text-yellow-700 warning";
|
|
54
|
+
break;
|
|
55
|
+
case "cancelled":
|
|
56
|
+
className += "bg-gray-100 text-gray-600";
|
|
57
|
+
break;
|
|
58
|
+
case "incomplete":
|
|
59
|
+
className += "bg-yellow-50 text-yellow-700";
|
|
60
|
+
break;
|
|
61
|
+
case "incomplete_expired":
|
|
62
|
+
className += "bg-red-50 text-red-700";
|
|
63
|
+
break;
|
|
64
|
+
case "unpaid":
|
|
65
|
+
className += "bg-red-50 text-red-700";
|
|
66
|
+
break;
|
|
67
|
+
default:
|
|
68
|
+
className += "bg-gray-100 text-gray-600";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const labelMap: Record<string, string> = {
|
|
72
|
+
active: "Active",
|
|
73
|
+
trialing: "Trialing",
|
|
74
|
+
past_due: "Past due",
|
|
75
|
+
cancelled: "Cancelled",
|
|
76
|
+
incomplete: "Incomplete",
|
|
77
|
+
incomplete_expired: "Expired",
|
|
78
|
+
unpaid: "Unpaid",
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const readableStatus = labelMap[status] ?? status;
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<span data-testid="plan-status" role="status" aria-label={`Subscription status: ${readableStatus}`} className={className}>
|
|
85
|
+
{labelMap[status] ?? status}
|
|
86
|
+
{status === "trialing" && trialEnd && (
|
|
87
|
+
<span className="ml-1">until {formatDate(trialEnd)}</span>
|
|
88
|
+
)}
|
|
89
|
+
</span>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function SubscriptionManager({
|
|
94
|
+
productId,
|
|
95
|
+
returnUrl,
|
|
96
|
+
onPlanChange,
|
|
97
|
+
statusMessage,
|
|
98
|
+
}: SubscriptionManagerProps) {
|
|
99
|
+
const { subscription, loading: subLoading, error: subError } = useSubscription();
|
|
100
|
+
const { product, loading: productLoading } = useProduct(productId);
|
|
101
|
+
const { openPortal, loading: portalLoading } = usePortal();
|
|
102
|
+
|
|
103
|
+
const loading = subLoading || productLoading;
|
|
104
|
+
|
|
105
|
+
if (loading) {
|
|
106
|
+
return (
|
|
107
|
+
<div data-testid="subscription-manager" className="max-w-3xl mx-auto px-6 py-12">
|
|
108
|
+
<p className="text-center text-gray-500">Loading subscription...</p>
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (subError) {
|
|
114
|
+
return (
|
|
115
|
+
<div data-testid="subscription-manager" className="max-w-3xl mx-auto px-6 py-12">
|
|
116
|
+
<div className="text-center text-red-600">
|
|
117
|
+
<p className="font-semibold">Something went wrong</p>
|
|
118
|
+
<p className="text-sm mt-1">{subError.message}</p>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const currentOfferId = subscription?.offer?.id ?? null;
|
|
125
|
+
const availableOffers = product?.offers.filter((o) => o.id !== currentOfferId) ?? [];
|
|
126
|
+
|
|
127
|
+
const handlePortalClick = async () => {
|
|
128
|
+
try {
|
|
129
|
+
const result = await openPortal(returnUrl);
|
|
130
|
+
window.location.href = result.url;
|
|
131
|
+
} catch {
|
|
132
|
+
// Error captured by hook state
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<div data-testid="subscription-manager" className="max-w-3xl mx-auto px-6 py-12 space-y-8">
|
|
138
|
+
{statusMessage === "success" && (
|
|
139
|
+
<div data-testid="status-banner-success" className="rounded-xl bg-green-50 border border-green-200 p-4 flex items-center gap-3">
|
|
140
|
+
<svg className="w-5 h-5 text-green-600 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
141
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
142
|
+
</svg>
|
|
143
|
+
<p className="text-sm font-medium text-green-800">Subscription activated successfully!</p>
|
|
144
|
+
</div>
|
|
145
|
+
)}
|
|
146
|
+
|
|
147
|
+
{statusMessage === "cancelled" && (
|
|
148
|
+
<div data-testid="status-banner-cancelled" className="rounded-xl bg-gray-50 border border-gray-200 p-4 flex items-center gap-3">
|
|
149
|
+
<svg className="w-5 h-5 text-gray-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
150
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
151
|
+
</svg>
|
|
152
|
+
<p className="text-sm font-medium text-gray-700">Checkout was cancelled. You can try again whenever you're ready.</p>
|
|
153
|
+
</div>
|
|
154
|
+
)}
|
|
155
|
+
|
|
156
|
+
{statusMessage === "activating" && (
|
|
157
|
+
<div data-testid="status-banner-activating" className="rounded-xl bg-blue-50 border border-blue-200 p-4 flex items-center gap-3">
|
|
158
|
+
<svg className="w-5 h-5 text-blue-600 shrink-0 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
159
|
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
|
160
|
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
|
161
|
+
</svg>
|
|
162
|
+
<p className="text-sm font-medium text-blue-800">Activating your subscription...</p>
|
|
163
|
+
</div>
|
|
164
|
+
)}
|
|
165
|
+
|
|
166
|
+
{subscription ? (
|
|
167
|
+
<div data-testid="current-plan" className="rounded-2xl border border-gray-200 bg-white shadow-sm p-8">
|
|
168
|
+
<div className="flex items-center justify-between mb-4">
|
|
169
|
+
<h2 className="text-xl font-bold text-gray-900">{subscription.offer.name}</h2>
|
|
170
|
+
<StatusBadge status={subscription.status} trialEnd={subscription.trial_end} />
|
|
171
|
+
</div>
|
|
172
|
+
|
|
173
|
+
<div className="mb-4">
|
|
174
|
+
{subscription.offer.is_free ? (
|
|
175
|
+
<span aria-label="Free">
|
|
176
|
+
<span aria-hidden="true" className="text-3xl font-extrabold text-gray-900">Free</span>
|
|
177
|
+
</span>
|
|
178
|
+
) : (
|
|
179
|
+
<span aria-label={`${Math.floor(parseFloat(subscription.offer.unit_price) || 0)} dollars ${subscription.offer.interval_display}`}>
|
|
180
|
+
<span aria-hidden="true" className="text-3xl font-extrabold text-gray-900">
|
|
181
|
+
${Math.floor(parseFloat(subscription.offer.unit_price) || 0)}
|
|
182
|
+
</span>
|
|
183
|
+
<span aria-hidden="true" className="text-base text-gray-500 ml-1">
|
|
184
|
+
{subscription.offer.interval_display}
|
|
185
|
+
</span>
|
|
186
|
+
</span>
|
|
187
|
+
)}
|
|
188
|
+
</div>
|
|
189
|
+
|
|
190
|
+
<p className="text-sm text-gray-500">
|
|
191
|
+
Billing period: {formatDate(subscription.current_period_start)} to{" "}
|
|
192
|
+
{formatDate(subscription.current_period_end)}
|
|
193
|
+
</p>
|
|
194
|
+
</div>
|
|
195
|
+
) : (
|
|
196
|
+
<div className="text-center py-8">
|
|
197
|
+
<p className="text-lg text-gray-600">No active subscription</p>
|
|
198
|
+
<p className="text-sm text-gray-400 mt-1">Choose a plan below to get started.</p>
|
|
199
|
+
</div>
|
|
200
|
+
)}
|
|
201
|
+
|
|
202
|
+
{availableOffers.length > 0 && (
|
|
203
|
+
<div data-testid="available-plans" className="space-y-4">
|
|
204
|
+
<h3 className="text-lg font-semibold text-gray-900">
|
|
205
|
+
{subscription ? "Change Plan" : "Available Plans"}
|
|
206
|
+
</h3>
|
|
207
|
+
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
208
|
+
{availableOffers.map((offer) => (
|
|
209
|
+
<div
|
|
210
|
+
key={offer.id}
|
|
211
|
+
className={`rounded-xl border p-6 ${
|
|
212
|
+
offer.is_featured
|
|
213
|
+
? "border-blue-600 shadow-md"
|
|
214
|
+
: "border-gray-200 bg-white shadow-sm"
|
|
215
|
+
}`}
|
|
216
|
+
>
|
|
217
|
+
<h4 className="text-lg font-bold text-gray-900 mb-2">{offer.name}</h4>
|
|
218
|
+
<div className="mb-3">
|
|
219
|
+
{offer.is_free ? (
|
|
220
|
+
<span aria-label="Free">
|
|
221
|
+
<span aria-hidden="true" className="text-2xl font-extrabold text-gray-900">Free</span>
|
|
222
|
+
</span>
|
|
223
|
+
) : (
|
|
224
|
+
<span aria-label={`${Math.floor(parseFloat(offer.unit_price) || 0)} dollars ${offer.interval_display}`}>
|
|
225
|
+
<span aria-hidden="true" className="text-2xl font-extrabold text-gray-900">
|
|
226
|
+
${Math.floor(parseFloat(offer.unit_price) || 0)}
|
|
227
|
+
</span>
|
|
228
|
+
<span aria-hidden="true" className="text-sm text-gray-500 ml-1">
|
|
229
|
+
{offer.interval_display}
|
|
230
|
+
</span>
|
|
231
|
+
</span>
|
|
232
|
+
)}
|
|
233
|
+
</div>
|
|
234
|
+
<button
|
|
235
|
+
data-testid={`upgrade-cta-${offer.slug}`}
|
|
236
|
+
onClick={() => onPlanChange?.(offer)}
|
|
237
|
+
className={`w-full py-2.5 px-4 text-sm font-bold rounded-xl cursor-pointer transition-colors ${
|
|
238
|
+
offer.is_featured
|
|
239
|
+
? "bg-blue-600 text-white hover:bg-blue-700"
|
|
240
|
+
: "bg-gray-100 text-gray-800 hover:bg-gray-200"
|
|
241
|
+
}`}
|
|
242
|
+
>
|
|
243
|
+
{offer.cta_text}
|
|
244
|
+
</button>
|
|
245
|
+
</div>
|
|
246
|
+
))}
|
|
247
|
+
</div>
|
|
248
|
+
</div>
|
|
249
|
+
)}
|
|
250
|
+
|
|
251
|
+
{subscription && subscription.status !== "cancelled" && (
|
|
252
|
+
<div className="pt-4 border-t border-gray-100">
|
|
253
|
+
<button
|
|
254
|
+
data-testid="portal-button"
|
|
255
|
+
onClick={handlePortalClick}
|
|
256
|
+
disabled={portalLoading}
|
|
257
|
+
aria-busy={portalLoading || undefined}
|
|
258
|
+
className={`inline-flex items-center gap-2 px-6 py-3 text-sm font-semibold rounded-xl border transition-colors ${
|
|
259
|
+
portalLoading
|
|
260
|
+
? "bg-gray-50 text-gray-400 border-gray-200 cursor-not-allowed"
|
|
261
|
+
: "bg-white text-gray-700 border-gray-200 hover:bg-gray-50 hover:border-gray-300 cursor-pointer"
|
|
262
|
+
}`}
|
|
263
|
+
>
|
|
264
|
+
{portalLoading ? "Loading..." : "Manage Billing"}
|
|
265
|
+
</button>
|
|
266
|
+
</div>
|
|
267
|
+
)}
|
|
268
|
+
</div>
|
|
269
|
+
);
|
|
270
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TDD tests for UpgradeModal component.
|
|
3
|
+
*
|
|
4
|
+
* UpgradeModal is a modal dialog that shows upgrade options and handles
|
|
5
|
+
* the checkout flow when a user selects an offer.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* <UpgradeModal
|
|
9
|
+
* productId="raise-simpli"
|
|
10
|
+
* open={showModal}
|
|
11
|
+
* onClose={() => setShowModal(false)}
|
|
12
|
+
* />
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it, expect, vi } from "vitest";
|
|
16
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
17
|
+
import userEvent from "@testing-library/user-event";
|
|
18
|
+
import React from "react";
|
|
19
|
+
import { BillingProvider } from "../hooks/BillingProvider";
|
|
20
|
+
import { UpgradeModal } from "./UpgradeModal";
|
|
21
|
+
import type { BillingProduct } from "../types";
|
|
22
|
+
|
|
23
|
+
const mockProduct: BillingProduct = {
|
|
24
|
+
id: "prod-1",
|
|
25
|
+
slug: "raise-simpli",
|
|
26
|
+
name: "RaiseSimpli",
|
|
27
|
+
description: "VC fundraising platform",
|
|
28
|
+
offers: [
|
|
29
|
+
{
|
|
30
|
+
id: "offer-1",
|
|
31
|
+
name: "Pro",
|
|
32
|
+
slug: "pro-monthly",
|
|
33
|
+
unit_price: "49.00",
|
|
34
|
+
currency: "USD",
|
|
35
|
+
pricing_model: "flat",
|
|
36
|
+
interval_unit: "month",
|
|
37
|
+
interval_count: 1,
|
|
38
|
+
interval_display: "per month",
|
|
39
|
+
is_recurring: true,
|
|
40
|
+
is_free: false,
|
|
41
|
+
features: [{ key: "seats", name: "Team seats", value: 5, limit: 5 }],
|
|
42
|
+
is_active: true,
|
|
43
|
+
is_featured: true,
|
|
44
|
+
trial_days: 14,
|
|
45
|
+
sort_order: 0,
|
|
46
|
+
cta_text: "Upgrade to Pro",
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function createWrapper(productFetch: typeof fetch, checkoutFetch?: typeof fetch) {
|
|
52
|
+
const fetcher = (checkoutFetch
|
|
53
|
+
? vi.fn()
|
|
54
|
+
.mockImplementationOnce(productFetch as any)
|
|
55
|
+
.mockImplementation(checkoutFetch as any)
|
|
56
|
+
: productFetch) as typeof fetch;
|
|
57
|
+
|
|
58
|
+
return function Wrapper({ children }: { children: React.ReactNode }) {
|
|
59
|
+
return (
|
|
60
|
+
<BillingProvider
|
|
61
|
+
apiBaseUrl="https://api.test.com/api/v1"
|
|
62
|
+
authToken="tok_123"
|
|
63
|
+
fetcher={fetcher}
|
|
64
|
+
>
|
|
65
|
+
{children}
|
|
66
|
+
</BillingProvider>
|
|
67
|
+
);
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function mockProductFetch() {
|
|
72
|
+
return vi.fn().mockResolvedValue({
|
|
73
|
+
ok: true,
|
|
74
|
+
json: () => Promise.resolve(mockProduct),
|
|
75
|
+
}) as unknown as typeof fetch;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
describe("UpgradeModal", () => {
|
|
79
|
+
it("renders nothing when closed", () => {
|
|
80
|
+
const Wrapper = createWrapper(mockProductFetch());
|
|
81
|
+
|
|
82
|
+
const { container } = render(
|
|
83
|
+
<Wrapper>
|
|
84
|
+
<UpgradeModal productId="raise-simpli" open={false} onClose={() => {}} />
|
|
85
|
+
</Wrapper>
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
expect(container.querySelector("[data-testid='upgrade-modal']")).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("renders offers when open", async () => {
|
|
92
|
+
const Wrapper = createWrapper(mockProductFetch());
|
|
93
|
+
|
|
94
|
+
render(
|
|
95
|
+
<Wrapper>
|
|
96
|
+
<UpgradeModal productId="raise-simpli" open={true} onClose={() => {}} />
|
|
97
|
+
</Wrapper>
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
await waitFor(() => {
|
|
101
|
+
expect(screen.getByText("Pro")).toBeDefined();
|
|
102
|
+
});
|
|
103
|
+
expect(screen.getByText(/\$49/)).toBeDefined();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("calls onClose when close button is clicked", async () => {
|
|
107
|
+
const onClose = vi.fn();
|
|
108
|
+
const Wrapper = createWrapper(mockProductFetch());
|
|
109
|
+
|
|
110
|
+
render(
|
|
111
|
+
<Wrapper>
|
|
112
|
+
<UpgradeModal productId="raise-simpli" open={true} onClose={onClose} />
|
|
113
|
+
</Wrapper>
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
await waitFor(() => {
|
|
117
|
+
expect(screen.getByText("Pro")).toBeDefined();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const closeButton = screen.getByLabelText(/close/i);
|
|
121
|
+
await userEvent.click(closeButton);
|
|
122
|
+
expect(onClose).toHaveBeenCalled();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("shows CTA button from offer data", async () => {
|
|
126
|
+
const Wrapper = createWrapper(mockProductFetch());
|
|
127
|
+
|
|
128
|
+
render(
|
|
129
|
+
<Wrapper>
|
|
130
|
+
<UpgradeModal productId="raise-simpli" open={true} onClose={() => {}} />
|
|
131
|
+
</Wrapper>
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
await waitFor(() => {
|
|
135
|
+
expect(screen.getByText("Upgrade to Pro")).toBeDefined();
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("shows trial info when offer has trial days", async () => {
|
|
140
|
+
const Wrapper = createWrapper(mockProductFetch());
|
|
141
|
+
|
|
142
|
+
render(
|
|
143
|
+
<Wrapper>
|
|
144
|
+
<UpgradeModal productId="raise-simpli" open={true} onClose={() => {}} />
|
|
145
|
+
</Wrapper>
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
await waitFor(() => {
|
|
149
|
+
expect(screen.getByText(/14.day/i)).toBeDefined();
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UpgradeModal — Modal dialog showing upgrade options with checkout.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* <UpgradeModal
|
|
6
|
+
* productId="raise-simpli"
|
|
7
|
+
* open={showModal}
|
|
8
|
+
* onClose={() => setShowModal(false)}
|
|
9
|
+
* />
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import React, { useEffect, useRef, useCallback } from "react";
|
|
13
|
+
import { useProduct } from "../hooks/useProduct";
|
|
14
|
+
import type { ProductOffer } from "../types";
|
|
15
|
+
|
|
16
|
+
export interface UpgradeModalProps {
|
|
17
|
+
productId: string;
|
|
18
|
+
open: boolean;
|
|
19
|
+
onClose: () => void;
|
|
20
|
+
onSelectOffer?: (offer: ProductOffer) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function UpgradeModal({
|
|
24
|
+
productId,
|
|
25
|
+
open,
|
|
26
|
+
onClose,
|
|
27
|
+
onSelectOffer,
|
|
28
|
+
}: UpgradeModalProps) {
|
|
29
|
+
const { product, loading, error } = useProduct(productId);
|
|
30
|
+
const modalRef = useRef<HTMLDivElement>(null);
|
|
31
|
+
const previousFocusRef = useRef<HTMLElement | null>(null);
|
|
32
|
+
|
|
33
|
+
const handleKeyDown = useCallback(
|
|
34
|
+
(e: React.KeyboardEvent) => {
|
|
35
|
+
if (e.key === "Escape") {
|
|
36
|
+
onClose();
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (e.key === "Tab" && modalRef.current) {
|
|
41
|
+
const focusable = modalRef.current.querySelectorAll<HTMLElement>(
|
|
42
|
+
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])'
|
|
43
|
+
);
|
|
44
|
+
if (focusable.length === 0) return;
|
|
45
|
+
|
|
46
|
+
const first = focusable[0];
|
|
47
|
+
const last = focusable[focusable.length - 1];
|
|
48
|
+
|
|
49
|
+
if (e.shiftKey) {
|
|
50
|
+
if (document.activeElement === first) {
|
|
51
|
+
e.preventDefault();
|
|
52
|
+
last.focus();
|
|
53
|
+
}
|
|
54
|
+
} else {
|
|
55
|
+
if (document.activeElement === last) {
|
|
56
|
+
e.preventDefault();
|
|
57
|
+
first.focus();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
[onClose]
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (open) {
|
|
67
|
+
previousFocusRef.current = document.activeElement as HTMLElement | null;
|
|
68
|
+
|
|
69
|
+
// Focus the first focusable element after render
|
|
70
|
+
const timer = setTimeout(() => {
|
|
71
|
+
if (modalRef.current) {
|
|
72
|
+
const focusable = modalRef.current.querySelector<HTMLElement>(
|
|
73
|
+
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])'
|
|
74
|
+
);
|
|
75
|
+
focusable?.focus();
|
|
76
|
+
}
|
|
77
|
+
}, 0);
|
|
78
|
+
|
|
79
|
+
return () => clearTimeout(timer);
|
|
80
|
+
} else {
|
|
81
|
+
previousFocusRef.current?.focus();
|
|
82
|
+
}
|
|
83
|
+
}, [open]);
|
|
84
|
+
|
|
85
|
+
if (!open) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<div
|
|
91
|
+
data-testid="upgrade-modal"
|
|
92
|
+
role="dialog"
|
|
93
|
+
aria-modal="true"
|
|
94
|
+
aria-labelledby="upgrade-modal-title"
|
|
95
|
+
onKeyDown={handleKeyDown}
|
|
96
|
+
ref={modalRef}
|
|
97
|
+
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-6"
|
|
98
|
+
>
|
|
99
|
+
<div className="bg-white rounded-2xl max-w-3xl w-full max-h-[90vh] overflow-auto p-8 relative">
|
|
100
|
+
<button
|
|
101
|
+
onClick={onClose}
|
|
102
|
+
aria-label="Close"
|
|
103
|
+
className="absolute top-4 right-4 text-gray-400 hover:text-gray-600 text-2xl leading-none p-1 cursor-pointer"
|
|
104
|
+
>
|
|
105
|
+
×
|
|
106
|
+
</button>
|
|
107
|
+
|
|
108
|
+
<h2 id="upgrade-modal-title" className="text-2xl font-extrabold text-gray-900 text-center mb-8">
|
|
109
|
+
Choose a plan
|
|
110
|
+
</h2>
|
|
111
|
+
|
|
112
|
+
{loading && (
|
|
113
|
+
<div className="text-center py-8 text-gray-500">Loading...</div>
|
|
114
|
+
)}
|
|
115
|
+
|
|
116
|
+
{error && (
|
|
117
|
+
<div className="text-center py-8 text-red-600">
|
|
118
|
+
Unable to load pricing. Please try again.
|
|
119
|
+
</div>
|
|
120
|
+
)}
|
|
121
|
+
|
|
122
|
+
{product && (
|
|
123
|
+
<div data-testid="upgrade-offers" className="flex flex-wrap justify-center gap-5">
|
|
124
|
+
{product.offers.map((offer) => {
|
|
125
|
+
const isFeatured = offer.is_featured;
|
|
126
|
+
return (
|
|
127
|
+
<div
|
|
128
|
+
key={offer.id}
|
|
129
|
+
data-testid={`upgrade-offer-${offer.slug}`}
|
|
130
|
+
className={`
|
|
131
|
+
flex flex-col flex-1 min-w-[200px] max-w-[240px] rounded-xl p-6
|
|
132
|
+
${isFeatured
|
|
133
|
+
? "border-2 border-blue-600 shadow-md"
|
|
134
|
+
: "border border-gray-200"
|
|
135
|
+
}
|
|
136
|
+
`}
|
|
137
|
+
>
|
|
138
|
+
<h3 className="text-lg font-bold text-gray-900 mb-2">{offer.name}</h3>
|
|
139
|
+
|
|
140
|
+
<div className="mb-2">
|
|
141
|
+
{offer.is_free ? (
|
|
142
|
+
<span aria-label="Free">
|
|
143
|
+
<span aria-hidden="true" className="text-2xl font-extrabold text-gray-900">Free</span>
|
|
144
|
+
</span>
|
|
145
|
+
) : (
|
|
146
|
+
<span aria-label={`${Math.floor(parseFloat(offer.unit_price) || 0)} dollars ${offer.interval_display}`}>
|
|
147
|
+
<span aria-hidden="true" className="text-2xl font-extrabold text-gray-900">
|
|
148
|
+
${Math.floor(parseFloat(offer.unit_price) || 0)}
|
|
149
|
+
</span>
|
|
150
|
+
<span aria-hidden="true" className="text-sm text-gray-500"> {offer.interval_display}</span>
|
|
151
|
+
</span>
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
|
|
155
|
+
{offer.trial_days > 0 && (
|
|
156
|
+
<span className="inline-block bg-blue-50 text-blue-700 text-xs font-semibold px-2.5 py-0.5 rounded-md mb-3 w-fit">
|
|
157
|
+
{offer.trial_days}-day free trial
|
|
158
|
+
</span>
|
|
159
|
+
)}
|
|
160
|
+
|
|
161
|
+
{offer.features.length > 0 && (
|
|
162
|
+
<ul className="list-none p-0 m-0 mb-4 flex-1 space-y-0">
|
|
163
|
+
{offer.features.map((f) => (
|
|
164
|
+
<li key={f.key} className="flex items-center gap-1.5 py-1 text-sm text-gray-600">
|
|
165
|
+
<svg className="w-3.5 h-3.5 text-green-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
|
166
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
167
|
+
</svg>
|
|
168
|
+
{f.name}
|
|
169
|
+
</li>
|
|
170
|
+
))}
|
|
171
|
+
</ul>
|
|
172
|
+
)}
|
|
173
|
+
|
|
174
|
+
<button
|
|
175
|
+
onClick={() => onSelectOffer?.(offer)}
|
|
176
|
+
data-testid={`upgrade-cta-${offer.slug}`}
|
|
177
|
+
className={`
|
|
178
|
+
w-full py-2.5 px-4 text-sm font-bold rounded-lg cursor-pointer transition-colors
|
|
179
|
+
${isFeatured
|
|
180
|
+
? "bg-blue-600 text-white hover:bg-blue-700"
|
|
181
|
+
: "bg-gray-100 text-gray-800 hover:bg-gray-200"
|
|
182
|
+
}
|
|
183
|
+
`}
|
|
184
|
+
>
|
|
185
|
+
{offer.cta_text}
|
|
186
|
+
</button>
|
|
187
|
+
</div>
|
|
188
|
+
);
|
|
189
|
+
})}
|
|
190
|
+
</div>
|
|
191
|
+
)}
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { PricingPage } from "./PricingPage";
|
|
2
|
+
export type { PricingPageProps } from "./PricingPage";
|
|
3
|
+
export { PricingSection } from "./PricingSection";
|
|
4
|
+
export type { PricingSectionProps } from "./PricingSection";
|
|
5
|
+
export { UpgradeModal } from "./UpgradeModal";
|
|
6
|
+
export type { UpgradeModalProps } from "./UpgradeModal";
|
|
7
|
+
export { PricingDetailPage } from "./PricingDetailPage";
|
|
8
|
+
export type { PricingDetailPageProps } from "./PricingDetailPage";
|
|
9
|
+
export { ManageSubscription } from "./ManageSubscription";
|
|
10
|
+
export type { ManageSubscriptionProps } from "./ManageSubscription";
|
|
11
|
+
export { SubscriptionManager } from "./SubscriptionManager";
|
|
12
|
+
export type { SubscriptionManagerProps } from "./SubscriptionManager";
|