@omnikit-js/ui 0.2.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/LICENSE +21 -0
- package/README.md +185 -0
- package/dist/client.esm.js +28 -0
- package/dist/client.esm.js.map +1 -0
- package/dist/client.js +28 -0
- package/dist/client.js.map +1 -0
- package/dist/index.esm.js +28 -0
- package/dist/index.esm.js.map +1 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/styles.css +3005 -0
- package/package.json +83 -0
- package/src/components/BillingClient.tsx +549 -0
- package/src/components/BillingServer.tsx +127 -0
- package/src/components/PaymentMethodForm.tsx +125 -0
- package/src/components/SubscriptionConfirmModal.tsx +185 -0
- package/src/components/theme-provider.tsx +11 -0
- package/src/components/ui/alert.tsx +59 -0
- package/src/components/ui/avatar.tsx +53 -0
- package/src/components/ui/badge.tsx +46 -0
- package/src/components/ui/button.tsx +59 -0
- package/src/components/ui/card-brand-icon.tsx +88 -0
- package/src/components/ui/card.tsx +92 -0
- package/src/components/ui/chart.tsx +353 -0
- package/src/components/ui/dialog.tsx +122 -0
- package/src/components/ui/dropdown-menu.tsx +257 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/label.tsx +24 -0
- package/src/components/ui/navigation-menu.tsx +168 -0
- package/src/components/ui/progress.tsx +31 -0
- package/src/components/ui/radio-group.tsx +44 -0
- package/src/components/ui/select.tsx +185 -0
- package/src/components/ui/separator.tsx +28 -0
- package/src/components/ui/switch.tsx +31 -0
- package/src/components/ui/tabs.tsx +66 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/index.ts +13 -0
- package/src/lib/utils.ts +6 -0
- package/src/styles/globals.css +3 -0
package/package.json
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@omnikit-js/ui",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "A SaaS billing component for Next.js with Stripe integration",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"module": "dist/index.esm.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"src"
|
|
11
|
+
],
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "rollup -c",
|
|
14
|
+
"dev": "rollup -c -w",
|
|
15
|
+
"test": "echo \"Error: no test specified\" && exit 1",
|
|
16
|
+
"prepublishOnly": "npm run build"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"react",
|
|
20
|
+
"nextjs",
|
|
21
|
+
"billing",
|
|
22
|
+
"saas",
|
|
23
|
+
"stripe",
|
|
24
|
+
"component"
|
|
25
|
+
],
|
|
26
|
+
"author": "OmniKit",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
33
|
+
"react-dom": "^18.0.0 || ^19.0.0",
|
|
34
|
+
"next": "^14.0.0 || ^15.0.0"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@radix-ui/react-avatar": "^1.1.10",
|
|
38
|
+
"@radix-ui/react-dialog": "^1.1.14",
|
|
39
|
+
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
|
40
|
+
"@radix-ui/react-label": "^2.1.7",
|
|
41
|
+
"@radix-ui/react-navigation-menu": "^1.2.13",
|
|
42
|
+
"@radix-ui/react-progress": "^1.1.7",
|
|
43
|
+
"@radix-ui/react-radio-group": "^1.3.7",
|
|
44
|
+
"@radix-ui/react-select": "^2.2.5",
|
|
45
|
+
"@radix-ui/react-separator": "^1.1.7",
|
|
46
|
+
"@radix-ui/react-slot": "^1.2.3",
|
|
47
|
+
"@radix-ui/react-switch": "^1.2.5",
|
|
48
|
+
"@radix-ui/react-tabs": "^1.1.12",
|
|
49
|
+
"@stripe/react-stripe-js": "^3.7.0",
|
|
50
|
+
"@stripe/stripe-js": "^7.4.0",
|
|
51
|
+
"class-variance-authority": "^0.7.1",
|
|
52
|
+
"clsx": "^2.1.1",
|
|
53
|
+
"lucide-react": "^0.525.0",
|
|
54
|
+
"tailwind-merge": "^3.3.1"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@babel/core": "^7.24.5",
|
|
58
|
+
"@babel/preset-env": "^7.24.5",
|
|
59
|
+
"@babel/preset-react": "^7.24.1",
|
|
60
|
+
"@babel/preset-typescript": "^7.27.1",
|
|
61
|
+
"@rollup/plugin-babel": "^6.0.4",
|
|
62
|
+
"@rollup/plugin-commonjs": "^25.0.7",
|
|
63
|
+
"@rollup/plugin-node-resolve": "^15.2.3",
|
|
64
|
+
"@rollup/plugin-replace": "^6.0.2",
|
|
65
|
+
"@rollup/plugin-terser": "^0.4.4",
|
|
66
|
+
"@rollup/plugin-typescript": "^11.1.6",
|
|
67
|
+
"@types/react": "^18.3.3",
|
|
68
|
+
"@types/react-dom": "^18.3.0",
|
|
69
|
+
"@types/node": "^20",
|
|
70
|
+
"autoprefixer": "^10.4.19",
|
|
71
|
+
"postcss": "^8.4.38",
|
|
72
|
+
"react": "^18.3.1",
|
|
73
|
+
"react-dom": "^18.3.1",
|
|
74
|
+
"rollup": "^4.17.2",
|
|
75
|
+
"rollup-plugin-copy": "^3.5.0",
|
|
76
|
+
"rollup-plugin-delete": "^2.0.0",
|
|
77
|
+
"rollup-plugin-dts": "^6.1.1",
|
|
78
|
+
"rollup-plugin-peer-deps-external": "^2.2.4",
|
|
79
|
+
"rollup-plugin-postcss": "^4.0.2",
|
|
80
|
+
"tailwindcss": "^3.4.3",
|
|
81
|
+
"typescript": "^5.4.5"
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { useRouter } from 'next/navigation';
|
|
5
|
+
import { loadStripe } from '@stripe/stripe-js';
|
|
6
|
+
import { Elements } from '@stripe/react-stripe-js';
|
|
7
|
+
import { Button } from "./ui/button";
|
|
8
|
+
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "./ui/card";
|
|
9
|
+
import { Badge } from "./ui/badge";
|
|
10
|
+
import { Progress } from "./ui/progress";
|
|
11
|
+
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
|
|
12
|
+
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog";
|
|
13
|
+
import { Check, CreditCard, FileText, AlertCircle, Loader2 } from "lucide-react";
|
|
14
|
+
import PaymentMethodForm from './PaymentMethodForm';
|
|
15
|
+
import SubscriptionConfirmModal from './SubscriptionConfirmModal';
|
|
16
|
+
import { CardBrandIcon } from './ui/card-brand-icon';
|
|
17
|
+
|
|
18
|
+
interface BillingClientProps {
|
|
19
|
+
customerData: any;
|
|
20
|
+
pricingData: any;
|
|
21
|
+
paymentMethods: any;
|
|
22
|
+
apiUrl: string;
|
|
23
|
+
apiKey: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY || '');
|
|
27
|
+
|
|
28
|
+
// Client-side actions
|
|
29
|
+
async function cancelSubscription(subscriptionId: string, apiUrl: string, apiKey: string) {
|
|
30
|
+
const response = await fetch(`${apiUrl}/billing/subscriptions/${subscriptionId}`, {
|
|
31
|
+
method: 'DELETE',
|
|
32
|
+
headers: {
|
|
33
|
+
'x-api-key': apiKey,
|
|
34
|
+
'Content-Type': 'application/json'
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
if (!response.ok) {
|
|
39
|
+
throw new Error('Failed to cancel subscription');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return await response.json();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function setDefaultPaymentMethod(paymentMethodId: string, apiUrl: string, apiKey: string) {
|
|
46
|
+
const response = await fetch(`${apiUrl}/billing/payment-methods/${paymentMethodId}/default`, {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
headers: {
|
|
49
|
+
'x-api-key': apiKey,
|
|
50
|
+
'Content-Type': 'application/json'
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
if (!response.ok) {
|
|
55
|
+
throw new Error('Failed to set default payment method');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return await response.json();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function deletePaymentMethod(paymentMethodId: string, apiUrl: string, apiKey: string) {
|
|
62
|
+
const response = await fetch(`${apiUrl}/billing/payment-methods/${paymentMethodId}`, {
|
|
63
|
+
method: 'DELETE',
|
|
64
|
+
headers: {
|
|
65
|
+
'x-api-key': apiKey,
|
|
66
|
+
'Content-Type': 'application/json'
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
if (!response.ok) {
|
|
71
|
+
throw new Error('Failed to delete payment method');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return await response.json();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function createSubscription(userId: string, priceId: string, paymentMethodId: string, apiUrl: string, apiKey: string) {
|
|
78
|
+
try {
|
|
79
|
+
const response = await fetch(`${apiUrl}/billing/subscriptions`, {
|
|
80
|
+
method: 'POST',
|
|
81
|
+
headers: {
|
|
82
|
+
'x-api-key': apiKey,
|
|
83
|
+
'Content-Type': 'application/json'
|
|
84
|
+
},
|
|
85
|
+
body: JSON.stringify({
|
|
86
|
+
user_id: userId,
|
|
87
|
+
price_id: priceId,
|
|
88
|
+
payment_method_id: paymentMethodId
|
|
89
|
+
})
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
if (!response.ok) {
|
|
93
|
+
const errorData = await response.json();
|
|
94
|
+
throw new Error(errorData.error || 'Failed to create subscription');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { success: true };
|
|
98
|
+
} catch (error) {
|
|
99
|
+
return {
|
|
100
|
+
success: false,
|
|
101
|
+
error: error instanceof Error ? error.message : 'An error occurred'
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function createSetupIntent(userId: string, apiUrl: string, apiKey: string) {
|
|
107
|
+
const response = await fetch(`${apiUrl}/billing/setup-intent`, {
|
|
108
|
+
method: 'POST',
|
|
109
|
+
headers: {
|
|
110
|
+
'x-api-key': apiKey,
|
|
111
|
+
'Content-Type': 'application/json'
|
|
112
|
+
},
|
|
113
|
+
body: JSON.stringify({
|
|
114
|
+
user_id: userId
|
|
115
|
+
})
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
if (!response.ok) {
|
|
119
|
+
throw new Error('Failed to create setup intent');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return await response.json();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export default function BillingClient({ customerData, pricingData, paymentMethods, apiUrl, apiKey }: BillingClientProps) {
|
|
126
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
127
|
+
const [addPaymentModalOpen, setAddPaymentModalOpen] = useState(false);
|
|
128
|
+
const [subscribeModalOpen, setSubscribeModalOpen] = useState(false);
|
|
129
|
+
const [selectedPlan, setSelectedPlan] = useState<any>(null);
|
|
130
|
+
const [activeTab, setActiveTab] = useState('overview');
|
|
131
|
+
const router = useRouter();
|
|
132
|
+
|
|
133
|
+
const currentSubscription = customerData?.subscriptions?.[0];
|
|
134
|
+
const currentPlanItem = currentSubscription?.items?.[0];
|
|
135
|
+
const usage = customerData?.usage;
|
|
136
|
+
const invoices = customerData?.invoices || [];
|
|
137
|
+
const entitlements = customerData?.entitlements || [];
|
|
138
|
+
|
|
139
|
+
// Find the current plan details from pricing data
|
|
140
|
+
const currentPlan = pricingData?.products?.find((product: any) =>
|
|
141
|
+
product.prices?.some((price: any) => price.id === currentPlanItem?.price_id)
|
|
142
|
+
);
|
|
143
|
+
const currentPrice = currentPlan?.prices?.find((price: any) => price.id === currentPlanItem?.price_id);
|
|
144
|
+
|
|
145
|
+
const formatDate = (date: string | number) => {
|
|
146
|
+
const dateObj = typeof date === 'string' ? new Date(date) : new Date(date * 1000);
|
|
147
|
+
return dateObj.toLocaleDateString('en-US', {
|
|
148
|
+
month: 'long',
|
|
149
|
+
day: 'numeric',
|
|
150
|
+
year: 'numeric'
|
|
151
|
+
});
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const formatCurrency = (amount: number) => {
|
|
155
|
+
return new Intl.NumberFormat('en-US', {
|
|
156
|
+
style: 'currency',
|
|
157
|
+
currency: 'usd',
|
|
158
|
+
}).format(amount / 100);
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const handleCancelSubscription = async () => {
|
|
162
|
+
if (!currentSubscription?.id) return;
|
|
163
|
+
|
|
164
|
+
if (!confirm('Are you sure you want to cancel your subscription? You will continue to have access until the end of your billing period.')) {
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
setIsLoading(true);
|
|
169
|
+
try {
|
|
170
|
+
await cancelSubscription(currentSubscription.id, apiUrl, apiKey);
|
|
171
|
+
router.refresh();
|
|
172
|
+
} catch (error) {
|
|
173
|
+
console.error('Failed to cancel subscription:', error);
|
|
174
|
+
} finally {
|
|
175
|
+
setIsLoading(false);
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const handleSetDefaultPayment = async (paymentMethodId: string) => {
|
|
180
|
+
setIsLoading(true);
|
|
181
|
+
try {
|
|
182
|
+
await setDefaultPaymentMethod(paymentMethodId, apiUrl, apiKey);
|
|
183
|
+
router.refresh();
|
|
184
|
+
} catch (error) {
|
|
185
|
+
console.error('Failed to set default payment method:', error);
|
|
186
|
+
} finally {
|
|
187
|
+
setIsLoading(false);
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const handleDeletePayment = async (paymentMethodId: string) => {
|
|
192
|
+
if (!confirm('Are you sure you want to delete this payment method?')) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
setIsLoading(true);
|
|
197
|
+
try {
|
|
198
|
+
await deletePaymentMethod(paymentMethodId, apiUrl, apiKey);
|
|
199
|
+
router.refresh();
|
|
200
|
+
} catch (error) {
|
|
201
|
+
console.error('Failed to delete payment method:', error);
|
|
202
|
+
} finally {
|
|
203
|
+
setIsLoading(false);
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const handlePaymentMethodAdded = () => {
|
|
208
|
+
setAddPaymentModalOpen(false);
|
|
209
|
+
router.refresh();
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const handleSubscribeClick = (plan: any) => {
|
|
213
|
+
const price = plan.prices?.[0];
|
|
214
|
+
const isFree = price && parseFloat(price.unit_amount) === 0;
|
|
215
|
+
|
|
216
|
+
if (!isFree && (!paymentMethods?.paymentMethods || paymentMethods.paymentMethods.length === 0)) {
|
|
217
|
+
setActiveTab('overview');
|
|
218
|
+
setAddPaymentModalOpen(true);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
setSelectedPlan(plan);
|
|
223
|
+
setSubscribeModalOpen(true);
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const handleSubscriptionCreated = () => {
|
|
227
|
+
setSubscribeModalOpen(false);
|
|
228
|
+
setSelectedPlan(null);
|
|
229
|
+
router.refresh();
|
|
230
|
+
setActiveTab('overview');
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const handleCreateSubscription = async (userId: string, priceId: string, paymentMethodId: string) => {
|
|
234
|
+
return await createSubscription(userId, priceId, paymentMethodId, apiUrl, apiKey);
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const handleCreateSetupIntent = async (userId: string) => {
|
|
238
|
+
return await createSetupIntent(userId, apiUrl, apiKey);
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
return (
|
|
242
|
+
<>
|
|
243
|
+
<div className="space-y-6">
|
|
244
|
+
<div>
|
|
245
|
+
<h3 className="text-lg font-medium">Billing</h3>
|
|
246
|
+
<p className="text-sm text-muted-foreground">
|
|
247
|
+
Manage your subscription and billing information.
|
|
248
|
+
</p>
|
|
249
|
+
</div>
|
|
250
|
+
|
|
251
|
+
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
|
|
252
|
+
<TabsList>
|
|
253
|
+
<TabsTrigger value="overview">Overview</TabsTrigger>
|
|
254
|
+
<TabsTrigger value="usage">Usage</TabsTrigger>
|
|
255
|
+
<TabsTrigger value="plans">Plans</TabsTrigger>
|
|
256
|
+
<TabsTrigger value="billing-history">Billing History</TabsTrigger>
|
|
257
|
+
</TabsList>
|
|
258
|
+
|
|
259
|
+
<TabsContent value="overview" className="space-y-4">
|
|
260
|
+
<Card>
|
|
261
|
+
<CardHeader>
|
|
262
|
+
<CardTitle>Current Plan</CardTitle>
|
|
263
|
+
<CardDescription>
|
|
264
|
+
{currentPlan ? `You are currently on the ${currentPlan.name}` : 'No active subscription'}
|
|
265
|
+
</CardDescription>
|
|
266
|
+
</CardHeader>
|
|
267
|
+
<CardContent>
|
|
268
|
+
{currentSubscription ? (
|
|
269
|
+
<div className="flex items-center justify-between">
|
|
270
|
+
<div>
|
|
271
|
+
<p className="text-2xl font-bold">
|
|
272
|
+
{currentPrice ? formatCurrency(parseFloat(currentPrice.unit_amount) * 100) : 'Active'}/month
|
|
273
|
+
</p>
|
|
274
|
+
<p className="text-sm text-muted-foreground">
|
|
275
|
+
Next billing date: {formatDate(currentSubscription.current_period_end)}
|
|
276
|
+
</p>
|
|
277
|
+
{currentSubscription.cancel_at_period_end && (
|
|
278
|
+
<p className="text-sm text-destructive mt-2">
|
|
279
|
+
<AlertCircle className="inline h-4 w-4 mr-1" />
|
|
280
|
+
Subscription will cancel at the end of the billing period
|
|
281
|
+
</p>
|
|
282
|
+
)}
|
|
283
|
+
</div>
|
|
284
|
+
<div className="space-x-2">
|
|
285
|
+
<Button variant="outline" onClick={() => setActiveTab('plans')}>
|
|
286
|
+
Change Plan
|
|
287
|
+
</Button>
|
|
288
|
+
{!currentSubscription.cancel_at_period_end && (
|
|
289
|
+
<Button
|
|
290
|
+
variant="outline"
|
|
291
|
+
onClick={handleCancelSubscription}
|
|
292
|
+
disabled={isLoading}
|
|
293
|
+
>
|
|
294
|
+
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
295
|
+
Cancel Subscription
|
|
296
|
+
</Button>
|
|
297
|
+
)}
|
|
298
|
+
</div>
|
|
299
|
+
</div>
|
|
300
|
+
) : (
|
|
301
|
+
<div className="text-center py-6">
|
|
302
|
+
<p className="text-muted-foreground mb-4">You don't have an active subscription</p>
|
|
303
|
+
<Button onClick={() => setActiveTab('plans')}>
|
|
304
|
+
Choose a Plan
|
|
305
|
+
</Button>
|
|
306
|
+
</div>
|
|
307
|
+
)}
|
|
308
|
+
</CardContent>
|
|
309
|
+
</Card>
|
|
310
|
+
|
|
311
|
+
<Card>
|
|
312
|
+
<CardHeader>
|
|
313
|
+
<CardTitle>Payment Methods</CardTitle>
|
|
314
|
+
<CardDescription>Manage your payment information</CardDescription>
|
|
315
|
+
</CardHeader>
|
|
316
|
+
<CardContent className="space-y-4">
|
|
317
|
+
{paymentMethods?.paymentMethods?.length > 0 ? (
|
|
318
|
+
<div className="space-y-3">
|
|
319
|
+
{paymentMethods.paymentMethods.map((method: any) => (
|
|
320
|
+
<div key={method.id} className="flex items-center justify-between p-4 border rounded-lg">
|
|
321
|
+
<div className="flex items-center gap-4">
|
|
322
|
+
<CardBrandIcon brand={method.brand} className="w-16 h-10" />
|
|
323
|
+
<div className="flex-1">
|
|
324
|
+
<p className="font-medium">•••• •••• •••• {method.last4}</p>
|
|
325
|
+
<p className="text-sm text-muted-foreground">
|
|
326
|
+
Expires {method.exp_month}/{method.exp_year}
|
|
327
|
+
</p>
|
|
328
|
+
</div>
|
|
329
|
+
{method.is_default && (
|
|
330
|
+
<Badge variant="secondary">Default</Badge>
|
|
331
|
+
)}
|
|
332
|
+
</div>
|
|
333
|
+
<div className="flex items-center gap-2 ml-4">
|
|
334
|
+
{!method.is_default && (
|
|
335
|
+
<Button
|
|
336
|
+
variant="outline"
|
|
337
|
+
size="sm"
|
|
338
|
+
onClick={() => handleSetDefaultPayment(method.stripe_payment_method_id)}
|
|
339
|
+
disabled={isLoading}
|
|
340
|
+
>
|
|
341
|
+
Set as Default
|
|
342
|
+
</Button>
|
|
343
|
+
)}
|
|
344
|
+
<Button
|
|
345
|
+
variant="outline"
|
|
346
|
+
size="sm"
|
|
347
|
+
onClick={() => handleDeletePayment(method.stripe_payment_method_id)}
|
|
348
|
+
disabled={isLoading}
|
|
349
|
+
>
|
|
350
|
+
Remove
|
|
351
|
+
</Button>
|
|
352
|
+
</div>
|
|
353
|
+
</div>
|
|
354
|
+
))}
|
|
355
|
+
</div>
|
|
356
|
+
) : (
|
|
357
|
+
<div className="text-center py-6">
|
|
358
|
+
<p className="text-muted-foreground mb-4">No payment methods on file</p>
|
|
359
|
+
</div>
|
|
360
|
+
)}
|
|
361
|
+
</CardContent>
|
|
362
|
+
<CardFooter>
|
|
363
|
+
<Button className="w-full" onClick={() => setAddPaymentModalOpen(true)}>
|
|
364
|
+
<CreditCard className="mr-2 h-4 w-4" />
|
|
365
|
+
Add Payment Method
|
|
366
|
+
</Button>
|
|
367
|
+
</CardFooter>
|
|
368
|
+
</Card>
|
|
369
|
+
</TabsContent>
|
|
370
|
+
|
|
371
|
+
<TabsContent value="usage" className="space-y-4">
|
|
372
|
+
<Card>
|
|
373
|
+
<CardHeader>
|
|
374
|
+
<CardTitle>Current Usage</CardTitle>
|
|
375
|
+
<CardDescription>Your resource usage for the current billing period</CardDescription>
|
|
376
|
+
</CardHeader>
|
|
377
|
+
<CardContent className="space-y-6">
|
|
378
|
+
{usage?.data?.map((item: any) => {
|
|
379
|
+
const percentage = typeof item.limit === 'number'
|
|
380
|
+
? Math.min((item.current_usage / item.limit) * 100, 100)
|
|
381
|
+
: 0;
|
|
382
|
+
|
|
383
|
+
return (
|
|
384
|
+
<div key={item.feature_key} className="space-y-3">
|
|
385
|
+
<div className="flex items-center justify-between text-sm">
|
|
386
|
+
<span>{item.feature_details.description}</span>
|
|
387
|
+
<span className="font-medium">
|
|
388
|
+
{typeof item.limit === 'number'
|
|
389
|
+
? `${item.current_usage.toLocaleString()} / ${item.limit.toLocaleString()}`
|
|
390
|
+
: item.current_usage
|
|
391
|
+
}
|
|
392
|
+
{item.feature_details.units && ` ${item.feature_details.units}`}
|
|
393
|
+
</span>
|
|
394
|
+
</div>
|
|
395
|
+
{typeof item.limit === 'number' && (
|
|
396
|
+
<>
|
|
397
|
+
<Progress value={percentage} className={`h-2 ${!item.within_limit ? 'bg-destructive' : ''}`} />
|
|
398
|
+
<p className="text-xs text-muted-foreground">
|
|
399
|
+
{item.within_limit
|
|
400
|
+
? `${Math.max(0, item.limit - item.current_usage).toLocaleString()} ${item.feature_details.units || ''} remaining`
|
|
401
|
+
: `${(item.current_usage - item.limit).toLocaleString()} ${item.feature_details.units || ''} over limit`
|
|
402
|
+
}
|
|
403
|
+
</p>
|
|
404
|
+
</>
|
|
405
|
+
)}
|
|
406
|
+
</div>
|
|
407
|
+
);
|
|
408
|
+
})}
|
|
409
|
+
</CardContent>
|
|
410
|
+
<CardFooter>
|
|
411
|
+
<p className="text-sm text-muted-foreground">
|
|
412
|
+
Usage resets on {currentSubscription ? formatDate(currentSubscription.current_period_end) : 'N/A'}.
|
|
413
|
+
Need more resources? Consider upgrading your plan.
|
|
414
|
+
</p>
|
|
415
|
+
</CardFooter>
|
|
416
|
+
</Card>
|
|
417
|
+
</TabsContent>
|
|
418
|
+
|
|
419
|
+
<TabsContent value="plans" className="space-y-4">
|
|
420
|
+
<div className="grid gap-4 md:grid-cols-3">
|
|
421
|
+
{pricingData?.products?.map((product: any) => {
|
|
422
|
+
const price = product.prices?.[0];
|
|
423
|
+
const features = product.features || [];
|
|
424
|
+
const isCurrentPlan = currentPlanItem?.price_id === price?.id;
|
|
425
|
+
|
|
426
|
+
return (
|
|
427
|
+
<Card key={product.id} className={isCurrentPlan ? "border-primary" : ""}>
|
|
428
|
+
<CardHeader>
|
|
429
|
+
<div className="flex items-center justify-between">
|
|
430
|
+
<CardTitle>{product.name}</CardTitle>
|
|
431
|
+
{isCurrentPlan && <Badge>Current</Badge>}
|
|
432
|
+
{product.metadata?.popular === 'true' && !isCurrentPlan && (
|
|
433
|
+
<Badge variant="secondary">Popular</Badge>
|
|
434
|
+
)}
|
|
435
|
+
</div>
|
|
436
|
+
<CardDescription>{product.description}</CardDescription>
|
|
437
|
+
</CardHeader>
|
|
438
|
+
<CardContent>
|
|
439
|
+
<div className="text-3xl font-bold mb-4">
|
|
440
|
+
{parseFloat(price?.unit_amount) === 0 ? 'Free' : formatCurrency(parseFloat(price?.unit_amount) * 100)}
|
|
441
|
+
{parseFloat(price?.unit_amount) > 0 && <span className="text-sm font-normal">/month</span>}
|
|
442
|
+
</div>
|
|
443
|
+
<ul className="space-y-2">
|
|
444
|
+
{features.map((feature: any) => (
|
|
445
|
+
<li key={feature.id} className="flex items-center">
|
|
446
|
+
<Check className="h-4 w-4 mr-2 text-primary" />
|
|
447
|
+
<span className="text-sm">
|
|
448
|
+
{feature.value} {feature.units} - {feature.description}
|
|
449
|
+
</span>
|
|
450
|
+
</li>
|
|
451
|
+
))}
|
|
452
|
+
</ul>
|
|
453
|
+
</CardContent>
|
|
454
|
+
<CardFooter>
|
|
455
|
+
<Button
|
|
456
|
+
className="w-full"
|
|
457
|
+
variant={isCurrentPlan ? "outline" : "default"}
|
|
458
|
+
disabled={isCurrentPlan}
|
|
459
|
+
onClick={() => !isCurrentPlan && handleSubscribeClick(product)}
|
|
460
|
+
>
|
|
461
|
+
{isCurrentPlan ? "Current Plan" : parseFloat(price?.unit_amount) === 0 ? "Get Started" : "Upgrade"}
|
|
462
|
+
</Button>
|
|
463
|
+
</CardFooter>
|
|
464
|
+
</Card>
|
|
465
|
+
);
|
|
466
|
+
})}
|
|
467
|
+
</div>
|
|
468
|
+
</TabsContent>
|
|
469
|
+
|
|
470
|
+
<TabsContent value="billing-history" className="space-y-4">
|
|
471
|
+
<Card>
|
|
472
|
+
<CardHeader>
|
|
473
|
+
<CardTitle>Billing History</CardTitle>
|
|
474
|
+
<CardDescription>Download your past invoices</CardDescription>
|
|
475
|
+
</CardHeader>
|
|
476
|
+
<CardContent>
|
|
477
|
+
<div className="space-y-4">
|
|
478
|
+
{invoices.length > 0 ? (
|
|
479
|
+
invoices.map((invoice: any) => (
|
|
480
|
+
<div key={invoice.id} className="flex items-center justify-between p-4 border rounded-lg">
|
|
481
|
+
<div className="flex items-center space-x-4">
|
|
482
|
+
<FileText className="h-8 w-8 text-muted-foreground" />
|
|
483
|
+
<div>
|
|
484
|
+
<p className="font-medium">Invoice #{invoice.id.slice(-8).toUpperCase()}</p>
|
|
485
|
+
<p className="text-sm text-muted-foreground">{formatDate(invoice.created)}</p>
|
|
486
|
+
</div>
|
|
487
|
+
</div>
|
|
488
|
+
<div className="flex items-center space-x-4">
|
|
489
|
+
<div className="text-right">
|
|
490
|
+
<p className="font-medium">{formatCurrency(invoice.amount_paid)}</p>
|
|
491
|
+
<p className="text-sm text-muted-foreground capitalize">{invoice.status}</p>
|
|
492
|
+
</div>
|
|
493
|
+
<Button
|
|
494
|
+
variant="outline"
|
|
495
|
+
size="sm"
|
|
496
|
+
onClick={() => window.open(invoice.invoice_pdf || invoice.hosted_invoice_url, '_blank')}
|
|
497
|
+
>
|
|
498
|
+
<FileText className="h-4 w-4 mr-2" />
|
|
499
|
+
Download
|
|
500
|
+
</Button>
|
|
501
|
+
</div>
|
|
502
|
+
</div>
|
|
503
|
+
))
|
|
504
|
+
) : (
|
|
505
|
+
<div className="text-center py-12">
|
|
506
|
+
<FileText className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
|
507
|
+
<p className="text-muted-foreground">No invoices yet</p>
|
|
508
|
+
</div>
|
|
509
|
+
)}
|
|
510
|
+
</div>
|
|
511
|
+
</CardContent>
|
|
512
|
+
</Card>
|
|
513
|
+
</TabsContent>
|
|
514
|
+
</Tabs>
|
|
515
|
+
</div>
|
|
516
|
+
|
|
517
|
+
<Dialog open={addPaymentModalOpen} onOpenChange={setAddPaymentModalOpen}>
|
|
518
|
+
<DialogContent>
|
|
519
|
+
<DialogHeader>
|
|
520
|
+
<DialogTitle>Add Payment Method</DialogTitle>
|
|
521
|
+
<DialogDescription>
|
|
522
|
+
Add a new payment method to your account. Your card information is securely processed by Stripe.
|
|
523
|
+
</DialogDescription>
|
|
524
|
+
</DialogHeader>
|
|
525
|
+
|
|
526
|
+
<Elements stripe={stripePromise}>
|
|
527
|
+
<PaymentMethodForm
|
|
528
|
+
userId={String(customerData?.id || '')}
|
|
529
|
+
onSuccess={handlePaymentMethodAdded}
|
|
530
|
+
onCancel={() => setAddPaymentModalOpen(false)}
|
|
531
|
+
createSetupIntent={handleCreateSetupIntent}
|
|
532
|
+
/>
|
|
533
|
+
</Elements>
|
|
534
|
+
</DialogContent>
|
|
535
|
+
</Dialog>
|
|
536
|
+
|
|
537
|
+
<SubscriptionConfirmModal
|
|
538
|
+
isOpen={subscribeModalOpen}
|
|
539
|
+
onClose={() => setSubscribeModalOpen(false)}
|
|
540
|
+
plan={selectedPlan}
|
|
541
|
+
currentPlan={currentPlan}
|
|
542
|
+
paymentMethods={paymentMethods?.paymentMethods || []}
|
|
543
|
+
userId={String(customerData?.id || '')}
|
|
544
|
+
onSuccess={handleSubscriptionCreated}
|
|
545
|
+
createSubscription={handleCreateSubscription}
|
|
546
|
+
/>
|
|
547
|
+
</>
|
|
548
|
+
);
|
|
549
|
+
}
|