mirror of
https://github.com/supabase/supabase.git
synced 2026-09-07 02:20:52 +08:00
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>
351 lines
12 KiB
TypeScript
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
|
|
charged—your 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>
|
|
)
|
|
}
|