@cavuno/board 4.27.0 → 4.29.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.
Files changed (66) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/dist/{_spec-B_pBNVUd.d.mts → _spec-CQx5p1cM.d.mts} +117 -20
  3. package/dist/{_spec-B_pBNVUd.d.ts → _spec-CQx5p1cM.d.ts} +117 -20
  4. package/dist/{blog-DzGRUsj5.d.ts → blog-BwyZQX_f.d.ts} +1 -1
  5. package/dist/{blog-fhXRik6k.d.mts → blog-DP06wTDw.d.mts} +1 -1
  6. package/dist/{board-fVAXZ8T5.d.ts → board-C2Y5fz0m.d.ts} +2 -2
  7. package/dist/{board-DEiEHcpe.d.mts → board-C7iju96a.d.mts} +2 -2
  8. package/dist/{breadcrumbs-DNaaP3xa.d.ts → breadcrumbs-BUzF85v3.d.ts} +2 -2
  9. package/dist/{breadcrumbs-qE3CD--N.d.mts → breadcrumbs-Db6UTihw.d.mts} +2 -2
  10. package/dist/browser/cavuno-board.global.min.js +1 -1
  11. package/dist/{chunk-DUTQOLOF.mjs → chunk-EHSB2VAJ.mjs} +4 -0
  12. package/dist/{chunk-T2KC6XGV.mjs → chunk-IVSECNCD.mjs} +54 -7
  13. package/dist/{chunk-OPMJ6YH5.mjs → chunk-KDFKVWTZ.mjs} +1 -1
  14. package/dist/{chunk-BGKNXG4H.mjs → chunk-XXQCESMU.mjs} +4 -0
  15. package/dist/filters.d.mts +2 -2
  16. package/dist/filters.d.ts +2 -2
  17. package/dist/format.d.mts +4 -4
  18. package/dist/format.d.ts +4 -4
  19. package/dist/format.mjs +2 -2
  20. package/dist/go.mjs +1 -1
  21. package/dist/index.d.mts +99 -15
  22. package/dist/index.d.ts +99 -15
  23. package/dist/index.js +44 -4
  24. package/dist/index.mjs +41 -5
  25. package/dist/{jobs-BRysn2m0.d.ts → jobs-BA_PnUCd.d.ts} +9 -7
  26. package/dist/{jobs-R-h3A3kl.d.mts → jobs-BZqtb1gY.d.mts} +9 -7
  27. package/dist/paths.d.mts +8 -1
  28. package/dist/paths.d.ts +8 -1
  29. package/dist/paths.js +5 -1
  30. package/dist/paths.mjs +5 -3
  31. package/dist/{roles-DBgzkvEt.d.ts → roles-Bh3WNF9u.d.mts} +3 -1
  32. package/dist/{roles-DBgzkvEt.d.mts → roles-Bh3WNF9u.d.ts} +3 -1
  33. package/dist/route-contract.d.mts +10 -5
  34. package/dist/route-contract.d.ts +10 -5
  35. package/dist/route-contract.js +53 -5
  36. package/dist/route-contract.mjs +2 -2
  37. package/dist/{search-BlB690ma.d.mts → search-Da3TjcZ0.d.mts} +1 -1
  38. package/dist/{search-BK_zHHNQ.d.ts → search-dsA-Cetf.d.ts} +1 -1
  39. package/dist/{seo-C7gMl6Y7.d.ts → seo-D-n2g6X3.d.ts} +1 -1
  40. package/dist/{seo-0mbZ7M_E.d.mts → seo-D3DhnHk8.d.mts} +1 -1
  41. package/dist/seo-files.d.mts +2 -2
  42. package/dist/seo-files.d.ts +2 -2
  43. package/dist/seo.d.mts +6 -6
  44. package/dist/seo.d.ts +6 -6
  45. package/dist/seo.mjs +2 -2
  46. package/dist/server.d.mts +6 -6
  47. package/dist/server.d.ts +6 -6
  48. package/dist/server.mjs +1 -1
  49. package/dist/sitemap.d.mts +6 -6
  50. package/dist/sitemap.d.ts +6 -6
  51. package/dist/sitemap.mjs +2 -2
  52. package/dist/suggest.d.mts +2 -2
  53. package/dist/suggest.d.ts +2 -2
  54. package/dist/well-known.d.mts +4 -1
  55. package/dist/well-known.d.ts +4 -1
  56. package/dist/well-known.js +30 -1
  57. package/dist/well-known.mjs +5 -3
  58. package/package.json +1 -1
  59. package/skills/cavuno-board-account/SKILL.md +27 -0
  60. package/skills/cavuno-board-errors/SKILL.md +1 -1
  61. package/skills/cavuno-board-filters/SKILL.md +90 -1
  62. package/skills/cavuno-board-format/SKILL.md +52 -0
  63. package/skills/cavuno-board-post-a-job/SKILL.md +24 -0
  64. package/skills/cavuno-board-salaries/SKILL.md +7 -0
  65. package/skills/cavuno-board-talent-access/SKILL.md +7 -0
  66. package/skills/manifest.json +2 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cavuno/board",
