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:
+15
-4
@@ -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';
|
||||
+4
-3
@@ -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.`,
|
||||
|
||||
+110
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+17
-41
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+52
@@ -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%' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+48
@@ -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');
|
||||
});
|
||||
});
|
||||
+48
@@ -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"');
|
||||
});
|
||||
});
|
||||
+37
@@ -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({});
|
||||
});
|
||||
});
|
||||
+26
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user