Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
5 changes: 5 additions & 0 deletions integration/templates/custom-flows-react-vite/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Home } from './routes/Home';
import { SignIn } from './routes/SignIn';
import { SignUp } from './routes/SignUp';
import { Protected } from './routes/Protected';
import { Waitlist } from './routes/Waitlist';

// Import your Publishable Key
const PUBLISHABLE_KEY = import.meta.env.VITE_CLERK_PUBLISHABLE_KEY;
Expand Down Expand Up @@ -37,6 +38,10 @@ createRoot(document.getElementById('root')!).render(
path='/sign-up'
element={<SignUp />}
/>
<Route
path='/waitlist'
element={<Waitlist />}
/>
<Route
path='/protected'
element={<Protected />}
Expand Down
112 changes: 112 additions & 0 deletions integration/templates/custom-flows-react-vite/src/routes/Waitlist.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
'use client';

import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useWaitlist } from '@clerk/react';
import { NavLink } from 'react-router';

export function Waitlist({ className, ...props }: React.ComponentProps<'div'>) {
const { waitlist, errors, fetchStatus } = useWaitlist();

const handleSubmit = async (formData: FormData) => {
const emailAddress = formData.get('emailAddress') as string | null;

if (!emailAddress) {
return;
}

await waitlist.join({ emailAddress });
};

if (waitlist.id) {
return (
<div
className={cn('flex flex-col gap-6', className)}
{...props}
>
<Card>
<CardHeader className='text-center'>
<CardTitle className='text-xl'>Successfully joined!</CardTitle>
<CardDescription>You&apos;re on the waitlist</CardDescription>
</CardHeader>
<CardContent>
<div className='grid gap-6'>
<div className='text-center text-sm'>
Already have an account?{' '}
<NavLink
to='/sign-in'
className='underline underline-offset-4'
data-testid='sign-in-link'
>
Sign in
</NavLink>
</div>
</div>
</CardContent>
</Card>
</div>
);
}

return (
<div
className={cn('flex flex-col gap-6', className)}
{...props}
>
<Card>
<CardHeader className='text-center'>
<CardTitle className='text-xl'>Join the Waitlist</CardTitle>
<CardDescription>Enter your email address to join the waitlist</CardDescription>
</CardHeader>
<CardContent>
<form action={handleSubmit}>
<div className='grid gap-6'>
<div className='grid gap-6'>
<div className='grid gap-3'>
<Label htmlFor='emailAddress'>Email address</Label>
<Input
id='emailAddress'
type='email'
placeholder='Email address'
required
name='emailAddress'
data-testid='email-input'
/>
{errors.fields.emailAddress && (
<p
className='text-sm text-red-600'
data-testid='email-error'
>
{errors.fields.emailAddress.longMessage}
</p>
)}
</div>
<Button
type='submit'
className='w-full'
disabled={fetchStatus === 'fetching'}
data-testid='submit-button'
>
Join Waitlist
</Button>
</div>
<div className='text-center text-sm'>
Already have an account?{' '}
<NavLink
to='/sign-in'
className='underline underline-offset-4'
data-testid='sign-in-link'
>
Sign in
</NavLink>
</div>
</div>
</form>
</CardContent>
</Card>
</div>
);
}
126 changes: 126 additions & 0 deletions integration/tests/custom-flows/waitlist.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { expect, test } from '@playwright/test';
import { parsePublishableKey } from '@clerk/shared/keys';
import { clerkSetup } from '@clerk/testing/playwright';

import type { Application } from '../../models/application';
import { appConfigs } from '../../presets';
import { createTestUtils, FakeUser } from '../../testUtils';

test.describe('Custom Flows Waitlist @custom', () => {
test.describe.configure({ mode: 'parallel' });
let app: Application;
let fakeUser: FakeUser;

test.beforeAll(async () => {
test.setTimeout(150_000);
app = await appConfigs.customFlows.reactVite.clone().commit();
await app.setup();
await app.withEnv(appConfigs.envs.withEmailCodes);
await app.dev();

const publishableKey = appConfigs.envs.withEmailCodes.publicVariables.get('CLERK_PUBLISHABLE_KEY');
const secretKey = appConfigs.envs.withEmailCodes.privateVariables.get('CLERK_SECRET_KEY');
const apiUrl = appConfigs.envs.withEmailCodes.privateVariables.get('CLERK_API_URL');
const { frontendApi: frontendApiUrl } = parsePublishableKey(publishableKey);

await clerkSetup({
publishableKey,
frontendApiUrl,
secretKey,
// @ts-expect-error
apiUrl,
dotenv: false,
});

const u = createTestUtils({ app });
fakeUser = u.services.users.createFakeUser({
fictionalEmail: true,
});
});

test.afterAll(async () => {
await app.teardown();
});

test('can join waitlist with email', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.page.goToRelative('/waitlist');
await expect(u.page.getByText('Join the Waitlist', { exact: true })).toBeVisible();

const emailInput = u.page.getByTestId('email-input');
const submitButton = u.page.getByTestId('submit-button');

await emailInput.fill(fakeUser.email);
await submitButton.click();

await expect(u.page.getByText('Successfully joined!')).toBeVisible();
await expect(u.page.getByText("You're on the waitlist")).toBeVisible();
});

test('renders error with invalid email', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.page.goToRelative('/waitlist');
await expect(u.page.getByText('Join the Waitlist', { exact: true })).toBeVisible();

const emailInput = u.page.getByTestId('email-input');
const submitButton = u.page.getByTestId('submit-button');

await emailInput.fill('invalid-email');
await submitButton.click();

await expect(u.page.getByTestId('email-error')).toBeVisible();
});

