@cavuno/board 1.24.0 → 1.26.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 +92 -3825
- package/dist/index.d.ts +92 -3825
- package/dist/index.js +154 -7
- package/dist/index.mjs +151 -4
- package/dist/jobs-CM67_J6H.d.mts +3836 -0
- package/dist/jobs-CM67_J6H.d.ts +3836 -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 +37 -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-setup/SKILL.md +26 -2
- package/skills/cavuno-board-smoke-test/SKILL.md +84 -0
- package/skills/cavuno-board-theme/SKILL.md +69 -0
- package/skills/manifest.json +92 -1
|
@@ -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.
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cavuno-board-filters
|
|
3
|
+
description: The canonical listing-filter vocabulary and URL search-param parsing with @cavuno/board/filters — remote/employment/seniority/sort sets, localized seniority labels, and parseListingFilters for jobs-listing routes. Use when building listing pages, filter sidebars, sort dropdowns, or validating listing URL params.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Filters: vocabulary + URL parsing
|
|
7
|
+
|
|
8
|
+
`@cavuno/board/filters` is the one filter vocabulary every tenant frontend
|
|
9
|
+
shares — the seniority set is golden-tested against the platform source, and
|
|
10
|
+
URL parsing follows the hosted semantics (hand-typed URLs are messy; parsing
|
|
11
|
+
never throws).
|
|
12
|
+
|
|
13
|
+
## When to use
|
|
14
|
+
|
|
15
|
+
- The jobs index and every programmatic listing page (category / skill /
|
|
16
|
+
location) — they all layer the same cross-cutting filters on any seed.
|
|
17
|
+
- `validateSearch`-style route param validation (server-safe, SSR-ready).
|
|
18
|
+
|
|
19
|
+
## When not to use
|
|
20
|
+
|
|
21
|
+
- Turning values into card display text — `cavuno-board-format`.
|
|
22
|
+
- Search QUERIES (`jobs.search` bodies) — this module is about the listing
|
|
23
|
+
URL contract, not the search POST body.
|
|
24
|
+
|
|
25
|
+
## Parse listing URLs
|
|
26
|
+
|
|
27
|
+
```ts snippet
|
|
28
|
+
import { parseListingFilters, DEFAULT_SORT } from '@cavuno/board/filters';
|
|
29
|
+
|
|
30
|
+
const filters = parseListingFilters(rawSearchParams);
|
|
31
|
+
// { q?, remoteOption?, employmentType?, seniority?: Seniority[], sort? }
|
|
32
|
+
|
|
33
|
+
const page = await board.jobs.list({
|
|
34
|
+
limit: 20,
|
|
35
|
+
seniority: filters.seniority,
|
|
36
|
+
remoteOption: filters.remoteOption ? [filters.remoteOption] : undefined,
|
|
37
|
+
employmentType: filters.employmentType ? [filters.employmentType] : undefined,
|
|
38
|
+
});
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Unknown values are dropped silently (public URLs, never throw). Seniority
|
|
42
|
+
accepts repeated params or a comma-string and normalizes with the hosted
|
|
43
|
+
rules: trim, lowercase, dedupe, keep order.
|
|
44
|
+
|
|
45
|
+
## Render the filter UI from the vocabulary
|
|
46
|
+
|
|
47
|
+
```ts snippet
|
|
48
|
+
import {
|
|
49
|
+
REMOTE_OPTIONS,
|
|
50
|
+
EMPLOYMENT_TYPES,
|
|
51
|
+
SENIORITIES,
|
|
52
|
+
JOB_SORTS,
|
|
53
|
+
seniorityLabels,
|
|
54
|
+
sortLabels,
|
|
55
|
+
} from '@cavuno/board/filters';
|
|
56
|
+
|
|
57
|
+
const { language } = await board.context();
|
|
58
|
+
const labels = seniorityLabels(language); // de: executive → "Führungskraft"
|
|
59
|
+
const sorts = sortLabels(language); // English defaults on every locale
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Seniority renders as a MULTI-select (hosted parity). `EMPLOYMENT_TYPES`
|
|
63
|
+
deliberately offers 5 of the 7 wire values (`volunteer`/`other` exist on
|
|
64
|
+
jobs but are not filter options). `JOB_SORTS` deliberately excludes
|
|
65
|
+
`oldest` (ADR-0048); `relevance` is the featured-ranked default.
|
|
66
|
+
|
|
67
|
+
## Anti-patterns
|
|
68
|
+
|
|
69
|
+
```ts no-check
|
|
70
|
+
// NEVER hand-roll the vocab per page — one page offering 'oldest' or a
|
|
71
|
+
// 6th employment type diverges from every other board frontend:
|
|
72
|
+
const sorts = ['relevance', 'newest', 'oldest'];
|
|
73
|
+
// NEVER trust raw search params into the SDK query:
|
|
74
|
+
board.jobs.list({ seniority: rawSearch.seniority });
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Out of scope — do not invent exports
|
|
78
|
+
|
|
79
|
+
No URL BUILDER (routes are app-owned — serialize with your router), no
|
|
80
|
+
category/skill/location taxonomy lists (those come from the API:
|
|
81
|
+
`board.taxonomy.*`, `jobs.list` filters), no saved-filter persistence.
|
|
82
|
+
|
|
83
|
+
## Verify
|
|
84
|
+
|
|
85
|
+
- [ ] `/jobs?seniority=Senior,%20lead&sort=oldest` renders the senior+lead
|
|
86
|
+
selection and the default sort — no crash, no leaked invalid value.
|
|
87
|
+
- [ ] The seniority multi-select shows all 8 levels, labeled in the board
|
|
88
|
+
language.
|
|
89
|
+
- [ ] The sort dropdown offers exactly relevance / newest / salary_high.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cavuno-board-format
|
|
3
|
+
description: Display formatting for board frontends with @cavuno/board/format — salary ranges, seniority/employment/remote labels, location labels, dates, and the board-language salary lexicon. Use when rendering job cards, detail pages, or salary figures, and whenever a value like salaryMin/salaryTimeframe/seniority needs to become display text.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Format: board-language display text
|
|
7
|
+
|
|
8
|
+
`@cavuno/board/format` turns wire values into the exact display text the
|
|
9
|
+
hosted board renders — golden-tested against the hosted formatters, so a
|
|
10
|
+
tenant frontend cannot drift from the platform's rendering rules.
|
|
11
|
+
|
|
12
|
+
## When to use
|
|
13
|
+
|
|
14
|
+
- Rendering salary ranges, dates, locations, or enum labels anywhere.
|
|
15
|
+
- Building salary-page metadata sentences (the lexicon frames).
|
|
16
|
+
|
|
17
|
+
## When not to use
|
|
18
|
+
|
|
19
|
+
- Filter vocabulary and URL parsing — `cavuno-board-filters`.
|
|
20
|
+
- Money math. Never compute or convert amounts client-side; format what the
|
|
21
|
+
wire provides.
|
|
22
|
+
|
|
23
|
+
## The board language is a required first argument
|
|
24
|
+
|
|
25
|
+
Every label-producing helper takes the board language — read it once from
|
|
26
|
+
context. There is no `en` default: a German-native board must never
|
|
27
|
+
silently render English.
|
|
28
|
+
|
|
29
|
+
```ts snippet
|
|
30
|
+
import {
|
|
31
|
+
formatSalaryRange,
|
|
32
|
+
formatPublishedRelativeDate,
|
|
33
|
+
formatDate,
|
|
34
|
+
fieldLabel,
|
|
35
|
+
} from '@cavuno/board/format';
|
|
36
|
+
|
|
37
|
+
const { language } = await board.context();
|
|
38
|
+
|
|
39
|
+
formatSalaryRange(language, job.salaryMin, job.salaryMax, job.salaryTimeframe, job.salaryCurrency);
|
|
40
|
+
// en: "$90K – $120K Yearly" · de: "90.000 € – 120.000 € pro Jahr"
|
|
41
|
+
formatPublishedRelativeDate(language, job.publishedAt); // "5d ago" — what job CARDS render
|
|
42
|
+
formatDate(language, job.publishedAt); // "Jun 24, 2026" — blog metadata / detail facts (UTC-pinned)
|
|
43
|
+
fieldLabel(language, job.seniority); // de: 'executive' → "Führungskraft"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Job cards and the job-detail header show the RELATIVE form; the absolute
|
|
47
|
+
medium date is for blog metadata and detail fact rows — matching hosted.
|
|
48
|
+
|
|
49
|
+
Localization matches hosted exactly: seniority + timeframe words are
|
|
50
|
+
localized (en+de lexicon, other locales fall back to English words with
|
|
51
|
+
locale-correct number formatting); employment/remote labels are English on
|
|
52
|
+
every board because hosted ships no other vocabulary yet.
|
|
53
|
+
|
|
54
|
+
## Location labels
|
|
55
|
+
|
|
56
|
+
Cards carry server-computed labels — use them via `cardLocationLabel`. Only
|
|
57
|
+
the full `PublicJob` needs client-side derivation:
|
|
58
|
+
|
|
59
|
+
```ts snippet
|
|
60
|
+
import { cardLocationLabel, locationLabel, fullJobToCard } from '@cavuno/board/format';
|
|
61
|
+
|
|
62
|
+
cardLocationLabel(language, card); // "Remote · Europe" | "Berlin, Germany"
|
|
63
|
+
locationLabel(language, job); // full PublicJob (detail page)
|
|
64
|
+
const card = fullJobToCard(language, job); // embed a saved job in a card list
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Salary lexicon (metadata sentences)
|
|
68
|
+
|
|
69
|
+
`getSalaryLexicon(language)` exposes the words and the `<title>`/meta
|
|
70
|
+
sentence frames the hosted salary pages use:
|
|
71
|
+
|
|
72
|
+
```ts snippet
|
|
73
|
+
import { getSalaryLexicon } from '@cavuno/board/format';
|
|
74
|
+
|
|
75
|
+
const lexicon = getSalaryLexicon(language);
|
|
76
|
+
lexicon.frames.entitySalariesTitle({ entity: 'JavaScript', range: '$70K – $90K' });
|
|
77
|
+
// "JavaScript Salaries ($70K – $90K/yr)"
|
|
78
|
+
lexicon.seniority.senior; // "Senior"
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Anti-patterns
|
|
82
|
+
|
|
83
|
+
```ts no-check
|
|
84
|
+
// NEVER hand-format money — symbol placement and words are locale rules:
|
|
85
|
+
`$${(job.salaryMin / 1000).toFixed(0)}k`; // wrong on de boards
|
|
86
|
+
// NEVER default the locale:
|
|
87
|
+
formatSalaryRange('en', ...); // hardcoded 'en' on a de board
|
|
88
|
+
// NEVER rebuild card location labels from scratch — the wire pre-computes them.
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Out of scope — do not invent exports
|
|
92
|
+
|
|
93
|
+
No currency conversion, no relative-time ("3 days ago") helper, no
|
|
94
|
+
number-only formatter — the SDK formats exactly what the hosted board
|
|
95
|
+
formats. Sort-dropdown copy lives in `cavuno-board-filters`.
|
|
96
|
+
|
|
97
|
+
## Verify
|
|
98
|
+
|
|
99
|
+
- [ ] A de board renders "pro Jahr"/"ab"/"Führungskraft" where an en board
|
|
100
|
+
renders "Yearly"/"From"/"Executive" — same code path, different
|
|
101
|
+
`language`.
|
|
102
|
+
- [ ] An en board's salary strings are byte-identical to the hosted board's
|
|
103
|
+
for the same job.
|
|
104
|
+
- [ ] No call site passes a hardcoded locale string.
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cavuno-board-job-alerts
|
|
3
|
+
description: Build job-alert flows with the @cavuno/board SDK — the anonymous double-opt-in surface (jobAlerts.subscribe/confirm/resendConfirmation + HMAC-token manage/unsubscribe/resubscribe/updatePreference/deletePreference) and the authenticated board.me.alerts CRUD. Covers which surface to use when, how the manage token rides, which filters actually scope delivery, and the full-replace update trap.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Job alerts: two surfaces
|
|
7
|
+
|
|
8
|
+
Two distinct surfaces. **Anonymous** (`board.jobAlerts.*`): email capture with double opt-in; later edits authenticate with an HMAC manage token from the digest email. **Authenticated** (`board.me.alerts.*`): CRUD for a signed-in board user (bearer token), active immediately — the authenticated action is the consent. Both are gated on `board.context()` → `features.jobAlerts`.
|
|
9
|
+
|
|
10
|
+
## When to use
|
|
11
|
+
|
|
12
|
+
- Anonymous: the "email me new jobs" capture form for visitors, the confirm landing page, and the manage/unsubscribe page linked from digest emails.
|
|
13
|
+
- Authenticated: an alerts section in the signed-in account area (up to 10 alerts per user).
|
|
14
|
+
|
|
15
|
+
## When not to use
|
|
16
|
+
|
|
17
|
+
- Board-user sign-in itself — see `cavuno-board-auth`.
|
|
18
|
+
|
|
19
|
+
## Out of scope — do not invent exports
|
|
20
|
+
|
|
21
|
+
Only the methods and fields shown here exist. There is no pause/suspend: deactivation is `unsubscribe` (anonymous) or `remove` (authenticated).
|
|
22
|
+
|
|
23
|
+
## Anonymous: subscribe → confirm
|
|
24
|
+
|
|
25
|
+
`subscribe` requires `consent: true` (server-enforced) and sends a confirmation email. `confirm` always returns HTTP 200 — branch on `status`.
|
|
26
|
+
|
|
27
|
+
```ts snippet
|
|
28
|
+
const sub = await board.jobAlerts.subscribe({
|
|
29
|
+
email: 'ada@example.com',
|
|
30
|
+
consent: true,
|
|
31
|
+
frequency: 'weekly', // 'daily' | 'weekly'
|
|
32
|
+
filters: { jobFunctions: ['engineering'], remoteOptions: ['remote'], placeSlugs: ['berlin'] },
|
|
33
|
+
});
|
|
34
|
+
sub.status; // 'created' | 'duplicate'
|
|
35
|
+
sub.requiresConfirmation; // true when the opt-in email was sent
|
|
36
|
+
|
|
37
|
+
// Confirm page — token from the email link:
|
|
38
|
+
const res = await board.jobAlerts.confirm({ token });
|
|
39
|
+
res.status; // 'confirmed' | 'already_confirmed' | 'expired' | 'not_found'
|
|
40
|
+
if (res.status === 'expired') {
|
|
41
|
+
await board.jobAlerts.resendConfirmation({ email }); // status: 'sent' | 'throttled' | ...
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Filter caveat: only `jobFunctions`, `placeSlugs`, and `remoteOptions` scope the digest server-side; `seniorityLevels`/`salaryMin`/`salaryMax`/`salaryCurrency` are stored but do not filter delivery.
|
|
46
|
+
|
|
47
|
+
## Anonymous: the HMAC manage token
|
|
48
|
+
|
|
49
|
+
Digest emails carry a per-subscription HMAC token. **`manage` is a GET — the token rides as query params `{ subscription, token }`. All writes are POST/DELETE — the token rides in the JSON body as `{ subscriptionId, token }`** (note the different key: `subscription` on the query, `subscriptionId` in bodies). Each preference in the manage state also carries its own `manageToken`.
|
|
50
|
+
|
|
51
|
+
```ts snippet
|
|
52
|
+
// Manage page, from the email link's query string:
|
|
53
|
+
const state = await board.jobAlerts.manage({ subscription: subscriptionId, token });
|
|
54
|
+
state.email; state.confirmed; state.unsubscribed;
|
|
55
|
+
state.preferences; // [{ id, label, frequency, isActive, filters, manageToken }]
|
|
56
|
+
|
|
57
|
+
await board.jobAlerts.unsubscribe({ subscriptionId, token });
|
|
58
|
+
await board.jobAlerts.resubscribe({ subscriptionId, token });
|
|
59
|
+
await board.jobAlerts.deletePreference({ subscriptionId, preferenceId, token });
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
`unsubscribe`/`resubscribe` also accept an optional `preferenceId` to scope to one preference. `updatePreference` is a **full replace** — `frequency` is required every time; restate the filters you aren't editing or they reset:
|
|
63
|
+
|
|
64
|
+
```ts snippet
|
|
65
|
+
const pref = state.preferences[0];
|
|
66
|
+
await board.jobAlerts.updatePreference({
|
|
67
|
+
subscriptionId,
|
|
68
|
+
preferenceId: pref.id,
|
|
69
|
+
token,
|
|
70
|
+
frequency: 'daily', // required — restate even if unchanged
|
|
71
|
+
filters: pref.filters, // round-trip stored filters you aren't editing
|
|
72
|
+
});
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Authenticated: board.me.alerts
|
|
76
|
+
|
|
77
|
+
Bearer-authenticated CRUD. Alerts are active on create (no opt-in email). The `AlertBody` uses `placeIds` (not `placeSlugs`), and `frequency` is required.
|
|
78
|
+
|
|
79
|
+
```ts snippet
|
|
80
|
+
const { data: alerts } = await board.me.alerts.list();
|
|
81
|
+
alerts[0].isActive; alerts[0].lastSentAt; alerts[0].filters;
|
|
82
|
+
|
|
83
|
+
const alert = await board.me.alerts.create({
|
|
84
|
+
frequency: 'weekly',
|
|
85
|
+
jobFunctions: ['engineering'],
|
|
86
|
+
remoteOptions: ['remote'],
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
await board.me.alerts.remove(alert.id); // 204 → void; the only way to stop an alert
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`update` is a PUT with **full-replace semantics** — the body is the same `AlertBody` as create, so round-trip every field you aren't changing:
|
|
93
|
+
|
|
94
|
+
```ts snippet
|
|
95
|
+
const current = await board.me.alerts.retrieve(alertId);
|
|
96
|
+
await board.me.alerts.update(alertId, {
|
|
97
|
+
frequency: 'daily', // the one edit
|
|
98
|
+
jobFunctions: current.filters.jobFunctions,
|
|
99
|
+
seniorityLevels: current.filters.seniorityLevels,
|
|
100
|
+
remoteOptions: current.filters.remoteOptions,
|
|
101
|
+
placeIds: current.filters.placeIds,
|
|
102
|
+
salaryMin: current.filters.salaryMin,
|
|
103
|
+
salaryMax: current.filters.salaryMax,
|
|
104
|
+
salaryCurrency: current.filters.salaryCurrency,
|
|
105
|
+
});
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Verify
|
|
109
|
+
|
|
110
|
+
- [ ] Subscribe form sends `consent: true` and handles `status: 'duplicate'` without erroring.
|
|
111
|
+
- [ ] Confirm page branches on all four statuses (including `expired` → resend).
|
|
112
|
+
- [ ] Manage page reads `subscription` + `token` from the email link's query string; writes send `subscriptionId` in the body.
|
|
113
|
+
- [ ] Editing one preference field leaves the others intact (full-replace round-trip, both surfaces).
|
|
114
|
+
- [ ] Alert UI only promises filtering on job function / place / remote option.
|
|
115
|
+
- [ ] Alert surfaces are hidden when `features.jobAlerts` is false.
|