Skip to content

Commit 64a237c

Browse files
bidahclaude
andcommitted
feat: add subscription page route
Add missing /subscription page so it's accessible on production. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 56093be commit 64a237c

2 files changed

Lines changed: 309 additions & 0 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { getServerSession } from '@/lib/auth/index'
2+
import { SubscriptionPageClient } from './subscription-page-client'
3+
4+
export default async function SubscriptionPage() {
5+
const session = await getServerSession()
6+
7+
return <SubscriptionPageClient session={session} />
8+
}
Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
'use client'
2+
3+
import { useState, useEffect } from 'react'
4+
import { Button } from '@/components/ui/button'
5+
import { Check, Crown, Loader2, Settings } from 'lucide-react'
6+
import { authClient } from '@/lib/auth/client'
7+
import { useToast } from '@/components/ui/use-toast'
8+
import { cn } from '@/lib/utils'
9+
import { Badge } from '@/components/ui/badge'
10+
import { getSubscriptionStatus } from '@/app/(app)/actions/subscription'
11+
import { AuthDialog } from '@/components/auth-dialog'
12+
import { NavHeader } from '@/components/nav-header'
13+
import { signOut } from '@/lib/auth/client'
14+
import type { Session } from '@/lib/auth'
15+
16+
interface Plan {
17+
name: string
18+
price: number
19+
originalPrice: number
20+
period: string
21+
features: string[]
22+
productId?: string
23+
popular?: boolean
24+
}
25+
26+
const plans: Plan[] = [
27+
{
28+
name: 'Start',
29+
price: 9.99,
30+
originalPrice: 20,
31+
period: 'mo',
32+
features: [
33+
'100 messages monthly',
34+
'Private projects',
35+
'Code editor',
36+
'History restore',
37+
'Email support',
38+
],
39+
productId: process.env.NEXT_PUBLIC_POLAR_START_PRODUCT_ID,
40+
},
41+
{
42+
name: 'Pro',
43+
price: 19.99,
44+
originalPrice: 45,
45+
period: 'mo',
46+
features: [
47+
'250 messages monthly',
48+
'Private projects',
49+
'Code editor',
50+
'History restore',
51+
'Chat support',
52+
],
53+
productId: process.env.NEXT_PUBLIC_POLAR_PRO_PRODUCT_ID,
54+
popular: true,
55+
},
56+
{
57+
name: 'Senior',
58+
price: 49.99,
59+
originalPrice: 90,
60+
period: 'mo',
61+
features: [
62+
'500 messages monthly',
63+
'Private projects',
64+
'Code editor',
65+
'History restore',
66+
'Chat support',
67+
],
68+
productId: process.env.NEXT_PUBLIC_POLAR_SENIOR_PRODUCT_ID,
69+
},
70+
]
71+
72+
interface SubscriptionPageClientProps {
73+
session: Session | null
74+
}
75+
76+
export function SubscriptionPageClient({ session }: SubscriptionPageClientProps) {
77+
const [loadingPlan, setLoadingPlan] = useState<string | null>(null)
78+
const [subscriptionStatus, setSubscriptionStatus] = useState<any>(null)
79+
const [isLoadingStatus, setIsLoadingStatus] = useState(true)
80+
const [isAuthDialogOpen, setAuthDialogOpen] = useState(false)
81+
const { toast } = useToast()
82+
83+
useEffect(() => {
84+
if (session) {
85+
getSubscriptionStatus()
86+
.then((data) => setSubscriptionStatus(data))
87+
.catch((error) => {
88+
console.error('[Subscription Page] Failed to fetch subscription status:', error)
89+
})
90+
.finally(() => setIsLoadingStatus(false))
91+
} else {
92+
setIsLoadingStatus(false)
93+
}
94+
}, [session])
95+
96+
const handleSubscribe = async (plan: Plan) => {
97+
if (!session) {
98+
setAuthDialogOpen(true)
99+
return
100+
}
101+
102+
if (!plan.productId) {
103+
toast({
104+
title: 'Configuration Error',
105+
description: `Product ID for ${plan.name} plan is not configured. Please contact support.`,
106+
variant: 'destructive',
107+
})
108+
return
109+
}
110+
111+
setLoadingPlan(plan.name)
112+
try {
113+
const response = await fetch('/api/polar/checkout', {
114+
method: 'POST',
115+
headers: { 'Content-Type': 'application/json' },
116+
body: JSON.stringify({
117+
productId: plan.productId,
118+
planName: plan.name,
119+
}),
120+
})
121+
122+
if (!response.ok) {
123+
throw new Error('Failed to create checkout session')
124+
}
125+
126+
const { checkoutUrl } = await response.json()
127+
128+
if (checkoutUrl) {
129+
window.location.href = checkoutUrl
130+
} else {
131+
throw new Error('No checkout URL received')
132+
}
133+
} catch (error) {
134+
console.error('Failed to start checkout:', error)
135+
toast({
136+
title: 'Subscription Error',
137+
description: 'Failed to start checkout process. Please try again.',
138+
variant: 'destructive',
139+
})
140+
} finally {
141+
setLoadingPlan(null)
142+
}
143+
}
144+
145+
const handleManageSubscription = async () => {
146+
window.open('https://polar.sh/capsule-app/portal', '_blank')
147+
}
148+
149+
const handleSignOut = async () => {
150+
await signOut()
151+
}
152+
153+
return (
154+
<div className="flex flex-col min-h-dvh">
155+
<NavHeader
156+
session={session}
157+
showLogin={() => setAuthDialogOpen(true)}
158+
signOut={handleSignOut}
159+
/>
160+
161+
<div className="flex-1 flex items-start justify-center pt-12 pb-12 px-4">
162+
{isLoadingStatus ? (
163+
<div className="flex flex-col items-center justify-center min-h-[200px]">
164+
<Loader2 className="h-8 w-8 animate-spin mb-4" />
165+
<p className="text-sm text-muted-foreground">Loading subscription details...</p>
166+
</div>
167+
) : session && subscriptionStatus?.hasSubscription ? (
168+
<div className="w-full max-w-md">
169+
<h1 className="text-2xl font-bold text-center mb-2">Subscription Details</h1>
170+
<p className="text-center text-muted-foreground mb-6">
171+
You are currently on the{' '}
172+
<Badge variant="outline" className="capitalize">
173+
{subscriptionStatus.currentPlan}
174+
</Badge>
175+
{' '}plan
176+
</p>
177+
178+
<div className="space-y-4">
179+
<div className="bg-muted rounded-lg p-4">
180+
<div className="flex justify-between items-center mb-2">
181+
<span className="text-sm text-muted-foreground">Current Plan</span>
182+
<span className="font-semibold capitalize">{subscriptionStatus.currentPlan}</span>
183+
</div>
184+
<div className="flex justify-between items-center mb-2">
185+
<span className="text-sm text-muted-foreground">Messages Used</span>
186+
<span className="font-semibold">
187+
{subscriptionStatus.messagesUsed} / {subscriptionStatus.messageLimit}
188+
</span>
189+
</div>
190+
{subscriptionStatus.resetDate && (
191+
<div className="flex justify-between items-center">
192+
<span className="text-sm text-muted-foreground">Resets</span>
193+
<span className="font-semibold">
194+
{new Date(subscriptionStatus.resetDate).toLocaleDateString()}
195+
</span>
196+
</div>
197+
)}
198+
</div>
199+
200+
<Button
201+
className="w-full"
202+
onClick={handleManageSubscription}
203+
>
204+
<Settings className="h-4 w-4 mr-2" />
205+
Manage Subscription
206+
</Button>
207+
</div>
208+
</div>
209+
) : (
210+
<div className="w-full max-w-4xl">
211+
<h1 className="text-2xl font-bold text-center mb-2">Choose Your Plan</h1>
212+
<div className="text-center mt-2 mb-6 bg-muted border border-border rounded-lg px-4 py-3 text-sm text-foreground max-w-2xl mx-auto">
213+
<span className="font-semibold">Announcement: </span>
214+
We have halved plan prices. React Native Vibe Code is now the most affordable vibe coding platform to create React Native apps. We will keep pushing to make the project the most open and affordable option to easily vibe code React Native apps. Enjoy.
215+
</div>
216+
217+
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
218+
{plans.map((plan) => (
219+
<div
220+
key={plan.name}
221+
className={cn(
222+
'relative rounded-lg border p-6 transition-all hover:shadow-lg',
223+
plan.popular
224+
? 'border-primary shadow-md scale-105'
225+
: 'border-border',
226+
)}
227+
>
228+
{plan.popular && (
229+
<div className="absolute -top-3 left-1/2 transform -translate-x-1/2">
230+
<span className="bg-primary text-primary-foreground px-3 py-1 rounded-full text-xs font-semibold">
231+
POPULAR
232+
</span>
233+
</div>
234+
)}
235+
236+
<div className="space-y-4">
237+
<div>
238+
<h3 className="text-xl font-semibold">{plan.name}</h3>
239+
<div className="mt-2 flex items-baseline">
240+
<span className="text-3xl font-bold">${plan.price}</span>
241+
<span className="text-muted-foreground ml-1">
242+
/{plan.period}
243+
</span>
244+
</div>
245+
<span className="text-2xl text-muted-foreground line-through">
246+
${plan.originalPrice}/{plan.period}
247+
</span>
248+
</div>
249+
250+
<ul className="space-y-3">
251+
{plan.features.map((feature, index) => (
252+
<li key={index} className="flex items-start">
253+
<Check className="h-4 w-4 text-primary mt-0.5 mr-2 flex-shrink-0" />
254+
<span className="text-sm">{feature}</span>
255+
</li>
256+
))}
257+
</ul>
258+
259+
<Button
260+
className={cn(
261+
'w-full',
262+
plan.popular
263+
? 'bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600'
264+
: '',
265+
)}
266+
variant={plan.popular ? 'default' : 'outline'}
267+
onClick={() => handleSubscribe(plan)}
268+
disabled={loadingPlan !== null}
269+
>
270+
{loadingPlan === plan.name ? (
271+
<>
272+
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
273+
Processing...
274+
</>
275+
) : (
276+
<>
277+
<Crown className="h-4 w-4 mr-2" />
278+
{plan.name === 'Start' ? 'Get Started' : `Upgrade to ${plan.name}`}
279+
</>
280+
)}
281+
</Button>
282+
</div>
283+
</div>
284+
))}
285+
</div>
286+
287+
<p className="mt-8 text-sm text-muted-foreground text-center">
288+
Note: Messages reset each month on 1st of the month.
289+
</p>
290+
</div>
291+
)}
292+
</div>
293+
294+
<AuthDialog
295+
open={isAuthDialogOpen}
296+
setOpen={setAuthDialogOpen}
297+
callbackURL="/subscription"
298+
/>
299+
</div>
300+
)
301+
}

0 commit comments

Comments
 (0)