feat(partners): require Twenty experience on apply, drop Cal success (#23223)
## Summary - Add a dedicated **Experience** step to the partner apply wizard (milestones, ≥200-char narrative, proof URL) before commercials - Stop collecting `applicationNotes`; rename Expertise chrome away from “experience” - Replace the post-submit Cal.com embed with a review-and-reach-out thank-you so unqualified inbound no longer books intros automatically **Companion PR (app):** #23224 — Partner schema, submit persistence, triage views, Tally CSV mapper (`twenty-partners` v1.4.0). Land the app PR with or before this one. ## Test plan - [ ] Open apply modal: wizard order is identity → profile → expertise → experience → commercials - [ ] Experience step blocks continue without ≥1 milestone, narrative ≥200 chars, and a valid https URL - [ ] Submit creates/updates Partner with the three experience fields (with #23224 deployed) - [ ] Success screen has no Cal embed / book-later CTA - [ ] `npx jest --config=jest.config.mjs partner-application` passes locally (71 tests) --------- Co-authored-by: Abdullah <125115953+mabdullahabaid@users.noreply.github.com>
This commit is contained in:
@@ -2,6 +2,11 @@ const ORIGINAL_FETCH = global.fetch;
|
||||
const ORIGINAL_WEBHOOK_URL = process.env.PARTNER_APPLICATION_WEBHOOK_URL;
|
||||
const ORIGINAL_API_KEY = process.env.PARTNER_APPLICATION_SECRET;
|
||||
|
||||
const VALID_EXPERIENCE_NOTES =
|
||||
'Built a custom Twenty app for a property-management client, modeled leases and ' +
|
||||
'tenants as data models, automated renewal workflows, and shipped a front component ' +
|
||||
'for the broker dashboard with role-based views.';
|
||||
|
||||
const VALID_PAYLOAD = {
|
||||
email: 'a@b.co',
|
||||
name: 'Ada Lovelace',
|
||||
@@ -10,6 +15,9 @@ const VALID_PAYLOAD = {
|
||||
city: 'London',
|
||||
hourlyRate: 150,
|
||||
projectBudgetMin: 5000,
|
||||
twentyExperience: ['WORKFLOWS'],
|
||||
twentyExperienceNotes: VALID_EXPERIENCE_NOTES,
|
||||
twentyExperienceProofLink: 'https://www.loom.com/share/example',
|
||||
};
|
||||
|
||||
const VALID_BODY = JSON.stringify(VALID_PAYLOAD);
|
||||
@@ -128,6 +136,48 @@ describe('POST /api/partner-application', () => {
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 when applicationNotes is present (removed from apply)', async () => {
|
||||
const { POST } = await loadRoute();
|
||||
const response = await POST(
|
||||
buildRequest({
|
||||
body: JSON.stringify({
|
||||
...VALID_PAYLOAD,
|
||||
applicationNotes: 'legacy catch-all notes',
|
||||
}),
|
||||
ip: '203.0.113.17',
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 when experience narrative is too short', async () => {
|
||||
const { POST } = await loadRoute();
|
||||
const response = await POST(
|
||||
buildRequest({
|
||||
body: JSON.stringify({
|
||||
...VALID_PAYLOAD,
|
||||
twentyExperienceNotes: 'Too short for a real implementation.',
|
||||
}),
|
||||
ip: '203.0.113.18',
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 when twentyExperience is empty', async () => {
|
||||
const { POST } = await loadRoute();
|
||||
const response = await POST(
|
||||
buildRequest({
|
||||
body: JSON.stringify({
|
||||
...VALID_PAYLOAD,
|
||||
twentyExperience: [],
|
||||
}),
|
||||
ip: '203.0.113.19',
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBe(400);
|
||||
});
|
||||
|
||||
it('returns 400 when country enum is unknown', async () => {
|
||||
const { POST } = await loadRoute();
|
||||
const response = await POST(
|
||||
@@ -168,6 +218,9 @@ describe('POST /api/partner-application', () => {
|
||||
city: 'London',
|
||||
hourlyRate: 150,
|
||||
projectBudgetMin: 5000,
|
||||
twentyExperience: ['WORKFLOWS'],
|
||||
twentyExperienceNotes: VALID_EXPERIENCE_NOTES,
|
||||
twentyExperienceProofLink: 'https://www.loom.com/share/example',
|
||||
});
|
||||
expect(init.signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
@@ -5,12 +5,9 @@ import { useLingui } from '@lingui/react';
|
||||
import { css } from '@linaria/core';
|
||||
import { styled } from '@linaria/react';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import {
|
||||
buildSchemeContext,
|
||||
DURATION,
|
||||
EASING,
|
||||
fontSize,
|
||||
mediaUp,
|
||||
semanticColor,
|
||||
@@ -40,11 +37,7 @@ const PartnerApplicationWizard = dynamic(
|
||||
{ loading: () => <WizardLoadingFallback />, ssr: false },
|
||||
);
|
||||
|
||||
// The form sits in a narrow panel; the booking screen eases wider for the
|
||||
// calendar. The Modal reads --modal-panel-width, so the change is a smooth
|
||||
// transition rather than the old site's instant jump.
|
||||
const formPanelClass = css`
|
||||
transition: width ${DURATION.md} ${EASING.standard};
|
||||
--modal-panel-width: min(360px, 100%);
|
||||
|
||||
${mediaUp('md')} {
|
||||
@@ -52,11 +45,6 @@ const formPanelClass = css`
|
||||
}
|
||||
`;
|
||||
|
||||
const bookingPanelClass = css`
|
||||
transition: width ${DURATION.md} ${EASING.standard};
|
||||
--modal-panel-width: min(960px, 100%);
|
||||
`;
|
||||
|
||||
// The near-black Modal panel is scheme-agnostic; the wizard reads semantic
|
||||
// colours and the Button keys on [data-scheme], so it runs on a dark scope —
|
||||
// the same context the full-page mount establishes.
|
||||
@@ -72,27 +60,16 @@ export function PartnerApplicationModal({
|
||||
open: boolean;
|
||||
}) {
|
||||
const { i18n } = useLingui();
|
||||
const [hasReachedBooking, setHasReachedBooking] = useState(false);
|
||||
|
||||
// Reset the widened panel after the modal closes, while nothing is on screen,
|
||||
// so a prior session's booking width can't flash on the next open.
|
||||
useEffect(() => {
|
||||
if (!open) setHasReachedBooking(false);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
ariaLabel={i18n._(msg`Partner application`)}
|
||||
className={hasReachedBooking ? bookingPanelClass : formPanelClass}
|
||||
className={formPanelClass}
|
||||
onClose={onClose}
|
||||
open={open}
|
||||
>
|
||||
<WizardScope data-scheme="dark">
|
||||
<PartnerApplicationWizard
|
||||
onSubmitted={() => setHasReachedBooking(true)}
|
||||
onSuccess={onClose}
|
||||
resetSignal={0}
|
||||
/>
|
||||
<PartnerApplicationWizard onSuccess={onClose} resetSignal={0} />
|
||||
</WizardScope>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
+16
-5
@@ -1,6 +1,11 @@
|
||||
import { buildLogicFunctionPayload } from './build-logic-function-payload';
|
||||
import { type PartnerApplicationRequest } from './partner-application-request-schema';
|
||||
|
||||
const validExperienceNotes =
|
||||
'Built a custom Twenty app for a property-management client, modeled leases and ' +
|
||||
'tenants as data models, automated renewal workflows, and shipped a front component ' +
|
||||
'for the broker dashboard with role-based views.';
|
||||
|
||||
const minimalValid: PartnerApplicationRequest = {
|
||||
name: 'Ada Lovelace',
|
||||
email: 'ada@example.com',
|
||||
@@ -9,6 +14,9 @@ const minimalValid: PartnerApplicationRequest = {
|
||||
city: 'London',
|
||||
hourlyRate: 150,
|
||||
projectBudgetMin: 5000,
|
||||
twentyExperience: ['WORKFLOWS'],
|
||||
twentyExperienceNotes: validExperienceNotes,
|
||||
twentyExperienceProofLink: 'https://www.loom.com/share/example',
|
||||
};
|
||||
|
||||
const fullValid: PartnerApplicationRequest = {
|
||||
@@ -19,7 +27,6 @@ const fullValid: PartnerApplicationRequest = {
|
||||
typeOfTeam: 'SOLO',
|
||||
partnerScope: ['ADVISORY', 'SOLUTIONING'],
|
||||
skills: ['React', 'TypeScript'],
|
||||
applicationNotes: 'refs: Acme, Globex',
|
||||
calendarLink: 'https://cal.com/ada',
|
||||
};
|
||||
|
||||
@@ -39,10 +46,14 @@ describe('buildLogicFunctionPayload', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards applicationNotes through to the payload', () => {
|
||||
expect(buildLogicFunctionPayload(fullValid).applicationNotes).toContain(
|
||||
'Acme',
|
||||
it('forwards twenty experience fields to the webhook payload', () => {
|
||||
const payload = buildLogicFunctionPayload(fullValid);
|
||||
expect(payload.twentyExperience).toEqual(['WORKFLOWS']);
|
||||
expect(payload.twentyExperienceNotes).toBe(validExperienceNotes);
|
||||
expect(payload.twentyExperienceProofLink).toBe(
|
||||
'https://www.loom.com/share/example',
|
||||
);
|
||||
expect(payload).not.toHaveProperty('applicationNotes');
|
||||
});
|
||||
|
||||
it('omits keys for undefined optional fields', () => {
|
||||
@@ -53,7 +64,7 @@ describe('buildLogicFunctionPayload', () => {
|
||||
expect(payload).not.toHaveProperty('partnerScope');
|
||||
});
|
||||
|
||||
it('omits empty arrays', () => {
|
||||
it('omits empty arrays for optional multi-selects', () => {
|
||||
const payload = buildLogicFunctionPayload({
|
||||
...minimalValid,
|
||||
languages: [],
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type PartnerCountryValue } from './data/partner-country-options';
|
||||
import { type PartnerLanguageValue } from './data/partner-language-options';
|
||||
import { type PartnerScopeValue } from './data/partner-scope-options';
|
||||
import { type PartnerTeamTypeValue } from './data/partner-team-type-options';
|
||||
import { type PartnerTwentyExperienceValue } from './data/partner-twenty-experience-options';
|
||||
import { type PartnerApplicationRequest } from './partner-application-request-schema';
|
||||
import { splitFullName } from './split-full-name';
|
||||
|
||||
@@ -21,7 +22,9 @@ export type PartnerApplicationLogicFunctionPayload = {
|
||||
typeOfTeam?: PartnerTeamTypeValue;
|
||||
partnerScope?: readonly PartnerScopeValue[];
|
||||
skills?: readonly string[];
|
||||
applicationNotes?: string;
|
||||
twentyExperience: readonly PartnerTwentyExperienceValue[];
|
||||
twentyExperienceNotes: string;
|
||||
twentyExperienceProofLink: string;
|
||||
hourlyRate?: number;
|
||||
projectBudgetMin?: number;
|
||||
calendarLink?: string;
|
||||
@@ -37,6 +40,9 @@ export function buildLogicFunctionPayload(
|
||||
lastName,
|
||||
email: request.email,
|
||||
companyName: request.company,
|
||||
twentyExperience: request.twentyExperience,
|
||||
twentyExperienceNotes: request.twentyExperienceNotes,
|
||||
twentyExperienceProofLink: request.twentyExperienceProofLink,
|
||||
};
|
||||
|
||||
if (request.website !== undefined) payload.domainName = request.website;
|
||||
@@ -50,8 +56,6 @@ export function buildLogicFunctionPayload(
|
||||
payload.partnerScope = request.partnerScope;
|
||||
if (request.skills !== undefined && request.skills.length > 0)
|
||||
payload.skills = request.skills;
|
||||
if (request.applicationNotes !== undefined)
|
||||
payload.applicationNotes = request.applicationNotes;
|
||||
if (request.hourlyRate !== undefined) payload.hourlyRate = request.hourlyRate;
|
||||
if (request.projectBudgetMin !== undefined)
|
||||
payload.projectBudgetMin = request.projectBudgetMin;
|
||||
|
||||
+24
-5
@@ -4,6 +4,11 @@ import {
|
||||
type PartnerApplicationState,
|
||||
} from './partner-application-state';
|
||||
|
||||
const validExperienceNotes =
|
||||
'Built a custom Twenty app for a property-management client, modeled leases and ' +
|
||||
'tenants as data models, automated renewal workflows, and shipped a front component ' +
|
||||
'for the broker dashboard with role-based views.';
|
||||
|
||||
const minimalState: PartnerApplicationState = {
|
||||
...INITIAL_PARTNER_APPLICATION_STATE,
|
||||
name: 'Ada Lovelace',
|
||||
@@ -13,10 +18,13 @@ const minimalState: PartnerApplicationState = {
|
||||
city: 'London',
|
||||
hourlyRate: '150',
|
||||
projectBudgetMin: '5000',
|
||||
twentyExperience: ['WORKFLOWS'],
|
||||
twentyExperienceNotes: validExperienceNotes,
|
||||
twentyExperienceProofLink: 'https://www.loom.com/share/example',
|
||||
};
|
||||
|
||||
describe('buildPartnerApplicationRequestBody', () => {
|
||||
it('keeps only the required fields when optionals are empty', () => {
|
||||
it('includes required fields and experience data', () => {
|
||||
expect(buildPartnerApplicationRequestBody(minimalState)).toEqual({
|
||||
name: 'Ada Lovelace',
|
||||
email: 'ada@example.com',
|
||||
@@ -25,6 +33,9 @@ describe('buildPartnerApplicationRequestBody', () => {
|
||||
city: 'London',
|
||||
hourlyRate: 150,
|
||||
projectBudgetMin: 5000,
|
||||
twentyExperience: ['WORKFLOWS'],
|
||||
twentyExperienceNotes: validExperienceNotes,
|
||||
twentyExperienceProofLink: 'https://www.loom.com/share/example',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,22 +45,27 @@ describe('buildPartnerApplicationRequestBody', () => {
|
||||
name: ' Ada Lovelace ',
|
||||
email: ' ada@example.com ',
|
||||
company: ' Analytical Engines Ltd ',
|
||||
twentyExperienceNotes: ` ${validExperienceNotes} `,
|
||||
twentyExperienceProofLink: ' https://www.loom.com/share/example ',
|
||||
});
|
||||
expect(body.name).toBe('Ada Lovelace');
|
||||
expect(body.email).toBe('ada@example.com');
|
||||
expect(body.company).toBe('Analytical Engines Ltd');
|
||||
expect(body.twentyExperienceNotes).toBe(validExperienceNotes);
|
||||
expect(body.twentyExperienceProofLink).toBe(
|
||||
'https://www.loom.com/share/example',
|
||||
);
|
||||
});
|
||||
|
||||
it('omits optional string fields that are blank or whitespace-only', () => {
|
||||
const body = buildPartnerApplicationRequestBody({
|
||||
...minimalState,
|
||||
linkedin: '',
|
||||
applicationNotes: '',
|
||||
calendarLink: ' ',
|
||||
});
|
||||
expect(body).not.toHaveProperty('linkedin');
|
||||
expect(body).not.toHaveProperty('applicationNotes');
|
||||
expect(body).not.toHaveProperty('calendarLink');
|
||||
expect(body).not.toHaveProperty('applicationNotes');
|
||||
});
|
||||
|
||||
it('trims optional string fields when present', () => {
|
||||
@@ -58,13 +74,11 @@ describe('buildPartnerApplicationRequestBody', () => {
|
||||
website: ' https://analyticalengines.example ',
|
||||
linkedin: ' https://www.linkedin.com/in/ada ',
|
||||
city: ' London ',
|
||||
applicationNotes: ' refs: Acme ',
|
||||
calendarLink: ' https://cal.com/ada ',
|
||||
});
|
||||
expect(body.website).toBe('https://analyticalengines.example');
|
||||
expect(body.linkedin).toBe('https://www.linkedin.com/in/ada');
|
||||
expect(body.city).toBe('London');
|
||||
expect(body.applicationNotes).toBe('refs: Acme');
|
||||
expect(body.calendarLink).toBe('https://cal.com/ada');
|
||||
});
|
||||
|
||||
@@ -83,12 +97,17 @@ describe('buildPartnerApplicationRequestBody', () => {
|
||||
languages: ['ENGLISH', 'FRENCH'],
|
||||
partnerScope: ['ADVISORY', 'SOLUTIONING'],
|
||||
skills: ['React', 'TypeScript'],
|
||||
twentyExperience: ['CUSTOM_APPS', 'FRONT_COMPONENTS'],
|
||||
});
|
||||
expect(filled.country).toBe('UNITED_KINGDOM');
|
||||
expect(filled.typeOfTeam).toBe('SOLO');
|
||||
expect(filled.languages).toEqual(['ENGLISH', 'FRENCH']);
|
||||
expect(filled.partnerScope).toEqual(['ADVISORY', 'SOLUTIONING']);
|
||||
expect(filled.skills).toEqual(['React', 'TypeScript']);
|
||||
expect(filled.twentyExperience).toEqual([
|
||||
'CUSTOM_APPS',
|
||||
'FRONT_COMPONENTS',
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses hourlyRate and projectBudgetMin into non-negative numbers', () => {
|
||||
|
||||
+3
-2
@@ -12,6 +12,9 @@ export function buildPartnerApplicationRequestBody(
|
||||
city: state.city.trim(),
|
||||
hourlyRate: Number.parseFloat(state.hourlyRate),
|
||||
projectBudgetMin: Number.parseFloat(state.projectBudgetMin),
|
||||
twentyExperience: state.twentyExperience,
|
||||
twentyExperienceNotes: state.twentyExperienceNotes.trim(),
|
||||
twentyExperienceProofLink: state.twentyExperienceProofLink.trim(),
|
||||
};
|
||||
|
||||
if (state.linkedin.trim()) body.linkedin = state.linkedin.trim();
|
||||
@@ -20,8 +23,6 @@ export function buildPartnerApplicationRequestBody(
|
||||
if (state.typeOfTeam !== '') body.typeOfTeam = state.typeOfTeam;
|
||||
if (state.partnerScope.length > 0) body.partnerScope = state.partnerScope;
|
||||
if (state.skills.length > 0) body.skills = state.skills;
|
||||
if (state.applicationNotes.trim())
|
||||
body.applicationNotes = state.applicationNotes.trim();
|
||||
if (state.calendarLink.trim()) body.calendarLink = state.calendarLink.trim();
|
||||
|
||||
return body;
|
||||
|
||||
+2
-1
@@ -2,8 +2,9 @@ export type PartnerApplicationStepId =
|
||||
| 'identity'
|
||||
| 'profile'
|
||||
| 'expertise'
|
||||
| 'experience'
|
||||
| 'commercials';
|
||||
|
||||
// Order is the wizard's step order: the reducer indexes into this by stepIndex.
|
||||
export const PARTNER_APPLICATION_STEP_IDS: readonly PartnerApplicationStepId[] =
|
||||
['identity', 'profile', 'expertise', 'commercials'];
|
||||
['identity', 'profile', 'expertise', 'experience', 'commercials'];
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
|
||||
import { defineFieldOptions } from './define-field-options';
|
||||
|
||||
export const PARTNER_TWENTY_EXPERIENCE_OPTIONS = defineFieldOptions([
|
||||
{ value: 'CUSTOM_APPS', label: msg`Custom apps` },
|
||||
{ value: 'DATA_MODELS', label: msg`Data models` },
|
||||
{ value: 'WORKFLOWS', label: msg`Workflows` },
|
||||
{ value: 'FRONT_COMPONENTS', label: msg`Front components` },
|
||||
]);
|
||||
|
||||
export type PartnerTwentyExperienceValue =
|
||||
(typeof PARTNER_TWENTY_EXPERIENCE_OPTIONS)[number]['value'];
|
||||
+1
@@ -0,0 +1 @@
|
||||
export const TWENTY_EXPERIENCE_NOTES_MIN_LENGTH = 200;
|
||||
@@ -1,8 +0,0 @@
|
||||
// The Cal.com path (no host) and namespace the success screen books the partner
|
||||
// intro call onto. The namespace scopes the embed's ui() config to this booking
|
||||
// only. A fixed link — this is the brand site, so there is no self-hoster
|
||||
// override (the old site's env knob does not apply here).
|
||||
export const PARTNER_INTRO_CAL: { link: string; namespace: string } = {
|
||||
link: 'rashad-twenty/partner-intro',
|
||||
namespace: 'partner-intro',
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
import { msg } from '@lingui/core/macro';
|
||||
|
||||
import { TWENTY_EXPERIENCE_NOTES_MIN_LENGTH } from './data/twenty-experience-notes-min-length';
|
||||
|
||||
export const PARTNER_APPLICATION_COPY = {
|
||||
title: msg`Apply to build\n*the future of CRM*`,
|
||||
subtitle: msg`Join our ecosystem and help businesses take control of their customer data with open-source primitives.`,
|
||||
@@ -7,9 +9,9 @@ export const PARTNER_APPLICATION_COPY = {
|
||||
next: msg`Next →`,
|
||||
submit: msg`Submit application`,
|
||||
submitInFlight: msg`Submitting…`,
|
||||
successTitle: msg`Thanks, you're in.\n*Now book your intro call.*`,
|
||||
bookIntroSubtitle: msg`Grab 30 minutes so we can get to know your team.`,
|
||||
bookLater: msg`I'll book later →`,
|
||||
successTitle: msg`Thanks for applying.`,
|
||||
successSubtitle: msg`Our team reviews every application and will reach out if there's a fit.`,
|
||||
successDone: msg`Done`,
|
||||
stepProgressLabel: (current: number, total: number) =>
|
||||
msg`Step ${current} of ${total}`,
|
||||
validation: {
|
||||
@@ -17,6 +19,7 @@ export const PARTNER_APPLICATION_COPY = {
|
||||
invalidEmail: msg`Enter a valid email address.`,
|
||||
invalidUrl: msg`Enter a valid URL (starting with http:// or https://).`,
|
||||
invalidAmount: msg`Enter a valid amount using numbers only.`,
|
||||
notesTooShort: msg`Please write at least ${TWENTY_EXPERIENCE_NOTES_MIN_LENGTH} characters about the implementation.`,
|
||||
submitFailed: msg`We could not submit your application. Please try again in a moment.`,
|
||||
},
|
||||
removeSkill: (skill: string) => msg`Remove ${skill}`,
|
||||
@@ -39,8 +42,13 @@ export const PARTNER_APPLICATION_COPY = {
|
||||
skills: msg`Technical skills`,
|
||||
skillsHint: msg`Tools, technologies and industries you specialize in. Press Enter or comma to add.`,
|
||||
skillsPlaceholder: msg`e.g. n8n, Shopify, Real estate…`,
|
||||
applicationNotes: msg`Anything else we should know?`,
|
||||
applicationNotesPlaceholder: msg`Workspace URL, customer references, relevant links…`,
|
||||
twentyExperience: msg`What you've built in Twenty *`,
|
||||
twentyExperienceHint: msg`Select every area you used in a real project — not what you offer, what you actually did.`,
|
||||
twentyExperienceNotes: msg`Tell us about the implementation *`,
|
||||
twentyExperienceNotesHint: msg`Describe a real Twenty project (customer or internal): who it was for, what you built, and how you used the areas you selected. Min. ${TWENTY_EXPERIENCE_NOTES_MIN_LENGTH} characters.`,
|
||||
twentyExperienceNotesPlaceholder: msg`Who it was for, what you built, which of the four areas you used…`,
|
||||
twentyExperienceProofLink: msg`Proof URL *`,
|
||||
twentyExperienceProofLinkHint: msg`Link that shows the work (Loom, GitHub, Notion, Drive, public write-up…). No public workspace? Share a short Loom.`,
|
||||
hourlyRate: msg`Hourly rate *`,
|
||||
hourlyRatePlaceholder: msg`150`,
|
||||
projectBudgetMin: msg`Minimum project budget *`,
|
||||
@@ -50,7 +58,8 @@ export const PARTNER_APPLICATION_COPY = {
|
||||
stepHeaders: {
|
||||
identity: msg`Identity`,
|
||||
profile: msg`Profile`,
|
||||
expertise: msg`Expertise & experience`,
|
||||
expertise: msg`Expertise`,
|
||||
experience: msg`Experience`,
|
||||
commercials: msg`Commercials`,
|
||||
},
|
||||
};
|
||||
|
||||
+81
-2
@@ -1,3 +1,4 @@
|
||||
import { PARTNER_APPLICATION_STEP_IDS } from './data/partner-application-step-ids';
|
||||
import { partnerApplicationReducer } from './partner-application-reducer';
|
||||
import {
|
||||
INITIAL_PARTNER_APPLICATION_STATE,
|
||||
@@ -11,11 +12,27 @@ const baseValidIdentity: Partial<PartnerApplicationState> = {
|
||||
website: 'https://analyticalengines.example',
|
||||
};
|
||||
|
||||
const validExperienceNotes =
|
||||
'Built a custom Twenty app for a property-management client, modeled leases and ' +
|
||||
'tenants as data models, automated renewal workflows, and shipped a front component ' +
|
||||
'for the broker dashboard with role-based views.';
|
||||
|
||||
describe('partnerApplicationReducer', () => {
|
||||
it('orders steps identity → profile → expertise → experience → commercials', () => {
|
||||
expect(PARTNER_APPLICATION_STEP_IDS).toEqual([
|
||||
'identity',
|
||||
'profile',
|
||||
'expertise',
|
||||
'experience',
|
||||
'commercials',
|
||||
]);
|
||||
});
|
||||
|
||||
it('starts at stepIndex 0 with empty fields', () => {
|
||||
expect(INITIAL_PARTNER_APPLICATION_STATE.stepIndex).toBe(0);
|
||||
expect(INITIAL_PARTNER_APPLICATION_STATE.name).toBe('');
|
||||
expect(INITIAL_PARTNER_APPLICATION_STATE.partnerScope).toEqual([]);
|
||||
expect(INITIAL_PARTNER_APPLICATION_STATE.twentyExperience).toEqual([]);
|
||||
});
|
||||
|
||||
it('SET_FIELD updates the field and clears any prior error for it', () => {
|
||||
@@ -45,6 +62,19 @@ describe('partnerApplicationReducer', () => {
|
||||
expect(removed.partnerScope).toEqual([]);
|
||||
});
|
||||
|
||||
it('TOGGLE_EXPERIENCE adds and then removes a milestone', () => {
|
||||
const added = partnerApplicationReducer(INITIAL_PARTNER_APPLICATION_STATE, {
|
||||
type: 'TOGGLE_EXPERIENCE',
|
||||
value: 'CUSTOM_APPS',
|
||||
});
|
||||
expect(added.twentyExperience).toEqual(['CUSTOM_APPS']);
|
||||
const removed = partnerApplicationReducer(added, {
|
||||
type: 'TOGGLE_EXPERIENCE',
|
||||
value: 'CUSTOM_APPS',
|
||||
});
|
||||
expect(removed.twentyExperience).toEqual([]);
|
||||
});
|
||||
|
||||
it('GO_NEXT on Identity with missing required fields fills errors and stays', () => {
|
||||
const next = partnerApplicationReducer(INITIAL_PARTNER_APPLICATION_STATE, {
|
||||
type: 'GO_NEXT',
|
||||
@@ -115,10 +145,59 @@ describe('partnerApplicationReducer', () => {
|
||||
expect(ok.fieldErrors).toEqual({});
|
||||
});
|
||||
|
||||
it('GO_NEXT on Expertise advances to Experience when partnerScope is set', () => {
|
||||
const onExpertise: PartnerApplicationState = {
|
||||
...INITIAL_PARTNER_APPLICATION_STATE,
|
||||
stepIndex: 2,
|
||||
partnerScope: ['ADVISORY'],
|
||||
};
|
||||
const next = partnerApplicationReducer(onExpertise, { type: 'GO_NEXT' });
|
||||
expect(next.stepIndex).toBe(3);
|
||||
expect(next.fieldErrors).toEqual({});
|
||||
});
|
||||
|
||||
it('GO_NEXT on Experience gates on milestones, narrative length, and proof URL', () => {
|
||||
const onExperience: PartnerApplicationState = {
|
||||
...INITIAL_PARTNER_APPLICATION_STATE,
|
||||
stepIndex: 3,
|
||||
};
|
||||
const blocked = partnerApplicationReducer(onExperience, {
|
||||
type: 'GO_NEXT',
|
||||
});
|
||||
expect(blocked.stepIndex).toBe(3);
|
||||
expect(blocked.fieldErrors.twentyExperience).toBe('required');
|
||||
expect(blocked.fieldErrors.twentyExperienceNotes).toBe('required');
|
||||
expect(blocked.fieldErrors.twentyExperienceProofLink).toBe('required');
|
||||
|
||||
const shortNotes = partnerApplicationReducer(
|
||||
{
|
||||
...onExperience,
|
||||
twentyExperience: ['WORKFLOWS'],
|
||||
twentyExperienceNotes: 'Too short.',
|
||||
twentyExperienceProofLink: 'https://www.loom.com/share/example',
|
||||
},
|
||||
{ type: 'GO_NEXT' },
|
||||
);
|
||||
expect(shortNotes.stepIndex).toBe(3);
|
||||
expect(shortNotes.fieldErrors.twentyExperienceNotes).toBe('too_short');
|
||||
|
||||
const ok = partnerApplicationReducer(
|
||||
{
|
||||
...onExperience,
|
||||
twentyExperience: ['WORKFLOWS'],
|
||||
twentyExperienceNotes: validExperienceNotes,
|
||||
twentyExperienceProofLink: 'https://www.loom.com/share/example',
|
||||
},
|
||||
{ type: 'GO_NEXT' },
|
||||
);
|
||||
expect(ok.stepIndex).toBe(4);
|
||||
expect(ok.fieldErrors).toEqual({});
|
||||
});
|
||||
|
||||
it('GO_NEXT on Commercials gates on hourly rate and minimum budget', () => {
|
||||
const onCommercials: PartnerApplicationState = {
|
||||
...INITIAL_PARTNER_APPLICATION_STATE,
|
||||
stepIndex: 3,
|
||||
stepIndex: 4,
|
||||
};
|
||||
const blocked = partnerApplicationReducer(onCommercials, {
|
||||
type: 'GO_NEXT',
|
||||
@@ -157,7 +236,7 @@ describe('partnerApplicationReducer', () => {
|
||||
const dirty: PartnerApplicationState = {
|
||||
...INITIAL_PARTNER_APPLICATION_STATE,
|
||||
name: 'x',
|
||||
stepIndex: 3,
|
||||
stepIndex: 4,
|
||||
isSubmitting: true,
|
||||
isSubmitted: true,
|
||||
};
|
||||
|
||||
@@ -42,6 +42,16 @@ export function partnerApplicationReducer(
|
||||
: [...state.languages, action.value];
|
||||
return { ...state, languages: next };
|
||||
}
|
||||
case 'TOGGLE_EXPERIENCE': {
|
||||
const next = state.twentyExperience.includes(action.value)
|
||||
? state.twentyExperience.filter((value) => value !== action.value)
|
||||
: [...state.twentyExperience, action.value];
|
||||
return {
|
||||
...state,
|
||||
twentyExperience: next,
|
||||
fieldErrors: dropError(state.fieldErrors, 'twentyExperience'),
|
||||
};
|
||||
}
|
||||
case 'SET_SKILLS':
|
||||
return { ...state, skills: action.value };
|
||||
case 'GO_NEXT': {
|
||||
@@ -75,7 +85,10 @@ export function partnerApplicationReducer(
|
||||
};
|
||||
case 'RESET':
|
||||
return INITIAL_PARTNER_APPLICATION_STATE;
|
||||
default:
|
||||
default: {
|
||||
const _exhaustive: never = action;
|
||||
void _exhaustive;
|
||||
return state;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+62
-2
@@ -1,5 +1,10 @@
|
||||
import { partnerApplicationRequestSchema } from './partner-application-request-schema';
|
||||
|
||||
const validExperienceNotes =
|
||||
'Built a custom Twenty app for a property-management client, modeled leases and ' +
|
||||
'tenants as data models, automated renewal workflows, and shipped a front component ' +
|
||||
'for the broker dashboard with role-based views.';
|
||||
|
||||
const minimalValid = {
|
||||
name: 'Ada Lovelace',
|
||||
email: 'ada@example.com',
|
||||
@@ -8,6 +13,9 @@ const minimalValid = {
|
||||
city: 'London',
|
||||
hourlyRate: 150,
|
||||
projectBudgetMin: 5000,
|
||||
twentyExperience: ['WORKFLOWS'],
|
||||
twentyExperienceNotes: validExperienceNotes,
|
||||
twentyExperienceProofLink: 'https://www.loom.com/share/example',
|
||||
};
|
||||
|
||||
const fullValid = {
|
||||
@@ -18,7 +26,6 @@ const fullValid = {
|
||||
typeOfTeam: 'SOLO',
|
||||
partnerScope: ['ADVISORY', 'SOLUTIONING'],
|
||||
skills: ['React', 'TypeScript'],
|
||||
applicationNotes: 'Workspace https://app.twenty.com/ws/ada · refs: Acme',
|
||||
calendarLink: 'https://cal.com/ada',
|
||||
};
|
||||
|
||||
@@ -62,6 +69,15 @@ describe('partnerApplicationRequestSchema', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects legacy applicationNotes', () => {
|
||||
expect(
|
||||
partnerApplicationRequestSchema.safeParse({
|
||||
...minimalValid,
|
||||
applicationNotes: 'Workspace URL and refs',
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an invalid email', () => {
|
||||
expect(
|
||||
partnerApplicationRequestSchema.safeParse({
|
||||
@@ -79,7 +95,15 @@ describe('partnerApplicationRequestSchema', () => {
|
||||
});
|
||||
|
||||
it('rejects when a newly-required field is missing', () => {
|
||||
for (const field of ['website', 'city', 'hourlyRate', 'projectBudgetMin']) {
|
||||
for (const field of [
|
||||
'website',
|
||||
'city',
|
||||
'hourlyRate',
|
||||
'projectBudgetMin',
|
||||
'twentyExperience',
|
||||
'twentyExperienceNotes',
|
||||
'twentyExperienceProofLink',
|
||||
]) {
|
||||
const withoutField = Object.fromEntries(
|
||||
Object.entries(minimalValid).filter(([key]) => key !== field),
|
||||
);
|
||||
@@ -89,6 +113,42 @@ describe('partnerApplicationRequestSchema', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects empty twentyExperience', () => {
|
||||
expect(
|
||||
partnerApplicationRequestSchema.safeParse({
|
||||
...minimalValid,
|
||||
twentyExperience: [],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an unknown twentyExperience milestone', () => {
|
||||
expect(
|
||||
partnerApplicationRequestSchema.safeParse({
|
||||
...minimalValid,
|
||||
twentyExperience: ['INTEGRATIONS'],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects twentyExperienceNotes shorter than 200 characters', () => {
|
||||
expect(
|
||||
partnerApplicationRequestSchema.safeParse({
|
||||
...minimalValid,
|
||||
twentyExperienceNotes: 'Too short for a real implementation narrative.',
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an invalid twentyExperienceProofLink', () => {
|
||||
expect(
|
||||
partnerApplicationRequestSchema.safeParse({
|
||||
...minimalValid,
|
||||
twentyExperienceProofLink: 'not-a-url',
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a negative hourly rate', () => {
|
||||
expect(
|
||||
partnerApplicationRequestSchema.safeParse({
|
||||
|
||||
+15
-2
@@ -4,6 +4,8 @@ import { PARTNER_COUNTRY_OPTIONS } from './data/partner-country-options';
|
||||
import { PARTNER_LANGUAGE_OPTIONS } from './data/partner-language-options';
|
||||
import { PARTNER_SCOPE_OPTIONS } from './data/partner-scope-options';
|
||||
import { PARTNER_TEAM_TYPE_OPTIONS } from './data/partner-team-type-options';
|
||||
import { PARTNER_TWENTY_EXPERIENCE_OPTIONS } from './data/partner-twenty-experience-options';
|
||||
import { TWENTY_EXPERIENCE_NOTES_MIN_LENGTH } from './data/twenty-experience-notes-min-length';
|
||||
import { emailFieldSchema } from './email-field-schema';
|
||||
import { httpUrlFieldSchema } from './http-url-field-schema';
|
||||
|
||||
@@ -11,8 +13,10 @@ const countryValues = PARTNER_COUNTRY_OPTIONS.map((option) => option.value);
|
||||
const languageValues = PARTNER_LANGUAGE_OPTIONS.map((option) => option.value);
|
||||
const scopeValues = PARTNER_SCOPE_OPTIONS.map((option) => option.value);
|
||||
const teamTypeValues = PARTNER_TEAM_TYPE_OPTIONS.map((option) => option.value);
|
||||
const twentyExperienceValues = PARTNER_TWENTY_EXPERIENCE_OPTIONS.map(
|
||||
(option) => option.value,
|
||||
);
|
||||
|
||||
const optionalNonEmptyString = z.string().trim().min(1).optional();
|
||||
const optionalUrl = httpUrlFieldSchema.optional();
|
||||
|
||||
export const partnerApplicationRequestSchema = z.strictObject({
|
||||
@@ -27,7 +31,16 @@ export const partnerApplicationRequestSchema = z.strictObject({
|
||||
typeOfTeam: z.enum(teamTypeValues).optional(),
|
||||
partnerScope: z.array(z.enum(scopeValues)).optional(),
|
||||
skills: z.array(z.string().trim().min(1)).optional(),
|
||||
applicationNotes: optionalNonEmptyString,
|
||||
twentyExperience: z
|
||||
.array(z.enum(twentyExperienceValues))
|
||||
.min(1, { error: 'Select at least one Twenty experience area.' }),
|
||||
twentyExperienceNotes: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(TWENTY_EXPERIENCE_NOTES_MIN_LENGTH, {
|
||||
error: `Describe the implementation in at least ${TWENTY_EXPERIENCE_NOTES_MIN_LENGTH} characters.`,
|
||||
}),
|
||||
twentyExperienceProofLink: httpUrlFieldSchema,
|
||||
hourlyRate: z.number({ error: 'Hourly rate is required.' }).nonnegative(),
|
||||
projectBudgetMin: z
|
||||
.number({ error: 'Minimum project budget is required.' })
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type PartnerCountryValue } from './data/partner-country-options';
|
||||
import { type PartnerLanguageValue } from './data/partner-language-options';
|
||||
import { type PartnerScopeValue } from './data/partner-scope-options';
|
||||
import { type PartnerTeamTypeValue } from './data/partner-team-type-options';
|
||||
import { type PartnerTwentyExperienceValue } from './data/partner-twenty-experience-options';
|
||||
|
||||
export type CountryFieldValue = PartnerCountryValue | '';
|
||||
|
||||
@@ -20,11 +21,15 @@ export type PartnerApplicationState = {
|
||||
country: CountryFieldValue;
|
||||
languages: PartnerLanguageValue[];
|
||||
|
||||
// Expertise & experience
|
||||
// Expertise
|
||||
typeOfTeam: PartnerTeamTypeValue | '';
|
||||
partnerScope: PartnerScopeValue[];
|
||||
skills: string[];
|
||||
applicationNotes: string;
|
||||
|
||||
// Experience (what they've built in Twenty)
|
||||
twentyExperience: PartnerTwentyExperienceValue[];
|
||||
twentyExperienceNotes: string;
|
||||
twentyExperienceProofLink: string;
|
||||
|
||||
// Commercials
|
||||
hourlyRate: string;
|
||||
@@ -39,7 +44,8 @@ export type PartnerApplicationState = {
|
||||
};
|
||||
|
||||
// The scalar (string-valued) fields a single SET_FIELD action can target;
|
||||
// multi-value fields (languages, partnerScope, skills) have their own actions.
|
||||
// multi-value fields (languages, partnerScope, skills, twentyExperience) have
|
||||
// their own actions.
|
||||
export type ScalarFieldName =
|
||||
| 'name'
|
||||
| 'email'
|
||||
@@ -49,7 +55,8 @@ export type ScalarFieldName =
|
||||
| 'city'
|
||||
| 'country'
|
||||
| 'typeOfTeam'
|
||||
| 'applicationNotes'
|
||||
| 'twentyExperienceNotes'
|
||||
| 'twentyExperienceProofLink'
|
||||
| 'hourlyRate'
|
||||
| 'projectBudgetMin'
|
||||
| 'calendarLink';
|
||||
@@ -58,6 +65,7 @@ export type PartnerApplicationAction =
|
||||
| { type: 'SET_FIELD'; field: ScalarFieldName; value: string }
|
||||
| { type: 'TOGGLE_SCOPE'; value: PartnerScopeValue }
|
||||
| { type: 'TOGGLE_LANGUAGE'; value: PartnerLanguageValue }
|
||||
| { type: 'TOGGLE_EXPERIENCE'; value: PartnerTwentyExperienceValue }
|
||||
| { type: 'SET_SKILLS'; value: string[] }
|
||||
| { type: 'GO_NEXT' }
|
||||
| { type: 'GO_BACK' }
|
||||
@@ -79,7 +87,9 @@ export const INITIAL_PARTNER_APPLICATION_STATE: PartnerApplicationState = {
|
||||
typeOfTeam: '',
|
||||
partnerScope: [],
|
||||
skills: [],
|
||||
applicationNotes: '',
|
||||
twentyExperience: [],
|
||||
twentyExperienceNotes: '',
|
||||
twentyExperienceProofLink: '',
|
||||
hourlyRate: '',
|
||||
projectBudgetMin: '',
|
||||
calendarLink: '',
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { buildPartnerIntroPrefill } from './partner-intro-prefill';
|
||||
|
||||
describe('buildPartnerIntroPrefill', () => {
|
||||
it('maps name and email and folds company into notes', () => {
|
||||
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 inputs and omits blank ones', () => {
|
||||
expect(
|
||||
buildPartnerIntroPrefill({ name: ' Ada ', email: '', company: ' ' }),
|
||||
).toEqual({ name: 'Ada' });
|
||||
});
|
||||
|
||||
it('returns an empty prefill for no input', () => {
|
||||
expect(buildPartnerIntroPrefill({})).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
// Cal.com prefill keys for the intro call. `notes` carries the applicant's
|
||||
// company so the booking arrives pre-qualified. Empty inputs are omitted.
|
||||
export type PartnerIntroPrefill = {
|
||||
name?: string;
|
||||
email?: string;
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
export function buildPartnerIntroPrefill(input: {
|
||||
name?: string;
|
||||
email?: string;
|
||||
company?: string;
|
||||
}): 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;
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useCallback, useReducer } from 'react';
|
||||
|
||||
import { type PartnerLanguageValue } from './data/partner-language-options';
|
||||
import { type PartnerScopeValue } from './data/partner-scope-options';
|
||||
import { type PartnerTwentyExperienceValue } from './data/partner-twenty-experience-options';
|
||||
import { partnerApplicationReducer } from './partner-application-reducer';
|
||||
import {
|
||||
INITIAL_PARTNER_APPLICATION_STATE,
|
||||
@@ -32,6 +33,11 @@ export function usePartnerApplicationState() {
|
||||
dispatch({ type: 'TOGGLE_LANGUAGE', value }),
|
||||
[],
|
||||
);
|
||||
const toggleExperience = useCallback(
|
||||
(value: PartnerTwentyExperienceValue) =>
|
||||
dispatch({ type: 'TOGGLE_EXPERIENCE', value }),
|
||||
[],
|
||||
);
|
||||
const setSkills = useCallback(
|
||||
(value: string[]) => dispatch({ type: 'SET_SKILLS', value }),
|
||||
[],
|
||||
@@ -57,6 +63,7 @@ export function usePartnerApplicationState() {
|
||||
setField,
|
||||
toggleScope,
|
||||
toggleLanguage,
|
||||
toggleExperience,
|
||||
setSkills,
|
||||
goNext,
|
||||
goBack,
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
import { INITIAL_PARTNER_APPLICATION_STATE } from './partner-application-state';
|
||||
import { validatePartnerApplicationStep } from './validate-partner-application-step';
|
||||
|
||||
const validExperienceNotes =
|
||||
'Built a custom Twenty app for a property-management client, modeled leases and ' +
|
||||
'tenants as data models, automated renewal workflows, and shipped a front component ' +
|
||||
'for the broker dashboard with role-based views.';
|
||||
|
||||
describe('validatePartnerApplicationStep', () => {
|
||||
it('requires experience milestones, narrative, and proof URL on Experience', () => {
|
||||
const errors = validatePartnerApplicationStep({
|
||||
...INITIAL_PARTNER_APPLICATION_STATE,
|
||||
stepIndex: 3,
|
||||
});
|
||||
expect(errors.twentyExperience).toBe('required');
|
||||
expect(errors.twentyExperienceNotes).toBe('required');
|
||||
expect(errors.twentyExperienceProofLink).toBe('required');
|
||||
});
|
||||
|
||||
it('rejects a narrative under 200 characters on Experience', () => {
|
||||
const errors = validatePartnerApplicationStep({
|
||||
...INITIAL_PARTNER_APPLICATION_STATE,
|
||||
stepIndex: 3,
|
||||
twentyExperience: ['WORKFLOWS'],
|
||||
twentyExperienceNotes: 'Too short for a real implementation narrative.',
|
||||
twentyExperienceProofLink: 'https://www.loom.com/share/example',
|
||||
});
|
||||
expect(errors.twentyExperienceNotes).toBe('too_short');
|
||||
});
|
||||
|
||||
it('rejects an invalid proof URL on Experience', () => {
|
||||
const errors = validatePartnerApplicationStep({
|
||||
...INITIAL_PARTNER_APPLICATION_STATE,
|
||||
stepIndex: 3,
|
||||
twentyExperience: ['CUSTOM_APPS'],
|
||||
twentyExperienceNotes: validExperienceNotes,
|
||||
twentyExperienceProofLink: 'not-a-url',
|
||||
});
|
||||
expect(errors.twentyExperienceProofLink).toBe('invalid_url');
|
||||
});
|
||||
|
||||
it('accepts a complete Experience step', () => {
|
||||
const errors = validatePartnerApplicationStep({
|
||||
...INITIAL_PARTNER_APPLICATION_STATE,
|
||||
stepIndex: 3,
|
||||
twentyExperience: ['CUSTOM_APPS', 'DATA_MODELS'],
|
||||
twentyExperienceNotes: validExperienceNotes,
|
||||
twentyExperienceProofLink: 'https://www.loom.com/share/example',
|
||||
});
|
||||
expect(errors).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
PARTNER_APPLICATION_STEP_IDS,
|
||||
type PartnerApplicationStepId,
|
||||
} from './data/partner-application-step-ids';
|
||||
import { TWENTY_EXPERIENCE_NOTES_MIN_LENGTH } from './data/twenty-experience-notes-min-length';
|
||||
import { emailFieldSchema } from './email-field-schema';
|
||||
import { httpUrlFieldSchema } from './http-url-field-schema';
|
||||
import { nonNegativeAmountFieldSchema } from './non-negative-amount-field-schema';
|
||||
@@ -14,6 +15,11 @@ const STEP_REQUIRED_FIELDS: Record<
|
||||
identity: ['name', 'email', 'company', 'website'],
|
||||
profile: ['country', 'typeOfTeam', 'city'],
|
||||
expertise: ['partnerScope'],
|
||||
experience: [
|
||||
'twentyExperience',
|
||||
'twentyExperienceNotes',
|
||||
'twentyExperienceProofLink',
|
||||
],
|
||||
commercials: ['hourlyRate', 'projectBudgetMin'],
|
||||
};
|
||||
|
||||
@@ -29,6 +35,7 @@ type FieldFormatCheck = {
|
||||
| 'website'
|
||||
| 'linkedin'
|
||||
| 'calendarLink'
|
||||
| 'twentyExperienceProofLink'
|
||||
| 'hourlyRate'
|
||||
| 'projectBudgetMin';
|
||||
schema:
|
||||
@@ -48,6 +55,13 @@ const STEP_FORMAT_CHECKS: Partial<
|
||||
profile: [
|
||||
{ field: 'linkedin', schema: httpUrlFieldSchema, errorCode: 'invalid_url' },
|
||||
],
|
||||
experience: [
|
||||
{
|
||||
field: 'twentyExperienceProofLink',
|
||||
schema: httpUrlFieldSchema,
|
||||
errorCode: 'invalid_url',
|
||||
},
|
||||
],
|
||||
commercials: [
|
||||
{
|
||||
field: 'hourlyRate',
|
||||
@@ -85,6 +99,15 @@ export function validatePartnerApplicationStep(
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
stepId === 'experience' &&
|
||||
errors.twentyExperienceNotes === undefined &&
|
||||
state.twentyExperienceNotes.trim().length <
|
||||
TWENTY_EXPERIENCE_NOTES_MIN_LENGTH
|
||||
) {
|
||||
errors.twentyExperienceNotes = 'too_short';
|
||||
}
|
||||
|
||||
for (const check of STEP_FORMAT_CHECKS[stepId] ?? []) {
|
||||
const value = state[check.field];
|
||||
if (value && !check.schema.safeParse(value).success) {
|
||||
|
||||
+11
-46
@@ -3,20 +3,10 @@
|
||||
import { useLingui } from '@lingui/react';
|
||||
import { styled } from '@linaria/react';
|
||||
|
||||
import { CalEmbed } from '@/platform/cal/CalEmbed';
|
||||
import {
|
||||
fontFamily,
|
||||
fontSize,
|
||||
FONT_WEIGHT,
|
||||
radius,
|
||||
semanticColor,
|
||||
spacing,
|
||||
} from '@/tokens';
|
||||
import { Body, Heading } from '@/ui';
|
||||
import { spacing } from '@/tokens';
|
||||
import { Body, Button, Heading } from '@/ui';
|
||||
|
||||
import { PARTNER_INTRO_CAL } from '../partner-application-config';
|
||||
import { PARTNER_APPLICATION_COPY } from '../partner-application-copy';
|
||||
import { buildPartnerIntroPrefill } from '../partner-intro-prefill';
|
||||
|
||||
const SuccessView = styled.div`
|
||||
display: flex;
|
||||
@@ -28,38 +18,16 @@ const SuccessView = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const EmbedFrame = styled.div`
|
||||
border: 1px solid ${semanticColor.line};
|
||||
border-radius: ${radius(2)};
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const BookLater = styled.button`
|
||||
const Actions = styled.div`
|
||||
align-self: flex-end;
|
||||
background: none;
|
||||
border: none;
|
||||
color: ${semanticColor.inkMuted};
|
||||
cursor: pointer;
|
||||
font-family: ${fontFamily('mono')};
|
||||
font-size: ${fontSize(3)};
|
||||
font-weight: ${FONT_WEIGHT.medium};
|
||||
padding: 0;
|
||||
text-transform: uppercase;
|
||||
`;
|
||||
|
||||
export function PartnerApplicationSuccess({
|
||||
company,
|
||||
email,
|
||||
name,
|
||||
onDismiss,
|
||||
}: {
|
||||
company: string;
|
||||
email: string;
|
||||
name: string;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const { i18n } = useLingui();
|
||||
const prefill = buildPartnerIntroPrefill({ company, email, name });
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -68,19 +36,16 @@ export function PartnerApplicationSuccess({
|
||||
</Heading>
|
||||
<SuccessView>
|
||||
<Body muted size="md">
|
||||
{i18n._(PARTNER_APPLICATION_COPY.bookIntroSubtitle)}
|
||||
{i18n._(PARTNER_APPLICATION_COPY.successSubtitle)}
|
||||
</Body>
|
||||
<EmbedFrame>
|
||||
<CalEmbed
|
||||
calLink={PARTNER_INTRO_CAL.link}
|
||||
layout="month_view"
|
||||
namespace={PARTNER_INTRO_CAL.namespace}
|
||||
prefill={prefill}
|
||||
<Actions>
|
||||
<Button
|
||||
label={i18n._(PARTNER_APPLICATION_COPY.successDone)}
|
||||
onClick={onDismiss}
|
||||
type="button"
|
||||
variant="filled"
|
||||
/>
|
||||
</EmbedFrame>
|
||||
<BookLater onClick={onDismiss} type="button">
|
||||
{i18n._(PARTNER_APPLICATION_COPY.bookLater)}
|
||||
</BookLater>
|
||||
</Actions>
|
||||
</SuccessView>
|
||||
</>
|
||||
);
|
||||
|
||||
+32
-21
@@ -25,6 +25,7 @@ import {
|
||||
import { validatePartnerApplicationStep } from '../validate-partner-application-step';
|
||||
import { PartnerApplicationSuccess } from './PartnerApplicationSuccess';
|
||||
import { CommercialsStep } from './steps/CommercialsStep';
|
||||
import { ExperienceStep } from './steps/ExperienceStep';
|
||||
import { ExpertiseStep } from './steps/ExpertiseStep';
|
||||
import { IdentityStep } from './steps/IdentityStep';
|
||||
import { ProfileStep } from './steps/ProfileStep';
|
||||
@@ -122,24 +123,47 @@ function StepRenderer({
|
||||
}: {
|
||||
controller: PartnerApplicationController;
|
||||
}) {
|
||||
switch (getCurrentStepId(controller.state)) {
|
||||
const stepId = getCurrentStepId(controller.state);
|
||||
switch (stepId) {
|
||||
case 'identity':
|
||||
return <IdentityStep controller={controller} />;
|
||||
case 'profile':
|
||||
return <ProfileStep controller={controller} />;
|
||||
case 'expertise':
|
||||
return <ExpertiseStep controller={controller} />;
|
||||
case 'experience':
|
||||
return <ExperienceStep controller={controller} />;
|
||||
case 'commercials':
|
||||
return <CommercialsStep controller={controller} />;
|
||||
default: {
|
||||
const _exhaustive: never = stepId;
|
||||
return _exhaustive;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveFieldErrorMessage(
|
||||
errorValues: string[],
|
||||
): (typeof COPY.validation)[keyof typeof COPY.validation] {
|
||||
if (errorValues.includes('invalid_email')) {
|
||||
return COPY.validation.invalidEmail;
|
||||
}
|
||||
if (errorValues.includes('invalid_url')) {
|
||||
return COPY.validation.invalidUrl;
|
||||
}
|
||||
if (errorValues.includes('invalid_amount')) {
|
||||
return COPY.validation.invalidAmount;
|
||||
}
|
||||
if (errorValues.includes('too_short')) {
|
||||
return COPY.validation.notesTooShort;
|
||||
}
|
||||
return COPY.validation.incompleteForm;
|
||||
}
|
||||
|
||||
export function PartnerApplicationWizard({
|
||||
onSubmitted,
|
||||
onSuccess,
|
||||
resetSignal,
|
||||
}: {
|
||||
onSubmitted?: () => void;
|
||||
onSuccess: () => void;
|
||||
resetSignal: number;
|
||||
}) {
|
||||
@@ -162,15 +186,11 @@ export function PartnerApplicationWizard({
|
||||
const stepId = getCurrentStepId(state);
|
||||
const stepIndex = state.stepIndex;
|
||||
const isLastStep = stepIndex === STEPS.length - 1;
|
||||
const errorValues = Object.values(state.fieldErrors);
|
||||
const errorValues = Object.values(state.fieldErrors).filter(
|
||||
(value): value is string => value !== undefined,
|
||||
);
|
||||
const hasFieldErrors = errorValues.length > 0;
|
||||
const fieldErrorMessage = errorValues.includes('invalid_email')
|
||||
? COPY.validation.invalidEmail
|
||||
: errorValues.includes('invalid_url')
|
||||
? COPY.validation.invalidUrl
|
||||
: errorValues.includes('invalid_amount')
|
||||
? COPY.validation.invalidAmount
|
||||
: COPY.validation.incompleteForm;
|
||||
const fieldErrorMessage = resolveFieldErrorMessage(errorValues);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (event: FormEvent<HTMLFormElement>) => {
|
||||
@@ -201,7 +221,6 @@ export function PartnerApplicationWizard({
|
||||
return;
|
||||
}
|
||||
setSubmitted();
|
||||
onSubmitted?.();
|
||||
} catch {
|
||||
setSubmitError(i18n._(COPY.validation.submitFailed));
|
||||
} finally {
|
||||
@@ -212,7 +231,6 @@ export function PartnerApplicationWizard({
|
||||
goNext,
|
||||
i18n,
|
||||
isLastStep,
|
||||
onSubmitted,
|
||||
setSubmitError,
|
||||
setSubmitted,
|
||||
setSubmitting,
|
||||
@@ -221,14 +239,7 @@ export function PartnerApplicationWizard({
|
||||
);
|
||||
|
||||
if (state.isSubmitted) {
|
||||
return (
|
||||
<PartnerApplicationSuccess
|
||||
company={state.company}
|
||||
email={state.email}
|
||||
name={state.name}
|
||||
onDismiss={onSuccess}
|
||||
/>
|
||||
);
|
||||
return <PartnerApplicationSuccess onDismiss={onSuccess} />;
|
||||
}
|
||||
|
||||
const stepLabel = `${i18n._(
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
'use client';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { ChipMultiSelect, Field, TextareaField, TextField } from '@/ui';
|
||||
|
||||
import { PARTNER_TWENTY_EXPERIENCE_OPTIONS } from '../../data/partner-twenty-experience-options';
|
||||
import { PARTNER_APPLICATION_COPY } from '../../partner-application-copy';
|
||||
import { type PartnerApplicationController } from '../../use-partner-application-state';
|
||||
|
||||
const FIELDS = PARTNER_APPLICATION_COPY.fields;
|
||||
|
||||
export function ExperienceStep({
|
||||
controller,
|
||||
}: {
|
||||
controller: PartnerApplicationController;
|
||||
}) {
|
||||
const { i18n } = useLingui();
|
||||
const { setField, state, toggleExperience } = controller;
|
||||
|
||||
const experienceOptions = PARTNER_TWENTY_EXPERIENCE_OPTIONS.map((option) => ({
|
||||
label: i18n._(option.label),
|
||||
value: option.value,
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Field
|
||||
hint={i18n._(FIELDS.twentyExperienceHint)}
|
||||
label={i18n._(FIELDS.twentyExperience)}
|
||||
>
|
||||
<ChipMultiSelect
|
||||
ariaLabel={i18n._(FIELDS.twentyExperience)}
|
||||
invalid={state.fieldErrors.twentyExperience !== undefined}
|
||||
onToggle={toggleExperience}
|
||||
options={experienceOptions}
|
||||
values={state.twentyExperience}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
hint={i18n._(FIELDS.twentyExperienceNotesHint)}
|
||||
label={i18n._(FIELDS.twentyExperienceNotes)}
|
||||
>
|
||||
<TextareaField
|
||||
ariaLabel={i18n._(FIELDS.twentyExperienceNotes)}
|
||||
invalid={state.fieldErrors.twentyExperienceNotes !== undefined}
|
||||
name="twentyExperienceNotes"
|
||||
onValueChange={(value) => setField('twentyExperienceNotes', value)}
|
||||
placeholder={i18n._(FIELDS.twentyExperienceNotesPlaceholder)}
|
||||
value={state.twentyExperienceNotes}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
hint={i18n._(FIELDS.twentyExperienceProofLinkHint)}
|
||||
label={i18n._(FIELDS.twentyExperienceProofLink)}
|
||||
>
|
||||
<TextField
|
||||
ariaLabel={i18n._(FIELDS.twentyExperienceProofLink)}
|
||||
inputMode="url"
|
||||
invalid={state.fieldErrors.twentyExperienceProofLink !== undefined}
|
||||
name="twentyExperienceProofLink"
|
||||
onValueChange={(value) =>
|
||||
setField('twentyExperienceProofLink', value)
|
||||
}
|
||||
placeholder={i18n._(msg`https://`)}
|
||||
value={state.twentyExperienceProofLink}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useLingui } from '@lingui/react';
|
||||
|
||||
import { CategoryCardSelect, Field, TagInput, TextareaField } from '@/ui';
|
||||
import { CategoryCardSelect, Field, TagInput } from '@/ui';
|
||||
|
||||
import { PARTNER_SCOPE_OPTIONS } from '../../data/partner-scope-options';
|
||||
import { PARTNER_SKILL_POOL } from '../../data/partner-skill-pool';
|
||||
@@ -18,7 +18,7 @@ export function ExpertiseStep({
|
||||
controller: PartnerApplicationController;
|
||||
}) {
|
||||
const { i18n } = useLingui();
|
||||
const { setField, setSkills, state, toggleScope } = controller;
|
||||
const { setSkills, state, toggleScope } = controller;
|
||||
|
||||
const scopeOptions = PARTNER_SCOPE_OPTIONS.map((option) => ({
|
||||
description: i18n._(option.description),
|
||||
@@ -54,15 +54,6 @@ export function ExpertiseStep({
|
||||
values={state.skills}
|
||||
/>
|
||||
</Field>
|
||||
<Field label={i18n._(FIELDS.applicationNotes)}>
|
||||
<TextareaField
|
||||
ariaLabel={i18n._(FIELDS.applicationNotes)}
|
||||
name="applicationNotes"
|
||||
onValueChange={(value) => setField('applicationNotes', value)}
|
||||
placeholder={i18n._(FIELDS.applicationNotesPlaceholder)}
|
||||
value={state.applicationNotes}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,10 @@ const Pill = styled.button`
|
||||
color: ${semanticColor.surface};
|
||||
}
|
||||
|
||||
&[data-invalid] {
|
||||
border-color: ${color('error')};
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid ${color('blue')};
|
||||
outline-offset: 2px;
|
||||
@@ -51,22 +55,29 @@ const Pill = styled.button`
|
||||
|
||||
export function ChipMultiSelect<TValue extends string>({
|
||||
ariaLabel,
|
||||
invalid = false,
|
||||
onToggle,
|
||||
options,
|
||||
values,
|
||||
}: {
|
||||
ariaLabel: string;
|
||||
invalid?: boolean;
|
||||
onToggle: (value: TValue) => void;
|
||||
options: readonly ChipOption<TValue>[];
|
||||
values: readonly TValue[];
|
||||
}) {
|
||||
return (
|
||||
<PillGroup aria-label={ariaLabel} role="group">
|
||||
<PillGroup
|
||||
aria-invalid={invalid ? true : undefined}
|
||||
aria-label={ariaLabel}
|
||||
role="group"
|
||||
>
|
||||
{options.map((option) => {
|
||||
const selected = values.includes(option.value);
|
||||
return (
|
||||
<Pill
|
||||
aria-pressed={selected}
|
||||
data-invalid={invalid && !selected ? '' : undefined}
|
||||
data-selected={selected ? '' : undefined}
|
||||
key={option.value}
|
||||
onClick={() => onToggle(option.value)}
|
||||
|
||||
Reference in New Issue
Block a user