CreditCard.tsx201 lines · main
| 1 | import { MoreHorizontal } from 'lucide-react' |
| 2 | import { |
| 3 | Badge, |
| 4 | Button, |
| 5 | DropdownMenu, |
| 6 | DropdownMenuContent, |
| 7 | DropdownMenuItem, |
| 8 | DropdownMenuSeparator, |
| 9 | DropdownMenuTrigger, |
| 10 | Tooltip, |
| 11 | TooltipContent, |
| 12 | TooltipTrigger, |
| 13 | } from 'ui' |
| 14 | |
| 15 | import { DropdownMenuItemTooltip } from '@/components/ui/DropdownMenuItemTooltip' |
| 16 | import PartnerIcon from '@/components/ui/PartnerIcon' |
| 17 | import type { OrganizationPaymentMethod } from '@/data/organizations/organization-payment-methods-query' |
| 18 | import type { PlanId } from '@/data/subscriptions/types' |
| 19 | import { BASE_PATH } from '@/lib/constants' |
| 20 | import { MANAGED_BY } from '@/lib/constants/infrastructure' |
| 21 | |
| 22 | interface 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 | |
| 32 | const 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 | |
| 201 | export default CreditCard |