founder-schema 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +112 -40
- package/dist/chat.d.ts +63 -15
- package/dist/chat.d.ts.map +1 -1
- package/dist/chat.js +55 -17
- package/dist/chat.js.map +1 -1
- package/dist/common.d.ts +84 -8
- package/dist/common.d.ts.map +1 -1
- package/dist/common.js +109 -17
- package/dist/common.js.map +1 -1
- package/dist/discover.d.ts +98 -6
- package/dist/discover.d.ts.map +1 -1
- package/dist/discover.js +25 -11
- package/dist/discover.js.map +1 -1
- package/dist/errors.d.ts +85 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +89 -0
- package/dist/errors.js.map +1 -0
- package/dist/feed.d.ts +400 -52
- package/dist/feed.d.ts.map +1 -1
- package/dist/feed.js +98 -68
- package/dist/feed.js.map +1 -1
- package/dist/founder-profile.d.ts +76 -46
- package/dist/founder-profile.d.ts.map +1 -1
- package/dist/founder-profile.js +46 -46
- package/dist/founder-profile.js.map +1 -1
- package/dist/index.d.ts +14 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +20 -10
- package/dist/index.js.map +1 -1
- package/dist/limits.d.ts +76 -0
- package/dist/limits.d.ts.map +1 -0
- package/dist/limits.js +84 -0
- package/dist/limits.js.map +1 -0
- package/dist/onboarding.d.ts +407 -53
- package/dist/onboarding.d.ts.map +1 -1
- package/dist/onboarding.js +281 -234
- package/dist/onboarding.js.map +1 -1
- package/dist/responses/common.d.ts +122 -4
- package/dist/responses/common.d.ts.map +1 -1
- package/dist/responses/common.js +26 -3
- package/dist/responses/common.js.map +1 -1
- package/dist/responses/index.d.ts +1 -1
- package/dist/responses/index.d.ts.map +1 -1
- package/dist/responses/index.js +1 -1
- package/dist/responses/index.js.map +1 -1
- package/dist/uploads.d.ts +113 -0
- package/dist/uploads.d.ts.map +1 -0
- package/dist/uploads.js +106 -0
- package/dist/uploads.js.map +1 -0
- package/dist/validation.d.ts +136 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +142 -0
- package/dist/validation.js.map +1 -0
- package/package.json +4 -3
package/dist/onboarding.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* founder-schema — Onboarding: steps, enums, and the two shapes of the answers
|
|
3
3
|
*
|
|
4
|
-
* ONE LIST DEFINES THE FLOW.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* at
|
|
9
|
-
*
|
|
4
|
+
* ONE LIST DEFINES THE FLOW, AND IT IS DERIVED. Each step's fields are a pick
|
|
5
|
+
* of the finish body (`ONBOARDING_STEP_SCHEMAS`), and `FOUNDER_ONBOARDING_STEPS`
|
|
6
|
+
* is built FROM those picks: its `fields`, and even whether a step may be
|
|
7
|
+
* skipped, come from the schema rather than from a second hand-written list.
|
|
8
|
+
* GymMonk arrived at one list after keeping two in sync by hand, where a step
|
|
9
|
+
* could exist with no title or, worse, no validation gate at all; until 0.2.0
|
|
10
|
+
* this file still wrote the field lists twice.
|
|
10
11
|
*
|
|
11
12
|
* TWO SHAPES, ONE SET OF RULES. A half-filled form legitimately holds `null`
|
|
12
13
|
* for a choice nobody has made yet; the finalize body only ever sees completed
|
|
@@ -15,10 +16,19 @@
|
|
|
15
16
|
* for the end. Letting those two drift is how a wizard says a step is optional
|
|
16
17
|
* and then answers 422 at Finish.
|
|
17
18
|
*
|
|
19
|
+
* NO COPY IN HERE. Step titles, help lines and topic category names are
|
|
20
|
+
* translation KEYS (`titleKey`, `helpKey`, `labelKey`), Jansathi's rule for
|
|
21
|
+
* every config string: an English sentence in a shared package is a sentence
|
|
22
|
+
* every other language has to match by wording.
|
|
23
|
+
*
|
|
18
24
|
* @module founder-schema/onboarding
|
|
19
25
|
*/
|
|
20
26
|
import { z } from 'zod';
|
|
21
|
-
import { GOAL_VALUES, genderSchema, goalSchema,
|
|
27
|
+
import { GOAL_VALUES, genderSchema, goalSchema, industrySchema, locationSchema, optionalTrimmedString, phoneSchema, skillSchema, startupStageSchema, } from './common.js';
|
|
28
|
+
import { founderLinksSchema, ventureSchema } from './founder-profile.js';
|
|
29
|
+
import { ABOUT_MAX, CITY_MAX, COUNTRY_MAX, DISPLAY_NAME_MAX, GOALS_MIN, HEADLINE_MAX, SKILLS_MAX, STATE_MAX, VENTURE_NAME_MAX, VENTURE_TAGLINE_MAX, } from './limits.js';
|
|
30
|
+
import { uploadKeySchema } from './uploads.js';
|
|
31
|
+
import { VALIDATION_MESSAGE } from './validation.js';
|
|
22
32
|
// ─── Age ─────────────────────────────────────────────────────────────────────
|
|
23
33
|
/**
|
|
24
34
|
* 18, ruled by the user 2026-09-21. Under-18 would pull in parental-consent
|
|
@@ -61,7 +71,7 @@ export function ageInYears(isoDate, now = new Date()) {
|
|
|
61
71
|
export const isoDateSchema = z
|
|
62
72
|
.string()
|
|
63
73
|
.trim()
|
|
64
|
-
.regex(/^\d{4}-\d{2}-\d{2}$/,
|
|
74
|
+
.regex(/^\d{4}-\d{2}-\d{2}$/, VALIDATION_MESSAGE.dateInvalid);
|
|
65
75
|
/** True only for a date that exists. Rejects 2026-02-30 and 2026-13-01. */
|
|
66
76
|
function isRealDate(iso) {
|
|
67
77
|
const [y, m, d] = iso.split('-').map(Number);
|
|
@@ -80,11 +90,9 @@ function isRealDate(iso) {
|
|
|
80
90
|
* discoverable profile, because the client was the only thing checking.
|
|
81
91
|
*/
|
|
82
92
|
export const dateOfBirthSchema = isoDateSchema
|
|
83
|
-
.refine(isRealDate, {
|
|
84
|
-
.refine((v) => ageInYears(v) >= MIN_AGE_YEARS, {
|
|
85
|
-
|
|
86
|
-
})
|
|
87
|
-
.refine((v) => ageInYears(v) <= MAX_AGE_YEARS, { message: 'Check the year of birth' });
|
|
93
|
+
.refine(isRealDate, { error: VALIDATION_MESSAGE.dobInvalid })
|
|
94
|
+
.refine((v) => ageInYears(v) >= MIN_AGE_YEARS, { error: VALIDATION_MESSAGE.dobUnderAge })
|
|
95
|
+
.refine((v) => ageInYears(v) <= MAX_AGE_YEARS, { error: VALIDATION_MESSAGE.dobTooOld });
|
|
88
96
|
// ─── Business ────────────────────────────────────────────────────────────────
|
|
89
97
|
export const BUSINESS_TYPE_VALUES = ['startup', 'sme', 'enterprise'];
|
|
90
98
|
export const businessTypeSchema = z.enum(BUSINESS_TYPE_VALUES);
|
|
@@ -197,113 +205,111 @@ export const TOPIC_VALUES = [
|
|
|
197
205
|
'education_access',
|
|
198
206
|
];
|
|
199
207
|
export const topicSchema = z.enum(TOPIC_VALUES);
|
|
208
|
+
/**
|
|
209
|
+
* The grouping the picker renders.
|
|
210
|
+
*
|
|
211
|
+
* ⚠️ `TOPIC_VALUES` above is the single source of truth and this is typed
|
|
212
|
+
* against it, so a typo here is a compile error rather than a chip that renders
|
|
213
|
+
* and can never be selected. The dating product keeps a categorised list and a
|
|
214
|
+
* flat list side by side and they have already drifted apart by two values;
|
|
215
|
+
* a test in this package asserts these two agree exactly, in both directions.
|
|
216
|
+
*/
|
|
217
|
+
export const TOPIC_CATEGORY_KEYS = [
|
|
218
|
+
'career',
|
|
219
|
+
'tech',
|
|
220
|
+
'business',
|
|
221
|
+
'growth',
|
|
222
|
+
'creative',
|
|
223
|
+
'impact',
|
|
224
|
+
];
|
|
225
|
+
const topicCategory = (key, values) => ({
|
|
226
|
+
key,
|
|
227
|
+
labelKey: `topics.category.${key}`,
|
|
228
|
+
values,
|
|
229
|
+
});
|
|
200
230
|
export const TOPIC_CATEGORIES = [
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
'graphic_design',
|
|
284
|
-
'filmmaking',
|
|
285
|
-
'digital_art',
|
|
286
|
-
'content_creation',
|
|
287
|
-
'blogging',
|
|
288
|
-
'vlogging',
|
|
289
|
-
'podcasts',
|
|
290
|
-
'animation',
|
|
291
|
-
'music_production',
|
|
292
|
-
],
|
|
293
|
-
},
|
|
294
|
-
{
|
|
295
|
-
key: 'impact',
|
|
296
|
-
label: 'Impact & Community',
|
|
297
|
-
values: [
|
|
298
|
-
'volunteering',
|
|
299
|
-
'ngo_work',
|
|
300
|
-
'environmental',
|
|
301
|
-
'women_empowerment',
|
|
302
|
-
'community_service',
|
|
303
|
-
'social_enterprise',
|
|
304
|
-
'education_access',
|
|
305
|
-
],
|
|
306
|
-
},
|
|
231
|
+
topicCategory('career', [
|
|
232
|
+
'mentoring',
|
|
233
|
+
'leadership',
|
|
234
|
+
'networking',
|
|
235
|
+
'public_speaking',
|
|
236
|
+
'teaching',
|
|
237
|
+
'hiring_talent',
|
|
238
|
+
'personal_branding',
|
|
239
|
+
'career_transition',
|
|
240
|
+
'remote_work',
|
|
241
|
+
]),
|
|
242
|
+
topicCategory('tech', [
|
|
243
|
+
'ai_ml',
|
|
244
|
+
'blockchain',
|
|
245
|
+
'crypto_web3',
|
|
246
|
+
'robotics',
|
|
247
|
+
'data_science',
|
|
248
|
+
'cybersecurity',
|
|
249
|
+
'game_dev',
|
|
250
|
+
'ux_design',
|
|
251
|
+
'app_development',
|
|
252
|
+
'open_source',
|
|
253
|
+
'electronics',
|
|
254
|
+
'space_exploration',
|
|
255
|
+
'green_tech',
|
|
256
|
+
'deeptech',
|
|
257
|
+
'no_code',
|
|
258
|
+
]),
|
|
259
|
+
topicCategory('business', [
|
|
260
|
+
'entrepreneurship',
|
|
261
|
+
'investing',
|
|
262
|
+
'stock_trading',
|
|
263
|
+
'personal_finance',
|
|
264
|
+
'fundraising',
|
|
265
|
+
'marketing',
|
|
266
|
+
'sales',
|
|
267
|
+
'consulting',
|
|
268
|
+
'ecommerce',
|
|
269
|
+
'retail',
|
|
270
|
+
'real_estate',
|
|
271
|
+
'supply_chain',
|
|
272
|
+
'product_management',
|
|
273
|
+
'growth',
|
|
274
|
+
'b2b_saas',
|
|
275
|
+
'd2c',
|
|
276
|
+
]),
|
|
277
|
+
topicCategory('growth', [
|
|
278
|
+
'meditation',
|
|
279
|
+
'journaling',
|
|
280
|
+
'self_improvement',
|
|
281
|
+
'productivity',
|
|
282
|
+
'reading',
|
|
283
|
+
'online_courses',
|
|
284
|
+
'philosophy',
|
|
285
|
+
'psychology',
|
|
286
|
+
'therapy',
|
|
287
|
+
'sleep_optimization',
|
|
288
|
+
'gym',
|
|
289
|
+
'yoga',
|
|
290
|
+
]),
|
|
291
|
+
topicCategory('creative', [
|
|
292
|
+
'photography',
|
|
293
|
+
'writing',
|
|
294
|
+
'graphic_design',
|
|
295
|
+
'filmmaking',
|
|
296
|
+
'digital_art',
|
|
297
|
+
'content_creation',
|
|
298
|
+
'blogging',
|
|
299
|
+
'vlogging',
|
|
300
|
+
'podcasts',
|
|
301
|
+
'animation',
|
|
302
|
+
'music_production',
|
|
303
|
+
]),
|
|
304
|
+
topicCategory('impact', [
|
|
305
|
+
'volunteering',
|
|
306
|
+
'ngo_work',
|
|
307
|
+
'environmental',
|
|
308
|
+
'women_empowerment',
|
|
309
|
+
'community_service',
|
|
310
|
+
'social_enterprise',
|
|
311
|
+
'education_access',
|
|
312
|
+
]),
|
|
307
313
|
];
|
|
308
314
|
/**
|
|
309
315
|
* How many a member must pick. A minimum exists because these drive feed
|
|
@@ -314,82 +320,51 @@ export const TOPIC_CATEGORIES = [
|
|
|
314
320
|
* minimum at all, and its own field config says "pick up to 10" while every
|
|
315
321
|
* screen and schema enforces 20.
|
|
316
322
|
*/
|
|
317
|
-
/**
|
|
318
|
-
* ⚠️ TRANSLATION KEY SHAPE, for whoever builds the picker:
|
|
319
|
-
* `<fieldKey>.<value>` for a value and `<fieldKey>._label` for the field name.
|
|
320
|
-
* The dating product's interests JSON is keyed by CATEGORY instead, so all five
|
|
321
|
-
* of its label lookups miss and every chip renders its raw English in every
|
|
322
|
-
* locale, with a fully translated Hindi file sitting unreachable. Lift the
|
|
323
|
-
* translations, reshape the keys.
|
|
324
|
-
*/
|
|
325
323
|
export const INTERESTS_MIN = 3;
|
|
326
324
|
export const INTERESTS_MAX = 10;
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
{
|
|
343
|
-
key: 'location',
|
|
344
|
-
title: 'Where are you based?',
|
|
345
|
-
helpText: 'Used to show founders near you. You can skip this.',
|
|
346
|
-
fields: ['location'],
|
|
347
|
-
required: false,
|
|
348
|
-
},
|
|
349
|
-
{
|
|
350
|
-
key: 'goals',
|
|
351
|
-
title: 'Goals & Passion',
|
|
352
|
-
helpText: 'Pick what matters most to you.',
|
|
353
|
-
fields: ['goals'],
|
|
354
|
-
required: true,
|
|
355
|
-
},
|
|
356
|
-
{
|
|
357
|
-
key: 'interests',
|
|
358
|
-
title: 'What inspires you the most?',
|
|
359
|
-
helpText: 'We use these to shape your feed.',
|
|
360
|
-
fields: ['interests'],
|
|
361
|
-
required: true,
|
|
362
|
-
},
|
|
363
|
-
{
|
|
364
|
-
key: 'business',
|
|
365
|
-
title: 'Tell us about your business',
|
|
366
|
-
helpText: 'Skip it if you are still figuring things out.',
|
|
367
|
-
fields: ['venture', 'businessType'],
|
|
368
|
-
required: false,
|
|
369
|
-
},
|
|
370
|
-
{
|
|
371
|
-
key: 'guidelines',
|
|
372
|
-
title: 'How we behave here',
|
|
373
|
-
helpText: 'A short read, and the only rule that matters.',
|
|
374
|
-
fields: ['guidelinesVersion'],
|
|
375
|
-
required: true,
|
|
376
|
-
},
|
|
325
|
+
// ─── The step keys ───────────────────────────────────────────────────────────
|
|
326
|
+
/**
|
|
327
|
+
* The step keys, in order, as a literal union, so a consumer can key a table by
|
|
328
|
+
* step and have TypeScript insist on every one. Before 0.2.0 `key` was a plain
|
|
329
|
+
* string and the web client kept its own copy of this list to get the same
|
|
330
|
+
* check; a test there held the two together. Now there is one list.
|
|
331
|
+
*/
|
|
332
|
+
export const ONBOARDING_STEP_KEYS = [
|
|
333
|
+
'identity',
|
|
334
|
+
'contact',
|
|
335
|
+
'location',
|
|
336
|
+
'goals',
|
|
337
|
+
'interests',
|
|
338
|
+
'business',
|
|
339
|
+
'guidelines',
|
|
377
340
|
];
|
|
378
|
-
|
|
341
|
+
// ─── Guidelines ──────────────────────────────────────────────────────────────
|
|
379
342
|
/**
|
|
380
|
-
*
|
|
381
|
-
*
|
|
343
|
+
* The community guidelines a member agrees to at the last step, by version.
|
|
344
|
+
*
|
|
345
|
+
* The finish body accepts THIS version and no other: an old tab still showing
|
|
346
|
+
* last month's text cannot record agreement to text it never displayed, and
|
|
347
|
+
* `'whatever'` is not a version. Until 0.2.0 any non-empty string passed, and
|
|
348
|
+
* the web client held this constant on its own.
|
|
349
|
+
*
|
|
350
|
+
* ⚠ Changing the guidelines text means bumping this, in the same publish.
|
|
351
|
+
* Every member who agreed to an earlier version keeps that version on their
|
|
352
|
+
* profile; that record is the point.
|
|
382
353
|
*/
|
|
383
|
-
export const
|
|
354
|
+
export const GUIDELINES_VERSION = '2026-10-03';
|
|
355
|
+
export const guidelinesVersionSchema = z.literal(GUIDELINES_VERSION, {
|
|
356
|
+
error: VALIDATION_MESSAGE.guidelinesOutdated,
|
|
357
|
+
});
|
|
384
358
|
// ─── Contact ─────────────────────────────────────────────────────────────────
|
|
385
359
|
/**
|
|
386
|
-
*
|
|
387
|
-
* the
|
|
360
|
+
* The links the contact step collects, picked from the profile's own links so
|
|
361
|
+
* the two cannot disagree on what a link is. The phone is `phoneSchema`: ten
|
|
362
|
+
* digits, stored raw with no country code, on the account rather than here.
|
|
388
363
|
*/
|
|
389
|
-
export const contactLinksSchema =
|
|
390
|
-
linkedin:
|
|
391
|
-
x:
|
|
392
|
-
instagram:
|
|
364
|
+
export const contactLinksSchema = founderLinksSchema.pick({
|
|
365
|
+
linkedin: true,
|
|
366
|
+
x: true,
|
|
367
|
+
instagram: true,
|
|
393
368
|
});
|
|
394
369
|
// ─── The form, mid-flight ────────────────────────────────────────────────────
|
|
395
370
|
/**
|
|
@@ -400,29 +375,34 @@ export const contactLinksSchema = z.object({
|
|
|
400
375
|
* this schema is required, on purpose: it describes a form somebody is halfway
|
|
401
376
|
* through, and the rules that decide whether they may FINISH live in
|
|
402
377
|
* `completeOnboardingBodySchema`.
|
|
378
|
+
*
|
|
379
|
+
* Text inputs stay text here (`teamSize`, `dateOfBirth`), because that is what
|
|
380
|
+
* an input holds while somebody types; the form converts them when it builds
|
|
381
|
+
* the finish body, which takes real numbers.
|
|
403
382
|
*/
|
|
404
383
|
export const onboardingFormSchema = z.object({
|
|
405
|
-
displayName: z.string().trim().max(
|
|
384
|
+
displayName: z.string().trim().max(DISPLAY_NAME_MAX),
|
|
406
385
|
gender: genderSchema.nullable(),
|
|
407
386
|
dateOfBirth: z.string().trim(),
|
|
408
|
-
headline: z.string().trim().max(
|
|
409
|
-
about: z.string().trim().max(
|
|
410
|
-
|
|
387
|
+
headline: z.string().trim().max(HEADLINE_MAX),
|
|
388
|
+
about: z.string().trim().max(ABOUT_MAX),
|
|
389
|
+
/** Only ever a key the server minted for this member. */
|
|
390
|
+
avatarUploadKey: uploadKeySchema.nullable(),
|
|
411
391
|
phone: z.string().trim(),
|
|
412
392
|
links: contactLinksSchema,
|
|
413
393
|
location: z
|
|
414
394
|
.object({
|
|
415
|
-
city: z.string().trim().max(
|
|
416
|
-
state: z.string().trim().max(
|
|
417
|
-
country: z.string().trim().max(
|
|
395
|
+
city: z.string().trim().max(CITY_MAX),
|
|
396
|
+
state: z.string().trim().max(STATE_MAX),
|
|
397
|
+
country: z.string().trim().max(COUNTRY_MAX),
|
|
418
398
|
})
|
|
419
399
|
.nullable(),
|
|
420
400
|
goals: z.array(goalSchema).max(GOAL_VALUES.length),
|
|
421
401
|
interests: z.array(topicSchema).max(INTERESTS_MAX),
|
|
422
402
|
venture: z
|
|
423
403
|
.object({
|
|
424
|
-
name: z.string().trim().max(
|
|
425
|
-
tagline: z.string().trim().max(
|
|
404
|
+
name: z.string().trim().max(VENTURE_NAME_MAX),
|
|
405
|
+
tagline: z.string().trim().max(VENTURE_TAGLINE_MAX),
|
|
426
406
|
website: z.string().trim(),
|
|
427
407
|
industry: industrySchema.nullable(),
|
|
428
408
|
stage: startupStageSchema.nullable(),
|
|
@@ -430,7 +410,7 @@ export const onboardingFormSchema = z.object({
|
|
|
430
410
|
})
|
|
431
411
|
.nullable(),
|
|
432
412
|
businessType: businessTypeSchema.nullable(),
|
|
433
|
-
skills: z.array(skillSchema).max(
|
|
413
|
+
skills: z.array(skillSchema).max(SKILLS_MAX),
|
|
434
414
|
guidelinesVersion: z.string().trim().nullable(),
|
|
435
415
|
});
|
|
436
416
|
// ─── The finalize body ───────────────────────────────────────────────────────
|
|
@@ -447,9 +427,14 @@ export const onboardingFormSchema = z.object({
|
|
|
447
427
|
* at finalize. `isOnboarded` may be set here and nowhere else: four of the
|
|
448
428
|
* profile's partial indexes are gated on it, so a half-built profile carrying
|
|
449
429
|
* it true appears in Discover half-built.
|
|
430
|
+
*
|
|
431
|
+
* Location, venture and links are the profile's own shapes, not copies: the
|
|
432
|
+
* copies are how the profile model came to lack Instagram.
|
|
450
433
|
*/
|
|
451
434
|
export const completeOnboardingBodySchema = z.object({
|
|
452
|
-
|
|
435
|
+
// Plain bounds, no custom message: `too_small` with minimum 1 already says
|
|
436
|
+
// "required", and the client words it in the member's language.
|
|
437
|
+
displayName: z.string().trim().min(1).max(DISPLAY_NAME_MAX),
|
|
453
438
|
/**
|
|
454
439
|
* ⚠️ NO `username` HERE, and its absence is deliberate.
|
|
455
440
|
*
|
|
@@ -467,48 +452,103 @@ export const completeOnboardingBodySchema = z.object({
|
|
|
467
452
|
*/
|
|
468
453
|
gender: genderSchema,
|
|
469
454
|
dateOfBirth: dateOfBirthSchema,
|
|
470
|
-
headline: optionalTrimmedString(
|
|
471
|
-
about: optionalTrimmedString(
|
|
472
|
-
|
|
455
|
+
headline: optionalTrimmedString(HEADLINE_MAX),
|
|
456
|
+
about: optionalTrimmedString(ABOUT_MAX),
|
|
457
|
+
/**
|
|
458
|
+
* The key the presign handed out, in the minted format. It was a bare
|
|
459
|
+
* string until 0.2.0, so `'../../etc/passwd'` passed this schema and only
|
|
460
|
+
* the server's own commit check stood in the way.
|
|
461
|
+
*/
|
|
462
|
+
avatarUploadKey: uploadKeySchema.optional(),
|
|
473
463
|
phone: phoneSchema,
|
|
474
464
|
links: contactLinksSchema.optional(),
|
|
475
465
|
/** Absent when the member skipped the step or refused the permission. */
|
|
476
|
-
location:
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
.array(topicSchema)
|
|
486
|
-
.min(INTERESTS_MIN, `Pick at least ${INTERESTS_MIN}`)
|
|
487
|
-
.max(INTERESTS_MAX),
|
|
488
|
-
skills: z.array(skillSchema).max(8).optional(),
|
|
489
|
-
/** Absent when the member skipped the business step. */
|
|
490
|
-
venture: z
|
|
491
|
-
.object({
|
|
492
|
-
name: z.string().trim().min(1).max(120),
|
|
493
|
-
tagline: optionalTrimmedString(160),
|
|
494
|
-
website: httpUrlSchema.optional(),
|
|
495
|
-
industry: industrySchema,
|
|
496
|
-
stage: startupStageSchema,
|
|
497
|
-
teamSize: z.coerce.number().int().min(1).max(100000).optional(),
|
|
498
|
-
})
|
|
499
|
-
.optional(),
|
|
466
|
+
location: locationSchema.optional(),
|
|
467
|
+
goals: z.array(goalSchema).min(GOALS_MIN).max(GOAL_VALUES.length),
|
|
468
|
+
interests: z.array(topicSchema).min(INTERESTS_MIN).max(INTERESTS_MAX),
|
|
469
|
+
skills: z.array(skillSchema).max(SKILLS_MAX).optional(),
|
|
470
|
+
/**
|
|
471
|
+
* Absent when the member skipped the business step. The profile's venture
|
|
472
|
+
* without `foundedYear`, which onboarding does not ask; the Me tab does.
|
|
473
|
+
*/
|
|
474
|
+
venture: ventureSchema.omit({ foundedYear: true }).optional(),
|
|
500
475
|
businessType: businessTypeSchema.optional(),
|
|
501
476
|
/**
|
|
502
|
-
* The version of the guidelines this member read
|
|
477
|
+
* The version of the guidelines this member read: `GUIDELINES_VERSION`,
|
|
478
|
+
* exactly.
|
|
503
479
|
*
|
|
504
480
|
* A version and not a boolean, and the server stamps the time. A boolean says
|
|
505
481
|
* somebody agreed; a version and a timestamp say WHAT they agreed to and
|
|
506
482
|
* WHEN, which is the only form worth anything the day the text changes or a
|
|
507
483
|
* moderation decision is disputed.
|
|
508
484
|
*/
|
|
509
|
-
guidelinesVersion:
|
|
485
|
+
guidelinesVersion: guidelinesVersionSchema,
|
|
486
|
+
});
|
|
487
|
+
// ─── The steps ───────────────────────────────────────────────────────────────
|
|
488
|
+
/**
|
|
489
|
+
* Each step's slice of the finish body, picked FROM it, so a client validating
|
|
490
|
+
* "may I leave this step" and the server validating the finish apply the very
|
|
491
|
+
* same rules. A copy would drift, and the drift shows up as a wizard that lets
|
|
492
|
+
* a step pass and a finish that refuses it, on the last screen.
|
|
493
|
+
*
|
|
494
|
+
* THIS IS THE ONE PLACE A STEP'S FIELDS ARE WRITTEN. `FOUNDER_ONBOARDING_STEPS`
|
|
495
|
+
* below reads them from here. A test asserts that every finish field belongs to
|
|
496
|
+
* exactly one step.
|
|
497
|
+
*
|
|
498
|
+
* These validate the FINISH shape (skipped = absent), not the in-progress form:
|
|
499
|
+
* a client maps its form state to the slice first. The server does not run them
|
|
500
|
+
* on a draft save, because a half-answered step must be storable.
|
|
501
|
+
*/
|
|
502
|
+
export const ONBOARDING_STEP_SCHEMAS = {
|
|
503
|
+
identity: completeOnboardingBodySchema.pick({
|
|
504
|
+
displayName: true,
|
|
505
|
+
gender: true,
|
|
506
|
+
dateOfBirth: true,
|
|
507
|
+
headline: true,
|
|
508
|
+
about: true,
|
|
509
|
+
avatarUploadKey: true,
|
|
510
|
+
// Here because ruling D8 folded the design's orphan "title, achievements,
|
|
511
|
+
// skills" screen into step one. It was on no step until 0.2.0, which made
|
|
512
|
+
// it the one finish field no step owned.
|
|
513
|
+
skills: true,
|
|
514
|
+
}),
|
|
515
|
+
contact: completeOnboardingBodySchema.pick({ phone: true, links: true }),
|
|
516
|
+
location: completeOnboardingBodySchema.pick({ location: true }),
|
|
517
|
+
goals: completeOnboardingBodySchema.pick({ goals: true }),
|
|
518
|
+
interests: completeOnboardingBodySchema.pick({ interests: true }),
|
|
519
|
+
business: completeOnboardingBodySchema.pick({ venture: true, businessType: true }),
|
|
520
|
+
guidelines: completeOnboardingBodySchema.pick({ guidelinesVersion: true }),
|
|
521
|
+
};
|
|
522
|
+
export const FOUNDER_ONBOARDING_STEPS = ONBOARDING_STEP_KEYS.map((key) => {
|
|
523
|
+
const slice = ONBOARDING_STEP_SCHEMAS[key];
|
|
524
|
+
return {
|
|
525
|
+
key,
|
|
526
|
+
titleKey: `onboarding.steps.${key}.title`,
|
|
527
|
+
helpKey: `onboarding.steps.${key}.help`,
|
|
528
|
+
fields: Object.keys(slice.shape),
|
|
529
|
+
required: !slice.safeParse({}).success,
|
|
530
|
+
};
|
|
531
|
+
});
|
|
532
|
+
export const ONBOARDING_TOTAL_STEPS = FOUNDER_ONBOARDING_STEPS.length;
|
|
533
|
+
/** Fields gating each step, in step order. Read from the same picks. */
|
|
534
|
+
export const ONBOARDING_STEP_FIELDS = FOUNDER_ONBOARDING_STEPS.map((s) => s.fields);
|
|
535
|
+
/** What finishing answers: the new profile's id and the handle derived for it. */
|
|
536
|
+
export const completeOnboardingResponseSchema = z.object({
|
|
537
|
+
id: z.string(),
|
|
538
|
+
username: z.string(),
|
|
510
539
|
});
|
|
511
540
|
// ─── The resume cursor ───────────────────────────────────────────────────────
|
|
541
|
+
/**
|
|
542
|
+
* The keys a draft may hold: exactly the in-progress form's fields.
|
|
543
|
+
*
|
|
544
|
+
* ⚠ The server writes each key as a dot path (`draft.<key>`). Unchecked, a key
|
|
545
|
+
* holding a dot writes somewhere nested the form never asked for, and the
|
|
546
|
+
* collection fills with whatever a client invents. Until 0.2.0 the backend
|
|
547
|
+
* kept its own copy of this list and DROPPED unknown keys without a word; now a
|
|
548
|
+
* draft carrying one is refused, with the key in the issue's path.
|
|
549
|
+
*/
|
|
550
|
+
export const onboardingDraftKeySchema = onboardingFormSchema.keyof();
|
|
551
|
+
export const ONBOARDING_DRAFT_KEYS = onboardingDraftKeySchema.options;
|
|
512
552
|
/**
|
|
513
553
|
* Where the member got to, kept in its own collection, separate from the
|
|
514
554
|
* profile.
|
|
@@ -517,22 +557,29 @@ export const completeOnboardingBodySchema = z.object({
|
|
|
517
557
|
* pair: one number is what lets a member start on the web and resume elsewhere,
|
|
518
558
|
* provided both ends agree on the encoding.
|
|
519
559
|
*
|
|
520
|
-
*
|
|
521
|
-
*
|
|
522
|
-
* it.
|
|
560
|
+
* The draft's KEYS come from the form; its VALUES are opaque on purpose. A
|
|
561
|
+
* half-answered step must be storable even though it would fail the finish,
|
|
562
|
+
* and everything in it is re-validated strictly at finalize.
|
|
563
|
+
*
|
|
564
|
+
* `partialRecord`, not `record`: in Zod 4 an enum-keyed `z.record` demands
|
|
565
|
+
* every key, and a draft holds whichever steps the member has reached.
|
|
523
566
|
*/
|
|
524
|
-
export const onboardingDraftSchema = z.
|
|
567
|
+
export const onboardingDraftSchema = z.partialRecord(onboardingDraftKeySchema, z.unknown());
|
|
525
568
|
export const onboardingProgressSchema = z.object({
|
|
526
569
|
step: z.number().int().min(0).max(ONBOARDING_TOTAL_STEPS),
|
|
527
570
|
draft: onboardingDraftSchema.optional(),
|
|
528
571
|
});
|
|
529
|
-
/**
|
|
572
|
+
/**
|
|
573
|
+
* `PATCH` body: any subset of the cursor, merged server-side. No defaults.
|
|
574
|
+
* `step` may equal `ONBOARDING_TOTAL_STEPS`: "past the last step", which the
|
|
575
|
+
* client sends on its way to finish.
|
|
576
|
+
*/
|
|
530
577
|
export const onboardingProgressUpdateSchema = z.object({
|
|
531
578
|
step: z.number().int().min(0).max(ONBOARDING_TOTAL_STEPS).optional(),
|
|
532
579
|
draft: onboardingDraftSchema.optional(),
|
|
533
580
|
});
|
|
534
581
|
/**
|
|
535
|
-
* What the client reads on return
|
|
582
|
+
* What the client reads on return, from `GET` and from every `PATCH`.
|
|
536
583
|
*
|
|
537
584
|
* ⚠️ `onboarded` is the gate and it comes from the profile, not from the
|
|
538
585
|
* cursor: a finished member on a new device must be recognised even when the
|
|
@@ -541,7 +588,7 @@ export const onboardingProgressUpdateSchema = z.object({
|
|
|
541
588
|
*/
|
|
542
589
|
export const onboardingStatusSchema = z.object({
|
|
543
590
|
onboarded: z.boolean(),
|
|
544
|
-
step: z.number().int().min(0),
|
|
591
|
+
step: z.number().int().min(0).max(ONBOARDING_TOTAL_STEPS),
|
|
545
592
|
draft: onboardingDraftSchema.optional(),
|
|
546
593
|
});
|
|
547
594
|
//# sourceMappingURL=onboarding.js.map
|