diff --git a/.changeset/young-areas-divide.md b/.changeset/young-areas-divide.md new file mode 100644 index 00000000000..d68ddff805c --- /dev/null +++ b/.changeset/young-areas-divide.md @@ -0,0 +1,7 @@ +--- +'@clerk/clerk-js': minor +'@clerk/shared': minor +'@clerk/react': minor +--- + +Introduce `useWaitlist()` hook diff --git a/integration/presets/envs.ts b/integration/presets/envs.ts index b85cc6926ce..9dc71de2c77 100644 --- a/integration/presets/envs.ts +++ b/integration/presets/envs.ts @@ -117,9 +117,9 @@ const withLegalConsent = base .setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-legal-consent').sk) .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-legal-consent').pk); -const withWaitlistdMode = withEmailCodes +const withWaitlistMode = withEmailCodes .clone() - .setId('withWaitlistdMode') + .setId('withWaitlistMode') .setEnvVariable('private', 'CLERK_SECRET_KEY', instanceKeys.get('with-waitlist-mode').sk) .setEnvVariable('public', 'CLERK_PUBLISHABLE_KEY', instanceKeys.get('with-waitlist-mode').pk); @@ -213,7 +213,7 @@ export const envs = { withSignInOrUpEmailLinksFlow, withSignInOrUpFlow, withSignInOrUpwithRestrictedModeFlow, - withWaitlistdMode, + withWaitlistMode, withWhatsappPhoneCode, withProtectService, } as const; diff --git a/integration/templates/custom-flows-react-vite/src/main.tsx b/integration/templates/custom-flows-react-vite/src/main.tsx index f89e557a8dc..7f8b6058c2c 100644 --- a/integration/templates/custom-flows-react-vite/src/main.tsx +++ b/integration/templates/custom-flows-react-vite/src/main.tsx @@ -7,6 +7,7 @@ import { Home } from './routes/Home'; import { SignIn } from './routes/SignIn'; import { SignUp } from './routes/SignUp'; import { Protected } from './routes/Protected'; +import { Waitlist } from './routes/Waitlist'; // Import your Publishable Key const PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY; @@ -43,6 +44,10 @@ createRoot(document.getElementById('root')!).render( path='/sign-up' element={} /> + } + /> } diff --git a/integration/templates/custom-flows-react-vite/src/routes/Waitlist.tsx b/integration/templates/custom-flows-react-vite/src/routes/Waitlist.tsx new file mode 100644 index 00000000000..59fd25015de --- /dev/null +++ b/integration/templates/custom-flows-react-vite/src/routes/Waitlist.tsx @@ -0,0 +1,112 @@ +'use client'; + +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { useWaitlist } from '@clerk/react'; +import { NavLink } from 'react-router'; + +export function Waitlist({ className, ...props }: React.ComponentProps<'div'>) { + const { waitlist, errors, fetchStatus } = useWaitlist(); + + const handleSubmit = async (formData: FormData) => { + const emailAddress = formData.get('emailAddress') as string | null; + + if (!emailAddress) { + return; + } + + await waitlist.join({ emailAddress }); + }; + + if (waitlist?.id) { + return ( +
+ + + Successfully joined! + You're on the waitlist + + +
+
+ Already have an account?{' '} + + Sign in + +
+
+
+
+
+ ); + } + + return ( +
+ + + Join the Waitlist + Enter your email address to join the waitlist + + +
+
+
+
+ + + {errors.fields.emailAddress && ( +

+ {errors.fields.emailAddress.longMessage} +

+ )} +
+ +
+
+ Already have an account?{' '} + + Sign in + +
+
+
+
+
+
+ ); +} diff --git a/integration/testUtils/index.ts b/integration/testUtils/index.ts index 53ee484f8a8..8aef94cccd0 100644 --- a/integration/testUtils/index.ts +++ b/integration/testUtils/index.ts @@ -8,6 +8,7 @@ import { createInvitationService } from './invitationsService'; import { createOrganizationsService } from './organizationsService'; import type { FakeAPIKey, FakeOrganization, FakeUser, FakeUserWithEmail } from './usersService'; import { createUserService } from './usersService'; +import { createWaitlistService } from './waitlistService'; export type { FakeAPIKey, FakeOrganization, FakeUser, FakeUserWithEmail }; @@ -40,6 +41,7 @@ export const createTestUtils = < users: createUserService(clerkClient), invitations: createInvitationService(clerkClient), organizations: createOrganizationsService(clerkClient), + waitlist: createWaitlistService(clerkClient), }; if (!params.page) { diff --git a/integration/testUtils/waitlistService.ts b/integration/testUtils/waitlistService.ts new file mode 100644 index 00000000000..b858059ca0e --- /dev/null +++ b/integration/testUtils/waitlistService.ts @@ -0,0 +1,19 @@ +import type { ClerkClient } from '@clerk/backend'; + +export type WaitlistService = { + clearWaitlistByEmail: (email: string) => Promise; +}; + +export const createWaitlistService = (clerkClient: ClerkClient) => { + const self: WaitlistService = { + clearWaitlistByEmail: async (email: string) => { + const { data: entries } = await clerkClient.waitlistEntries.list({ query: email, status: 'pending' }); + + if (entries.length > 0) { + await clerkClient.waitlistEntries.delete(entries[0].id); + } + }, + }; + + return self; +}; diff --git a/integration/tests/custom-flows/waitlist.test.ts b/integration/tests/custom-flows/waitlist.test.ts new file mode 100644 index 00000000000..06288ca48b6 --- /dev/null +++ b/integration/tests/custom-flows/waitlist.test.ts @@ -0,0 +1,100 @@ +import { parsePublishableKey } from '@clerk/shared/keys'; +import { clerkSetup } from '@clerk/testing/playwright'; +import { expect, test } from '@playwright/test'; + +import type { Application } from '../../models/application'; +import { hash } from '../../models/helpers'; +import { appConfigs } from '../../presets'; +import { createTestUtils } from '../../testUtils'; + +test.describe('Custom Flows Waitlist @custom', () => { + test.describe.configure({ mode: 'parallel' }); + let app: Application; + const fakeEmails: string[] = []; + + test.beforeAll(async () => { + app = await appConfigs.customFlows.reactVite.clone().commit(); + await app.setup(); + await app.withEnv(appConfigs.envs.withWaitlistMode); + await app.dev(); + + const publishableKey = appConfigs.envs.withWaitlistMode.publicVariables.get('CLERK_PUBLISHABLE_KEY'); + const secretKey = appConfigs.envs.withWaitlistMode.privateVariables.get('CLERK_SECRET_KEY'); + const apiUrl = appConfigs.envs.withWaitlistMode.privateVariables.get('CLERK_API_URL'); + const { frontendApi: frontendApiUrl } = parsePublishableKey(publishableKey); + + await clerkSetup({ + publishableKey, + frontendApiUrl, + secretKey, + // @ts-expect-error + apiUrl, + dotenv: false, + }); + }); + + test.afterAll(async () => { + const u = createTestUtils({ app }); + await Promise.all(fakeEmails.map(email => u.services.waitlist.clearWaitlistByEmail(email))); + await app.teardown(); + }); + + test('can join waitlist with email', async ({ page, context }) => { + const fakeEmail = `${hash()}+clerk_test@clerkcookie.com`; + fakeEmails.push(fakeEmail); + + const u = createTestUtils({ app, page, context }); + await u.page.goToRelative('/waitlist'); + await u.page.waitForClerkJsLoaded(); + await expect(u.page.getByText('Join the Waitlist', { exact: true })).toBeVisible(); + + const emailInput = u.page.getByTestId('email-input'); + const submitButton = u.page.getByTestId('submit-button'); + + await emailInput.fill(fakeEmail); + await submitButton.click(); + + await expect(u.page.getByText('Successfully joined!')).toBeVisible(); + await expect(u.page.getByText("You're on the waitlist")).toBeVisible(); + }); + + test('renders error with invalid email', async ({ page, context }) => { + const u = createTestUtils({ app, page, context }); + await u.page.goToRelative('/waitlist'); + await u.page.waitForClerkJsLoaded(); + await expect(u.page.getByText('Join the Waitlist', { exact: true })).toBeVisible(); + + const emailInput = u.page.getByTestId('email-input'); + const submitButton = u.page.getByTestId('submit-button'); + + await emailInput.fill('invalid-email@com'); + await submitButton.click(); + + await expect(u.page.getByTestId('email-error')).toBeVisible(); + }); + + test('displays loading state while joining', async ({ page, context }) => { + const fakeEmail = `${hash()}+clerk_test@clerkcookie.com`; + fakeEmails.push(fakeEmail); + + const u = createTestUtils({ app, page, context }); + await u.page.goToRelative('/waitlist'); + await u.page.waitForClerkJsLoaded(); + await expect(u.page.getByText('Join the Waitlist', { exact: true })).toBeVisible(); + + const emailInput = u.page.getByTestId('email-input'); + const submitButton = u.page.getByTestId('submit-button'); + + await emailInput.fill(fakeEmail); + + const submitPromise = submitButton.click(); + + // Check that button is disabled during fetch + await expect(submitButton).toBeDisabled(); + + await submitPromise; + + // Wait for success state + await expect(u.page.getByText('Successfully joined!')).toBeVisible(); + }); +}); diff --git a/integration/tests/waitlist-mode.test.ts b/integration/tests/waitlist-mode.test.ts index 3880f898c48..79e867cc31c 100644 --- a/integration/tests/waitlist-mode.test.ts +++ b/integration/tests/waitlist-mode.test.ts @@ -78,7 +78,7 @@ test.describe('Waitlist mode', () => { ) .commit(); await app.setup(); - await app.withEnv(appConfigs.envs.withWaitlistdMode); + await app.withEnv(appConfigs.envs.withWaitlistMode); await app.dev(); const m = createTestUtils({ app }); diff --git a/packages/clerk-js/src/core/resources/Waitlist.ts b/packages/clerk-js/src/core/resources/Waitlist.ts index 3dd13ebbbc8..f12617bbc30 100644 --- a/packages/clerk-js/src/core/resources/Waitlist.ts +++ b/packages/clerk-js/src/core/resources/Waitlist.ts @@ -1,6 +1,9 @@ +import type { ClerkError } from '@clerk/shared/error'; import type { JoinWaitlistParams, WaitlistJSON, WaitlistResource } from '@clerk/shared/types'; import { unixEpochToDate } from '../../utils/date'; +import { runAsyncResourceTask } from '../../utils/runAsyncResourceTask'; +import { eventBus } from '../events'; import { BaseResource } from './internal'; export class Waitlist extends BaseResource implements WaitlistResource { @@ -10,7 +13,7 @@ export class Waitlist extends BaseResource implements WaitlistResource { updatedAt: Date | null = null; createdAt: Date | null = null; - constructor(data: WaitlistJSON) { + constructor(data: WaitlistJSON | null = null) { super(); this.fromJSON(data); } @@ -23,18 +26,24 @@ export class Waitlist extends BaseResource implements WaitlistResource { this.id = data.id; this.updatedAt = unixEpochToDate(data.updated_at); this.createdAt = unixEpochToDate(data.created_at); + + eventBus.emit('resource:update', { resource: this }); return this; } + async join(params: JoinWaitlistParams): Promise<{ error: ClerkError | null }> { + return runAsyncResourceTask(this, async () => { + await Waitlist.join(params); + }); + } + static async join(params: JoinWaitlistParams): Promise { - const json = ( - await BaseResource._fetch({ - path: '/waitlist', - method: 'POST', - body: params as any, - }) - )?.response as unknown as WaitlistJSON; - - return new Waitlist(json); + const json = await BaseResource._fetch({ + path: '/waitlist', + method: 'POST', + body: params as any, + }); + + return new Waitlist(json as unknown as WaitlistJSON); } } diff --git a/packages/clerk-js/src/core/signals.ts b/packages/clerk-js/src/core/signals.ts index b200154be15..f6f50cf720f 100644 --- a/packages/clerk-js/src/core/signals.ts +++ b/packages/clerk-js/src/core/signals.ts @@ -1,11 +1,20 @@ import type { ClerkAPIError, ClerkError } from '@clerk/shared/error'; import { createClerkGlobalHookError, isClerkAPIResponseError } from '@clerk/shared/error'; -import type { Errors, SignInErrors, SignInSignal, SignUpErrors, SignUpSignal } from '@clerk/shared/types'; +import type { + Errors, + SignInErrors, + SignInSignal, + SignUpErrors, + SignUpSignal, + WaitlistErrors, + WaitlistSignal, +} from '@clerk/shared/types'; import { snakeToCamel } from '@clerk/shared/underscore'; import { computed, signal } from 'alien-signals'; import type { SignIn } from './resources/SignIn'; import type { SignUp } from './resources/SignUp'; +import type { Waitlist } from './resources/Waitlist'; export const signInResourceSignal = signal<{ resource: SignIn | null }>({ resource: null }); export const signInErrorSignal = signal<{ error: ClerkError | null }>({ error: null }); @@ -35,6 +44,20 @@ export const signUpComputedSignal: SignUpSignal = computed(() => { return { errors, fetchStatus, signUp: signUp ? signUp.__internal_future : null }; }); +export const waitlistResourceSignal = signal<{ resource: Waitlist | null }>({ resource: null }); +export const waitlistErrorSignal = signal<{ error: ClerkError | null }>({ error: null }); +export const waitlistFetchSignal = signal<{ status: 'idle' | 'fetching' }>({ status: 'idle' }); + +export const waitlistComputedSignal: WaitlistSignal = computed(() => { + const waitlist = waitlistResourceSignal().resource; + const error = waitlistErrorSignal().error; + const fetchStatus = waitlistFetchSignal().status; + + const errors = errorsToWaitlistErrors(error); + + return { errors, fetchStatus, waitlist }; +}); + /** * Converts an error to a parsed errors object that reports the specific fields that the error pertains to. Will put * generic non-API errors into the global array. @@ -111,3 +134,9 @@ function errorsToSignUpErrors(error: ClerkError | null): SignUpErrors { legalAccepted: null, }); } + +function errorsToWaitlistErrors(error: ClerkError | null): WaitlistErrors { + return errorsToParsedErrors(error, { + emailAddress: null, + }); +} diff --git a/packages/clerk-js/src/core/state.ts b/packages/clerk-js/src/core/state.ts index 411325c068b..d601cdc581d 100644 --- a/packages/clerk-js/src/core/state.ts +++ b/packages/clerk-js/src/core/state.ts @@ -6,6 +6,7 @@ import { eventBus } from './events'; import type { BaseResource } from './resources/Base'; import { SignIn } from './resources/SignIn'; import { SignUp } from './resources/SignUp'; +import { Waitlist } from './resources/Waitlist'; import { signInComputedSignal, signInErrorSignal, @@ -15,6 +16,10 @@ import { signUpErrorSignal, signUpFetchSignal, signUpResourceSignal, + waitlistComputedSignal, + waitlistErrorSignal, + waitlistFetchSignal, + waitlistResourceSignal, } from './signals'; export class State implements StateInterface { @@ -28,6 +33,13 @@ export class State implements StateInterface { signUpFetchSignal = signUpFetchSignal; signUpSignal = signUpComputedSignal; + waitlistResourceSignal = waitlistResourceSignal; + waitlistErrorSignal = waitlistErrorSignal; + waitlistFetchSignal = waitlistFetchSignal; + waitlistSignal = waitlistComputedSignal; + + private _waitlistInstance: Waitlist; + __internal_effect = effect; __internal_computed = computed; @@ -35,6 +47,13 @@ export class State implements StateInterface { eventBus.on('resource:update', this.onResourceUpdated); eventBus.on('resource:error', this.onResourceError); eventBus.on('resource:fetch', this.onResourceFetch); + + this._waitlistInstance = new Waitlist(null); + this.waitlistResourceSignal({ resource: this._waitlistInstance }); + } + + get __internal_waitlist() { + return this._waitlistInstance; } private onResourceError = (payload: { resource: BaseResource; error: ClerkError | null }) => { @@ -45,6 +64,10 @@ export class State implements StateInterface { if (payload.resource instanceof SignUp) { this.signUpErrorSignal({ error: payload.error }); } + + if (payload.resource instanceof Waitlist) { + this.waitlistErrorSignal({ error: payload.error }); + } }; private onResourceUpdated = (payload: { resource: BaseResource }) => { @@ -63,6 +86,11 @@ export class State implements StateInterface { } this.signUpResourceSignal({ resource: payload.resource }); } + + if (payload.resource instanceof Waitlist) { + this._waitlistInstance = payload.resource; + this.waitlistResourceSignal({ resource: payload.resource }); + } }; private onResourceFetch = (payload: { resource: BaseResource; status: 'idle' | 'fetching' }) => { @@ -73,6 +101,10 @@ export class State implements StateInterface { if (payload.resource instanceof SignUp) { this.signUpFetchSignal({ status: payload.status }); } + + if (payload.resource instanceof Waitlist) { + this.waitlistFetchSignal({ status: payload.status }); + } }; } diff --git a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap index b1fb6544b7b..e8f2ed3c6c2 100644 --- a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap @@ -64,6 +64,7 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "useSignIn", "useSignUp", "useUser", + "useWaitlist", ] `; diff --git a/packages/react/src/hooks/index.ts b/packages/react/src/hooks/index.ts index 8beaba1c56f..b2ff54e467d 100644 --- a/packages/react/src/hooks/index.ts +++ b/packages/react/src/hooks/index.ts @@ -1,6 +1,6 @@ export { useAuth } from './useAuth'; export { useEmailLink } from './useEmailLink'; -export { useSignIn, useSignUp } from './useClerkSignal'; +export { useSignIn, useSignUp, useWaitlist } from './useClerkSignal'; export { useClerk, useOrganization, diff --git a/packages/react/src/hooks/useClerkSignal.ts b/packages/react/src/hooks/useClerkSignal.ts index a3aa9cbe7c6..54976b6ab79 100644 --- a/packages/react/src/hooks/useClerkSignal.ts +++ b/packages/react/src/hooks/useClerkSignal.ts @@ -1,5 +1,5 @@ import { eventMethodCalled } from '@clerk/shared/telemetry'; -import type { SignInSignalValue, SignUpSignalValue } from '@clerk/shared/types'; +import type { SignInSignalValue, SignUpSignalValue, WaitlistSignalValue } from '@clerk/shared/types'; import { useCallback, useSyncExternalStore } from 'react'; import { useIsomorphicClerkContext } from '../contexts/IsomorphicClerkContext'; @@ -7,7 +7,10 @@ import { useAssertWrappedByClerkProvider } from './useAssertWrappedByClerkProvid function useClerkSignal(signal: 'signIn'): SignInSignalValue; function useClerkSignal(signal: 'signUp'): SignUpSignalValue; -function useClerkSignal(signal: 'signIn' | 'signUp'): SignInSignalValue | SignUpSignalValue { +function useClerkSignal(signal: 'waitlist'): WaitlistSignalValue; +function useClerkSignal( + signal: 'signIn' | 'signUp' | 'waitlist', +): SignInSignalValue | SignUpSignalValue | WaitlistSignalValue { useAssertWrappedByClerkProvider('useClerkSignal'); const clerk = useIsomorphicClerkContext(); @@ -19,6 +22,9 @@ function useClerkSignal(signal: 'signIn' | 'signUp'): SignInSignalValue | SignUp case 'signUp': clerk.telemetry?.record(eventMethodCalled('useSignUp', { apiVersion: '2025-11' })); break; + case 'waitlist': + clerk.telemetry?.record(eventMethodCalled('useWaitlist', { apiVersion: '2025-11' })); + break; default: break; } @@ -37,6 +43,9 @@ function useClerkSignal(signal: 'signIn' | 'signUp'): SignInSignalValue | SignUp case 'signUp': clerk.__internal_state.signUpSignal(); break; + case 'waitlist': + clerk.__internal_state.waitlistSignal(); + break; default: throw new Error(`Unknown signal: ${signal}`); } @@ -51,6 +60,8 @@ function useClerkSignal(signal: 'signIn' | 'signUp'): SignInSignalValue | SignUp return clerk.__internal_state.signInSignal() as SignInSignalValue; case 'signUp': return clerk.__internal_state.signUpSignal() as SignUpSignalValue; + case 'waitlist': + return clerk.__internal_state.waitlistSignal() as WaitlistSignalValue; default: throw new Error(`Unknown signal: ${signal}`); } @@ -65,7 +76,7 @@ function useClerkSignal(signal: 'signIn' | 'signUp'): SignInSignalValue | SignUp * This hook allows you to access the Signal-based `SignIn` resource. * * @example - * import { useSignInSignal } from "@clerk/react/experimental"; + * import { useSignIn } from "@clerk/react"; * * function SignInForm() { * const { signIn, errors, fetchStatus } = useSignInSignal(); @@ -82,7 +93,7 @@ export function useSignIn() { * This hook allows you to access the Signal-based `SignUp` resource. * * @example - * import { useSignUpSignal } from "@clerk/react/experimental"; + * import { useSignUp } from "@clerk/react"; * * function SignUpForm() { * const { signUp, errors, fetchStatus } = useSignUpSignal(); @@ -94,3 +105,20 @@ export function useSignIn() { export function useSignUp() { return useClerkSignal('signUp'); } + +/** + * This hook allows you to access the Signal-based `Waitlist` resource. + * + * @example + * import { useWaitlist } from "@clerk/react"; + * + * function WaitlistForm() { + * const { waitlist, errors, fetchStatus } = useWaitlist(); + * // + * } + * + * @experimental This experimental API is subject to change. + */ +export function useWaitlist() { + return useClerkSignal('waitlist'); +} diff --git a/packages/react/src/stateProxy.ts b/packages/react/src/stateProxy.ts index e4e9afc9667..046545303da 100644 --- a/packages/react/src/stateProxy.ts +++ b/packages/react/src/stateProxy.ts @@ -7,6 +7,8 @@ import type { SignInErrors, SignUpErrors, State, + WaitlistErrors, + WaitlistResource, } from '@clerk/shared/types'; import { errorThrower } from './errors/errorThrower'; @@ -38,6 +40,14 @@ const defaultSignUpErrors = (): SignUpErrors => ({ global: null, }); +const defaultWaitlistErrors = (): WaitlistErrors => ({ + fields: { + emailAddress: null, + }, + raw: null, + global: null, +}); + type CheckoutSignalProps = { for?: ForPayerType; planPeriod: BillingSubscriptionPlanPeriod; @@ -49,6 +59,7 @@ export class StateProxy implements State { private readonly signInSignalProxy = this.buildSignInProxy(); private readonly signUpSignalProxy = this.buildSignUpProxy(); + private readonly waitlistSignalProxy = this.buildWaitlistProxy(); signInSignal() { return this.signInSignalProxy; @@ -56,6 +67,13 @@ export class StateProxy implements State { signUpSignal() { return this.signUpSignalProxy; } + waitlistSignal() { + return this.waitlistSignalProxy; + } + + get __internal_waitlist() { + return this.state.__internal_waitlist; + } checkoutSignal(params: CheckoutSignalProps) { return this.buildCheckoutProxy(params); @@ -259,6 +277,34 @@ export class StateProxy implements State { }; } + private buildWaitlistProxy() { + const gateProperty = this.gateProperty.bind(this); + const gateMethod = this.gateMethod.bind(this); + const target = (): WaitlistResource => { + return this.state.__internal_waitlist; + }; + + return { + errors: defaultWaitlistErrors(), + fetchStatus: 'idle' as const, + waitlist: { + pathRoot: '/waitlist', + get id() { + return gateProperty(target, 'id', ''); + }, + get createdAt() { + return gateProperty(target, 'createdAt', null); + }, + get updatedAt() { + return gateProperty(target, 'updatedAt', null); + }, + + join: gateMethod(target, 'join'), + reload: gateMethod(target, 'reload'), + }, + }; + } + private buildCheckoutProxy(params: CheckoutSignalProps): CheckoutSignalValue { const gateProperty = this.gateProperty.bind(this); const targetCheckout = () => this.checkout(params); @@ -322,6 +368,14 @@ export class StateProxy implements State { throw new Error('__internal_computed called before Clerk is loaded'); } + private get state() { + const s = this.isomorphicClerk.__internal_state; + if (!s) { + throw new Error('Clerk state not ready'); + } + return s; + } + private get client() { const c = this.isomorphicClerk.client; if (!c) { diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index e0163926bb8..33b15d3bfe8 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -43,7 +43,7 @@ import type { Web3Strategy } from './strategies'; import type { TelemetryCollector } from './telemetry'; import type { UserResource } from './user'; import type { Autocomplete, DeepPartial, DeepSnakeToCamel, Without } from './utils'; -import type { WaitlistResource } from './waitlist'; +import type { JoinWaitlistParams, WaitlistResource } from './waitlist'; /** * Global appearance type registry that can be augmented by packages that depend on `@clerk/ui`. @@ -2271,10 +2271,6 @@ export interface ClerkAuthenticateWithWeb3Params { walletName?: string; } -export type JoinWaitlistParams = { - emailAddress: string; -}; - export interface AuthenticateWithMetamaskParams { customNavigate?: (to: string) => Promise; redirectUrl?: string; diff --git a/packages/shared/src/types/state.ts b/packages/shared/src/types/state.ts index 0a36fb1e6a4..7c065226454 100644 --- a/packages/shared/src/types/state.ts +++ b/packages/shared/src/types/state.ts @@ -1,6 +1,7 @@ import type { ClerkGlobalHookError } from '../errors/globalHookError'; import type { SignInFutureResource } from './signInFuture'; import type { SignUpFutureResource } from './signUpFuture'; +import type { WaitlistResource } from './waitlist'; /** * Represents an error on a specific field. @@ -99,6 +100,16 @@ export interface SignUpFields { legalAccepted: FieldError | null; } +/** + * Fields available for Waitlist errors. + */ +export interface WaitlistFields { + /** + * The error for the email address field. + */ + emailAddress: FieldError | null; +} + /** * Errors type for SignIn operations. */ @@ -109,6 +120,11 @@ export type SignInErrors = Errors; */ export type SignUpErrors = Errors; +/** + * Errors type for Waitlist operations. + */ +export type WaitlistErrors = Errors; + /** * The value returned by the `useSignInSignal` hook. */ @@ -154,6 +170,27 @@ export interface SignUpSignal { (): NullableSignUpSignal; } +export interface WaitlistSignalValue { + /** + * The errors that occurred during the last fetch of the underlying `Waitlist` resource. + */ + errors: WaitlistErrors; + /** + * The fetch status of the underlying `Waitlist` resource. + */ + fetchStatus: 'idle' | 'fetching'; + /** + * The underlying `Waitlist` resource. + */ + waitlist: WaitlistResource; +} +export type NullableWaitlistSignal = Omit & { + waitlist: WaitlistResource | null; +}; +export interface WaitlistSignal { + (): NullableWaitlistSignal; +} + export interface State { /** * A Signal that updates when the underlying `SignIn` resource changes, including errors. @@ -165,6 +202,11 @@ export interface State { */ signUpSignal: SignUpSignal; + /** + * A Signal that updates when the underlying `Waitlist` resource changes, including errors. + */ + waitlistSignal: WaitlistSignal; + /** * An alias for `effect()` from `alien-signals`, which can be used to subscribe to changes from Signals. * @@ -183,4 +225,8 @@ export interface State { * @experimental This experimental API is subject to change. */ __internal_computed: (getter: (previousValue?: T) => T) => () => T; + /** + * An instance of the Waitlist resource. + */ + __internal_waitlist: WaitlistResource; } diff --git a/packages/shared/src/types/waitlist.ts b/packages/shared/src/types/waitlist.ts index 8b8fe3a7ee1..52f16a427b6 100644 --- a/packages/shared/src/types/waitlist.ts +++ b/packages/shared/src/types/waitlist.ts @@ -1,7 +1,28 @@ +import type { ClerkError } from '../error'; import type { ClerkResource } from './resource'; export interface WaitlistResource extends ClerkResource { - id: string; - createdAt: Date | null; - updatedAt: Date | null; + /** + * The unique identifier for the waitlist entry. `''` if the user has not joined the waitlist yet. + */ + readonly id: string; + + /** + * The date and time the waitlist entry was created. `null` if the user has not joined the waitlist yet. + */ + readonly createdAt: Date | null; + + /** + * The date and time the waitlist entry was last updated. `null` if the user has not joined the waitlist yet. + */ + readonly updatedAt: Date | null; + + /** + * Used to add the provided `emailAddress` to the waitlist. + */ + join: (params: JoinWaitlistParams) => Promise<{ error: ClerkError | null }>; } + +export type JoinWaitlistParams = { + emailAddress: string; +}; diff --git a/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap index eaba504c812..21c0511015a 100644 --- a/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/tanstack-react-start/src/__tests__/__snapshots__/exports.test.ts.snap @@ -69,6 +69,7 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "useSignIn", "useSignUp", "useUser", + "useWaitlist", ] `;