@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
|
@@ -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.
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cavuno-board-job-posting
|
|
3
|
+
description: Build the anonymous "Post a job" funnel with the @cavuno/board SDK — jobPosting.plans, create and its status-discriminated JobPostingResult (checkout / published / pending_approval / invoice_sent), logo upload + fetchLogoByDomain, and the email-verified billing helpers (checkBilling, sendBillingVerification, getBillingOptions, checkSubscriptionEntitlements).
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Job posting: the anonymous submission funnel
|
|
7
|
+
|
|
8
|
+
The no-account "Post a job" wizard (ADR-0042): pick a plan, upload a logo, submit — then branch on the status-discriminated result. Anonymous by design (no board-user token) and rate-limited server-side.
|
|
9
|
+
|
|
10
|
+
Out of scope — do not invent exports: the `checkout` branch returns a Stripe Checkout **URL** — the full-page redirect is host-app-owned, and the webhook publishes the job after payment. The SDK ships no Stripe code and no publish/confirm method for the paid path.
|
|
11
|
+
|
|
12
|
+
## When to use
|
|
13
|
+
|
|
14
|
+
- The public `/post` wizard: plan picker, logo step, submission, and the post-submit screen.
|
|
15
|
+
- Letting an existing plan/bundle holder submit against remaining capacity via email verification.
|
|
16
|
+
|
|
17
|
+
## When not to use
|
|
18
|
+
|
|
19
|
+
- Authenticated employer job management (drafts, edit, republish, ATS) — that is `board.me.companies.jobs.*`.
|
|
20
|
+
- The candidate-access paywall — see `cavuno-board-paywall`.
|
|
21
|
+
|
|
22
|
+
## Plans
|
|
23
|
+
|
|
24
|
+
The chosen plan's `id` goes in `submission.selectedPlan`:
|
|
25
|
+
|
|
26
|
+
```ts snippet
|
|
27
|
+
const { data: plans } = await board.jobPosting.plans();
|
|
28
|
+
for (const plan of plans) {
|
|
29
|
+
plan.id; // → submission.selectedPlan
|
|
30
|
+
plan.prices; // [{ currency, amountCents, isActive }]
|
|
31
|
+
plan.invoiceOnly; // collect invoiceBilling instead of paying now
|
|
32
|
+
plan.isRecommended; // highlight in the picker
|
|
33
|
+
plan.features; // [{ key, value }] rows for the plan card
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Logo
|
|
38
|
+
|
|
39
|
+
Upload a file, or fetch by company domain (Brandfetch). Both return a stored `publicUrl` you pass back as `logoUrl` on `create(...)`:
|
|
40
|
+
|
|
41
|
+
```ts snippet
|
|
42
|
+
const logo = await board.jobPosting.uploadLogo(file); // JPEG/PNG/WebP/GIF, ≤2 MB
|
|
43
|
+
// or:
|
|
44
|
+
const fetched = await board.jobPosting.fetchLogoByDomain('acme.com');
|
|
45
|
+
// a BoardApiError code 'job_posting_logo_not_found' = no usable logo — continue without one
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Submit and branch on the result
|
|
49
|
+
|
|
50
|
+
`create` returns a `JobPostingResult` — a discriminated union on `.status` with exactly four variants. The host app **must** branch on it; a rejected submission throws a `BoardApiError` instead.
|
|
51
|
+
|
|
52
|
+
```ts snippet
|
|
53
|
+
const result = await board.jobPosting.create({
|
|
54
|
+
submission: {
|
|
55
|
+
companyName: 'Acme',
|
|
56
|
+
contactName: 'Ada',
|
|
57
|
+
contactEmail: 'ada@acme.com',
|
|
58
|
+
title: 'Staff Engineer',
|
|
59
|
+
description: '<p>…</p>',
|
|
60
|
+
employmentType: 'full_time',
|
|
61
|
+
remoteOption: 'remote',
|
|
62
|
+
officeLocations: [],
|
|
63
|
+
applicationUrl: 'https://acme.com/apply',
|
|
64
|
+
salaryRangeEnabled: false,
|
|
65
|
+
selectedPlan: plan.id,
|
|
66
|
+
},
|
|
67
|
+
logoUrl: logo.publicUrl,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
switch (result.status) {
|
|
71
|
+
case 'checkout': // paid plan → send the poster to Stripe
|
|
72
|
+
location.href = result.checkoutUrl; // webhook publishes on payment
|
|
73
|
+
break;
|
|
74
|
+
case 'published': // live now
|
|
75
|
+
result.jobSlug; // link straight to the job
|
|
76
|
+
break;
|
|
77
|
+
case 'pending_approval': // moderated board — live after review
|
|
78
|
+
result.jobId;
|
|
79
|
+
break;
|
|
80
|
+
case 'invoice_sent': // invoice plan — a Stripe invoice was emailed
|
|
81
|
+
result.jobId;
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The five billing paths map onto those four statuses: paid-Stripe → `checkout`; free and existing bundle/subscription credit publish without payment (`published`, or `pending_approval` on moderated boards); invoice plans → `invoice_sent`.
|
|
87
|
+
|
|
88
|
+
## Existing credit: the verified-email branch
|
|
89
|
+
|
|
90
|
+
A submitter whose email already holds a plan/bundle can post against remaining capacity — no new charge. Verify the email first; an option's fields become `selectedBilling`:
|
|
91
|
+
|
|
92
|
+
```ts snippet
|
|
93
|
+
const { hasActiveBilling } = await board.jobPosting.checkBilling({ email });
|
|
94
|
+
if (hasActiveBilling) {
|
|
95
|
+
await board.jobPosting.sendBillingVerification({ email }); // emails a token
|
|
96
|
+
// …the poster pastes/clicks the token…
|
|
97
|
+
const { options } = await board.jobPosting.getBillingOptions({
|
|
98
|
+
verificationToken,
|
|
99
|
+
});
|
|
100
|
+
const option = options[0]!;
|
|
101
|
+
option.jobsRemaining; // capacity left
|
|
102
|
+
option.featuredRemaining; // featured slots left
|
|
103
|
+
option.renewsAt;
|
|
104
|
+
|
|
105
|
+
await board.jobPosting.create({
|
|
106
|
+
submission, // as above — selectedPlan not needed on this path
|
|
107
|
+
selectedBilling: { type: option.type, id: option.id, planId: option.planId },
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
For subscription holders, gate the "featured" toggle on entitlements:
|
|
113
|
+
|
|
114
|
+
```ts snippet
|
|
115
|
+
const ent = await board.jobPosting.checkSubscriptionEntitlements({
|
|
116
|
+
email,
|
|
117
|
+
planId: plan.id,
|
|
118
|
+
});
|
|
119
|
+
ent.hasSubscription;
|
|
120
|
+
ent.canFeature; // may the wizard offer isFeatured?
|
|
121
|
+
ent.featuredSlotsRemaining; // and how many
|
|
122
|
+
ent.maxActiveRemaining;
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Verify
|
|
126
|
+
|
|
127
|
+
- [ ] Every `create` call site handles all four `result.status` variants — no fallthrough that assumes `published`.
|
|
128
|
+
- [ ] A paid-plan submission lands on Stripe via `result.checkoutUrl`, and the job appears on the board only after payment (webhook), not on redirect.
|
|
129
|
+
- [ ] A free-plan submission on an unmoderated board returns `published` with a `jobSlug` that resolves.
|
|
130
|
+
- [ ] `fetchLogoByDomain` on a bogus domain surfaces the not-found error path, and the wizard still submits without a logo.
|
|
@@ -85,6 +85,21 @@ if (page.gatedCount && page.gatedCount > 0) {
|
|
|
85
85
|
|
|
86
86
|
A board-user bearer token on the same call returns the entitled (ungated) view — the endpoint is optional-auth, one URL for both anonymous and personalized reads.
|
|
87
87
|
|
|
88
|
+
## Full-catalog walks: paginate()
|
|
89
|
+
|
|
90
|
+
For sitemaps, feeds, or exports, never hand-roll the cursor loop — `paginate()` iterates items (or raw pages via `.pages()`) until `hasMore` is false, and drops `offset` after the first page (offset would win over the cursor and re-serve the same page forever):
|
|
91
|
+
|
|
92
|
+
```ts snippet
|
|
93
|
+
import { paginate } from '@cavuno/board';
|
|
94
|
+
|
|
95
|
+
for await (const card of paginate(board.jobs.list, { limit: 100 })) {
|
|
96
|
+
urls.push(card.links.public);
|
|
97
|
+
}
|
|
98
|
+
const first500 = await paginate(board.jobs.list).toArray({ limit: 500 });
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Iteration order is stable only under an explicit sort/search — a churning board reorders default browse results between pages.
|
|
102
|
+
|
|
88
103
|
## Checklist
|
|
89
104
|
|
|
90
105
|
- [ ] Listing/search use `PublicJobCard`; detail uses `PublicJob`.
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cavuno-board-messaging
|
|
3
|
+
description: Build the board-user messaging inbox with the @cavuno/board SDK — me.conversations (list, unreadCount, retrieve, listMessages, start, reply, markRead, archive), me.messages (edit, unsend, report), me.blocks. Covers the polled-REST contract (no realtime transport in v1), visibility-aware polling, the unread badge, and read receipts.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Messaging: the polled inbox
|
|
7
|
+
|
|
8
|
+
Employer↔candidate direct messaging for signed-in board users. The v1 transport is **polled REST by design (ADR-0053)** — there is no realtime primitive; near-live UX comes from re-fetching on an interval (3–5s is the reference cadence).
|
|
9
|
+
|
|
10
|
+
Out of scope — do not invent exports: no websockets, no SSE, no `subscribe`/`onMessage` — polling `list` / `listMessages` / `unreadCount` IS the contract.
|
|
11
|
+
|
|
12
|
+
## When to use
|
|
13
|
+
|
|
14
|
+
- The inbox page, unread badge, thread view, and composer.
|
|
15
|
+
- Edit/unsend, report-a-message, and block flows.
|
|
16
|
+
|
|
17
|
+
## When not to use
|
|
18
|
+
|
|
19
|
+
- Anonymous visitors — every method here requires a signed-in board user (see `cavuno-board-auth`).
|
|
20
|
+
- Job applications themselves — messaging an applicant *starts from* an application, but the application surface is `board.me.applications.*`.
|
|
21
|
+
|
|
22
|
+
## Poll while visible
|
|
23
|
+
|
|
24
|
+
Poll only while the tab is visible — stop on hide, resume (with an immediate refresh) on show:
|
|
25
|
+
|
|
26
|
+
```ts snippet
|
|
27
|
+
const POLL_MS = 4000; // 3–5s reference cadence
|
|
28
|
+
|
|
29
|
+
async function refresh() {
|
|
30
|
+
const [{ count }, inbox] = await Promise.all([
|
|
31
|
+
board.me.conversations.unreadCount(), // distinct unread threads → badge
|
|
32
|
+
board.me.conversations.list({ limit: 20 }),
|
|
33
|
+
]);
|
|
34
|
+
render(count, inbox.data);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
38
|
+
function start() {
|
|
39
|
+
void refresh();
|
|
40
|
+
timer ??= setInterval(refresh, POLL_MS);
|
|
41
|
+
}
|
|
42
|
+
function stop() {
|
|
43
|
+
clearInterval(timer);
|
|
44
|
+
timer = undefined;
|
|
45
|
+
}
|
|
46
|
+
document.addEventListener('visibilitychange', () => {
|
|
47
|
+
document.visibilityState === 'visible' ? start() : stop();
|
|
48
|
+
});
|
|
49
|
+
start();
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Each inbox row is a `Conversation`: `lastMessageAt`, `lastMessageSnippet`, `hasUnread`, and a live-resolved `counterparty` (`displayName`, `avatarUrl`, `companyName`, `handle`). `list({ archived: true })` is the archived view; `archive`/`unarchive` move a thread per-side and are idempotent.
|
|
53
|
+
|
|
54
|
+
## The thread: header, messages, read receipts
|
|
55
|
+
|
|
56
|
+
The header and the messages are separate calls. Messages come oldest-first; unsent messages are tombstones (empty `body`, `deletedAt` set) — render a placeholder, don't filter them out.
|
|
57
|
+
|
|
58
|
+
```ts snippet
|
|
59
|
+
const convo = await board.me.conversations.retrieve(conversationId);
|
|
60
|
+
convo.viewerRole; // 'employer' | 'candidate'
|
|
61
|
+
convo.viewerLastReadMessageId; // my last-read pointer (null = never)
|
|
62
|
+
|
|
63
|
+
const { data: messages } = await board.me.conversations.listMessages(
|
|
64
|
+
conversationId,
|
|
65
|
+
{ limit: 50 },
|
|
66
|
+
);
|
|
67
|
+
for (const m of messages) {
|
|
68
|
+
m.body; // '' when unsent (tombstone)
|
|
69
|
+
m.readAt; // read receipt — when the recipient read it, or null
|
|
70
|
+
m.editedAt; // non-null after an edit
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
await board.me.conversations.markRead(conversationId); // idempotent
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Call `markRead` when the viewer opens the thread — it clears `hasUnread` and drives the counterparty's `readAt` receipts.
|
|
77
|
+
|
|
78
|
+
## Sending
|
|
79
|
+
|
|
80
|
+
`start` cold-initiates a candidate (employer-only) and converges on the existing thread; `startAboutApplication` messages an applicant in application context; `reply` continues a thread. Each returns the created `Message`.
|
|
81
|
+
|
|
82
|
+
```ts snippet
|
|
83
|
+
// Route to an existing thread instead of opening a composer:
|
|
84
|
+
const { conversationId } = await board.me.conversations.findExisting({
|
|
85
|
+
candidateBoardUserId,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const first = await board.me.conversations.start({
|
|
89
|
+
candidateBoardUserId,
|
|
90
|
+
body: 'Hi — your profile looks like a great fit.',
|
|
91
|
+
});
|
|
92
|
+
await board.me.conversations.reply(first.conversationId, {
|
|
93
|
+
body: 'Following up!',
|
|
94
|
+
});
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Sends are gated server-side (messaging enabled, cold-message rule, per-pair daily limits, blocks) — failures arrive as `messaging_*` `BoardApiError` codes; branch with the guards from `cavuno-board-errors`.
|
|
98
|
+
|
|
99
|
+
## Edit, unsend, report, block
|
|
100
|
+
|
|
101
|
+
Your own messages can be edited or unsent within a 15-minute window. Reporting a message addressed to you auto-blocks its author.
|
|
102
|
+
|
|
103
|
+
```ts snippet
|
|
104
|
+
await board.me.messages.edit(messageId, { body: 'fixed typo' });
|
|
105
|
+
await board.me.messages.unsend(messageId); // tombstones; idempotent
|
|
106
|
+
|
|
107
|
+
const { blocked } = await board.me.messages.report(messageId, {
|
|
108
|
+
reason: 'spam', // 'spam' | 'harassment' | 'misrepresentation' | 'other'
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
await board.me.blocks.create({ boardUserId }); // silent; idempotent
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
`board.me.blocks.list()` / `.remove(boardUserId)` / `.status(boardUserId)` round out blocking.
|
|
115
|
+
|
|
116
|
+
## Verify
|
|
117
|
+
|
|
118
|
+
- [ ] Kill the network tab timer: polling stops when the tab is hidden, resumes on focus with a fresh read.
|
|
119
|
+
- [ ] Send from a second account: the badge (`unreadCount().count`) and `hasUnread` flip within one poll interval.
|
|
120
|
+
- [ ] Open the thread: `markRead` fires once and the sender sees `readAt` populate on their next poll.
|
|
121
|
+
- [ ] Unsend a message: it renders as a tombstone (empty `body`), not a gap.
|