@cavuno/board 2.0.5 → 3.0.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/CHANGELOG.md +16 -0
- package/README.md +1 -3
- package/dist/{_spec-BKtVFcV9.d.mts → _spec-nkWAraS0.d.mts} +4 -18
- package/dist/{_spec-BKtVFcV9.d.ts → _spec-nkWAraS0.d.ts} +4 -18
- package/dist/bin.mjs +40 -57
- package/dist/{board-3Xj_Y-Tx.d.ts → board-NE98PlwN.d.ts} +2 -3
- package/dist/{board-pNN710Zc.d.mts → board-muu8BI8P.d.mts} +2 -3
- package/dist/doctor.js +21 -47
- package/dist/doctor.mjs +24 -50
- package/dist/filters.d.mts +2 -2
- package/dist/filters.d.ts +2 -2
- package/dist/format.d.mts +3 -3
- package/dist/format.d.ts +3 -3
- package/dist/index.d.mts +11 -27
- package/dist/index.d.ts +11 -27
- package/dist/index.js +3 -3
- package/dist/index.mjs +3 -3
- package/dist/{jobs-C4qRfCdP.d.ts → jobs-BiEvL9d7.d.ts} +1 -1
- package/dist/{jobs-k84C0v_8.d.mts → jobs-lVvKCBCU.d.mts} +1 -1
- package/dist/{salaries--J5mTylN.d.ts → salaries-BahBpxWT.d.ts} +2 -2
- package/dist/{salaries-C9YLO5NG.d.mts → salaries-ChSqDBAM.d.mts} +2 -2
- package/dist/{search-CE_qQiM7.d.mts → search-CCyFWPi3.d.mts} +1 -1
- package/dist/{search-CLRQ_El9.d.ts → search-CyRvVMUp.d.ts} +1 -1
- package/dist/seo.d.mts +4 -4
- package/dist/seo.d.ts +4 -4
- package/dist/server.d.mts +5 -5
- package/dist/server.d.ts +5 -5
- package/dist/sitemap.d.mts +5 -5
- package/dist/sitemap.d.ts +5 -5
- package/dist/skills.d.mts +15 -4
- package/dist/skills.d.ts +15 -4
- package/dist/skills.js +30 -7
- package/dist/skills.mjs +30 -8
- package/dist/suggest.d.mts +2 -2
- package/dist/suggest.d.ts +2 -2
- package/package.json +1 -11
- package/skills/cavuno-board-account/SKILL.md +54 -66
- package/skills/cavuno-board-api-client/SKILL.md +74 -0
- package/skills/cavuno-board-applications/SKILL.md +38 -52
- package/skills/cavuno-board-auth/SKILL.md +36 -61
- package/skills/cavuno-board-blog/SKILL.md +60 -49
- package/skills/cavuno-board-companies/SKILL.md +71 -53
- package/skills/cavuno-board-errors/SKILL.md +29 -46
- package/skills/cavuno-board-filters/SKILL.md +64 -75
- package/skills/cavuno-board-format/SKILL.md +63 -68
- package/skills/cavuno-board-i18n/SKILL.md +33 -62
- package/skills/cavuno-board-job-alerts/SKILL.md +68 -57
- package/skills/cavuno-board-jobs/SKILL.md +63 -55
- package/skills/cavuno-board-messaging/SKILL.md +53 -59
- package/skills/cavuno-board-paywall/SKILL.md +48 -54
- package/skills/cavuno-board-post-a-job/SKILL.md +118 -0
- package/skills/cavuno-board-salaries/SKILL.md +73 -50
- package/skills/cavuno-board-search-suggestions/SKILL.md +82 -0
- package/skills/cavuno-board-seo/BLOG.md +40 -0
- package/skills/cavuno-board-seo/JOB_AND_BREADCRUMBS.md +49 -0
- package/skills/cavuno-board-seo/LISTING.md +36 -0
- package/skills/cavuno-board-seo/SALARY.md +61 -0
- package/skills/cavuno-board-seo/SKILL.md +32 -163
- package/skills/cavuno-board-server-sessions/SKILL.md +143 -0
- package/skills/cavuno-board-setup/SKILL.md +43 -80
- package/skills/cavuno-board-sitemap/SKILL.md +76 -93
- package/skills/cavuno-board-smoke-test/SKILL.md +34 -69
- package/skills/manifest.json +43 -57
- package/dist/theme.d.mts +0 -115
- package/dist/theme.d.ts +0 -115
- package/dist/theme.js +0 -195
- package/dist/theme.mjs +0 -174
- package/skills/cavuno-board-client/SKILL.md +0 -89
- package/skills/cavuno-board-job-posting/SKILL.md +0 -116
- package/skills/cavuno-board-server/SKILL.md +0 -229
- package/skills/cavuno-board-suggest/SKILL.md +0 -119
- package/skills/cavuno-board-theme/SKILL.md +0 -57
- package/skills/flavors/tanstack-start/SKILL.md +0 -102
|
@@ -1,108 +1,102 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: cavuno-board-paywall
|
|
3
|
-
description:
|
|
3
|
+
description: Grant-is-truth candidate paywall with @cavuno/board. Use for offers, gated job counts, embedded checkout handoff, access confirmation, or subscription portal links.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
#
|
|
6
|
+
# Grant-is-truth candidate paywall
|
|
7
7
|
|
|
8
|
-
The candidate
|
|
8
|
+
The candidate money flow is anonymous offers → authenticated embedded checkout → webhook-backed grant. The grant, rather than checkout completion, owns access.
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
Board-password access is a separate `X-Board-Access` mechanism. Employer posting payments use `cavuno-board-post-a-job`.
|
|
11
11
|
|
|
12
|
-
##
|
|
12
|
+
## Render offers and gating
|
|
13
13
|
|
|
14
|
-
|
|
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).
|
|
14
|
+
`board.paywall.offers()` is anonymous and returns an empty list when disabled. Internal Stripe price ids stay server-side.
|
|
24
15
|
|
|
25
16
|
```ts snippet
|
|
26
17
|
const { data: offers } = await board.paywall.offers();
|
|
27
18
|
for (const offer of offers) {
|
|
28
|
-
offer.offerKey;
|
|
19
|
+
offer.offerKey;
|
|
29
20
|
offer.label;
|
|
30
21
|
offer.billingLabel;
|
|
31
22
|
offer.amountCents;
|
|
32
23
|
offer.currency;
|
|
33
|
-
offer.offerType;
|
|
34
|
-
offer.isDefault;
|
|
24
|
+
offer.offerType;
|
|
25
|
+
offer.isDefault;
|
|
35
26
|
}
|
|
36
27
|
```
|
|
37
28
|
|
|
38
|
-
|
|
29
|
+
`offerType` is `'recurring' | 'lifetime'`; post the selected `offerKey` to checkout.
|
|
39
30
|
|
|
40
|
-
|
|
31
|
+
Gated catalog reads expose withheld inventory as `gatedCount`. The same jobs endpoint returns the entitled view when called with the candidate bearer token.
|
|
41
32
|
|
|
42
33
|
```ts snippet
|
|
43
34
|
const page = await board.jobs.list({ limit: 20 });
|
|
44
|
-
if (page.gatedCount
|
|
45
|
-
|
|
35
|
+
if ((page.gatedCount ?? 0) > 0) {
|
|
36
|
+
renderUpsell(page.gatedCount);
|
|
46
37
|
}
|
|
47
38
|
```
|
|
48
39
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
## Checkout: mint the mount kit
|
|
40
|
+
## Mint and mount checkout
|
|
52
41
|
|
|
53
|
-
`board.me.access.checkout`
|
|
42
|
+
`board.me.access.checkout` requires a signed-in candidate profile and returns a mount kit. `returnPath` is relative; the server makes the canonical absolute return URL and appends Stripe's session placeholder.
|
|
54
43
|
|
|
55
44
|
```ts snippet
|
|
56
|
-
import { loadStripe } from '@stripe/stripe-js';
|
|
45
|
+
import { loadStripe } from '@stripe/stripe-js';
|
|
57
46
|
|
|
58
47
|
const kit = await board.me.access.checkout({
|
|
59
|
-
offerKey: 'monthly',
|
|
60
|
-
returnPath: '/account/access',
|
|
48
|
+
offerKey: 'monthly',
|
|
49
|
+
returnPath: '/account/access',
|
|
61
50
|
colorMode: 'light',
|
|
62
51
|
});
|
|
63
52
|
|
|
64
|
-
// Host-owned mounting — stripeAccount is required for connected-account embeds:
|
|
65
53
|
const stripe = await loadStripe(kit.publishableKey, {
|
|
66
54
|
stripeAccount: kit.stripeAccountId,
|
|
67
55
|
});
|
|
68
56
|
```
|
|
69
57
|
|
|
70
|
-
|
|
58
|
+
The kit contains `sessionId`, `clientSecret`, `stripeAccountId`, `publishableKey`, and `offerType`. The host owns Stripe.js, embedded-checkout mounting, and redirects. A repeated checkout POST safely creates another session that can expire unpaid.
|
|
71
59
|
|
|
72
|
-
##
|
|
60
|
+
## Confirm access from the grant
|
|
73
61
|
|
|
74
|
-
`retrieveCheckout
|
|
62
|
+
`retrieveCheckout` reports `open`, `complete`, or `expired`. Open sessions are remountable with their `clientSecret`; expired sessions require a new checkout. After completion, poll `grant()` within a bounded window because webhook delivery is asynchronous.
|
|
75
63
|
|
|
76
64
|
```ts snippet
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
grant
|
|
84
|
-
|
|
85
|
-
grant.
|
|
86
|
-
|
|
87
|
-
|
|
65
|
+
const checkout = await board.me.access.retrieveCheckout(kit.sessionId);
|
|
66
|
+
|
|
67
|
+
if (checkout.status === 'complete') {
|
|
68
|
+
let grant = await board.me.access.grant();
|
|
69
|
+
for (let poll = 0; !grant.hasAccess && poll < 10; poll++) {
|
|
70
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
71
|
+
grant = await board.me.access.grant();
|
|
72
|
+
}
|
|
73
|
+
if (grant.hasAccess) renderUnlockedJobs();
|
|
74
|
+
else renderPendingAccess();
|
|
75
|
+
}
|
|
88
76
|
```
|
|
89
77
|
|
|
90
|
-
|
|
78
|
+
`grant()` always resolves: no access is `{ hasAccess: false }`. Its `status`, `offerType`, `currentPeriodEnd`, and `cancelAtPeriodEnd` drive the account UI. Gate failures use `paywall_disabled`, `paywall_no_candidate_profile`, `paywall_offer_not_found`, `paywall_already_active`, and `paywall_invalid_checkout_session`; handle them with `cavuno-board-errors`.
|
|
91
79
|
|
|
92
|
-
##
|
|
80
|
+
## Open the subscription portal
|
|
93
81
|
|
|
94
|
-
Only
|
|
82
|
+
Only recurring grants have a portal. The host follows the returned Stripe-hosted URL.
|
|
95
83
|
|
|
96
84
|
```ts snippet
|
|
85
|
+
const grant = await board.me.access.grant();
|
|
97
86
|
if (grant.offerType === 'recurring') {
|
|
98
|
-
const { url } = await board.me.access.portal({
|
|
99
|
-
|
|
87
|
+
const { url } = await board.me.access.portal({
|
|
88
|
+
returnPath: '/account',
|
|
89
|
+
});
|
|
90
|
+
location.href = url;
|
|
100
91
|
}
|
|
101
92
|
```
|
|
102
93
|
|
|
103
|
-
|
|
94
|
+
A lifetime grant produces `paywall_no_recurring_subscription` for `portal`.
|
|
95
|
+
|
|
96
|
+
## Completion gate
|
|
104
97
|
|
|
105
|
-
-
|
|
106
|
-
-
|
|
107
|
-
-
|
|
108
|
-
-
|
|
98
|
+
- Anonymous offers render without authorization; disabled paywall renders no tiers.
|
|
99
|
+
- Unentitled jobs show `gatedCount`; the active-grant request exposes the full list.
|
|
100
|
+
- Test checkout reaches `complete`, then bounded grant polling reaches `hasAccess: true` after webhook delivery.
|
|
101
|
+
- UI access decisions read `grant().hasAccess`.
|
|
102
|
+
- Recurring grants open the portal; lifetime grants show a deliberate non-portal state.
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cavuno-board-post-a-job
|
|
3
|
+
description: Post a job publicly with @cavuno/board. Use for plan selection, logos, checkout handoff, or verified existing credit.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Status-first job posting
|
|
7
|
+
|
|
8
|
+
The public posting funnel is anonymous and rate-limited. Its invariant is status-first handling: `board.jobPosting.create` returns one of four success states, and the host follows that state. Rejected submissions throw `BoardApiError` instead.
|
|
9
|
+
|
|
10
|
+
Employer dashboard job management belongs to `board.me.companies.jobs.*`. Candidate access payments belong to `cavuno-board-paywall`.
|
|
11
|
+
|
|
12
|
+
## Select a plan
|
|
13
|
+
|
|
14
|
+
```ts snippet
|
|
15
|
+
const { data: plans } = await board.jobPosting.plans();
|
|
16
|
+
for (const plan of plans) {
|
|
17
|
+
plan.id;
|
|
18
|
+
plan.prices;
|
|
19
|
+
plan.invoiceOnly;
|
|
20
|
+
plan.isRecommended;
|
|
21
|
+
plan.features;
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Pass the chosen `plan.id` as `submission.selectedPlan`. Invoice-only plans collect `invoiceBilling` rather than immediate payment.
|
|
26
|
+
|
|
27
|
+
## Resolve a logo
|
|
28
|
+
|
|
29
|
+
Both logo paths return a stored `publicUrl` for `create`. The SDK owns multipart encoding.
|
|
30
|
+
|
|
31
|
+
```ts snippet
|
|
32
|
+
const uploaded = await board.jobPosting.uploadLogo(file);
|
|
33
|
+
const fetched = await board.jobPosting.fetchLogoByDomain('acme.com');
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Uploads accept JPEG, PNG, WebP, or GIF up to 2 MB. A missing domain logo throws code `job_posting_logo_not_found`; continue without a logo.
|
|
37
|
+
|
|
38
|
+
## Submit and exhaust the result
|
|
39
|
+
|
|
40
|
+
```ts snippet
|
|
41
|
+
const result = await board.jobPosting.create({
|
|
42
|
+
submission: {
|
|
43
|
+
companyName: 'Acme',
|
|
44
|
+
contactName: 'Ada',
|
|
45
|
+
contactEmail: 'ada@acme.com',
|
|
46
|
+
title: 'Staff Engineer',
|
|
47
|
+
description: '<p>…</p>',
|
|
48
|
+
employmentType: 'full_time',
|
|
49
|
+
remoteOption: 'remote',
|
|
50
|
+
officeLocations: [],
|
|
51
|
+
applicationUrl: 'https://acme.com/apply',
|
|
52
|
+
salaryRangeEnabled: false,
|
|
53
|
+
selectedPlan: plan.id,
|
|
54
|
+
},
|
|
55
|
+
logoUrl: uploaded.publicUrl,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
switch (result.status) {
|
|
59
|
+
case 'checkout':
|
|
60
|
+
location.href = result.checkoutUrl;
|
|
61
|
+
break;
|
|
62
|
+
case 'published':
|
|
63
|
+
linkToJob(result.jobSlug);
|
|
64
|
+
break;
|
|
65
|
+
case 'pending_approval':
|
|
66
|
+
showPending(result.jobId);
|
|
67
|
+
break;
|
|
68
|
+
case 'invoice_sent':
|
|
69
|
+
showInvoiceSent(result.jobId);
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The mapping is:
|
|
75
|
+
|
|
76
|
+
- paid Stripe plan → `checkout`;
|
|
77
|
+
- free or credited posting → `published`, or `pending_approval` on moderated boards;
|
|
78
|
+
- invoice plan → `invoice_sent`.
|
|
79
|
+
|
|
80
|
+
The checkout value is a URL for a host-owned full-page redirect. Payment publication happens by webhook; the SDK exposes no Stripe integration or publish-confirm method.
|
|
81
|
+
|
|
82
|
+
## Verified existing credit
|
|
83
|
+
|
|
84
|
+
Email verification protects billing ownership. Send verification first, then exchange the token for options; a selected option becomes `selectedBilling`.
|
|
85
|
+
|
|
86
|
+
```ts snippet
|
|
87
|
+
await board.jobPosting.sendBillingVerification({ email });
|
|
88
|
+
|
|
89
|
+
const { options } = await board.jobPosting.getBillingOptions({
|
|
90
|
+
verificationToken,
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const option = options[0];
|
|
94
|
+
if (option) {
|
|
95
|
+
option.jobsRemaining;
|
|
96
|
+
option.featuredRemaining;
|
|
97
|
+
option.renewsAt;
|
|
98
|
+
|
|
99
|
+
await board.jobPosting.create({
|
|
100
|
+
submission,
|
|
101
|
+
selectedBilling: {
|
|
102
|
+
type: option.type,
|
|
103
|
+
id: option.id,
|
|
104
|
+
planId: option.planId,
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Bare-email billing lookup is intentionally absent because it would expose whether an address holds credit.
|
|
111
|
+
|
|
112
|
+
## Completion gate
|
|
113
|
+
|
|
114
|
+
- Every `create` call handles all four statuses.
|
|
115
|
+
- Paid checkout redirects through `checkoutUrl`; the job appears only after webhook completion.
|
|
116
|
+
- Free unmoderated submission returns a resolving `jobSlug`.
|
|
117
|
+
- Missing domain logos leave submission usable without `logoUrl`.
|
|
118
|
+
- Existing credit is selectable only after token verification.
|
|
@@ -1,87 +1,110 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: cavuno-board-salaries
|
|
3
|
-
description:
|
|
3
|
+
description: Salary read models with @cavuno/board. Use for title, skill, or location salary indexes and detail, cross-axis pages, or the salary companies hub.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
|
-
#
|
|
6
|
+
# Salaries
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
The salary namespaces return pre-aggregated read models. Render their figures
|
|
9
|
+
directly in the response `currency`; server-side weighted averages, medians,
|
|
10
|
+
percentile bands, and seniority splits are the source of truth.
|
|
9
11
|
|
|
10
|
-
|
|
12
|
+
Single-company salary pages use `companies.salaries` and
|
|
13
|
+
`companies.salaries.category`. A job's own range comes from its
|
|
14
|
+
`salaryMin`/`salaryMax` fields.
|
|
11
15
|
|
|
12
|
-
|
|
13
|
-
- Cross-axis pages: "Software Engineer salary in Berlin", "Python salary by location".
|
|
14
|
-
- The `/salaries/companies` hub.
|
|
16
|
+
## Build axis indexes
|
|
15
17
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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`.
|
|
18
|
+
Titles, skills, and locations each expose `.list()`. Index items contain
|
|
19
|
+
`avgSalaryMin`, `avgSalaryMax`, and sample-size `jobCount`; title and skill
|
|
20
|
+
items also contain `p25SalaryMin`, `p75SalaryMax`, and `currency`.
|
|
28
21
|
|
|
29
22
|
```ts snippet
|
|
30
23
|
const titles = await board.salaries.titles.list();
|
|
31
|
-
for (const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
24
|
+
for (const title of titles.data) {
|
|
25
|
+
title.slug;
|
|
26
|
+
title.name;
|
|
27
|
+
title.avgSalaryMin;
|
|
28
|
+
title.avgSalaryMax;
|
|
29
|
+
title.jobCount;
|
|
35
30
|
}
|
|
36
31
|
const skills = await board.salaries.skills.list();
|
|
37
|
-
const companies = await board.salaries.companies.list();
|
|
32
|
+
const companies = await board.salaries.companies.list();
|
|
38
33
|
```
|
|
39
34
|
|
|
40
|
-
`salaries.
|
|
35
|
+
`salaries.companies.list()` ranks companies by sample size.
|
|
36
|
+
`salaries.locations.list()` returns a flattened place tree: rebuild country,
|
|
37
|
+
region, and city nesting from each `SalaryLocation.parentSlug`; top-level rows
|
|
38
|
+
have `parentSlug: null`.
|
|
41
39
|
|
|
42
|
-
##
|
|
40
|
+
## Retrieve detail and canonicalize slugs
|
|
43
41
|
|
|
44
|
-
`retrieve(slug)` accepts
|
|
42
|
+
Every axis `retrieve(slug)` accepts an inbound English or board-language slug.
|
|
43
|
+
The result includes immutable English `sourceSlug` and board-language
|
|
44
|
+
`canonicalSlug`. Pass `{ locale }` for board-language names and canonical
|
|
45
|
+
slugs, then issue a 308 when the inbound slug differs from `canonicalSlug`.
|
|
46
|
+
The API itself returns data rather than a redirect.
|
|
45
47
|
|
|
46
48
|
```ts snippet
|
|
47
|
-
const title = await board.salaries.titles.retrieve('software-engineer', {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
title.
|
|
51
|
-
title.
|
|
52
|
-
title.
|
|
49
|
+
const title = await board.salaries.titles.retrieve('software-engineer', {
|
|
50
|
+
locale: 'de',
|
|
51
|
+
});
|
|
52
|
+
title.canonicalSlug;
|
|
53
|
+
title.overallSalary;
|
|
54
|
+
title.bySeniority;
|
|
55
|
+
title.topCompanies;
|
|
56
|
+
title.currency;
|
|
53
57
|
|
|
54
58
|
const skill = await board.salaries.skills.retrieve('python');
|
|
55
59
|
const place = await board.salaries.locations.retrieve('berlin');
|
|
56
60
|
```
|
|
57
61
|
|
|
58
|
-
`overallSalary` is
|
|
62
|
+
`overallSalary` is nullable. Render an empty state when it is `null`.
|
|
63
|
+
Title detail contains `{ avgMin, avgMax, p25Min, p75Max, jobCount }` there and
|
|
64
|
+
exposes medians as top-level `boardMedianMin` and `boardMedianMax`. Skill and
|
|
65
|
+
location details instead include `medianMin` and `medianMax` inside
|
|
66
|
+
`overallSalary`. Title and skill `bySeniority` rows include board-comparison
|
|
67
|
+
fields such as `boardAvgMin` and `diffPercent`.
|
|
59
68
|
|
|
60
|
-
##
|
|
69
|
+
## Build cross-axis pages
|
|
61
70
|
|
|
62
|
-
Titles and skills
|
|
71
|
+
Titles and skills expose `.locations(slug)` and
|
|
72
|
+
`.location(slug, locationSlug)`. Locations expose `.titles(slug)` and
|
|
73
|
+
`.skills(slug)`.
|
|
63
74
|
|
|
64
75
|
```ts snippet
|
|
65
|
-
const
|
|
66
|
-
|
|
76
|
+
const index = await board.salaries.titles.locations('software-engineer');
|
|
77
|
+
index.locations;
|
|
67
78
|
|
|
68
|
-
const berlin = await board.salaries.titles.location(
|
|
69
|
-
|
|
79
|
+
const berlin = await board.salaries.titles.location(
|
|
80
|
+
'software-engineer',
|
|
81
|
+
'berlin');
|
|
82
|
+
berlin.categoryCanonicalSlug;
|
|
70
83
|
berlin.locationCanonicalSlug;
|
|
71
|
-
berlin.overallSalary;
|
|
84
|
+
berlin.overallSalary;
|
|
72
85
|
|
|
73
|
-
const
|
|
86
|
+
const titlesInBerlin = await board.salaries.locations.titles('berlin');
|
|
74
87
|
const skillsInBerlin = await board.salaries.locations.skills('berlin');
|
|
75
88
|
```
|
|
76
89
|
|
|
77
|
-
|
|
90
|
+
A cross-axis detail carries four slug fields: source and canonical slugs for
|
|
91
|
+
both category and location. Canonicalize both URL segments. Its
|
|
92
|
+
`overallSalary.p25Min` and `p75Max` may be null.
|
|
93
|
+
|
|
94
|
+
## Preserve aggregate semantics
|
|
95
|
+
|
|
96
|
+
Display response fields as-is. Client arithmetic such as averaging min/max,
|
|
97
|
+
deriving medians from percentiles, summing rails, or converting currencies
|
|
98
|
+
changes the weighted meaning and diverges from the hosted board. Use
|
|
99
|
+
`jobCount`, rather than `data.length`, for sample-size copy.
|
|
78
100
|
|
|
79
|
-
|
|
101
|
+
## Completion gate
|
|
80
102
|
|
|
81
|
-
|
|
103
|
+
Finish only after every applicable check passes:
|
|
82
104
|
|
|
83
|
-
-
|
|
84
|
-
|
|
85
|
-
-
|
|
86
|
-
-
|
|
87
|
-
-
|
|
105
|
+
- Stale axis and cross-axis slugs produce 308s to every returned canonical
|
|
106
|
+
segment.
|
|
107
|
+
- A non-English board passes `{ locale }` and renders board-language names.
|
|
108
|
+
- `overallSalary: null` renders an empty state rather than `0` or `NaN`.
|
|
109
|
+
- Every salary, comparison, and sample size maps directly to a response field
|
|
110
|
+
and uses the response currency.
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cavuno-board-search-suggestions
|
|
3
|
+
description: Build server-ranked search suggestions with @cavuno/board. Use for company-and-term typeahead, autocomplete dropdowns, or suggestion-controller wiring.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Server-ranked search suggestions
|
|
7
|
+
|
|
8
|
+
`board.search.suggest` returns one interleaved list of companies and taxonomy terms. Server rank is the contract: render its order unchanged. For UI, the headless controller adds debounce, abort, and stale-result dropping.
|
|
9
|
+
|
|
10
|
+
Location autocomplete uses `board.taxonomy.places.list({ q })`; filter option lists use `board.taxonomy.categories.list`, `board.taxonomy.skills.list`, or `board.taxonomy.suggestions.list`; job results use `board.jobs.list` or `board.jobs.search`.
|
|
11
|
+
|
|
12
|
+
## Read suggestions
|
|
13
|
+
|
|
14
|
+
```ts snippet
|
|
15
|
+
const { items, query } = await board.search.suggest({
|
|
16
|
+
q: 'acme',
|
|
17
|
+
limit: 10,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
for (const item of items) {
|
|
21
|
+
if (item.type === 'company') {
|
|
22
|
+
item.slug;
|
|
23
|
+
item.name;
|
|
24
|
+
item.jobCount;
|
|
25
|
+
} else {
|
|
26
|
+
item.termType;
|
|
27
|
+
item.displayName;
|
|
28
|
+
item.canonicalSlug;
|
|
29
|
+
item.sourceSlug;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Queries shorter than two characters return no items. `limit` accepts 1–25 and defaults to 25.
|
|
35
|
+
|
|
36
|
+
Term suggestions have `termType: 'category' | 'skill'`; use `canonicalSlug` for links and `sourceSlug` for job filters.
|
|
37
|
+
|
|
38
|
+
## Drive UI with the controller
|
|
39
|
+
|
|
40
|
+
```ts snippet
|
|
41
|
+
import { createSuggestController } from '@cavuno/board/suggest';
|
|
42
|
+
|
|
43
|
+
const suggest = createSuggestController(board, { limit: 10 });
|
|
44
|
+
const unsubscribe = suggest.subscribe(() => {
|
|
45
|
+
const state = suggest.getState();
|
|
46
|
+
renderSuggestions(state.items, state.status, state.query);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
suggest.setQuery(inputValue);
|
|
50
|
+
suggest.setExcludedCompanySlugs(appliedCompanySlugs);
|
|
51
|
+
|
|
52
|
+
unsubscribe();
|
|
53
|
+
suggest.dispose();
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Defaults are a 250 ms debounce and two-character minimum. Previous items remain while status is `loading`. The store is compatible with `useSyncExternalStore`. `setExcludedCompanySlugs` is the view-level exception: it removes company items synchronously without requesting again. Preserve the remaining items and order.
|
|
57
|
+
|
|
58
|
+
The SDK provides the data and controller, while the host owns dropdown markup, keyboard navigation, and framework bindings.
|
|
59
|
+
|
|
60
|
+
## Apply a company suggestion
|
|
61
|
+
|
|
62
|
+
Company slugs are public URL identity. Write the slug into listing filters and pass it as `companySlug`.
|
|
63
|
+
|
|
64
|
+
```ts snippet
|
|
65
|
+
import { parseListingFilters } from '@cavuno/board/filters';
|
|
66
|
+
|
|
67
|
+
const filters = parseListingFilters(rawSearchParams);
|
|
68
|
+
const page = await board.jobs.list({
|
|
69
|
+
limit: 20,
|
|
70
|
+
companySlug: filters.company,
|
|
71
|
+
});
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Unknown slugs are dropped server-side. If none resolve, the listing has `count: 0`. When `companySlug` and `companyId` are both present, the server uses their union.
|
|
75
|
+
|
|
76
|
+
## Completion gate
|
|
77
|
+
|
|
78
|
+
- A controller query below two characters renders no results and makes no request.
|
|
79
|
+
- Rapid typing renders only the final response.
|
|
80
|
+
- Excluded companies disappear without changing the relative order of other items.
|
|
81
|
+
- Selecting a company writes its slug to the URL and sends `companySlug` directly.
|
|
82
|
+
- Teardown unsubscribes listeners and calls `dispose`.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Blog structured data
|
|
2
|
+
|
|
3
|
+
Read this reference for blog-post `Article` and author `ProfilePage` JSON-LD.
|
|
4
|
+
|
|
5
|
+
```ts snippet
|
|
6
|
+
import {
|
|
7
|
+
createAuthorProfileJsonLd,
|
|
8
|
+
createBlogArticleJsonLd,
|
|
9
|
+
} from '@cavuno/board/seo';
|
|
10
|
+
|
|
11
|
+
const { name } = await board.context();
|
|
12
|
+
const post = await board.blog.posts.retrieve('hello-world');
|
|
13
|
+
const article = createBlogArticleJsonLd({
|
|
14
|
+
post,
|
|
15
|
+
boardName: name,
|
|
16
|
+
permalink: `https://jobs.example.com/blog/${post.slug}`,
|
|
17
|
+
ogImageUrl: `https://jobs.example.com/blog/${post.slug}/og`,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const author = await board.blog.authors.retrieve('jane');
|
|
21
|
+
const { data: authorPosts } = await board.blog.posts.list({
|
|
22
|
+
authorSlug: 'jane',
|
|
23
|
+
});
|
|
24
|
+
const profile = createAuthorProfileJsonLd({
|
|
25
|
+
author,
|
|
26
|
+
canonical: `https://jobs.example.com/blog/author/${author.slug}`,
|
|
27
|
+
description: author.bio ?? `Posts by ${author.name}`,
|
|
28
|
+
origin: 'https://jobs.example.com',
|
|
29
|
+
posts: authorPosts,
|
|
30
|
+
totalPosts: authorPosts.length,
|
|
31
|
+
});
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The article `ogImageUrl` is the fallback when the post has no cover. Supply
|
|
35
|
+
author posts newest-first; the profile builder keeps the first five as
|
|
36
|
+
`hasPart`.
|
|
37
|
+
|
|
38
|
+
This branch is complete when the post emits an `Article`, the author emits a
|
|
39
|
+
`ProfilePage`, every URL is absolute and canonical, and a null builder result
|
|
40
|
+
leaves no script tag.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Job detail and breadcrumbs
|
|
2
|
+
|
|
3
|
+
Read this reference for Google for Jobs `JobPosting` or `BreadcrumbList`
|
|
4
|
+
structured data.
|
|
5
|
+
|
|
6
|
+
## Build JobPosting JSON-LD
|
|
7
|
+
|
|
8
|
+
`PublicJob` carries the inputs, including derived remote permit codes.
|
|
9
|
+
Worldwide-remote jobs automatically enumerate all 249 countries because
|
|
10
|
+
Google requires a location signal alongside `TELECOMMUTE`.
|
|
11
|
+
|
|
12
|
+
```ts snippet
|
|
13
|
+
import { createJobPostingJsonLd } from '@cavuno/board/seo';
|
|
14
|
+
|
|
15
|
+
const { name, logoUrl } = await board.context();
|
|
16
|
+
const job = await board.jobs.retrieve('senior-chef');
|
|
17
|
+
|
|
18
|
+
const jsonLd = createJobPostingJsonLd({
|
|
19
|
+
job,
|
|
20
|
+
board: { name, logoUrl },
|
|
21
|
+
shareUrl:
|
|
22
|
+
`https://jobs.example.com/companies/${job.company?.slug}/jobs/${job.slug}`,
|
|
23
|
+
});
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Use the builder as the schema boundary. It encodes pruning, salary mirroring,
|
|
27
|
+
remote-location behavior, and Google for Jobs shapes.
|
|
28
|
+
|
|
29
|
+
## Build BreadcrumbList JSON-LD
|
|
30
|
+
|
|
31
|
+
Labels are trimmed, blank crumbs are dropped, and fewer than two surviving
|
|
32
|
+
crumbs return `null`. The current page omits `href`, which makes the builder
|
|
33
|
+
omit its `item`. Supply absolute hrefs, or pass `origin` for path hrefs.
|
|
34
|
+
|
|
35
|
+
```ts snippet
|
|
36
|
+
import { createBreadcrumbJsonLd } from '@cavuno/board/seo';
|
|
37
|
+
|
|
38
|
+
const trail = createBreadcrumbJsonLd(
|
|
39
|
+
[
|
|
40
|
+
{ label: 'Home', href: '/' },
|
|
41
|
+
{ label: 'Jobs', href: '/jobs' },
|
|
42
|
+
{ label: job.title },
|
|
43
|
+
],
|
|
44
|
+
{ origin: 'https://jobs.example.com' });
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
This branch is complete when the JobPosting passes Google's Rich Results test,
|
|
48
|
+
the worldwide-remote fixture contains 249 countries, and breadcrumb output is
|
|
49
|
+
absent below two usable crumbs.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Listing metadata and JSON-LD
|
|
2
|
+
|
|
3
|
+
Read this reference for a job-listing page's head descriptors and structural
|
|
4
|
+
JSON-LD. These builders are locale-neutral pass-throughs; supply the page's
|
|
5
|
+
resolved heading copy.
|
|
6
|
+
|
|
7
|
+
```ts snippet
|
|
8
|
+
import { listingHead, listingJsonLd } from '@cavuno/board/seo';
|
|
9
|
+
|
|
10
|
+
const { name } = await board.context();
|
|
11
|
+
const head = listingHead({
|
|
12
|
+
boardName: name,
|
|
13
|
+
origin: 'https://jobs.example.com',
|
|
14
|
+
path: '/jobs/robotics',
|
|
15
|
+
heading: 'Robotics jobs',
|
|
16
|
+
count: 42,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const objects = listingJsonLd({
|
|
20
|
+
origin: 'https://jobs.example.com',
|
|
21
|
+
breadcrumbs: [{ name: 'Home', path: '/' }, { name: 'Jobs' }],
|
|
22
|
+
jobs: cards.map((card) => ({
|
|
23
|
+
slug: card.slug,
|
|
24
|
+
company: card.company,
|
|
25
|
+
})),
|
|
26
|
+
});
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
`head.meta` contains title, description, and Open Graph descriptors;
|
|
30
|
+
`head.links` contains the canonical link. For the example, the count-aware
|
|
31
|
+
title is `42 Robotics jobs | Acme Jobs`.
|
|
32
|
+
|
|
33
|
+
This branch is complete when head descriptors reflect the resolved page
|
|
34
|
+
heading and count, the canonical URL combines the intended origin and path,
|
|
35
|
+
and `listingJsonLd` receives card slugs plus company objects from the listing
|
|
36
|
+
response.
|