@omnikit-js/ui 0.3.1 → 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -0
- package/package.json +7 -5
- package/src/components/BillingClient.tsx +87 -30
- package/src/components/SubscriptionConfirmModal.tsx +29 -1
- package/src/components/ui/tooltip.tsx +30 -0
- package/styles.css +3 -0
package/README.md
CHANGED
|
@@ -19,6 +19,31 @@ A comprehensive SaaS billing component for Next.js applications with Stripe inte
|
|
|
19
19
|
npm install @omnikit-js/ui
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
+
**Important**: The component requires Tailwind CSS to be configured in your project.
|
|
23
|
+
|
|
24
|
+
### Setup Tailwind CSS
|
|
25
|
+
|
|
26
|
+
1. Install Tailwind CSS if you haven't already:
|
|
27
|
+
```bash
|
|
28
|
+
npm install -D tailwindcss postcss autoprefixer
|
|
29
|
+
npx tailwindcss init -p
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
2. Import the component's CSS in your main CSS file or `layout.js`:
|
|
33
|
+
```css
|
|
34
|
+
@import '@omnikit-js/ui/styles.css';
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Or in your `app/globals.css`:
|
|
38
|
+
```css
|
|
39
|
+
@tailwind base;
|
|
40
|
+
@tailwind components;
|
|
41
|
+
@tailwind utilities;
|
|
42
|
+
|
|
43
|
+
/* Import the component styles */
|
|
44
|
+
@import '@omnikit-js/ui/styles.css';
|
|
45
|
+
```
|
|
46
|
+
|
|
22
47
|
## Prerequisites
|
|
23
48
|
|
|
24
49
|
1. A Stripe account with API keys
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@omnikit-js/ui",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "A SaaS billing component for Next.js with Stripe integration",
|
|
5
5
|
"main": "src/index.ts",
|
|
6
6
|
"module": "src/index.ts",
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
}
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
16
|
-
"src"
|
|
16
|
+
"src",
|
|
17
|
+
"styles.css"
|
|
17
18
|
],
|
|
18
19
|
"scripts": {
|
|
19
20
|
"build": "rollup -c",
|
|
@@ -35,9 +36,9 @@
|
|
|
35
36
|
"access": "public"
|
|
36
37
|
},
|
|
37
38
|
"peerDependencies": {
|
|
39
|
+
"next": "^14.0.0 || ^15.0.0",
|
|
38
40
|
"react": "^18.0.0 || ^19.0.0",
|
|
39
|
-
"react-dom": "^18.0.0 || ^19.0.0"
|
|
40
|
-
"next": "^14.0.0 || ^15.0.0"
|
|
41
|
+
"react-dom": "^18.0.0 || ^19.0.0"
|
|
41
42
|
},
|
|
42
43
|
"dependencies": {
|
|
43
44
|
"@radix-ui/react-avatar": "^1.1.10",
|
|
@@ -52,6 +53,7 @@
|
|
|
52
53
|
"@radix-ui/react-slot": "^1.2.3",
|
|
53
54
|
"@radix-ui/react-switch": "^1.2.5",
|
|
54
55
|
"@radix-ui/react-tabs": "^1.1.12",
|
|
56
|
+
"@radix-ui/react-tooltip": "^1.2.7",
|
|
55
57
|
"@stripe/react-stripe-js": "^3.7.0",
|
|
56
58
|
"@stripe/stripe-js": "^7.4.0",
|
|
57
59
|
"class-variance-authority": "^0.7.1",
|
|
@@ -70,9 +72,9 @@
|
|
|
70
72
|
"@rollup/plugin-replace": "^6.0.2",
|
|
71
73
|
"@rollup/plugin-terser": "^0.4.4",
|
|
72
74
|
"@rollup/plugin-typescript": "^11.1.6",
|
|
75
|
+
"@types/node": "^20",
|
|
73
76
|
"@types/react": "^18.3.3",
|
|
74
77
|
"@types/react-dom": "^18.3.0",
|
|
75
|
-
"@types/node": "^20",
|
|
76
78
|
"autoprefixer": "^10.4.19",
|
|
77
79
|
"postcss": "^8.4.38",
|
|
78
80
|
"react": "^18.3.1",
|
|
@@ -11,6 +11,7 @@ import { Progress } from "./ui/progress";
|
|
|
11
11
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
|
|
12
12
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog";
|
|
13
13
|
import { Check, CreditCard, FileText, AlertCircle, AlertTriangle, CheckCircle, Info, Loader2 } from "lucide-react";
|
|
14
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
|
|
14
15
|
import PaymentMethodForm from './PaymentMethodForm';
|
|
15
16
|
import SubscriptionConfirmModal from './SubscriptionConfirmModal';
|
|
16
17
|
import { CardBrandIcon } from './ui/card-brand-icon';
|
|
@@ -48,7 +49,8 @@ async function setDefaultPaymentMethod(paymentMethodId: string, apiUrl: string,
|
|
|
48
49
|
headers: {
|
|
49
50
|
'x-api-key': apiKey,
|
|
50
51
|
'Content-Type': 'application/json'
|
|
51
|
-
}
|
|
52
|
+
},
|
|
53
|
+
body: JSON.stringify({})
|
|
52
54
|
});
|
|
53
55
|
|
|
54
56
|
if (!response.ok) {
|
|
@@ -64,7 +66,8 @@ async function deletePaymentMethod(paymentMethodId: string, apiUrl: string, apiK
|
|
|
64
66
|
headers: {
|
|
65
67
|
'x-api-key': apiKey,
|
|
66
68
|
'Content-Type': 'application/json'
|
|
67
|
-
}
|
|
69
|
+
},
|
|
70
|
+
body: JSON.stringify({})
|
|
68
71
|
});
|
|
69
72
|
|
|
70
73
|
if (!response.ok) {
|
|
@@ -144,9 +147,27 @@ export default function BillingClient({ customerData, pricingData, paymentMethod
|
|
|
144
147
|
const [isLoading, setIsLoading] = useState(false);
|
|
145
148
|
const [addPaymentModalOpen, setAddPaymentModalOpen] = useState(false);
|
|
146
149
|
const [subscribeModalOpen, setSubscribeModalOpen] = useState(false);
|
|
150
|
+
const [deletePaymentModalOpen, setDeletePaymentModalOpen] = useState(false);
|
|
147
151
|
const [selectedPlan, setSelectedPlan] = useState<any>(null);
|
|
152
|
+
const [selectedPaymentMethodId, setSelectedPaymentMethodId] = useState<string | null>(null);
|
|
148
153
|
const [activeTab, setActiveTab] = useState('overview');
|
|
149
154
|
const router = useRouter();
|
|
155
|
+
|
|
156
|
+
const formatFeatureValue = (value: any) => {
|
|
157
|
+
// Handle string boolean values
|
|
158
|
+
if (value === 'true' || value === true) {
|
|
159
|
+
return '✓'
|
|
160
|
+
}
|
|
161
|
+
if (value === 'false' || value === false) {
|
|
162
|
+
return '✗'
|
|
163
|
+
}
|
|
164
|
+
// Handle numeric values (both string and number)
|
|
165
|
+
const numValue = typeof value === 'string' ? parseFloat(value) : value
|
|
166
|
+
if (!isNaN(numValue) && numValue >= 1000) {
|
|
167
|
+
return new Intl.NumberFormat('en-US').format(numValue)
|
|
168
|
+
}
|
|
169
|
+
return value
|
|
170
|
+
}
|
|
150
171
|
|
|
151
172
|
const currentSubscription = customerData?.subscriptions?.[0];
|
|
152
173
|
const currentPlanItem = currentSubscription?.items?.[0];
|
|
@@ -206,19 +227,24 @@ export default function BillingClient({ customerData, pricingData, paymentMethod
|
|
|
206
227
|
}
|
|
207
228
|
};
|
|
208
229
|
|
|
209
|
-
const handleDeletePayment =
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
230
|
+
const handleDeletePayment = (paymentMethodId: string) => {
|
|
231
|
+
setSelectedPaymentMethodId(paymentMethodId);
|
|
232
|
+
setDeletePaymentModalOpen(true);
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const confirmDeletePayment = async () => {
|
|
236
|
+
if (!selectedPaymentMethodId) return;
|
|
237
|
+
|
|
215
238
|
try {
|
|
216
|
-
|
|
239
|
+
setIsLoading(true);
|
|
240
|
+
await deletePaymentMethod(selectedPaymentMethodId, apiUrl, apiKey);
|
|
217
241
|
router.refresh();
|
|
218
242
|
} catch (error) {
|
|
219
|
-
console.error('
|
|
243
|
+
console.error('Error deleting payment method:', error);
|
|
220
244
|
} finally {
|
|
221
245
|
setIsLoading(false);
|
|
246
|
+
setDeletePaymentModalOpen(false);
|
|
247
|
+
setSelectedPaymentMethodId(null);
|
|
222
248
|
}
|
|
223
249
|
};
|
|
224
250
|
|
|
@@ -393,7 +419,7 @@ export default function BillingClient({ customerData, pricingData, paymentMethod
|
|
|
393
419
|
<CardDescription>Your resource usage for the current billing period</CardDescription>
|
|
394
420
|
</CardHeader>
|
|
395
421
|
<CardContent className="space-y-6">
|
|
396
|
-
{usage?.data?.map((item: any) => {
|
|
422
|
+
{usage?.data?.filter((item: any) => typeof item.limit === 'number' && item.limit > 0).map((item: any) => {
|
|
397
423
|
const percentage = typeof item.limit === 'number'
|
|
398
424
|
? Math.min((item.current_usage / item.limit) * 100, 100)
|
|
399
425
|
: 0;
|
|
@@ -465,7 +491,7 @@ export default function BillingClient({ customerData, pricingData, paymentMethod
|
|
|
465
491
|
</TabsContent>
|
|
466
492
|
|
|
467
493
|
<TabsContent value="plans" className="space-y-4">
|
|
468
|
-
<div className=
|
|
494
|
+
<div className={`grid gap-4 ${pricingData?.products?.length === 3 ? 'md:grid-cols-3' : 'md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4'}`}>
|
|
469
495
|
{pricingData?.products?.map((product: any) => {
|
|
470
496
|
const price = product.prices?.[0];
|
|
471
497
|
const features = product.features || [];
|
|
@@ -493,44 +519,55 @@ export default function BillingClient({ customerData, pricingData, paymentMethod
|
|
|
493
519
|
};
|
|
494
520
|
|
|
495
521
|
return (
|
|
496
|
-
<Card key={product.id} className={`relative overflow-hidden ${isCurrentPlan ? "border-primary shadow-md" : "border-muted"} ${product.metadata?.popular === 'true' && !isCurrentPlan ? "border-primary/50" : ""}`}>
|
|
522
|
+
<Card key={product.id} className={`relative overflow-hidden h-full flex flex-col ${isCurrentPlan ? "border-primary shadow-md" : "border-muted"} ${product.metadata?.popular === 'true' && !isCurrentPlan ? "border-primary/50" : ""}`}>
|
|
497
523
|
{product.metadata?.popular === 'true' && !isCurrentPlan && (
|
|
498
524
|
<div className="absolute top-0 right-0 bg-primary text-primary-foreground text-xs font-medium px-3 py-1 rounded-bl-lg">
|
|
499
525
|
POPULAR
|
|
500
526
|
</div>
|
|
501
527
|
)}
|
|
502
|
-
<CardHeader className="text-center pb-
|
|
503
|
-
<CardTitle className="text-
|
|
504
|
-
<CardDescription className="mt-
|
|
528
|
+
<CardHeader className="text-center pb-4 pt-4">
|
|
529
|
+
<CardTitle className="text-lg font-semibold">{product.name}</CardTitle>
|
|
530
|
+
<CardDescription className="mt-1 text-sm">{product.description}</CardDescription>
|
|
505
531
|
</CardHeader>
|
|
506
|
-
<CardContent className="text-center pb-
|
|
507
|
-
<div className="mb-
|
|
532
|
+
<CardContent className="text-center pb-4 flex-1">
|
|
533
|
+
<div className="mb-4">
|
|
508
534
|
<div className="flex items-baseline justify-center">
|
|
509
|
-
<span className="text-
|
|
510
|
-
{parseFloat(price?.unit_amount) === 0 ? 'Free' : `$${parseFloat(price?.unit_amount).toFixed(
|
|
535
|
+
<span className="text-3xl font-bold tracking-tight">
|
|
536
|
+
{parseFloat(price?.unit_amount) === 0 ? 'Free' : `$${parseFloat(price?.unit_amount).toFixed(2).replace(/\.00$/, '')}`}
|
|
511
537
|
</span>
|
|
512
538
|
{parseFloat(price?.unit_amount) > 0 && (
|
|
513
|
-
<span className="ml-1 text-
|
|
539
|
+
<span className="ml-1 text-sm text-muted-foreground">/month</span>
|
|
514
540
|
)}
|
|
515
541
|
</div>
|
|
516
|
-
{isCurrentPlan
|
|
542
|
+
{isCurrentPlan ? (
|
|
517
543
|
<Badge variant="secondary" className="mt-2">Current Plan</Badge>
|
|
544
|
+
) : (
|
|
545
|
+
<div className="mt-2 h-6"></div>
|
|
518
546
|
)}
|
|
519
547
|
</div>
|
|
520
|
-
<div className="space-y-
|
|
548
|
+
<div className="space-y-3 flex-1 flex flex-col justify-between min-h-[300px]">
|
|
521
549
|
{features.map((feature: any) => (
|
|
522
|
-
<div key={feature.id} className="border-t pt-
|
|
523
|
-
<div className="text-
|
|
524
|
-
{feature.value} {feature.units}
|
|
525
|
-
</div>
|
|
526
|
-
<div className="text-sm text-muted-foreground mt-1">
|
|
527
|
-
{feature.description}
|
|
550
|
+
<div key={feature.id} className="border-t pt-3 first:border-t-0 first:pt-0 flex-1 flex flex-col justify-center">
|
|
551
|
+
<div className="text-lg font-semibold text-foreground">
|
|
552
|
+
{formatFeatureValue(feature.value)} <span className="text-sm text-gray-500 font-normal">{feature.units}</span>
|
|
528
553
|
</div>
|
|
554
|
+
<TooltipProvider>
|
|
555
|
+
<Tooltip>
|
|
556
|
+
<TooltipTrigger asChild>
|
|
557
|
+
<div className="text-xs text-gray-500 mt-1 cursor-help">
|
|
558
|
+
{feature.name || feature.description}
|
|
559
|
+
</div>
|
|
560
|
+
</TooltipTrigger>
|
|
561
|
+
<TooltipContent>
|
|
562
|
+
<p>{feature.description}</p>
|
|
563
|
+
</TooltipContent>
|
|
564
|
+
</Tooltip>
|
|
565
|
+
</TooltipProvider>
|
|
529
566
|
</div>
|
|
530
567
|
))}
|
|
531
568
|
</div>
|
|
532
569
|
</CardContent>
|
|
533
|
-
<CardFooter className="pt-
|
|
570
|
+
<CardFooter className="pt-3">
|
|
534
571
|
<Button
|
|
535
572
|
className="w-full"
|
|
536
573
|
variant={isCurrentPlan ? "outline" : product.metadata?.popular === 'true' ? "default" : "secondary"}
|
|
@@ -624,6 +661,26 @@ export default function BillingClient({ customerData, pricingData, paymentMethod
|
|
|
624
661
|
onSuccess={handleSubscriptionCreated}
|
|
625
662
|
createSubscription={handleCreateSubscription}
|
|
626
663
|
/>
|
|
664
|
+
|
|
665
|
+
<Dialog open={deletePaymentModalOpen} onOpenChange={setDeletePaymentModalOpen}>
|
|
666
|
+
<DialogContent>
|
|
667
|
+
<DialogHeader>
|
|
668
|
+
<DialogTitle>Delete Payment Method</DialogTitle>
|
|
669
|
+
<DialogDescription>
|
|
670
|
+
Are you sure you want to delete this payment method? This action cannot be undone.
|
|
671
|
+
</DialogDescription>
|
|
672
|
+
</DialogHeader>
|
|
673
|
+
<div className="flex justify-end space-x-2 pt-4">
|
|
674
|
+
<Button variant="outline" onClick={() => setDeletePaymentModalOpen(false)}>
|
|
675
|
+
Cancel
|
|
676
|
+
</Button>
|
|
677
|
+
<Button variant="destructive" onClick={confirmDeletePayment} disabled={isLoading}>
|
|
678
|
+
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
|
679
|
+
Delete
|
|
680
|
+
</Button>
|
|
681
|
+
</div>
|
|
682
|
+
</DialogContent>
|
|
683
|
+
</Dialog>
|
|
627
684
|
</>
|
|
628
685
|
);
|
|
629
686
|
}
|
|
@@ -6,6 +6,7 @@ import { Button } from './ui/button';
|
|
|
6
6
|
import { Card } from './ui/card';
|
|
7
7
|
import { Check, Loader2, CheckCircle, AlertCircle } from 'lucide-react';
|
|
8
8
|
import { Alert, AlertDescription } from './ui/alert';
|
|
9
|
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
|
|
9
10
|
import { RadioGroup, RadioGroupItem } from './ui/radio-group';
|
|
10
11
|
import { Label } from './ui/label';
|
|
11
12
|
import { CardBrandIcon } from './ui/card-brand-icon';
|
|
@@ -45,6 +46,22 @@ export default function SubscriptionConfirmModal({
|
|
|
45
46
|
}).format(amount / 100);
|
|
46
47
|
};
|
|
47
48
|
|
|
49
|
+
const formatFeatureValue = (value: any) => {
|
|
50
|
+
// Handle string boolean values
|
|
51
|
+
if (value === 'true' || value === true) {
|
|
52
|
+
return '✓'
|
|
53
|
+
}
|
|
54
|
+
if (value === 'false' || value === false) {
|
|
55
|
+
return '✗'
|
|
56
|
+
}
|
|
57
|
+
// Handle numeric values (both string and number)
|
|
58
|
+
const numValue = typeof value === 'string' ? parseFloat(value) : value
|
|
59
|
+
if (!isNaN(numValue) && numValue >= 1000) {
|
|
60
|
+
return new Intl.NumberFormat('en-US').format(numValue)
|
|
61
|
+
}
|
|
62
|
+
return value
|
|
63
|
+
};
|
|
64
|
+
|
|
48
65
|
const handleConfirm = async () => {
|
|
49
66
|
setIsSubscribing(true);
|
|
50
67
|
setError(null);
|
|
@@ -120,7 +137,18 @@ export default function SubscriptionConfirmModal({
|
|
|
120
137
|
{plan.features?.map((feature: any) => (
|
|
121
138
|
<li key={feature.id} className="flex items-start text-sm">
|
|
122
139
|
<Check className="h-4 w-4 mr-2 text-primary mt-0.5 flex-shrink-0" />
|
|
123
|
-
<
|
|
140
|
+
<TooltipProvider>
|
|
141
|
+
<Tooltip>
|
|
142
|
+
<TooltipTrigger asChild>
|
|
143
|
+
<span className="cursor-help">
|
|
144
|
+
{formatFeatureValue(feature.value)} <span className="text-sm text-muted-foreground font-normal">{feature.units}</span> - {feature.name || feature.description}
|
|
145
|
+
</span>
|
|
146
|
+
</TooltipTrigger>
|
|
147
|
+
<TooltipContent>
|
|
148
|
+
<p>{feature.description}</p>
|
|
149
|
+
</TooltipContent>
|
|
150
|
+
</Tooltip>
|
|
151
|
+
</TooltipProvider>
|
|
124
152
|
</li>
|
|
125
153
|
))}
|
|
126
154
|
</ul>
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from "react"
|
|
4
|
+
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
|
5
|
+
|
|
6
|
+
import { cn } from "../../lib/utils"
|
|
7
|
+
|
|
8
|
+
const TooltipProvider = TooltipPrimitive.Provider
|
|
9
|
+
|
|
10
|
+
const Tooltip = TooltipPrimitive.Root
|
|
11
|
+
|
|
12
|
+
const TooltipTrigger = TooltipPrimitive.Trigger
|
|
13
|
+
|
|
14
|
+
const TooltipContent = React.forwardRef<
|
|
15
|
+
React.ElementRef<typeof TooltipPrimitive.Content>,
|
|
16
|
+
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
|
17
|
+
>(({ className, sideOffset = 4, ...props }, ref) => (
|
|
18
|
+
<TooltipPrimitive.Content
|
|
19
|
+
ref={ref}
|
|
20
|
+
sideOffset={sideOffset}
|
|
21
|
+
className={cn(
|
|
22
|
+
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
|
23
|
+
className
|
|
24
|
+
)}
|
|
25
|
+
{...props}
|
|
26
|
+
/>
|
|
27
|
+
))
|
|
28
|
+
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
|
29
|
+
|
|
30
|
+
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
package/styles.css
ADDED