Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(clerk-js,nextjs,clerk-react,types): Introduce <Waitlist /> component #4376

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
---

Show `Join waitlist` promt from `<SignIn />` component when mode is `waitlist`
Change the text in `RestrictedAccess` component when mode is `waitlist`
Introduce `<Waitlist />` 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

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",
"useAuth",
"useClerk",
"useEmailLink",
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 @@ -36,6 +36,7 @@ import type {
HandleEmailLinkVerificationParams,
HandleOAuthCallbackParams,
InstanceType,
JoinWaitlistParams,
ListenerCallback,
LoadedClerk,
NavigateOptions,
Expand All @@ -61,6 +62,8 @@ import type {
UserButtonProps,
UserProfileProps,
UserResource,
WaitlistProps,
WaitlistResource,
Web3Provider,
} from '@clerk/types';

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

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -1078,6 +1123,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 @@ -1479,6 +1531,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('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();
});
});
Original file line number Diff line number Diff line change
@@ -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,
}
`;
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