Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/nine-squids-buy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"@clerk/localizations": minor
"@clerk/clerk-js": minor
"@clerk/nextjs": minor
"@clerk/clerk-react": minor
"@clerk/types": minor
---
New Feature: Introduce the `<Waitlist />` component and the `waitlist` sign up mode.

- Allow users to request access with an email address via the new `<Waitlist />` component.
- Show `Join waitlist` prompt from `<SignIn />` component when mode is `waitlist`.
- Appropriate the text in the Sign Up component when mode is `waitlist`.
- Added `joinWaitlist()` method in `Clerk` singleton.
- Added `redirectToWaitlist()` method in `Clerk` singleton to allow user to redirect to waitlist page.
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ exports[`public exports should not include a breaking change 1`] = `
"SignedOut",
"UserButton",
"UserProfile",
"Waitlist",
"__experimental_useReverification",
"useAuth",
"useClerk",
Expand Down
11 changes: 10 additions & 1 deletion packages/clerk-js/src/core/__tests__/clerk.redirects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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 = {
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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', () => {
Expand Down
55 changes: 55 additions & 0 deletions packages/clerk-js/src/core/clerk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import type {
HandleEmailLinkVerificationParams,
HandleOAuthCallbackParams,
InstanceType,
JoinWaitlistParams,
ListenerCallback,
LoadedClerk,
NavigateOptions,
Expand All @@ -53,6 +54,8 @@ import type {
UserButtonProps,
UserProfileProps,
UserResource,
WaitlistProps,
WaitlistResource,
Web3Provider,
} from '@clerk/types';

Expand Down Expand Up @@ -111,6 +114,7 @@ import {
EmailLinkErrorCode,
Environment,
Organization,
Waitlist,
} from './resources/internal';
import { warnings } from './warnings';

Expand Down Expand Up @@ -503,6 +507,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);
Expand Down Expand Up @@ -737,6 +753,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.
*/
Expand Down Expand Up @@ -974,6 +1009,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(
Expand Down Expand Up @@ -1088,6 +1133,13 @@ export class Clerk implements ClerkInterface {
return;
};

public redirectToWaitlist = async (): Promise<unknown> => {
if (inBrowser()) {
return this.navigate(this.buildWaitlistUrl());
}
return;
};

public handleEmailLinkVerification = async (
params: HandleEmailLinkVerificationParams,
customNavigate?: (to: string) => Promise<unknown>,
Expand Down Expand Up @@ -1481,6 +1533,9 @@ export class Clerk implements ClerkInterface {
public getOrganization = async (organizationId: string): Promise<OrganizationResource> =>
Organization.get(organizationId);

public joinWaitlist = async ({ emailAddress }: JoinWaitlistParams): Promise<WaitlistResource> =>
Waitlist.join({ emailAddress });

public updateEnvironment(environment: EnvironmentResource): asserts this is { environment: EnvironmentResource } {
this.environment = environment;
this.#authService?.setEnvironment(environment);
Expand Down
1 change: 1 addition & 0 deletions packages/clerk-js/src/core/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,5 @@ export const DEBOUNCE_MS = 350;
export const SIGN_UP_MODES: Record<string, SignUpModes> = {
PUBLIC: 'public',
RESTRICTED: 'restricted',
WAITLIST: 'waitlist',
};
2 changes: 2 additions & 0 deletions packages/clerk-js/src/core/resources/DisplayConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export class DisplayConfig extends BaseResource implements DisplayConfigResource
showDevModeWarning!: boolean;
termsUrl!: string;
privacyPolicyUrl!: string;
waitlistUrl!: string;

public constructor(data: DisplayConfigJSON) {
super();
Expand Down Expand Up @@ -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;
}
}
40 changes: 40 additions & 0 deletions packages/clerk-js/src/core/resources/Waitlist.ts
Original file line number Diff line number Diff line change
@@ -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<WaitlistResource> {
const json = (
await BaseResource._fetch<WaitlistJSON>({
path: '/waitlist',
method: 'POST',
body: params as any,
})
)?.response as unknown as WaitlistJSON;

return new Waitlist(json);
}
}
14 changes: 14 additions & 0 deletions packages/clerk-js/src/core/resources/__tests__/Waitlist.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Waitlist } from '../internal';

describe('Waitlist', () => {
it('has the same initial properties', () => {
const waitlist = new Waitlist({
object: 'waitlist',
id: 'test_id',
created_at: 12345,
updated_at: 5678,
});

expect(waitlist).toMatchSnapshot();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Waitlist 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,
}
`;
1 change: 1 addition & 0 deletions packages/clerk-js/src/core/resources/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ export * from './Token';
export * from './User';
export * from './Verification';
export * from './Web3Wallet';
export * from './Waitlist';
1 change: 1 addition & 0 deletions packages/clerk-js/src/core/resources/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,4 @@ export * from './User';
export * from './UserOrganizationInvitation';
export * from './Verification';
export * from './Web3Wallet';
export * from './Waitlist';
35 changes: 32 additions & 3 deletions packages/clerk-js/src/ui/Components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
SignInProps,
SignUpProps,
UserProfileProps,
WaitlistProps,
} from '@clerk/types';
import React, { Suspense } from 'react';