test('displays loading state while joining', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.page.goToRelative('/waitlist');
await expect(u.page.getByText('Join the Waitlist', { exact: true })).toBeVisible();

const emailInput = u.page.getByTestId('email-input');
const submitButton = u.page.getByTestId('submit-button');

await emailInput.fill(fakeUser.email);

const submitPromise = submitButton.click();

// Check that button is disabled during fetch
await expect(submitButton).toBeDisabled();

await submitPromise;

// Wait for success state
await expect(u.page.getByText('Successfully joined!')).toBeVisible();
});

test('can navigate to sign-in from waitlist', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.page.goToRelative('/waitlist');
await expect(u.page.getByText('Join the Waitlist', { exact: true })).toBeVisible();

const signInLink = u.page.getByTestId('sign-in-link');
await expect(signInLink).toBeVisible();
await signInLink.click();

await expect(u.page.getByText('Sign in', { exact: true })).toBeVisible();
await u.page.waitForURL(/sign-in/);
});

test('waitlist hook provides correct properties', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.page.goToRelative('/waitlist');

// Check initial state - waitlist resource should be available but empty
const emailInput = u.page.getByTestId('email-input');
const submitButton = u.page.getByTestId('submit-button');

await expect(emailInput).toBeVisible();
await expect(submitButton).toBeEnabled();

// Join waitlist
await emailInput.fill(fakeUser.email);
await submitButton.click();

// After successful join, the component should show success state
await expect(u.page.getByText('Successfully joined!')).toBeVisible();
});
});
48 changes: 46 additions & 2 deletions packages/clerk-js/src/core/resources/Waitlist.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { JoinWaitlistParams, WaitlistJSON, WaitlistResource } from '@clerk/types';
import type { JoinWaitlistParams, WaitlistFutureResource, WaitlistJSON, WaitlistResource } from '@clerk/types';

import { unixEpochToDate } from '../../utils/date';
import { runAsyncResourceTask } from '../../utils/runAsyncResourceTask';
import { eventBus } from '../events';
import { BaseResource } from './internal';

export class Waitlist extends BaseResource implements WaitlistResource {
Expand All @@ -10,7 +12,22 @@ export class Waitlist extends BaseResource implements WaitlistResource {
updatedAt: Date | null = null;
createdAt: Date | null = null;

constructor(data: WaitlistJSON) {
/**
* @experimental This experimental API is subject to change.
*
* An instance of `WaitlistFuture`, which has a different API than `Waitlist`, intended to be used in custom flows.
*/
__internal_future: WaitlistFuture = new WaitlistFuture(this);

/**
* @internal Only used for internal purposes, and is not intended to be used directly.
*
* This property is used to provide access to underlying Client methods to `WaitlistFuture`, which wraps an instance
* of `Waitlist`.
*/
__internal_basePost = this._basePost.bind(this);

constructor(data: WaitlistJSON | null = null) {
super();
this.fromJSON(data);
}
Expand All @@ -23,6 +40,8 @@ export class Waitlist extends BaseResource implements WaitlistResource {
this.id = data.id;
this.updatedAt = unixEpochToDate(data.updated_at);
this.createdAt = unixEpochToDate(data.created_at);

eventBus.emit('resource:update', { resource: this });
return this;
}

Expand All @@ -38,3 +57,28 @@ export class Waitlist extends BaseResource implements WaitlistResource {
return new Waitlist(json);
}
}

class WaitlistFuture implements WaitlistFutureResource {
constructor(readonly resource: Waitlist) {}

get id() {
return this.resource.id || undefined;
}

get createdAt() {
return this.resource.createdAt;
}

get updatedAt() {
return this.resource.updatedAt;
}

async join(params: JoinWaitlistParams): Promise<{ error: unknown }> {
return runAsyncResourceTask(this.resource, async () => {
await this.resource.__internal_basePost({
path: this.resource.pathRoot,
body: params,
});
});
}
}
17 changes: 16 additions & 1 deletion packages/clerk-js/src/core/signals.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { isClerkAPIResponseError } from '@clerk/shared/error';
import { snakeToCamel } from '@clerk/shared/underscore';
import type { Errors, SignInSignal, SignUpSignal } from '@clerk/types';
import type { Errors, SignInSignal, SignUpSignal, WaitlistSignal } from '@clerk/types';
import { computed, signal } from 'alien-signals';

import type { SignIn } from './resources/SignIn';
import type { SignUp } from './resources/SignUp';
import type { Waitlist } from './resources/Waitlist';

export const signInResourceSignal = signal<{ resource: SignIn | null }>({ resource: null });
export const signInErrorSignal = signal<{ error: unknown }>({ error: null });
Expand Down Expand Up @@ -34,6 +35,20 @@ export const signUpComputedSignal: SignUpSignal = computed(() => {
return { errors, fetchStatus, signUp: signUp ? signUp.__internal_future : null };
});

export const waitlistResourceSignal = signal<{ resource: Waitlist | null }>({ resource: null });
export const waitlistErrorSignal = signal<{ error: unknown }>({ error: null });
export const waitlistFetchSignal = signal<{ status: 'idle' | 'fetching' }>({ status: 'idle' });

export const waitlistComputedSignal: WaitlistSignal = computed(() => {
const waitlist = waitlistResourceSignal().resource;
const error = waitlistErrorSignal().error;
const fetchStatus = waitlistFetchSignal().status;

const errors = errorsToParsedErrors(error);

return { errors, fetchStatus, waitlist: waitlist ? waitlist.__internal_future : null };
});

/**
* Converts an error to a parsed errors object that reports the specific fields that the error pertains to. Will put
* generic non-API errors into the global array.
Expand Down
Loading
Loading