|
1 | 1 | 'use client' |
2 | 2 |
|
3 | | -import { |
4 | | - SUBSCRIPTION_DISPLAY_NAME, |
5 | | - SUBSCRIPTION_TIERS, |
6 | | -} from '@codebuff/common/constants/subscription-plans' |
7 | | - |
8 | | -import type { SubscriptionTierPrice } from '@codebuff/common/constants/subscription-plans' |
9 | | -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' |
| 3 | +import { SUBSCRIPTION_DISPLAY_NAME } from '@codebuff/common/constants/subscription-plans' |
| 4 | +import { env } from '@codebuff/common/env' |
| 5 | +import { useQuery } from '@tanstack/react-query' |
10 | 6 | import { |
11 | 7 | Zap, |
12 | 8 | Clock, |
13 | 9 | CalendarDays, |
14 | | - Loader2, |
15 | 10 | AlertTriangle, |
16 | | - ArrowRightLeft, |
| 11 | + ExternalLink, |
| 12 | + Loader2, |
17 | 13 | } from 'lucide-react' |
18 | 14 | import Link from 'next/link' |
19 | 15 | import { useSession } from 'next-auth/react' |
20 | | -import { useState } from 'react' |
21 | 16 |
|
22 | 17 | import { Button } from '@/components/ui/button' |
23 | 18 | import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' |
24 | | -import { |
25 | | - Dialog, |
26 | | - DialogContent, |
27 | | - DialogDescription, |
28 | | - DialogFooter, |
29 | | - DialogHeader, |
30 | | - DialogTitle, |
31 | | -} from '@/components/ui/dialog' |
32 | | -import { toast } from '@/components/ui/use-toast' |
33 | 19 | import { cn } from '@/lib/utils' |
34 | 20 |
|
35 | 21 | interface SubscriptionApiResponse { |
@@ -129,77 +115,15 @@ function ProgressBar({ |
129 | 115 |
|
130 | 116 | function SubscriptionActive({ |
131 | 117 | data, |
| 118 | + email, |
132 | 119 | }: { |
133 | 120 | data: SubscriptionApiResponse |
| 121 | + email: string |
134 | 122 | }) { |
135 | | - const queryClient = useQueryClient() |
136 | | - const [showCancelDialog, setShowCancelDialog] = useState(false) |
137 | | - const [showChangePlanDialog, setShowChangePlanDialog] = useState(false) |
138 | | - |
139 | | - const cancelMutation = useMutation({ |
140 | | - mutationFn: async () => { |
141 | | - const response = await fetch('/api/stripe/cancel-subscription', { |
142 | | - method: 'POST', |
143 | | - }) |
144 | | - if (!response.ok) { |
145 | | - const err = await response.json().catch(() => ({})) |
146 | | - throw new Error(err.error || 'Failed to cancel subscription') |
147 | | - } |
148 | | - return response.json() |
149 | | - }, |
150 | | - onSuccess: () => { |
151 | | - setShowCancelDialog(false) |
152 | | - queryClient.invalidateQueries({ queryKey: ['subscription'] }) |
153 | | - toast({ |
154 | | - title: 'Subscription canceled', |
155 | | - description: `Your ${SUBSCRIPTION_DISPLAY_NAME} subscription will remain active until the end of your billing period.`, |
156 | | - }) |
157 | | - }, |
158 | | - onError: (error: Error) => { |
159 | | - setShowCancelDialog(false) |
160 | | - toast({ |
161 | | - title: 'Error', |
162 | | - description: error.message, |
163 | | - variant: 'destructive', |
164 | | - }) |
165 | | - }, |
166 | | - }) |
167 | | - |
168 | | - const changeTierMutation = useMutation({ |
169 | | - mutationFn: async (selectedTier: SubscriptionTierPrice) => { |
170 | | - const response = await fetch('/api/stripe/change-subscription-tier', { |
171 | | - method: 'POST', |
172 | | - headers: { 'Content-Type': 'application/json' }, |
173 | | - body: JSON.stringify({ tier: selectedTier }), |
174 | | - }) |
175 | | - if (!response.ok) { |
176 | | - const err = await response.json().catch(() => ({})) |
177 | | - throw new Error(err.error || 'Failed to change plan') |
178 | | - } |
179 | | - return response.json() |
180 | | - }, |
181 | | - onSuccess: () => { |
182 | | - setShowChangePlanDialog(false) |
183 | | - queryClient.invalidateQueries({ queryKey: ['subscription'] }) |
184 | | - toast({ |
185 | | - title: 'Plan changed', |
186 | | - description: 'Your subscription plan has been updated.', |
187 | | - }) |
188 | | - }, |
189 | | - onError: (error: Error) => { |
190 | | - setShowChangePlanDialog(false) |
191 | | - toast({ |
192 | | - title: 'Error', |
193 | | - description: error.message, |
194 | | - variant: 'destructive', |
195 | | - }) |
196 | | - }, |
197 | | - }) |
198 | | - |
199 | 123 | const { subscription, rateLimit } = data |
200 | 124 |
|
201 | 125 | const isCanceling = subscription?.cancelAtPeriodEnd |
202 | | - const currentTier = (subscription?.tier ?? 200) as SubscriptionTierPrice |
| 126 | + const billingPortalUrl = `${env.NEXT_PUBLIC_STRIPE_CUSTOMER_PORTAL}?prefilled_email=${encodeURIComponent(email)}` |
203 | 127 |
|
204 | 128 | return ( |
205 | 129 | <Card> |
@@ -301,141 +225,26 @@ function SubscriptionActive({ |
301 | 225 | </> |
302 | 226 | )} |
303 | 227 |
|
304 | | - {/* Billing info & cancel */} |
| 228 | + {/* Billing info & manage */} |
305 | 229 | <div className="flex items-center justify-between border-t pt-4"> |
306 | 230 | <p className="text-sm text-muted-foreground"> |
307 | 231 | {isCanceling |
308 | 232 | ? `Cancels ${subscription ? formatDate(subscription.billingPeriodEnd) : ''}` |
309 | 233 | : `Renews ${subscription ? formatDate(subscription.billingPeriodEnd) : ''}`} |
310 | 234 | </p> |
311 | | - {!isCanceling && ( |
312 | | - <div className="flex items-center gap-2"> |
313 | | - <Button |
314 | | - variant="ghost" |
315 | | - size="sm" |
316 | | - className="text-muted-foreground" |
317 | | - onClick={() => setShowChangePlanDialog(true)} |
318 | | - > |
319 | | - <ArrowRightLeft className="mr-1 h-3 w-3" /> |
320 | | - Change Plan |
321 | | - </Button> |
322 | | - <Button |
323 | | - variant="ghost" |
324 | | - size="sm" |
325 | | - className="text-muted-foreground hover:text-destructive" |
326 | | - onClick={() => setShowCancelDialog(true)} |
327 | | - > |
328 | | - Cancel Subscription |
329 | | - </Button> |
330 | | - </div> |
331 | | - )} |
| 235 | + <Button |
| 236 | + variant="ghost" |
| 237 | + size="sm" |
| 238 | + className="text-muted-foreground" |
| 239 | + asChild |
| 240 | + > |
| 241 | + <a href={billingPortalUrl} target="_blank" rel="noopener noreferrer"> |
| 242 | + <ExternalLink className="mr-1.5 h-3.5 w-3.5" /> |
| 243 | + Manage Subscription |
| 244 | + </a> |
| 245 | + </Button> |
332 | 246 | </div> |
333 | 247 | </CardContent> |
334 | | - |
335 | | - <Dialog open={showCancelDialog} onOpenChange={setShowCancelDialog}> |
336 | | - <DialogContent> |
337 | | - <DialogHeader> |
338 | | - <DialogTitle>Cancel subscription?</DialogTitle> |
339 | | - <DialogDescription> |
340 | | - Your {SUBSCRIPTION_DISPLAY_NAME} subscription will remain active |
341 | | - until{' '} |
342 | | - {subscription |
343 | | - ? formatDate(subscription.billingPeriodEnd) |
344 | | - : 'the end of your billing period'} |
345 | | - . After that, you'll return to the free tier. |
346 | | - </DialogDescription> |
347 | | - </DialogHeader> |
348 | | - <DialogFooter> |
349 | | - <Button |
350 | | - variant="outline" |
351 | | - onClick={() => setShowCancelDialog(false)} |
352 | | - disabled={cancelMutation.isPending} |
353 | | - > |
354 | | - Keep Subscription |
355 | | - </Button> |
356 | | - <Button |
357 | | - variant="destructive" |
358 | | - onClick={() => cancelMutation.mutate()} |
359 | | - disabled={cancelMutation.isPending} |
360 | | - > |
361 | | - {cancelMutation.isPending ? ( |
362 | | - <Loader2 className="mr-1 h-3 w-3 animate-spin" /> |
363 | | - ) : null} |
364 | | - Yes, Cancel |
365 | | - </Button> |
366 | | - </DialogFooter> |
367 | | - </DialogContent> |
368 | | - </Dialog> |
369 | | - |
370 | | - <Dialog open={showChangePlanDialog} onOpenChange={setShowChangePlanDialog}> |
371 | | - <DialogContent> |
372 | | - <DialogHeader> |
373 | | - <DialogTitle>Change Plan</DialogTitle> |
374 | | - <DialogDescription> |
375 | | - Select a new plan for your {SUBSCRIPTION_DISPLAY_NAME} subscription. The change takes effect immediately with a prorated charge. |
376 | | - </DialogDescription> |
377 | | - </DialogHeader> |
378 | | - <div className="flex flex-col gap-3 py-2"> |
379 | | - {Object.entries(SUBSCRIPTION_TIERS).map( |
380 | | - ([key, tier]) => { |
381 | | - const tierPrice = Number(key) as SubscriptionTierPrice |
382 | | - const isCurrent = tierPrice === currentTier |
383 | | - const tierName = |
384 | | - tierPrice === 100 |
385 | | - ? 'Starter' |
386 | | - : tierPrice === 200 |
387 | | - ? 'Pro' |
388 | | - : 'Team' |
389 | | - const tierDescription = |
390 | | - tierPrice === 100 |
391 | | - ? 'Great for individuals getting started.' |
392 | | - : tierPrice === 200 |
393 | | - ? 'For professionals who need more capacity.' |
394 | | - : 'For power users and teams with heavy workloads.' |
395 | | - return ( |
396 | | - <button |
397 | | - key={tierPrice} |
398 | | - disabled={isCurrent || changeTierMutation.isPending} |
399 | | - onClick={() => changeTierMutation.mutate(tierPrice)} |
400 | | - className={cn( |
401 | | - 'flex items-center justify-between rounded-lg border p-4 text-left transition-colors', |
402 | | - isCurrent |
403 | | - ? 'cursor-default border-indigo-300 bg-indigo-50 dark:border-indigo-700 dark:bg-indigo-900/20' |
404 | | - : 'hover:border-indigo-300 hover:bg-muted dark:hover:border-indigo-700', |
405 | | - )} |
406 | | - > |
407 | | - <div> |
408 | | - <div className="flex items-center gap-2"> |
409 | | - <span className="font-semibold">{tierName}</span> |
410 | | - {isCurrent && ( |
411 | | - <span className="inline-flex items-center rounded-full bg-indigo-100 px-2 py-0.5 text-xs font-medium text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400"> |
412 | | - Current |
413 | | - </span> |
414 | | - )} |
415 | | - </div> |
416 | | - <p className="mt-0.5 text-sm text-muted-foreground"> |
417 | | - {tierDescription} |
418 | | - </p> |
419 | | - </div> |
420 | | - <span className="ml-4 text-lg font-semibold"> |
421 | | - ${tier.monthlyPrice}/mo |
422 | | - </span> |
423 | | - </button> |
424 | | - ) |
425 | | - }, |
426 | | - )} |
427 | | - </div> |
428 | | - <DialogFooter> |
429 | | - <Button |
430 | | - variant="outline" |
431 | | - onClick={() => setShowChangePlanDialog(false)} |
432 | | - disabled={changeTierMutation.isPending} |
433 | | - > |
434 | | - Cancel |
435 | | - </Button> |
436 | | - </DialogFooter> |
437 | | - </DialogContent> |
438 | | - </Dialog> |
439 | 248 | </Card> |
440 | 249 | ) |
441 | 250 | } |
@@ -469,7 +278,7 @@ function SubscriptionCta() { |
469 | 278 | } |
470 | 279 |
|
471 | 280 | export function SubscriptionSection() { |
472 | | - const { status } = useSession() |
| 281 | + const { data: session, status } = useSession() |
473 | 282 |
|
474 | 283 | const { data, isLoading } = useQuery<SubscriptionApiResponse>({ |
475 | 284 | queryKey: ['subscription'], |
@@ -500,5 +309,7 @@ export function SubscriptionSection() { |
500 | 309 | return <SubscriptionCta /> |
501 | 310 | } |
502 | 311 |
|
503 | | - return <SubscriptionActive data={data} /> |
| 312 | + const email = session?.user?.email || '' |
| 313 | + |
| 314 | + return <SubscriptionActive data={data} email={email} /> |
504 | 315 | } |
0 commit comments