Expand All @@ -30,6 +31,7 @@ import {
SignUpModal,
UserProfileModal,
UserVerificationModal,
WaitlistModal,
} from './lazyModules/components';
import {
LazyComponentRenderer,
Expand Down Expand Up @@ -65,7 +67,8 @@ export type ComponentControls = {
| 'userProfile'
| 'organizationProfile'
| 'createOrganization'
| 'userVerification',
| 'userVerification'
| 'waitlist',
>(
modal: T,
props: T extends 'signIn'
Expand All @@ -74,7 +77,9 @@ export type ComponentControls = {
? SignUpProps
: T extends 'userVerification'
? __experimental_UserVerificationProps
: UserProfileProps,
: T extends 'waitlist'
? WaitlistProps
: UserProfileProps,
) => void;
closeModal: (
modal:
Expand All @@ -84,7 +89,8 @@ export type ComponentControls = {
| 'userProfile'
| 'organizationProfile'
| 'createOrganization'
| 'userVerification',
| 'userVerification'
| 'waitlist',
options?: {
notify?: boolean;
},
Expand Down Expand Up @@ -119,6 +125,7 @@ interface ComponentsState {
organizationProfileModal: null | OrganizationProfileProps;
createOrganizationModal: null | CreateOrganizationProps;
organizationSwitcherPrefetch: boolean;
waitlistModal: null | WaitlistProps;
nodes: Map<HTMLDivElement, HtmlNodeOptions>;
impersonationFab: boolean;
}
Expand Down Expand Up @@ -183,6 +190,7 @@ const componentNodes = Object.freeze({
UserProfile: 'userProfileModal',
OrganizationProfile: 'organizationProfileModal',
CreateOrganization: 'createOrganizationModal',
Waitlist: 'waitlistModal',
}) as any;

const Components = (props: ComponentsProps) => {
Expand All @@ -197,6 +205,7 @@ const Components = (props: ComponentsProps) => {
organizationProfileModal: null,
createOrganizationModal: null,
organizationSwitcherPrefetch: false,
waitlistModal: null,
nodes: new Map(),
impersonationFab: false,
});
Expand All @@ -209,6 +218,7 @@ const Components = (props: ComponentsProps) => {
userVerificationModal,
organizationProfileModal,
createOrganizationModal,
waitlistModal,
nodes,
} = state;

Expand Down Expand Up @@ -334,6 +344,7 @@ const Components = (props: ComponentsProps) => {
>
<SignInModal {...signInModal} />
<SignUpModal {...signInModal} />
<WaitlistModal {...waitlistModal} />
</LazyModalRenderer>
);

Expand All @@ -350,6 +361,7 @@ const Components = (props: ComponentsProps) => {
>
<SignInModal {...signUpModal} />
<SignUpModal {...signUpModal} />
<WaitlistModal {...waitlistModal} />
</LazyModalRenderer>
);

Expand Down Expand Up @@ -427,6 +439,22 @@ const Components = (props: ComponentsProps) => {
</LazyModalRenderer>
);

const mountedWaitlistModal = (
<LazyModalRenderer
globalAppearance={state.appearance}
appearanceKey={'waitlist'}
componentAppearance={waitlistModal?.appearance}
flowName={'waitlist'}
onClose={() => componentsControls.closeModal('waitlist')}
onExternalNavigate={() => componentsControls.closeModal('waitlist')}
startPath={buildVirtualRouterUrl({ base: '/waitlist', path: urlStateParam?.path })}
componentName={'WaitlistModal'}
>
<WaitlistModal {...waitlistModal} />
<SignInModal {...waitlistModal} />
</LazyModalRenderer>
);

return (
<Suspense fallback={''}>
<LazyProviders
Expand Down Expand Up @@ -455,6 +483,7 @@ const Components = (props: ComponentsProps) => {
{userVerificationModal && mountedUserVerificationModal}
{organizationProfileModal && mountedOrganizationProfileModal}
{createOrganizationModal && mountedCreateOrganizationModal}
{waitlistModal && mountedWaitlistModal}
{state.impersonationFab && (
<LazyImpersonationFabProvider globalAppearance={state.appearance}>
<ImpersonationFab />
Expand Down
Loading
Loading