From 611c328961c2ee679d95e96900872dd71b60bf35 Mon Sep 17 00:00:00 2001 From: Sam Weaver Date: Thu, 13 Aug 2026 12:41:54 -0400 Subject: [PATCH] Replace Prismic open positions with Ashby-sourced roles Swap the careers page "Open positions" list from Prismic-managed job offers to Plural's live Ashby job board, sourced via Ashby's public Job Posting API. - Add getAshbyJobs() (src/utils/ashby.ts): server-side fetch of the Ashby posting API with hourly ISR revalidation, filtered to listed roles and normalized for the UI. Degrades to an empty list on error. - Make OpenPositionsDefault an async server component that fetches the roles server-side (same pattern as BlogDefault). The Prismic-driven eyebrow/title header and "don't see your role?" CTA are unchanged. - Move the filterable list into a client component (OpenPositionsList) that reuses the existing FilterButtonGroup/Button/row markup, adds a compensation line, and links each role to its Ashby posting page. No Prismic content change is required; the open_positions slice stays on the careers page. Board name is overridable via NEXT_PUBLIC_ASHBY_JOB_BOARD_NAME (defaults to "Plural"). Co-Authored-By: Claude Opus 4.8 --- next-env.d.ts | 2 +- package-lock.json | 1 - .../OpenPositions/OpenPositionsDefault.tsx | 128 +----------------- .../components/OpenPositionsList.tsx | 106 +++++++++++++++ src/utils/ashby.ts | 93 +++++++++++++ 5 files changed, 207 insertions(+), 123 deletions(-) create mode 100644 src/slices/main/OpenPositions/components/OpenPositionsList.tsx create mode 100644 src/utils/ashby.ts diff --git a/next-env.d.ts b/next-env.d.ts index c4b7818f..9edff1c7 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/package-lock.json b/package-lock.json index 8cfbbb5b..cd2f6143 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11219,7 +11219,6 @@ "version": "8.5.22", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", "integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==", - "dev": true, "funding": [ { "type": "opencollective", diff --git a/src/slices/main/OpenPositions/OpenPositionsDefault.tsx b/src/slices/main/OpenPositions/OpenPositionsDefault.tsx index 30db4c7e..33f886f5 100644 --- a/src/slices/main/OpenPositions/OpenPositionsDefault.tsx +++ b/src/slices/main/OpenPositions/OpenPositionsDefault.tsx @@ -1,82 +1,30 @@ -'use client' - import type { Content } from '@prismicio/client' import { asLinkAttrs } from '@prismicio/client' import { PrismicNextLink } from '@prismicio/next' import { PrismicRichText } from '@prismicio/react' -import * as React from 'react' -import { useMemo, useState } from 'react' import type { SliceVariationProps } from '@/types/prismicio' -import FilterButtonGroup from '@/components/FilterButtonGroup' import SliceContainer from '@/components/SliceContainer' -import SvgArrowDownAccordion from '@/components/svg/SvgArrowDownAccordion' import { Button } from '@/components/ui/Button' import Eyebrow from '@/components/ui/Eyebrow' +import { getAshbyJobs } from '@/utils/ashby' -const VIEW_ALL = 'View All' -type JobOfferRawCategory = Content.JobOfferCategoryDocumentData['name'] -type JobOfferCategory = JobOfferRawCategory | typeof VIEW_ALL -type MappedJobOffer = Content.OpenPositionsSliceDefaultPrimaryJobOffersItem & { - category: JobOfferRawCategory -} +import OpenPositionsList from './components/OpenPositionsList' export type OpenPositionsDefaultProps = SliceVariationProps< Content.OpenPositionsSlice, 'default' > -export default function OpenPositionsDefault({ +export default async function OpenPositionsDefault({ slice, }: OpenPositionsDefaultProps) { - const { - eyebrow, - title, - job_offers, - bottom_title, - bottom_description, - hire_link, - } = slice.primary - - const [selectedCategory, setSelectedCategory] = - useState(VIEW_ALL) - - const mappedJobOffers = useMemo((): MappedJobOffer[] => { - return job_offers - .map((jobOffer) => { - const { job_offer_category } = jobOffer - if ( - job_offer_category?.link_type !== 'Document' || - !job_offer_category?.data - ) { - return jobOffer - } - return { ...jobOffer, category: job_offer_category.data.name } - }) - .filter(Boolean) as MappedJobOffer[] - }, [job_offers]) + const { eyebrow, title, bottom_title, bottom_description, hire_link } = + slice.primary - const categories = useMemo((): JobOfferCategory[] => { - const mappedCategories = [ - ...new Set(mappedJobOffers.map(({ category }) => category)), - ] as JobOfferCategory[] - return [VIEW_ALL, ...mappedCategories] - }, [mappedJobOffers]) - - const onCategoryButtonClick = (team: JobOfferCategory) => () => - setSelectedCategory(team) - - const filteredOffers = useMemo( - () => - selectedCategory === VIEW_ALL - ? mappedJobOffers - : mappedJobOffers.filter( - ({ category }) => category === selectedCategory - ), - [mappedJobOffers, selectedCategory] - ) + const jobs = await getAshbyJobs() return ( - - buttons={categories} - onButtonClick={onCategoryButtonClick} - selectedButtons={selectedCategory} - />
-
- {filteredOffers.map( - ({ title, location, category, job_offer_link }, i) => ( - -
- ( -

{children}

- ), - }} - /> - ( - - {children} - - ), - }} - /> - - {category} - - -
- ( - - {children} - - ), - }} - /> - - ・ - - - {category} - -
-
-
- ) - )} -
+
diff --git a/src/slices/main/OpenPositions/components/OpenPositionsList.tsx b/src/slices/main/OpenPositions/components/OpenPositionsList.tsx new file mode 100644 index 00000000..f796c5fc --- /dev/null +++ b/src/slices/main/OpenPositions/components/OpenPositionsList.tsx @@ -0,0 +1,106 @@ +'use client' + +import { useMemo, useState } from 'react' + +import type { AshbyJob } from '@/utils/ashby' + +import FilterButtonGroup from '@/components/FilterButtonGroup' +import SvgArrowDownAccordion from '@/components/svg/SvgArrowDownAccordion' +import { Button } from '@/components/ui/Button' + +const VIEW_ALL = 'View All' + +export type OpenPositionsListProps = { + jobs: AshbyJob[] +} + +export default function OpenPositionsList({ jobs }: OpenPositionsListProps) { + const [selectedCategory, setSelectedCategory] = useState(VIEW_ALL) + + const categories = useMemo( + () => [ + VIEW_ALL, + ...new Set(jobs.map(({ category }) => category).filter(Boolean)), + ], + [jobs] + ) + + const onCategoryButtonClick = (category: string) => () => + setSelectedCategory(category) + + const filteredJobs = useMemo( + () => + selectedCategory === VIEW_ALL + ? jobs + : jobs.filter(({ category }) => category === selectedCategory), + [jobs, selectedCategory] + ) + + if (jobs.length === 0) { + return ( +

+ There are no open positions right now — check back soon. +

+ ) + } + + return ( + <> + + buttons={categories} + onButtonClick={onCategoryButtonClick} + selectedButtons={selectedCategory} + /> + + + ) +} diff --git a/src/utils/ashby.ts b/src/utils/ashby.ts new file mode 100644 index 00000000..ed8ca4c5 --- /dev/null +++ b/src/utils/ashby.ts @@ -0,0 +1,93 @@ +/** + * Server-side fetch of open roles from Ashby's public Job Posting API. + * + * Docs: https://developers.ashbyhq.com/reference/job-posting-api + * The board name is the slug in your Ashby board URL (jobs.ashbyhq.com/). + */ + +const ASHBY_JOB_BOARD_NAME = + process.env.NEXT_PUBLIC_ASHBY_JOB_BOARD_NAME?.trim() || 'Plural' + +const ASHBY_POSTING_API = `https://api.ashbyhq.com/posting-api/job-board/${ASHBY_JOB_BOARD_NAME}?includeCompensation=true` + +// Roles change often; re-fetch at most once an hour (ISR). +const REVALIDATE_SECONDS = 60 * 60 + +/** Raw shape returned by the Ashby posting API (only the fields we use). */ +type AshbyApiJob = { + id: string + title: string + team?: string + department?: string + location?: string + workplaceType?: string + employmentType?: string + isRemote?: boolean + isListed?: boolean + jobUrl: string + applyUrl: string + compensation?: { + compensationTierSummary?: string | null + } | null +} + +type AshbyApiResponse = { + jobs?: AshbyApiJob[] +} + +/** Normalized job used by the UI. */ +export type AshbyJob = { + id: string + title: string + /** Team/department — used as the filterable category. */ + category: string + /** Human-readable location, e.g. "New York City · Hybrid". */ + location: string + /** Compensation summary, e.g. "$175K – $200K • Offers Equity", if published. */ + compensation: string | null + /** External Ashby posting URL (description + apply button). */ + url: string +} + +function toLocationLabel(job: AshbyApiJob): string { + const parts = [job.location, job.workplaceType].filter(Boolean) + return parts.join(' · ') +} + +function normalize(job: AshbyApiJob): AshbyJob { + return { + id: job.id, + title: job.title, + category: job.team || job.department || '', + location: toLocationLabel(job), + compensation: job.compensation?.compensationTierSummary ?? null, + url: job.jobUrl, + } +} + +/** + * Fetches the currently-listed open roles from Ashby. Runs on the server, + * so it avoids CORS and keeps the third-party script off the page. Returns an + * empty array on any error so the section degrades gracefully. + */ +export async function getAshbyJobs(): Promise { + try { + const res = await fetch(ASHBY_POSTING_API, { + next: { revalidate: REVALIDATE_SECONDS }, + }) + + if (!res.ok) { + console.error(`Ashby posting API returned ${res.status}`) + return [] + } + + const data = (await res.json()) as AshbyApiResponse + + return (data.jobs ?? []) + .filter((job) => job.isListed !== false) + .map(normalize) + } catch (error) { + console.error('Failed to fetch Ashby jobs', error) + return [] + } +}