6b99bcea7f
## 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>
120 lines
3.3 KiB
TypeScript
120 lines
3.3 KiB
TypeScript
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';
|
|
import { type PartnerApplicationState } from './partner-application-state';
|
|
|
|
const STEP_REQUIRED_FIELDS: Record<
|
|
PartnerApplicationStepId,
|
|
readonly (keyof PartnerApplicationState)[]
|
|
> = {
|
|
identity: ['name', 'email', 'company', 'website'],
|
|
profile: ['country', 'typeOfTeam', 'city'],
|
|
expertise: ['partnerScope'],
|
|
experience: [
|
|
'twentyExperience',
|
|
'twentyExperienceNotes',
|
|
'twentyExperienceProofLink',
|
|
],
|
|
commercials: ['hourlyRate', 'projectBudgetMin'],
|
|
};
|
|
|
|
function isEmpty(value: unknown): boolean {
|
|
if (typeof value === 'string') return value.trim() === '';
|
|
if (Array.isArray(value)) return value.length === 0;
|
|
return value === null || value === undefined;
|
|
}
|
|
|
|
type FieldFormatCheck = {
|
|
field:
|
|
| 'email'
|
|
| 'website'
|
|
| 'linkedin'
|
|
| 'calendarLink'
|
|
| 'twentyExperienceProofLink'
|
|
| 'hourlyRate'
|
|
| 'projectBudgetMin';
|
|
schema:
|
|
| typeof emailFieldSchema
|
|
| typeof httpUrlFieldSchema
|
|
| typeof nonNegativeAmountFieldSchema;
|
|
errorCode: 'invalid_email' | 'invalid_url' | 'invalid_amount';
|
|
};
|
|
|
|
const STEP_FORMAT_CHECKS: Partial<
|
|
Record<PartnerApplicationStepId, readonly FieldFormatCheck[]>
|
|
> = {
|
|
identity: [
|
|
{ field: 'email', schema: emailFieldSchema, errorCode: 'invalid_email' },
|
|
{ field: 'website', schema: httpUrlFieldSchema, errorCode: 'invalid_url' },
|
|
],
|
|
profile: [
|
|
{ field: 'linkedin', schema: httpUrlFieldSchema, errorCode: 'invalid_url' },
|
|
],
|
|
experience: [
|
|
{
|
|
field: 'twentyExperienceProofLink',
|
|
schema: httpUrlFieldSchema,
|
|
errorCode: 'invalid_url',
|
|
},
|
|
],
|
|
commercials: [
|
|
{
|
|
field: 'hourlyRate',
|
|
schema: nonNegativeAmountFieldSchema,
|
|
errorCode: 'invalid_amount',
|
|
},
|
|
{
|
|
field: 'projectBudgetMin',
|
|
schema: nonNegativeAmountFieldSchema,
|
|
errorCode: 'invalid_amount',
|
|
},
|
|
{
|
|
field: 'calendarLink',
|
|
schema: httpUrlFieldSchema,
|
|
errorCode: 'invalid_url',
|
|
},
|
|
],
|
|
};
|
|
|
|
export function validatePartnerApplicationStep(
|
|
state: PartnerApplicationState,
|
|
): Partial<Record<string, string>> {
|
|
if (
|
|
state.stepIndex < 0 ||
|
|
state.stepIndex >= PARTNER_APPLICATION_STEP_IDS.length
|
|
) {
|
|
return { step: 'invalid_step' };
|
|
}
|
|
const stepId = PARTNER_APPLICATION_STEP_IDS[state.stepIndex];
|
|
const errors: Partial<Record<string, string>> = {};
|
|
|
|
for (const field of STEP_REQUIRED_FIELDS[stepId]) {
|
|
if (isEmpty(state[field])) {
|
|
errors[field] = 'required';
|
|
}
|
|
}
|
|
|
|
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) {
|
|
errors[check.field] = check.errorCode;
|
|
}
|
|
}
|
|
|
|
return errors;
|
|
}
|