BillingCustomerDataForm.tsx 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235
  1. import { AddressElement } from '@stripe/react-stripe-js'
  2. import type {
  3. StripeAddressElement,
  4. StripeAddressElementChangeEvent,
  5. StripeAddressElementOptions,
  6. } from '@stripe/stripe-js'
  7. import { Check, ChevronsUpDown, Info, X } from 'lucide-react'
  8. import { useEffect, useId, useMemo, useRef, useState } from 'react'
  9. import { UseFormReturn } from 'react-hook-form'
  10. import {
  11. Button,
  12. cn,
  13. Command,
  14. CommandEmpty,
  15. CommandGroup,
  16. CommandInput,
  17. CommandItem,
  18. CommandList,
  19. FormControl,
  20. FormField,
  21. FormMessage,
  22. Input,
  23. Popover,
  24. PopoverContent,
  25. PopoverTrigger,
  26. Tooltip,
  27. TooltipContent,
  28. TooltipTrigger,
  29. } from 'ui'
  30. import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
  31. import { z } from 'zod'
  32. import { TAX_IDS } from './TaxID.constants'
  33. interface BillingCustomerDataFormProps {
  34. form: UseFormReturn<TaxIdFormValues>
  35. disabled?: boolean
  36. className?: string
  37. addressOptions: StripeAddressElementOptions
  38. resetKey: number
  39. onAddressChange: (evt: StripeAddressElementChangeEvent) => void
  40. onAddressReady?: (element: StripeAddressElement) => void
  41. addressCountry?: string
  42. }
  43. export const TaxIdSchema = z.object({
  44. tax_id_type: z.string(),
  45. tax_id_value: z.string(),
  46. tax_id_name: z.string(),
  47. })
  48. export type TaxIdFormValues = z.infer<typeof TaxIdSchema>
  49. export const BillingCustomerDataForm = ({
  50. form,
  51. disabled = false,
  52. className,
  53. addressOptions,
  54. resetKey,
  55. onAddressChange,
  56. onAddressReady,
  57. addressCountry,
  58. }: BillingCustomerDataFormProps) => {
  59. const [showTaxIDsPopover, setShowTaxIDsPopover] = useState(false)
  60. const taxIdListboxId = useId()
  61. const onSelectTaxIdType = (name: string) => {
  62. const selectedTaxIdOption = TAX_IDS.find((option) => option.name === name)
  63. if (!selectedTaxIdOption) return
  64. form.setValue('tax_id_type', selectedTaxIdOption.type, { shouldDirty: true })
  65. form.setValue('tax_id_value', '', { shouldDirty: true })
  66. form.setValue('tax_id_name', name, { shouldDirty: true })
  67. }
  68. const onRemoveTaxId = () => {
  69. form.setValue('tax_id_name', '', { shouldDirty: true })
  70. form.setValue('tax_id_type', '', { shouldDirty: true })
  71. form.setValue('tax_id_value', '', { shouldDirty: true })
  72. }
  73. const { tax_id_name } = form.watch()
  74. const selectedTaxId = TAX_IDS.find((option) => option.name === tax_id_name)
  75. const availableTaxIds = useMemo(() => {
  76. return TAX_IDS.filter((taxId) => !addressCountry || taxId.countryIso2 === addressCountry).sort(
  77. (a, b) => a.country.localeCompare(b.country)
  78. )
  79. }, [addressCountry])
  80. // Clear tax ID fields when the billing country changes so a stale tax ID
  81. // from the previous country doesn't persist under the new one.
  82. const prevCountryRef = useRef(addressCountry)
  83. useEffect(() => {
  84. if (!addressCountry) return
  85. const isCountryChange =
  86. prevCountryRef.current !== undefined && prevCountryRef.current !== addressCountry
  87. prevCountryRef.current = addressCountry
  88. if (isCountryChange) {
  89. form.setValue('tax_id_type', '', { shouldDirty: true })
  90. form.setValue('tax_id_value', '', { shouldDirty: true })
  91. form.setValue('tax_id_name', '', { shouldDirty: true })
  92. }
  93. }, [addressCountry, form])
  94. return (
  95. <div className={cn('flex flex-col space-y-4', className)}>
  96. <div className={cn('relative', disabled && 'opacity-50')}>
  97. <AddressElement
  98. key={`billing-address-${resetKey}`}
  99. options={addressOptions}
  100. onChange={onAddressChange}
  101. onReady={onAddressReady}
  102. />
  103. {disabled && <div className="absolute inset-0 z-10 cursor-not-allowed" />}
  104. </div>
  105. <div className={cn('grid grid-cols-2 gap-x-6 w-full items-end', disabled && 'opacity-50')}>
  106. <FormField
  107. name="tax_id_name"
  108. control={form.control}
  109. render={() => (
  110. <FormItemLayout
  111. hideMessage
  112. layout="vertical"
  113. label="Business Tax ID"
  114. afterLabel={
  115. <Tooltip>
  116. <TooltipTrigger asChild>
  117. <Info size={14} className="text-foreground-lighter cursor-pointer" />
  118. </TooltipTrigger>
  119. <TooltipContent side="right" className="max-w-xs text-left">
  120. If you are an individual, no need to add a Tax ID. If you are a business below
  121. your country's income threshold and don't have a Tax ID, you can leave this
  122. blank. Taxes will be added to your invoice according to your country's tax laws
  123. in the near future.
  124. </TooltipContent>
  125. </Tooltip>
  126. }
  127. >
  128. <Popover open={showTaxIDsPopover} onOpenChange={setShowTaxIDsPopover}>
  129. <PopoverTrigger asChild>
  130. <FormControl>
  131. <Button
  132. type="default"
  133. role="combobox"
  134. size="medium"
  135. disabled={disabled}
  136. aria-expanded={showTaxIDsPopover}
  137. aria-controls={taxIdListboxId}
  138. className={cn(
  139. 'w-full justify-between h-[34px] pr-2',
  140. !selectedTaxId && 'text-muted'
  141. )}
  142. iconRight={
  143. <ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" strokeWidth={1.5} />
  144. }
  145. >
  146. {selectedTaxId
  147. ? `${selectedTaxId.country} - ${selectedTaxId.name}`
  148. : 'Select tax ID'}
  149. </Button>
  150. </FormControl>
  151. </PopoverTrigger>
  152. <PopoverContent
  153. id={taxIdListboxId}
  154. sameWidthAsTrigger
  155. className="p-0"
  156. align="start"
  157. >
  158. <Command>
  159. <CommandInput placeholder="Search tax ID..." />
  160. <CommandList>
  161. <CommandEmpty>No tax ID found.</CommandEmpty>
  162. <CommandGroup>
  163. {availableTaxIds.map((option) => (
  164. <CommandItem
  165. key={option.name}
  166. value={`${option.country} - ${option.name}`}
  167. onSelect={() => {
  168. onSelectTaxIdType(option.name)
  169. setShowTaxIDsPopover(false)
  170. }}
  171. >
  172. <Check
  173. className={cn(
  174. 'mr-2 h-4 w-4',
  175. selectedTaxId?.name === option.name ? 'opacity-100' : 'opacity-0'
  176. )}
  177. />
  178. {option.country} - {option.name}
  179. </CommandItem>
  180. ))}
  181. </CommandGroup>
  182. </CommandList>
  183. </Command>
  184. </PopoverContent>
  185. </Popover>
  186. <FormMessage />
  187. </FormItemLayout>
  188. )}
  189. />
  190. {selectedTaxId && (
  191. <div className="flex items-center space-x-2 [&>div]:w-full">
  192. <FormField
  193. name="tax_id_value"
  194. control={form.control}
  195. render={({ field }) => (
  196. <FormItemLayout hideMessage>
  197. <FormControl>
  198. <Input
  199. {...field}
  200. disabled={disabled}
  201. placeholder={selectedTaxId?.placeholder}
  202. />
  203. </FormControl>
  204. </FormItemLayout>
  205. )}
  206. />
  207. <Button
  208. type="text"
  209. className="px-1"
  210. icon={<X />}
  211. disabled={disabled}
  212. onClick={() => onRemoveTaxId()}
  213. />
  214. </div>
  215. )}
  216. </div>
  217. </div>
  218. )
  219. }