mirror of
https://github.com/supabase/supabase.git
synced 2026-10-08 10:55:06 +03:00
feat(billing-address): radio groups to purchase as business/individual
This commit is contained in:
4 files changed
+230
-141
No files matched your search
+42
-23
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
+4
@@ -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 && (
|
||||
|
||||
+160
-114
@@ -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>
|
||||
)
|
||||
}
|
||||
+24
-4
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user