feat(billing-address): radio groups to purchase as business/individual

This commit is contained in:
JulesWritesCode committed 2026-07-25 20:56:39 +02:00
1 parent 2b27ed0ab1
commit 4eb98c87fb
4 files changed
+230 -141

No files matched your search

@@ -18,7 +18,6 @@ import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import {
Button,
Checkbox,
cn,
Command,
CommandEmpty,
@@ -34,6 +33,8 @@ import {
Popover,
PopoverContent,
PopoverTrigger,
RadioGroup,
RadioGroupItem,
Tooltip,
TooltipContent,
TooltipTrigger,
@@ -144,7 +145,11 @@ export const NewPaymentMethodElement = forwardRef(
const taxIdValue = rawTaxIdValue?.trim() ?? ''
const selectedTaxId = TAX_IDS.find((option) => option.name === tax_id_name)
const [purchasingAsBusiness, setPurchasingAsBusiness] = useState(currentTaxId != null)
const [purchasingAs, setPurchasingAs] = useState<'individual' | 'business'>(
currentTaxId != null ? 'business' : 'individual'
)
const purchasingAsBusiness = purchasingAs === 'business'
const [stripeAddress, setStripeAddress] = useState<
StripeAddressElementChangeEvent['value'] | undefined
>(undefined)
@@ -286,6 +291,7 @@ export const NewPaymentMethodElement = forwardRef(
apiKey: process.env.NEXT_PUBLIC_GOOGLE_MAPS_KEY!,
mode: 'google_maps_api',
},
display: { name: purchasingAsBusiness ? 'organization' : 'full' },
// Use live form state (stripeAddress) so the address survives remounts triggered
// by the purchasingAsBusiness toggle (which changes the key prop). Without this,
@@ -341,28 +347,41 @@ export const NewPaymentMethodElement = forwardRef(
{fullyLoaded && (
<div className="flex items-center space-x-2 py-4">
<Checkbox
id="business"
checked={purchasingAsBusiness}
onCheckedChange={() => setPurchasingAsBusiness(!purchasingAsBusiness)}
/>
<label htmlFor="business" className="text-foreground text-sm leading-none">
I’m purchasing as a business
</label>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle
size={14}
className="text-foreground-lighter hover:text-foreground transition"
<RadioGroup
defaultValue={'individual'}
value={purchasingAs}
onValueChange={(v) => setPurchasingAs(v === 'business' ? 'business' : 'individual')}
className="text-sm"
>
<div className="flex items-center gap-3">
<RadioGroupItem
value="individual"
id="purchasing-as-individual"
className="cursor-pointer"
/>
</TooltipTrigger>
<TooltipContent side="top" className="w-72">
Check this only if you need a tax ID (e.g. US EIN, VAT, GST) on your invoice. You’ll
be asked to enter it, and it’ll appear on a compliant business invoice. If you don’t
have a tax ID, or don’t need one shown, leave this unchecked. You’ll still receive a
receipt.
</TooltipContent>
</Tooltip>
<label htmlFor="purchasing-as-individual">I'm purchasing as an individual</label>
</div>
<div className="flex items-center gap-3">
<RadioGroupItem
value="business"
id="purchasing-as-business"
className="cursor-pointer"
/>
<label htmlFor="purchasing-as-business">I'm purchasing as a business</label>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle
size={14}
className="text-foreground-lighter hover:text-foreground transition"
/>
</TooltipTrigger>
<TooltipContent side="top" className="w-72">
Purchasing as a business allows you to add a tax ID (e.g. US EIN, VAT, GST) to
your account, which will then show up on your invoice.
</TooltipContent>
</Tooltip>
</div>
</RadioGroup>
</div>
)}
@@ -79,6 +79,8 @@ export const BillingCustomerData = () => {
markCurrentValuesAsSaved,
addressCountry,
addressOptions,
purchasingAs,
setPurchasingAs,
} = useBillingCustomerDataForm({
customerProfile,
taxId,
@@ -221,6 +223,8 @@ export const BillingCustomerData = () => {
addressElementRef.current = element
}}
addressCountry={addressCountry}
purchasingAs={purchasingAs}
setPurchasingAs={setPurchasingAs}
/>
<CardFooter className="border-t justify-end px-8">
{!canUpdateBillingCustomerData && (
@@ -4,7 +4,7 @@ import type {
StripeAddressElementChangeEvent,
StripeAddressElementOptions,
} from '@stripe/stripe-js'
import { Check, ChevronsUpDown, Info, X } from 'lucide-react'
import { Check, ChevronsUpDown, HelpCircle, Info, X } from 'lucide-react'
import { useEffect, useId, useMemo, useRef, useState } from 'react'
import { UseFormReturn } from 'react-hook-form'
import {
@@ -23,6 +23,8 @@ import {
Popover,
PopoverContent,
PopoverTrigger,
RadioGroup,
RadioGroupItem,
Tooltip,
TooltipContent,
TooltipTrigger,
@@ -31,6 +33,8 @@ import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { z } from 'zod'
import { TAX_IDS } from './TaxID.constants'
import { PurchasingAs } from './useBillingCustomerDataForm'
import { CustomerTaxId } from '@/data/organizations/types'
interface BillingCustomerDataFormProps {
form: UseFormReturn<TaxIdFormValues>
@@ -40,7 +44,10 @@ interface BillingCustomerDataFormProps {
resetKey: number
onAddressChange: (evt: StripeAddressElementChangeEvent) => void
onAddressReady?: (element: StripeAddressElement) => void
purchasingAs: PurchasingAs
setPurchasingAs: (as: PurchasingAs) => void
addressCountry?: string
taxId?: CustomerTaxId
}
export const TaxIdSchema = z.object({
@@ -60,6 +67,8 @@ export const BillingCustomerDataForm = ({
onAddressChange,
onAddressReady,
addressCountry,
purchasingAs,
setPurchasingAs,
}: BillingCustomerDataFormProps) => {
const [showTaxIDsPopover, setShowTaxIDsPopover] = useState(false)
const taxIdListboxId = useId()
@@ -106,6 +115,38 @@ export const BillingCustomerDataForm = ({
return (
<div className={cn('flex flex-col space-y-4', className)}>
<RadioGroup
defaultValue={'individual'}
value={purchasingAs}
onValueChange={(v) => setPurchasingAs(v === 'business' ? 'business' : 'individual')}
className="text-sm"
>
<div className="flex items-center gap-3">
<RadioGroupItem
value="individual"
id="purchasing-as-individual"
className="cursor-pointer"
/>
<label htmlFor="purchasing-as-individual">I'm purchasing as an individual</label>
</div>
<div className="flex items-center gap-3">
<RadioGroupItem value="business" id="purchasing-as-business" className="cursor-pointer" />
<label htmlFor="purchasing-as-business">I'm purchasing as a business</label>
<Tooltip>
<TooltipTrigger asChild>
<HelpCircle
size={14}
className="text-foreground-lighter hover:text-foreground transition"
/>
</TooltipTrigger>
<TooltipContent side="top" className="w-72">
Purchasing as a business allows you to add a tax ID (e.g. US EIN, VAT, GST) to your
account, which will then show up on your invoice.
</TooltipContent>
</Tooltip>
</div>
</RadioGroup>
<div className={cn('relative', disabled && 'opacity-50')}>
<AddressElement
key={`billing-address-${resetKey}`}
@@ -116,120 +157,125 @@ export const BillingCustomerDataForm = ({
{disabled && <div className="absolute inset-0 z-10 cursor-not-allowed" />}
</div>
<div className={cn('grid grid-cols-2 gap-x-6 w-full items-end', disabled && 'opacity-50')}>
<FormField
name="tax_id_name"
control={form.control}
render={() => (
<FormItemLayout
hideMessage
layout="vertical"
label="Business Tax ID"
afterLabel={
<Tooltip>
<TooltipTrigger asChild>
<Info size={14} className="text-foreground-lighter cursor-pointer" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs text-left">
If you are an individual, no need to add a Tax ID. If you are a business below
your country's income threshold and don't have a Tax ID, you can leave this
blank. Taxes will be added to your invoice according to your country's tax laws
in the near future.
</TooltipContent>
</Tooltip>
}
>
<Popover open={showTaxIDsPopover} onOpenChange={setShowTaxIDsPopover}>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="default"
role="combobox"
size="medium"
disabled={disabled}
aria-expanded={showTaxIDsPopover}
aria-controls={taxIdListboxId}
className={cn(
'w-full justify-between h-[34px] pr-2',
!selectedTaxId && 'text-muted'
)}
iconRight={
<ChevronsUpDown className="h-4 w-4 shrink-0 opacity-50" strokeWidth={1.5} />
}
>
{selectedTaxId
? `${selectedTaxId.country} - ${selectedTaxId.name}`
: 'Select tax ID'}
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent
id={taxIdListboxId}
sameWidthAsTrigger
className="p-0"
align="start"
>
<Command>
<CommandInput placeholder="Search tax ID..." />
<CommandList>
<CommandEmpty>No tax ID found.</CommandEmpty>
<CommandGroup>
{availableTaxIds.map((option) => (
<CommandItem
key={option.name}
value={`${option.country} - ${option.name}`}
onSelect={() => {
onSelectTaxIdType(option.name)
setShowTaxIDsPopover(false)
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedTaxId?.name === option.name ? 'opacity-100' : 'opacity-0'
)}
/>
{option.country} - {option.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItemLayout>
{purchasingAs === 'business' && (
<div className={cn('grid grid-cols-2 gap-x-6 w-full items-end', disabled && 'opacity-50')}>
<FormField
name="tax_id_name"
control={form.control}
render={() => (
<FormItemLayout
hideMessage
layout="vertical"
label="Business Tax ID"
afterLabel={
<Tooltip>
<TooltipTrigger asChild>
<Info size={14} className="text-foreground-lighter cursor-pointer" />
</TooltipTrigger>
<TooltipContent side="right" className="max-w-xs text-left">
If you are an individual, no need to add a Tax ID. If you are a business below
your country's income threshold and don't have a Tax ID, you can leave this
blank. Taxes will be added to your invoice according to your country's tax
laws in the near future.
</TooltipContent>
</Tooltip>
}
>
<Popover open={showTaxIDsPopover} onOpenChange={setShowTaxIDsPopover}>
<PopoverTrigger asChild>
<FormControl>
<Button
variant="default"
role="combobox"
size="medium"
disabled={disabled}
aria-expanded={showTaxIDsPopover}
aria-controls={taxIdListboxId}
className={cn(
'w-full justify-between h-[34px] pr-2',
!selectedTaxId && 'text-muted'
)}
iconRight={
<ChevronsUpDown
className="h-4 w-4 shrink-0 opacity-50"
strokeWidth={1.5}
/>
}
>
{selectedTaxId
? `${selectedTaxId.country} - ${selectedTaxId.name}`
: 'Select tax ID'}
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent
id={taxIdListboxId}
sameWidthAsTrigger
className="p-0"
align="start"
>
<Command>
<CommandInput placeholder="Search tax ID..." />
<CommandList>
<CommandEmpty>No tax ID found.</CommandEmpty>
<CommandGroup>
{availableTaxIds.map((option) => (
<CommandItem
key={option.name}
value={`${option.country} - ${option.name}`}
onSelect={() => {
onSelectTaxIdType(option.name)
setShowTaxIDsPopover(false)
}}
>
<Check
className={cn(
'mr-2 h-4 w-4',
selectedTaxId?.name === option.name ? 'opacity-100' : 'opacity-0'
)}
/>
{option.country} - {option.name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<FormMessage />
</FormItemLayout>
)}
/>
{selectedTaxId && (
<div className="flex items-center space-x-2 [&>div]:w-full">
<FormField
name="tax_id_value"
control={form.control}
render={({ field }) => (
<FormItemLayout hideMessage>
<FormControl>
<Input
{...field}
disabled={disabled}
placeholder={selectedTaxId?.placeholder}
/>
</FormControl>
</FormItemLayout>
)}
/>
<Button
variant="text"
className="px-1"
icon={<X />}
disabled={disabled}
onClick={() => onRemoveTaxId()}
/>
</div>
)}
/>
{selectedTaxId && (
<div className="flex items-center space-x-2 [&>div]:w-full">
<FormField
name="tax_id_value"
control={form.control}
render={({ field }) => (
<FormItemLayout hideMessage>
<FormControl>
<Input
{...field}
disabled={disabled}
placeholder={selectedTaxId?.placeholder}
/>
</FormControl>
</FormItemLayout>
)}
/>
<Button
variant="text"
className="px-1"
icon={<X />}
disabled={disabled}
onClick={() => onRemoveTaxId()}
/>
</div>
)}
</div>
</div>
)}
</div>
)
}
@@ -31,6 +31,8 @@ export type BillingAddressPayload = {
tax_id: CustomerTaxId | null
}
export type PurchasingAs = 'business' | 'individual'
type SubmittedBillingFormState = {
addressValue: StripeAddressValue
taxIdValues: TaxIdFormValues
@@ -66,6 +68,14 @@ export function useBillingCustomerDataForm({
[customerProfile]
)
const [purchasingAs, setPurchasingAs] = useState<PurchasingAs>(
taxId != null ? 'business' : 'individual'
)
useEffect(() => {
setPurchasingAs(taxId ? 'business' : 'individual')
}, [taxId])
const addressOptions: StripeAddressElementOptions = useMemo(
() => ({
mode: 'billing',
@@ -74,10 +84,10 @@ export function useBillingCustomerDataForm({
apiKey: process.env.NEXT_PUBLIC_GOOGLE_MAPS_KEY!,
mode: 'google_maps_api' as const,
},
display: { name: 'full' as const },
display: { name: purchasingAs === 'business' ? 'organization' : 'full' },
defaultValues: initialStripeAddressValue,
}),
[initialStripeAddressValue]
[initialStripeAddressValue, purchasingAs]
)
const initialTaxIdValues = useMemo(
@@ -119,6 +129,10 @@ export function useBillingCustomerDataForm({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialStripeAddressValue, initialTaxIdValues])
useEffect(() => {
setResetKey((c) => c + 1)
}, [purchasingAs])
const onAddressChange = useCallback((evt: StripeAddressElementChangeEvent) => {
stripeAddressRef.current = evt.value
stripeAddressValidationRef.current = evt.complete ? 'complete' : 'incomplete'
@@ -133,7 +147,8 @@ export function useBillingCustomerDataForm({
setIsAddressDirty(!isAddressEqual(result.value, savedStripeAddressRef.current))
}, [])
const isDirty = isAddressDirty || form.formState.isDirty
const savedTaxIdButPurchasingAsIndividual = taxId && purchasingAs === 'individual'
const isDirty = isAddressDirty || form.formState.isDirty || savedTaxIdButPurchasingAsIndividual
const syncCurrentState = useCallback(
(addressValue: StripeAddressValue, taxIdValues: TaxIdFormValues) => {
@@ -179,7 +194,10 @@ export function useBillingCustomerDataForm({
},
billing_name: address.name.trim(),
tax_id:
selectedTaxId && taxIdValues.tax_id_type?.length && taxIdValue.length
selectedTaxId &&
taxIdValues.tax_id_type?.length &&
taxIdValue.length &&
purchasingAs === 'business'
? {
type: taxIdValues.tax_id_type,
value: sanitizeTaxIdValue({
@@ -224,6 +242,8 @@ export function useBillingCustomerDataForm({
markCurrentValuesAsSaved,
addressCountry,
addressOptions,
purchasingAs,
setPurchasingAs,
}
}