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 ( + {}} + /> + ); +}