Files
supabase/apps/studio/components/interfaces/Organization/BillingSettings/CreditCodeRedemption.tsx
Alaister Young ca2b50a0a7 chore(ui-patterns): collapse the admonition shim into ui-patterns/Admonition (#48377)
Follow-up to #48344: collapses the two resolution paths for the
Admonition module into one.

`src/admonition.tsx` was a back-compat shim re-exporting
`src/Admonition/`. Two ways to resolve one module is exactly what
produced the macOS self-import bug fixed in #48344, and the local
typecheck errors that #48374 worked around. This removes the shim and
standardizes on the PascalCase subpath, matching every other export in
the package.

**Changed:**

- Codemodded all 246 `ui-patterns/admonition` imports to
`ui-patterns/Admonition` (240 `.tsx`, 5 `.mdx`, 1 `.ts` across studio,
docs, www, design-system, and lite-studio)
- Pointed the 5 internal `'../admonition'` imports back at the
`'../Admonition'` directory

**Removed:**

- `packages/ui-patterns/src/admonition.tsx`, and its `./admonition`
entry in the exports map (regenerated with `pnpm gen:exports`)

## To test

- `grep -r "ui-patterns/admonition" --include='*.ts*'` → no hits
- `pnpm test:case-hazards` → passes
- `pnpm typecheck` → all 15 tasks green
- `pnpm --filter studio run lint:ratchet` → passes
- `pnpm --filter ui-patterns vitest run src/Admonition` → 11 tests pass

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Standardized Admonition component imports across the application and
documentation.
* Improved compatibility with case-sensitive environments by using the
canonical component path.
  * Removed the legacy Admonition import entry point.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
2026-07-29 00:48:56 +08:00

351 lines
12 KiB
TypeScript

import HCaptcha from '@hcaptcha/react-hcaptcha'
import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { Calendar, PartyPopper } from 'lucide-react'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { useEffect, useRef, useState } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'
import {
Button,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogSection,
DialogSectionSeparator,
DialogTitle,
DialogTrigger,
Form,
FormField,
Input,
Separator,
} from 'ui'
import { Admonition } from 'ui-patterns/Admonition'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
import { TimestampInfo } from 'ui-patterns/TimestampInfo'
import { z } from 'zod'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { UpgradePlanButton } from '@/components/ui/UpgradePlanButton'
import { useOrganizationCreditCodeRedemptionMutation } from '@/data/organizations/organization-credit-code-redemption-mutation'
import { useOrganizationQuery } from '@/data/organizations/organization-query'
import { useOrgBalanceQuery } from '@/data/subscriptions/org-balance-query'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useLatest } from '@/hooks/misc/useLatest'
const FORM_ID = 'credit-code-redemption'
const FormSchema = z.object({
code: z.string().min(1, 'Code is required'),
})
type CreditCodeRedemptionForm = z.infer<typeof FormSchema>
export const CreditCodeRedemption = ({
slug,
modalVisible = false,
onClose,
}: {
slug?: string
modalVisible?: boolean
onClose?: () => void
}) => {
const router = useRouter()
const [codeRedemptionModalVisible, setCodeRedemptionModalVisible] = useState(
modalVisible || false
)
const { data: org, isLoading: isOrgLoading } = useOrganizationQuery({ slug })
const { data: orgBalance, isLoading: isOrgBalanceLoading } = useOrgBalanceQuery(
{ orgSlug: slug },
{ enabled: codeRedemptionModalVisible }
)
const combinedCreditBalanceCents = orgBalance?.total_balance_cents
const { can: canRedeemCode, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions(
PermissionAction.BILLING_WRITE,
'stripe.subscriptions',
undefined,
{ organizationSlug: slug }
)
const captchaRef = useRef<HCaptcha>(null)
const captchaTokenRef = useRef<string | null>(null)
const codeRedemptionDisabled =
!canRedeemCode || !isPermissionsLoaded || isOrgLoading || isOrgBalanceLoading
const form = useForm<CreditCodeRedemptionForm>({
resolver: zodResolver(FormSchema),
defaultValues: { code: '' },
})
const { isValid } = form.formState
const {
mutate: redeemCode,
isPending: redeemingCode,
error: errorRedeemingCode,
data: codeRedemptionResult,
reset: resetCodeRedemption,
} = useOrganizationCreditCodeRedemptionMutation({
onSuccess: () => {
form.setValue('code', '')
resetCaptcha()
},
})
const resetCaptcha = () => {
captchaTokenRef.current = null
captchaRef.current?.resetCaptcha()
}
const initHcaptcha = async () => {
let token = captchaTokenRef.current
try {
if (!token) {
const captchaResponse = await captchaRef.current?.execute({ async: true })
token = captchaResponse?.response ?? null
captchaTokenRef.current = token
return token
}
} catch (error) {
return token
}
return token
}
const initHcaptchaRef = useLatest(initHcaptcha)
const onSubmit: SubmitHandler<CreditCodeRedemptionForm> = async ({ code }) => {
const token = await initHcaptcha()
redeemCode({ slug, code, hcaptchaToken: token })
}
const onCodeRedemptionDialogVisibilityChange = (visible: boolean) => {
setCodeRedemptionModalVisible(visible)
if (!visible) {
resetCodeRedemption()
resetCaptcha()
onClose?.()
}
}
useEffect(() => {
if (!router.isReady) return
const queryCode = router.query.code
const codeFromParams = Array.isArray(queryCode) ? queryCode[0] : queryCode
if (typeof codeFromParams === 'string' && codeFromParams.trim().length > 2) {
form.setValue('code', codeFromParams)
}
}, [router.isReady, router.query.code, form])
useEffect(() => {
if (codeRedemptionModalVisible) {
initHcaptchaRef.current()
}
}, [codeRedemptionModalVisible, initHcaptchaRef])
return (
<Dialog open={codeRedemptionModalVisible} onOpenChange={onCodeRedemptionDialogVisibilityChange}>
{!modalVisible && (
<DialogTrigger asChild>
<ButtonTooltip
variant="default"
className="pointer-events-auto"
disabled={codeRedemptionDisabled}
tooltip={{
content: {
side: 'bottom',
text:
isPermissionsLoaded && !canRedeemCode
? 'You need additional permissions to redeem codes'
: undefined,
},
}}
>
Redeem Code
</ButtonTooltip>
</DialogTrigger>
)}
<DialogContent size="medium" onInteractOutside={(e) => e.preventDefault()}>
<HCaptcha
ref={captchaRef}
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={() => {
if (document !== undefined) document.body.classList.remove('pointer-events-auto!')
}}
onVerify={(token) => {
captchaTokenRef.current = token
if (document !== undefined) document.body.classList.remove('pointer-events-auto!')
}}
onExpire={() => {
captchaTokenRef.current = null
}}
/>
{!!codeRedemptionResult ? (
<div className="p-8">
<div className="text-center flex items-center justify-center">
<PartyPopper strokeWidth={1} className="h-14 w-14" />
</div>
<div className="text-center">
<p className=" text-lg mt-2">Credits redeemed!</p>
</div>
<Separator className="my-4" />
<div className="flex w-full justify-center items-center">
<div className="flex items-center space-x-1">
<p className="opacity-50 text-sm">$</p>
<p className="text-2xl">{codeRedemptionResult.amount_cents / 100}</p>
<p className="opacity-50 text-sm"> credits applied</p>
</div>
</div>
{codeRedemptionResult.credits_expire_at && (
<div className="mt-2 flex items-center justify-center gap-2 text-sm text-muted-foreground bg-muted/50 py-3 px-4 rounded-lg">
<Calendar className="h-4 w-4" />
<span>
Expires on{' '}
<TimestampInfo
className="text-sm"
utcTimestamp={codeRedemptionResult.credits_expire_at}
labelFormat="MMMM DD, YYYY"
/>
</span>
</div>
)}
{(!router.pathname.includes('/org/') || org?.plan.id === 'free') && (
<div className="mt-4 flex flex-col gap-y-4">
<Separator />
<div className="flex justify-center items-center gap-x-2">
{org?.plan.id === 'free' && (
<UpgradePlanButton plan="Pro" source="code-redeem" slug={org.slug}>
Upgrade organization
</UpgradePlanButton>
)}
{!router.pathname.includes('/org/') && (
<Button asChild variant="default">
<Link href={`/org/${org?.slug}`}>Go to organization</Link>
</Button>
)}
</div>
</div>
)}
</div>
) : (
<>
<DialogHeader>
<DialogTitle>Redeem Code</DialogTitle>
<DialogDescription className="space-y-2">
Redeem your credit code to add credits to your organization
</DialogDescription>
</DialogHeader>
<DialogSectionSeparator />
<Form {...form}>
{isOrgLoading || isOrgBalanceLoading || !isPermissionsLoaded ? (
<div className="p-6 space-y-4">
<ShimmeringLoader />
<div className="flex space-x-4">
<ShimmeringLoader className="w-1/2" />
<ShimmeringLoader className="w-1/2" />
</div>
</div>
) : (
<form id={FORM_ID} onSubmit={form.handleSubmit(onSubmit)}>
<DialogSection className="flex flex-col gap-2">
<FormField
control={form.control}
name="code"
render={({ field }) => (
<FormItemLayout
hideMessage
label="Code"
className="gap-1"
layout="horizontal"
>
<Input
{...field}
className="uppercase w-56 ml-auto"
placeholder="ABCD-1234-EFGH-5678"
/>
</FormItemLayout>
)}
/>
{combinedCreditBalanceCents !== undefined && combinedCreditBalanceCents > 0 && (
<div className="flex w-full justify-between items-center">
<span className="text-sm">Current Balance</span>
<div className="flex items-center gap-x-1">
<p className="opacity-50 text-sm">$</p>
<p className="text-2xl">{combinedCreditBalanceCents / 100}</p>
<p className="opacity-50 text-sm">/credits</p>
</div>
</div>
)}
<Admonition type="note" title="Potential future charges">
<p>
Credits are applied to <strong>{org?.name}</strong> only and cannot be
shared or transferred to other organizations. Credits are automatically used
toward invoices.
</p>
<p className="mt-2">
When credits run out on a paid plan, your default payment method will be
chargedyour plan won't be downgraded automatically.
</p>
</Admonition>
{errorRedeemingCode && (
<Admonition
type="warning"
title="Unable to redeem code"
description={errorRedeemingCode?.message}
/>
)}
</DialogSection>
<DialogFooter>
<ButtonTooltip
variant="primary"
className="pointer-events-auto"
loading={redeemingCode}
disabled={codeRedemptionDisabled || !isValid}
type="submit"
tooltip={{
content: {
side: 'bottom',
text:
isPermissionsLoaded && !canRedeemCode
? 'You need additional permissions to redeem codes'
: undefined,
},
}}
>
Redeem
</ButtonTooltip>
</DialogFooter>
</form>
)}
</Form>
</>
)}
</DialogContent>
</Dialog>
)
}