Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
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.
1 change: 0 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

128 changes: 7 additions & 121 deletions src/slices/main/OpenPositions/OpenPositionsDefault.tsx
Original file line number Diff line number Diff line change
@@ -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<JobOfferCategory>(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 (
<SliceContainer
Expand All @@ -99,71 +47,9 @@ export default function OpenPositionsDefault({
}}
/>
</div>
<FilterButtonGroup<JobOfferCategory>
buttons={categories}
onButtonClick={onCategoryButtonClick}
selectedButtons={selectedCategory}
/>
</div>
<div className="2xl:content">
<div className="mt-6 flex flex-col">
{filteredOffers.map(
({ title, location, category, job_offer_link }, i) => (
<PrismicNextLink
key={`${title}-${i}`}
field={job_offer_link}
>
<div className="group border-neutral-000/10 grid cursor-pointer grid-cols-[1fr_2.5rem] gap-2 border-b py-5 transition lg:grid-cols-[3.5fr_1.5fr_2fr_2.5rem] lg:items-center lg:px-4 lg:hover:bg-neutral-800">
<PrismicRichText
field={title}
components={{
heading4: ({ children }) => (
<h4 className="text-title-large">{children}</h4>
),
}}
/>
<PrismicRichText
field={location}
components={{
paragraph: ({ children }) => (
<span className="text-caption text-neutral-000 hidden lg:block">
{children}
</span>
),
}}
/>
<span className="text-caption text-neutral-000/70 hidden lg:block">
{category}
</span>
<Button
className="row-span-2 h-10 w-10 self-center justify-self-end p-0 group-hover:border-white/24 lg:row-span-1"
variant="secondary"
>
<SvgArrowDownAccordion className="rotate-[-90deg] transform text-white" />
</Button>
<div className="flex items-center gap-0.5 lg:hidden">
<PrismicRichText
field={location}
components={{
paragraph: ({ children }) => (
<span className="text-caption text-neutral-000">
{children}
</span>
),
}}
/>
<span className="text-caption text-neutral-000/70 h-[0.8125rem] w-4">
</span>
<span className="text-caption text-neutral-000/70">
{category}
</span>
</div>
</div>
</PrismicNextLink>
)
)}
</div>
<OpenPositionsList jobs={jobs} />
</div>
<div className="2xl:content mb-8 md:mb-16 md:px-4">
<div className="mt-6 flex flex-col gap-2">
Expand Down
106 changes: 106 additions & 0 deletions src/slices/main/OpenPositions/components/OpenPositionsList.tsx
Original file line number Diff line number Diff line change
@@ -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<string>(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 (
<p className="text-body-small text-neutral-000/70 mt-6">
There are no open positions right now — check back soon.
</p>
)
}

return (
<>
<FilterButtonGroup<string>
buttons={categories}
onButtonClick={onCategoryButtonClick}
selectedButtons={selectedCategory}
/>
<div className="mt-6 flex flex-col">
{filteredJobs.map(
({ id, title, location, category, compensation, url }) => (
<a
key={id}
href={url}
target="_blank"
rel="noopener noreferrer"
>
<div className="group border-neutral-000/10 grid cursor-pointer grid-cols-[1fr_2.5rem] gap-2 border-b py-5 transition lg:grid-cols-[3.5fr_1.5fr_2fr_2.5rem] lg:items-center lg:px-4 lg:hover:bg-neutral-800">
<div>
<h4 className="text-title-large">{title}</h4>
{compensation && (
<span className="text-caption text-neutral-000/50 mt-1 block">
{compensation}
</span>
)}
</div>
<span className="text-caption text-neutral-000 hidden lg:block">
{location}
</span>
<span className="text-caption text-neutral-000/70 hidden lg:block">
{category}
</span>
<Button
className="row-span-2 h-10 w-10 self-center justify-self-end p-0 group-hover:border-white/24 lg:row-span-1"
variant="secondary"
>
<SvgArrowDownAccordion className="rotate-[-90deg] transform text-white" />
</Button>
Comment on lines +78 to +83

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Nested interactive job controls

Each job-row anchor contains a Button that defaults to a native <button>, creating two nested focusable controls for one action and producing redundant focus stops and ambiguous semantics for keyboard and screen-reader users.

Suggested change
<Button
className="row-span-2 h-10 w-10 self-center justify-self-end p-0 group-hover:border-white/24 lg:row-span-1"
variant="secondary"
>
<SvgArrowDownAccordion className="rotate-[-90deg] transform text-white" />
</Button>
<span className="border-white/16 row-span-2 flex h-10 w-10 self-center justify-self-end rounded-full border p-0 group-hover:border-white/24 lg:row-span-1">
<SvgArrowDownAccordion className="m-auto rotate-[-90deg] transform text-white" />
</span>

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

<div className="flex items-center gap-0.5 lg:hidden">
<span className="text-caption text-neutral-000">
{location}
</span>
{category && (
<>
<span className="text-caption text-neutral-000/70 h-[0.8125rem] w-4">
</span>
<span className="text-caption text-neutral-000/70">
{category}
</span>
</>
)}
</div>
</div>
</a>
)
)}
</div>
</>
)
}
93 changes: 93 additions & 0 deletions src/utils/ashby.ts
Original file line number Diff line number Diff line change
@@ -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/<name>).
*/

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<AshbyJob[]> {
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 []
}
}
Loading