diff --git a/.changeset/mosaic-waitlist-ci.md b/.changeset/mosaic-waitlist-ci.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/mosaic-waitlist-ci.md
@@ -0,0 +1,2 @@
+---
+---
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/.changeset/mosaic-waitlist.md b/.changeset/mosaic-waitlist.md
new file mode 100644
index 00000000000..a845151cc84
--- /dev/null
+++ b/.changeset/mosaic-waitlist.md
@@ -0,0 +1,2 @@
+---
+---
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..c41d17d3e08
--- /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());
+
+ await waitFor(() => expect(screen.getByRole('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..5ce1f5755e2
--- /dev/null
+++ b/packages/mosaic/src/features/waitlist/waitlist.controller.ts
@@ -0,0 +1,57 @@
+import { useForm } from '../../components/form';
+import { setup } from '../../machine/setup';
+import { useMachine } from '../../machine/use-machine';
+import type { WaitlistModel } from './waitlist.types';
+import type { WaitlistViewProps } from './waitlist.view';
+
+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]: {
+ guard: context => context.onRedirect !== undefined,
+ actions: context => context.onRedirect?.(),
+ },
+ },
+ },
+ },
+});
+
+export type WaitlistReadyModel = Extract;
+
+export function useWaitlistController(model: WaitlistReadyModel): WaitlistViewProps {
+ 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..e906df147cd
--- /dev/null
+++ b/packages/mosaic/src/features/waitlist/waitlist.model.ts
@@ -0,0 +1,34 @@
+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'];
+
+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: router.getSearchParams().get('email_address') ?? '',
+ 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..d7fc77345e0
--- /dev/null
+++ b/packages/mosaic/src/features/waitlist/waitlist.tsx
@@ -0,0 +1,51 @@
+'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
+ *
+ * ```
+ *
+ * @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/localization/registry.ts b/packages/mosaic/src/localization/registry.ts
index 420df1eba8e..bde56a7ec04 100644
--- a/packages/mosaic/src/localization/registry.ts
+++ b/packages/mosaic/src/localization/registry.ts
@@ -30,6 +30,7 @@ import { userProfileAddPhoneMessages } from '../features/user-profile/user-profi
import { userProfilePhoneSectionMessages } from '../features/user-profile/user-profile-phone-section/user-profile-phone-section.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';
@@ -74,6 +75,7 @@ export const mosaicMessages = {
form: formMessages,
errors: errorMessages,
roles: roleMessages,
+ waitlist: waitlistMessages,
};
export type MosaicMessages = typeof mosaicMessages;
diff --git a/packages/mosaic/src/styles/index.ts b/packages/mosaic/src/styles/index.ts
index ece753befcb..94310c08ab7 100644
--- a/packages/mosaic/src/styles/index.ts
+++ b/packages/mosaic/src/styles/index.ts
@@ -180,6 +180,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/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
new file mode 100644
index 00000000000..48727dab817
--- /dev/null
+++ b/packages/swingset/src/app/(clerk)/live/misc/waitlist/page.tsx
@@ -0,0 +1,63 @@
+'use client';
+
+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 { 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 = useMosaicEnvironment();
+ 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 d93567e5f3c..b8d3d5f52c8 100644
--- a/packages/swingset/src/components/DocsViewer.tsx
+++ b/packages/swingset/src/components/DocsViewer.tsx
@@ -56,6 +56,7 @@ const docModules: Record> = {
'user-avatar': dynamic(() => import('../stories/user-avatar.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 92049024d8b..0bbcdc3f312 100644
--- a/packages/swingset/src/lib/registry.ts
+++ b/packages/swingset/src/lib/registry.ts
@@ -485,6 +485,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';
@@ -694,6 +700,13 @@ const userButtonModule: StoryModule = {
User: UserButtonUser,
};
+const waitlistModule: StoryModule = {
+ meta: waitlistMeta,
+ Default: WaitlistDefault,
+ Joined: WaitlistJoined,
+ Redirecting: WaitlistRedirecting,
+};
+
const userAvatarModule: StoryModule = {
meta: userAvatarMeta,
Default: UserAvatarDefault,
@@ -1081,6 +1094,7 @@ export const registry: StoryModule[] = [
userAvatarModule,
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..77e850e82a4
--- /dev/null
+++ b/packages/swingset/src/stories/waitlist.mdx
@@ -0,0 +1,49 @@
+import * as WaitlistStories from './waitlist.stories';
+
+# Waitlist
+
+## Example
+
+
+
+## Usage
+
+Waitlist is available internally in Swingset. It is not yet exported from `@clerk/mosaic`.
+
+```tsx
+;
+```
+
+| 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 (
+ {}}
+ />
+ );
+}