diff --git a/.changeset/nine-squids-buy.md b/.changeset/nine-squids-buy.md new file mode 100644 index 0000000000..f92e2fae0c --- /dev/null +++ b/.changeset/nine-squids-buy.md @@ -0,0 +1,14 @@ +--- +"@clerk/localizations": minor +"@clerk/clerk-js": minor +"@clerk/nextjs": minor +"@clerk/clerk-react": minor +"@clerk/types": minor +--- + +Show `Join waitlist` promt from `` component when mode is `waitlist` +Change the text in `RestrictedAccess` component when mode is `waitlist` +Introduce `` component to allow users to join in the waitlist via email +Introduce `joinWaitlist` method in `clerk` class +Introduce `redirectToWaitlist` function in `clerk` class to allow user to redirect to waitlist page + diff --git a/packages/chrome-extension/src/__tests__/__snapshots__/exports.test.ts.snap b/packages/chrome-extension/src/__tests__/__snapshots__/exports.test.ts.snap index 5926a52cb7..460d9627d3 100644 --- a/packages/chrome-extension/src/__tests__/__snapshots__/exports.test.ts.snap +++ b/packages/chrome-extension/src/__tests__/__snapshots__/exports.test.ts.snap @@ -27,6 +27,7 @@ exports[`public exports should not include a breaking change 1`] = ` "SignedOut", "UserButton", "UserProfile", + "Waitlist", "useAuth", "useClerk", "useEmailLink", diff --git a/packages/clerk-js/src/core/__tests__/clerk.redirects.test.ts b/packages/clerk-js/src/core/__tests__/clerk.redirects.test.ts index 3d8bd18413..a87df11c5d 100644 --- a/packages/clerk-js/src/core/__tests__/clerk.redirects.test.ts +++ b/packages/clerk-js/src/core/__tests__/clerk.redirects.test.ts @@ -36,6 +36,7 @@ const mockDisplayConfigWithSameOrigin = { homeUrl: 'http://test.host/home', createOrganizationUrl: 'http://test.host/create-organization', organizationProfileUrl: 'http://test.host/organization-profile', + waitlistUrl: 'http://test.host/waitlist', } as DisplayConfig; const mockDisplayConfigWithDifferentOrigin = { @@ -45,6 +46,7 @@ const mockDisplayConfigWithDifferentOrigin = { homeUrl: 'http://another-test.host/home', createOrganizationUrl: 'http://another-test.host/create-organization', organizationProfileUrl: 'http://another-test.host/organization-profile', + waitlistUrl: 'http://another-test.host/waitlist', } as DisplayConfig; const mockUserSettings = { @@ -96,7 +98,7 @@ describe('Clerk singleton - Redirects', () => { afterEach(() => mockNavigate.mockReset()); - describe('.redirectTo(SignUp|SignIn|UserProfile|AfterSignIn|AfterSignUp|CreateOrganization|OrganizationProfile)', () => { + describe('.redirectTo(SignUp|SignIn|UserProfile|AfterSignIn|AfterSignUp|CreateOrganization|OrganizationProfile|Waitlist)', () => { let clerkForProductionInstance: Clerk; let clerkForDevelopmentInstance: Clerk; @@ -189,6 +191,13 @@ describe('Clerk singleton - Redirects', () => { expect(mockNavigate.mock.calls[0][0]).toBe('/organization-profile'); expect(mockNavigate.mock.calls[1][0]).toBe('/organization-profile'); }); + + it('redirects to waitlitUrl', async () => { + await clerkForDevelopmentInstance.redirectToWaitlist(); + expect(mockNavigate).toHaveBeenCalledWith('/waitlist', { + windowNavigate: expect.any(Function), + }); + }); }); describe('when redirects point to different origin urls', () => { diff --git a/packages/clerk-js/src/core/clerk.ts b/packages/clerk-js/src/core/clerk.ts index 36ff3a1eef..2fb314d186 100644 --- a/packages/clerk-js/src/core/clerk.ts +++ b/packages/clerk-js/src/core/clerk.ts @@ -36,6 +36,7 @@ import type { HandleEmailLinkVerificationParams, HandleOAuthCallbackParams, InstanceType, + JoinWaitlistParams, ListenerCallback, LoadedClerk, NavigateOptions, @@ -61,6 +62,8 @@ import type { UserButtonProps, UserProfileProps, UserResource, + WaitlistProps, + WaitlistResource, Web3Provider, } from '@clerk/types'; @@ -119,6 +122,7 @@ import { EmailLinkErrorCode, Environment, Organization, + Waitlist, } from './resources/internal'; import { warnings } from './warnings'; @@ -509,6 +513,18 @@ export class Clerk implements ClerkInterface { void this.#componentControls.ensureMounted().then(controls => controls.closeModal('createOrganization')); }; + public openWaitlist = (props?: WaitlistProps): void => { + this.assertComponentsReady(this.#componentControls); + void this.#componentControls + .ensureMounted({ preloadHint: 'Waitlist' }) + .then(controls => controls.openModal('waitlist', props || {})); + }; + + public closeWaitlist = (): void => { + this.assertComponentsReady(this.#componentControls); + void this.#componentControls.ensureMounted().then(controls => controls.closeModal('waitlist')); + }; + public mountSignIn = (node: HTMLDivElement, props?: SignInProps): void => { if (props && props.__experimental?.newComponents && this.__experimental_ui) { this.__experimental_ui.mount('SignIn', node, props); @@ -743,6 +759,25 @@ export class Clerk implements ClerkInterface { void this.#componentControls?.ensureMounted().then(controls => controls.unmountComponent({ node })); }; + public mountWaitlist = (node: HTMLDivElement, props?: WaitlistProps) => { + this.assertComponentsReady(this.#componentControls); + void this.#componentControls?.ensureMounted({ preloadHint: 'Waitlist' }).then(controls => + controls.mountComponent({ + name: 'Waitlist', + appearanceKey: 'waitlist', + node, + props, + }), + ); + + this.telemetry?.record(eventPrebuiltComponentMounted('Waitlist', props)); + }; + + public unmountWaitlist = (node: HTMLDivElement): void => { + this.assertComponentsReady(this.#componentControls); + void this.#componentControls?.ensureMounted().then(controls => controls.unmountComponent({ node })); + }; + /** * `setActive` can be used to set the active session and/or organization. */ @@ -964,6 +999,16 @@ export class Clerk implements ClerkInterface { return this.buildUrlWithAuth(this.#options.afterSignOutUrl); } + public buildWaitlistUrl(): string { + if (!this.environment || !this.environment.displayConfig) { + return ''; + } + + const waitlistUrl = this.#options['waitlistUrl'] || this.environment.displayConfig.waitlistUrl; + + return buildURL({ base: waitlistUrl }, { stringify: true }); + } + public buildAfterMultiSessionSingleSignOutUrl(): string { if (!this.#options.afterMultiSessionSingleSignOutUrl) { return this.buildUrlWithAuth( @@ -1078,6 +1123,13 @@ export class Clerk implements ClerkInterface { return; }; + public redirectToWaitlist = async (): Promise => { + if (inBrowser()) { + return this.navigate(this.buildWaitlistUrl()); + } + return; + }; + public handleEmailLinkVerification = async ( params: HandleEmailLinkVerificationParams, customNavigate?: (to: string) => Promise, @@ -1479,6 +1531,9 @@ export class Clerk implements ClerkInterface { public getOrganization = async (organizationId: string): Promise => Organization.get(organizationId); + public joinWaitlist = async ({ emailAddress }: JoinWaitlistParams): Promise => + Waitlist.join({ emailAddress }); + public updateEnvironment(environment: EnvironmentResource): asserts this is { environment: EnvironmentResource } { this.environment = environment; this.#authService?.setEnvironment(environment); diff --git a/packages/clerk-js/src/core/constants.ts b/packages/clerk-js/src/core/constants.ts index a3ef8a6844..3905e61f75 100644 --- a/packages/clerk-js/src/core/constants.ts +++ b/packages/clerk-js/src/core/constants.ts @@ -36,4 +36,5 @@ export const DEBOUNCE_MS = 350; export const SIGN_UP_MODES: Record = { PUBLIC: 'public', RESTRICTED: 'restricted', + WAITLIST: 'waitlist', }; diff --git a/packages/clerk-js/src/core/resources/DisplayConfig.ts b/packages/clerk-js/src/core/resources/DisplayConfig.ts index 73150b2e37..b2750c1f52 100644 --- a/packages/clerk-js/src/core/resources/DisplayConfig.ts +++ b/packages/clerk-js/src/core/resources/DisplayConfig.ts @@ -46,6 +46,7 @@ export class DisplayConfig extends BaseResource implements DisplayConfigResource showDevModeWarning!: boolean; termsUrl!: string; privacyPolicyUrl!: string; + waitlistUrl!: string; public constructor(data: DisplayConfigJSON) { super(); @@ -91,6 +92,7 @@ export class DisplayConfig extends BaseResource implements DisplayConfigResource this.showDevModeWarning = data.show_devmode_warning; this.termsUrl = data.terms_url; this.privacyPolicyUrl = data.privacy_policy_url; + this.waitlistUrl = data.waitlist_url; return this; } } diff --git a/packages/clerk-js/src/core/resources/Waitlist.ts b/packages/clerk-js/src/core/resources/Waitlist.ts new file mode 100644 index 0000000000..dffce0a897 --- /dev/null +++ b/packages/clerk-js/src/core/resources/Waitlist.ts @@ -0,0 +1,40 @@ +import type { JoinWaitlistParams, WaitlistJSON, WaitlistResource } from '@clerk/types'; + +import { unixEpochToDate } from '../../utils/date'; +import { BaseResource } from './internal'; + +export class Waitlist extends BaseResource implements WaitlistResource { + pathRoot = '/waitlist'; + + id = ''; + updatedAt: Date | null = null; + createdAt: Date | null = null; + + constructor(data: WaitlistJSON) { + super(); + this.fromJSON(data); + } + + protected fromJSON(data: WaitlistJSON | null): this { + if (!data) { + return this; + } + + this.id = data.id; + this.updatedAt = unixEpochToDate(data.updated_at); + this.createdAt = unixEpochToDate(data.created_at); + return this; + } + + static async join(params: JoinWaitlistParams): Promise { + const json = ( + await BaseResource._fetch({ + path: '/waitlist', + method: 'POST', + body: params as any, + }) + )?.response as unknown as WaitlistJSON; + + return new Waitlist(json); + } +} diff --git a/packages/clerk-js/src/core/resources/__tests__/Waitlist.test.ts b/packages/clerk-js/src/core/resources/__tests__/Waitlist.test.ts new file mode 100644 index 0000000000..d3c8ac5512 --- /dev/null +++ b/packages/clerk-js/src/core/resources/__tests__/Waitlist.test.ts @@ -0,0 +1,14 @@ +import { Waitlist } from '../internal'; + +describe('Organization', () => { + it('has the same initial properties', () => { + const organization = new Waitlist({ + object: 'waitlist', + id: 'test_id', + created_at: 12345, + updated_at: 5678, + }); + + expect(organization).toMatchSnapshot(); + }); +}); diff --git a/packages/clerk-js/src/core/resources/__tests__/__snapshots__/Waitlist.test.ts.snap b/packages/clerk-js/src/core/resources/__tests__/__snapshots__/Waitlist.test.ts.snap new file mode 100644 index 0000000000..85b3635146 --- /dev/null +++ b/packages/clerk-js/src/core/resources/__tests__/__snapshots__/Waitlist.test.ts.snap @@ -0,0 +1,10 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Organization has the same initial properties 1`] = ` +Waitlist { + "createdAt": 1970-01-01T00:00:12.345Z, + "id": "test_id", + "pathRoot": "/waitlist", + "updatedAt": 1970-01-01T00:00:05.678Z, +} +`; diff --git a/packages/clerk-js/src/core/resources/index.ts b/packages/clerk-js/src/core/resources/index.ts index f7d86f3f78..fea2110153 100644 --- a/packages/clerk-js/src/core/resources/index.ts +++ b/packages/clerk-js/src/core/resources/index.ts @@ -18,3 +18,4 @@ export * from './Token'; export * from './User'; export * from './Verification'; export * from './Web3Wallet'; +export * from './Waitlist'; diff --git a/packages/clerk-js/src/core/resources/internal.ts b/packages/clerk-js/src/core/resources/internal.ts index ef992f16ce..9777cf01dd 100644 --- a/packages/clerk-js/src/core/resources/internal.ts +++ b/packages/clerk-js/src/core/resources/internal.ts @@ -32,3 +32,4 @@ export * from './User'; export * from './UserOrganizationInvitation'; export * from './Verification'; export * from './Web3Wallet'; +export * from './Waitlist'; diff --git a/packages/clerk-js/src/ui/Components.tsx b/packages/clerk-js/src/ui/Components.tsx index 5fd0168ee3..cf186260ec 100644 --- a/packages/clerk-js/src/ui/Components.tsx +++ b/packages/clerk-js/src/ui/Components.tsx @@ -12,6 +12,7 @@ import type { SignInProps, SignUpProps, UserProfileProps, + WaitlistProps, } from '@clerk/types'; import React, { Suspense } from 'react'; @@ -30,6 +31,7 @@ import { SignUpModal, UserProfileModal, UserVerificationModal, + WaitlistModal, } from './lazyModules/components'; import { LazyComponentRenderer, @@ -65,7 +67,8 @@ export type ComponentControls = { | 'userProfile' | 'organizationProfile' | 'createOrganization' - | 'userVerification', + | 'userVerification' + | 'waitlist', >( modal: T, props: T extends 'signIn' @@ -74,7 +77,9 @@ export type ComponentControls = { ? SignUpProps : T extends 'userVerification' ? __experimental_UserVerificationProps - : UserProfileProps, + : T extends 'waitlist' + ? WaitlistProps + : UserProfileProps, ) => void; closeModal: ( modal: @@ -84,7 +89,8 @@ export type ComponentControls = { | 'userProfile' | 'organizationProfile' | 'createOrganization' - | 'userVerification', + | 'userVerification' + | 'waitlist', options?: { notify?: boolean; }, @@ -119,6 +125,7 @@ interface ComponentsState { organizationProfileModal: null | OrganizationProfileProps; createOrganizationModal: null | CreateOrganizationProps; organizationSwitcherPrefetch: boolean; + waitlistModal: null | WaitlistProps; nodes: Map; impersonationFab: boolean; } @@ -183,6 +190,7 @@ const componentNodes = Object.freeze({ UserProfile: 'userProfileModal', OrganizationProfile: 'organizationProfileModal', CreateOrganization: 'createOrganizationModal', + Waitlist: 'waitlistModal', }) as any; const Components = (props: ComponentsProps) => { @@ -197,6 +205,7 @@ const Components = (props: ComponentsProps) => { organizationProfileModal: null, createOrganizationModal: null, organizationSwitcherPrefetch: false, + waitlistModal: null, nodes: new Map(), impersonationFab: false, }); @@ -209,6 +218,7 @@ const Components = (props: ComponentsProps) => { userVerificationModal, organizationProfileModal, createOrganizationModal, + waitlistModal, nodes, } = state; @@ -334,6 +344,7 @@ const Components = (props: ComponentsProps) => { > + ); @@ -350,6 +361,7 @@ const Components = (props: ComponentsProps) => { > + ); @@ -427,6 +439,22 @@ const Components = (props: ComponentsProps) => { ); + const mountedWaitlistModal = ( + componentsControls.closeModal('waitlist')} + onExternalNavigate={() => componentsControls.closeModal('waitlist')} + startPath={buildVirtualRouterUrl({ base: '/waitlist', path: urlStateParam?.path })} + componentName={'WaitlistModal'} + > + + + + ); + return ( { {userVerificationModal && mountedUserVerificationModal} {organizationProfileModal && mountedOrganizationProfileModal} {createOrganizationModal && mountedCreateOrganizationModal} + {waitlistModal && mountedWaitlistModal} {state.impersonationFab && ( diff --git a/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx b/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx index 0f4287db47..91b236f601 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignIn.tsx @@ -80,6 +80,7 @@ export const SignIn: React.ComponentType = withCoreSessionSwitchGua export const SignInModal = (props: SignInModalProps): JSX.Element => { const signInProps = { signUpUrl: `/${VIRTUAL_ROUTER_BASE_PATH}/sign-up`, + waitlistUrl: `/${VIRTUAL_ROUTER_BASE_PATH}/waitlist`, ...props, }; diff --git a/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx b/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx index 97651aaf66..31ae4fc780 100644 --- a/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/SignInStart.tsx @@ -65,7 +65,7 @@ export function _SignInStart(): JSX.Element { const signIn = useCoreSignIn(); const { navigate } = useRouter(); const ctx = useSignInContext(); - const { navigateAfterSignIn, signUpUrl } = ctx; + const { navigateAfterSignIn, signUpUrl, waitlistUrl } = ctx; const supportEmail = useSupportEmail(); const identifierAttributes = useMemo( () => groupIdentifiers(userSettings.enabledFirstFactorIdentifiers), @@ -420,6 +420,15 @@ export function _SignInStart(): JSX.Element { /> )} + {userSettings.signUp.mode === SIGN_UP_MODES.WAITLIST && ( + + + + + )} diff --git a/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInStart.test.tsx b/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInStart.test.tsx index b88ad62ae3..a85f1f3be6 100644 --- a/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInStart.test.tsx +++ b/packages/clerk-js/src/ui/components/SignIn/__tests__/SignInStart.test.tsx @@ -134,6 +134,17 @@ describe('SignInStart', () => { }); }); + describe('Waitlist mode', () => { + it('shows the waitlist message', async () => { + const { wrapper } = await createFixtures(f => { + f.withEmailAddress(); + f.withWaitlistMode(); + }); + render(, { wrapper }); + screen.getByText('Join waitlist'); + }); + }); + describe('Social OAuth', () => { it.each(OAUTH_PROVIDERS)('shows the "Continue with $name" social OAuth button', async ({ provider, name }) => { const { wrapper } = await createFixtures(f => { diff --git a/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx b/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx index 12cbbb0f2d..0ae292691c 100644 --- a/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx +++ b/packages/clerk-js/src/ui/components/SignUp/SignUp.tsx @@ -92,6 +92,7 @@ export const SignUp: React.ComponentType = withCoreSessionSwitchGua export const SignUpModal = (props: SignUpModalProps): JSX.Element => { const signUpProps = { signInUrl: `/${VIRTUAL_ROUTER_BASE_PATH}/sign-in`, + waitlistUrl: `/${VIRTUAL_ROUTER_BASE_PATH}/waitlist`, ...props, }; diff --git a/packages/clerk-js/src/ui/components/SignUp/SignUpRestrictedAccess.tsx b/packages/clerk-js/src/ui/components/SignUp/SignUpRestrictedAccess.tsx index 2a931b5ce1..927e111951 100644 --- a/packages/clerk-js/src/ui/components/SignUp/SignUpRestrictedAccess.tsx +++ b/packages/clerk-js/src/ui/components/SignUp/SignUpRestrictedAccess.tsx @@ -1,22 +1,35 @@ import { useClerk } from '@clerk/shared/react'; -import { useSignUpContext } from '../../contexts'; +import { SIGN_UP_MODES } from '../../../core/constants'; +import { useEnvironment, useSignUpContext } from '../../contexts'; import { Button, Flex, Icon, localizationKeys } from '../../customizables'; import { Card, Header } from '../../elements'; import { useCardState } from '../../elements/contexts'; import { useSupportEmail } from '../../hooks/useSupportEmail'; import { Block } from '../../icons'; - +import { useRouter } from '../../router'; export const SignUpRestrictedAccess = () => { const clerk = useClerk(); const card = useCardState(); - const { signInUrl } = useSignUpContext(); + const { navigate } = useRouter(); + const { signInUrl, waitlistUrl } = useSignUpContext(); const supportEmail = useSupportEmail(); + const { userSettings } = useEnvironment(); + const { mode } = userSettings.signUp; const handleEmailSupport = () => { window.location.href = `mailto:${supportEmail}`; }; + const handleWaitlistNavigate = async () => { + await navigate(clerk.buildUrlWithAuth(waitlistUrl)); + }; + + const subtitle = + mode === SIGN_UP_MODES.RESTRICTED + ? localizationKeys('signUp.restrictedAccess.subtitle') + : localizationKeys('signUp.restrictedAccess.subtitleWaitlist'); + return ( @@ -30,10 +43,10 @@ export const SignUpRestrictedAccess = () => { })} /> - + {card.error} - {supportEmail && ( + {mode === SIGN_UP_MODES.RESTRICTED && supportEmail && ( { + diff --git a/playground/nextjs/pages/waitlist/index.tsx b/playground/nextjs/pages/waitlist/index.tsx new file mode 100644 index 0000000000..899280f67c --- /dev/null +++ b/playground/nextjs/pages/waitlist/index.tsx @@ -0,0 +1,19 @@ +import type { NextPage } from 'next'; +import { Waitlist, useClerk } from '@clerk/nextjs'; + +const WaitlistPage: NextPage = () => { + const clerk = useClerk(); + + return ( + <> +
+ +
+
+ +
+ + ) +} + +export default WaitlistPage; \ No newline at end of file