vibefast-cli 1.3.4 → 1.3.5
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 +1 -1
- package/dist/commands/add.d.ts.map +1 -1
- package/dist/commands/add.js +35 -14
- package/dist/commands/add.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +0 -5
- package/dist/commands/init.js.map +1 -1
- package/docs/commands.md +2 -2
- package/docs/quickstart.md +1 -1
- package/package.json +1 -1
- package/recipes/lemonsqueezy-supabase/apps/web/app/billing/page.tsx +48 -0
- package/recipes/lemonsqueezy-supabase/apps/web/app/pricing/page.tsx +19 -0
- package/recipes/lemonsqueezy-supabase/apps/web/components/payments/BillingSection.tsx +224 -0
- package/recipes/lemonsqueezy-supabase/apps/web/components/payments/PricingCard.tsx +87 -0
- package/recipes/lemonsqueezy-supabase/apps/web/components/payments/PricingSection.tsx +100 -0
- package/recipes/lemonsqueezy-supabase/apps/web/components/payments/index.ts +2 -0
- package/recipes/lemonsqueezy-supabase/apps/web/lib/plans.ts +84 -0
- package/recipes/lemonsqueezy-supabase/packages/backend/supabase/functions/payments/config.ts +92 -0
- package/recipes/lemonsqueezy-supabase/packages/backend/supabase/functions/payments/index.ts +138 -0
- package/recipes/lemonsqueezy-supabase/packages/backend/supabase/functions/payments/providers/lemonsqueezy.ts +165 -0
- package/recipes/lemonsqueezy-supabase/packages/backend/supabase/functions/payments/providers/stripe.ts +179 -0
- package/recipes/lemonsqueezy-supabase/packages/backend/supabase/functions/payments/types.ts +57 -0
- package/recipes/lemonsqueezy-supabase/packages/backend/supabase/functions/payments-webhook/index.ts +339 -0
- package/recipes/lemonsqueezy-supabase/packages/backend/supabase/migrations/web_payments.sql +85 -0
- package/recipes/lemonsqueezy-supabase/recipe.json +129 -0
- package/recipes/lemonsqueezy-supabase-web@latest.zip +0 -0
- package/recipes/lemonsqueezy-web@latest.zip +0 -0
- package/recipes/stripe-supabase/apps/web/app/billing/page.tsx +48 -0
- package/recipes/stripe-supabase/apps/web/app/pricing/page.tsx +19 -0
- package/recipes/stripe-supabase/apps/web/components/payments/BillingSection.tsx +224 -0
- package/recipes/stripe-supabase/apps/web/components/payments/PricingCard.tsx +87 -0
- package/recipes/stripe-supabase/apps/web/components/payments/PricingSection.tsx +100 -0
- package/recipes/stripe-supabase/apps/web/components/payments/index.ts +2 -0
- package/recipes/stripe-supabase/apps/web/lib/plans.ts +84 -0
- package/recipes/stripe-supabase/packages/backend/supabase/functions/payments/config.ts +92 -0
- package/recipes/stripe-supabase/packages/backend/supabase/functions/payments/index.ts +138 -0
- package/recipes/stripe-supabase/packages/backend/supabase/functions/payments/providers/lemonsqueezy.ts +165 -0
- package/recipes/stripe-supabase/packages/backend/supabase/functions/payments/providers/stripe.ts +179 -0
- package/recipes/stripe-supabase/packages/backend/supabase/functions/payments/types.ts +57 -0
- package/recipes/stripe-supabase/packages/backend/supabase/functions/payments-webhook/index.ts +339 -0
- package/recipes/stripe-supabase/packages/backend/supabase/migrations/web_payments.sql +85 -0
- package/recipes/stripe-supabase/recipe.json +123 -0
- package/recipes/stripe-supabase-web@latest.zip +0 -0
- package/recipes/stripe-web@latest.zip +0 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import { Button } from '@/components/ui/button';
|
|
5
|
+
import { CreditCard, ExternalLink, Settings, AlertCircle } from 'lucide-react';
|
|
6
|
+
import { useAuth, useSupabaseClient } from '@/lib/supabase-provider';
|
|
7
|
+
|
|
8
|
+
type Subscription = {
|
|
9
|
+
id: string;
|
|
10
|
+
provider: string;
|
|
11
|
+
status: string;
|
|
12
|
+
planId?: string;
|
|
13
|
+
priceId?: string;
|
|
14
|
+
currentPeriodEnd?: number;
|
|
15
|
+
cancelAt?: number;
|
|
16
|
+
cancelAtPeriodEnd?: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// ============================================================================
|
|
20
|
+
// Billing Section Component (Supabase)
|
|
21
|
+
// Displays user's subscription status and billing management options
|
|
22
|
+
// ============================================================================
|
|
23
|
+
|
|
24
|
+
export function BillingSection() {
|
|
25
|
+
const { isAuthenticated, isLoading } = useAuth();
|
|
26
|
+
const supabase = useSupabaseClient();
|
|
27
|
+
const [subscription, setSubscription] = useState<Subscription | null>(null);
|
|
28
|
+
const [loading, setLoading] = useState(false);
|
|
29
|
+
const [error, setError] = useState<string | null>(null);
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (!isAuthenticated || isLoading) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let cancelled = false;
|
|
37
|
+
|
|
38
|
+
const loadSubscription = async () => {
|
|
39
|
+
try {
|
|
40
|
+
const { data, error: invokeError } = await supabase.functions.invoke(
|
|
41
|
+
'payments',
|
|
42
|
+
{ body: { action: 'subscription' } },
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
if (invokeError) {
|
|
46
|
+
throw invokeError;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (!data?.ok) {
|
|
50
|
+
throw new Error(data?.message || 'Failed to load subscription');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!cancelled) {
|
|
54
|
+
setSubscription(data.subscription ?? null);
|
|
55
|
+
}
|
|
56
|
+
} catch (err) {
|
|
57
|
+
console.error('Subscription error:', err);
|
|
58
|
+
if (!cancelled) {
|
|
59
|
+
setError(
|
|
60
|
+
err instanceof Error ? err.message : 'Failed to load subscription',
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
loadSubscription();
|
|
67
|
+
|
|
68
|
+
return () => {
|
|
69
|
+
cancelled = true;
|
|
70
|
+
};
|
|
71
|
+
}, [isAuthenticated, isLoading, supabase]);
|
|
72
|
+
|
|
73
|
+
const handleManageBilling = async () => {
|
|
74
|
+
setLoading(true);
|
|
75
|
+
setError(null);
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const { data, error: invokeError } = await supabase.functions.invoke(
|
|
79
|
+
'payments',
|
|
80
|
+
{
|
|
81
|
+
body: {
|
|
82
|
+
action: 'portal',
|
|
83
|
+
returnUrl: window.location.href,
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
if (invokeError) {
|
|
89
|
+
throw invokeError;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (!data?.ok || !data.url) {
|
|
93
|
+
throw new Error(data?.message || 'No portal URL returned');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
window.location.href = data.url;
|
|
97
|
+
} catch (err) {
|
|
98
|
+
console.error('Portal error:', err);
|
|
99
|
+
setError(
|
|
100
|
+
err instanceof Error ? err.message : 'Failed to open billing portal',
|
|
101
|
+
);
|
|
102
|
+
} finally {
|
|
103
|
+
setLoading(false);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const cancelAt = typeof subscription?.cancelAt === 'number'
|
|
108
|
+
? subscription.cancelAt
|
|
109
|
+
: null;
|
|
110
|
+
const hasActiveSubscription =
|
|
111
|
+
subscription?.status === 'active' || subscription?.status === 'trialing';
|
|
112
|
+
const isCanceling = Boolean(subscription?.cancelAtPeriodEnd || cancelAt);
|
|
113
|
+
|
|
114
|
+
const formatDate = (timestamp?: number) => {
|
|
115
|
+
if (!timestamp) return 'N/A';
|
|
116
|
+
return new Date(timestamp * 1000).toLocaleDateString(undefined, {
|
|
117
|
+
year: 'numeric',
|
|
118
|
+
month: 'long',
|
|
119
|
+
day: 'numeric',
|
|
120
|
+
});
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<div className="space-y-6">
|
|
125
|
+
<div className="rounded-lg border bg-card p-6">
|
|
126
|
+
<div className="flex items-center justify-between">
|
|
127
|
+
<div className="flex items-center gap-3">
|
|
128
|
+
<CreditCard className="h-5 w-5 text-muted-foreground" />
|
|
129
|
+
<h3 className="font-semibold">Subscription</h3>
|
|
130
|
+
</div>
|
|
131
|
+
<span
|
|
132
|
+
className={`rounded-full px-2.5 py-0.5 text-xs font-medium ${
|
|
133
|
+
hasActiveSubscription
|
|
134
|
+
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300'
|
|
135
|
+
: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300'
|
|
136
|
+
}`}
|
|
137
|
+
>
|
|
138
|
+
{hasActiveSubscription
|
|
139
|
+
? subscription?.status === 'trialing'
|
|
140
|
+
? 'Trial'
|
|
141
|
+
: isCanceling
|
|
142
|
+
? 'Canceling'
|
|
143
|
+
: 'Active'
|
|
144
|
+
: 'No Plan'}
|
|
145
|
+
</span>
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
{subscription && hasActiveSubscription && (
|
|
149
|
+
<div className="mt-4 rounded-md bg-muted p-4">
|
|
150
|
+
<div className="grid gap-2 text-sm">
|
|
151
|
+
<div className="flex justify-between">
|
|
152
|
+
<span className="text-muted-foreground">Plan</span>
|
|
153
|
+
<span className="font-medium">
|
|
154
|
+
{subscription.planId || 'Custom Plan'}
|
|
155
|
+
</span>
|
|
156
|
+
</div>
|
|
157
|
+
<div className="flex justify-between">
|
|
158
|
+
<span className="text-muted-foreground">
|
|
159
|
+
{isCanceling ? 'Ends' : 'Renews'}
|
|
160
|
+
</span>
|
|
161
|
+
<span className="font-medium">
|
|
162
|
+
{formatDate(
|
|
163
|
+
isCanceling && cancelAt
|
|
164
|
+
? cancelAt
|
|
165
|
+
: subscription.currentPeriodEnd,
|
|
166
|
+
)}
|
|
167
|
+
</span>
|
|
168
|
+
</div>
|
|
169
|
+
{isCanceling && (
|
|
170
|
+
<div className="mt-2 flex items-center gap-2 text-amber-600 dark:text-amber-400">
|
|
171
|
+
<AlertCircle className="h-4 w-4" />
|
|
172
|
+
<span className="text-xs">Scheduled to cancel</span>
|
|
173
|
+
</div>
|
|
174
|
+
)}
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
)}
|
|
178
|
+
|
|
179
|
+
{!hasActiveSubscription && (
|
|
180
|
+
<p className="mt-4 text-sm text-muted-foreground">
|
|
181
|
+
You don't have an active subscription. Choose a plan to get
|
|
182
|
+
started.
|
|
183
|
+
</p>
|
|
184
|
+
)}
|
|
185
|
+
</div>
|
|
186
|
+
|
|
187
|
+
{error && (
|
|
188
|
+
<div className="rounded-lg border border-destructive bg-destructive/10 p-4 text-sm text-destructive">
|
|
189
|
+
{error}
|
|
190
|
+
</div>
|
|
191
|
+
)}
|
|
192
|
+
|
|
193
|
+
<div className="flex flex-col gap-3 sm:flex-row">
|
|
194
|
+
{hasActiveSubscription && (
|
|
195
|
+
<Button
|
|
196
|
+
onClick={handleManageBilling}
|
|
197
|
+
disabled={loading}
|
|
198
|
+
variant="outline"
|
|
199
|
+
className="flex-1"
|
|
200
|
+
>
|
|
201
|
+
{loading ? (
|
|
202
|
+
<span className="flex items-center gap-2">
|
|
203
|
+
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
|
204
|
+
Opening...
|
|
205
|
+
</span>
|
|
206
|
+
) : (
|
|
207
|
+
<>
|
|
208
|
+
<Settings className="mr-2 h-4 w-4" />
|
|
209
|
+
Manage Billing
|
|
210
|
+
</>
|
|
211
|
+
)}
|
|
212
|
+
</Button>
|
|
213
|
+
)}
|
|
214
|
+
|
|
215
|
+
<Button variant={hasActiveSubscription ? 'outline' : 'default'} asChild>
|
|
216
|
+
<a href="/pricing">
|
|
217
|
+
<ExternalLink className="mr-2 h-4 w-4" />
|
|
218
|
+
{hasActiveSubscription ? 'Change Plan' : 'View Plans'}
|
|
219
|
+
</a>
|
|
220
|
+
</Button>
|
|
221
|
+
</div>
|
|
222
|
+
</div>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Button } from '@/components/ui/button';
|
|
4
|
+
import { Check } from 'lucide-react';
|
|
5
|
+
import { cn } from '@/lib/utils';
|
|
6
|
+
|
|
7
|
+
// ============================================================================
|
|
8
|
+
// Pricing Card Component
|
|
9
|
+
// Reusable card for displaying a single pricing plan
|
|
10
|
+
// ============================================================================
|
|
11
|
+
|
|
12
|
+
interface PricingCardProps {
|
|
13
|
+
name: string;
|
|
14
|
+
price: string;
|
|
15
|
+
description?: string;
|
|
16
|
+
features: string[];
|
|
17
|
+
popular?: boolean;
|
|
18
|
+
onSelect: () => void;
|
|
19
|
+
loading?: boolean;
|
|
20
|
+
buttonText?: string;
|
|
21
|
+
disabled?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function PricingCard({
|
|
25
|
+
name,
|
|
26
|
+
price,
|
|
27
|
+
description,
|
|
28
|
+
features,
|
|
29
|
+
popular,
|
|
30
|
+
onSelect,
|
|
31
|
+
loading,
|
|
32
|
+
buttonText = 'Get Started',
|
|
33
|
+
disabled,
|
|
34
|
+
}: PricingCardProps) {
|
|
35
|
+
return (
|
|
36
|
+
<div
|
|
37
|
+
className={cn(
|
|
38
|
+
'relative flex flex-col rounded-2xl border bg-card p-6 shadow-sm transition-all',
|
|
39
|
+
popular && 'border-primary shadow-lg ring-1 ring-primary',
|
|
40
|
+
'hover:shadow-md',
|
|
41
|
+
)}
|
|
42
|
+
>
|
|
43
|
+
{popular && (
|
|
44
|
+
<div className="absolute -top-3 left-1/2 -translate-x-1/2 rounded-full bg-primary px-3 py-1 text-xs font-semibold text-primary-foreground">
|
|
45
|
+
Most Popular
|
|
46
|
+
</div>
|
|
47
|
+
)}
|
|
48
|
+
|
|
49
|
+
<div className="mb-4">
|
|
50
|
+
<h3 className="text-lg font-semibold">{name}</h3>
|
|
51
|
+
{description && (
|
|
52
|
+
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
|
|
53
|
+
)}
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<div className="mb-6">
|
|
57
|
+
<span className="text-3xl font-bold">{price}</span>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<ul className="mb-6 flex-1 space-y-3">
|
|
61
|
+
{features.map((feature, index) => (
|
|
62
|
+
<li key={index} className="flex items-start gap-2 text-sm">
|
|
63
|
+
<Check className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
|
|
64
|
+
<span>{feature}</span>
|
|
65
|
+
</li>
|
|
66
|
+
))}
|
|
67
|
+
</ul>
|
|
68
|
+
|
|
69
|
+
<Button
|
|
70
|
+
onClick={onSelect}
|
|
71
|
+
disabled={loading || disabled}
|
|
72
|
+
className="w-full"
|
|
73
|
+
variant={popular ? 'default' : 'outline'}
|
|
74
|
+
size="lg"
|
|
75
|
+
>
|
|
76
|
+
{loading ? (
|
|
77
|
+
<span className="flex items-center gap-2">
|
|
78
|
+
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
|
79
|
+
Processing...
|
|
80
|
+
</span>
|
|
81
|
+
) : (
|
|
82
|
+
buttonText
|
|
83
|
+
)}
|
|
84
|
+
</Button>
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { PricingCard } from './PricingCard';
|
|
5
|
+
import { PLANS, type Plan } from '@/lib/plans';
|
|
6
|
+
import { useAuth, useSupabaseClient } from '@/lib/supabase-provider';
|
|
7
|
+
|
|
8
|
+
// ============================================================================
|
|
9
|
+
// Pricing Section Component (Supabase)
|
|
10
|
+
// Displays all available pricing plans with checkout functionality
|
|
11
|
+
// ============================================================================
|
|
12
|
+
|
|
13
|
+
interface PricingSectionProps {
|
|
14
|
+
plans?: Plan[];
|
|
15
|
+
title?: string;
|
|
16
|
+
subtitle?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function PricingSection({
|
|
20
|
+
plans = PLANS,
|
|
21
|
+
title = 'Simple, Transparent Pricing',
|
|
22
|
+
subtitle = 'Choose the plan that fits your needs. Cancel anytime.',
|
|
23
|
+
}: PricingSectionProps) {
|
|
24
|
+
const { isAuthenticated } = useAuth();
|
|
25
|
+
const supabase = useSupabaseClient();
|
|
26
|
+
const [loadingPlan, setLoadingPlan] = useState<string | null>(null);
|
|
27
|
+
const [error, setError] = useState<string | null>(null);
|
|
28
|
+
|
|
29
|
+
const handleSelectPlan = async (plan: (typeof PLANS)[0]) => {
|
|
30
|
+
if (!isAuthenticated) {
|
|
31
|
+
setError('Please sign in to continue.');
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
setLoadingPlan(plan.id);
|
|
36
|
+
setError(null);
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const { data, error: invokeError } = await supabase.functions.invoke(
|
|
40
|
+
'payments',
|
|
41
|
+
{
|
|
42
|
+
body: {
|
|
43
|
+
action: 'checkout',
|
|
44
|
+
priceId: plan.priceId,
|
|
45
|
+
successUrl: `${window.location.origin}/billing?success=true`,
|
|
46
|
+
cancelUrl: `${window.location.origin}/pricing?canceled=true`,
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
if (invokeError) {
|
|
52
|
+
throw invokeError;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!data?.ok || !data.url) {
|
|
56
|
+
throw new Error(data?.message || 'No checkout URL returned');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
window.location.href = data.url;
|
|
60
|
+
} catch (err) {
|
|
61
|
+
console.error('Checkout error:', err);
|
|
62
|
+
setError(
|
|
63
|
+
err instanceof Error ? err.message : 'Failed to start checkout',
|
|
64
|
+
);
|
|
65
|
+
} finally {
|
|
66
|
+
setLoadingPlan(null);
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<section className="py-16">
|
|
72
|
+
<div className="text-center">
|
|
73
|
+
<h2 className="text-3xl font-bold tracking-tight">{title}</h2>
|
|
74
|
+
<p className="mt-2 text-muted-foreground">{subtitle}</p>
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
{error && (
|
|
78
|
+
<div className="mx-auto mt-6 max-w-md rounded-lg border border-destructive bg-destructive/10 p-4 text-center text-sm text-destructive">
|
|
79
|
+
{error}
|
|
80
|
+
</div>
|
|
81
|
+
)}
|
|
82
|
+
|
|
83
|
+
<div className="mt-10 grid gap-6 md:grid-cols-3">
|
|
84
|
+
{plans.map((plan) => (
|
|
85
|
+
<PricingCard
|
|
86
|
+
key={plan.id}
|
|
87
|
+
name={plan.name}
|
|
88
|
+
description={plan.description}
|
|
89
|
+
price={plan.price}
|
|
90
|
+
features={plan.features}
|
|
91
|
+
popular={plan.popular}
|
|
92
|
+
loading={loadingPlan === plan.id}
|
|
93
|
+
disabled={loadingPlan !== null && loadingPlan !== plan.id}
|
|
94
|
+
onSelect={() => handleSelectPlan(plan)}
|
|
95
|
+
/>
|
|
96
|
+
))}
|
|
97
|
+
</div>
|
|
98
|
+
</section>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Shared Plans Configuration (Frontend)
|
|
3
|
+
// Single source of truth for pricing plans in the web app
|
|
4
|
+
// ============================================================================
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Plan definition for pricing display
|
|
8
|
+
*/
|
|
9
|
+
export interface Plan {
|
|
10
|
+
id: string;
|
|
11
|
+
name: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
price: string;
|
|
14
|
+
priceId: string;
|
|
15
|
+
features: string[];
|
|
16
|
+
popular?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Pricing plans configuration
|
|
21
|
+
* Update these with your actual pricing plans from Stripe/LemonSqueezy
|
|
22
|
+
*
|
|
23
|
+
* Note: Uses NEXT_PUBLIC_ prefixed env vars for client-side access
|
|
24
|
+
* The backend config.ts uses non-prefixed versions for server-side
|
|
25
|
+
*/
|
|
26
|
+
export const PLANS: Plan[] = [
|
|
27
|
+
{
|
|
28
|
+
id: 'starter',
|
|
29
|
+
name: 'Starter',
|
|
30
|
+
description: 'Perfect for individuals getting started',
|
|
31
|
+
price: '$9/mo',
|
|
32
|
+
priceId: process.env.NEXT_PUBLIC_PLAN_STARTER_PRICE_ID ?? 'price_starter',
|
|
33
|
+
features: [
|
|
34
|
+
'100 AI credits per month',
|
|
35
|
+
'Basic support',
|
|
36
|
+
'Single project',
|
|
37
|
+
'Community access',
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: 'pro',
|
|
42
|
+
name: 'Pro',
|
|
43
|
+
description: 'Best for professionals and growing teams',
|
|
44
|
+
price: '$29/mo',
|
|
45
|
+
priceId: process.env.NEXT_PUBLIC_PLAN_PRO_PRICE_ID ?? 'price_pro',
|
|
46
|
+
features: [
|
|
47
|
+
'Unlimited AI credits',
|
|
48
|
+
'Priority support',
|
|
49
|
+
'Unlimited projects',
|
|
50
|
+
'API access',
|
|
51
|
+
'Advanced analytics',
|
|
52
|
+
],
|
|
53
|
+
popular: true,
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: 'enterprise',
|
|
57
|
+
name: 'Enterprise',
|
|
58
|
+
description: 'For large teams with custom needs',
|
|
59
|
+
price: '$99/mo',
|
|
60
|
+
priceId:
|
|
61
|
+
process.env.NEXT_PUBLIC_PLAN_ENTERPRISE_PRICE_ID ?? 'price_enterprise',
|
|
62
|
+
features: [
|
|
63
|
+
'Everything in Pro',
|
|
64
|
+
'Dedicated support',
|
|
65
|
+
'Custom integrations',
|
|
66
|
+
'SLA guarantee',
|
|
67
|
+
'SSO & advanced security',
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Get a plan by its ID
|
|
74
|
+
*/
|
|
75
|
+
export const getPlanById = (planId: string): Plan | undefined => {
|
|
76
|
+
return PLANS.find((plan) => plan.id === planId);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Get a plan by its provider-specific price ID
|
|
81
|
+
*/
|
|
82
|
+
export const getPlanByPriceId = (priceId: string): Plan | undefined => {
|
|
83
|
+
return PLANS.find((plan) => plan.priceId === priceId);
|
|
84
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import type { PaymentProvider, Plan } from './types';
|
|
2
|
+
|
|
3
|
+
export const getPaymentProvider = (): PaymentProvider => {
|
|
4
|
+
const provider = (Deno.env.get('PAYMENT_PROVIDER') ||
|
|
5
|
+
'') as PaymentProvider;
|
|
6
|
+
if (!provider) {
|
|
7
|
+
throw new Error(
|
|
8
|
+
'PAYMENT_PROVIDER environment variable must be set to "stripe" or "lemonsqueezy"',
|
|
9
|
+
);
|
|
10
|
+
}
|
|
11
|
+
if (provider !== 'stripe' && provider !== 'lemonsqueezy') {
|
|
12
|
+
throw new Error(
|
|
13
|
+
`Invalid PAYMENT_PROVIDER: "${provider}". Must be "stripe" or "lemonsqueezy"`,
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
return provider;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const getSiteUrl = (): string => {
|
|
20
|
+
const url = Deno.env.get('SITE_URL');
|
|
21
|
+
if (!url) {
|
|
22
|
+
throw new Error('SITE_URL environment variable must be set');
|
|
23
|
+
}
|
|
24
|
+
return url.replace(/\/$/, '');
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const getCheckoutUrls = (
|
|
28
|
+
customSuccess?: string,
|
|
29
|
+
customCancel?: string,
|
|
30
|
+
) => {
|
|
31
|
+
const siteUrl = getSiteUrl();
|
|
32
|
+
return {
|
|
33
|
+
successUrl: customSuccess ?? `${siteUrl}/billing?success=true`,
|
|
34
|
+
cancelUrl: customCancel ?? `${siteUrl}/pricing?canceled=true`,
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const PLANS: Plan[] = [
|
|
39
|
+
{
|
|
40
|
+
id: 'starter',
|
|
41
|
+
name: 'Starter',
|
|
42
|
+
description: 'Perfect for individuals getting started',
|
|
43
|
+
price: '$9/mo',
|
|
44
|
+
priceId: Deno.env.get('PLAN_STARTER_PRICE_ID') ?? 'price_starter',
|
|
45
|
+
interval: 'month',
|
|
46
|
+
features: [
|
|
47
|
+
'100 AI credits per month',
|
|
48
|
+
'Basic support',
|
|
49
|
+
'Single project',
|
|
50
|
+
'Community access',
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: 'pro',
|
|
55
|
+
name: 'Pro',
|
|
56
|
+
description: 'Best for professionals and growing teams',
|
|
57
|
+
price: '$29/mo',
|
|
58
|
+
priceId: Deno.env.get('PLAN_PRO_PRICE_ID') ?? 'price_pro',
|
|
59
|
+
interval: 'month',
|
|
60
|
+
features: [
|
|
61
|
+
'Unlimited AI credits',
|
|
62
|
+
'Priority support',
|
|
63
|
+
'Unlimited projects',
|
|
64
|
+
'API access',
|
|
65
|
+
'Advanced analytics',
|
|
66
|
+
],
|
|
67
|
+
popular: true,
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
id: 'enterprise',
|
|
71
|
+
name: 'Enterprise',
|
|
72
|
+
description: 'For large teams with custom needs',
|
|
73
|
+
price: '$99/mo',
|
|
74
|
+
priceId: Deno.env.get('PLAN_ENTERPRISE_PRICE_ID') ?? 'price_enterprise',
|
|
75
|
+
interval: 'month',
|
|
76
|
+
features: [
|
|
77
|
+
'Everything in Pro',
|
|
78
|
+
'Dedicated support',
|
|
79
|
+
'Custom integrations',
|
|
80
|
+
'SLA guarantee',
|
|
81
|
+
'SSO & advanced security',
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
export const getPlanById = (planId: string): Plan | undefined => {
|
|
87
|
+
return PLANS.find((plan) => plan.id === planId);
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const getPlanByPriceId = (priceId: string): Plan | undefined => {
|
|
91
|
+
return PLANS.find((plan) => plan.priceId === priceId);
|
|
92
|
+
};
|