feat(website): book an intro call after partner application (#21343)

## What

After a partner submits the application wizard, the success screen now
offers an inline Cal.com booking widget so they can book an intro call
on the spot — keeping the partner process high-touch.

- Inline Cal.com embed on the application success step, prefilled with
the applicant's name/email (company in the notes)
- Wide `month_view` layout; the success modal widens to ~960px (the
4-step form and mobile are unchanged)
- Event-type-details panel hidden via `cal('ui', { hideEventTypeDetails:
true })` so it's just calendar + times, on its own `partner-intro` Cal
namespace (isolated from the ContactCal embed)
- "I'll book later" escape hatch; backend / submission path untouched

## Why

The warmest moment is right after someone opts in. Today the success
screen only shows a Close button — this turns that moment into a
scheduled conversation.

## How

- `PartnerIntroCalEmbed` — thin wrapper over `@calcom/embed-react`
(already a dependency), reusing the existing `ContactCal` embed pattern
- `buildPartnerIntroPrefill` — pure mapping of applicant fields → Cal
prefill
- `PartnerApplicationSuccess` — presentational success view (heading +
subtitle + embed + dismiss)
- The wizard reports submitted-state up (`onSubmittedChange`) so the
modal widens only on the booking step
- New Lingui copy + regenerated catalogs (en/es/fr)

## Test plan

- `npx jest PartnerApplication` — green (prefill mapping, embed
link/layout/prefill, success view)
- `npx nx typecheck twenty-website` — clean
- `npx oxlint -c .oxlintrc.json` / `npx oxfmt --check` — clean
- Manual: submit the wizard → success step shows the wide booking
calendar, prefilled, dark theme, no event-details panel; "I'll book
later" closes the modal

## Notes

- Frontend only — no backend, schema, or submission-path change
- Cal link `rashad-twenty/partner-intro` lives as a constant in
`config.ts`
- Branch is currently behind `main`; happy to rebase before review
This commit is contained in:
Rashad Karanouh
2026-06-09 18:00:48 +04:00
committed by GitHub
parent ba4ac6b70e
commit bda6fcfec9
17 changed files with 423 additions and 88 deletions
@@ -5,7 +5,7 @@ import {
PartnerApplicationWizard,
partnerWizardPanelClass,
} from '@/sections/PartnerApplication/wizard/PartnerApplicationWizard';
import { useEffect, useState } from 'react';
import { useLayoutEffect, useState } from 'react';
type PartnerApplicationModalProps = {
open: boolean;
@@ -17,9 +17,15 @@ export function PartnerApplicationModal({
onClose,
}: PartnerApplicationModalProps) {
const [resetSignal, setResetSignal] = useState(0);
const [submitted, setSubmitted] = useState(false);
useEffect(() => {
if (open) setResetSignal((n) => n + 1);
// Reset before paint (not useEffect) so a stale submitted=true from a prior
// session can't flash the wide booking panel for a frame on reopen.
useLayoutEffect(() => {
if (open) {
setResetSignal((n) => n + 1);
setSubmitted(false);
}
}, [open]);
return (
@@ -29,8 +35,13 @@ export function PartnerApplicationModal({
if (!next) onClose();
}}
className={partnerWizardPanelClass}
panelWidth={submitted ? 'min(960px, 100%)' : undefined}
>
<PartnerApplicationWizard resetSignal={resetSignal} onSuccess={onClose} />
<PartnerApplicationWizard
resetSignal={resetSignal}
onSuccess={onClose}
onSubmitted={() => setSubmitted(true)}
/>
</Modal.Root>
);
}
@@ -0,0 +1,7 @@
// Cal.com path (no host) the partner application success screen books the intro
// call onto. Overridable via env so the org / self-hosters can point it at a
// team link without a code change; defaults to the canonical handle.
// Full link: https://cal.com/<this-value>
export const PARTNER_INTRO_CAL_LINK =
process.env.NEXT_PUBLIC_PARTNER_INTRO_CAL_LINK ||
'rashad-twenty/partner-intro';
@@ -12,9 +12,10 @@ export const PARTNER_APPLICATION_MODAL_COPY = {
next: msg`Next →`,
submit: msg`Submit application`,
submitInFlight: msg`Submitting…`,
successTitleSerif: msg`Thanks,`,
successTitleSans: msg`we'll be in touch!`,
successClose: msg`Close`,
successTitleSerif: msg`Thanks, you're in.`,
successTitleSans: msg`Now book your intro call.`,
bookIntroSubtitle: msg`Grab 30 minutes so we can get to know your team.`,
bookLater: msg`I'll book later →`,
validation: {
incompleteForm: msg`Please complete all required fields before continuing.`,
invalidEmail: msg`Enter a valid email address.`,
@@ -0,0 +1,110 @@
'use client';
import { Body, Heading, HeadingPart } from '@/design-system/components';
import { PartnerIntroCalEmbed } from '@/sections/PartnerApplication/wizard/PartnerIntroCalEmbed';
import { buildPartnerIntroPrefill } from '@/sections/PartnerApplication/wizard/partner-intro-prefill';
import { theme } from '@/theme';
import { styled } from '@linaria/react';
type DialogPrimitive = React.ComponentType<{ render?: React.ReactElement }>;
type PartnerApplicationSuccessProps = {
Title: DialogPrimitive;
Description: DialogPrimitive;
titleSerif: string;
titleSans: string;
subtitle: string;
bookLaterLabel: string;
name?: string;
email?: string;
company?: string;
onDismiss: () => void;
};
const TitleHeadingWrapper = styled.div`
color: ${theme.colors.secondary.text[100]};
`;
const SuccessView = styled.div`
align-items: stretch;
display: flex;
flex-direction: column;
gap: clamp(16px, 4vh, 32px);
margin-top: clamp(16px, 4vh, 32px);
`;
const Subtitle = styled.div`
color: ${theme.colors.secondary.text[60]};
`;
const EmbedFrame = styled.div`
border: 1px solid ${theme.colors.secondary.border[20]};
border-radius: ${theme.radius(2)};
overflow: hidden;
`;
const BookLaterButton = styled.button`
align-self: flex-end;
background: transparent;
border: none;
color: ${theme.colors.secondary.text[60]};
cursor: pointer;
font-family: ${theme.font.family.mono};
font-size: ${theme.font.size(3)};
padding: 0;
text-transform: uppercase;
`;
export function PartnerApplicationSuccess({
Title,
Description,
titleSerif,
titleSans,
subtitle,
bookLaterLabel,
name,
email,
company,
onDismiss,
}: PartnerApplicationSuccessProps) {
const prefill = buildPartnerIntroPrefill({ name, email, company });
return (
<>
<Title
render={
<TitleHeadingWrapper>
<Heading as="h2" size="lg" weight="light">
<HeadingPart fontFamily="serif" fontWeight="light">
{titleSerif}
</HeadingPart>
<br />
<HeadingPart fontFamily="sans" fontWeight="light">
{titleSans}
</HeadingPart>
</Heading>
</TitleHeadingWrapper>
}
/>
<SuccessView>
<Description
render={
<Subtitle>
<Body size="md">{subtitle}</Body>
</Subtitle>
}
/>
<EmbedFrame>
<PartnerIntroCalEmbed
name={prefill.name}
email={prefill.email}
notes={prefill.notes}
/>
</EmbedFrame>
<BookLaterButton type="button" onClick={onDismiss}>
{bookLaterLabel}
</BookLaterButton>
</SuccessView>
</>
);
}
@@ -16,6 +16,7 @@ import { IdentityStep } from '@/sections/PartnerApplication/wizard/steps/Identit
import { ProfileStep } from '@/sections/PartnerApplication/wizard/steps/ProfileStep';
import { ExpertiseStep } from '@/sections/PartnerApplication/wizard/steps/ExpertiseStep';
import { CommercialsStep } from '@/sections/PartnerApplication/wizard/steps/CommercialsStep';
import { PartnerApplicationSuccess } from '@/sections/PartnerApplication/wizard/PartnerApplicationSuccess';
import {
buildPartnerApplicationRequestBody,
getCurrentStepId,
@@ -124,14 +125,6 @@ const FieldErrorBanner = styled.p`
margin: 0;
`;
const SuccessView = styled.div`
align-items: stretch;
display: flex;
flex-direction: column;
gap: clamp(16px, 4vh, 32px);
margin-top: clamp(16px, 4vh, 32px);
`;
function StepRenderer({
controller,
}: {
@@ -160,12 +153,14 @@ type WizardSlots = {
type WizardProps = {
resetSignal: number;
onSuccess: () => void;
onSubmitted?: () => void;
slots?: WizardSlots;
};
export function PartnerApplicationWizard({
resetSignal,
onSuccess,
onSubmitted,
slots,
}: WizardProps) {
const { i18n } = useLingui();
@@ -237,6 +232,7 @@ export function PartnerApplicationWizard({
return;
}
setSubmitted();
onSubmitted?.();
} catch {
setSubmitError(
i18n._(PARTNER_APPLICATION_MODAL_COPY.validation.submitFailed),
@@ -252,45 +248,25 @@ export function PartnerApplicationWizard({
setSubmitError,
setSubmitting,
setSubmitted,
onSubmitted,
i18n,
],
);
if (state.isSubmitted) {
return (
<>
<TitleBlock>
<Title
render={
<TitleHeadingWrapper>
<Heading as="h2" size="lg" weight="light">
<HeadingPart fontFamily="serif" fontWeight="light">
{i18n._(PARTNER_APPLICATION_MODAL_COPY.successTitleSerif)}
</HeadingPart>
<br />
<HeadingPart fontFamily="sans" fontWeight="light">
{i18n._(PARTNER_APPLICATION_MODAL_COPY.successTitleSans)}
</HeadingPart>
</Heading>
</TitleHeadingWrapper>
}
/>
</TitleBlock>
<SuccessView>
<Modal.Footer>
<PrimaryButton type="button" onClick={onSuccess}>
<ButtonShape
fillColor={theme.colors.primary.background[100]}
height={BUTTON_HEIGHTS_PX.regular}
strokeColor="none"
/>
<PrimaryLabel>
{i18n._(PARTNER_APPLICATION_MODAL_COPY.successClose)}
</PrimaryLabel>
</PrimaryButton>
</Modal.Footer>
</SuccessView>
</>
<PartnerApplicationSuccess
Title={Title}
Description={Description}
titleSerif={i18n._(PARTNER_APPLICATION_MODAL_COPY.successTitleSerif)}
titleSans={i18n._(PARTNER_APPLICATION_MODAL_COPY.successTitleSans)}
subtitle={i18n._(PARTNER_APPLICATION_MODAL_COPY.bookIntroSubtitle)}
bookLaterLabel={i18n._(PARTNER_APPLICATION_MODAL_COPY.bookLater)}
name={state.name}
email={state.email}
company={state.company}
onDismiss={onSuccess}
/>
);
}
@@ -0,0 +1,52 @@
'use client';
import Cal, { getCalApi } from '@calcom/embed-react';
import { useEffect } from 'react';
import { PARTNER_INTRO_CAL_LINK } from '@/sections/PartnerApplication/config';
// Own namespace so the ui() config (hiding event-type details) applies only to
// this embed, not the ContactCal "talk to us" embed elsewhere on the site.
const PARTNER_INTRO_CAL_NAMESPACE = 'partner-intro';
type PartnerIntroCalEmbedProps = {
name?: string;
email?: string;
notes?: string;
};
// Renders Cal.com's own booking UI in an iframe. We control the link, theme,
// month_view layout, and prefill via config; hideEventTypeDetails must go
// through ui() (it is not part of the config prop type). Cal owns the rest.
export function PartnerIntroCalEmbed({
name,
email,
notes,
}: PartnerIntroCalEmbedProps) {
useEffect(() => {
let cancelled = false;
void (async () => {
const cal = await getCalApi({ namespace: PARTNER_INTRO_CAL_NAMESPACE });
if (cancelled) return;
cal('ui', { hideEventTypeDetails: true });
})();
return () => {
cancelled = true;
};
}, []);
return (
<Cal
namespace={PARTNER_INTRO_CAL_NAMESPACE}
calLink={PARTNER_INTRO_CAL_LINK}
config={{
theme: 'dark',
layout: 'month_view',
...(name !== undefined ? { name } : {}),
...(email !== undefined ? { email } : {}),
...(notes !== undefined ? { notes } : {}),
}}
style={{ minHeight: 520, overflow: 'auto', width: '100%' }}
/>
);
}
@@ -0,0 +1,48 @@
// __tests__/PartnerApplicationSuccess.test.tsx
import { renderToStaticMarkup } from 'react-dom/server';
jest.mock('@calcom/embed-react', () => ({
__esModule: true,
default: (props: {
calLink: string;
config?: { name?: string; email?: string; notes?: string };
}) => (
<div
data-testid="cal-embed"
data-callink={props.calLink}
data-name={props.config?.name}
data-notes={props.config?.notes}
/>
),
}));
import { PartnerApplicationSuccess } from '@/sections/PartnerApplication/wizard/PartnerApplicationSuccess';
const Passthrough = ({ render }: { render?: React.ReactElement }) => (
<>{render}</>
);
describe('PartnerApplicationSuccess', () => {
it('renders the booking embed prefilled from the applicant plus an escape hatch', () => {
const html = renderToStaticMarkup(
<PartnerApplicationSuccess
Title={Passthrough}
Description={Passthrough}
titleSerif="Thanks, you are in."
titleSans="Now book your intro call."
subtitle="Grab 30 minutes so we can get to know your team."
bookLaterLabel="book later"
name="Ada Lovelace"
email="ada@example.com"
company="Analytical Engines"
onDismiss={() => undefined}
/>,
);
expect(html).toContain('data-callink="rashad-twenty/partner-intro"');
expect(html).toContain('data-name="Ada Lovelace"');
expect(html).toContain('data-notes="Company: Analytical Engines"');
expect(html).toContain('Now book your intro call.');
expect(html).toContain('book later');
});
});
@@ -0,0 +1,48 @@
// __tests__/PartnerIntroCalEmbed.test.tsx
import { renderToStaticMarkup } from 'react-dom/server';
jest.mock('@calcom/embed-react', () => ({
__esModule: true,
default: (props: {
calLink: string;
config?: {
theme?: string;
layout?: string;
name?: string;
email?: string;
notes?: string;
};
}) => (
<div
data-testid="cal-embed"
data-callink={props.calLink}
data-theme={props.config?.theme}
data-layout={props.config?.layout}
data-name={props.config?.name}
data-email={props.config?.email}
data-notes={props.config?.notes}
/>
),
getCalApi: jest.fn(async () => jest.fn()),
}));
import { PartnerIntroCalEmbed } from '@/sections/PartnerApplication/wizard/PartnerIntroCalEmbed';
describe('PartnerIntroCalEmbed', () => {
it('points at the partner-intro Cal link and forwards prefill + theme', () => {
const html = renderToStaticMarkup(
<PartnerIntroCalEmbed
name="Ada Lovelace"
email="ada@example.com"
notes="Company: Acme"
/>,
);
expect(html).toContain('data-callink="rashad-twenty/partner-intro"');
expect(html).toContain('data-theme="dark"');
expect(html).toContain('data-layout="month_view"');
expect(html).toContain('data-name="Ada Lovelace"');
expect(html).toContain('data-email="ada@example.com"');
expect(html).toContain('data-notes="Company: Acme"');
});
});
@@ -0,0 +1,37 @@
import { buildPartnerIntroPrefill } from '../partner-intro-prefill';
describe('buildPartnerIntroPrefill', () => {
it('maps name, email and company into Cal prefill fields', () => {
expect(
buildPartnerIntroPrefill({
name: 'Ada Lovelace',
email: 'ada@example.com',
company: 'Analytical Engines',
}),
).toEqual({
name: 'Ada Lovelace',
email: 'ada@example.com',
notes: 'Company: Analytical Engines',
});
});
it('trims values before mapping', () => {
expect(
buildPartnerIntroPrefill({
name: ' Ada ',
email: ' ada@example.com ',
company: ' Acme ',
}),
).toEqual({
name: 'Ada',
email: 'ada@example.com',
notes: 'Company: Acme',
});
});
it('omits empty or whitespace-only fields', () => {
expect(
buildPartnerIntroPrefill({ name: '', email: ' ', company: undefined }),
).toEqual({});
});
});
@@ -0,0 +1,26 @@
export type PartnerIntroPrefillInput = {
name?: string;
email?: string;
company?: string;
};
// Cal.com prefill keys. `notes` surfaces the applicant's company on the booking
// so the call arrives pre-qualified.
export type PartnerIntroPrefill = {
name?: string;
email?: string;
notes?: string;
};
export function buildPartnerIntroPrefill(
input: PartnerIntroPrefillInput,
): PartnerIntroPrefill {
const prefill: PartnerIntroPrefill = {};
const name = input.name?.trim();
const email = input.email?.trim();
const company = input.company?.trim();
if (name) prefill.name = name;
if (email) prefill.email = email;
if (company) prefill.notes = `Company: ${company}`;
return prefill;
}