@carlonicora/nextjs-jsonapi 1.28.0 → 1.29.1
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/dist/{BlockNoteEditor-CAUNVZUF.js → BlockNoteEditor-7BDLLHRA.js} +13 -13
- package/dist/{BlockNoteEditor-CAUNVZUF.js.map → BlockNoteEditor-7BDLLHRA.js.map} +1 -1
- package/dist/{BlockNoteEditor-EOA4OEVX.mjs → BlockNoteEditor-F5KCNLVF.mjs} +3 -3
- package/dist/billing/index.d.mts +47 -17
- package/dist/billing/index.d.ts +47 -17
- package/dist/billing/index.js +1241 -1073
- package/dist/billing/index.js.map +1 -1
- package/dist/billing/index.mjs +1375 -1207
- package/dist/billing/index.mjs.map +1 -1
- package/dist/{chunk-IXI4GAKB.js → chunk-7M7NPKOF.js} +490 -433
- package/dist/chunk-7M7NPKOF.js.map +1 -0
- package/dist/{chunk-ORFXBO7F.mjs → chunk-DU64WMZD.mjs} +6 -3
- package/dist/chunk-DU64WMZD.mjs.map +1 -0
- package/dist/{chunk-TSEU4KZ2.js → chunk-J22NEVSK.js} +21 -18
- package/dist/chunk-J22NEVSK.js.map +1 -0
- package/dist/{chunk-PYASRX75.mjs → chunk-YLSLXQ3O.mjs} +83 -26
- package/dist/chunk-YLSLXQ3O.mjs.map +1 -0
- package/dist/client/index.d.mts +14 -5
- package/dist/client/index.d.ts +14 -5
- package/dist/client/index.js +5 -3
- package/dist/client/index.js.map +1 -1
- package/dist/client/index.mjs +4 -2
- package/dist/components/index.d.mts +2 -2
- package/dist/components/index.d.ts +2 -2
- package/dist/components/index.js +3 -3
- package/dist/components/index.mjs +2 -2
- package/dist/{config-B4pZpLT9.d.ts → config-CHwoRDOp.d.ts} +1 -1
- package/dist/{config-DT1K-t6I.d.mts → config-DiWyJzk9.d.mts} +1 -1
- package/dist/{content.interface-B2Ldg0vg.d.mts → content.interface-BSpowEiW.d.mts} +1 -1
- package/dist/{content.interface-D8NHv3DX.d.ts → content.interface-DFQ7mkpL.d.ts} +1 -1
- package/dist/contexts/index.d.mts +2 -2
- package/dist/contexts/index.d.ts +2 -2
- package/dist/contexts/index.js +3 -3
- package/dist/contexts/index.mjs +2 -2
- package/dist/core/index.d.mts +39 -37
- package/dist/core/index.d.ts +39 -37
- package/dist/core/index.js +2 -2
- package/dist/core/index.mjs +1 -1
- package/dist/index.d.mts +4 -4
- package/dist/index.d.ts +4 -4
- package/dist/index.js +2 -2
- package/dist/index.mjs +1 -1
- package/dist/{notification.interface-H0L9WBge.d.ts → notification.interface-CmKmObIU.d.ts} +1 -0
- package/dist/{notification.interface-DEn-Yp_b.d.mts → notification.interface-D5MbtfZK.d.mts} +1 -0
- package/dist/{s3.service-BNytYanU.d.mts → s3.service-BMT7W6KS.d.mts} +19 -19
- package/dist/{s3.service-C7f_Ygz5.d.ts → s3.service-DsXo9nop.d.ts} +19 -19
- package/dist/server/index.d.mts +3 -3
- package/dist/server/index.d.ts +3 -3
- package/dist/server/index.js +3 -3
- package/dist/server/index.mjs +1 -1
- package/dist/{useSocket-BcnThTD0.d.mts → useSocket-DUqGoPya.d.mts} +1 -1
- package/dist/{useSocket-QZTOCzRF.d.ts → useSocket-QuHa0ZmO.d.ts} +1 -1
- package/package.json +1 -1
- package/src/client/index.ts +1 -0
- package/src/components/forms/FormSelect.tsx +2 -1
- package/src/components/pages/PageContentContainer.tsx +2 -2
- package/src/features/auth/data/auth.ts +0 -2
- package/src/features/billing/components/containers/BillingDashboardContainer.tsx +60 -3
- package/src/features/billing/stripe-customer/components/forms/PaymentMethodEditor.tsx +12 -152
- package/src/features/billing/stripe-customer/components/forms/PaymentMethodForm.tsx +168 -0
- package/src/features/billing/stripe-customer/components/forms/index.ts +1 -0
- package/src/features/billing/stripe-price/components/forms/PriceEditor.tsx +19 -1
- package/src/features/billing/stripe-product/components/forms/ProductEditor.tsx +2 -2
- package/src/features/billing/stripe-subscription/components/containers/SubscriptionsContainer.tsx +24 -235
- package/src/features/billing/stripe-subscription/components/details/SubscriptionDetails.tsx +7 -18
- package/src/features/billing/stripe-subscription/components/forms/index.ts +0 -1
- package/src/features/billing/stripe-subscription/components/lists/SubscriptionsList.tsx +10 -1
- package/src/features/billing/stripe-subscription/components/widgets/IntervalToggle.tsx +28 -0
- package/src/features/billing/stripe-subscription/components/widgets/ProductPricingList.tsx +128 -0
- package/src/features/billing/stripe-subscription/components/widgets/ProductPricingRow.tsx +54 -0
- package/src/features/billing/stripe-subscription/components/widgets/SubscriptionConfirmation.tsx +68 -0
- package/src/features/billing/stripe-subscription/components/widgets/index.ts +4 -1
- package/src/features/billing/stripe-subscription/components/wizards/SubscriptionWizard.tsx +114 -0
- package/src/features/billing/stripe-subscription/components/wizards/WizardProgressIndicator.tsx +66 -0
- package/src/features/billing/stripe-subscription/components/wizards/WizardStepPaymentMethod.tsx +32 -0
- package/src/features/billing/stripe-subscription/components/wizards/WizardStepPlanSelection.tsx +103 -0
- package/src/features/billing/stripe-subscription/components/wizards/WizardStepReview.tsx +133 -0
- package/src/features/billing/stripe-subscription/components/wizards/index.ts +6 -0
- package/src/features/billing/stripe-subscription/hooks/useSubscriptionWizard.ts +217 -0
- package/src/features/billing/stripe-subscription/index.ts +3 -2
- package/src/features/company/components/details/TokenStatusIndicator.tsx +19 -9
- package/src/features/company/data/company.interface.ts +2 -0
- package/src/features/company/data/company.ts +7 -0
- package/src/features/company/hooks/index.ts +1 -0
- package/src/features/company/hooks/useSubscriptionStatus.ts +71 -0
- package/src/features/user/components/forms/UserEditor.tsx +1 -1
- package/src/features/user/components/lists/AdminUsersList.tsx +1 -1
- package/src/features/user/contexts/CurrentUserContext.tsx +1 -1
- package/src/features/user/data/user.ts +1 -1
- package/dist/chunk-IXI4GAKB.js.map +0 -1
- package/dist/chunk-ORFXBO7F.mjs.map +0 -1
- package/dist/chunk-PYASRX75.mjs.map +0 -1
- package/dist/chunk-TSEU4KZ2.js.map +0 -1
- package/src/features/billing/stripe-subscription/components/forms/SubscriptionEditor.tsx +0 -331
- package/src/features/billing/stripe-subscription/components/widgets/PricingCardsGrid.tsx +0 -110
- /package/dist/{BlockNoteEditor-EOA4OEVX.mjs.map → BlockNoteEditor-F5KCNLVF.mjs.map} +0 -0
package/src/features/billing/stripe-subscription/components/containers/SubscriptionsContainer.tsx
CHANGED
|
@@ -1,45 +1,21 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import { useEffect, useState } from "react";
|
|
5
|
-
import {
|
|
6
|
-
import { Alert, AlertDescription, AlertTitle, Button } from "../../../../../shadcnui";
|
|
3
|
+
import { CreditCard } from "lucide-react";
|
|
4
|
+
import { useCallback, useEffect, useState } from "react";
|
|
5
|
+
import { Button } from "../../../../../shadcnui";
|
|
7
6
|
import { BillingAlertBanner } from "../../../components";
|
|
8
|
-
import { PaymentMethodEditor } from "../../../stripe-customer/components/forms/PaymentMethodEditor";
|
|
9
|
-
import { StripeCustomerService } from "../../../stripe-customer/data/stripe-customer.service";
|
|
10
|
-
import { StripePriceInterface } from "../../../stripe-price/data/stripe-price.interface";
|
|
11
|
-
import { StripeProductInterface, StripeProductService } from "../../../stripe-product";
|
|
12
7
|
import { StripeSubscriptionInterface, StripeSubscriptionService, SubscriptionStatus } from "../../data";
|
|
13
|
-
import { useConfirmSubscriptionPayment } from "../../hooks";
|
|
14
|
-
import { SubscriptionEditor } from "../forms";
|
|
15
8
|
import { SubscriptionsList } from "../lists";
|
|
16
|
-
import { PricesByProduct, PricingCardsGrid } from "../widgets/PricingCardsGrid";
|
|
17
9
|
|
|
18
|
-
type
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const { confirmPayment, isConfirming } = useConfirmSubscriptionPayment();
|
|
10
|
+
type SubscriptionsContainerProps = {
|
|
11
|
+
onOpenWizard?: (subscription?: StripeSubscriptionInterface) => void;
|
|
12
|
+
};
|
|
22
13
|
|
|
14
|
+
export function SubscriptionsContainer({ onOpenWizard }: SubscriptionsContainerProps) {
|
|
23
15
|
const [subscriptions, setSubscriptions] = useState<StripeSubscriptionInterface[]>([]);
|
|
24
16
|
const [loading, setLoading] = useState<boolean>(true);
|
|
25
|
-
const [showCreateSubscription, setShowCreateSubscription] = useState<boolean>(false);
|
|
26
|
-
const [showPaymentMethodEditor, setShowPaymentMethodEditor] = useState<boolean>(false);
|
|
27
|
-
|
|
28
|
-
// Pricing data for empty state
|
|
29
|
-
const [products, setProducts] = useState<StripeProductInterface[]>([]);
|
|
30
|
-
const [pricesByProduct, setPricesByProduct] = useState<PricesByProduct>(new Map());
|
|
31
|
-
const [loadingPricing, setLoadingPricing] = useState<boolean>(false);
|
|
32
|
-
|
|
33
|
-
// Payment method and pending subscription state
|
|
34
|
-
const [hasPaymentMethod, setHasPaymentMethod] = useState<boolean | null>(null);
|
|
35
|
-
const [pendingPriceId, setPendingPriceId] = useState<string | null>(null);
|
|
36
|
-
const [creatingSubscription, setCreatingSubscription] = useState<boolean>(false);
|
|
37
17
|
|
|
38
|
-
|
|
39
|
-
const [paymentConfirmationState, setPaymentConfirmationState] = useState<PaymentConfirmationState>("idle");
|
|
40
|
-
const [paymentError, setPaymentError] = useState<string | null>(null);
|
|
41
|
-
|
|
42
|
-
const loadSubscriptions = async () => {
|
|
18
|
+
const loadSubscriptions = useCallback(async () => {
|
|
43
19
|
setLoading(true);
|
|
44
20
|
try {
|
|
45
21
|
const fetchedSubscriptions = await StripeSubscriptionService.listSubscriptions();
|
|
@@ -49,125 +25,12 @@ export function SubscriptionsContainer() {
|
|
|
49
25
|
} finally {
|
|
50
26
|
setLoading(false);
|
|
51
27
|
}
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
const loadPricingData = async () => {
|
|
55
|
-
setLoadingPricing(true);
|
|
56
|
-
try {
|
|
57
|
-
const fetchedProducts = await StripeProductService.listProducts({ active: true });
|
|
58
|
-
|
|
59
|
-
// Build prices map from product.stripePrices
|
|
60
|
-
const grouped: PricesByProduct = new Map();
|
|
61
|
-
for (const product of fetchedProducts) {
|
|
62
|
-
if (product.stripePrices && product.stripePrices.length > 0) {
|
|
63
|
-
grouped.set(product.id, product.stripePrices);
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
setProducts(fetchedProducts);
|
|
68
|
-
setPricesByProduct(grouped);
|
|
69
|
-
} catch (error) {
|
|
70
|
-
console.error("[SubscriptionsContainer] Failed to load pricing data:", error);
|
|
71
|
-
} finally {
|
|
72
|
-
setLoadingPricing(false);
|
|
73
|
-
}
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
const checkPaymentMethod = async () => {
|
|
77
|
-
try {
|
|
78
|
-
const paymentMethods = await StripeCustomerService.listPaymentMethods();
|
|
79
|
-
const hasMethod = paymentMethods.length > 0;
|
|
80
|
-
setHasPaymentMethod(hasMethod);
|
|
81
|
-
} catch (error) {
|
|
82
|
-
console.error("[SubscriptionsContainer] Failed to check payment methods:", error);
|
|
83
|
-
setHasPaymentMethod(false);
|
|
84
|
-
}
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
const createSubscriptionWithPrice = async (priceId: string) => {
|
|
88
|
-
setCreatingSubscription(true);
|
|
89
|
-
setPaymentError(null);
|
|
90
|
-
setPaymentConfirmationState("idle");
|
|
91
|
-
|
|
92
|
-
try {
|
|
93
|
-
const result = await StripeSubscriptionService.createSubscription({
|
|
94
|
-
id: v4(),
|
|
95
|
-
priceId,
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
// Check if payment confirmation is required (SCA flow)
|
|
99
|
-
if (result.meta.requiresAction && result.meta.clientSecret) {
|
|
100
|
-
setPaymentConfirmationState("confirming");
|
|
101
|
-
|
|
102
|
-
const confirmation = await confirmPayment(result.meta.clientSecret);
|
|
103
|
-
|
|
104
|
-
if (!confirmation.success) {
|
|
105
|
-
console.error("[SubscriptionsContainer] Payment confirmation failed:", confirmation.error);
|
|
106
|
-
setPaymentConfirmationState("error");
|
|
107
|
-
setPaymentError(confirmation.error || "Payment confirmation failed");
|
|
108
|
-
setCreatingSubscription(false);
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// Sync subscription to get updated status from Stripe
|
|
113
|
-
await StripeSubscriptionService.syncSubscription({
|
|
114
|
-
subscriptionId: result.subscription.id,
|
|
115
|
-
});
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
// Success
|
|
119
|
-
setPaymentConfirmationState("success");
|
|
120
|
-
await loadSubscriptions();
|
|
121
|
-
} catch (error: any) {
|
|
122
|
-
console.error("[SubscriptionsContainer] Failed to create subscription:", error);
|
|
123
|
-
// Handle 402 error - payment method required despite our check
|
|
124
|
-
if (error?.status === 402 || error?.response?.status === 402) {
|
|
125
|
-
setPendingPriceId(priceId);
|
|
126
|
-
setHasPaymentMethod(false);
|
|
127
|
-
setShowPaymentMethodEditor(true);
|
|
128
|
-
} else {
|
|
129
|
-
setPaymentConfirmationState("error");
|
|
130
|
-
setPaymentError(error?.message || "Failed to create subscription");
|
|
131
|
-
}
|
|
132
|
-
} finally {
|
|
133
|
-
setCreatingSubscription(false);
|
|
134
|
-
}
|
|
135
|
-
};
|
|
136
|
-
|
|
137
|
-
const handleSelectPrice = async (price: StripePriceInterface) => {
|
|
138
|
-
const priceId = price.id; // Use internal UUID, not Stripe ID
|
|
139
|
-
|
|
140
|
-
if (!hasPaymentMethod) {
|
|
141
|
-
setPendingPriceId(priceId);
|
|
142
|
-
setShowPaymentMethodEditor(true);
|
|
143
|
-
return;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
await createSubscriptionWithPrice(priceId);
|
|
147
|
-
};
|
|
148
|
-
|
|
149
|
-
const handlePaymentMethodSuccess = async () => {
|
|
150
|
-
setShowPaymentMethodEditor(false);
|
|
151
|
-
setHasPaymentMethod(true);
|
|
152
|
-
|
|
153
|
-
if (pendingPriceId) {
|
|
154
|
-
await createSubscriptionWithPrice(pendingPriceId);
|
|
155
|
-
setPendingPriceId(null);
|
|
156
|
-
}
|
|
157
|
-
};
|
|
28
|
+
}, []);
|
|
158
29
|
|
|
159
30
|
useEffect(() => {
|
|
160
31
|
loadSubscriptions();
|
|
161
32
|
}, []);
|
|
162
33
|
|
|
163
|
-
// Load pricing data when there are no subscriptions
|
|
164
|
-
useEffect(() => {
|
|
165
|
-
if (!loading && subscriptions.length === 0) {
|
|
166
|
-
loadPricingData();
|
|
167
|
-
checkPaymentMethod();
|
|
168
|
-
}
|
|
169
|
-
}, [loading, subscriptions.length]);
|
|
170
|
-
|
|
171
34
|
// Detect critical subscriptions
|
|
172
35
|
const criticalSubscriptions = subscriptions.filter(
|
|
173
36
|
(sub) =>
|
|
@@ -194,7 +57,7 @@ export function SubscriptionsContainer() {
|
|
|
194
57
|
<h1 className="text-3xl font-bold">Subscriptions</h1>
|
|
195
58
|
</div>
|
|
196
59
|
{subscriptions.length > 0 && (
|
|
197
|
-
<Button onClick={() =>
|
|
60
|
+
<Button onClick={() => onOpenWizard?.()}>Subscribe to a Plan</Button>
|
|
198
61
|
)}
|
|
199
62
|
</div>
|
|
200
63
|
|
|
@@ -203,100 +66,26 @@ export function SubscriptionsContainer() {
|
|
|
203
66
|
<BillingAlertBanner key={subscription.id} subscription={subscription} />
|
|
204
67
|
))}
|
|
205
68
|
|
|
206
|
-
{/*
|
|
69
|
+
{/* Empty state CTA */}
|
|
207
70
|
{subscriptions.length === 0 && (
|
|
208
|
-
<div className="space-y-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
<
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
</div>
|
|
218
|
-
)}
|
|
219
|
-
|
|
220
|
-
{paymentConfirmationState === "success" && (
|
|
221
|
-
<div className="flex flex-col items-center justify-center py-8 space-y-4 bg-green-50 rounded-lg border border-green-200">
|
|
222
|
-
<CheckCircle className="h-12 w-12 text-green-500" />
|
|
223
|
-
<div className="text-center">
|
|
224
|
-
<p className="font-medium text-green-600">Payment successful!</p>
|
|
225
|
-
<p className="text-sm text-muted-foreground">Your subscription is now active.</p>
|
|
226
|
-
</div>
|
|
227
|
-
</div>
|
|
228
|
-
)}
|
|
229
|
-
|
|
230
|
-
{paymentConfirmationState === "error" && (
|
|
231
|
-
<Alert variant="destructive">
|
|
232
|
-
<AlertTitle>Payment Failed</AlertTitle>
|
|
233
|
-
<AlertDescription className="mt-2">
|
|
234
|
-
<p className="mb-4">{paymentError || "We couldn't process your payment. Please try again."}</p>
|
|
235
|
-
<Button
|
|
236
|
-
onClick={() => {
|
|
237
|
-
setPaymentConfirmationState("idle");
|
|
238
|
-
setPaymentError(null);
|
|
239
|
-
}}
|
|
240
|
-
variant="outline"
|
|
241
|
-
>
|
|
242
|
-
Try Again
|
|
243
|
-
</Button>
|
|
244
|
-
</AlertDescription>
|
|
245
|
-
</Alert>
|
|
246
|
-
)}
|
|
247
|
-
|
|
248
|
-
{paymentConfirmationState === "idle" && !isConfirming && (
|
|
249
|
-
<>
|
|
250
|
-
<div className="text-center">
|
|
251
|
-
<CreditCard className="text-muted-foreground mx-auto h-16 w-16 mb-4" />
|
|
252
|
-
<h3 className="mb-2 text-xl font-semibold">Choose Your Plan</h3>
|
|
253
|
-
<p className="text-muted-foreground mb-6">
|
|
254
|
-
Select a subscription plan to get started with our services.
|
|
255
|
-
</p>
|
|
256
|
-
</div>
|
|
257
|
-
|
|
258
|
-
<PricingCardsGrid
|
|
259
|
-
products={products}
|
|
260
|
-
pricesByProduct={pricesByProduct}
|
|
261
|
-
loading={loadingPricing}
|
|
262
|
-
loadingPriceId={creatingSubscription ? (pendingPriceId ?? undefined) : undefined}
|
|
263
|
-
onSelectPrice={handleSelectPrice}
|
|
264
|
-
/>
|
|
265
|
-
</>
|
|
266
|
-
)}
|
|
71
|
+
<div className="flex flex-col items-center justify-center py-12 space-y-4">
|
|
72
|
+
<CreditCard className="h-16 w-16 text-muted-foreground" />
|
|
73
|
+
<div className="text-center">
|
|
74
|
+
<h3 className="text-xl font-semibold mb-2">No Active Subscriptions</h3>
|
|
75
|
+
<p className="text-muted-foreground mb-6">
|
|
76
|
+
Choose a subscription plan to get started with our services.
|
|
77
|
+
</p>
|
|
78
|
+
<Button onClick={() => onOpenWizard?.()}>Subscribe to a Plan</Button>
|
|
79
|
+
</div>
|
|
267
80
|
</div>
|
|
268
81
|
)}
|
|
269
82
|
|
|
270
83
|
{/* Subscriptions List */}
|
|
271
84
|
{subscriptions.length > 0 && (
|
|
272
|
-
<SubscriptionsList
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
{showCreateSubscription && (
|
|
277
|
-
<SubscriptionEditor
|
|
278
|
-
open={showCreateSubscription}
|
|
279
|
-
onOpenChange={setShowCreateSubscription}
|
|
280
|
-
onSuccess={loadSubscriptions}
|
|
281
|
-
onAddPaymentMethod={() => {
|
|
282
|
-
setShowCreateSubscription(false);
|
|
283
|
-
setShowPaymentMethodEditor(true);
|
|
284
|
-
}}
|
|
285
|
-
/>
|
|
286
|
-
)}
|
|
287
|
-
|
|
288
|
-
{/* Payment Method Editor Modal */}
|
|
289
|
-
{showPaymentMethodEditor && (
|
|
290
|
-
<PaymentMethodEditor
|
|
291
|
-
open={showPaymentMethodEditor}
|
|
292
|
-
onOpenChange={(open) => {
|
|
293
|
-
setShowPaymentMethodEditor(open);
|
|
294
|
-
if (!open) {
|
|
295
|
-
// Clear pending price if user cancels
|
|
296
|
-
setPendingPriceId(null);
|
|
297
|
-
}
|
|
298
|
-
}}
|
|
299
|
-
onSuccess={handlePaymentMethodSuccess}
|
|
85
|
+
<SubscriptionsList
|
|
86
|
+
subscriptions={subscriptions}
|
|
87
|
+
onSubscriptionsChange={loadSubscriptions}
|
|
88
|
+
onChangePlan={(sub) => onOpenWizard?.(sub)}
|
|
300
89
|
/>
|
|
301
90
|
)}
|
|
302
91
|
</div>
|
|
@@ -7,7 +7,6 @@ import { StripeCustomerService } from "../../../stripe-customer";
|
|
|
7
7
|
import { StripePriceInterface } from "../../../stripe-price/data/stripe-price.interface";
|
|
8
8
|
import { StripeSubscriptionInterface, StripeSubscriptionService, SubscriptionStatus } from "../../data";
|
|
9
9
|
import { CancelSubscriptionDialog } from "../forms/CancelSubscriptionDialog";
|
|
10
|
-
import { SubscriptionEditor } from "../forms/SubscriptionEditor";
|
|
11
10
|
import { SubscriptionStatusBadge } from "../widgets/SubscriptionStatusBadge";
|
|
12
11
|
|
|
13
12
|
/**
|
|
@@ -53,6 +52,7 @@ type SubscriptionDetailsProps = {
|
|
|
53
52
|
open: boolean;
|
|
54
53
|
onOpenChange: (open: boolean) => void;
|
|
55
54
|
onSubscriptionChange: () => void;
|
|
55
|
+
onChangePlan?: (subscription: StripeSubscriptionInterface) => void;
|
|
56
56
|
};
|
|
57
57
|
|
|
58
58
|
export function SubscriptionDetails({
|
|
@@ -60,8 +60,8 @@ export function SubscriptionDetails({
|
|
|
60
60
|
open,
|
|
61
61
|
onOpenChange,
|
|
62
62
|
onSubscriptionChange,
|
|
63
|
+
onChangePlan,
|
|
63
64
|
}: SubscriptionDetailsProps) {
|
|
64
|
-
const [showEdit, setShowEdit] = useState<boolean>(false);
|
|
65
65
|
const [showCancel, setShowCancel] = useState<boolean>(false);
|
|
66
66
|
const [isProcessing, setIsProcessing] = useState<boolean>(false);
|
|
67
67
|
|
|
@@ -163,9 +163,11 @@ export function SubscriptionDetails({
|
|
|
163
163
|
|
|
164
164
|
{/* Action Buttons */}
|
|
165
165
|
<div className="flex flex-wrap gap-2 pt-4 border-t">
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
166
|
+
{onChangePlan && (
|
|
167
|
+
<Button variant="default" onClick={() => onChangePlan(subscription)}>
|
|
168
|
+
Change Plan
|
|
169
|
+
</Button>
|
|
170
|
+
)}
|
|
169
171
|
|
|
170
172
|
{canPause && (
|
|
171
173
|
<Button variant="outline" onClick={handlePause} disabled={isProcessing}>
|
|
@@ -193,19 +195,6 @@ export function SubscriptionDetails({
|
|
|
193
195
|
</DialogContent>
|
|
194
196
|
</Dialog>
|
|
195
197
|
|
|
196
|
-
{/* Edit Subscription Dialog */}
|
|
197
|
-
{showEdit && (
|
|
198
|
-
<SubscriptionEditor
|
|
199
|
-
subscription={subscription}
|
|
200
|
-
open={showEdit}
|
|
201
|
-
onOpenChange={setShowEdit}
|
|
202
|
-
onSuccess={() => {
|
|
203
|
-
onSubscriptionChange();
|
|
204
|
-
setShowEdit(false);
|
|
205
|
-
}}
|
|
206
|
-
/>
|
|
207
|
-
)}
|
|
208
|
-
|
|
209
198
|
{/* Cancel Subscription Dialog */}
|
|
210
199
|
{showCancel && (
|
|
211
200
|
<CancelSubscriptionDialog
|
|
@@ -41,9 +41,10 @@ function formatPlanName(price: StripePriceInterface | undefined): string {
|
|
|
41
41
|
type SubscriptionsListProps = {
|
|
42
42
|
subscriptions: StripeSubscriptionInterface[];
|
|
43
43
|
onSubscriptionsChange: () => void;
|
|
44
|
+
onChangePlan?: (subscription: StripeSubscriptionInterface) => void;
|
|
44
45
|
};
|
|
45
46
|
|
|
46
|
-
export function SubscriptionsList({ subscriptions, onSubscriptionsChange }: SubscriptionsListProps) {
|
|
47
|
+
export function SubscriptionsList({ subscriptions, onSubscriptionsChange, onChangePlan }: SubscriptionsListProps) {
|
|
47
48
|
const [selectedSub, setSelectedSub] = useState<StripeSubscriptionInterface | null>(null);
|
|
48
49
|
|
|
49
50
|
const handleRowClick = (subscription: StripeSubscriptionInterface) => {
|
|
@@ -97,6 +98,14 @@ export function SubscriptionsList({ subscriptions, onSubscriptionsChange }: Subs
|
|
|
97
98
|
onSubscriptionsChange();
|
|
98
99
|
setSelectedSub(null);
|
|
99
100
|
}}
|
|
101
|
+
onChangePlan={
|
|
102
|
+
onChangePlan
|
|
103
|
+
? (sub) => {
|
|
104
|
+
setSelectedSub(null); // Close details dialog first
|
|
105
|
+
onChangePlan(sub); // Then open wizard at parent level
|
|
106
|
+
}
|
|
107
|
+
: undefined
|
|
108
|
+
}
|
|
100
109
|
/>
|
|
101
110
|
)}
|
|
102
111
|
</>
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Tabs, TabsList, TabsTrigger } from "../../../../../shadcnui";
|
|
4
|
+
|
|
5
|
+
export type BillingInterval = "month" | "year";
|
|
6
|
+
|
|
7
|
+
export type IntervalToggleProps = {
|
|
8
|
+
value: BillingInterval;
|
|
9
|
+
onChange: (interval: BillingInterval) => void;
|
|
10
|
+
hasMonthly: boolean;
|
|
11
|
+
hasYearly: boolean;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function IntervalToggle({ value, onChange, hasMonthly, hasYearly }: IntervalToggleProps) {
|
|
15
|
+
// Only render if BOTH intervals are available
|
|
16
|
+
if (!hasMonthly || !hasYearly) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<Tabs value={value} onValueChange={(v) => onChange(v as BillingInterval)}>
|
|
22
|
+
<TabsList>
|
|
23
|
+
<TabsTrigger value="month">Monthly</TabsTrigger>
|
|
24
|
+
<TabsTrigger value="year">Yearly</TabsTrigger>
|
|
25
|
+
</TabsList>
|
|
26
|
+
</Tabs>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Skeleton } from "../../../../../shadcnui";
|
|
4
|
+
import { StripePriceInterface } from "../../../stripe-price/data/stripe-price.interface";
|
|
5
|
+
import { StripeProductInterface } from "../../../stripe-product";
|
|
6
|
+
import { BillingInterval } from "./IntervalToggle";
|
|
7
|
+
import { ProductPricingRow } from "./ProductPricingRow";
|
|
8
|
+
|
|
9
|
+
export type ProductPricingListProps = {
|
|
10
|
+
products: StripeProductInterface[];
|
|
11
|
+
selectedInterval: BillingInterval;
|
|
12
|
+
currentPriceId?: string;
|
|
13
|
+
selectedPriceId?: string;
|
|
14
|
+
loadingPriceId?: string;
|
|
15
|
+
loading?: boolean;
|
|
16
|
+
onSelectPrice: (price: StripePriceInterface) => void;
|
|
17
|
+
hideRecurringPrices?: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function isRecurringProduct(prices: StripePriceInterface[]): boolean {
|
|
21
|
+
return prices.some((p) => p.priceType === "recurring");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getFilteredPrices(prices: StripePriceInterface[], selectedInterval: BillingInterval): StripePriceInterface[] {
|
|
25
|
+
const isRecurring = isRecurringProduct(prices);
|
|
26
|
+
|
|
27
|
+
if (!isRecurring) {
|
|
28
|
+
return prices.filter((p) => p.priceType === "one_time");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const intervalPrices = prices.filter(
|
|
32
|
+
(p) => p.priceType === "recurring" && p.recurring?.interval === selectedInterval,
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
if (intervalPrices.length === 0) {
|
|
36
|
+
const fallbackInterval = selectedInterval === "month" ? "year" : "month";
|
|
37
|
+
return prices.filter((p) => p.priceType === "recurring" && p.recurring?.interval === fallbackInterval);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return intervalPrices;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function ProductPricingList({
|
|
44
|
+
products,
|
|
45
|
+
selectedInterval,
|
|
46
|
+
currentPriceId,
|
|
47
|
+
selectedPriceId,
|
|
48
|
+
loadingPriceId,
|
|
49
|
+
loading = false,
|
|
50
|
+
onSelectPrice,
|
|
51
|
+
hideRecurringPrices = false,
|
|
52
|
+
}: ProductPricingListProps) {
|
|
53
|
+
if (loading) {
|
|
54
|
+
return <ProductPricingListSkeleton />;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (products.length === 0) {
|
|
58
|
+
return <div className="text-center py-8 text-muted-foreground">No plans available</div>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const sortedProducts = [...products].sort((a, b) => {
|
|
62
|
+
const aRecurring = isRecurringProduct(a.stripePrices || []);
|
|
63
|
+
const bRecurring = isRecurringProduct(b.stripePrices || []);
|
|
64
|
+
if (aRecurring && !bRecurring) return -1;
|
|
65
|
+
if (!aRecurring && bRecurring) return 1;
|
|
66
|
+
return 0;
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Filter products based on hideRecurringPrices
|
|
70
|
+
const filteredProducts = hideRecurringPrices
|
|
71
|
+
? sortedProducts
|
|
72
|
+
.map((product) => ({
|
|
73
|
+
...product,
|
|
74
|
+
stripePrices: (product.stripePrices || []).filter((price) => price.priceType !== "recurring"),
|
|
75
|
+
}))
|
|
76
|
+
.filter((product) => product.stripePrices.length > 0)
|
|
77
|
+
: sortedProducts;
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div className="space-y-6">
|
|
81
|
+
{filteredProducts.map((product) => {
|
|
82
|
+
const allPrices = product.stripePrices || [];
|
|
83
|
+
const filteredPrices = getFilteredPrices(allPrices, selectedInterval);
|
|
84
|
+
|
|
85
|
+
if (filteredPrices.length === 0) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<ProductPricingRow
|
|
91
|
+
key={product.id}
|
|
92
|
+
product={product}
|
|
93
|
+
prices={filteredPrices}
|
|
94
|
+
currentPriceId={currentPriceId}
|
|
95
|
+
selectedPriceId={selectedPriceId}
|
|
96
|
+
loadingPriceId={loadingPriceId}
|
|
97
|
+
onSelectPrice={onSelectPrice}
|
|
98
|
+
/>
|
|
99
|
+
);
|
|
100
|
+
})}
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function ProductPricingListSkeleton() {
|
|
106
|
+
return (
|
|
107
|
+
<div className="space-y-6">
|
|
108
|
+
{[1, 2].map((rowIndex) => (
|
|
109
|
+
<div key={rowIndex} className="space-y-3">
|
|
110
|
+
<Skeleton className="h-6 w-32" />
|
|
111
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
|
112
|
+
{[1, 2, 3].map((cardIndex) => (
|
|
113
|
+
<div key={cardIndex} className="p-4 rounded-lg border animate-pulse space-y-3">
|
|
114
|
+
<Skeleton className="h-6 w-24" />
|
|
115
|
+
<Skeleton className="h-8 w-32" />
|
|
116
|
+
<div className="space-y-2">
|
|
117
|
+
<Skeleton className="h-4 w-full" />
|
|
118
|
+
<Skeleton className="h-4 w-3/4" />
|
|
119
|
+
</div>
|
|
120
|
+
<Skeleton className="h-10 w-full" />
|
|
121
|
+
</div>
|
|
122
|
+
))}
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
))}
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { StripePriceInterface } from "../../../stripe-price/data/stripe-price.interface";
|
|
4
|
+
import { StripeProductInterface } from "../../../stripe-product";
|
|
5
|
+
import { PricingCard } from "./PricingCard";
|
|
6
|
+
|
|
7
|
+
export type ProductPricingRowProps = {
|
|
8
|
+
product: StripeProductInterface;
|
|
9
|
+
prices: StripePriceInterface[]; // Multiple prices for this product
|
|
10
|
+
currentPriceId?: string;
|
|
11
|
+
selectedPriceId?: string;
|
|
12
|
+
loadingPriceId?: string;
|
|
13
|
+
onSelectPrice: (price: StripePriceInterface) => void;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function ProductPricingRow({
|
|
17
|
+
product,
|
|
18
|
+
prices,
|
|
19
|
+
currentPriceId,
|
|
20
|
+
selectedPriceId,
|
|
21
|
+
loadingPriceId,
|
|
22
|
+
onSelectPrice,
|
|
23
|
+
}: ProductPricingRowProps) {
|
|
24
|
+
if (prices.length === 0) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div className="space-y-3">
|
|
30
|
+
{/* Product name header */}
|
|
31
|
+
<h3 className="font-semibold text-lg">{product.name}</h3>
|
|
32
|
+
|
|
33
|
+
{/* Price cards in columns */}
|
|
34
|
+
<div
|
|
35
|
+
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"
|
|
36
|
+
role="radiogroup"
|
|
37
|
+
aria-label={`Pricing options for ${product.name}`}
|
|
38
|
+
>
|
|
39
|
+
{prices
|
|
40
|
+
.sort((a, b) => (a.unitAmount ?? 0) - (b.unitAmount ?? 0))
|
|
41
|
+
.map((price) => (
|
|
42
|
+
<PricingCard
|
|
43
|
+
key={price.id}
|
|
44
|
+
price={price}
|
|
45
|
+
isCurrentPlan={price.id === currentPriceId}
|
|
46
|
+
isSelected={price.id === selectedPriceId}
|
|
47
|
+
isLoading={price.id === loadingPriceId}
|
|
48
|
+
onSelect={onSelectPrice}
|
|
49
|
+
/>
|
|
50
|
+
))}
|
|
51
|
+
</div>
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
package/src/features/billing/stripe-subscription/components/widgets/SubscriptionConfirmation.tsx
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Check, Loader2 } from "lucide-react";
|
|
4
|
+
import { Button } from "../../../../../shadcnui";
|
|
5
|
+
import { formatCurrency, formatInterval } from "../../../components/utils";
|
|
6
|
+
import { StripePriceInterface } from "../../../stripe-price/data/stripe-price.interface";
|
|
7
|
+
|
|
8
|
+
type SubscriptionConfirmationProps = {
|
|
9
|
+
price: StripePriceInterface;
|
|
10
|
+
isLoading: boolean;
|
|
11
|
+
onConfirm: () => void;
|
|
12
|
+
onCancel: () => void;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function SubscriptionConfirmation({ price, isLoading, onConfirm, onCancel }: SubscriptionConfirmationProps) {
|
|
16
|
+
const productName = price.product?.name || price.nickname || "Selected Plan";
|
|
17
|
+
const productDescription = price.product?.description || price.description;
|
|
18
|
+
const features = price.features || [];
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<div className="bg-accent/10 border border-accent/30 rounded-lg p-4">
|
|
22
|
+
<h4 className="font-semibold mb-3">Confirm Your Subscription</h4>
|
|
23
|
+
|
|
24
|
+
<div className="space-y-3">
|
|
25
|
+
{/* Plan name and description */}
|
|
26
|
+
<div>
|
|
27
|
+
<div className="font-medium">{productName}</div>
|
|
28
|
+
{productDescription && <div className="text-sm text-muted-foreground">{productDescription}</div>}
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
{/* Price */}
|
|
32
|
+
<div className="text-lg font-semibold">
|
|
33
|
+
{formatCurrency(price.unitAmount, price.currency)}
|
|
34
|
+
<span className="text-sm font-normal text-muted-foreground">{formatInterval(price)}</span>
|
|
35
|
+
</div>
|
|
36
|
+
|
|
37
|
+
{/* Features */}
|
|
38
|
+
{features.length > 0 && (
|
|
39
|
+
<div className="space-y-1">
|
|
40
|
+
{features.map((feature, index) => (
|
|
41
|
+
<div key={index} className="flex items-center gap-2 text-sm ">
|
|
42
|
+
<Check className="h-4 w-4 text-primary" />
|
|
43
|
+
<span>{feature}</span>
|
|
44
|
+
</div>
|
|
45
|
+
))}
|
|
46
|
+
</div>
|
|
47
|
+
)}
|
|
48
|
+
|
|
49
|
+
{/* Action buttons */}
|
|
50
|
+
<div className="flex justify-end gap-3 pt-2 border-t border-accent/30">
|
|
51
|
+
<Button variant="outline" onClick={onCancel} disabled={isLoading}>
|
|
52
|
+
Cancel
|
|
53
|
+
</Button>
|
|
54
|
+
<Button onClick={onConfirm} disabled={isLoading}>
|
|
55
|
+
{isLoading ? (
|
|
56
|
+
<>
|
|
57
|
+
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
|
58
|
+
Processing...
|
|
59
|
+
</>
|
|
60
|
+
) : (
|
|
61
|
+
"Subscribe"
|
|
62
|
+
)}
|
|
63
|
+
</Button>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|