{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "payment-card",
  "title": "Payment Card",
  "author": "@fma-ds/ds",
  "description": "The payment card screens: the saved card and the form to add one.\n\n### Saved card\n\n- **`PaymentCardDetails`**: draws the saved card (`credit-card`) beside a `description-list` (issuer, last digits, holder, expiry) from the gateway's data (`number` with hidden digits, `issuer`, `name`, `expiry`), with `actions` under it.\n- **`PaymentCardRemove`**: the destructive button that asks through a `ConfirmDialog` and runs `onRemove` with the dialog pending, showing an error if it throws.\n\n### Adding a card\n\n`PaymentCardForm` is a react-aria `Form` that lays `PaymentCardPreview` beside `PaymentCardFields` (stacked when narrow). The fields are ready-made react-aria `TextField`s that feed the live preview with their value and focus (the code field turns the card):\n\n- **`PaymentCardNumberField`**: masked by issuer.\n- **`PaymentCardNameField`**: `part=\"first\"`/`\"last\"` split it for gateways that take them apart.\n- **`PaymentCardExpiryField`**: `MM/YY`.\n- **`PaymentCardCodeField`**: digits, sized by the issuer.\n\nEach takes `value`/`onChange` or `defaultValue`, `name`, `validate`, `isInvalid` and `errorMessage` (for react-hook-form's `Controller`), `label` and `description`. Validate with the `credit-card-validation` lib.\n\nBuilt-in text is in English; translate it with `label`, `placeholder` and `i18n.labels`.",
  "dependencies": [
    "cn@^0.5.0",
    "lucide-react@^1.61.0",
    "react-aria-components@^1.22.1"
  ],
  "registryDependencies": [
    "@fma-ds/button",
    "@fma-ds/confirm-dialog",
    "@fma-ds/credit-card",
    "@fma-ds/description-list",
    "@fma-ds/input",
    "@fma-ds/label",
    "@fma-ds/masked-input",
    "@fma-ds/use-dialog-promise",
    "@fma-ds/credit-card-validation"
  ],
  "files": [
    {
      "path": "src/blocks/payment-card/payment-card.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"cn\";\nimport { Trash2Icon } from \"lucide-react\";\nimport * as React from \"react\";\nimport {\n  FieldError,\n  Form,\n  Text,\n  TextField,\n  type TextFieldProps,\n  type ValidationResult,\n} from \"react-aria-components\";\nimport { Button } from \"@/core/button/button\";\nimport { ConfirmDialog } from \"@/core/confirm-dialog/confirm-dialog\";\nimport {\n  CreditCard,\n  type CreditCardField,\n  type CreditCardProps,\n} from \"@/core/credit-card/credit-card\";\nimport {\n  DescriptionDetails,\n  DescriptionItem,\n  DescriptionList,\n  DescriptionTerm,\n} from \"@/core/description-list/description-list\";\nimport { Input } from \"@/core/input/input\";\nimport { Label } from \"@/core/label/label\";\nimport { MaskedInput } from \"@/core/masked-input/masked-input\";\nimport { useDialogPromise } from \"@/hooks/use-dialog-promise\";\nimport { getCreditCardIssuer, getCreditCardIssuerById } from \"@/lib/credit-card-validation\";\n\n/* -------------------------------------------------------------------------- */\n/*                                Saved card                                  */\n/* -------------------------------------------------------------------------- */\n\ntype PaymentCardDetailsLabels = {\n  issuer: string;\n  number: string;\n  holder: string;\n  expiry: string;\n};\n\nconst defaultPaymentCardDetailsLabels: PaymentCardDetailsLabels = {\n  issuer: \"Card\",\n  number: \"Number\",\n  holder: \"Name on card\",\n  expiry: \"Expires\",\n};\n\ntype PaymentCardDetailsProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** The number as the gateway returns it, digits hidden with `*` or `x`. */\n  number: string;\n  /** The issuer id (`visa`, `mastercard`, `amex`…). */\n  issuer?: string;\n  name?: string;\n  /** `MM/YY`, `MM/YYYY` or digits only. */\n  expiry?: string;\n  /** Actions under the details, such as `PaymentCardRemove`. */\n  actions?: React.ReactNode;\n  i18n?: {\n    labels?: Partial<PaymentCardDetailsLabels>;\n    card?: CreditCardProps[\"labels\"];\n  };\n};\n\n/** `MM/YY` from the formats `CreditCard` takes. */\nfunction formatExpiry(expiry: string) {\n  const [month = \"\", year = \"\"] = expiry.includes(\"/\")\n    ? expiry.split(\"/\")\n    : [expiry.slice(0, 2), expiry.slice(2)];\n  return `${month}/${year.slice(-2)}`;\n}\n\n/**\n * A saved card: the card drawn beside its details (issuer, last digits,\n * holder, expiry), with its actions under them.\n */\nfunction PaymentCardDetails({\n  number,\n  issuer,\n  name,\n  expiry,\n  actions,\n  i18n,\n  className,\n  ...props\n}: PaymentCardDetailsProps) {\n  const labels = { ...defaultPaymentCardDetailsLabels, ...i18n?.labels };\n  const info = issuer ? getCreditCardIssuerById(issuer) : getCreditCardIssuer(number);\n  const lastDigits = /(\\d{4})\\D*$/.exec(number)?.[1];\n\n  return (\n    <div\n      data-slot=\"payment-card-details\"\n      className={cn(\"@container flex flex-wrap items-start gap-x-8 gap-y-6\", className)}\n      {...props}\n    >\n      <CreditCard\n        number={number}\n        issuer={issuer}\n        name={name}\n        expiry={expiry}\n        labels={i18n?.card}\n        className=\"w-full max-w-72\"\n      />\n      <div className=\"flex min-w-56 flex-1 flex-col gap-6\">\n        <DescriptionList>\n          {info && (\n            <DescriptionItem>\n              <DescriptionTerm>{labels.issuer}</DescriptionTerm>\n              <DescriptionDetails>{info.niceType}</DescriptionDetails>\n            </DescriptionItem>\n          )}\n          {lastDigits && (\n            <DescriptionItem>\n              <DescriptionTerm>{labels.number}</DescriptionTerm>\n              <DescriptionDetails className=\"tabular-nums\">•••• {lastDigits}</DescriptionDetails>\n            </DescriptionItem>\n          )}\n          {name && (\n            <DescriptionItem>\n              <DescriptionTerm>{labels.holder}</DescriptionTerm>\n              <DescriptionDetails className=\"uppercase\">{name}</DescriptionDetails>\n            </DescriptionItem>\n          )}\n          {expiry && (\n            <DescriptionItem>\n              <DescriptionTerm>{labels.expiry}</DescriptionTerm>\n              <DescriptionDetails className=\"tabular-nums\">\n                {formatExpiry(expiry)}\n              </DescriptionDetails>\n            </DescriptionItem>\n          )}\n        </DescriptionList>\n        {actions && <div className=\"flex flex-wrap gap-2\">{actions}</div>}\n      </div>\n    </div>\n  );\n}\n\ntype PaymentCardRemoveLabels = {\n  remove: string;\n  confirmTitle: string;\n  confirmDescription: string;\n  confirm: string;\n  cancel: string;\n  pending: string;\n  error: string;\n};\n\nconst defaultPaymentCardRemoveLabels: PaymentCardRemoveLabels = {\n  remove: \"Remove card\",\n  confirmTitle: \"Remove this card?\",\n  confirmDescription: \"You'll need to add a card again to pay with one.\",\n  confirm: \"Remove card\",\n  cancel: \"Cancel\",\n  pending: \"Removing…\",\n  error: \"The card couldn't be removed. Please try again.\",\n};\n\ntype PaymentCardRemoveProps = Omit<\n  React.ComponentProps<typeof Button>,\n  \"onPress\" | \"children\" | \"variant\"\n> & {\n  /**\n   * Removes the card, after the user confirms. The dialog stays open and\n   * pending until it settles; if it throws, the dialog shows the error.\n   */\n  onRemove: () => Promise<void> | void;\n  i18n?: { labels?: Partial<PaymentCardRemoveLabels> };\n};\n\n/** The destructive button that asks for confirmation, then runs `onRemove`. */\nfunction PaymentCardRemove({ onRemove, i18n, className, ...props }: PaymentCardRemoveProps) {\n  const labels = { ...defaultPaymentCardRemoveLabels, ...i18n?.labels };\n  const dialog = useDialogPromise();\n\n  return (\n    <>\n      <Button\n        variant=\"destructive\"\n        className={className}\n        onPress={() => void dialog.open()}\n        {...props}\n      >\n        <Trash2Icon aria-hidden=\"true\" />\n        {labels.remove}\n      </Button>\n      <ConfirmDialog\n        controller={dialog}\n        tone=\"destructive\"\n        media={<Trash2Icon />}\n        title={labels.confirmTitle}\n        description={labels.confirmDescription}\n        confirmLabel={labels.confirm}\n        cancelLabel={labels.cancel}\n        pendingLabel={labels.pending}\n        errorMessage={labels.error}\n        onConfirm={onRemove}\n      />\n    </>\n  );\n}\n\n/* -------------------------------------------------------------------------- */\n/*                                  New card                                  */\n/* -------------------------------------------------------------------------- */\n\ntype PaymentCardValueKey = \"number\" | \"name\" | \"firstName\" | \"lastName\" | \"expiry\" | \"cvc\";\n\ntype PaymentCardFormContextValue = {\n  values: Partial<Record<PaymentCardValueKey, string>>;\n  focused: CreditCardField | null;\n  setValue: (key: PaymentCardValueKey, value: string) => void;\n  setFocused: (field: CreditCardField | null) => void;\n};\n\nconst PaymentCardFormContext = React.createContext<PaymentCardFormContextValue | null>(null);\n\ntype PaymentCardFormProps = React.ComponentProps<typeof Form>;\n\n/**\n * The new-card form: a react-aria `Form` that lays the preview\n * (`PaymentCardPreview`) beside the fields (`PaymentCardFields`) and stacks\n * them when narrow. The fields inside tell the preview what they hold and\n * which one has focus.\n */\nfunction PaymentCardForm({ className, ...props }: PaymentCardFormProps) {\n  const [values, setValues] = React.useState<PaymentCardFormContextValue[\"values\"]>({});\n  const [focused, setFocused] = React.useState<CreditCardField | null>(null);\n  const setValue = React.useCallback((key: PaymentCardValueKey, value: string) => {\n    setValues((current) => (current[key] === value ? current : { ...current, [key]: value }));\n  }, []);\n  const context = React.useMemo(\n    () => ({ values, focused, setValue, setFocused }),\n    [values, focused, setValue],\n  );\n\n  return (\n    <PaymentCardFormContext.Provider value={context}>\n      <div data-slot=\"payment-card-form\" className=\"@container\">\n        <Form\n          className={cn(\n            \"flex flex-col items-center gap-8 @2xl:flex-row @2xl:items-start\",\n            className,\n          )}\n          {...props}\n        />\n      </div>\n    </PaymentCardFormContext.Provider>\n  );\n}\n\ntype PaymentCardPreviewProps = Omit<\n  CreditCardProps,\n  \"number\" | \"name\" | \"expiry\" | \"cvc\" | \"focused\"\n>;\n\n/** The card drawn from the fields of its `PaymentCardForm`, turning to the code. */\nfunction PaymentCardPreview({ className, ...props }: PaymentCardPreviewProps) {\n  const context = React.useContext(PaymentCardFormContext);\n  if (!context) throw new Error(\"PaymentCardPreview must be used within a PaymentCardForm.\");\n  const { values, focused } = context;\n  const name = values.name ?? [values.firstName, values.lastName].filter(Boolean).join(\" \");\n\n  return (\n    <CreditCard\n      number={values.number}\n      name={name}\n      expiry={values.expiry}\n      cvc={values.cvc}\n      focused={focused}\n      className={cn(\"w-full max-w-72 shrink-0\", className)}\n      {...props}\n    />\n  );\n}\n\n/** The fields' grid: number and name on full rows, expiry and code side by side. */\nfunction PaymentCardFields({ className, ...props }: React.ComponentProps<\"div\">) {\n  return (\n    <div\n      data-slot=\"payment-card-fields\"\n      className={cn(\"grid w-full max-w-lg grid-cols-2 gap-4\", className)}\n      {...props}\n    />\n  );\n}\n\ntype PaymentCardFieldProps = Omit<TextFieldProps, \"children\" | \"className\"> & {\n  /** Replaces the built-in English label. */\n  label?: React.ReactNode;\n  description?: React.ReactNode;\n  /** Shown while invalid; by default the validation message. */\n  errorMessage?: React.ReactNode | ((validation: ValidationResult) => React.ReactNode);\n  placeholder?: string;\n  className?: string;\n};\n\n/**\n * The value of a field, controlled or not, reported to the form's preview\n * with its focus.\n */\nfunction usePaymentCardField(\n  key: PaymentCardValueKey,\n  focusAs: CreditCardField,\n  { value, defaultValue, onChange, onFocus, onBlur }: PaymentCardFieldProps,\n  transform: (value: string) => string = (next) => next,\n) {\n  const context = React.useContext(PaymentCardFormContext);\n  const [uncontrolled, setUncontrolled] = React.useState(defaultValue ?? \"\");\n  const current = value ?? uncontrolled;\n  const setValue = context?.setValue;\n  const setFocused = context?.setFocused;\n\n  React.useEffect(() => {\n    setValue?.(key, current);\n  }, [setValue, key, current]);\n\n  return {\n    value: current,\n    onChange: (next: string) => {\n      const transformed = transform(next);\n      setUncontrolled(transformed);\n      onChange?.(transformed);\n    },\n    onFocus: (event: React.FocusEvent<HTMLInputElement>) => {\n      setFocused?.(focusAs);\n      onFocus?.(event);\n    },\n    onBlur: (event: React.FocusEvent<HTMLInputElement>) => {\n      setFocused?.(null);\n      onBlur?.(event);\n    },\n  };\n}\n\n/** The field around an input: label, description and error, wired by react-aria. */\nfunction PaymentCardTextField({\n  label,\n  description,\n  errorMessage,\n  className,\n  field,\n  children,\n  // Handled by `field`, or by the input inside.\n  value: _value,\n  defaultValue: _defaultValue,\n  onChange: _onChange,\n  onFocus: _onFocus,\n  onBlur: _onBlur,\n  placeholder: _placeholder,\n  ...props\n}: PaymentCardFieldProps & {\n  field: ReturnType<typeof usePaymentCardField>;\n  children: React.ReactNode;\n}) {\n  return (\n    <TextField {...props} {...field} className={cn(\"col-span-2 flex flex-col gap-2\", className)}>\n      <Label>{label}</Label>\n      {children}\n      {description && (\n        <Text slot=\"description\" className=\"text-sm text-muted-foreground\">\n          {description}\n        </Text>\n      )}\n      <FieldError className=\"text-sm text-destructive\">{errorMessage}</FieldError>\n    </TextField>\n  );\n}\n\n/**\n * The card number, masked by issuer (Amex 4-6-5). Its value is the masked\n * text; `isValidCreditCardNumber` takes it as is.\n */\nfunction PaymentCardNumberField({\n  label = \"Card number\",\n  placeholder,\n  ...props\n}: PaymentCardFieldProps) {\n  const field = usePaymentCardField(\"number\", \"number\", props);\n  return (\n    <PaymentCardTextField {...props} label={label} field={field}>\n      <MaskedInput\n        mask=\"credit-card\"\n        autoComplete=\"cc-number\"\n        {...(placeholder !== undefined && { placeholder })}\n      />\n    </PaymentCardTextField>\n  );\n}\n\nconst NAME_PARTS = {\n  full: { key: \"name\", label: \"Name on card\", autoComplete: \"cc-name\", span: \"col-span-2\" },\n  first: {\n    key: \"firstName\",\n    label: \"First name\",\n    autoComplete: \"cc-given-name\",\n    span: \"col-span-1\",\n  },\n  last: { key: \"lastName\", label: \"Last name\", autoComplete: \"cc-family-name\", span: \"col-span-1\" },\n} as const;\n\n/**\n * The holder's name as printed on the card. `part=\"first\"` and `part=\"last\"`\n * split it in two fields, for gateways that take them apart; the preview\n * joins them.\n */\nfunction PaymentCardNameField({\n  part = \"full\",\n  label,\n  placeholder,\n  className,\n  ...props\n}: PaymentCardFieldProps & { part?: keyof typeof NAME_PARTS }) {\n  const config = NAME_PARTS[part];\n  const field = usePaymentCardField(config.key, \"name\", props);\n  return (\n    <PaymentCardTextField\n      {...props}\n      label={label ?? config.label}\n      field={field}\n      className={cn(config.span, className)}\n    >\n      <Input autoComplete={config.autoComplete} placeholder={placeholder} />\n    </PaymentCardTextField>\n  );\n}\n\n/** The expiry as `MM/YY`; `isValidCreditCardExpiry` checks it hasn't passed. */\nfunction PaymentCardExpiryField({\n  label = \"Expiry date\",\n  placeholder = \"MM/YY\",\n  className,\n  ...props\n}: PaymentCardFieldProps) {\n  const field = usePaymentCardField(\"expiry\", \"expiry\", props);\n  return (\n    <PaymentCardTextField\n      {...props}\n      label={label}\n      field={field}\n      className={cn(\"col-span-1\", className)}\n    >\n      <MaskedInput\n        mask={{ type: \"date\", mode: \"mm/yy\" }}\n        autoComplete=\"cc-exp\"\n        placeholder={placeholder}\n      />\n    </PaymentCardTextField>\n  );\n}\n\n/**\n * The security code: digits only, as many as the issuer of the number\n * typed in the same form prints (Amex 4, most 3; up to 4 while unknown).\n */\nfunction PaymentCardCodeField({\n  label = \"Security code\",\n  placeholder,\n  className,\n  ...props\n}: PaymentCardFieldProps) {\n  const context = React.useContext(PaymentCardFormContext);\n  const size = getCreditCardIssuer(context?.values.number ?? \"\")?.code.size ?? 4;\n  const field = usePaymentCardField(\"cvc\", \"cvc\", props, (next) =>\n    next.replace(/\\D/g, \"\").slice(0, size),\n  );\n  return (\n    <PaymentCardTextField\n      {...props}\n      label={label}\n      field={field}\n      className={cn(\"col-span-1\", className)}\n    >\n      <Input\n        inputMode=\"numeric\"\n        autoComplete=\"cc-csc\"\n        maxLength={size}\n        placeholder={placeholder ?? \"•\".repeat(size === 4 ? 4 : 3)}\n      />\n    </PaymentCardTextField>\n  );\n}\n\nexport type {\n  PaymentCardDetailsLabels,\n  PaymentCardDetailsProps,\n  PaymentCardFieldProps,\n  PaymentCardFormProps,\n  PaymentCardPreviewProps,\n  PaymentCardRemoveLabels,\n  PaymentCardRemoveProps,\n};\nexport {\n  PaymentCardCodeField,\n  PaymentCardDetails,\n  PaymentCardExpiryField,\n  PaymentCardFields,\n  PaymentCardForm,\n  PaymentCardNameField,\n  PaymentCardNumberField,\n  PaymentCardPreview,\n  PaymentCardRemove,\n};\n",
      "type": "registry:component",
      "target": "components/fma-ds/payment-card/payment-card.tsx"
    }
  ],
  "meta": {
    "status": "experimental",
    "source": "original",
    "license": "MIT",
    "tags": [
      "block",
      "payment",
      "credit-card",
      "form",
      "billing"
    ],
    "contentHash": "0ecb95fbae48"
  },
  "categories": [
    "blocks"
  ],
  "type": "registry:block"
}