@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,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.
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cavuno-board-paywall
|
|
3
|
+
description: Build the candidate job-access paywall with the @cavuno/board SDK — anonymous offer reads (paywall.offers), the gated browse experience (gatedCount on job lists), the embedded Stripe checkout mount kit (me.access.checkout → retrieveCheckout → grant), and the billing portal (me.access.portal for recurring grants).
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Candidate paywall: offers, checkout, grant
|
|
7
|
+
|
|
8
|
+
The candidate-facing money flow (doc 36 / ADR-0056): anonymous pricing, gated job lists, and an embedded Stripe checkout that ends in an access grant.
|
|
9
|
+
|
|
10
|
+
Out of scope — do not invent exports: the SDK is Stripe-agnostic. It returns a *mount kit*; loading Stripe.js (`loadStripe`, `initEmbeddedCheckout`) and every redirect is host-app-owned. There is no SDK helper that mounts, redirects, or handles webhooks.
|
|
11
|
+
|
|
12
|
+
## When to use
|
|
13
|
+
|
|
14
|
+
- The pricing/upsell page, the gated jobs view, the checkout page, and the "manage subscription" button.
|
|
15
|
+
|
|
16
|
+
## When not to use
|
|
17
|
+
|
|
18
|
+
- Board-password gating (`X-Board-Access`) — unrelated; see `cavuno-board-errors`.
|
|
19
|
+
- Employer job-posting payments — see `cavuno-board-job-posting`.
|
|
20
|
+
|
|
21
|
+
## Offers (anonymous)
|
|
22
|
+
|
|
23
|
+
`board.paywall.offers()` lists the enabled offer tiers — public, no auth. Returns `[]` when the paywall is disabled (the internal Stripe price id is never exposed).
|
|
24
|
+
|
|
25
|
+
```ts snippet
|
|
26
|
+
const { data: offers } = await board.paywall.offers();
|
|
27
|
+
for (const offer of offers) {
|
|
28
|
+
offer.offerKey; // e.g. 'monthly' — what you post to checkout
|
|
29
|
+
offer.label;
|
|
30
|
+
offer.billingLabel;
|
|
31
|
+
offer.amountCents;
|
|
32
|
+
offer.currency;
|
|
33
|
+
offer.offerType; // 'recurring' | 'lifetime'
|
|
34
|
+
offer.isDefault; // pre-select this tier
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## The gated browse: gatedCount
|
|
39
|
+
|
|
40
|
+
On gated boards the jobs catalog reads (browse / search / company-jobs) withhold results from unentitled viewers and report how many via `gatedCount` on the envelope. Surface it as the upsell:
|
|
41
|
+
|
|
42
|
+
```ts snippet
|
|
43
|
+
const page = await board.jobs.list({ limit: 20 });
|
|
44
|
+
if (page.gatedCount && page.gatedCount > 0) {
|
|
45
|
+
// "Unlock N more roles" → link to the offers page
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The same call with an entitled board-user bearer token returns the ungated view — one URL for both.
|
|
50
|
+
|
|
51
|
+
## Checkout: mint the mount kit
|
|
52
|
+
|
|
53
|
+
`board.me.access.checkout` (signed-in, candidate profile required) starts an embedded checkout and returns a connected-account mount kit — `{ sessionId, clientSecret, stripeAccountId, publishableKey, offerType }`. `returnPath` is relative-only; the server resolves it against the board's canonical host and appends `?session_id={CHECKOUT_SESSION_ID}`.
|
|
54
|
+
|
|
55
|
+
```ts snippet
|
|
56
|
+
import { loadStripe } from '@stripe/stripe-js'; // host-app dependency
|
|
57
|
+
|
|
58
|
+
const kit = await board.me.access.checkout({
|
|
59
|
+
offerKey: 'monthly', // the chosen PaywallOffer.offerKey
|
|
60
|
+
returnPath: '/account/access', // relative path Stripe returns to
|
|
61
|
+
colorMode: 'light',
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Host-owned mounting — stripeAccount is required for connected-account embeds:
|
|
65
|
+
const stripe = await loadStripe(kit.publishableKey, {
|
|
66
|
+
stripeAccount: kit.stripeAccountId,
|
|
67
|
+
});
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Duplicate POSTs are benign (a second session simply expires unpaid) — no idempotency machinery needed.
|
|
71
|
+
|
|
72
|
+
## Completion: poll the session, confirm the grant
|
|
73
|
+
|
|
74
|
+
`retrieveCheckout(sessionId)` reports `open` (re-mountable — `clientSecret` set), `complete`, or `expired`. On completion, the entitlement lands via webhook within seconds — `grant()` is the source of truth, and it always resolves (no access is `hasAccess: false`, not an error):
|
|
75
|
+
|
|
76
|
+
```ts snippet
|
|
77
|
+
const state = await board.me.access.retrieveCheckout(kit.sessionId);
|
|
78
|
+
// 'open' → remount with state.clientSecret
|
|
79
|
+
// 'expired' → mint a fresh session
|
|
80
|
+
// 'complete' → poll the grant until the webhook lands:
|
|
81
|
+
|
|
82
|
+
const grant = await board.me.access.grant();
|
|
83
|
+
grant.hasAccess; // gate the ungated jobs view on this
|
|
84
|
+
grant.status; // 'active' | 'past_due' | … | null
|
|
85
|
+
grant.offerType; // 'recurring' | 'lifetime' | null
|
|
86
|
+
grant.currentPeriodEnd;
|
|
87
|
+
grant.cancelAtPeriodEnd;
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Gate failures throw `paywall_*` codes (`paywall_disabled`, `paywall_no_candidate_profile`, `paywall_offer_not_found`, `paywall_already_active`, `paywall_invalid_checkout_session`) — branch with the guards from `cavuno-board-errors`.
|
|
91
|
+
|
|
92
|
+
## Manage subscription: portal
|
|
93
|
+
|
|
94
|
+
Only `recurring` grants get a portal (`paywall_no_recurring_subscription` otherwise). Unlike checkout, the portal IS a redirect flow — the host app navigates to the Stripe-hosted URL:
|
|
95
|
+
|
|
96
|
+
```ts snippet
|
|
97
|
+
if (grant.offerType === 'recurring') {
|
|
98
|
+
const { url } = await board.me.access.portal({ returnPath: '/account' });
|
|
99
|
+
location.href = url; // host-owned redirect to Stripe's portal
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Verify
|
|
104
|
+
|
|
105
|
+
- [ ] Anonymous fetch of `paywall.offers()` renders tiers with no auth header; a paywall-disabled board renders none (`data: []`).
|
|
106
|
+
- [ ] As an unentitled viewer, `board.jobs.list` carries `gatedCount > 0` and the upsell shows; with an active grant the same call returns `gatedCount` absent/0 and the full list.
|
|
107
|
+
- [ ] Complete a test payment: `retrieveCheckout` flips to `complete` and `grant().hasAccess` turns true within seconds (webhook latency), not instantly.
|
|
108
|
+
- [ ] `portal()` succeeds for a recurring grant and throws for a lifetime one.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cavuno-board-salaries
|
|
3
|
+
description: Build salary pages with the @cavuno/board SDK — salaries.titles/skills/locations (list + retrieve + cross-axis .locations/.location/.titles/.skills), salaries.companies.list. Covers the sourceSlug/canonicalSlug 308 contract, the { locale } overlay, aggregate field shapes (avg/median/percentile bands + jobCount), and why salary math must never be recomputed client-side.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Salary pages
|
|
7
|
+
|
|
8
|
+
Pre-aggregated salary read-models, one nested resource per axis (titles, skills, locations) plus a companies hub. Every number is server-computed; the SDK returns render-ready aggregates.
|
|
9
|
+
|
|
10
|
+
## When to use
|
|
11
|
+
|
|
12
|
+
- `/salaries` index pages (by title, skill, or location) and their detail pages.
|
|
13
|
+
- Cross-axis pages: "Software Engineer salary in Berlin", "Python salary by location".
|
|
14
|
+
- The `/salaries/companies` hub.
|
|
15
|
+
|
|
16
|
+
## When not to use
|
|
17
|
+
|
|
18
|
+
- A single company's salary pages — use `companies.salaries(slug)` / `companies.salaries.category(slug, categorySlug)` (see the companies namespace).
|
|
19
|
+
- Per-job salary display — the job itself carries `salaryMin`/`salaryMax` (see `cavuno-board-jobs`).
|
|
20
|
+
|
|
21
|
+
## Out of scope — do not invent exports
|
|
22
|
+
|
|
23
|
+
Only the methods and fields shown here exist. If a field is not in these snippets or the exported types, do not reference it.
|
|
24
|
+
|
|
25
|
+
## Index pages
|
|
26
|
+
|
|
27
|
+
Each axis has `list` returning a `ListEnvelope` of index items. Index items carry `avgSalaryMin`/`avgSalaryMax` and `jobCount` (sample size); title/skill items add `p25SalaryMin`/`p75SalaryMax` and `currency`.
|
|
28
|
+
|
|
29
|
+
```ts snippet
|
|
30
|
+
const titles = await board.salaries.titles.list();
|
|
31
|
+
for (const t of titles.data) {
|
|
32
|
+
t.slug; t.name;
|
|
33
|
+
t.avgSalaryMin; t.avgSalaryMax; // render as-is, in t.currency
|
|
34
|
+
t.jobCount; // "based on N jobs"
|
|
35
|
+
}
|
|
36
|
+
const skills = await board.salaries.skills.list();
|
|
37
|
+
const companies = await board.salaries.companies.list(); // ranked by sample size
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`salaries.locations.list()` returns a flattened place tree — each `SalaryLocation` has `parentSlug` (`null` at the top level); rebuild the country → region → city browse from those edges.
|
|
41
|
+
|
|
42
|
+
## Detail pages and the slug contract
|
|
43
|
+
|
|
44
|
+
`retrieve(slug)` accepts the inbound slug (board-language or English) and returns both `sourceSlug` (immutable English stats key) and `canonicalSlug` (board-language URL). **The API never redirects — your app 308s to `canonicalSlug` when the inbound slug differs.** Pass `{ locale }` for board-language names + canonical slugs (`en` is the identity fast-path).
|
|
45
|
+
|
|
46
|
+
```ts snippet
|
|
47
|
+
const title = await board.salaries.titles.retrieve('software-engineer', { locale: 'de' });
|
|
48
|
+
title.canonicalSlug; // 308 target if it differs from the requested slug
|
|
49
|
+
title.overallSalary; // { avgMin, avgMax, p25Min, p75Max, jobCount } | null
|
|
50
|
+
title.bySeniority; // rows with avgSalaryMin/avgSalaryMax/jobCount + board comparison
|
|
51
|
+
title.topCompanies; // rails: { avgSalaryMin, avgSalaryMax, jobCount, ... }
|
|
52
|
+
title.currency; // the single currency all numbers are quoted in
|
|
53
|
+
|
|
54
|
+
const skill = await board.salaries.skills.retrieve('python');
|
|
55
|
+
const place = await board.salaries.locations.retrieve('berlin');
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
`overallSalary` is `null` when the axis has no sample — render an empty state, don't fabricate a band. Skill and location details also carry `medianMin`/`medianMax` inside `overallSalary`; the title detail exposes medians as top-level `boardMedianMin`/`boardMedianMax` instead. `bySeniority` rows on title/skill details include board-wide comparison fields (`boardAvgMin`, `diffPercent`, …) for "X% above board average" copy.
|
|
59
|
+
|
|
60
|
+
## Cross-axis pages
|
|
61
|
+
|
|
62
|
+
Titles and skills each expose `.locations(slug)` (index of places with data) and `.location(slug, locationSlug)` (one place); locations expose the suffix reads `.titles(slug)` and `.skills(slug)`.
|
|
63
|
+
|
|
64
|
+
```ts snippet
|
|
65
|
+
const idx = await board.salaries.titles.locations('software-engineer');
|
|
66
|
+
idx.locations; // SalaryLocation[] — flattened parentSlug tree
|
|
67
|
+
|
|
68
|
+
const berlin = await board.salaries.titles.location('software-engineer', 'berlin');
|
|
69
|
+
berlin.categoryCanonicalSlug; // 4 slugs: category/location × source/canonical
|
|
70
|
+
berlin.locationCanonicalSlug;
|
|
71
|
+
berlin.overallSalary; // p25Min/p75Max nullable here
|
|
72
|
+
|
|
73
|
+
const inBerlin = await board.salaries.locations.titles('berlin');
|
|
74
|
+
const skillsInBerlin = await board.salaries.locations.skills('berlin');
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Never recompute salary math
|
|
78
|
+
|
|
79
|
+
Render the returned aggregates **as-is**. Do not average mins and maxes, derive medians from percentiles, sum across rails, or convert currency client-side — every response quotes one board `currency` and the weighted math (averages, medians, p25/p75 bands, per-seniority splits) is done server-side against the full sample. Client-side arithmetic will disagree with the hosted board and other consumers.
|
|
80
|
+
|
|
81
|
+
## Verify
|
|
82
|
+
|
|
83
|
+
- [ ] Requesting a title/skill/location by a stale slug renders a 308 to `canonicalSlug`.
|
|
84
|
+
- [ ] A non-`en` board passes `{ locale }` and shows board-language names.
|
|
85
|
+
- [ ] An axis with `overallSalary: null` shows an empty state, not `NaN`/`0`.
|
|
86
|
+
- [ ] All displayed figures match the raw response fields (no client math), formatted in the response `currency`.
|
|
87
|
+
- [ ] Sample sizes come from `jobCount`, not `data.length`.
|
|
@@ -68,7 +68,7 @@ export const board = createBoardClient({
|
|
|
68
68
|
|
|
69
69
|
## Build jobs browsing + detail
|
|
70
70
|
|
|
71
|
-
The core surface. `jobs.list` / `jobs.search` for listing pages, `jobs.retrieve` for the detail page, `jobs.similar` for the related rail. Honor storefront pagination and the candidate-paywall `gatedCount
|
|
71
|
+
The core surface. `jobs.list` / `jobs.search` for listing pages, `jobs.retrieve` for the detail page, `jobs.similar` for the related rail. Honor storefront pagination and the candidate-paywall `gatedCount`; use `paginate()` for full-catalog walks (sitemaps, feeds). See `cavuno-board-jobs`.
|
|
72
72
|
|
|
73
73
|
## Add board users + saved jobs
|
|
74
74
|
|
|
@@ -78,6 +78,30 @@ Register/login/refresh/logout, then `me.retrieve` and `me.savedJobs.*`. There is
|
|
|
78
78
|
|
|
79
79
|
Every method throws `BoardApiError` on a non-2xx; branch with the typed guards. Password-protected boards need a `password.verify()` grant. See `cavuno-board-errors`.
|
|
80
80
|
|
|
81
|
+
## Style, format, and filter with the helper subpaths
|
|
82
|
+
|
|
83
|
+
Three subpath modules carry the display layer — use them instead of
|
|
84
|
+
hand-rolling (each has a skill):
|
|
85
|
+
|
|
86
|
+
- Salary/date/label formatting in the board language → `cavuno-board-format`
|
|
87
|
+
- Listing-filter vocabulary + URL param parsing → `cavuno-board-filters`
|
|
88
|
+
- Board theme → shadcn CSS variables + fonts → `cavuno-board-theme`
|
|
89
|
+
|
|
90
|
+
## Build the remaining surfaces per feature flag
|
|
91
|
+
|
|
92
|
+
One focused skill per surface — delegate by name, and build a surface only
|
|
93
|
+
when its `features` flag (from `board.context()`) is enabled:
|
|
94
|
+
|
|
95
|
+
- Companies directory, markets, per-company jobs → `cavuno-board-companies`
|
|
96
|
+
- Blog (posts, tags, authors, search) → `cavuno-board-blog`
|
|
97
|
+
- Programmatic salary pages → `cavuno-board-salaries`
|
|
98
|
+
- Job alerts (anonymous double-opt-in + account CRUD) → `cavuno-board-job-alerts`
|
|
99
|
+
- Candidate account self-service (profile, resume, avatar, prefs) → `cavuno-board-account`
|
|
100
|
+
- Apply flow + my applications + saved jobs → `cavuno-board-applications`
|
|
101
|
+
- Candidate↔employer messaging (polled REST) → `cavuno-board-messaging`
|
|
102
|
+
- Candidate job-access paywall (offers, checkout, grant) → `cavuno-board-paywall`
|
|
103
|
+
- Public job-posting funnel (plans, submit, billing) → `cavuno-board-job-posting`
|
|
104
|
+
|
|
81
105
|
## App-owned concerns (out of scope)
|
|
82
106
|
|
|
83
107
|
The SDK serves data only. Your app owns: page layout and chrome copy, marketing/legal prose, and the authoring of SEO artifacts (sitemap, RSS, OG images) — built from API data, not served as documents. The Board API never returns layouts or page-builder JSON.
|
|
@@ -89,7 +113,7 @@ The SDK serves data only. Your app owns: page layout and chrome copy, marketing/
|
|
|
89
113
|
- A detail page renders from `board.jobs.retrieve(slug)`.
|
|
90
114
|
- An invalid slug surfaces a handled `isNotFound(err)` path, not a crash.
|
|
91
115
|
|
|
92
|
-
|
|
116
|
+
Then run the `cavuno-board-smoke-test` skill against your `pk_…` — type checks are not enough for board wiring.
|
|
93
117
|
|
|
94
118
|
## Stop conditions
|
|
95
119
|
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cavuno-board-smoke-test
|
|
3
|
+
description: Runtime verification for a wired Cavuno board frontend — literal curl probes against the Board API plus per-surface behavioral checks and a mandatory production-build pass. Run after cavuno-board-setup finishes, after upgrading @cavuno/board, or whenever board wiring is suspect. Type checks are not enough for board wiring.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Smoke-testing a Cavuno board frontend
|
|
7
|
+
|
|
8
|
+
Type checks prove the code compiles against the SDK; they do not prove the
|
|
9
|
+
app reaches the right board with the right credentials, or that gating,
|
|
10
|
+
auth, and SEO surfaces behave. Verify at runtime, in this order.
|
|
11
|
+
|
|
12
|
+
## When to use
|
|
13
|
+
|
|
14
|
+
- Right after `cavuno-board-setup` completes.
|
|
15
|
+
- After upgrading `@cavuno/board` or rotating the `pk_…` key.
|
|
16
|
+
- When any surface renders empty and you don't know which layer is wrong.
|
|
17
|
+
|
|
18
|
+
## 1 — Probe the API directly (before blaming app code)
|
|
19
|
+
|
|
20
|
+
Use the real env values the app reads (`PUBLIC_CAVUNO_API_URL`,
|
|
21
|
+
`PUBLIC_CAVUNO_BOARD`). Expected outputs are exact.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
# Board context: MUST return JSON with "object": "public_board" and your
|
|
25
|
+
# board's name — not an HTML error page, not {"error":{"code":"boards_not_found"}}.
|
|
26
|
+
curl -s "$PUBLIC_CAVUNO_API_URL/v1/boards/$PUBLIC_CAVUNO_BOARD" | head -c 300
|
|
27
|
+
|
|
28
|
+
# Jobs list: MUST return {"object":"list", ... "data":[...]}. An empty data
|
|
29
|
+
# array on a board you know has jobs means the wrong board identifier.
|
|
30
|
+
curl -s "$PUBLIC_CAVUNO_API_URL/v1/boards/$PUBLIC_CAVUNO_BOARD/jobs?limit=2" | head -c 300
|
|
31
|
+
|
|
32
|
+
# Error envelope: a bogus job slug MUST return the v1 error shape with
|
|
33
|
+
# "code":"jobs_not_found" — anything else means a proxy is rewriting responses.
|
|
34
|
+
curl -s "$PUBLIC_CAVUNO_API_URL/v1/boards/$PUBLIC_CAVUNO_BOARD/jobs/definitely-not-a-job"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
On a password-protected board, every content read above returns 401 with
|
|
38
|
+
`"code":"board_password_required"` — that is correct behavior, not a
|
|
39
|
+
failure; verify the grant flow in step 3 instead.
|
|
40
|
+
|
|
41
|
+
## 2 — Verify the app serves board data
|
|
42
|
+
|
|
43
|
+
Start the app (dev is fine for this step) and probe its own routes:
|
|
44
|
+
|
|
45
|
+
- The home/shell renders the board name and logo from `board.context()`.
|
|
46
|
+
- A listing page shows real job cards; a card links to
|
|
47
|
+
`/companies/:companySlug/jobs/:jobSlug` (never `/jobs/:slug`).
|
|
48
|
+
- A fabricated job URL renders the app's not-found state — a handled
|
|
49
|
+
`isNotFound` branch, not a crash or blank page.
|
|
50
|
+
|
|
51
|
+
## 3 — Per-surface behavioral checks (only for surfaces you built)
|
|
52
|
+
|
|
53
|
+
- **Auth**: register → the verification gate appears; login → an authed
|
|
54
|
+
read (`me.retrieve`) succeeds; after `auth.logout()` the same read fails
|
|
55
|
+
with a 401 that the app handles by signing out, not looping.
|
|
56
|
+
- **Password gate**: with protection enabled, an anonymous content read
|
|
57
|
+
redirects to the password page; `password.verify()` + retry renders
|
|
58
|
+
content; a wrong password shows `board_password_invalid` messaging.
|
|
59
|
+
- **Saved jobs / apply**: save → reload → still saved (server state, not
|
|
60
|
+
local); a second apply to the same job returns the same application
|
|
61
|
+
(idempotent), not a duplicate.
|
|
62
|
+
- **Paywall** (gated boards): anonymous list shows the `gatedCount` upsell;
|
|
63
|
+
an entitled login makes the same URL return the ungated view.
|
|
64
|
+
- **Alerts**: subscribe → `status: "created"`; repeat → `"duplicate"`.
|
|
65
|
+
|
|
66
|
+
## 4 — Production build (mandatory)
|
|
67
|
+
|
|
68
|
+
Dev servers mask wiring bugs (looser env loading, dev-only middleware, no
|
|
69
|
+
tree-shake). Run the app's real build, boot the production output, and
|
|
70
|
+
repeat step 2 against it. `import 'server-only'`-style boundaries and env
|
|
71
|
+
prefixes (`VITE_`, `NEXT_PUBLIC_`) frequently pass dev and fail prod.
|
|
72
|
+
|
|
73
|
+
## Out of scope — do not invent checks
|
|
74
|
+
|
|
75
|
+
No load testing, no Lighthouse/SEO scoring, no visual regression — those
|
|
76
|
+
are app-owned. This skill verifies board wiring only.
|
|
77
|
+
|
|
78
|
+
## Stop conditions
|
|
79
|
+
|
|
80
|
+
Stop and report to the human when: step 1 fails (the problem is
|
|
81
|
+
credentials/network, not code — do not "fix" app code to compensate); the
|
|
82
|
+
API returns codes the app has no branch for; or a check needs a credential
|
|
83
|
+
you don't have (board password, test account). Never commit real
|
|
84
|
+
credentials while fixing findings.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cavuno-board-theme
|
|
3
|
+
description: Board branding with @cavuno/board/theme — map the board's stored theme (16 semantic colors × light/dark + typography from board.context().theme) onto shadcn CSS variables, resolve the color-scheme mode, and build the Google Fonts request. Use when wiring the app shell, dark mode, or brand styling for a tenant frontend.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Theme: board branding → shadcn tokens
|
|
7
|
+
|
|
8
|
+
`@cavuno/board/theme` maps the board's stored theme onto the canonical
|
|
9
|
+
shadcn token vocabulary as CSS-variable overrides. One contract, two
|
|
10
|
+
consumers: the dashboard edits the board theme; agents restyle through the
|
|
11
|
+
standard shadcn theme file — both end up in the same tokens.
|
|
12
|
+
|
|
13
|
+
## When to use
|
|
14
|
+
|
|
15
|
+
- The app shell/root layout of any tenant frontend, once.
|
|
16
|
+
- Wiring dark mode or brand fonts.
|
|
17
|
+
|
|
18
|
+
## When not to use
|
|
19
|
+
|
|
20
|
+
- Component-level styling — write normal Tailwind/shadcn classes; they pick
|
|
21
|
+
the overridden tokens up automatically.
|
|
22
|
+
|
|
23
|
+
## Wire it once at the shell
|
|
24
|
+
|
|
25
|
+
```ts snippet
|
|
26
|
+
import { boardThemeToCss, themeMode, googleFontsUrl } from '@cavuno/board/theme';
|
|
27
|
+
|
|
28
|
+
const context = await board.context();
|
|
29
|
+
const css = boardThemeToCss(context.theme); // ':root {…}' + '.dark {…}'
|
|
30
|
+
const mode = themeMode(context.theme); // 'light' | 'dark' | 'system'
|
|
31
|
+
const fontsHref = googleFontsUrl(context.theme); // one <link>, or null
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Inject `css` in a `<style>` AFTER the static theme stylesheet so the
|
|
35
|
+
overrides win; render nothing when it's empty (a null theme means the app's
|
|
36
|
+
default theme applies untouched). Apply `mode` by toggling the `.dark`
|
|
37
|
+
class (`system` = follow `prefers-color-scheme`).
|
|
38
|
+
|
|
39
|
+
## The mapping is the contract
|
|
40
|
+
|
|
41
|
+
All 16 board color keys are consumed — a coverage golden in-monorepo
|
|
42
|
+
asserts neither the hosted board nor this module drops one. Standard
|
|
43
|
+
shadcn tokens carry the core (background/foreground, card, popover,
|
|
44
|
+
primary, secondary, muted, accent, destructive, border, input, ring);
|
|
45
|
+
the four keys shadcn has no standard token for ship as
|
|
46
|
+
`--contrast-background`, `--contrast-foreground`, `--foreground-subtle`,
|
|
47
|
+
`--foreground-disabled`, plus `--foreground-error`.
|
|
48
|
+
|
|
49
|
+
## Anti-patterns
|
|
50
|
+
|
|
51
|
+
```ts no-check
|
|
52
|
+
// NEVER hardcode brand colors in components — they bypass the board theme:
|
|
53
|
+
<button style={{ background: '#7c3aed' }} />
|
|
54
|
+
// NEVER re-map theme keys ad hoc per page; the shell mapping is the single source.
|
|
55
|
+
// NEVER fetch fonts per-family — googleFontsUrl builds ONE deduped request.
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Out of scope — do not invent exports
|
|
59
|
+
|
|
60
|
+
No color math (hover/pressed derivation, contrast checking — the hosted
|
|
61
|
+
dashboard owns palette design), no per-component theme props, no CSS-in-JS
|
|
62
|
+
runtime. The module emits strings; the app owns injection.
|
|
63
|
+
|
|
64
|
+
## Verify
|
|
65
|
+
|
|
66
|
+
- [ ] A board with a custom theme renders its brand color on primary
|
|
67
|
+
buttons and focus rings; a board without one renders the app default.
|
|
68
|
+
- [ ] Dark palette applies under `.dark` and `mode` drives the class.
|
|
69
|
+
- [ ] The Google Fonts request appears once, covering sans + heading.
|