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