mirror of
https://github.com/twentyhq/twenty.git
synced 2026-06-11 17:37:18 -04:00
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:
@@ -40,3 +40,7 @@ ENTERPRISE_JWT_PUBLIC_KEY=
|
||||
# (server-side only) via the /s/partners REST endpoint.
|
||||
TWENTY_PARTNERS_API_URL=
|
||||
TWENTY_PARTNERS_API_KEY=
|
||||
|
||||
# Cal.com path (no host) the partner application success screen books the intro
|
||||
# call onto, e.g. acme/partner-intro. Falls back to a built-in default when unset.
|
||||
NEXT_PUBLIC_PARTNER_INTRO_CAL_LINK=
|
||||
|
||||
@@ -1153,11 +1153,6 @@ msgstr "Classic never dies. It just gets extended one more time."
|
||||
msgid "Clear filters"
|
||||
msgstr "Clear filters"
|
||||
|
||||
#. js-lingui-id: yz7wBu
|
||||
#: src/sections/PartnerApplication/partner-application-modal-data.ts
|
||||
msgid "Close"
|
||||
msgstr "Close"
|
||||
|
||||
#. js-lingui-id: L/LPQZ
|
||||
#: src/sections/Faq/faq.data.ts
|
||||
msgid "Cloud Pro is $9/user/month (yearly). Organization is $19/user/month and unlocks SSO and row-level permissions for teams that need finer access control."
|
||||
@@ -2138,6 +2133,11 @@ msgstr "Ghana 🇬🇭"
|
||||
msgid "Good choice!"
|
||||
msgstr "Good choice!"
|
||||
|
||||
#. js-lingui-id: Zh0acB
|
||||
#: src/sections/PartnerApplication/partner-application-modal-data.ts
|
||||
msgid "Grab 30 minutes so we can get to know your team."
|
||||
msgstr "Grab 30 minutes so we can get to know your team."
|
||||
|
||||
#. js-lingui-id: +tDN2S
|
||||
#: src/sections/PartnerApplication/wizard/partner-fields.data.ts
|
||||
msgid "Greece 🇬🇷"
|
||||
@@ -2320,6 +2320,11 @@ msgstr "How W3villa Technologies shipped W3Grads, an AI mock interview platform
|
||||
msgid "Hungary 🇭🇺"
|
||||
msgstr "Hungary 🇭🇺"
|
||||
|
||||
#. js-lingui-id: T6wyby
|
||||
#: src/sections/PartnerApplication/partner-application-modal-data.ts
|
||||
msgid "I'll book later →"
|
||||
msgstr "I'll book later →"
|
||||
|
||||
#. js-lingui-id: RYDa0v
|
||||
#: src/sections/PartnerApplication/wizard/partner-fields.data.ts
|
||||
msgid "Iceland 🇮🇸"
|
||||
@@ -3084,6 +3089,11 @@ msgstr "Norwegian"
|
||||
msgid "Now a developer can describe what they want to Claude Code and have a working app in an afternoon. A custom object, a scoring workflow, a new view, an integration. The bottleneck isn't building anymore. It's whether your platform lets you."
|
||||
msgstr "Now a developer can describe what they want to Claude Code and have a working app in an afternoon. A custom object, a scoring workflow, a new view, an integration. The bottleneck isn't building anymore. It's whether your platform lets you."
|
||||
|
||||
#. js-lingui-id: WFvpMn
|
||||
#: src/sections/PartnerApplication/partner-application-modal-data.ts
|
||||
msgid "Now book your intro call."
|
||||
msgstr "Now book your intro call."
|
||||
|
||||
#. js-lingui-id: CzeIij
|
||||
#: src/app/[locale]/pricing/plan-table.data.ts
|
||||
msgid "Number of dashboards"
|
||||
@@ -4128,10 +4138,10 @@ msgstr "Thai"
|
||||
msgid "Thailand 🇹🇭"
|
||||
msgstr "Thailand 🇹🇭"
|
||||
|
||||
#. js-lingui-id: pYwj0k
|
||||
#. js-lingui-id: eRjhUK
|
||||
#: src/sections/PartnerApplication/partner-application-modal-data.ts
|
||||
msgid "Thanks,"
|
||||
msgstr "Thanks,"
|
||||
msgid "Thanks, you're in."
|
||||
msgstr "Thanks, you're in."
|
||||
|
||||
#. js-lingui-id: FjkPYg
|
||||
#: src/app/[locale]/customers/elevate-consulting/page.tsx
|
||||
@@ -4770,11 +4780,6 @@ msgstr "We could not submit your application. Please try again in a moment."
|
||||
msgid "We didn't want to patch over the problem. We wanted to build something institutions could rely on at scale, and that meant starting from a foundation solid enough to support the full complexity of what we had in mind."
|
||||
msgstr "We didn't want to patch over the problem. We wanted to build something institutions could rely on at scale, and that meant starting from a foundation solid enough to support the full complexity of what we had in mind."
|
||||
|
||||
#. js-lingui-id: u7hKUm
|
||||
#: src/sections/PartnerApplication/partner-application-modal-data.ts
|
||||
msgid "we'll be in touch!"
|
||||
msgstr "we'll be in touch!"
|
||||
|
||||
#. js-lingui-id: d5CWI6
|
||||
#: src/app/[locale]/partners/components/PartnerHero.tsx
|
||||
msgid "We're building the #1 Open Source CRM, but we can't do it alone. Join our partner ecosystem and grow with us."
|
||||
|
||||
@@ -1158,11 +1158,6 @@ msgstr "Lo clásico nunca muere. Solo se extiende una vez más."
|
||||
msgid "Clear filters"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: yz7wBu
|
||||
#: src/sections/PartnerApplication/partner-application-modal-data.ts
|
||||
msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: L/LPQZ
|
||||
#: src/sections/Faq/faq.data.ts
|
||||
msgid "Cloud Pro is $9/user/month (yearly). Organization is $19/user/month and unlocks SSO and row-level permissions for teams that need finer access control."
|
||||
@@ -2143,6 +2138,11 @@ msgstr ""
|
||||
msgid "Good choice!"
|
||||
msgstr "¡Buena elección!"
|
||||
|
||||
#. js-lingui-id: Zh0acB
|
||||
#: src/sections/PartnerApplication/partner-application-modal-data.ts
|
||||
msgid "Grab 30 minutes so we can get to know your team."
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: +tDN2S
|
||||
#: src/sections/PartnerApplication/wizard/partner-fields.data.ts
|
||||
msgid "Greece 🇬🇷"
|
||||
@@ -2325,6 +2325,11 @@ msgstr "Cómo W3villa Technologies lanzó W3Grads, una plataforma de entrevistas
|
||||
msgid "Hungary 🇭🇺"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: T6wyby
|
||||
#: src/sections/PartnerApplication/partner-application-modal-data.ts
|
||||
msgid "I'll book later →"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: RYDa0v
|
||||
#: src/sections/PartnerApplication/wizard/partner-fields.data.ts
|
||||
msgid "Iceland 🇮🇸"
|
||||
@@ -3089,6 +3094,11 @@ msgstr ""
|
||||
msgid "Now a developer can describe what they want to Claude Code and have a working app in an afternoon. A custom object, a scoring workflow, a new view, an integration. The bottleneck isn't building anymore. It's whether your platform lets you."
|
||||
msgstr "Ahora un desarrollador puede describir lo que quiere a Claude Code y tener una app funcionando en una tarde. Un objeto personalizado, un flujo de puntuación, una vista nueva, una integración. El cuello de botella ya no es construir. Es si tu plataforma te lo permite."
|
||||
|
||||
#. js-lingui-id: WFvpMn
|
||||
#: src/sections/PartnerApplication/partner-application-modal-data.ts
|
||||
msgid "Now book your intro call."
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: CzeIij
|
||||
#: src/app/[locale]/pricing/plan-table.data.ts
|
||||
msgid "Number of dashboards"
|
||||
@@ -4133,9 +4143,9 @@ msgstr ""
|
||||
msgid "Thailand 🇹🇭"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: pYwj0k
|
||||
#. js-lingui-id: eRjhUK
|
||||
#: src/sections/PartnerApplication/partner-application-modal-data.ts
|
||||
msgid "Thanks,"
|
||||
msgid "Thanks, you're in."
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: FjkPYg
|
||||
@@ -4775,11 +4785,6 @@ msgstr "No pudimos enviar tu solicitud. Inténtalo de nuevo en un momento."
|
||||
msgid "We didn't want to patch over the problem. We wanted to build something institutions could rely on at scale, and that meant starting from a foundation solid enough to support the full complexity of what we had in mind."
|
||||
msgstr "No queríamos parchear el problema. Queríamos crear algo en lo que las instituciones pudieran confiar a escala, y eso significaba partir de una base lo suficientemente sólida como para soportar toda la complejidad de lo que teníamos en mente."
|
||||
|
||||
#. js-lingui-id: u7hKUm
|
||||
#: src/sections/PartnerApplication/partner-application-modal-data.ts
|
||||
msgid "we'll be in touch!"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: d5CWI6
|
||||
#: src/app/[locale]/partners/components/PartnerHero.tsx
|
||||
msgid "We're building the #1 Open Source CRM, but we can't do it alone. Join our partner ecosystem and grow with us."
|
||||
|
||||
@@ -1158,11 +1158,6 @@ msgstr "Le classique ne meurt jamais. Il est simplement étendu une fois de plus
|
||||
msgid "Clear filters"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: yz7wBu
|
||||
#: src/sections/PartnerApplication/partner-application-modal-data.ts
|
||||
msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: L/LPQZ
|
||||
#: src/sections/Faq/faq.data.ts
|
||||
msgid "Cloud Pro is $9/user/month (yearly). Organization is $19/user/month and unlocks SSO and row-level permissions for teams that need finer access control."
|
||||
@@ -2143,6 +2138,11 @@ msgstr ""
|
||||
msgid "Good choice!"
|
||||
msgstr "Bon choix !"
|
||||
|
||||
#. js-lingui-id: Zh0acB
|
||||
#: src/sections/PartnerApplication/partner-application-modal-data.ts
|
||||
msgid "Grab 30 minutes so we can get to know your team."
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: +tDN2S
|
||||
#: src/sections/PartnerApplication/wizard/partner-fields.data.ts
|
||||
msgid "Greece 🇬🇷"
|
||||
@@ -2325,6 +2325,11 @@ msgstr "Comment W3villa Technologies a livré W3Grads, une plateforme d’entret
|
||||
msgid "Hungary 🇭🇺"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: T6wyby
|
||||
#: src/sections/PartnerApplication/partner-application-modal-data.ts
|
||||
msgid "I'll book later →"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: RYDa0v
|
||||
#: src/sections/PartnerApplication/wizard/partner-fields.data.ts
|
||||
msgid "Iceland 🇮🇸"
|
||||
@@ -3089,6 +3094,11 @@ msgstr ""
|
||||
msgid "Now a developer can describe what they want to Claude Code and have a working app in an afternoon. A custom object, a scoring workflow, a new view, an integration. The bottleneck isn't building anymore. It's whether your platform lets you."
|
||||
msgstr "Aujourd’hui, un développeur peut décrire ce qu’il veut à Claude Code et avoir une application fonctionnelle dans l’après-midi. Un objet personnalisé, un workflow de scoring, une nouvelle vue, une intégration. Le goulot d’étranglement n’est plus la construction. C’est de savoir si votre plateforme vous le permet."
|
||||
|
||||
#. js-lingui-id: WFvpMn
|
||||
#: src/sections/PartnerApplication/partner-application-modal-data.ts
|
||||
msgid "Now book your intro call."
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: CzeIij
|
||||
#: src/app/[locale]/pricing/plan-table.data.ts
|
||||
msgid "Number of dashboards"
|
||||
@@ -4133,9 +4143,9 @@ msgstr ""
|
||||
msgid "Thailand 🇹🇭"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: pYwj0k
|
||||
#. js-lingui-id: eRjhUK
|
||||
#: src/sections/PartnerApplication/partner-application-modal-data.ts
|
||||
msgid "Thanks,"
|
||||
msgid "Thanks, you're in."
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: FjkPYg
|
||||
@@ -4775,11 +4785,6 @@ msgstr "Nous n'avons pas pu soumettre votre candidature. Veuillez réessayer dan
|
||||
msgid "We didn't want to patch over the problem. We wanted to build something institutions could rely on at scale, and that meant starting from a foundation solid enough to support the full complexity of what we had in mind."
|
||||
msgstr "Nous ne voulions pas simplement masquer le problème. Nous voulions construire quelque chose sur lequel les établissements pourraient s'appuyer à grande échelle, ce qui signifiait partir d'une base suffisamment solide pour supporter toute la complexité de ce que nous avions en tête."
|
||||
|
||||
#. js-lingui-id: u7hKUm
|
||||
#: src/sections/PartnerApplication/partner-application-modal-data.ts
|
||||
msgid "we'll be in touch!"
|
||||
msgstr ""
|
||||
|
||||
#. js-lingui-id: d5CWI6
|
||||
#: src/app/[locale]/partners/components/PartnerHero.tsx
|
||||
msgid "We're building the #1 Open Source CRM, but we can't do it alone. Join our partner ecosystem and grow with us."
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user