diff --git a/integration/presets/envs.ts b/integration/presets/envs.ts index 318bbc4133b..cd807f0f242 100644 --- a/integration/presets/envs.ts +++ b/integration/presets/envs.ts @@ -134,9 +134,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); @@ -220,7 +220,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 966d034a194..e4d516d8fad 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; @@ -38,6 +39,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..80820298256 --- /dev/null +++ b/integration/tests/custom-flows/waitlist.test.ts @@ -0,0 +1,96 @@ +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; + let fakeEmail: 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, + }); + + fakeEmail = `${hash()}+clerk_test@clerkcookie.com`; + }); + + test.afterAll(async () => { + const u = createTestUtils({ app }); + await u.services.waitlist.clearWaitlistByEmail(fakeEmail); + await app.teardown(); + }); + + test('can join waitlist with 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(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 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/backend/src/api/endpoints/WaitlistEntryApi.ts b/packages/backend/src/api/endpoints/WaitlistEntryApi.ts index f740d9f58de..2d09db2d525 100644 --- a/packages/backend/src/api/endpoints/WaitlistEntryApi.ts +++ b/packages/backend/src/api/endpoints/WaitlistEntryApi.ts @@ -37,7 +37,7 @@ export class WaitlistEntryAPI extends AbstractAPI { * @param params Optional parameters (e.g., `query`, `status`, `orderBy`). */ public async list(params: WaitlistEntryListParams = {}) { - return this.request>({ + return this.request>({ method: 'GET', path: basePath, queryParams: params, 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 2046eca114c..9286e57c936 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. @@ -112,3 +135,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..9bf041232c8 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,10 @@ export class State implements StateInterface { } this.signUpResourceSignal({ resource: payload.resource }); } + + if (payload.resource instanceof Waitlist) { + this.waitlistResourceSignal({ resource: payload.resource }); + } }; private onResourceFetch = (payload: { resource: BaseResource; status: 'idle' | 'fetching' }) => { @@ -73,6 +100,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/localizations/README.md b/packages/localizations/README.md index 560fdb73126..dcd5092fc6c 100644 --- a/packages/localizations/README.md +++ b/packages/localizations/README.md @@ -66,6 +66,7 @@ We're open to all community contributions! If you'd like to contribute in any wa 1. Open the [`localizations/src/en-US.ts`](https://github.com/clerk/javascript/blob/main/packages/localizations/src/en-US.ts) file and add your new key to the object. `en-US` is the default language. If you feel comfortable adding your message in another language than English, feel free to also edit other files. 1. Use the new localization key inside the component. There are two ways: + - The string is inside a component like ``: ```diff 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 8b268b93a36..cba4c47026a 100644 --- a/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/react-router/src/__tests__/__snapshots__/exports.test.ts.snap @@ -65,6 +65,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..63c5fcdae5f 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(); @@ -37,6 +40,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 +57,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}`); } @@ -94,3 +102,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/experimental"; + * + * 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 ab74fa77b5c..6310ea81de9 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -65,7 +65,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'; type __experimental_CheckoutStatus = 'needs_initialization' | 'needs_confirmation' | 'completed'; @@ -2254,10 +2254,6 @@ export interface ClerkAuthenticateWithWeb3Params { secondFactorUrl?: 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/package.json b/packages/tanstack-react-start/package.json index 7f245de6b04..c6fb7067eae 100644 --- a/packages/tanstack-react-start/package.json +++ b/packages/tanstack-react-start/package.json @@ -47,10 +47,6 @@ "types": "./dist/legacy.d.ts", "default": "./dist/legacy.js" }, - "./experimental": { - "types": "./dist/experimental.d.ts", - "default": "./dist/experimental.js" - }, "./package.json": "./package.json" }, "main": "dist/index.js", 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 c8a1f96ceba..6778dea3902 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 @@ -70,6 +70,7 @@ exports[`root public exports > should not change unexpectedly 1`] = ` "useSignIn", "useSignUp", "useUser", + "useWaitlist", ] `;