From fca81775727cdb0c403afbde16bd59770c266565 Mon Sep 17 00:00:00 2001 From: Kyle MacDonald Date: Wed, 28 Oct 2026 17:35:17 -0400 Subject: [PATCH 1/2] feat(mosaic): add Waitlist Adds a connected to @clerk/mosaic, split into model, controller and view, with a feature test against faked FAPI. Wires it into swingset with a docs page and a live sandbox page. --- .changeset/mosaic-waitlist.md | 5 + .../mosaic/src/__tests__/feature/fake-fapi.ts | 30 ++++ .../__tests__/waitlist.feature.test.tsx | 169 ++++++++++++++++++ .../features/waitlist/waitlist.controller.ts | 67 +++++++ .../features/waitlist/waitlist.messages.ts | 19 ++ .../src/features/waitlist/waitlist.model.ts | 38 ++++ .../src/features/waitlist/waitlist.styles.ts | 18 ++ .../mosaic/src/features/waitlist/waitlist.tsx | 53 ++++++ .../src/features/waitlist/waitlist.types.ts | 18 ++ .../src/features/waitlist/waitlist.view.tsx | 150 ++++++++++++++++ packages/mosaic/src/index.ts | 2 + packages/mosaic/src/localization/registry.ts | 2 + packages/mosaic/src/styles/index.ts | 1 + .../app/(clerk)/live/misc/waitlist/page.tsx | 64 +++++++ .../swingset/src/components/DocsViewer.tsx | 1 + packages/swingset/src/lib/live-navigation.ts | 1 + packages/swingset/src/lib/registry.ts | 14 ++ packages/swingset/src/stories/waitlist.mdx | 49 +++++ .../swingset/src/stories/waitlist.stories.tsx | 84 +++++++++ 19 files changed, 785 insertions(+) create mode 100644 .changeset/mosaic-waitlist.md create mode 100644 packages/mosaic/src/features/waitlist/__tests__/waitlist.feature.test.tsx create mode 100644 packages/mosaic/src/features/waitlist/waitlist.controller.ts create mode 100644 packages/mosaic/src/features/waitlist/waitlist.messages.ts create mode 100644 packages/mosaic/src/features/waitlist/waitlist.model.ts create mode 100644 packages/mosaic/src/features/waitlist/waitlist.styles.ts create mode 100644 packages/mosaic/src/features/waitlist/waitlist.tsx create mode 100644 packages/mosaic/src/features/waitlist/waitlist.types.ts create mode 100644 packages/mosaic/src/features/waitlist/waitlist.view.tsx create mode 100644 packages/swingset/src/app/(clerk)/live/misc/waitlist/page.tsx create mode 100644 packages/swingset/src/stories/waitlist.mdx create mode 100644 packages/swingset/src/stories/waitlist.stories.tsx diff --git a/.changeset/mosaic-waitlist.md b/.changeset/mosaic-waitlist.md new file mode 100644 index 00000000000..01c14b91a4b --- /dev/null +++ b/.changeset/mosaic-waitlist.md @@ -0,0 +1,5 @@ +--- +'@clerk/mosaic': minor +--- + +Add ``, a card that adds an email address to the application's waitlist. It takes `afterJoinWaitlistUrl` and `signInUrl` like the existing `Waitlist` component, fills the email field from an `email_address` query parameter, and its strings can be overridden under the `waitlist` localization namespace. diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi.ts b/packages/mosaic/src/__tests__/feature/fake-fapi.ts index 691dcfb4819..ab901902786 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi.ts +++ b/packages/mosaic/src/__tests__/feature/fake-fapi.ts @@ -16,6 +16,7 @@ import type { SessionWithActivitiesJSON, UserJSON, UserOrganizationInvitationJSON, + WaitlistJSON, Web3WalletJSON, } from '@clerk/shared/types'; import { http, HttpResponse } from 'msw'; @@ -77,6 +78,7 @@ export interface FakeFapiState { apiKeys: ApiKeyJSON[]; verification: FakeVerificationState; passwordUpdates: URLSearchParams[]; + waitlistEntries: string[]; enterpriseConnections: EnterpriseConnectionJSON[]; enterpriseLinking: FakeEnterpriseLinking; mfa: FakeMfaState; @@ -316,6 +318,7 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { suggestions: [], apiKeys: [], passwordUpdates: [], + waitlistEntries: [], enterpriseConnections: [], deviceTrackingEnabled: true, mfa: createMfaState(), @@ -574,6 +577,33 @@ export function serveFapi(seed: FakeFapiSeed = {}): FakeFapiState { target.status = 'revoked'; return envelope({ ...deviceSessionPayload(target), user: requester.user, status: 'revoked' }, state.client); }), + http.post(fapiUrl('/v1/waitlist'), async ({ request }) => { + const emailAddress = new URLSearchParams(await request.text()).get('email_address') ?? ''; + if (!/^[^\s@]+@[^\s@]+$/.test(emailAddress)) { + return HttpResponse.json( + { + errors: [ + { + code: 'form_param_format_invalid', + message: 'is invalid', + long_message: 'email_address must be a valid email address.', + meta: { param_name: 'email_address' }, + }, + ], + }, + { status: 422 }, + ); + } + state.waitlistEntries.push(emailAddress); + const now = Date.now(); + const entry: WaitlistJSON = { + object: 'waitlist', + id: `wle_${state.waitlistEntries.length}`, + created_at: now, + updated_at: now, + }; + return envelope(entry, null); + }), http.post(fapiUrl('/v1/me/change_password'), async ({ request }) => { const session = findSession(state, state.client.last_active_session_id); if (!session) { diff --git a/packages/mosaic/src/features/waitlist/__tests__/waitlist.feature.test.tsx b/packages/mosaic/src/features/waitlist/__tests__/waitlist.feature.test.tsx new file mode 100644 index 00000000000..2615149900e --- /dev/null +++ b/packages/mosaic/src/features/waitlist/__tests__/waitlist.feature.test.tsx @@ -0,0 +1,169 @@ +import { act, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, onTestFinished, vi } from 'vitest'; + +import { holdRequests, serveFapi } from '../../../__tests__/feature/fake-fapi'; +import { fapiEnvironment } from '../../../__tests__/feature/fapi'; +import { renderWithClerk } from '../../../__tests__/feature/render'; +import type { WaitlistProps } from '../waitlist'; +import { Waitlist } from '../waitlist'; + +function renderWaitlist(props: WaitlistProps = {}) { + return renderWithClerk(); +} + +const emailField = () => screen.getByRole('textbox', { name: 'Email address' }); +const joinButton = () => screen.getByRole('button', { name: 'Join the waitlist' }); + +async function join(emailAddress: string) { + const user = userEvent.setup(); + await user.type(await screen.findByRole('textbox', { name: 'Email address' }), emailAddress); + await user.click(joinButton()); + return user; +} + +describe('Waitlist', () => { + describe('joining', () => { + it('adds the email address to the waitlist and thanks the user', async () => { + const fapi = serveFapi(); + await renderWaitlist(); + + await join('alice@example.com'); + + expect(await screen.findByRole('heading', { name: 'Thanks for joining the waitlist!' })).toHaveFocus(); + expect(screen.getByText('We’ll be in touch when your spot is ready')).toBeInTheDocument(); + expect(fapi.waitlistEntries).toEqual(['alice@example.com']); + expect(screen.queryByRole('status')).toBeNull(); + }); + + it('holds the form while the request is in flight', async () => { + serveFapi(); + await renderWaitlist(); + const request = holdRequests('post', '/v1/waitlist'); + + await join('alice@example.com'); + + await waitFor(() => expect(request.requests).toHaveLength(1)); + expect(emailField()).toHaveAttribute('aria-disabled', 'true'); + await userEvent.setup().click(screen.getByRole('button', { name: /Join the waitlist/ })); + expect(request.requests).toHaveLength(1); + + request.release(); + expect(await screen.findByRole('heading', { name: 'Thanks for joining the waitlist!' })).toBeInTheDocument(); + }); + + it('cannot be submitted without an email address', async () => { + serveFapi(); + await renderWaitlist(); + + await screen.findByRole('textbox', { name: 'Email address' }); + expect(joinButton()).toHaveAttribute('aria-disabled', 'true'); + }); + + it('fills in the email address from the query string', async () => { + const originalUrl = window.location.href; + window.history.replaceState(null, '', '?email_address=bob%40example.com'); + onTestFinished(() => window.history.replaceState(null, '', originalUrl)); + const fapi = serveFapi(); + await renderWaitlist(); + + expect(await screen.findByRole('textbox', { name: 'Email address' })).toHaveValue('bob@example.com'); + await userEvent.setup().click(joinButton()); + + await screen.findByRole('heading', { name: 'Thanks for joining the waitlist!' }); + expect(fapi.waitlistEntries).toEqual(['bob@example.com']); + }); + }); + + describe('errors', () => { + it('shows an invalid email address on the field', async () => { + const fapi = serveFapi(); + await renderWaitlist(); + + await join('not-an-email'); + + await waitFor(() => + expect(emailField()).toHaveAccessibleDescription('Email address must be a valid email address.'), + ); + expect(emailField()).toHaveAttribute('aria-invalid', 'true'); + expect(fapi.waitlistEntries).toEqual([]); + expect(screen.queryByRole('heading', { name: 'Thanks for joining the waitlist!' })).toBeNull(); + }); + + it('shows a failure that names no field above the form, and lets the user try again', async () => { + serveFapi(); + await renderWaitlist(); + const request = holdRequests('post', '/v1/waitlist'); + + await join('alice@example.com'); + await waitFor(() => expect(request.requests).toHaveLength(1)); + request.fail('waitlist_unavailable', 'The waitlist is not accepting new entries.'); + + await waitFor(() => + expect(screen.getByRole('alert')).toHaveTextContent('The waitlist is not accepting new entries.'), + ); + expect(emailField()).toHaveValue('alice@example.com'); + expect(emailField()).not.toHaveAttribute('aria-disabled'); + expect(joinButton()).not.toHaveAttribute('aria-disabled'); + }); + }); + + describe('after joining', () => { + it('sends the user to afterJoinWaitlistUrl once the thank-you has shown', async () => { + serveFapi(); + const { navigate } = await renderWaitlist({ afterJoinWaitlistUrl: '/thanks' }); + vi.useFakeTimers({ shouldAdvanceTime: true }); + onTestFinished(() => { + vi.useRealTimers(); + }); + + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + await user.type(await screen.findByRole('textbox', { name: 'Email address' }), 'alice@example.com'); + await user.click(joinButton()); + + expect(await screen.findByRole('status')).toHaveTextContent('You will be redirected soon…'); + expect(navigate).not.toHaveBeenCalled(); + + await act(() => vi.advanceTimersByTimeAsync(2000)); + await waitFor(() => expect(navigate).toHaveBeenCalledWith('/thanks')); + }); + }); + + describe('signing in', () => { + it('goes to the application sign-in URL', async () => { + serveFapi({ environment: fapiEnvironment({ display_config: { sign_in_url: '/login' } }) }); + const { navigate } = await renderWaitlist(); + + await userEvent.setup().click(await screen.findByRole('button', { name: 'Sign in' })); + + await waitFor(() => expect(navigate).toHaveBeenCalledWith('/login')); + }); + + it('goes to signInUrl when one is passed', async () => { + serveFapi(); + const { navigate } = await renderWaitlist({ signInUrl: '/custom-sign-in' }); + + await userEvent.setup().click(await screen.findByRole('button', { name: 'Sign in' })); + + await waitFor(() => expect(navigate).toHaveBeenCalledWith('/custom-sign-in')); + }); + }); + + describe('branding', () => { + it('signs the card when the instance is branded', async () => { + serveFapi({ environment: fapiEnvironment({ display_config: { branded: true } }) }); + await renderWaitlist(); + + await screen.findByRole('textbox', { name: 'Email address' }); + expect(screen.getByText(/Secured by/)).toBeInTheDocument(); + }); + + it('carries no signature when the branding is paid off', async () => { + serveFapi(); + await renderWaitlist(); + + await screen.findByRole('textbox', { name: 'Email address' }); + expect(screen.queryByText(/Secured by/)).toBeNull(); + }); + }); +}); diff --git a/packages/mosaic/src/features/waitlist/waitlist.controller.ts b/packages/mosaic/src/features/waitlist/waitlist.controller.ts new file mode 100644 index 00000000000..3fd82d25df2 --- /dev/null +++ b/packages/mosaic/src/features/waitlist/waitlist.controller.ts @@ -0,0 +1,67 @@ +import type { UseFormResult } from '../../components/form'; +import { useForm } from '../../components/form'; +import { setup } from '../../machine/setup'; +import { useMachine } from '../../machine/use-machine'; +import type { WaitlistModel, WaitlistStep, WaitlistValues } from './waitlist.types'; + +export const WAITLIST_REDIRECT_DELAY_MS = 2000; + +interface WaitlistMachineContext { + onRedirect: (() => void) | undefined; +} + +type WaitlistMachineEvent = { type: 'JOINED' }; + +const { createMachine } = setup(); + +const waitlistMachine = createMachine({ + id: 'waitlist', + initial: 'form', + context: { onRedirect: undefined }, + states: { + form: { + on: { JOINED: 'joined' }, + }, + joined: { + after: { + [WAITLIST_REDIRECT_DELAY_MS]: { + target: 'redirected', + guard: context => context.onRedirect !== undefined, + actions: context => context.onRedirect?.(), + }, + }, + }, + redirected: { type: 'final' }, + }, +}); + +export type WaitlistReadyModel = Extract; + +export interface WaitlistController { + step: WaitlistStep; + form: UseFormResult; + renderBranding: boolean; + isRedirecting: boolean; + onSignIn: () => void; +} + +export function useWaitlistController(model: WaitlistReadyModel): WaitlistController { + const { initialEmailAddress, renderBranding, onJoinWaitlist, onSignIn, onRedirect } = model; + const [snapshot, send] = useMachine(waitlistMachine, { context: { onRedirect } }); + const form = useForm({ + initialValues: { emailAddress: initialEmailAddress }, + canSubmit: values => values.emailAddress.trim() !== '', + onSubmit: async values => { + await onJoinWaitlist(values.emailAddress); + send({ type: 'JOINED' }); + }, + }); + + return { + step: snapshot.value === 'form' ? 'form' : 'joined', + form, + renderBranding, + isRedirecting: onRedirect !== undefined, + onSignIn, + }; +} diff --git a/packages/mosaic/src/features/waitlist/waitlist.messages.ts b/packages/mosaic/src/features/waitlist/waitlist.messages.ts new file mode 100644 index 00000000000..ea9da145f2c --- /dev/null +++ b/packages/mosaic/src/features/waitlist/waitlist.messages.ts @@ -0,0 +1,19 @@ +export const waitlistMessages = { + start: { + title: 'Join the waitlist', + description: 'Enter your email address and we’ll let you know when your spot is ready', + emailLabel: 'Email address', + emailPlaceholder: 'Enter your email address', + submit: 'Join the waitlist', + pending: 'Joining the waitlist', + }, + success: { + title: 'Thanks for joining the waitlist!', + description: 'We’ll be in touch when your spot is ready', + redirecting: 'You will be redirected soon…', + }, + signIn: { + text: 'Already have access?', + action: 'Sign in', + }, +} as const; diff --git a/packages/mosaic/src/features/waitlist/waitlist.model.ts b/packages/mosaic/src/features/waitlist/waitlist.model.ts new file mode 100644 index 00000000000..a1dc495a124 --- /dev/null +++ b/packages/mosaic/src/features/waitlist/waitlist.model.ts @@ -0,0 +1,38 @@ +import { useClerk } from '@clerk/shared/react'; +import type { WaitlistProps } from '@clerk/shared/types'; + +import { useMosaicEnvironment } from '../../hooks/use-mosaic-environment'; +import { useMosaicRouter } from '../../hooks/use-mosaic-router'; +import { save } from '../../utils/errors'; +import type { WaitlistField, WaitlistModel } from './waitlist.types'; + +export type WaitlistModelOptions = Pick; + +const WAITLIST_FIELDS: readonly WaitlistField[] = ['emailAddress']; + +function emailAddressFromQuery(): string { + return new URLSearchParams(window.location.search).get('email_address') ?? ''; +} + +export function useWaitlistModel(options: WaitlistModelOptions = {}): WaitlistModel { + const clerk = useClerk(); + const environment = useMosaicEnvironment(); + const router = useMosaicRouter(); + + if (!clerk.loaded || !environment) { + return { status: 'loading' }; + } + + const { displayConfig } = environment; + const signInUrl = options.signInUrl || clerk.__internal_getOption('signInUrl') || displayConfig.signInUrl; + const { afterJoinWaitlistUrl } = options; + + return { + status: 'ready', + initialEmailAddress: emailAddressFromQuery(), + renderBranding: displayConfig.branded, + onJoinWaitlist: emailAddress => save(() => clerk.joinWaitlist({ emailAddress }), WAITLIST_FIELDS), + onSignIn: () => void router.navigate(clerk.buildUrlWithAuth(signInUrl)), + onRedirect: afterJoinWaitlistUrl ? () => void router.navigate(afterJoinWaitlistUrl) : undefined, + }; +} diff --git a/packages/mosaic/src/features/waitlist/waitlist.styles.ts b/packages/mosaic/src/features/waitlist/waitlist.styles.ts new file mode 100644 index 00000000000..97f018d9e5a --- /dev/null +++ b/packages/mosaic/src/features/waitlist/waitlist.styles.ts @@ -0,0 +1,18 @@ +import * as stylex from '@stylexjs/stylex'; + +import { space } from '../../tokens.stylex'; + +export const styles = stylex.create({ + redirecting: { + gap: space['2'], + alignItems: 'center', + display: 'flex', + justifyContent: 'center', + }, + footer: { + justifyContent: 'center', + }, + footerText: { + textAlign: 'center', + }, +}); diff --git a/packages/mosaic/src/features/waitlist/waitlist.tsx b/packages/mosaic/src/features/waitlist/waitlist.tsx new file mode 100644 index 00000000000..9f64af81046 --- /dev/null +++ b/packages/mosaic/src/features/waitlist/waitlist.tsx @@ -0,0 +1,53 @@ +'use client'; + +import type { ReactElement, ReactNode } from 'react'; + +import type { WaitlistReadyModel } from './waitlist.controller'; +import { useWaitlistController } from './waitlist.controller'; +import type { WaitlistModelOptions } from './waitlist.model'; +import { useWaitlistModel } from './waitlist.model'; +import { WaitlistView } from './waitlist.view'; + +export interface WaitlistProps extends WaitlistModelOptions { + /** Rendered while Clerk loads. */ + fallback?: ReactNode; +} + +/** + * A card that collects an email address and adds it to the application's waitlist, then thanks the + * user. It reads the application from Clerk, so it takes no data. It renders `fallback` until Clerk + * answers. + * + * An `email_address` query parameter fills in the field. + * + * @example + * ```tsx + * import { Waitlist } from '@clerk/mosaic'; + * + * + * ``` + * + * @example + * `afterJoinWaitlistUrl` sends the user on two seconds after they join. `signInUrl` is where the + * "Sign in" link goes, and defaults to the application's sign-in URL. + * ```tsx + * + * ``` + */ +export function Waitlist({ fallback, ...options }: WaitlistProps = {}): ReactElement { + const model = useWaitlistModel(options); + + if (model.status === 'loading') { + return <>{fallback}; + } + + return ; +} + +function ReadyWaitlist({ model }: { model: WaitlistReadyModel }) { + const controller = useWaitlistController(model); + return ; +} diff --git a/packages/mosaic/src/features/waitlist/waitlist.types.ts b/packages/mosaic/src/features/waitlist/waitlist.types.ts new file mode 100644 index 00000000000..22657ed84e0 --- /dev/null +++ b/packages/mosaic/src/features/waitlist/waitlist.types.ts @@ -0,0 +1,18 @@ +export type WaitlistField = 'emailAddress'; + +export interface WaitlistValues { + emailAddress: string; +} + +export type WaitlistStep = 'form' | 'joined'; + +export type WaitlistModel = + | { status: 'loading' } + | { + status: 'ready'; + initialEmailAddress: string; + renderBranding: boolean; + onJoinWaitlist: (emailAddress: string) => Promise; + onSignIn: () => void; + onRedirect?: () => void; + }; diff --git a/packages/mosaic/src/features/waitlist/waitlist.view.tsx b/packages/mosaic/src/features/waitlist/waitlist.view.tsx new file mode 100644 index 00000000000..b62a2c9fe1f --- /dev/null +++ b/packages/mosaic/src/features/waitlist/waitlist.view.tsx @@ -0,0 +1,150 @@ +import { Button, SubmitButton } from '../../components/button'; +import { Card } from '../../components/card'; +import { Field } from '../../components/field'; +import { Flow, useFlowAutoFocus } from '../../components/flow'; +import type { UseFormResult } from '../../components/form'; +import { Input } from '../../components/input'; +import { Spinner } from '../../components/spinner'; +import { Text } from '../../components/text'; +import { useMessages } from '../../localization'; +import { Form } from '../../primitives/form'; +import { styles } from './waitlist.styles'; +import type { WaitlistStep, WaitlistValues } from './waitlist.types'; + +export interface WaitlistViewProps { + step: WaitlistStep; + form: UseFormResult; + renderBranding: boolean; + isRedirecting: boolean; + onSignIn: () => void; +} + +export function WaitlistView({ step, form, renderBranding, isRedirecting, onSignIn }: WaitlistViewProps) { + const m = useMessages('waitlist'); + + return ( + + + {() => ( + <> + + + + + + + + )} + + + + {m.signIn.text}{' '} + + + + + ); +} + +function WaitlistFormStep({ form }: { form: UseFormResult }) { + const m = useMessages('waitlist').start; + + return ( + <> + + {m.title} + {m.description} + + + {form.error} + + + } + > + + + {m.submit} + + + + ); +} + +function EmailAddressField({ form }: { form: UseFormResult }) { + const m = useMessages('waitlist').start; + const { feedback } = form.fields.emailAddress; + + return ( + + {m.emailLabel} + + + + ); +} + +function WaitlistJoinedStep({ isRedirecting }: { isRedirecting: boolean }) { + const m = useMessages('waitlist').success; + + return ( + <> + + ()} + tabIndex={-1} + > + {m.title} + + {m.description} + + {isRedirecting ? ( + + + {m.redirecting} + + ) : null} + + ); +} diff --git a/packages/mosaic/src/index.ts b/packages/mosaic/src/index.ts index b9690e71e22..26174982cff 100644 --- a/packages/mosaic/src/index.ts +++ b/packages/mosaic/src/index.ts @@ -4,6 +4,8 @@ export type { UserButtonProps } from './features/user-button/user-button'; export { UserButton } from './features/user-button/user-button'; export type { OrganizationSwitcherProps } from './features/organization-switcher/organization-switcher'; export { OrganizationSwitcher } from './features/organization-switcher/organization-switcher'; +export type { WaitlistProps } from './features/waitlist/waitlist'; +export { Waitlist } from './features/waitlist/waitlist'; export { MosaicProvider } from './mosaic-provider'; export type { PortalContainer, PortalProviderProps } from './primitives/portal'; export { PortalProvider } from './primitives/portal'; diff --git a/packages/mosaic/src/localization/registry.ts b/packages/mosaic/src/localization/registry.ts index c458b3c2ff8..75f04ff4059 100644 --- a/packages/mosaic/src/localization/registry.ts +++ b/packages/mosaic/src/localization/registry.ts @@ -28,6 +28,7 @@ import { userProfilePasswordSectionMessages } from '../features/user-profile/use import { userProfileAddPhoneMessages } from '../features/user-profile/user-profile-phone-section/user-profile-add-phone.messages'; import { userProfileProfileSectionMessages } from '../features/user-profile/user-profile-profile-section/user-profile-profile-section.messages'; import { userProfileWeb3WalletsMessages } from '../features/user-profile/user-profile-web3-wallets-section/user-profile-web3-wallets.messages'; +import { waitlistMessages } from '../features/waitlist/waitlist.messages'; import { errorMessages } from './errors.messages'; import { roleMessages } from './roles.messages'; @@ -64,6 +65,7 @@ export const mosaicMessages = { userProfilePasswordSection: userProfilePasswordSectionMessages, userProfileProfileSection: userProfileProfileSectionMessages, userProfileWeb3Wallets: userProfileWeb3WalletsMessages, + waitlist: waitlistMessages, }; export type MosaicMessages = typeof mosaicMessages; diff --git a/packages/mosaic/src/styles/index.ts b/packages/mosaic/src/styles/index.ts index 89c6b5bf22d..50de255f56c 100644 --- a/packages/mosaic/src/styles/index.ts +++ b/packages/mosaic/src/styles/index.ts @@ -178,6 +178,7 @@ export type { } from '../components/profile'; export { OrganizationSwitcher } from '../features/organization-switcher/organization-switcher'; export { UserButton } from '../features/user-button/user-button'; +export { Waitlist } from '../features/waitlist/waitlist'; import { colorVars, diff --git a/packages/swingset/src/app/(clerk)/live/misc/waitlist/page.tsx b/packages/swingset/src/app/(clerk)/live/misc/waitlist/page.tsx new file mode 100644 index 00000000000..6ebfbb6235e --- /dev/null +++ b/packages/swingset/src/app/(clerk)/live/misc/waitlist/page.tsx @@ -0,0 +1,64 @@ +'use client'; + +import { Button } from '@clerk/mosaic/components/button'; +import { Waitlist } from '@clerk/mosaic/features/waitlist/waitlist'; +import { MosaicProvider } from '@clerk/mosaic/mosaic-provider'; +import { useClerk } from '@clerk/nextjs'; +import type { EnvironmentResource } from '@clerk/shared/types'; +import { useState } from 'react'; + +import { LivePageHeader } from '../../../live-page-header'; + +export default function WaitlistLivePage() { + const clerk = useClerk(); + const [redirect, setRedirect] = useState(false); + const [demoKey, setDemoKey] = useState(0); + const environment = (clerk as unknown as { __internal_environment?: EnvironmentResource | null }) + .__internal_environment; + const signUpMode = environment?.userSettings.signUp.mode; + + return ( + +
+
+ + The connected waitlist. Joining adds a real entry to the application's waitlist, so the application + needs its sign-up mode set to Waitlist. + + {clerk.loaded && signUpMode !== 'waitlist' ? ( +

+ This application's sign-up mode is {signUpMode}, not{' '} + waitlist. The form renders, but joining will fail until + waitlist mode is turned on in the Clerk Dashboard. +

+ ) : null} +
+ + +
+
+
+ +
+
+
+ ); +} diff --git a/packages/swingset/src/components/DocsViewer.tsx b/packages/swingset/src/components/DocsViewer.tsx index 66e3234b6da..5702a108b07 100644 --- a/packages/swingset/src/components/DocsViewer.tsx +++ b/packages/swingset/src/components/DocsViewer.tsx @@ -55,6 +55,7 @@ const docModules: Record> = { 'organization-switcher': dynamic(() => import('../stories/organization-switcher.mdx')), 'api-keys-table': dynamic(() => import('../stories/api-keys-table.mdx')), reverification: dynamic(() => import('../stories/reverification.mdx')), + waitlist: dynamic(() => import('../stories/waitlist.mdx')), }, blocks: { confirmation: dynamic(() => import('../stories/confirmation.mdx')), diff --git a/packages/swingset/src/lib/live-navigation.ts b/packages/swingset/src/lib/live-navigation.ts index b4f662c4181..9c8d69ec91b 100644 --- a/packages/swingset/src/lib/live-navigation.ts +++ b/packages/swingset/src/lib/live-navigation.ts @@ -38,6 +38,7 @@ const livePages = new Set([ '/user-profile/user-profile-web3wallets-section', '/misc/api-keys-table', '/misc/reverification', + '/misc/waitlist', '/user-button/user-button', ]); diff --git a/packages/swingset/src/lib/registry.ts b/packages/swingset/src/lib/registry.ts index 088e9994198..fc7d4f56b04 100644 --- a/packages/swingset/src/lib/registry.ts +++ b/packages/swingset/src/lib/registry.ts @@ -470,6 +470,12 @@ import { LiveRegion as VisuallyHiddenLiveRegion, meta as visuallyHiddenMeta, } from '../stories/visually-hidden.stories'; +import { + Default as WaitlistDefault, + Joined as WaitlistJoined, + meta as waitlistMeta, + Redirecting as WaitlistRedirecting, +} from '../stories/waitlist.stories'; import { toSlug } from './slug'; import type { StoryModule } from './types'; @@ -670,6 +676,13 @@ const userButtonModule: StoryModule = { User: UserButtonUser, }; +const waitlistModule: StoryModule = { + meta: waitlistMeta, + Default: WaitlistDefault, + Joined: WaitlistJoined, + Redirecting: WaitlistRedirecting, +}; + const organizationSwitcherModule: StoryModule = { meta: organizationSwitcherMeta, Default: OrganizationSwitcherDefault, @@ -1048,6 +1061,7 @@ export const registry: StoryModule[] = [ organizationSwitcherModule, apiKeysTableModule, reverificationModule, + waitlistModule, // Blocks — flows assembled from components, wired by the caller's machine. confirmationModule, destructiveModule, diff --git a/packages/swingset/src/stories/waitlist.mdx b/packages/swingset/src/stories/waitlist.mdx new file mode 100644 index 00000000000..8f422b1aa09 --- /dev/null +++ b/packages/swingset/src/stories/waitlist.mdx @@ -0,0 +1,49 @@ +import * as WaitlistStories from './waitlist.stories'; + +# Waitlist + +## Example + + + +## Usage + +```tsx +import { Waitlist } from '@clerk/mosaic'; + +; +``` + +| Prop | Type | Default | Description | +| ---------------------- | ----------- | ----------------------------- | ------------------------------------------------- | +| `afterJoinWaitlistUrl` | `string` | — | Where the user is sent two seconds after joining. | +| `signInUrl` | `string` | the application's sign-in URL | Where **Sign in** goes. | +| `fallback` | `ReactNode` | — | Rendered while Clerk loads. | + +## Joined + +Once the address is on the waitlist, the card thanks the user and takes focus to its title, so a +screen reader announces it. + + + +With `afterJoinWaitlistUrl`, the card says the user is about to be redirected, and sends them on two +seconds later. + + diff --git a/packages/swingset/src/stories/waitlist.stories.tsx b/packages/swingset/src/stories/waitlist.stories.tsx new file mode 100644 index 00000000000..c4d12dc432e --- /dev/null +++ b/packages/swingset/src/stories/waitlist.stories.tsx @@ -0,0 +1,84 @@ +import { FormSubmitError, useForm } from '@clerk/mosaic/components/form'; +import type { WaitlistStep, WaitlistValues } from '@clerk/mosaic/features/waitlist/waitlist.types'; +import { WaitlistView } from '@clerk/mosaic/features/waitlist/waitlist.view'; +import { useState } from 'react'; + +import type { StoryMeta } from '@/lib/types'; + +export { default as __source } from './waitlist.stories?raw'; + +export const meta: StoryMeta = { + group: 'Misc', + title: 'Waitlist', + status: 'stable', + source: 'packages/mosaic/src/features/waitlist/waitlist.tsx', +}; + +const settleAfter = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +async function joinWaitlist({ emailAddress }: WaitlistValues) { + await settleAfter(800); + if (!emailAddress.includes('@')) { + throw new FormSubmitError({ + fields: { emailAddress: 'Email address must be a valid email address.' }, + }); + } + if (emailAddress.startsWith('closed@')) { + throw new FormSubmitError({ message: 'The waitlist is not accepting new entries.' }); + } +} + +function useWaitlistPrototype(initialStep: WaitlistStep = 'form') { + const [step, setStep] = useState(initialStep); + const form = useForm({ + initialValues: { emailAddress: '' }, + canSubmit: values => values.emailAddress.trim() !== '', + onSubmit: async values => { + await joinWaitlist(values); + setStep('joined'); + }, + }); + return { step, form }; +} + +export function Default(_args: Record) { + const { step, form } = useWaitlistPrototype(); + + return ( + {}} + /> + ); +} + +export function Joined(_args: Record) { + const { step, form } = useWaitlistPrototype('joined'); + + return ( + {}} + /> + ); +} + +export function Redirecting(_args: Record) { + const { step, form } = useWaitlistPrototype('joined'); + + return ( + {}} + /> + ); +} From 469562fde5380d35d2486a6db5995f59b6eca43c Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Wed, 28 Oct 2026 19:58:05 -0400 Subject: [PATCH 2/2] refactor(mosaic): apply review fixes to Waitlist (#10506) --- .changeset/mosaic-waitlist-review-fixes.md | 2 ++ .../src/features/waitlist/waitlist.controller.ts | 16 +++------------- .../src/features/waitlist/waitlist.model.ts | 6 +----- .../src/app/(clerk)/live/live-overview.tsx | 9 +++------ .../src/app/(clerk)/live/misc/waitlist/page.tsx | 5 ++--- 5 files changed, 11 insertions(+), 27 deletions(-) create mode 100644 .changeset/mosaic-waitlist-review-fixes.md diff --git a/.changeset/mosaic-waitlist-review-fixes.md b/.changeset/mosaic-waitlist-review-fixes.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-waitlist-review-fixes.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/features/waitlist/waitlist.controller.ts b/packages/mosaic/src/features/waitlist/waitlist.controller.ts index 3fd82d25df2..5ce1f5755e2 100644 --- a/packages/mosaic/src/features/waitlist/waitlist.controller.ts +++ b/packages/mosaic/src/features/waitlist/waitlist.controller.ts @@ -1,8 +1,8 @@ -import type { UseFormResult } from '../../components/form'; import { useForm } from '../../components/form'; import { setup } from '../../machine/setup'; import { useMachine } from '../../machine/use-machine'; -import type { WaitlistModel, WaitlistStep, WaitlistValues } from './waitlist.types'; +import type { WaitlistModel } from './waitlist.types'; +import type { WaitlistViewProps } from './waitlist.view'; export const WAITLIST_REDIRECT_DELAY_MS = 2000; @@ -25,27 +25,17 @@ const waitlistMachine = createMachine({ joined: { after: { [WAITLIST_REDIRECT_DELAY_MS]: { - target: 'redirected', guard: context => context.onRedirect !== undefined, actions: context => context.onRedirect?.(), }, }, }, - redirected: { type: 'final' }, }, }); export type WaitlistReadyModel = Extract; -export interface WaitlistController { - step: WaitlistStep; - form: UseFormResult; - renderBranding: boolean; - isRedirecting: boolean; - onSignIn: () => void; -} - -export function useWaitlistController(model: WaitlistReadyModel): WaitlistController { +export function useWaitlistController(model: WaitlistReadyModel): WaitlistViewProps { const { initialEmailAddress, renderBranding, onJoinWaitlist, onSignIn, onRedirect } = model; const [snapshot, send] = useMachine(waitlistMachine, { context: { onRedirect } }); const form = useForm({ diff --git a/packages/mosaic/src/features/waitlist/waitlist.model.ts b/packages/mosaic/src/features/waitlist/waitlist.model.ts index a1dc495a124..e906df147cd 100644 --- a/packages/mosaic/src/features/waitlist/waitlist.model.ts +++ b/packages/mosaic/src/features/waitlist/waitlist.model.ts @@ -10,10 +10,6 @@ export type WaitlistModelOptions = Pick save(() => clerk.joinWaitlist({ emailAddress }), WAITLIST_FIELDS), onSignIn: () => void router.navigate(clerk.buildUrlWithAuth(signInUrl)), diff --git a/packages/swingset/src/app/(clerk)/live/live-overview.tsx b/packages/swingset/src/app/(clerk)/live/live-overview.tsx index 8199d9fca48..f63a61e5158 100644 --- a/packages/swingset/src/app/(clerk)/live/live-overview.tsx +++ b/packages/swingset/src/app/(clerk)/live/live-overview.tsx @@ -1,7 +1,7 @@ 'use client'; -import { useClerk, useUser } from '@clerk/nextjs'; -import type { EnvironmentResource } from '@clerk/shared/types'; +import { useMosaicEnvironment } from '@clerk/mosaic/hooks/use-mosaic-environment'; +import { useUser } from '@clerk/nextjs'; import { ExternalLinkIcon } from 'lucide-react'; import Link from 'next/link'; @@ -18,11 +18,8 @@ export function LiveOverview({ dashboardUrl: string | null; deployment: { label: string; dashboardUrl: string | null } | null; }) { - const clerk = useClerk(); const { isLoaded, isSignedIn, user } = useUser(); - // The environment resource isn't part of the public Clerk interface; reach through the internal getter. - const environment = (clerk as unknown as { __internal_environment?: EnvironmentResource | null }) - .__internal_environment; + const environment = useMosaicEnvironment(); const applicationName = environment?.displayConfig.applicationName; return ( diff --git a/packages/swingset/src/app/(clerk)/live/misc/waitlist/page.tsx b/packages/swingset/src/app/(clerk)/live/misc/waitlist/page.tsx index 6ebfbb6235e..48727dab817 100644 --- a/packages/swingset/src/app/(clerk)/live/misc/waitlist/page.tsx +++ b/packages/swingset/src/app/(clerk)/live/misc/waitlist/page.tsx @@ -2,9 +2,9 @@ import { Button } from '@clerk/mosaic/components/button'; import { Waitlist } from '@clerk/mosaic/features/waitlist/waitlist'; +import { useMosaicEnvironment } from '@clerk/mosaic/hooks/use-mosaic-environment'; import { MosaicProvider } from '@clerk/mosaic/mosaic-provider'; import { useClerk } from '@clerk/nextjs'; -import type { EnvironmentResource } from '@clerk/shared/types'; import { useState } from 'react'; import { LivePageHeader } from '../../../live-page-header'; @@ -13,8 +13,7 @@ export default function WaitlistLivePage() { const clerk = useClerk(); const [redirect, setRedirect] = useState(false); const [demoKey, setDemoKey] = useState(0); - const environment = (clerk as unknown as { __internal_environment?: EnvironmentResource | null }) - .__internal_environment; + const environment = useMosaicEnvironment(); const signUpMode = environment?.userSettings.signUp.mode; return (