mirror of
https://github.com/supabase/supabase.git
synced 2026-10-09 11:25:06 +03:00
## What kind of change does this PR introduce? Mechanical cleanup on top of the Button default-variant change (#50160). ## What is the current behavior? Many callsites still pass `variant="default"` even though that is now the component default. ## What is the new behavior? Removes redundant static `variant="default"` from legacy `Button` and `ButtonTooltip` callsites. Keeps explicit defaults where they document the API: - `button-default.tsx` and `button-sizes.tsx` demos - `DocsButton`, which pins neutral styling at the wrapper boundary ## To test Studio: - [Auth → Rate Limits](https://studio-staging-2s957kwc4-supabase.vercel.app/dashboard/project/_/auth/rate-limits): dirty the form so Cancel appears; Cancel stays neutral, Save stays green - [Project Settings → API Keys](https://studio-staging-2s957kwc4-supabase.vercel.app/dashboard/project/_/settings/api-keys): `DocsButton` in the header actions stays neutral Design system: - [Design system → Button](https://design-system-git-dnywh-dc924ac1-supabase.vercel.app/design-system/docs/components/button): `button-default` / `button-sizes` still show explicit default styling; Primary (green) is restricted to the Primary section (and `asChild`) WWW: - [www → Brand assets](https://zone-www-dot-com-git-dnywh-dc924ac1-supabase.vercel.app/brand-assets): Download logo kit / Download button kit stay neutral
426 lines
15 KiB
TypeScript
426 lines
15 KiB
TypeScript
import HCaptcha from '@hcaptcha/react-hcaptcha'
|
|
import { Elements } from '@stripe/react-stripe-js'
|
|
import { loadStripe, PaymentMethod, StripeElementsOptions } from '@stripe/stripe-js'
|
|
import { useParams } from 'common'
|
|
import { Loader, Plus } from 'lucide-react'
|
|
import { useTheme } from 'next-themes'
|
|
import {
|
|
forwardRef,
|
|
useCallback,
|
|
useEffect,
|
|
useImperativeHandle,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react'
|
|
import { toast } from 'sonner'
|
|
import { Button, Checkbox, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from 'ui'
|
|
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
|
|
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
|
|
|
|
import { getStripeElementsAppearanceOptions } from '@/components/interfaces/Billing/Payment/Payment.utils'
|
|
import {
|
|
NewPaymentMethodElement,
|
|
type PaymentMethodElementRef,
|
|
} from '@/components/interfaces/Billing/Payment/PaymentMethods/NewPaymentMethodElement'
|
|
import { useOrganizationCustomerProfileQuery } from '@/data/organizations/organization-customer-profile-query'
|
|
import { useOrganizationCustomerProfileUpdateMutation } from '@/data/organizations/organization-customer-profile-update-mutation'
|
|
import { useOrganizationPaymentMethodSetupIntent } from '@/data/organizations/organization-payment-method-setup-intent-mutation'
|
|
import { useOrganizationPaymentMethodsQuery } from '@/data/organizations/organization-payment-methods-query'
|
|
import { useOrganizationTaxIdQuery } from '@/data/organizations/organization-tax-id-query'
|
|
import type { CustomerAddress, CustomerTaxId } from '@/data/organizations/types'
|
|
import { SetupIntentResponse } from '@/data/stripe/setup-intent-mutation'
|
|
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
|
import { BASE_PATH, STRIPE_PUBLIC_KEY } from '@/lib/constants'
|
|
import { useOrgSettingsPageStateSnapshot } from '@/state/organization-settings'
|
|
|
|
const stripePromise = loadStripe(STRIPE_PUBLIC_KEY)
|
|
|
|
export interface PaymentMethodSelectionProps {
|
|
selectedPaymentMethod?: string
|
|
onSelectPaymentMethod: (id: string) => void
|
|
layout?: 'vertical' | 'horizontal'
|
|
readOnly: boolean
|
|
onAddressChange?: (address: CustomerAddress) => void
|
|
onTaxIdChange?: (taxId: CustomerTaxId | null) => void
|
|
useAsDefaultBillingAddress: boolean
|
|
onUseAsDefaultBillingAddressChange: (useAsDefault: boolean) => void
|
|
onClose?: () => void
|
|
}
|
|
|
|
const PaymentMethodSelection = forwardRef(function PaymentMethodSelection(
|
|
{
|
|
selectedPaymentMethod,
|
|
onSelectPaymentMethod,
|
|
layout = 'vertical',
|
|
readOnly,
|
|
onAddressChange,
|
|
onTaxIdChange,
|
|
useAsDefaultBillingAddress,
|
|
onUseAsDefaultBillingAddressChange,
|
|
onClose,
|
|
}: PaymentMethodSelectionProps,
|
|
ref
|
|
) {
|
|
const { slug } = useParams()
|
|
const snap = useOrgSettingsPageStateSnapshot()
|
|
const { data: selectedOrganization } = useSelectedOrganizationQuery()
|
|
const [captchaToken, setCaptchaToken] = useState<string | null>(null)
|
|
const [captchaRef, setCaptchaRef] = useState<HCaptcha | null>(null)
|
|
const [setupIntent, setSetupIntent] = useState<SetupIntentResponse | undefined>(undefined)
|
|
const { resolvedTheme } = useTheme()
|
|
const paymentRef = useRef<PaymentMethodElementRef | null>(null)
|
|
const [setupNewPaymentMethod, setSetupNewPaymentMethod] = useState<boolean | null>(null)
|
|
const { data: customerProfile, isPending: isCustomerProfileLoading } =
|
|
useOrganizationCustomerProfileQuery({
|
|
slug,
|
|
})
|
|
const {
|
|
data: taxId,
|
|
isPending: isCustomerTaxIdLoading,
|
|
isError: isTaxIdError,
|
|
} = useOrganizationTaxIdQuery({ slug })
|
|
const { mutateAsync: updateCustomerProfile } = useOrganizationCustomerProfileUpdateMutation({
|
|
onError: () => {},
|
|
})
|
|
|
|
const { data: allPaymentMethods, isPending: isLoading } = useOrganizationPaymentMethodsQuery({
|
|
slug,
|
|
})
|
|
|
|
const paymentMethods = useMemo(() => {
|
|
if (!allPaymentMethods)
|
|
return {
|
|
data: [],
|
|
defaultPaymentMethodId: null,
|
|
}
|
|
|
|
return {
|
|
// force customer to put down address via payment method creation flow if they don't have an address set
|
|
data: customerProfile?.address == null ? [] : allPaymentMethods.data,
|
|
defaultPaymentMethodId: allPaymentMethods.data.some(
|
|
(pm) => pm.id === allPaymentMethods.defaultPaymentMethodId
|
|
)
|
|
? allPaymentMethods.defaultPaymentMethodId
|
|
: null,
|
|
}
|
|
}, [allPaymentMethods, customerProfile])
|
|
|
|
const captchaRefCallback = useCallback((node: any) => {
|
|
setCaptchaRef(node)
|
|
}, [])
|
|
|
|
const { mutate: initSetupIntent, isPending: setupIntentLoading } =
|
|
useOrganizationPaymentMethodSetupIntent({
|
|
onSuccess: (intent) => {
|
|
setSetupIntent(intent)
|
|
},
|
|
onError: (error) => {
|
|
toast.error(`Failed to setup intent: ${error.message}`)
|
|
},
|
|
})
|
|
|
|
useEffect(() => {
|
|
if (paymentMethods?.data && paymentMethods.data.length === 0 && setupNewPaymentMethod == null) {
|
|
setSetupNewPaymentMethod(true)
|
|
}
|
|
}, [paymentMethods])
|
|
|
|
useEffect(() => {
|
|
const loadSetupIntent = async (hcaptchaToken: string | undefined) => {
|
|
const slug = selectedOrganization?.slug
|
|
if (!slug) return console.error('Slug is required')
|
|
if (!hcaptchaToken) return console.error('HCaptcha token required')
|
|
|
|
setSetupIntent(undefined)
|
|
initSetupIntent({ slug: slug!, hcaptchaToken })
|
|
}
|
|
|
|
const loadPaymentForm = async () => {
|
|
if (setupNewPaymentMethod && captchaRef) {
|
|
let token = captchaToken
|
|
|
|
try {
|
|
if (!token) {
|
|
const captchaResponse = await captchaRef.execute({ async: true })
|
|
token = captchaResponse?.response ?? null
|
|
}
|
|
} catch (error) {
|
|
return
|
|
}
|
|
|
|
await loadSetupIntent(token ?? undefined)
|
|
resetCaptcha()
|
|
}
|
|
}
|
|
|
|
loadPaymentForm()
|
|
}, [captchaRef, setupNewPaymentMethod])
|
|
|
|
const resetCaptcha = () => {
|
|
setCaptchaToken(null)
|
|
captchaRef?.resetCaptcha()
|
|
}
|
|
|
|
const stripeOptionsPaymentMethod: StripeElementsOptions = useMemo(
|
|
() =>
|
|
({
|
|
clientSecret: setupIntent ? setupIntent.client_secret! : '',
|
|
appearance: getStripeElementsAppearanceOptions(resolvedTheme),
|
|
paymentMethodCreation: 'manual',
|
|
}) as const,
|
|
[setupIntent, resolvedTheme]
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (paymentMethods?.data && paymentMethods.data.length > 0) {
|
|
const selectedPaymentMethodExists = paymentMethods.data.some(
|
|
(it) => it.id === selectedPaymentMethod
|
|
)
|
|
|
|
if (!selectedPaymentMethod || !selectedPaymentMethodExists) {
|
|
const defaultPaymentMethod = paymentMethods.data.find((method) => method.is_default)
|
|
if (defaultPaymentMethod !== undefined) {
|
|
onSelectPaymentMethod(defaultPaymentMethod.id)
|
|
} else {
|
|
onSelectPaymentMethod(paymentMethods.data[0].id)
|
|
}
|
|
}
|
|
}
|
|
}, [selectedPaymentMethod, paymentMethods, onSelectPaymentMethod])
|
|
|
|
const getFormValues = async (): ReturnType<PaymentMethodElementRef['getFormValues']> => {
|
|
if (setupNewPaymentMethod || (paymentMethods?.data && paymentMethods.data.length === 0)) {
|
|
return paymentRef.current?.getFormValues()
|
|
} else {
|
|
return {
|
|
address: customerProfile?.address ?? ({} as CustomerAddress),
|
|
customerName: customerProfile?.billing_name || '',
|
|
taxId: taxId ?? null,
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate address/tax ID with a dry run before proceeding with Stripe,
|
|
// so validation errors (e.g. invalid tax ID) block the flow early.
|
|
const validateBillingProfile = async (): Promise<boolean> => {
|
|
if (!useAsDefaultBillingAddress) return true
|
|
|
|
if (isTaxIdError || isCustomerTaxIdLoading) {
|
|
toast.error(
|
|
isTaxIdError
|
|
? 'Unable to load current tax ID. Please try again.'
|
|
: 'Tax ID is still loading. Please wait and try again.'
|
|
)
|
|
return false
|
|
}
|
|
|
|
const formValues = await getFormValues()
|
|
if (!formValues) return false
|
|
|
|
try {
|
|
await updateCustomerProfile({
|
|
slug,
|
|
address: formValues.address,
|
|
billing_name: formValues.customerName,
|
|
tax_id: formValues.taxId,
|
|
dry_run: true,
|
|
})
|
|
} catch (error) {
|
|
const errorMessage =
|
|
error instanceof Error ? error.message : 'Failed to validate billing profile'
|
|
if (errorMessage.includes('The tax ID type does not match the billing address country')) {
|
|
toast.error(
|
|
<div>
|
|
<p>{errorMessage} Update your billing address or tax ID before upgrading your plan.</p>
|
|
<Button
|
|
className="mt-2"
|
|
onClick={() => {
|
|
if (onClose) {
|
|
onClose()
|
|
snap.setPanelKey(undefined)
|
|
const el = document.getElementById('address')
|
|
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
}
|
|
}}
|
|
>
|
|
Update tax ID
|
|
</Button>
|
|
</div>
|
|
)
|
|
} else {
|
|
toast.error(errorMessage)
|
|
}
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// If createPaymentMethod already exists, use it. Otherwise, define it here.
|
|
const createPaymentMethod = async (): ReturnType<
|
|
PaymentMethodElementRef['createPaymentMethod']
|
|
> => {
|
|
if (setupNewPaymentMethod || (paymentMethods?.data && paymentMethods.data.length === 0)) {
|
|
const paymentResult = await paymentRef.current?.createPaymentMethod()
|
|
|
|
if (!paymentResult) return paymentResult
|
|
|
|
return {
|
|
paymentMethod: paymentResult.paymentMethod,
|
|
customerName: useAsDefaultBillingAddress ? paymentResult.customerName : null,
|
|
address: useAsDefaultBillingAddress ? paymentResult.address : null,
|
|
taxId: useAsDefaultBillingAddress ? paymentResult.taxId : null,
|
|
}
|
|
} else {
|
|
return {
|
|
paymentMethod: { id: selectedPaymentMethod } as PaymentMethod,
|
|
customerName: useAsDefaultBillingAddress ? customerProfile?.billing_name || '' : null,
|
|
address: useAsDefaultBillingAddress ? (customerProfile?.address ?? null) : null,
|
|
taxId: useAsDefaultBillingAddress ? (taxId ?? null) : null,
|
|
}
|
|
}
|
|
}
|
|
|
|
useImperativeHandle(ref, () => ({
|
|
createPaymentMethod,
|
|
validateBillingProfile,
|
|
}))
|
|
|
|
return (
|
|
<>
|
|
<HCaptcha
|
|
ref={captchaRefCallback}
|
|
sitekey={process.env.NEXT_PUBLIC_HCAPTCHA_SITE_KEY!}
|
|
size="invisible"
|
|
onOpen={() => {
|
|
// [Joshen] This is to ensure that hCaptcha popup remains clickable
|
|
if (document !== undefined) document.body.classList.add('pointer-events-auto!')
|
|
}}
|
|
onClose={() => {
|
|
setSetupIntent(undefined)
|
|
if (document !== undefined) document.body.classList.remove('pointer-events-auto!')
|
|
}}
|
|
onVerify={(token) => {
|
|
setCaptchaToken(token)
|
|
if (document !== undefined) document.body.classList.remove('pointer-events-auto!')
|
|
}}
|
|
onExpire={() => {
|
|
setCaptchaToken(null)
|
|
}}
|
|
/>
|
|
|
|
<div>
|
|
{isLoading || isCustomerProfileLoading ? (
|
|
<div className="flex items-center px-4 py-2 space-x-4 border rounded-md border-strong bg-surface-200">
|
|
<Loader className="animate-spin" size={14} />
|
|
<p className="text-sm text-foreground-light">Retrieving payment methods</p>
|
|
</div>
|
|
) : paymentMethods?.data && paymentMethods?.data.length > 0 && !setupNewPaymentMethod ? (
|
|
<FormItemLayout
|
|
id="payment-method"
|
|
isReactForm={false}
|
|
layout={layout}
|
|
label="Payment method"
|
|
className="gap-[2px]"
|
|
size="tiny"
|
|
>
|
|
<Select
|
|
value={selectedPaymentMethod}
|
|
onValueChange={(value) => {
|
|
if (value === 'new') {
|
|
setSetupNewPaymentMethod(true)
|
|
return
|
|
}
|
|
onSelectPaymentMethod(value)
|
|
}}
|
|
>
|
|
<SelectTrigger id="payment-method">
|
|
<SelectValue className="flex gap-2" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{paymentMethods?.data.map((method) => {
|
|
const label = `•••• •••• •••• ${method.card?.last4}`
|
|
return (
|
|
<SelectItem key={method.id} value={method.id}>
|
|
<div className="flex gap-2">
|
|
<img
|
|
alt="Credit Card Brand"
|
|
src={`${BASE_PATH}/img/payment-methods/${method.card?.brand
|
|
.replace(' ', '-')
|
|
.toLowerCase()}.png`}
|
|
width="32"
|
|
/>
|
|
{label}
|
|
</div>
|
|
</SelectItem>
|
|
)
|
|
})}
|
|
<SelectItem value="new">
|
|
<div className="flex gap-2">
|
|
<Plus size={16} />
|
|
<p className="transition text-foreground-light group-hover:text-foreground">
|
|
Add new payment method
|
|
</p>
|
|
</div>
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</FormItemLayout>
|
|
) : null}
|
|
|
|
{stripePromise && setupIntent && customerProfile && (
|
|
<>
|
|
<Elements stripe={stripePromise} options={stripeOptionsPaymentMethod}>
|
|
<NewPaymentMethodElement
|
|
ref={paymentRef}
|
|
email={selectedOrganization?.billing_email ?? undefined}
|
|
readOnly={readOnly}
|
|
customerName={customerProfile?.billing_name}
|
|
currentAddress={customerProfile?.address}
|
|
currentTaxId={taxId}
|
|
onAddressChange={onAddressChange}
|
|
onTaxIdChange={onTaxIdChange}
|
|
/>
|
|
</Elements>
|
|
|
|
{/* If the customer already has a billing address, optionally allow overwriting it - if they have no address, we use that as a default */}
|
|
{customerProfile?.address != null && (
|
|
<div className="flex items-center space-x-2 mt-4">
|
|
<Checkbox
|
|
id="defaultBillingAddress"
|
|
checked={useAsDefaultBillingAddress}
|
|
onCheckedChange={() => {
|
|
onUseAsDefaultBillingAddressChange(!useAsDefaultBillingAddress)
|
|
}}
|
|
/>
|
|
<label
|
|
htmlFor="defaultBillingAddress"
|
|
className="text-sm leading-none text-foreground-light"
|
|
>
|
|
Use address as my org's billing address
|
|
</label>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{(setupIntentLoading || isCustomerProfileLoading || isCustomerTaxIdLoading) && (
|
|
<div className="space-y-2">
|
|
<ShimmeringLoader className="h-10" />
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<ShimmeringLoader className="h-10" />
|
|
<ShimmeringLoader className="h-10" />
|
|
</div>
|
|
<ShimmeringLoader className="h-10" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)
|
|
})
|
|
|
|
PaymentMethodSelection.displayName = 'PaymentMethodSelection'
|
|
|
|
export default PaymentMethodSelection
|