@cavuno/board 1.25.0 → 1.27.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 +88 -0
- package/dist/bin.mjs +67 -34
- package/dist/filters.d.mts +55 -0
- package/dist/filters.d.ts +55 -0
- package/dist/filters.js +193 -0
- package/dist/filters.mjs +170 -0
- package/dist/format.d.mts +240 -0
- package/dist/format.d.ts +240 -0
- package/dist/format.js +453 -0
- package/dist/format.mjs +430 -0
- package/dist/index.d.mts +81 -3965
- package/dist/index.d.ts +81 -3965
- package/dist/index.js +154 -7
- package/dist/index.mjs +151 -4
- package/dist/jobs-DK5mPBgq.d.mts +3836 -0
- package/dist/jobs-DK5mPBgq.d.ts +3836 -0
- package/dist/salaries-CXt6Vkrp.d.ts +130 -0
- package/dist/salaries-CrJsaZe6.d.mts +130 -0
- package/dist/seo.d.mts +288 -0
- package/dist/seo.d.ts +288 -0
- package/dist/seo.js +1102 -0
- package/dist/seo.mjs +1079 -0
- package/dist/sitemap.d.mts +63 -0
- package/dist/sitemap.d.ts +63 -0
- package/dist/sitemap.js +353 -0
- package/dist/sitemap.mjs +330 -0
- package/dist/theme.d.mts +63 -0
- package/dist/theme.d.ts +63 -0
- package/dist/theme.js +149 -0
- package/dist/theme.mjs +128 -0
- package/package.json +57 -2
- package/skills/cavuno-board-account/SKILL.md +147 -0
- package/skills/cavuno-board-applications/SKILL.md +110 -0
- package/skills/cavuno-board-blog/SKILL.md +99 -0
- package/skills/cavuno-board-companies/SKILL.md +99 -0
- package/skills/cavuno-board-filters/SKILL.md +89 -0
- package/skills/cavuno-board-format/SKILL.md +104 -0
- package/skills/cavuno-board-job-alerts/SKILL.md +115 -0
- package/skills/cavuno-board-job-posting/SKILL.md +130 -0
- package/skills/cavuno-board-jobs/SKILL.md +15 -0
- package/skills/cavuno-board-messaging/SKILL.md +121 -0
- package/skills/cavuno-board-paywall/SKILL.md +108 -0
- package/skills/cavuno-board-salaries/SKILL.md +87 -0
- package/skills/cavuno-board-seo/SKILL.md +180 -0
- package/skills/cavuno-board-setup/SKILL.md +26 -2
- package/skills/cavuno-board-sitemap/SKILL.md +147 -0
- package/skills/cavuno-board-smoke-test/SKILL.md +84 -0
- package/skills/cavuno-board-theme/SKILL.md +69 -0
- package/skills/manifest.json +106 -1
package/dist/theme.mjs
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// src/theme/index.ts
|
|
2
|
+
var BOARD_COLOR_KEYS = [
|
|
3
|
+
"brandColor",
|
|
4
|
+
"buttonPrimary",
|
|
5
|
+
"buttonPrimaryText",
|
|
6
|
+
"buttonDanger",
|
|
7
|
+
"background",
|
|
8
|
+
"surfaceBackground",
|
|
9
|
+
"mutedBackground",
|
|
10
|
+
"highlightBackground",
|
|
11
|
+
"text",
|
|
12
|
+
"textSubtle",
|
|
13
|
+
"textMuted",
|
|
14
|
+
"textDisabled",
|
|
15
|
+
"textError",
|
|
16
|
+
"border",
|
|
17
|
+
"contrastBackground",
|
|
18
|
+
"contrastText"
|
|
19
|
+
];
|
|
20
|
+
function color(colors, key) {
|
|
21
|
+
const value = colors[key];
|
|
22
|
+
return typeof value === "string" && value ? value : void 0;
|
|
23
|
+
}
|
|
24
|
+
function tokenLines(colors) {
|
|
25
|
+
const lines = [];
|
|
26
|
+
const set = (token, value) => {
|
|
27
|
+
if (value) lines.push(` --${token}: ${value};`);
|
|
28
|
+
};
|
|
29
|
+
set("background", color(colors, "background"));
|
|
30
|
+
set("foreground", color(colors, "text"));
|
|
31
|
+
set("card", color(colors, "surfaceBackground"));
|
|
32
|
+
set("card-foreground", color(colors, "text"));
|
|
33
|
+
set("popover", color(colors, "surfaceBackground"));
|
|
34
|
+
set("popover-foreground", color(colors, "text"));
|
|
35
|
+
set("primary", color(colors, "buttonPrimary"));
|
|
36
|
+
set("primary-foreground", color(colors, "buttonPrimaryText"));
|
|
37
|
+
set("secondary", color(colors, "mutedBackground"));
|
|
38
|
+
set("secondary-foreground", color(colors, "text"));
|
|
39
|
+
set("muted", color(colors, "mutedBackground"));
|
|
40
|
+
set("muted-foreground", color(colors, "textMuted"));
|
|
41
|
+
set("accent", color(colors, "highlightBackground"));
|
|
42
|
+
set("accent-foreground", color(colors, "text"));
|
|
43
|
+
set(
|
|
44
|
+
"destructive",
|
|
45
|
+
color(colors, "buttonDanger") ?? color(colors, "textError")
|
|
46
|
+
);
|
|
47
|
+
set("border", color(colors, "border"));
|
|
48
|
+
set("input", color(colors, "border"));
|
|
49
|
+
set("ring", color(colors, "brandColor") ?? color(colors, "buttonPrimary"));
|
|
50
|
+
set("contrast-background", color(colors, "contrastBackground"));
|
|
51
|
+
set("contrast-foreground", color(colors, "contrastText"));
|
|
52
|
+
set("foreground-subtle", color(colors, "textSubtle"));
|
|
53
|
+
set("foreground-disabled", color(colors, "textDisabled"));
|
|
54
|
+
set("foreground-error", color(colors, "textError"));
|
|
55
|
+
return lines;
|
|
56
|
+
}
|
|
57
|
+
function boardThemeToCss(theme) {
|
|
58
|
+
if (!theme) return "";
|
|
59
|
+
const light = tokenLines(theme.colors.light ?? {});
|
|
60
|
+
const dark = tokenLines(theme.colors.dark ?? {});
|
|
61
|
+
const fontSans = theme.typography?.fontSans;
|
|
62
|
+
if (fontSans) {
|
|
63
|
+
light.push(
|
|
64
|
+
` --font-sans: '${themeFontFamily(fontSans)}', ui-sans-serif, system-ui, sans-serif;`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
const blocks = [];
|
|
68
|
+
if (light.length > 0) blocks.push(`:root {
|
|
69
|
+
${light.join("\n")}
|
|
70
|
+
}`);
|
|
71
|
+
if (dark.length > 0) blocks.push(`.dark {
|
|
72
|
+
${dark.join("\n")}
|
|
73
|
+
}`);
|
|
74
|
+
return blocks.join("\n\n");
|
|
75
|
+
}
|
|
76
|
+
function themeMode(theme) {
|
|
77
|
+
if (!theme) return "system";
|
|
78
|
+
return theme.mode === "light" || theme.mode === "dark" ? theme.mode : "system";
|
|
79
|
+
}
|
|
80
|
+
var THEME_FONT_GOOGLE_FAMILIES = {
|
|
81
|
+
"be-vietnam-pro": "Be+Vietnam+Pro",
|
|
82
|
+
inter: "Inter",
|
|
83
|
+
"plus-jakarta-sans": "Plus+Jakarta+Sans",
|
|
84
|
+
"dm-sans": "DM+Sans",
|
|
85
|
+
"public-sans": "Public+Sans",
|
|
86
|
+
figtree: "Figtree",
|
|
87
|
+
"work-sans": "Work+Sans",
|
|
88
|
+
"open-sans": "Open+Sans",
|
|
89
|
+
poppins: "Poppins",
|
|
90
|
+
hind: "Hind",
|
|
91
|
+
lexend: "Lexend",
|
|
92
|
+
"fira-sans": "Fira+Sans",
|
|
93
|
+
manrope: "Manrope",
|
|
94
|
+
"source-sans-pro": "Source+Sans+3",
|
|
95
|
+
outfit: "Outfit",
|
|
96
|
+
"space-grotesk": "Space+Grotesk",
|
|
97
|
+
geist: "Geist",
|
|
98
|
+
"source-serif-4": "Source+Serif+4",
|
|
99
|
+
lora: "Lora",
|
|
100
|
+
"crimson-pro": "Crimson+Pro"
|
|
101
|
+
};
|
|
102
|
+
function googleFamily(fontKey) {
|
|
103
|
+
return THEME_FONT_GOOGLE_FAMILIES[fontKey] ?? encodeURIComponent(fontKey).replaceAll("%20", "+");
|
|
104
|
+
}
|
|
105
|
+
function themeFontFamily(fontKey) {
|
|
106
|
+
return googleFamily(fontKey).replaceAll("+", " ");
|
|
107
|
+
}
|
|
108
|
+
function googleFontsUrl(theme) {
|
|
109
|
+
if (!theme) return null;
|
|
110
|
+
const families = /* @__PURE__ */ new Set();
|
|
111
|
+
if (theme.typography?.fontSans) {
|
|
112
|
+
families.add(googleFamily(theme.typography.fontSans));
|
|
113
|
+
}
|
|
114
|
+
if (theme.typography?.fontHeading) {
|
|
115
|
+
families.add(googleFamily(theme.typography.fontHeading));
|
|
116
|
+
}
|
|
117
|
+
if (families.size === 0) return null;
|
|
118
|
+
const params = [...families].map((f) => `family=${f}:wght@400;500;600;700`).join("&");
|
|
119
|
+
return `https://fonts.googleapis.com/css2?${params}&display=swap`;
|
|
120
|
+
}
|
|
121
|
+
export {
|
|
122
|
+
BOARD_COLOR_KEYS,
|
|
123
|
+
THEME_FONT_GOOGLE_FAMILIES,
|
|
124
|
+
boardThemeToCss,
|
|
125
|
+
googleFontsUrl,
|
|
126
|
+
themeFontFamily,
|
|
127
|
+
themeMode
|
|
128
|
+
};
|
package/package.json
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cavuno/board",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.27.0",
|
|
4
4
|
"description": "Typed isomorphic client for the Cavuno Board API",
|
|
5
5
|
"license": "MIT",
|
|
6
|
+
"type": "commonjs",
|
|
6
7
|
"repository": {
|
|
7
8
|
"type": "git",
|
|
8
9
|
"url": "git+https://github.com/wollemiahq/cavuno.git",
|
|
@@ -29,6 +30,56 @@
|
|
|
29
30
|
"default": "./dist/index.js"
|
|
30
31
|
}
|
|
31
32
|
},
|
|
33
|
+
"./format": {
|
|
34
|
+
"import": {
|
|
35
|
+
"types": "./dist/format.d.mts",
|
|
36
|
+
"default": "./dist/format.mjs"
|
|
37
|
+
},
|
|
38
|
+
"require": {
|
|
39
|
+
"types": "./dist/format.d.ts",
|
|
40
|
+
"default": "./dist/format.js"
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
"./filters": {
|
|
44
|
+
"import": {
|
|
45
|
+
"types": "./dist/filters.d.mts",
|
|
46
|
+
"default": "./dist/filters.mjs"
|
|
47
|
+
},
|
|
48
|
+
"require": {
|
|
49
|
+
"types": "./dist/filters.d.ts",
|
|
50
|
+
"default": "./dist/filters.js"
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"./theme": {
|
|
54
|
+
"import": {
|
|
55
|
+
"types": "./dist/theme.d.mts",
|
|
56
|
+
"default": "./dist/theme.mjs"
|
|
57
|
+
},
|
|
58
|
+
"require": {
|
|
59
|
+
"types": "./dist/theme.d.ts",
|
|
60
|
+
"default": "./dist/theme.js"
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
"./seo": {
|
|
64
|
+
"import": {
|
|
65
|
+
"types": "./dist/seo.d.mts",
|
|
66
|
+
"default": "./dist/seo.mjs"
|
|
67
|
+
},
|
|
68
|
+
"require": {
|
|
69
|
+
"types": "./dist/seo.d.ts",
|
|
70
|
+
"default": "./dist/seo.js"
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
"./sitemap": {
|
|
74
|
+
"import": {
|
|
75
|
+
"types": "./dist/sitemap.d.mts",
|
|
76
|
+
"default": "./dist/sitemap.mjs"
|
|
77
|
+
},
|
|
78
|
+
"require": {
|
|
79
|
+
"types": "./dist/sitemap.d.ts",
|
|
80
|
+
"default": "./dist/sitemap.js"
|
|
81
|
+
}
|
|
82
|
+
},
|
|
32
83
|
"./skills": {
|
|
33
84
|
"import": {
|
|
34
85
|
"types": "./dist/skills.d.mts",
|
|
@@ -49,19 +100,23 @@
|
|
|
49
100
|
"access": "public"
|
|
50
101
|
},
|
|
51
102
|
"scripts": {
|
|
52
|
-
"build": "tsup",
|
|
103
|
+
"build": "rm -rf dist && tsup",
|
|
53
104
|
"clean": "git clean -xdf .turbo dist node_modules src/generated",
|
|
54
105
|
"typecheck": "tsgo --noEmit && tsgo --noEmit -p tsconfig.node.json",
|
|
55
106
|
"test": "vitest run",
|
|
107
|
+
"check:publish": "publint --strict && attw --pack . --profile node16",
|
|
56
108
|
"gen:types": "tsx scripts/gen-types.ts",
|
|
109
|
+
"check:types": "OPENAPI_URL=spec/openapi.snapshot.json tsx scripts/gen-types.ts && git diff --exit-code -- src/generated/openapi-types.ts",
|
|
57
110
|
"gen:skills-manifest": "tsx scripts/generate-skills-manifest.ts",
|
|
58
111
|
"assert-publish-target": "node -e \"const p=require('./package.json'); if(p.name!=='@cavuno/board'){throw new Error('Refusing to publish: package.json name is '+p.name+', expected @cavuno/board')}; if(p.private){throw new Error('Refusing to publish: package.json has private:true')}\"",
|
|
59
112
|
"prepublishOnly": "pnpm run assert-publish-target && pnpm run gen:skills-manifest && pnpm run build"
|
|
60
113
|
},
|
|
61
114
|
"devDependencies": {
|
|
115
|
+
"@arethetypeswrong/cli": "^0.18.2",
|
|
62
116
|
"@kit/tsconfig": "workspace:*",
|
|
63
117
|
"@types/node": "catalog:",
|
|
64
118
|
"openapi-typescript": "^7.6.0",
|
|
119
|
+
"publint": "^0.3.12",
|
|
65
120
|
"tsup": "^8.4.0",
|
|
66
121
|
"tsx": "^4.19.2",
|
|
67
122
|
"vitest": "^2.1.9"
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cavuno-board-account
|
|
3
|
+
description: Candidate account self-service with the @cavuno/board SDK — me.retrieve/delete, the candidate profile (merge-patch update, handle availability), experience/education CRUD, skills/languages full-replace, avatar upload, resume upload with async parse polling, and notification preferences plus the anonymous token unsubscribe. Use when building account, profile-edit, onboarding, or notification-settings pages for a signed-in board user.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Candidate account self-service
|
|
7
|
+
|
|
8
|
+
Everything the signed-in candidate manages about themselves lives under `board.me`. Every call here requires a board-user bearer token — in the browser the SDK reads it from `auth.storage` after login; on the server pass it per call via `options.headers` (see `cavuno-board-auth` for the httpOnly-cookie pattern). The one exception is the anonymous token unsubscribe at the end.
|
|
9
|
+
|
|
10
|
+
## When to use
|
|
11
|
+
|
|
12
|
+
- Account page: show the signed-in user, delete the account.
|
|
13
|
+
- Profile editor: bio/headline/handle, experience, education, skills, languages, avatar.
|
|
14
|
+
- Resume-driven onboarding (upload → parse → auto-populated profile).
|
|
15
|
+
- Email notification settings + the unsubscribe link in emails.
|
|
16
|
+
|
|
17
|
+
## When not to use
|
|
18
|
+
|
|
19
|
+
- Login/registration/tokens — `cavuno-board-auth`.
|
|
20
|
+
- Applications and saved jobs — `cavuno-board-applications`.
|
|
21
|
+
- The employer facet (`me.companies.*`), messaging (`me.conversations.*`), job alerts (`me.alerts.*`).
|
|
22
|
+
|
|
23
|
+
Out of scope — do not invent exports: the SDK ships no form components, file-picker UI, or upload widgets; the host app owns all UI and cookie plumbing.
|
|
24
|
+
|
|
25
|
+
## Account: retrieve and delete
|
|
26
|
+
|
|
27
|
+
```ts snippet
|
|
28
|
+
const me = await board.me.retrieve(); // the authenticated BoardUser
|
|
29
|
+
await board.me.delete(); // 204 — synchronous, irreversible cascade
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`delete` removes the account and all dependent data (profile, collections, saved jobs, alerts, avatar/resume files). Gate it behind an explicit confirmation.
|
|
33
|
+
|
|
34
|
+
## Profile: retrieve, update, handle availability
|
|
35
|
+
|
|
36
|
+
The profile is a singleton. `update` is a merge-patch — omitted fields stay unchanged.
|
|
37
|
+
|
|
38
|
+
```ts snippet
|
|
39
|
+
const profile = await board.me.profile.retrieve();
|
|
40
|
+
profile.handle; profile.headline; profile.jobSearchStatus;
|
|
41
|
+
|
|
42
|
+
await board.me.profile.update({
|
|
43
|
+
headline: 'Staff Engineer',
|
|
44
|
+
jobSearchStatus: 'open_to_offers',
|
|
45
|
+
profileVisibility: 'public',
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const { available } = await board.me.profile.handleAvailable('jane');
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`handleAvailable` is advisory (my current handle counts as available); `update` re-checks on write.
|
|
52
|
+
|
|
53
|
+
## Experience and education: per-entry CRUD
|
|
54
|
+
|
|
55
|
+
Both collections are id-keyed CRUD; updates are merge-patch. `createExperience` requires `title`, `companyName`, `startDate`; `createEducation` requires `institutionName`.
|
|
56
|
+
|
|
57
|
+
```ts snippet
|
|
58
|
+
const { data: entries } = await board.me.profile.listExperience();
|
|
59
|
+
const entry = await board.me.profile.createExperience({
|
|
60
|
+
title: 'Staff Engineer',
|
|
61
|
+
companyName: 'Acme',
|
|
62
|
+
startDate: '2022-01',
|
|
63
|
+
});
|
|
64
|
+
await board.me.profile.updateExperience(entry.id, { endDate: '2025-06' });
|
|
65
|
+
await board.me.profile.deleteExperience(entry.id);
|
|
66
|
+
// Education mirrors this: listEducation / createEducation /
|
|
67
|
+
// updateEducation(id, body) / deleteEducation(id).
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Skills and languages: PUT full-replace
|
|
71
|
+
|
|
72
|
+
`updateSkills` / `updateLanguages` replace the **whole set** — there is no add/remove-one endpoint. Sending only the new item wipes the rest. Round-trip the current list:
|
|
73
|
+
|
|
74
|
+
```ts snippet
|
|
75
|
+
const current = await board.me.profile.listSkills();
|
|
76
|
+
await board.me.profile.updateSkills({
|
|
77
|
+
skills: [...current.data.map((s) => s.name), 'TypeScript'], // ordered names
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await board.me.profile.updateLanguages({
|
|
81
|
+
languages: [{ name: 'English', proficiency: 'native' }],
|
|
82
|
+
});
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Both return the full updated list envelope, so you can render straight from the response.
|
|
86
|
+
|
|
87
|
+
## Avatar upload
|
|
88
|
+
|
|
89
|
+
Pass a `Blob`/`File` — the SDK builds the multipart `FormData` (field `file`). JPEG/PNG/WebP, ≤ 5 MB.
|
|
90
|
+
|
|
91
|
+
```ts snippet
|
|
92
|
+
const { avatarUrl } = await board.me.profile.uploadAvatar(file);
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Resume: upload, then poll the parse
|
|
96
|
+
|
|
97
|
+
`upload` starts an **async parse** that auto-populates the profile. It returns the resume with `parseStatus: 'parsing'` — poll `retrieve()` until `parsed` or `failed`, then re-read `profile.*` for the populated fields. Options: `keepResumeOnFile`, `importMode: 'append_only' | 'replace_all'`, `confirmReplaceAll`.
|
|
98
|
+
|
|
99
|
+
```ts snippet
|
|
100
|
+
let resume = await board.me.resume.upload(file, { keepResumeOnFile: true });
|
|
101
|
+
// Bounded poll — a stuck parse job must not hang the UI forever.
|
|
102
|
+
const MAX_POLLS = 30; // ~60s at 2s cadence
|
|
103
|
+
for (let i = 0; resume.parseStatus === 'parsing' && i < MAX_POLLS; i++) {
|
|
104
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
105
|
+
resume = await board.me.resume.retrieve();
|
|
106
|
+
}
|
|
107
|
+
if (resume.parseStatus === 'parsed') {
|
|
108
|
+
const profile = await board.me.profile.retrieve(); // auto-populated
|
|
109
|
+
} else if (resume.parseStatus === 'failed') {
|
|
110
|
+
resume.parseFailureReason; // surface it
|
|
111
|
+
} else {
|
|
112
|
+
// still 'parsing' after the window: show "taking longer than usual",
|
|
113
|
+
// keep the manual profile editor usable — never block on the parse.
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`resume.file` holds the stored file (`url` is a short-lived signed download URL). `parseStatus` is `null` when no parse has run. `board.me.resume.delete()` removes the stored file and withdraws keep-on-file consent — parsed profile fields stay.
|
|
118
|
+
|
|
119
|
+
## Notification preferences + anonymous unsubscribe
|
|
120
|
+
|
|
121
|
+
Two channels today: `messageEmails` and `applicationEmails`. `update` toggles one and returns the full updated set.
|
|
122
|
+
|
|
123
|
+
```ts snippet
|
|
124
|
+
const { data: prefs } = await board.me.notificationPreferences.retrieve();
|
|
125
|
+
await board.me.notificationPreferences.update({
|
|
126
|
+
channel: 'messageEmails',
|
|
127
|
+
subscribed: false,
|
|
128
|
+
});
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
The one-click unsubscribe link in emails is **anonymous** — no session; the HMAC token is the authorization. All three fields come from the link's query string; resolves void (204):
|
|
132
|
+
|
|
133
|
+
```ts snippet
|
|
134
|
+
await board.me.notificationPreferences.unsubscribeWithToken({
|
|
135
|
+
boardUserId,
|
|
136
|
+
channel: 'applicationEmails',
|
|
137
|
+
token,
|
|
138
|
+
});
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Verify
|
|
142
|
+
|
|
143
|
+
- [ ] A profile `update` followed by `retrieve` shows the new field values.
|
|
144
|
+
- [ ] Adding a skill preserves the existing ones (the full-replace round-trip is in place).
|
|
145
|
+
- [ ] After `uploadAvatar`, the returned `avatarUrl` renders.
|
|
146
|
+
- [ ] Resume upload reaches `parsed` (or surfaces `failed` + `parseFailureReason`), and the profile shows parsed data.
|
|
147
|
+
- [ ] The email unsubscribe link works in a logged-out browser.
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cavuno-board-applications
|
|
3
|
+
description: The native apply flow with the @cavuno/board SDK — jobs.apply (signed-in or guest), attaching a resume via jobs.uploadApplicationResume, the jobs.myApplication "have I applied?" check, managing submitted applications (me.applications list/retrieve/updateFacts/withdraw), and saved jobs (me.savedJobs). Use when building an apply form, an application-tracker page, or a save-job button.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Apply flow and application tracking
|
|
7
|
+
|
|
8
|
+
Apply lives on the job (`board.jobs.apply` — optional-auth); everything after submission lives under `board.me.applications` (bearer token required, see `cavuno-board-auth`). Saved jobs follow the same authenticated pattern.
|
|
9
|
+
|
|
10
|
+
## When to use
|
|
11
|
+
|
|
12
|
+
- The apply form on a job page (native apply, signed-in or guest).
|
|
13
|
+
- Attaching a resume to an application.
|
|
14
|
+
- "My applications" tracker: list, detail, edit facts, withdraw.
|
|
15
|
+
- Save/unsave-job buttons and the saved-jobs page.
|
|
16
|
+
|
|
17
|
+
## When not to use
|
|
18
|
+
|
|
19
|
+
- Jobs whose `applicationUrl` points off-board (external apply) — just link out.
|
|
20
|
+
- The employer's pipeline view of the same data (`me.companies.applicants.*`).
|
|
21
|
+
- Candidate profile/resume self-service — `cavuno-board-account`.
|
|
22
|
+
|
|
23
|
+
Out of scope — do not invent exports: the SDK provides no apply-form components, file-picker UI, or auth cookie plumbing — the host app owns those. There is also **no guest-claim method** on the SDK: after a guest signs up, their applications are associated via a server-driven magic-link email flow, not an SDK call.
|
|
24
|
+
|
|
25
|
+
## Submit an application
|
|
26
|
+
|
|
27
|
+
`jobs.apply` is optional-auth on one URL. Signed in, name/email derive from the candidate profile — send at most a `coverNote`. A guest supplies `name` + `email` (allowed only when the board permits applying without sign-up; otherwise the call fails). Idempotent — a repeat apply returns the existing application.
|
|
28
|
+
|
|
29
|
+
```ts snippet
|
|
30
|
+
// Signed-in candidate:
|
|
31
|
+
const application = await board.jobs.apply('senior-chef', {
|
|
32
|
+
coverNote: 'Excited to cook here.',
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Guest (board must allow applications without sign-up):
|
|
36
|
+
const guestApp = await board.jobs.apply('senior-chef', {
|
|
37
|
+
name: 'Ada Lovelace',
|
|
38
|
+
email: 'ada@example.com',
|
|
39
|
+
});
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Attach a resume
|
|
43
|
+
|
|
44
|
+
A separate multipart call — pass a `Blob`/`File`; the SDK builds the `FormData`. A signed-in candidate targets their own application for the job; a guest passes the `applicationId` returned by `apply`. Returns the updated application (`resumeFilename` set).
|
|
45
|
+
|
|
46
|
+
```ts snippet
|
|
47
|
+
// Signed-in:
|
|
48
|
+
await board.jobs.uploadApplicationResume('senior-chef', file);
|
|
49
|
+
// Guest:
|
|
50
|
+
await board.jobs.uploadApplicationResume('senior-chef', file, {
|
|
51
|
+
applicationId: guestApp.id,
|
|
52
|
+
});
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## "Have I applied?" — the apply-button state
|
|
56
|
+
|
|
57
|
+
`jobs.myApplication` returns the authenticated candidate's application for the job, and **throws a 404 `BoardApiError` when there is none** — branch with `isNotFound`:
|
|
58
|
+
|
|
59
|
+
```ts snippet
|
|
60
|
+
import { isNotFound } from '@cavuno/board';
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const mine = await board.jobs.myApplication('senior-chef');
|
|
64
|
+
// already applied — show status instead of the apply button
|
|
65
|
+
} catch (err) {
|
|
66
|
+
if (!isNotFound(err)) throw err;
|
|
67
|
+
// not applied yet — show the apply form
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Application status — polled, not realtime
|
|
72
|
+
|
|
73
|
+
`Application.status` is a coarse candidate-facing state **derived from the employer's pipeline stage**: `'applied' | 'interviewing' | 'negotiation' | 'hired' | 'archived'`. It changes server-side as the employer moves the applicant — the surface is plain REST, so re-fetch to observe updates; nothing pushes to the client. `job` on the application is nullable (the job may have been removed).
|
|
74
|
+
|
|
75
|
+
## Manage my applications
|
|
76
|
+
|
|
77
|
+
```ts snippet
|
|
78
|
+
const { data, nextCursor } = await board.me.applications.list({ limit: 20 }); // newest first
|
|
79
|
+
const app = await board.me.applications.retrieve(applicationId);
|
|
80
|
+
|
|
81
|
+
// Merge-patch the candidate-facing facts — only while still editable:
|
|
82
|
+
await board.me.applications.updateFacts(applicationId, {
|
|
83
|
+
coverNote: 'Updated after our call.',
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
await board.me.applications.withdraw(applicationId); // 204 — permanently deletes
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`updateFacts` accepts `candidateName`, `candidateEmail`, `candidateHeadline`, `candidateLocation`, `coverNote` (all optional). Withdraw is permanent deletion, not a status flip — confirm before calling.
|
|
90
|
+
|
|
91
|
+
## Saved jobs — the adjacent pattern
|
|
92
|
+
|
|
93
|
+
Same authenticated `me.*` shape. Each saved row embeds the full `PublicJob`, so the saved-jobs page renders without extra fetches.
|
|
94
|
+
|
|
95
|
+
```ts snippet
|
|
96
|
+
const saved = await board.me.savedJobs.list({ limit: 20 });
|
|
97
|
+
saved.data[0]?.job.title; // full PublicJob embedded
|
|
98
|
+
|
|
99
|
+
await board.me.savedJobs.save({ jobId: job.id }); // converges — re-saving returns the same row
|
|
100
|
+
await board.me.savedJobs.unsave(job.id); // idempotent — unknown ids still 204
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Verify
|
|
104
|
+
|
|
105
|
+
- [ ] Signed-in apply sends no name/email; guest apply sends both.
|
|
106
|
+
- [ ] A second apply to the same job returns the same application id (no duplicate).
|
|
107
|
+
- [ ] After `uploadApplicationResume`, the application's `resumeFilename` is set.
|
|
108
|
+
- [ ] The apply button flips state via `myApplication` + `isNotFound`, not a local flag.
|
|
109
|
+
- [ ] Withdraw removes the row from `me.applications.list` on re-fetch.
|
|
110
|
+
- [ ] Unsave leaves the saved-jobs list consistent even when clicked twice.
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cavuno-board-blog
|
|
3
|
+
description: Build the blog with the @cavuno/board SDK — post archives (blog.posts.list with tagSlug/authorSlug/featured), the post page (blog.posts.retrieve + adjacent + similar), blog.tags and blog.authors, and free-text blog.search. Covers the summary-vs-detail split (html on the single read only), cursor pagination, slug-change redirects (redirected/newSlug), and the SEO fields the API actually returns.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Blog: posts, tags, authors, search
|
|
7
|
+
|
|
8
|
+
Every list and search returns `PublicBlogPostSummary`; only `posts.retrieve` returns the full `PublicBlogPost` with the `html` body.
|
|
9
|
+
|
|
10
|
+
## When to use
|
|
11
|
+
|
|
12
|
+
- The blog index, tag archives, author archives, and featured rails.
|
|
13
|
+
- The post page: body, prev/next nav, related-posts rail.
|
|
14
|
+
- Blog search boxes.
|
|
15
|
+
|
|
16
|
+
## When not to use
|
|
17
|
+
|
|
18
|
+
- Writing posts/tags/authors — that is the admin API; this SDK is the public read surface.
|
|
19
|
+
- Deciding whether the board has a blog at all — read `features.blog` from the board context.
|
|
20
|
+
|
|
21
|
+
Out of scope — do not invent exports: no RSS feed, sitemap, or OG-image generation — the host app builds those routes from the data here (`publishedAt`, `canonicalUrl`, `ogImageUrl` are all returned).
|
|
22
|
+
|
|
23
|
+
## List posts and archives
|
|
24
|
+
|
|
25
|
+
`blog.posts.list` returns a `ListEnvelope<PublicBlogPostSummary>`. `BlogPostsListQuery` supports `limit` (1–100), `cursor`, `tagSlug`, `authorSlug`, and `featured: 'true'` (opt-in only — the string literal, not a boolean). Blog lists page by cursor only; there is no `offset` and no total `count`.
|
|
26
|
+
|
|
27
|
+
```ts snippet
|
|
28
|
+
const page = await board.blog.posts.list({ tagSlug: 'news', limit: 12 });
|
|
29
|
+
for (const post of page.data) {
|
|
30
|
+
post.title;
|
|
31
|
+
post.customExcerpt; // null unless the author wrote one
|
|
32
|
+
post.coverUrl; // null when no cover image
|
|
33
|
+
post.readingTimeMin;
|
|
34
|
+
post.authors; // embedded: id, name, slug, bio, avatarUrl, social URLs
|
|
35
|
+
post.tags; // embedded: id, name, slug, description
|
|
36
|
+
}
|
|
37
|
+
const next = page.nextCursor
|
|
38
|
+
? await board.blog.posts.list({ tagSlug: 'news', limit: 12, cursor: page.nextCursor })
|
|
39
|
+
: null; // nextCursor is null when hasMore is false
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Render a post
|
|
43
|
+
|
|
44
|
+
`posts.retrieve` adds the detail-only fields: `html` (the rendered rich-text body, nullable), `ogImageUrl`, `featureImageCaption`, `seoTitle`, `seoDescription`, `redirected`, `newSlug`.
|
|
45
|
+
|
|
46
|
+
```ts snippet
|
|
47
|
+
const post = await board.blog.posts.retrieve('hello-world');
|
|
48
|
+
if (post.redirected && post.newSlug) {
|
|
49
|
+
// the slug changed — redirect to the post at newSlug instead of rendering here
|
|
50
|
+
}
|
|
51
|
+
post.html; // inject as the article body
|
|
52
|
+
post.seoTitle ?? post.title; // <title>
|
|
53
|
+
post.seoDescription ?? post.customExcerpt; // meta description
|
|
54
|
+
post.canonicalUrl; // author-set canonical override, or null
|
|
55
|
+
post.ogImageUrl ?? post.coverUrl; // social card image
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Anti-pattern: don't render a post at a stale URL. When `redirected` is true, issue a redirect to `newSlug` — old slugs keep resolving, but the canonical location moved.
|
|
59
|
+
|
|
60
|
+
Anti-pattern: don't look for `html` on list items. Summaries never carry it; fetch `retrieve` for the post page only.
|
|
61
|
+
|
|
62
|
+
## Prev/next and related
|
|
63
|
+
|
|
64
|
+
```ts snippet
|
|
65
|
+
const { previous, next } = await board.blog.posts.adjacent('hello-world');
|
|
66
|
+
// previous = older post, next = newer; each a summary or null
|
|
67
|
+
|
|
68
|
+
const rail = await board.blog.posts.similar('hello-world', { limit: 6 }); // 1–20, default 6
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Tags and authors
|
|
72
|
+
|
|
73
|
+
Both are list + retrieve-by-slug. `PublicBlogTag`: `id`, `name`, `slug`, `description`. `PublicBlogAuthor`: `id`, `name`, `slug`, `bio`, `avatarUrl`, `websiteUrl`, `twitterUrl`, `linkedinUrl`, `githubUrl`.
|
|
74
|
+
|
|
75
|
+
```ts snippet
|
|
76
|
+
const { data: tags } = await board.blog.tags.list();
|
|
77
|
+
const tag = await board.blog.tags.retrieve('news');
|
|
78
|
+
const { data: authors } = await board.blog.authors.list();
|
|
79
|
+
const author = await board.blog.authors.retrieve('jane');
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Archive pages combine the two: `tags.retrieve` for the header, `posts.list({ tagSlug })` for the posts.
|
|
83
|
+
|
|
84
|
+
## Search
|
|
85
|
+
|
|
86
|
+
`blog.search` posts a `BlogSearchBody` (`query` up to 200 chars, optional `cursor`, `limit` 1–50 — note the lower cap than lists) and returns a `SearchEnvelope<PublicBlogPostSummary>`:
|
|
87
|
+
|
|
88
|
+
```ts snippet
|
|
89
|
+
const results = await board.blog.search({ query: 'launch', limit: 10 });
|
|
90
|
+
results.data[0]?.slug;
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Verify
|
|
94
|
+
|
|
95
|
+
- A list item has no `html` key; `posts.retrieve` of the same slug returns it (string or null).
|
|
96
|
+
- Retrieving a post by an old slug returns `redirected: true` with `newSlug` set, and your route redirects there.
|
|
97
|
+
- `posts.list({ featured: 'true' })` returns only posts with `featured: true`.
|
|
98
|
+
- Cursor paging terminates: `nextCursor` is `null` on the last page.
|
|
99
|
+
- `adjacent` on the newest post returns `next: null`; on the oldest, `previous: null`.
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cavuno-board-companies
|
|
3
|
+
description: Build company surfaces with the @cavuno/board SDK — the companies index (companies.list / companies.search with the market filter), the company profile (companies.retrieve, listJobs, similar), the markets sub-surface (companies.markets + markets.resolve with its 308 redirectTo), and company salary pages (companies.salaries + salaries.category). Covers PublicCompany vs PublicCompanyDetail and envelope pagination.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Companies: index, profile, markets, salaries
|
|
7
|
+
|
|
8
|
+
Lists and search return `PublicCompany`; only `retrieve` returns `PublicCompanyDetail` (which adds `markets`). Company salary pages are their own sub-surface under `companies.salaries`.
|
|
9
|
+
|
|
10
|
+
## When to use
|
|
11
|
+
|
|
12
|
+
- The companies index page and its market (sector) filter.
|
|
13
|
+
- The company profile page: detail, open-jobs list, similar-companies rail.
|
|
14
|
+
- Per-company salary pages (`/companies/:slug/salaries[/:category]`).
|
|
15
|
+
|
|
16
|
+
## When not to use
|
|
17
|
+
|
|
18
|
+
- Board-wide job browse/search — `jobs.*` (see cavuno-board-jobs).
|
|
19
|
+
- Board-wide salary hubs (titles/skills/locations) — `board.salaries.*`.
|
|
20
|
+
- Employer self-service (claiming/managing a company) — the authed `board.me.companies.*` surface.
|
|
21
|
+
|
|
22
|
+
Out of scope — do not invent exports: no company create/update/logo upload (that is the admin API, not this SDK), and no sitemap or OG-image generation — the host app owns those routes.
|
|
23
|
+
|
|
24
|
+
## List, search, market filter
|
|
25
|
+
|
|
26
|
+
`companies.list` returns a `CompanyListEnvelope`: `ListEnvelope<PublicCompany>` plus optional `relatedSearches` (market suggestions). `CompaniesListQuery` supports `limit` (1–100), `cursor`, `offset` (takes precedence over `cursor`; pair with the response `count` to page in parallel), and `marketSlug` — unknown market slugs 404.
|
|
27
|
+
|
|
28
|
+
```ts snippet
|
|
29
|
+
const page = await board.companies.list({ limit: 20, marketSlug: 'cybersecurity' });
|
|
30
|
+
page.hasMore;
|
|
31
|
+
page.nextCursor; // null when hasMore is false
|
|
32
|
+
for (const company of page.data) {
|
|
33
|
+
company.name;
|
|
34
|
+
company.publishedJobCount;
|
|
35
|
+
company.links.public; // canonical URL, or null when the company lacks a slug
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`companies.search` posts a `CompaniesSearchBody` (`query` free text matched against the company name, up to 200 chars; optional `marketSlug`, `cursor`, `limit` 1–100) and returns a `SearchEnvelope<PublicCompany>`:
|
|
40
|
+
|
|
41
|
+
```ts snippet
|
|
42
|
+
const results = await board.companies.search({ query: 'acme', limit: 20 });
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Markets (sectors)
|
|
46
|
+
|
|
47
|
+
`companies.markets` is callable AND carries `.resolve`. The call lists the board's markets ranked by company count (`CompanyMarket`: `slug`, `name`, `companyCount`; `limit` 1–200, default 100 — a top-N preview; optional `search`). `.resolve(slug)` returns a `TaxonomyResolution` — `sourceSlug`, `canonicalSlug`, `displayName`, and `redirectTo` (the canonical slug to 308 to when the inbound slug differs; `null` otherwise).
|
|
48
|
+
|
|
49
|
+
```ts snippet
|
|
50
|
+
const { data: markets } = await board.companies.markets({ search: 'robotics' });
|
|
51
|
+
const market = await board.companies.markets.resolve('cybersecurity');
|
|
52
|
+
if (market.redirectTo) {
|
|
53
|
+
// 308 the market-scoped browse to the canonical slug
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Resolve first, then pass the resolved slug as `marketSlug` — don't guess slugs (unknown ones 404).
|
|
58
|
+
|
|
59
|
+
## Profile: retrieve, jobs, similar
|
|
60
|
+
|
|
61
|
+
`PublicCompany` (lists/search) carries: `id`, `name`, `slug`, `website`, `logoUrl`, `description`, `jobCount`, `publishedJobCount`, `links.public`. `PublicCompanyDetail` (retrieve only) adds `markets: CompanyMarketRef[]` (`name` + source `slug`; empty when none).
|
|
62
|
+
|
|
63
|
+
```ts snippet
|
|
64
|
+
const company = await board.companies.retrieve('acme'); // PublicCompanyDetail
|
|
65
|
+
company.markets; // detail-only
|
|
66
|
+
|
|
67
|
+
const jobs = await board.companies.listJobs('acme', { limit: 10 });
|
|
68
|
+
// JobCardListEnvelope — same PublicJobCard shape as jobs.list; cursor + limit only
|
|
69
|
+
|
|
70
|
+
const rail = await board.companies.similar('acme', { limit: 6 }); // 1–20, default 6
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`similar` uses the hosted ranking (most open roles first) and excludes the company itself.
|
|
74
|
+
|
|
75
|
+
Anti-pattern: don't render `markets` from a list row — it only exists on the detail. Fetch `retrieve` for the profile page.
|
|
76
|
+
|
|
77
|
+
## Company salaries
|
|
78
|
+
|
|
79
|
+
`companies.salaries` is callable AND carries `.category`. The call is the company salary overview (`CompanySalary`): `overallSalary` (nullable), `bySeniority` rows vs the board baseline (`diffPercent`), `competitors`, `topLocations`, `byCategory`, board-wide baselines, and `currency`. `.category(companySlug, categorySlug, { locale })` is one job category at the company (`CompanyCategorySalary`).
|
|
80
|
+
|
|
81
|
+
```ts snippet
|
|
82
|
+
const overview = await board.companies.salaries('acme');
|
|
83
|
+
overview.bySeniority[0]?.diffPercent; // vs the board baseline, or null
|
|
84
|
+
|
|
85
|
+
const cat = await board.companies.salaries.category('acme', 'software-engineer', {
|
|
86
|
+
locale: 'de',
|
|
87
|
+
});
|
|
88
|
+
cat.categorySourceSlug; // immutable English slug
|
|
89
|
+
cat.categoryCanonicalSlug; // board-language slug — 308 to it when the inbound differs
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Pass `{ locale }` for board-language category names; the company slug/name are never localized. The response returns BOTH `categorySourceSlug` and `categoryCanonicalSlug` — the consumer issues the 308 itself.
|
|
93
|
+
|
|
94
|
+
## Verify
|
|
95
|
+
|
|
96
|
+
- `companies.retrieve('<known-slug>')` returns `object: 'public_company'` with a `markets` array; the same company in `companies.list` has no `markets` key.
|
|
97
|
+
- A slug from `companies.markets()` works as `marketSlug`; a made-up slug returns 404 (`isNotFound`).
|
|
98
|
+
- Paginating with `nextCursor` terminates: `nextCursor` is `null` on the last page.
|
|
99
|
+
- `companies.salaries.category(...)` with a non-canonical category slug returns a differing `categoryCanonicalSlug`, and your route 308s to it.
|