CreditCard.tsx 7.1 KB

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