|
1 | 1 | import * as React from 'react'; |
| 2 | +import { useQueryClient } from '@tanstack/react-query'; |
| 3 | +import { useRouter, useRouterState } from '@tanstack/react-router'; |
| 4 | +import { motion } from 'framer-motion'; |
| 5 | +import { CheckCircle2, X } from 'lucide-react'; |
| 6 | + |
2 | 7 | import { CreditMeter } from '~/components/billing/CreditMeter'; |
3 | 8 | import { EnterpriseCTA } from '~/components/billing/EnterpriseCTA'; |
4 | 9 | import { PlanCard } from '~/components/billing/PlanCard'; |
| 10 | +import { Alert, AlertDescription, AlertTitle } from '~/components/ui/alert'; |
5 | 11 | import { Button } from '~/components/ui/button'; |
6 | 12 | import { useBillingInfo, useOpenPortal, useStartCheckout } from '~/hooks/useBilling'; |
| 13 | +import { isClient } from '~/lib/environment'; |
7 | 14 |
|
8 | 15 | export function PlanSettingsSection() { |
9 | 16 | const { data, isPending, error } = useBillingInfo(); |
10 | 17 | const startCheckout = useStartCheckout(); |
11 | 18 | const openPortal = useOpenPortal(); |
| 19 | + const queryClient = useQueryClient(); |
| 20 | + const router = useRouter(); |
| 21 | + const location = useRouterState({ select: (state) => state.location }); |
| 22 | + const search = (location.search as Record<string, unknown>) ?? {}; |
| 23 | + const billingStatus = typeof search.billingStatus === 'string' ? (search.billingStatus as string) : null; |
| 24 | + const showSuccess = billingStatus === 'success'; |
| 25 | + |
| 26 | + const [alertVisible, setAlertVisible] = React.useState(showSuccess); |
| 27 | + const [confettiActive, setConfettiActive] = React.useState(false); |
| 28 | + |
| 29 | + const clearBillingStatusParam = React.useCallback(() => { |
| 30 | + if (!isClient) return; |
| 31 | + const params = new URLSearchParams(window.location.search); |
| 32 | + params.delete('billingStatus'); |
| 33 | + const query = params.toString(); |
| 34 | + const href = `${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash ?? ''}`; |
| 35 | + void router.navigate({ href, replace: true }); |
| 36 | + }, [router]); |
| 37 | + |
| 38 | + const dismissSuccessAlert = React.useCallback(() => { |
| 39 | + setAlertVisible(false); |
| 40 | + clearBillingStatusParam(); |
| 41 | + }, [clearBillingStatusParam]); |
| 42 | + |
| 43 | + React.useEffect(() => { |
| 44 | + if (!showSuccess) return; |
| 45 | + setAlertVisible(true); |
| 46 | + void queryClient.invalidateQueries({ queryKey: ['billingInfo'] }); |
| 47 | + if (!isClient) return; |
| 48 | + setConfettiActive(true); |
| 49 | + const timeout = window.setTimeout(() => setConfettiActive(false), 2000); |
| 50 | + return () => window.clearTimeout(timeout); |
| 51 | + }, [showSuccess, queryClient]); |
| 52 | + |
| 53 | + React.useEffect(() => { |
| 54 | + if (!showSuccess || !alertVisible) return; |
| 55 | + if (!isClient) return; |
| 56 | + const timeout = window.setTimeout(() => { |
| 57 | + dismissSuccessAlert(); |
| 58 | + }, 6000); |
| 59 | + return () => window.clearTimeout(timeout); |
| 60 | + }, [showSuccess, alertVisible, dismissSuccessAlert]); |
| 61 | + |
| 62 | + React.useEffect(() => { |
| 63 | + if (!showSuccess) { |
| 64 | + setAlertVisible(false); |
| 65 | + } |
| 66 | + }, [showSuccess]); |
12 | 67 |
|
13 | 68 | if (isPending) { |
14 | 69 | return <div className="text-sm text-muted-foreground">Loading plan details…</div>; |
@@ -47,6 +102,10 @@ export function PlanSettingsSection() { |
47 | 102 |
|
48 | 103 | return ( |
49 | 104 | <div className="space-y-6"> |
| 105 | + {alertVisible && ( |
| 106 | + <SuccessCelebration confettiActive={confettiActive} onDismiss={dismissSuccessAlert} /> |
| 107 | + )} |
| 108 | + |
50 | 109 | <CreditMeter |
51 | 110 | allotment={data.credits.monthlyAllotment} |
52 | 111 | used={data.credits.allotmentUsed} |
@@ -123,3 +182,79 @@ export function PlanSettingsSection() { |
123 | 182 | </div> |
124 | 183 | ); |
125 | 184 | } |
| 185 | + |
| 186 | +const CONFETTI_COLORS = ['#34d399', '#22d3ee', '#6366f1', '#f97316', '#facc15']; |
| 187 | + |
| 188 | +function SuccessCelebration({ |
| 189 | + confettiActive, |
| 190 | + onDismiss, |
| 191 | +}: { |
| 192 | + readonly confettiActive: boolean; |
| 193 | + readonly onDismiss: () => void; |
| 194 | +}) { |
| 195 | + return ( |
| 196 | + <div className="relative overflow-hidden"> |
| 197 | + <Alert variant="success" className="pr-12"> |
| 198 | + <CheckCircle2 className="text-emerald-600 dark:text-emerald-200" /> |
| 199 | + <AlertTitle>Subscription updated</AlertTitle> |
| 200 | + <AlertDescription> |
| 201 | + Your plan has been refreshed. Give it a moment if credits take a few seconds to sync. |
| 202 | + </AlertDescription> |
| 203 | + <button |
| 204 | + type="button" |
| 205 | + onClick={onDismiss} |
| 206 | + className="absolute right-4 top-4 rounded-full p-1 text-emerald-900/60 transition hover:text-emerald-900 dark:text-emerald-50/60 dark:hover:text-emerald-50" |
| 207 | + > |
| 208 | + <X className="h-4 w-4" /> |
| 209 | + <span className="sr-only">Dismiss success message</span> |
| 210 | + </button> |
| 211 | + </Alert> |
| 212 | + <ConfettiBurst active={confettiActive} /> |
| 213 | + </div> |
| 214 | + ); |
| 215 | +} |
| 216 | + |
| 217 | +function ConfettiBurst({ active }: { readonly active: boolean }) { |
| 218 | + const pieces = React.useMemo( |
| 219 | + () => |
| 220 | + Array.from({ length: 28 }).map((_, index) => ({ |
| 221 | + id: index, |
| 222 | + x: (Math.random() - 0.5) * 260, |
| 223 | + y: Math.random() * 180 + 40, |
| 224 | + rotate: Math.random() * 360, |
| 225 | + delay: Math.random() * 0.3, |
| 226 | + duration: 1.1 + Math.random() * 0.6, |
| 227 | + color: CONFETTI_COLORS[index % CONFETTI_COLORS.length], |
| 228 | + })), |
| 229 | + [] |
| 230 | + ); |
| 231 | + |
| 232 | + if (!active || !isClient) { |
| 233 | + return null; |
| 234 | + } |
| 235 | + |
| 236 | + return ( |
| 237 | + <div className="pointer-events-none absolute inset-0 overflow-hidden"> |
| 238 | + {pieces.map((piece) => ( |
| 239 | + <motion.span |
| 240 | + key={piece.id} |
| 241 | + className="absolute h-2 w-1 rounded-full" |
| 242 | + style={{ |
| 243 | + backgroundColor: piece.color, |
| 244 | + left: '50%', |
| 245 | + top: '0%', |
| 246 | + }} |
| 247 | + initial={{ opacity: 0, x: 0, y: 0, scale: 0.75 }} |
| 248 | + animate={{ |
| 249 | + opacity: [0, 1, 1, 0], |
| 250 | + x: piece.x, |
| 251 | + y: piece.y, |
| 252 | + rotate: piece.rotate, |
| 253 | + scale: 1, |
| 254 | + }} |
| 255 | + transition={{ duration: piece.duration, delay: piece.delay, ease: 'easeOut' }} |
| 256 | + /> |
| 257 | + ))} |
| 258 | + </div> |
| 259 | + ); |
| 260 | +} |
0 commit comments