@umituz/web-dashboard 2.2.0 → 2.3.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/package.json +12 -1
- package/src/domains/billing/components/BillingLayout.tsx +63 -0
- package/src/domains/billing/components/BillingPortal.tsx +198 -0
- package/src/domains/billing/components/InvoiceCard.tsx +143 -0
- package/src/domains/billing/components/PaymentMethodsList.tsx +116 -0
- package/src/domains/billing/components/PlanComparison.tsx +199 -0
- package/src/domains/billing/components/UsageCard.tsx +103 -0
- package/src/domains/billing/components/index.ts +12 -0
- package/src/domains/billing/hooks/index.ts +7 -0
- package/src/domains/billing/hooks/useBilling.ts +385 -0
- package/src/domains/billing/index.ts +69 -0
- package/src/domains/billing/types/billing.ts +347 -0
- package/src/domains/billing/types/index.ts +28 -0
- package/src/domains/billing/utils/billing.ts +344 -0
- package/src/domains/billing/utils/index.ts +29 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan Comparison Component
|
|
3
|
+
*
|
|
4
|
+
* Configurable plan selection and comparison
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Check, Loader2 } from "lucide-react";
|
|
8
|
+
import { cn } from "@umituz/web-design-system/utils";
|
|
9
|
+
import { Button } from "@umituz/web-design-system/atoms";
|
|
10
|
+
import type { PlanComparisonProps } from "../types/billing";
|
|
11
|
+
import { formatPrice, getPlanPrice, calculateDiscount, isPopularPlan, formatFeature } from "../utils/billing";
|
|
12
|
+
|
|
13
|
+
export const PlanComparison = ({
|
|
14
|
+
plans,
|
|
15
|
+
selectedPlan,
|
|
16
|
+
cycle = "monthly",
|
|
17
|
+
showCycleToggle = true,
|
|
18
|
+
showFeatures = true,
|
|
19
|
+
onPlanSelect,
|
|
20
|
+
onCycleChange,
|
|
21
|
+
loading = false,
|
|
22
|
+
}: PlanComparisonProps) => {
|
|
23
|
+
const handleCycleChange = (newCycle: "monthly" | "yearly") => {
|
|
24
|
+
onCycleChange?.(newCycle);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const handleSelectPlan = (planId: string) => {
|
|
28
|
+
if (!loading) {
|
|
29
|
+
onPlanSelect?.(planId);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div className="w-full space-y-8">
|
|
35
|
+
{/* Billing Cycle Toggle */}
|
|
36
|
+
{showCycleToggle && (
|
|
37
|
+
<div className="flex items-center justify-center">
|
|
38
|
+
<div className="inline-flex items-center bg-muted rounded-full p-1">
|
|
39
|
+
<button
|
|
40
|
+
onClick={() => handleCycleChange("monthly")}
|
|
41
|
+
className={cn(
|
|
42
|
+
"px-6 py-2 rounded-full text-sm font-medium transition-all",
|
|
43
|
+
cycle === "monthly"
|
|
44
|
+
? "bg-background text-foreground shadow-sm"
|
|
45
|
+
: "text-muted-foreground hover:text-foreground"
|
|
46
|
+
)}
|
|
47
|
+
>
|
|
48
|
+
Monthly
|
|
49
|
+
</button>
|
|
50
|
+
<button
|
|
51
|
+
onClick={() => handleCycleChange("yearly")}
|
|
52
|
+
className={cn(
|
|
53
|
+
"px-6 py-2 rounded-full text-sm font-medium transition-all relative",
|
|
54
|
+
cycle === "yearly"
|
|
55
|
+
? "bg-background text-foreground shadow-sm"
|
|
56
|
+
: "text-muted-foreground hover:text-foreground"
|
|
57
|
+
)}
|
|
58
|
+
>
|
|
59
|
+
Yearly
|
|
60
|
+
<span className="ml-1 text-xs bg-primary text-primary-foreground px-2 py-0.5 rounded-full">
|
|
61
|
+
Save 17%
|
|
62
|
+
</span>
|
|
63
|
+
</button>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
)}
|
|
67
|
+
|
|
68
|
+
{/* Plans Grid */}
|
|
69
|
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
|
70
|
+
{plans.map((plan) => {
|
|
71
|
+
const isSelected = selectedPlan === plan.id;
|
|
72
|
+
const price = getPlanPrice(plan, cycle);
|
|
73
|
+
const isPopular = isPopularPlan(plan, plans);
|
|
74
|
+
const discount = calculateDiscount(plan.monthlyPrice, plan.yearlyPrice);
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<div
|
|
78
|
+
key={plan.id}
|
|
79
|
+
className={cn(
|
|
80
|
+
"relative bg-background border-2 rounded-2xl p-6 transition-all",
|
|
81
|
+
isSelected
|
|
82
|
+
? "border-primary shadow-lg shadow-primary/20"
|
|
83
|
+
: "border-border hover:border-primary/50",
|
|
84
|
+
isPopular && "border-primary shadow-lg shadow-primary/20"
|
|
85
|
+
)}
|
|
86
|
+
>
|
|
87
|
+
{/* Popular Badge */}
|
|
88
|
+
{isPopular && (
|
|
89
|
+
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
|
90
|
+
<span className="bg-primary text-primary-foreground text-xs font-bold px-4 py-1 rounded-full">
|
|
91
|
+
Most Popular
|
|
92
|
+
</span>
|
|
93
|
+
</div>
|
|
94
|
+
)}
|
|
95
|
+
|
|
96
|
+
{/* Plan Badge */}
|
|
97
|
+
{plan.badge && (
|
|
98
|
+
<div className="mb-4">
|
|
99
|
+
<span
|
|
100
|
+
className={cn(
|
|
101
|
+
"text-xs font-bold uppercase tracking-wider px-3 py-1 rounded-full",
|
|
102
|
+
plan.badgeColor || "bg-primary/10 text-primary"
|
|
103
|
+
)}
|
|
104
|
+
>
|
|
105
|
+
{plan.badge}
|
|
106
|
+
</span>
|
|
107
|
+
</div>
|
|
108
|
+
)}
|
|
109
|
+
|
|
110
|
+
{/* Plan Name */}
|
|
111
|
+
<h3 className="text-2xl font-bold text-foreground mb-2">
|
|
112
|
+
{plan.name}
|
|
113
|
+
</h3>
|
|
114
|
+
|
|
115
|
+
{/* Description */}
|
|
116
|
+
<p className="text-muted-foreground text-sm mb-6">
|
|
117
|
+
{plan.description}
|
|
118
|
+
</p>
|
|
119
|
+
|
|
120
|
+
{/* Price */}
|
|
121
|
+
<div className="mb-6">
|
|
122
|
+
<div className="flex items-baseline gap-1">
|
|
123
|
+
<span className="text-4xl font-extrabold text-foreground">
|
|
124
|
+
{formatPrice(price, plan.currency)}
|
|
125
|
+
</span>
|
|
126
|
+
<span className="text-muted-foreground">
|
|
127
|
+
/{cycle === "monthly" ? "mo" : "yr"}
|
|
128
|
+
</span>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
{cycle === "yearly" && (
|
|
132
|
+
<p className="text-sm text-green-600 dark:text-green-500 mt-1">
|
|
133
|
+
Save {discount}% with yearly billing
|
|
134
|
+
</p>
|
|
135
|
+
)}
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
{/* Features */}
|
|
139
|
+
{showFeatures && plan.features.length > 0 && (
|
|
140
|
+
<ul className="space-y-3 mb-6 flex-1">
|
|
141
|
+
{plan.features.map((feature, index) => {
|
|
142
|
+
const { text, bold, included } = formatFeature(feature);
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<li
|
|
146
|
+
key={index}
|
|
147
|
+
className={cn(
|
|
148
|
+
"flex items-start gap-3 text-sm",
|
|
149
|
+
included === false && "opacity-50 line-through"
|
|
150
|
+
)}
|
|
151
|
+
>
|
|
152
|
+
<Check
|
|
153
|
+
className={cn(
|
|
154
|
+
"h-5 w-5 shrink-0 mt-0.5",
|
|
155
|
+
included === false
|
|
156
|
+
? "text-muted-foreground"
|
|
157
|
+
: "text-primary"
|
|
158
|
+
)}
|
|
159
|
+
/>
|
|
160
|
+
<span className={cn(bold && "font-semibold", "text-foreground")}>
|
|
161
|
+
{text}
|
|
162
|
+
</span>
|
|
163
|
+
</li>
|
|
164
|
+
);
|
|
165
|
+
})}
|
|
166
|
+
</ul>
|
|
167
|
+
)}
|
|
168
|
+
|
|
169
|
+
{/* Select Button */}
|
|
170
|
+
<Button
|
|
171
|
+
onClick={() => handleSelectPlan(plan.id)}
|
|
172
|
+
disabled={loading}
|
|
173
|
+
className={cn(
|
|
174
|
+
"w-full rounded-full py-6",
|
|
175
|
+
isSelected || isPopular
|
|
176
|
+
? "bg-primary text-primary-foreground hover:bg-primary/90"
|
|
177
|
+
: "bg-muted text-foreground hover:bg-muted/80"
|
|
178
|
+
)}
|
|
179
|
+
>
|
|
180
|
+
{loading ? (
|
|
181
|
+
<>
|
|
182
|
+
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
183
|
+
Processing...
|
|
184
|
+
</>
|
|
185
|
+
) : isSelected ? (
|
|
186
|
+
"Current Plan"
|
|
187
|
+
) : (
|
|
188
|
+
"Select Plan"
|
|
189
|
+
)}
|
|
190
|
+
</Button>
|
|
191
|
+
</div>
|
|
192
|
+
);
|
|
193
|
+
})}
|
|
194
|
+
</div>
|
|
195
|
+
</div>
|
|
196
|
+
);
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
export default PlanComparison;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usage Card Component
|
|
3
|
+
*
|
|
4
|
+
* Display usage metrics with progress bar
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { AlertTriangle, TrendingUp } from "lucide-react";
|
|
8
|
+
import { cn } from "@umituz/web-design-system/utils";
|
|
9
|
+
import type { UsageCardProps } from "../types/billing";
|
|
10
|
+
import { calculateUsagePercentage, isNearLimit, formatNumber } from "../utils/billing";
|
|
11
|
+
|
|
12
|
+
export const UsageCard = ({
|
|
13
|
+
metric,
|
|
14
|
+
showProgress = true,
|
|
15
|
+
showLimit = true,
|
|
16
|
+
}: UsageCardProps) => {
|
|
17
|
+
const percentage = calculateUsagePercentage(metric);
|
|
18
|
+
const nearLimit = isNearLimit(metric);
|
|
19
|
+
const isOverLimit = metric.current > metric.limit;
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<div className="p-6 rounded-xl border border-border bg-background">
|
|
23
|
+
{/* Header */}
|
|
24
|
+
<div className="flex items-start justify-between mb-4">
|
|
25
|
+
<div>
|
|
26
|
+
<p className="text-sm text-muted-foreground mb-1">{metric.name}</p>
|
|
27
|
+
<p className="text-3xl font-bold text-foreground">
|
|
28
|
+
{formatNumber(metric.current)}
|
|
29
|
+
<span className="text-base font-normal text-muted-foreground ml-1">
|
|
30
|
+
{metric.unit}
|
|
31
|
+
</span>
|
|
32
|
+
</p>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
{/* Warning Icon */}
|
|
36
|
+
{(nearLimit || isOverLimit) && (
|
|
37
|
+
<div
|
|
38
|
+
className={cn(
|
|
39
|
+
"w-10 h-10 rounded-lg flex items-center justify-center",
|
|
40
|
+
isOverLimit
|
|
41
|
+
? "bg-destructive/10 text-destructive"
|
|
42
|
+
: "bg-orange-500/10 text-orange-600 dark:text-orange-500"
|
|
43
|
+
)}
|
|
44
|
+
>
|
|
45
|
+
{isOverLimit ? (
|
|
46
|
+
<AlertTriangle className="h-5 w-5" />
|
|
47
|
+
) : (
|
|
48
|
+
<TrendingUp className="h-5 w-5" />
|
|
49
|
+
)}
|
|
50
|
+
</div>
|
|
51
|
+
)}
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
{/* Progress Bar */}
|
|
55
|
+
{showProgress && (
|
|
56
|
+
<div className="space-y-2">
|
|
57
|
+
<div className="flex items-center justify-between text-xs">
|
|
58
|
+
{showLimit && (
|
|
59
|
+
<span className="text-muted-foreground">
|
|
60
|
+
{formatNumber(metric.limit)} {metric.unit} limit
|
|
61
|
+
</span>
|
|
62
|
+
)}
|
|
63
|
+
<span
|
|
64
|
+
className={cn(
|
|
65
|
+
"font-medium",
|
|
66
|
+
isOverLimit
|
|
67
|
+
? "text-destructive"
|
|
68
|
+
: nearLimit
|
|
69
|
+
? "text-orange-600 dark:text-orange-500"
|
|
70
|
+
: "text-foreground"
|
|
71
|
+
)}
|
|
72
|
+
>
|
|
73
|
+
{percentage.toFixed(0)}%
|
|
74
|
+
</span>
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
<div className="w-full h-2 bg-muted rounded-full overflow-hidden">
|
|
78
|
+
<div
|
|
79
|
+
className={cn(
|
|
80
|
+
"h-full transition-all duration-500",
|
|
81
|
+
isOverLimit
|
|
82
|
+
? "bg-destructive"
|
|
83
|
+
: nearLimit
|
|
84
|
+
? "bg-orange-500"
|
|
85
|
+
: "bg-primary"
|
|
86
|
+
)}
|
|
87
|
+
style={{ width: `${Math.min(percentage, 100)}%` }}
|
|
88
|
+
/>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
{/* Reset Info */}
|
|
92
|
+
{metric.resetAt && (
|
|
93
|
+
<p className="text-xs text-muted-foreground mt-2">
|
|
94
|
+
Resets {new Date(metric.resetAt).toLocaleDateString()}
|
|
95
|
+
</p>
|
|
96
|
+
)}
|
|
97
|
+
</div>
|
|
98
|
+
)}
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export default UsageCard;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Billing Components
|
|
3
|
+
*
|
|
4
|
+
* Export all billing components
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { PlanComparison } from "./PlanComparison";
|
|
8
|
+
export { PaymentMethodsList } from "./PaymentMethodsList";
|
|
9
|
+
export { InvoiceCard } from "./InvoiceCard";
|
|
10
|
+
export { UsageCard } from "./UsageCard";
|
|
11
|
+
export { BillingPortal } from "./BillingPortal";
|
|
12
|
+
export { BillingLayout } from "./BillingLayout";
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useBilling Hook
|
|
3
|
+
*
|
|
4
|
+
* Core billing hook for managing subscription and payments
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useState, useCallback, useEffect } from "react";
|
|
8
|
+
import type {
|
|
9
|
+
BillingSummary,
|
|
10
|
+
BillingCycle,
|
|
11
|
+
PlanTier,
|
|
12
|
+
PaymentMethod,
|
|
13
|
+
Invoice,
|
|
14
|
+
UsageMetric,
|
|
15
|
+
} from "../types/billing";
|
|
16
|
+
import { formatPrice } from "../utils/billing";
|
|
17
|
+
|
|
18
|
+
interface UseBillingOptions {
|
|
19
|
+
/** Billing API base URL */
|
|
20
|
+
apiUrl?: string;
|
|
21
|
+
/** Initial billing data */
|
|
22
|
+
initialData?: BillingSummary;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface BillingActions {
|
|
26
|
+
/** Load billing summary */
|
|
27
|
+
loadBilling: () => Promise<void>;
|
|
28
|
+
/** Update subscription plan */
|
|
29
|
+
updatePlan: (planId: string) => Promise<void>;
|
|
30
|
+
/** Cancel subscription */
|
|
31
|
+
cancelSubscription: () => Promise<void>;
|
|
32
|
+
/** Update billing cycle */
|
|
33
|
+
updateCycle: (cycle: BillingCycle) => Promise<void>;
|
|
34
|
+
/** Add payment method */
|
|
35
|
+
addPaymentMethod: (paymentMethodDetails: any) => Promise<PaymentMethod>;
|
|
36
|
+
/** Remove payment method */
|
|
37
|
+
removePaymentMethod: (methodId: string) => Promise<void>;
|
|
38
|
+
/** Set default payment method */
|
|
39
|
+
setDefaultPaymentMethod: (methodId: string) => Promise<void>;
|
|
40
|
+
/** Get invoice URL */
|
|
41
|
+
getInvoiceUrl: (invoiceId: string) => Promise<string>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* useBilling hook
|
|
46
|
+
*
|
|
47
|
+
* Manages billing state and actions
|
|
48
|
+
*
|
|
49
|
+
* @param options - Hook options
|
|
50
|
+
* @returns Billing state and actions
|
|
51
|
+
*/
|
|
52
|
+
export function useBilling(options: UseBillingOptions = {}) {
|
|
53
|
+
const { apiUrl = "/api/billing", initialData } = options;
|
|
54
|
+
|
|
55
|
+
// State
|
|
56
|
+
const [billing, setBilling] = useState<BillingSummary | null>(initialData || null);
|
|
57
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
58
|
+
const [error, setError] = useState<string | null>(null);
|
|
59
|
+
|
|
60
|
+
// Load billing summary
|
|
61
|
+
const loadBilling = useCallback(async () => {
|
|
62
|
+
setIsLoading(true);
|
|
63
|
+
setError(null);
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
// In production, call your billing API
|
|
67
|
+
// const response = await fetch(apiUrl);
|
|
68
|
+
// const data = await response.json();
|
|
69
|
+
|
|
70
|
+
// Mock data for demo
|
|
71
|
+
const mockBilling: BillingSummary = {
|
|
72
|
+
subscription: {
|
|
73
|
+
id: "sub_123",
|
|
74
|
+
planId: "pro",
|
|
75
|
+
plan: {
|
|
76
|
+
id: "pro",
|
|
77
|
+
type: "pro",
|
|
78
|
+
name: "Pro Plan",
|
|
79
|
+
description: "For growing teams",
|
|
80
|
+
monthlyPrice: 49,
|
|
81
|
+
yearlyPrice: 490,
|
|
82
|
+
currency: "USD",
|
|
83
|
+
features: [
|
|
84
|
+
"Up to 10 users",
|
|
85
|
+
"100GB storage",
|
|
86
|
+
"100K API calls/month",
|
|
87
|
+
"Priority support",
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
status: "active",
|
|
91
|
+
cycle: "monthly",
|
|
92
|
+
currentPeriodStart: new Date().toISOString(),
|
|
93
|
+
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
|
94
|
+
seats: 5,
|
|
95
|
+
},
|
|
96
|
+
paymentMethods: [
|
|
97
|
+
{
|
|
98
|
+
id: "pm_123",
|
|
99
|
+
type: "card",
|
|
100
|
+
isDefault: true,
|
|
101
|
+
card: {
|
|
102
|
+
last4: "4242",
|
|
103
|
+
brand: "Visa",
|
|
104
|
+
expiryMonth: 12,
|
|
105
|
+
expiryYear: 2025,
|
|
106
|
+
name: "John Doe",
|
|
107
|
+
},
|
|
108
|
+
createdAt: new Date().toISOString(),
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
defaultPaymentMethod: {
|
|
112
|
+
id: "pm_123",
|
|
113
|
+
type: "card",
|
|
114
|
+
isDefault: true,
|
|
115
|
+
card: {
|
|
116
|
+
last4: "4242",
|
|
117
|
+
brand: "Visa",
|
|
118
|
+
expiryMonth: 12,
|
|
119
|
+
expiryYear: 2025,
|
|
120
|
+
name: "John Doe",
|
|
121
|
+
},
|
|
122
|
+
createdAt: new Date().toISOString(),
|
|
123
|
+
},
|
|
124
|
+
upcomingInvoice: {
|
|
125
|
+
amount: 49,
|
|
126
|
+
currency: "USD",
|
|
127
|
+
date: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
|
128
|
+
},
|
|
129
|
+
usage: [
|
|
130
|
+
{
|
|
131
|
+
id: "storage",
|
|
132
|
+
name: "Storage",
|
|
133
|
+
current: 67.5,
|
|
134
|
+
limit: 100,
|
|
135
|
+
unit: "GB",
|
|
136
|
+
resetPeriod: "monthly",
|
|
137
|
+
resetAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
id: "api",
|
|
141
|
+
name: "API Calls",
|
|
142
|
+
current: 75000,
|
|
143
|
+
limit: 100000,
|
|
144
|
+
unit: "calls",
|
|
145
|
+
resetPeriod: "monthly",
|
|
146
|
+
resetAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
recentInvoices: [],
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
setBilling(mockBilling);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
const errorMessage = err instanceof Error ? err.message : "Failed to load billing";
|
|
155
|
+
setError(errorMessage);
|
|
156
|
+
throw err;
|
|
157
|
+
} finally {
|
|
158
|
+
setIsLoading(false);
|
|
159
|
+
}
|
|
160
|
+
}, [apiUrl]);
|
|
161
|
+
|
|
162
|
+
// Update subscription plan
|
|
163
|
+
const updatePlan = useCallback(async (planId: string) => {
|
|
164
|
+
if (!billing) return;
|
|
165
|
+
|
|
166
|
+
setIsLoading(true);
|
|
167
|
+
setError(null);
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
// In production, call your billing API
|
|
171
|
+
// await fetch(`${apiUrl}/subscription`, {
|
|
172
|
+
// method: 'PATCH',
|
|
173
|
+
// body: JSON.stringify({ planId }),
|
|
174
|
+
// });
|
|
175
|
+
|
|
176
|
+
// Mock update
|
|
177
|
+
setBilling((prev) => {
|
|
178
|
+
if (!prev) return null;
|
|
179
|
+
return {
|
|
180
|
+
...prev,
|
|
181
|
+
subscription: {
|
|
182
|
+
...prev.subscription,
|
|
183
|
+
planId,
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
});
|
|
187
|
+
} catch (err) {
|
|
188
|
+
const errorMessage = err instanceof Error ? err.message : "Failed to update plan";
|
|
189
|
+
setError(errorMessage);
|
|
190
|
+
throw err;
|
|
191
|
+
} finally {
|
|
192
|
+
setIsLoading(false);
|
|
193
|
+
}
|
|
194
|
+
}, [apiUrl, billing]);
|
|
195
|
+
|
|
196
|
+
// Cancel subscription
|
|
197
|
+
const cancelSubscription = useCallback(async () => {
|
|
198
|
+
setIsLoading(true);
|
|
199
|
+
setError(null);
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
// In production, call your billing API
|
|
203
|
+
// await fetch(`${apiUrl}/subscription/cancel`, { method: 'POST' });
|
|
204
|
+
|
|
205
|
+
// Mock cancel
|
|
206
|
+
setBilling((prev) => {
|
|
207
|
+
if (!prev) return null;
|
|
208
|
+
return {
|
|
209
|
+
...prev,
|
|
210
|
+
subscription: {
|
|
211
|
+
...prev.subscription,
|
|
212
|
+
status: "canceled",
|
|
213
|
+
cancelAtPeriodEnd: true,
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
});
|
|
217
|
+
} catch (err) {
|
|
218
|
+
const errorMessage = err instanceof Error ? err.message : "Failed to cancel subscription";
|
|
219
|
+
setError(errorMessage);
|
|
220
|
+
throw err;
|
|
221
|
+
} finally {
|
|
222
|
+
setIsLoading(false);
|
|
223
|
+
}
|
|
224
|
+
}, [apiUrl]);
|
|
225
|
+
|
|
226
|
+
// Update billing cycle
|
|
227
|
+
const updateCycle = useCallback(async (cycle: BillingCycle) => {
|
|
228
|
+
setIsLoading(true);
|
|
229
|
+
setError(null);
|
|
230
|
+
|
|
231
|
+
try {
|
|
232
|
+
// In production, call your billing API
|
|
233
|
+
// await fetch(`${apiUrl}/subscription/cycle`, {
|
|
234
|
+
// method: 'PATCH',
|
|
235
|
+
// body: JSON.stringify({ cycle }),
|
|
236
|
+
// });
|
|
237
|
+
|
|
238
|
+
// Mock update
|
|
239
|
+
setBilling((prev) => {
|
|
240
|
+
if (!prev) return null;
|
|
241
|
+
return {
|
|
242
|
+
...prev,
|
|
243
|
+
subscription: {
|
|
244
|
+
...prev.subscription,
|
|
245
|
+
cycle,
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
});
|
|
249
|
+
} catch (err) {
|
|
250
|
+
const errorMessage = err instanceof Error ? err.message : "Failed to update cycle";
|
|
251
|
+
setError(errorMessage);
|
|
252
|
+
throw err;
|
|
253
|
+
} finally {
|
|
254
|
+
setIsLoading(false);
|
|
255
|
+
}
|
|
256
|
+
}, [apiUrl]);
|
|
257
|
+
|
|
258
|
+
// Add payment method
|
|
259
|
+
const addPaymentMethod = useCallback(async (paymentMethodDetails: any) => {
|
|
260
|
+
setIsLoading(true);
|
|
261
|
+
setError(null);
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
// In production, call your billing API
|
|
265
|
+
// const response = await fetch(`${apiUrl}/payment-methods`, {
|
|
266
|
+
// method: 'POST',
|
|
267
|
+
// body: JSON.stringify(paymentMethodDetails),
|
|
268
|
+
// });
|
|
269
|
+
// const newMethod = await response.json();
|
|
270
|
+
|
|
271
|
+
// Mock add
|
|
272
|
+
const newMethod: PaymentMethod = {
|
|
273
|
+
id: `pm_${Date.now()}`,
|
|
274
|
+
type: "card",
|
|
275
|
+
isDefault: false,
|
|
276
|
+
card: paymentMethodDetails,
|
|
277
|
+
createdAt: new Date().toISOString(),
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
setBilling((prev) => {
|
|
281
|
+
if (!prev) return null;
|
|
282
|
+
return {
|
|
283
|
+
...prev,
|
|
284
|
+
paymentMethods: [...prev.paymentMethods, newMethod],
|
|
285
|
+
};
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
return newMethod;
|
|
289
|
+
} catch (err) {
|
|
290
|
+
const errorMessage = err instanceof Error ? err.message : "Failed to add payment method";
|
|
291
|
+
setError(errorMessage);
|
|
292
|
+
throw err;
|
|
293
|
+
} finally {
|
|
294
|
+
setIsLoading(false);
|
|
295
|
+
}
|
|
296
|
+
}, [apiUrl]);
|
|
297
|
+
|
|
298
|
+
// Remove payment method
|
|
299
|
+
const removePaymentMethod = useCallback(async (methodId: string) => {
|
|
300
|
+
setIsLoading(true);
|
|
301
|
+
setError(null);
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
// In production, call your billing API
|
|
305
|
+
// await fetch(`${apiUrl}/payment-methods/${methodId}`, {
|
|
306
|
+
// method: 'DELETE',
|
|
307
|
+
// });
|
|
308
|
+
|
|
309
|
+
// Mock remove
|
|
310
|
+
setBilling((prev) => {
|
|
311
|
+
if (!prev) return null;
|
|
312
|
+
return {
|
|
313
|
+
...prev,
|
|
314
|
+
paymentMethods: prev.paymentMethods.filter((pm) => pm.id !== methodId),
|
|
315
|
+
};
|
|
316
|
+
});
|
|
317
|
+
} catch (err) {
|
|
318
|
+
const errorMessage = err instanceof Error ? err.message : "Failed to remove payment method";
|
|
319
|
+
setError(errorMessage);
|
|
320
|
+
throw err;
|
|
321
|
+
} finally {
|
|
322
|
+
setIsLoading(false);
|
|
323
|
+
}
|
|
324
|
+
}, [apiUrl]);
|
|
325
|
+
|
|
326
|
+
// Set default payment method
|
|
327
|
+
const setDefaultPaymentMethod = useCallback(async (methodId: string) => {
|
|
328
|
+
setIsLoading(true);
|
|
329
|
+
setError(null);
|
|
330
|
+
|
|
331
|
+
try {
|
|
332
|
+
// In production, call your billing API
|
|
333
|
+
// await fetch(`${apiUrl}/payment-methods/${methodId}/default`, {
|
|
334
|
+
// method: 'PATCH',
|
|
335
|
+
// });
|
|
336
|
+
|
|
337
|
+
// Mock update
|
|
338
|
+
setBilling((prev) => {
|
|
339
|
+
if (!prev) return null;
|
|
340
|
+
return {
|
|
341
|
+
...prev,
|
|
342
|
+
paymentMethods: prev.paymentMethods.map((pm) => ({
|
|
343
|
+
...pm,
|
|
344
|
+
isDefault: pm.id === methodId,
|
|
345
|
+
})),
|
|
346
|
+
defaultPaymentMethod: prev.paymentMethods.find((pm) => pm.id === methodId),
|
|
347
|
+
};
|
|
348
|
+
});
|
|
349
|
+
} catch (err) {
|
|
350
|
+
const errorMessage = err instanceof Error ? err.message : "Failed to set default payment method";
|
|
351
|
+
setError(errorMessage);
|
|
352
|
+
throw err;
|
|
353
|
+
} finally {
|
|
354
|
+
setIsLoading(false);
|
|
355
|
+
}
|
|
356
|
+
}, [apiUrl]);
|
|
357
|
+
|
|
358
|
+
// Get invoice URL
|
|
359
|
+
const getInvoiceUrl = useCallback(async (invoiceId: string) => {
|
|
360
|
+
// In production, call your billing API
|
|
361
|
+
// const response = await fetch(`${apiUrl}/invoices/${invoiceId}`);
|
|
362
|
+
// const data = await response.json();
|
|
363
|
+
// return data.invoiceUrl;
|
|
364
|
+
|
|
365
|
+
return `#invoice-${invoiceId}`;
|
|
366
|
+
}, [apiUrl]);
|
|
367
|
+
|
|
368
|
+
const actions: BillingActions = {
|
|
369
|
+
loadBilling,
|
|
370
|
+
updatePlan,
|
|
371
|
+
cancelSubscription,
|
|
372
|
+
updateCycle,
|
|
373
|
+
addPaymentMethod,
|
|
374
|
+
removePaymentMethod,
|
|
375
|
+
setDefaultPaymentMethod,
|
|
376
|
+
getInvoiceUrl,
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
return {
|
|
380
|
+
billing,
|
|
381
|
+
isLoading,
|
|
382
|
+
error,
|
|
383
|
+
...actions,
|
|
384
|
+
};
|
|
385
|
+
}
|