|
| 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