3
- "version": "4.27.0",
3
+ "version": "4.29.0",
4
4
  "description": "Build custom job boards and careers pages with Cavuno’s TypeScript SDK.",
5
5
  "keywords": [
6
6
  "api-client",
@@ -11,6 +11,31 @@ Anonymous methods here are token-based email unsubscribe and email-change confir
11
11
 
12
12
  The host application owns forms, file pickers, and cookie plumbing; this SDK surface supplies data operations.
13
13
 
14
+ ## Candidate screens are candidate-only
15
+
16
+ A board user is either a candidate or an employer (`me.role`). Only candidates have a candidate profile. Render candidate screens and fields (the profile form, profile completeness, resume upload, experience, education, skills, and languages) only when `me.role === 'candidate'`. On the account page, give an employer account the employer profile view instead: avatar, `displayName` (the one profile field every account can update), and a link to the employer screens (`board.me.companies.*`). Never show them the empty candidate form.
17
+
18
+ The API is the backstop: a profile write from an account with no candidate profile returns 403 `candidate_profile_required` before anything is saved. `displayName` alone is the one profile field every account can update. Surface this error as a message; never swallow it and report "Saved".
19
+
20
+ ```ts snippet
21
+ import { isBoardApiError } from '@cavuno/board';
22
+
23
+ const me = await board.me.retrieve();
24
+ if (me.role !== 'candidate') {
25
+ return renderEmployerProfile(me); // avatar, displayName, link to employer screens
26
+ }
27
+
28
+ try {
29
+ await board.me.profile.update({ headline: 'Staff Engineer' });
30
+ showSaved();
31
+ } catch (error) {
32
+ if (isBoardApiError(error) && error.code === 'candidate_profile_required') {
33
+ return showError('Only candidate accounts have a candidate profile.');
34
+ }
35
+ throw error;
36
+ }
37
+ ```
38
+
14
39
  ## Account and profile
15
40
 
16
41
  Account deletion is a synchronous, irreversible cascade over the profile, collections, saved jobs, alerts, avatar, and resume. Obtain explicit confirmation before calling it.
@@ -252,6 +277,8 @@ await board.me.notificationPreferences.unsubscribeWithToken({
252
277
 
253
278
  ## Completion gate
254
279
 
280
+ - Candidate screens render only for `me.role === 'candidate'`; an employer account gets the employer profile view (avatar, `displayName`, link to employer screens).
281
+ - A `candidate_profile_required` 403 shows an error and never a "Saved" state.
255
282
  - The profile form follows `forms.talent`: order matches, hidden fields are absent, and required fields block save while empty.
256
283
  - Profile update is visible after retrieval.
257
284
  - Scalar field updates preserve omitted values; collection selection updates preserve every selection the user meant to keep.
@@ -44,7 +44,7 @@ try {
44
44
  }
45
45
  ```
46
46
 
47
- The remaining status guards are `isForbidden` for 403 and `isConflict` for 409. Record `code` and `requestId` in support diagnostics. Because v1 may add error codes, unmatched `BoardApiError` instances still need a safe generic state.
47
+ The remaining status guards are `isForbidden` for 403 and `isConflict` for 409. A 403 `candidate_profile_required` means an account that is not a candidate (for example an employer) tried to write candidate profile data: gate candidate screens on `me.role === 'candidate'` and show the error rather than a success state. Record `code` and `requestId` in support diagnostics. Because v1 may add error codes, unmatched `BoardApiError` instances still need a safe generic state.
48
48
 
49
49
  **Complete when:** every expected failure has one typed branch, an invalid resource renders not-found, and unmatched failures retain their original error.
50
50
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: cavuno-board-filters
3
- description: Listing-filter contracts with @cavuno/board. Use for job filter controls, profile-field filters, collection choices, sort controls, listing URL validation, or taxonomy-backed filter options.
3
+ description: Listing-filter contracts with @cavuno/board. Use for job filter controls, job and company custom-field filters, profile-field filters, collection choices, sort controls, listing URL validation, or taxonomy-backed filter options.
4
4
  ---
5
5
 
6
6
  # Listing filters
@@ -150,6 +150,60 @@ const { items } = await board.search.suggest({
150
150
  The host router owns URL serialization and saved-filter persistence.
151
151
  Locations come from `board.taxonomy.places` rather than a static filter export.
152
152
 
153
+ ## Filter jobs by a job custom field
154
+
155
+ The operator's job custom fields arrive as definitions on
156
+ `board.context().customFields.job`. Expose a field as a filter only when the
157
+ operator intends it as one: keep an explicit list of filter keys in your app
158
+ config. Do not render a dropdown for every select field automatically; most
159
+ custom fields are details for the job page, not listing facets.
160
+
161
+ ```ts snippet
162
+ const context = await board.context();
163
+
164
+ // The operator picked these; everything else stays a job-page detail.
165
+ const FILTER_FIELD_KEYS = ['work_type'];
166
+ const filterFields = context.customFields.job.filter(
167
+ (field) =>
168
+ FILTER_FIELD_KEYS.includes(field.key) &&
169
+ (field.type === 'single_select' || field.type === 'multi_select'));
170
+
171
+ // Controls: option KEYS are the values, option labels are the text.
172
+ const controls = filterFields.map((field) => ({
173
+ key: field.key,
174
+ label: field.label,
175
+ options: (field.options ?? []).map((option) => ({
176
+ value: option.key,
177
+ label: option.label,
178
+ })),
179
+ }));
180
+
181
+ // URL state holds option keys (`/jobs?work_type=contract`), never labels.
182
+ // Drop anything that is not a current option before it reaches the API.
183
+ const customFields = filterFields.flatMap((field) => {
184
+ const values = searchParams
185
+ .getAll(field.key)
186
+ .filter((value) => field.options?.some((option) => option.key === value));
187
+ return values.length > 0 ? [{ key: field.key, values }] : [];
188
+ });
189
+
190
+ const results = await board.jobs.search({
191
+ query: filters.q,
192
+ filters: {
193
+ seniority: filters.seniority,
194
+ customFields: customFields.length > 0 ? customFields : undefined,
195
+ },
196
+ limit: 20,
197
+ });
198
+ ```
199
+
200
+ Clauses are AND-matched; values inside one clause are alternatives. Each
201
+ request takes at most 10 clauses of 10 values. An unknown key or an option key
202
+ the field no longer offers returns `invalid_filter`, so validate URL input
203
+ against the definitions as above. Boolean and number fields filter the same way
204
+ with `values: [true]` or `values: [3]`. Labels change when the operator renames
205
+ an option; keys do not, so bookmarked URLs keep working.
206
+
153
207
  ## Build filters from public profile fields
154
208
 
155
209
  `board.profileFields.retrieve('company')` and `retrieve('candidate')` return only public scalar and collection-reference definitions. Use each stored field key for filtering. For a collection reference, page or search its active public choices and submit the returned record `id`; the optional `logoUrl` is display metadata.
@@ -189,6 +243,36 @@ void talent.data;
189
243
 
190
244
  Clauses are AND-matched. Values or record IDs inside one clause are alternatives. Unknown, private, invalid, or archived choices return `invalid_filter`; each array accepts at most 10 clauses and each clause at most 10 choices. Job search supports scalar `customFields` under `filters`, but job collection references are outside this surface.
191
245
 
246
+ ## Filter jobs by job and company custom fields
247
+
248
+ `jobs.search` takes two custom-field filters. `filters.customFields` matches the job's own fields, defined in `board.context().customFields.job`. `filters.companyCustomFields` matches the public profile fields of the job's company, defined in `board.profileFields.retrieve('company')`. Both use the clause shape above; the two lists never share keys, so a job field and a company field may both be called `type`.
249
+
250
+ ```ts snippet
251
+ const [context, companyFields] = await Promise.all([
252
+ board.context(),
253
+ board.profileFields.retrieve('company'),
254
+ ]);
255
+ const opportunityType = context.customFields.job.find(
256
+ (field) => field.key === 'opportunity_type');
257
+ const employerType = companyFields.definitions.find(
258
+ (field) => field.key === 'employer_type');
259
+
260
+ const results = await board.jobs.search({
261
+ query: filters.q,
262
+ filters: {
263
+ customFields: [{ key: 'opportunity_type', values: ['identified'] }],
264
+ companyCustomFields: [{ key: 'employer_type', values: ['acco'] }],
265
+ },
266
+ limit: 20,
267
+ });
268
+
269
+ void opportunityType;
270
+ void employerType;
271
+ void results.data;
272
+ ```
273
+
274
+ Render each dropdown from the definition's `options` in order: show `label`, submit `key`, and keep the key in the URL so shared links survive a label change. Accept only keys the current definition offers; drop anything else from a hand-typed URL. Only `jobs.search` takes these filters. A company's jobs are re-indexed automatically when its values change. Worked example: [Custom field filters and badges](https://cavuno.com/docs/sdk/cookbook/custom-field-filters-and-badges).
275
+
192
276
  ## Completion gate
193
277
 
194
278
  Finish only after every applicable check passes:
@@ -204,8 +288,13 @@ Finish only after every applicable check passes:
204
288
  - `/jobs?category=engineering,design` sends
205
289
  `filters.categories: ['engineering', 'design']` to `jobs.search`.
206
290
  - Every paged taxonomy request forwards the previous opaque `nextCursor`.
291
+ - Job custom-field controls exist only for keys the operator chose as
292
+ filters, read their options from `board.context().customFields.job`, and send
293
+ option keys in `filters.customFields`; an unknown option in the URL never
294
+ reaches the API.
207
295
  - Company and talent custom controls come from `board.profileFields`; private definitions and archived choices never become filter options.
208
296
  - Profile-filter requests use stored scalar values and returned collection entry IDs, not display labels.
297
+ - Job-field dropdowns send `filters.customFields` and company-field dropdowns send `filters.companyCustomFields` to `jobs.search`, both built from the current definitions; a stale option key in the URL is dropped, not sent.
209
298
 
210
299
  ## Cavuno SDK reference
211
300
 
@@ -96,6 +96,55 @@ the same card view-model as listings — do not convert a full job client-side.
96
96
  const card = toJobCardVM(saved.job, { language, /* … */ });
97
97
  ```
98
98
 
99
+ ## Badges on job cards
100
+
101
+ Every job card (listings, search, company jobs, similar, recommended, and
102
+ saved jobs) carries `customFieldValues` for the job and
103
+ `company.customFieldValues` for its company. Both hold only select, boolean,
104
+ and number values keyed by field key; select values are option keys. Text
105
+ fields stay on the full job and company, and private company fields never
106
+ appear. Both are `{}` when empty, so a card needs no extra request to show a
107
+ badge.
108
+
109
+ ```ts snippet
110
+ import { resolveCustomFieldDisplay } from '@cavuno/board/format';
111
+
112
+ const context = await board.context();
113
+ const companyFields = await board.profileFields.retrieve('company');
114
+
115
+ // Badge only the fields the operator wants on cards (an app-config list).
116
+ const JOB_BADGE_KEYS = ['work_type'];
117
+ const jobBadges = resolveCustomFieldDisplay(
118
+ context.language,
119
+ context.customFields.job.filter((field) => JOB_BADGE_KEYS.includes(field.key)),
120
+ card.customFieldValues).flatMap((entry) => {
121
+ switch (entry.kind) {
122
+ case 'text': // a single_select resolves to its option label
123
+ return [entry.value];
124
+ case 'multi_select':
125
+ return entry.values;
126
+ case 'boolean':
127
+ return entry.value ? [entry.label] : [];
128
+ case 'number':
129
+ return [`${entry.label}: ${entry.value}`];
130
+ }
131
+ });
132
+
133
+ // Company badge: resolve the stored option key to its public label.
134
+ const employerField = companyFields.definitions.find(
135
+ (field) => field.key === 'employer_category');
136
+ const employerKey = card.company?.customFieldValues.employer_category;
137
+ const employerBadge =
138
+ typeof employerKey === 'string'
139
+ ? (employerField?.options?.find((option) => option.key === employerKey)
140
+ ?.label ?? null)
141
+ : null;
142
+ ```
143
+
144
+ Load the definitions once per page (both are cacheable board metadata), not
145
+ once per card. A key with no current definition or option resolves to nothing:
146
+ drop the badge rather than printing the raw key.
147
+
99
148
  ## Completion gate
100
149
 
101
150
  Finish only after every applicable check passes:
@@ -107,6 +156,9 @@ Finish only after every applicable check passes:
107
156
  - Open ranges return `bound: 'from' | 'upTo'`; the application adds open-range
108
157
  chrome and composes order itself.
109
158
  - Saved jobs arrive as cards; no `fullJobToCard` conversion.
159
+ - Card badges read `card.customFieldValues` / `card.company.customFieldValues`
160
+ and resolve option keys to labels through the definitions; no raw key is
161
+ shown and no card triggers a per-job detail fetch.
110
162
  - Salary-stat formatters receive the detail's `currency` (never invent USD).
111
163
  - Formatting changes neither salary amounts nor invents chrome words.
112
164
 
@@ -87,6 +87,30 @@ The list replaces the job's wording: omit it on `update` to keep what is stored,
87
87
 
88
88
  Validate `required` client-side from these entries before submitting, so the poster sees a missing field before the request.
89
89
 
90
+ ## Pick office locations
91
+
92
+ Back the `location` field with `board.locations.search`, a worldwide search over countries, regions, cities, and localities. `session` is required: generate one per location-field interaction, reuse it across keystrokes and the pick-time `board.locations.resolve` call, then generate a new one for the next interaction. Renew it before 50 search requests or after 180 seconds. Pass `jobForm.location.allowedCountries` as `country` to narrow suggestions to the board's allowed countries.
93
+
94
+ ```ts snippet
95
+ const session = crypto.randomUUID();
96
+ const { data: locations } = await board.locations.search({
97
+ q: 'berl',
98
+ session,
99
+ country: jobForm.location.allowedCountries?.join(','),
100
+ });
101
+
102
+ const picked = locations[0];
103
+ if (picked) {
104
+ const location = await board.locations.resolve({
105
+ locationId: picked.id,
106
+ session,
107
+ });
108
+ submission.officeLocations.push({ locationId: location.id });
109
+ }
110
+ ```
111
+
112
+ Show `fullName` in the input once picked and `contextLabel` as the secondary line in the result list. Resolve the picked `id` with the same session before committing the selection, then send the resolved `id` as `locationId` in the later job submission. `board.taxonomy.places.list({ q })` ids are accepted as `locationId` too. An id the server does not recognise throws `locations_invalid_id`, and `locations_unavailable` means the search is temporarily down, so let the poster retry.
113
+
90
114
  ## Resolve a logo
91
115
 
92
116
  Both logo paths return a stored `publicUrl` for `create`. The SDK owns multipart encoding.
@@ -37,6 +37,13 @@ const companies = await board.salaries.companies.list();
37
37
  region, and city nesting from each `SalaryLocation.parentSlug`; top-level rows
38
38
  have `parentSlug: null`.
39
39
 
40
+ Every place row in a salary response (location index nodes, `topLocations`,
41
+ child, sibling, and other locations) carries `placeName` and `placeLabel`.
42
+ Render `placeLabel` wherever places are listed: it tells a city from the
43
+ region it shares a name with ("New York, NY" under "New York", "Washington,
44
+ DC" beside "Washington"). Keep `placeName` for data you pass on, such as
45
+ JSON-LD.
46
+
40
47
  ## Retrieve detail and canonicalize slugs
41
48
 
42
49
  Every axis `retrieve(slug)` accepts an inbound English or board-language slug.
@@ -135,6 +135,13 @@ const named = await board.talent.retrieve('jane-doe');
135
135
  const opaque = await board.talent.retrieve('bu_candidate');
136
136
  ```
137
137
 
138
+ The profile route may move to another shape with fixed text around the handle, for example `src/routes/@{$handle}.tsx` for `/@jane`. When you move it:
139
+
140
+ - Keep `export const cavunoPage = 'talentProfile'` on the profile route file, so links Cavuno builds (webhooks, admin links) follow the new address.
141
+ - Keep `/p/$handle` working as a permanent (301) redirect to the same value on the new route.
142
+ - Build every profile link (cards, messages, canonical tag, JSON-LD) through one helper that encodes only the handle, never the fixed text.
143
+ - Never serve a bare `/$handle` at the root: it collides with board pages and is rejected as a profile address.
144
+
138
145
  ## Completion gate
139
146
 
140
147
  - `retrieve()` drives the Message-vs-upsell CTA and remaining-credit hints.
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "4.27.0",
2
+ "version": "4.29.0",
3
3
  "skills": [
4
4
  {
5
5
  "name": "cavuno-board-account",
@@ -59,7 +59,7 @@
59
59
  },
60
60
  {
61
61
  "name": "cavuno-board-filters",
62
- "description": "Listing-filter contracts with @cavuno/board. Use for job filter controls, profile-field filters, collection choices, sort controls, listing URL validation, or taxonomy-backed filter options.",
62
+ "description": "Listing-filter contracts with @cavuno/board. Use for job filter controls, job and company custom-field filters, profile-field filters, collection choices, sort controls, listing URL validation, or taxonomy-backed filter options.",
63
63
  "path": "skills/cavuno-board-filters/SKILL.md",
64
64
  "framework": null,
65
65
  "category": "core"