postx-framer-form
v0.4.2
Published
Reusable React 18 + TypeScript multi-step recruitment form, designed to be dropped into Framer as a Code Component.
Readme
postx-framer-form
Reusable multi-step recruitment form. React 18 + TypeScript (strict), no framework dependency, designed to be dropped into Framer as a Code Component.
- No Next.js, no server-side Node APIs. The runtime touches only
fetch,AbortControllerand the DOM. - API code is separate from UI.
src/api/**andsrc/validation/**contain no React at all;src/components/**contains nofetch. - React 18 compatible.
useReducer/useState/useEffectonly — nouse(), nouseActionState, no server actions. - Zero runtime dependencies. React and React DOM are peer dependencies.
Flow
| Step | What happens |
| --- | --- |
| 1 — Email | POST https://api.postx.ai/public/recruitment/applications/start with { email, integrationId }, plus formVariantId when the integration runs more than one form variant. The returned applicationId is stored in state. |
| 2 — Personal details | POST .../applications/{applicationId}/personal-details with the details plus integrationId. |
| 3 — Success | Confirmation, optionally showing the application reference. |
Usage
import { RecruitmentForm } from 'postx-framer-form';
<RecruitmentForm
integrationId="int_abc123"
onCompleted={(applicationId) => console.log('done', applicationId)}
/>;Props
| Prop | Type | Default | Notes |
| --- | --- | --- | --- |
| integrationId | string | — | Required. Rendering without it shows a configuration error instead of firing a request. |
| formVariantId | string | — | Optional. Only needed if this customer runs more than one recruitment form (e.g. van driver vs car driver) — identifies which one this is. Backend validation/matching on this field is tracked separately. |
| apiBaseUrl | string | https://api.postx.ai | Point at staging or a proxy. |
| requestTimeoutMs | number | 20000 | Per-request client-side timeout. |
| apiHeaders | Record<string, string> | {} | Merged into every request. |
| theme | Partial<FormTheme> | see DEFAULT_THEME | Colours, radius, font family. |
| copy | Partial<RecruitmentFormCopy> | see DEFAULT_COPY | All user-facing strings. |
| showApplicationReference | boolean | true | Show the applicationId on success. |
| allowBackNavigation | boolean | true | "Back" button on step 2. |
| allowRestart | boolean | false | "Start another application" on success. |
| className, style | — | — | Applied to the outer wrapper. |
| onApplicationStarted | (applicationId, email) => void | — | Fired after step 1 succeeds. |
| onCompleted | (applicationId) => void | — | Fired after step 2 succeeds. |
| onError | (error: unknown) => void | — | Receives ApiError / NetworkError / TimeoutError. |
| onStepChange | (step: FormStep) => void | — | 'email' \| 'personalDetails' \| 'success'. |
Using the pieces separately
The API client and the state hook are exported on their own, so a bespoke UI can reuse the transport and validation:
import { RecruitmentApi, useRecruitmentForm } from 'postx-framer-form';
const api = new RecruitmentApi({ baseUrl: 'https://staging-api.postx.ai' });
const { applicationId } = await api.startApplication({ email, integrationId });Using it in Framer
The PostX Recruitment Framer plugin bundles this package at build time and installs the wrapper Code File into a customer's project automatically — that's the supported path for customers, and none of this section applies to them.
For a manual integration instead:
npm run build.- Framer Code Files only resolve bare imports for
react,react-domandframer— not arbitrary npm packages or third-party CDN URLs — so bundleframer/PostXRecruitmentForm.tsxtogether with this package (e.g. with esbuild,external: ['react', 'framer'], JSX left unpreserved is fine since Framer transpiles the file itself) into one self-contained file. - In Framer: Assets → Code → New Code File, then paste the bundled output.
The wrapper adds
addPropertyControlsand sizing hints and nothing else, sointegrationId, the colours and every string become editable on the Framer canvas.
framer/ is excluded from the library tsconfig — it only type-checks inside
Framer, where the framer module exists.
Layout and responsiveness
The form has no CSS file to import (a Framer Code Component is a single module),
so the stylesheet is injected once into document.head, ref-counted across
instances, and namespaced under .pxf-root. Sizing is driven by container
queries (cqw, @container) rather than viewport media queries, so it adapts to
the frame it is placed in rather than to the browser window. Inputs are 16px to
stop iOS Safari zooming on focus, and tap targets are 48px tall.
Error handling
Every failure surfaces as one of ApiError, NetworkError, TimeoutError or
AbortedError (the last is swallowed by the UI — it means the user navigated away).
400responses in NestJS/class-validator shape ({ message: ["email must be an email"] }) are mapped onto inline field errors.5xxand429render a step-level alert with a Try again action.- A
2xxfrom step 1 that omitsapplicationIdis treated as a failure rather than letting step 2 POST to a broken URL. - Requests are aborted on unmount and when the user navigates back.
Development
npm install
npm run dev # playground at http://localhost:5173
npm run typecheck
npm run build # dist/ ESM + UMD + .d.ts
npm test # build + API smoke test + jsdom multi-step flow testVITE_INTEGRATION_ID and VITE_API_BASE_URL in .env.local point the playground
at a real integration.
Assumed API contract
Only POST /public/recruitment/applications/start was specified. The rest is
assumed and lives in one place — DEFAULT_ENDPOINTS in
src/api/recruitmentApi.ts — so it can be corrected without touching the UI.
Every step below is scoped to the application id start returns, and every
step 2-6 response has the same shape: { applicationId, currentStep }, where
currentStep is the backend's RecruitmentApplicationStep the application
just advanced to (PERSONAL_DETAILS / CV / RIGHT_TO_WORK /
DRIVING_LICENCE / INSURANCE_DECLARATIONS / AVAILABILITY / REVIEW /
COMPLETED) — used to resume an in-progress application on the right step.
- Step 1 response:
{ applicationId: string }({ id }and{ data: { applicationId } }are also accepted) - Step 2 — personal details:
PATCH /public/recruitment/applications/{applicationId}/personal-details, sent asmultipart/form-data(not JSON — it carries the CV file). Fields:firstName,lastName,email,phone(E.164),dateOfBirth(YYYY-MM-DD),gender?(MALE/FEMALE),line1,line2?,town,state,postcode,cv(PDF/DOC/DOCX, max 10MB),integrationId. - Step 3 — right to work:
PATCH /public/recruitment/applications/{applicationId}/eligibility, sent asmultipart/form-data(to carry the conditional document uploads). Fields:rightToWorkStatus(NOT_ELIGIBLE/BRITISH_CITIZEN/VALID_VISA),passportPhotoPage(required forBRITISH_CITIZEN/VALID_VISA; PNG/JPEG/WebP/PDF, max 10MB),immigrationStatusProof(required only forVALID_VISA; same file types/size limit),integrationId. - Step 4 — driving licence & experience:
PATCH /public/recruitment/applications/{applicationId}/driving-details, sent asmultipart/form-data(to carry the screenshot upload). Fields:hasFullValidUkDrivingLicence(boolean),drivingExperienceYears,vanDrivingExperienceYears,drivingLicenceNumber,drivingLicenceExpiryDate(YYYY-MM-DD),drivingLicenceCheckCode,checkCodeScreenshot(PNG/JPEG/WebP, max 10MB),integrationId. - Step 5 — insurance declarations:
PATCH /public/recruitment/applications/{applicationId}/insurance. Fields:maritalStatus(required — one ofSINGLE/MARRIED/CIVIL_PARTNER/DIVORCED/CIVIL_PARTNERSHIP_DISSOLVED/WIDOWED/SURVIVING_CIVIL_PARTNER/SEPARATED/OTHER), plus optional declarations:hasChildrenUnder16?,ukResidencyStatus?(LESS_THAN_12_MONTHS/AT_LEAST_12_MONTHS/SINCE_BIRTH),drivingLicenceObtainedAt?(YYYY-MM-DD),hasNoMoreThanSixPoints?,hasNoSeriousMotoringConvictions?,hasNoDrivingBansLastFiveYears?,hasNoCancelledInsurancePolicies?,hasNoUnspentCriminalConvictions?,integrationId. - Step 6 — availability:
PATCH /public/recruitment/applications/{applicationId}/availability. Fields:availabilityType(WEEKDAYS_AND_WEEKENDS/WEEKDAYS_ONLY/WEEKENDS_ONLY/SPECIFIC_DAYS),availableDays?(required and non-empty only whenavailabilityTypeisSPECIFIC_DAYS),availableStartTime/availableEndTime(24-hourHH:MM, end strictly after start),integrationId. - Step 7 — submit:
POST /public/recruitment/applications/{applicationId}/submit. Body:{ integrationId }. Unlike every other step, the response is a flat object, not wrapped in{ data }:{ applicationId, reference, status: 'SUBMITTED' }. - Branding endpoint:
GET /public/recruitment/integrations/{integrationId}/branding, returning{ data: { publicIntegrationId, customerName, imageUrl, primaryColour, active } }. Fetched once on mount byuseRecruitmentBrandingand rendered byRecruitmentForm— an unknown/revoked integration (404) or any network error resolves tonullrather than throwing, so the form falls back to its default styling and copy. A resolvable butactive: falseintegration is fetched but not shown or applied — the form keeps its default (or Framer-configured) look rather than branding itself with a suspended customer.
Override without forking:
new RecruitmentApi({
endpoints: {
personalDetails: (id) => `/public/recruitment/applications/${id}/applicant`,
},
});The endpoints are called with credentials: 'omit', so api.postx.ai must allow
the Framer site origin via CORS.
License
Copyright © PostX. Published to the public npm registry for distribution into Framer projects only. No license is granted to use, copy, modify, or redistribute this package outside of that purpose without written permission from PostX.
