CreditCard.tsx201 lines · main
1import { MoreHorizontal } from 'lucide-react'
2import {
3 Badge,
4 Button,
5 DropdownMenu,
6 DropdownMenuContent,
7 DropdownMenuItem,
8 DropdownMenuSeparator,
9 DropdownMenuTrigger,
10 Tooltip,
11 TooltipContent,
12 TooltipTrigger,
13} from 'ui'
14
15import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip'
16import PartnerIcon from '@/components/ui/PartnerIcon'
17import type { OrganizationPaymentMethod } from '@/data/organizations/organization-payment-methods-query'
18import type { PlanId } from '@/data/subscriptions/types'
19import { BASE_PATH } from '@/lib/constants'
20import { MANAGED_BY } from '@/lib/constants/infrastructure'
21
22interface CreditCardProps {
23 paymentMethod: OrganizationPaymentMethod
24 canUpdatePaymentMethods?: boolean
25 paymentMethodType?: string
26 paymentMethodCount: number
27 subscriptionPlan?: PlanId
28 setSelectedMethodForUse?: (paymentMethod: OrganizationPaymentMethod) => void
29 setSelectedMethodToDelete?: (paymentMethod: OrganizationPaymentMethod) => void
30}
31
32const CreditCard = ({
33 paymentMethod,
34 canUpdatePaymentMethods = true,
35 paymentMethodType,
36 subscriptionPlan,
37 paymentMethodCount,
38 setSelectedMethodForUse,
39 setSelectedMethodToDelete,
40}: CreditCardProps) => {
41 const isSpt = paymentMethod.type === 'shared_payment_token'
42 const spt = paymentMethod.shared_payment_token
43 const isActive = paymentMethod.is_default
44 const isRemovable =
45 !paymentMethod.is_default || (subscriptionPlan === 'free' && paymentMethodCount === 1)
46
47 const expiryYear = paymentMethod.card?.exp_year ?? 0
48 const expiryMonth = paymentMethod.card?.exp_month ?? 0
49
50 const currentMonth = new Date().getMonth() + 1
51 const currentYear = new Date().getFullYear()
52
53 const isCardExpiringSoon = expiryYear === currentYear && expiryMonth === currentMonth
54 const isCardExpired =
55 expiryYear < currentYear || (expiryYear === currentYear && expiryMonth < currentMonth)
56 const isTokenExpired = spt?.is_expired ?? false
57 const tokenExpiry = spt?.expires_at
58 ? `${new Date(spt.expires_at * 1000).getMonth() + 1}/${new Date(spt.expires_at * 1000).getFullYear()}`
59 : undefined
60
61 const stripeStatus = (() => {
62 if (!isSpt) return null
63 if (isTokenExpired) {
64 return {
65 label: 'Token expired',
66 variant: 'destructive' as const,
67 description: 'Stripe Projects token has expired',
68 }
69 }
70 if (isCardExpired) {
71 return {
72 label: 'Needs review',
73 variant: 'warning' as const,
74 description: 'Underlying card has expired',
75 }
76 }
77 if (isCardExpiringSoon) {
78 return {
79 label: 'Needs review',
80 variant: 'warning' as const,
81 description: 'Underlying card expires soon',
82 }
83 }
84 return {
85 label: 'Active',
86 variant: 'success' as const,
87 description: 'Stripe Projects token is active',
88 }
89 })()
90
91 if (!paymentMethod.card) return null
92
93 return (
94 <div key={paymentMethod.id} className="space-y-3">
95 <div className="flex items-center justify-between gap-8">
96 <div className="flex items-center gap-8">
97 <div className="relative shrink-0">
98 <img
99 alt="Credit card brand"
100 src={`${BASE_PATH}/img/payment-methods/${paymentMethod.card.brand
101 .replace(' ', '-')
102 .toLowerCase()}.png`}
103 width="32"
104 />
105 {isSpt && (
106 <Tooltip>
107 <TooltipTrigger asChild>
108 <span className="absolute -bottom-1.5 -right-2 rounded-md bg-background outline outline-2 outline-background">
109 <PartnerIcon
110 organization={{ managed_by: MANAGED_BY.STRIPE_PROJECTS }}
111 showTooltip={false}
112 size="small"
113 />
114 </span>
115 </TooltipTrigger>
116 <TooltipContent side="top">Handled via Stripe Projects</TooltipContent>
117 </Tooltip>
118 )}
119 </div>
120 <div className="flex flex-col gap-0.5">
121 <div className="flex items-center gap-8">
122 <p className="prose text-sm font-mono">**** **** **** {paymentMethod.card.last4}</p>
123 <p className="text-sm tabular-nums">
124 Expires: {paymentMethod.card.exp_month}/{paymentMethod.card.exp_year}
125 </p>
126 </div>
127 {isSpt && spt && (
128 <div className="mt-2.5 flex items-center gap-2 border-t pt-2.5 text-xs text-foreground-light">
129 <p className="m-0">
130 Managed via Stripe Projects · Token ending in{' '}
131 <code className="text-code-inline">{spt.last4}</code>
132 {tokenExpiry && <span> · Expires {tokenExpiry}</span>}
133 </p>
134 </div>
135 )}
136 </div>
137 </div>
138
139 <div className="flex items-center gap-2">
140 {!isSpt && isCardExpiringSoon && <Badge variant="warning">Expiring soon</Badge>}
141 {!isSpt && isCardExpired && <Badge variant="destructive">Expired</Badge>}
142 {!isSpt && !isCardExpired && isActive && <Badge variant="success">Active</Badge>}
143 {stripeStatus && (
144 <Tooltip>
145 <TooltipTrigger asChild>
146 <span>
147 <Badge variant={stripeStatus.variant}>{stripeStatus.label}</Badge>
148 </span>
149 </TooltipTrigger>
150 <TooltipContent side="left">{stripeStatus.description}</TooltipContent>
151 </Tooltip>
152 )}
153
154 {canUpdatePaymentMethods && !isSpt && (
155 <DropdownMenu>
156 <DropdownMenuTrigger asChild>
157 <Button
158 type="outline"
159 className="hover:border-muted px-1"
160 icon={<MoreHorizontal />}
161 aria-label="More options"
162 />
163 </DropdownMenuTrigger>
164 <DropdownMenuContent align="end" className="w-36">
165 {paymentMethodType === 'card' && !isActive && (
166 <>
167 <DropdownMenuItem
168 key="make-default"
169 onClick={() => setSelectedMethodForUse?.(paymentMethod)}
170 >
171 <p>Use this card</p>
172 </DropdownMenuItem>
173 <DropdownMenuSeparator />
174 </>
175 )}
176 <DropdownMenuItemTooltip
177 key="delete-method"
178 disabled={!isRemovable}
179 className="pointer-events-auto!"
180 onClick={() => setSelectedMethodToDelete?.(paymentMethod)}
181 tooltip={{
182 content: {
183 side: 'left',
184 text: !isRemovable
185 ? 'Unable to delete a card that is currently active'
186 : undefined,
187 },
188 }}
189 >
190 <p>Delete card</p>
191 </DropdownMenuItemTooltip>
192 </DropdownMenuContent>
193 </DropdownMenu>
194 )}
195 </div>
196 </div>
197 </div>
198 )
199}
200
201export default CreditCard