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.
Files changed (44) hide show
  1. package/README.md +1 -1
  2. package/dist/commands/add.d.ts.map +1 -1
  3. package/dist/commands/add.js +35 -14
  4. package/dist/commands/add.js.map +1 -1
  5. package/dist/commands/init.d.ts.map +1 -1
  6. package/dist/commands/init.js +0 -5
  7. package/dist/commands/init.js.map +1 -1
  8. package/docs/commands.md +2 -2
  9. package/docs/quickstart.md +1 -1
  10. package/package.json +1 -1
  11. package/recipes/lemonsqueezy-supabase/apps/web/app/billing/page.tsx +48 -0
  12. package/recipes/lemonsqueezy-supabase/apps/web/app/pricing/page.tsx +19 -0
  13. package/recipes/lemonsqueezy-supabase/apps/web/components/payments/BillingSection.tsx +224 -0
  14. package/recipes/lemonsqueezy-supabase/apps/web/components/payments/PricingCard.tsx +87 -0
  15. package/recipes/lemonsqueezy-supabase/apps/web/components/payments/PricingSection.tsx +100 -0
  16. package/recipes/lemonsqueezy-supabase/apps/web/components/payments/index.ts +2 -0
  17. package/recipes/lemonsqueezy-supabase/apps/web/lib/plans.ts +84 -0
  18. package/recipes/lemonsqueezy-supabase/packages/backend/supabase/functions/payments/config.ts +92 -0
  19. package/recipes/lemonsqueezy-supabase/packages/backend/supabase/functions/payments/index.ts +138 -0
  20. package/recipes/lemonsqueezy-supabase/packages/backend/supabase/functions/payments/providers/lemonsqueezy.ts +165 -0
  21. package/recipes/lemonsqueezy-supabase/packages/backend/supabase/functions/payments/providers/stripe.ts +179 -0
  22. package/recipes/lemonsqueezy-supabase/packages/backend/supabase/functions/payments/types.ts +57 -0
  23. package/recipes/lemonsqueezy-supabase/packages/backend/supabase/functions/payments-webhook/index.ts +339 -0
  24. package/recipes/lemonsqueezy-supabase/packages/backend/supabase/migrations/web_payments.sql +85 -0
  25. package/recipes/lemonsqueezy-supabase/recipe.json +129 -0
  26. package/recipes/lemonsqueezy-supabase-web@latest.zip +0 -0
  27. package/recipes/lemonsqueezy-web@latest.zip +0 -0
  28. package/recipes/stripe-supabase/apps/web/app/billing/page.tsx +48 -0
  29. package/recipes/stripe-supabase/apps/web/app/pricing/page.tsx +19 -0
  30. package/recipes/stripe-supabase/apps/web/components/payments/BillingSection.tsx +224 -0
  31. package/recipes/stripe-supabase/apps/web/components/payments/PricingCard.tsx +87 -0
  32. package/recipes/stripe-supabase/apps/web/components/payments/PricingSection.tsx +100 -0
  33. package/recipes/stripe-supabase/apps/web/components/payments/index.ts +2 -0
  34. package/recipes/stripe-supabase/apps/web/lib/plans.ts +84 -0
  35. package/recipes/stripe-supabase/packages/backend/supabase/functions/payments/config.ts +92 -0
  36. package/recipes/stripe-supabase/packages/backend/supabase/functions/payments/index.ts +138 -0
  37. package/recipes/stripe-supabase/packages/backend/supabase/functions/payments/providers/lemonsqueezy.ts +165 -0
  38. package/recipes/stripe-supabase/packages/backend/supabase/functions/payments/providers/stripe.ts +179 -0
  39. package/recipes/stripe-supabase/packages/backend/supabase/functions/payments/types.ts +57 -0
  40. package/recipes/stripe-supabase/packages/backend/supabase/functions/payments-webhook/index.ts +339 -0
  41. package/recipes/stripe-supabase/packages/backend/supabase/migrations/web_payments.sql +85 -0
  42. package/recipes/stripe-supabase/recipe.json +123 -0
  43. package/recipes/stripe-supabase-web@latest.zip +0 -0
  44. 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&apos;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,2 @@
1
+ export { PricingSection } from './PricingSection';
2
+ export { BillingSection } from './BillingSection';
@@ -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
+ };