Files
twenty/packages/twenty-website/src/partner-application/validate-partner-application-step.ts
T
Rashad Karanouh 6b99bcea7f 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>
2026-07-24 09:40:22 +05:00

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;
}