chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
export { JobBoard } from './job-board'
|
||||
export { JobGroups } from './job-groups'
|
||||
export { filterPostings, groupByDepartment, hasActiveFilters } from './utils'
|
||||
@@ -0,0 +1,74 @@
|
||||
'use client'
|
||||
|
||||
import { ChipSelect, type ChipSelectOption } from '@sim/emcn'
|
||||
import { useQueryStates } from 'nuqs'
|
||||
import type { CareerPosting } from '@/lib/ashby/jobs'
|
||||
import { JobGroups } from '@/app/(landing)/careers/components/job-board/job-groups'
|
||||
import {
|
||||
filterPostings,
|
||||
groupByDepartment,
|
||||
hasActiveFilters,
|
||||
} from '@/app/(landing)/careers/components/job-board/utils'
|
||||
import {
|
||||
ALL_FILTER_VALUE,
|
||||
careersParsers,
|
||||
careersUrlKeys,
|
||||
} from '@/app/(landing)/careers/search-params'
|
||||
|
||||
interface JobBoardProps {
|
||||
postings: CareerPosting[]
|
||||
}
|
||||
|
||||
/** Builds `{ label, value }` options for a filter, with an "All" row at the top. */
|
||||
function toFilterOptions(values: string[], allLabel: string): ChipSelectOption[] {
|
||||
return [
|
||||
{ label: allLabel, value: ALL_FILTER_VALUE },
|
||||
...values.map((value) => ({ label: value, value })),
|
||||
]
|
||||
}
|
||||
|
||||
/** Distinct, alphabetically sorted values from a list. */
|
||||
function uniqueSorted(values: string[]): string[] {
|
||||
return Array.from(new Set(values)).sort((a, b) => a.localeCompare(b))
|
||||
}
|
||||
|
||||
/**
|
||||
* The interactive open-roles board — the single `'use client'` leaf on the
|
||||
* careers page. Every posting is server-rendered into the HTML (via the static
|
||||
* {@link JobGroups} Suspense fallback in `careers.tsx`), so all roles stay
|
||||
* crawlable; this leaf hydrates on top to add Team/Location filtering. Filter
|
||||
* state lives in the URL via nuqs (`?team=`/`?location=`) so a filtered view is
|
||||
* shareable and survives reload/back-forward. The filter set is small and
|
||||
* static, so filtering reads the instant URL value directly (no debounce).
|
||||
*/
|
||||
export function JobBoard({ postings }: JobBoardProps) {
|
||||
const [{ team, location }, setFilters] = useQueryStates(careersParsers, careersUrlKeys)
|
||||
|
||||
const teamOptions = toFilterOptions(uniqueSorted(postings.map((p) => p.department)), 'All teams')
|
||||
const locationOptions = toFilterOptions(
|
||||
uniqueSorted(postings.flatMap((p) => (p.location ? [p.location] : []))),
|
||||
'All locations'
|
||||
)
|
||||
const groups = groupByDepartment(filterPostings(postings, team, location))
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-10'>
|
||||
<div className='flex flex-wrap items-center gap-3'>
|
||||
<ChipSelect
|
||||
options={teamOptions}
|
||||
value={team}
|
||||
onChange={(value) => setFilters({ team: value })}
|
||||
aria-label='Filter roles by team'
|
||||
/>
|
||||
<ChipSelect
|
||||
options={locationOptions}
|
||||
value={location}
|
||||
onChange={(value) => setFilters({ location: value })}
|
||||
aria-label='Filter roles by location'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<JobGroups groups={groups} filtersActive={hasActiveFilters(team, location)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { cn } from '@sim/emcn'
|
||||
import { ArrowRight } from '@sim/emcn/icons'
|
||||
import type { CareerPosting } from '@/lib/ashby/jobs'
|
||||
import type { DepartmentGroup } from '@/app/(landing)/careers/components/job-board/utils'
|
||||
|
||||
/** Empty-state copy: distinguishes a truly empty board from a filtered-to-zero view. */
|
||||
const NO_OPEN_ROLES_MESSAGE = 'No open roles right now — check back soon.'
|
||||
const NO_MATCHING_ROLES_MESSAGE =
|
||||
'No roles match these filters right now. Try clearing them, or check back soon.'
|
||||
|
||||
interface JobGroupsProps {
|
||||
groups: DepartmentGroup[]
|
||||
/**
|
||||
* Whether a Team/Location filter is active. Selects the empty-state copy so an
|
||||
* unfiltered empty board ("no open roles") never reads as a filtered miss ("no
|
||||
* matches") — and the server fallback and client board always agree.
|
||||
*/
|
||||
filtersActive?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* The presentational open-roles list: one labeled section per department, each a
|
||||
* list of {@link JobRow}s. Server-safe (no client hooks) so it renders both as
|
||||
* the static Suspense fallback and inside the client {@link JobBoard}.
|
||||
*/
|
||||
export function JobGroups({ groups, filtersActive = false }: JobGroupsProps) {
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<p className='py-10 text-[var(--text-muted)] text-base'>
|
||||
{filtersActive ? NO_MATCHING_ROLES_MESSAGE : NO_OPEN_ROLES_MESSAGE}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-12'>
|
||||
{groups.map((group) => (
|
||||
<section
|
||||
key={group.department}
|
||||
aria-label={`${group.department} roles`}
|
||||
className='flex flex-col'
|
||||
>
|
||||
<h3 className='pb-2 font-medium text-[var(--text-muted)] text-sm'>{group.department}</h3>
|
||||
<ul className='flex flex-col'>
|
||||
{group.postings.map((posting) => (
|
||||
<li key={posting.id}>
|
||||
<JobRow posting={posting} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface JobRowProps {
|
||||
posting: CareerPosting
|
||||
}
|
||||
|
||||
/**
|
||||
* A single role row: title over a metadata line, with an "Apply" affordance that
|
||||
* links out to the posting on Ashby. The whole row is the link target; hovering
|
||||
* tints the row and advances the arrow. The metadata values are de-duplicated
|
||||
* because a remote posting normalizes both `location` and `workplaceType` to
|
||||
* "Remote", which would otherwise render "Remote · Remote" and collide as keys.
|
||||
*/
|
||||
function JobRow({ posting }: JobRowProps) {
|
||||
const meta = Array.from(
|
||||
new Set(
|
||||
[
|
||||
posting.location,
|
||||
posting.employmentType,
|
||||
posting.workplaceType,
|
||||
posting.compensationSummary,
|
||||
].filter((value): value is string => Boolean(value))
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
<a
|
||||
href={posting.jobUrl}
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={cn(
|
||||
'-mx-3 group flex items-center justify-between gap-6 rounded-lg border-[var(--border)]',
|
||||
'border-t px-3 py-5 transition-colors hover:bg-[var(--surface-hover)]'
|
||||
)}
|
||||
>
|
||||
<div className='flex min-w-0 flex-col gap-1.5'>
|
||||
<h4 className='truncate font-medium text-[var(--text-primary)] text-base'>
|
||||
{posting.title}
|
||||
</h4>
|
||||
<div className='flex flex-wrap items-center gap-x-2 gap-y-1 text-[var(--text-muted)] text-sm'>
|
||||
{meta.map((item, index) => (
|
||||
<span key={item} className='flex items-center gap-2'>
|
||||
{index > 0 && (
|
||||
<span aria-hidden className='text-[var(--text-muted)]'>
|
||||
·
|
||||
</span>
|
||||
)}
|
||||
{item}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className='flex shrink-0 items-center gap-1.5 font-medium text-[var(--text-body)] text-sm'>
|
||||
Apply
|
||||
<ArrowRight className='size-[14px] text-[var(--text-icon)] transition-transform group-hover:translate-x-0.5' />
|
||||
</span>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { CareerPosting } from '@/lib/ashby/jobs'
|
||||
import { ALL_FILTER_VALUE } from '@/app/(landing)/careers/search-params'
|
||||
|
||||
export interface DepartmentGroup {
|
||||
department: string
|
||||
postings: CareerPosting[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrows postings to a selected Team and Location, treating {@link ALL_FILTER_VALUE}
|
||||
* as "any". Shared by the server-rendered fallback and the client board so a
|
||||
* deep-linked filter resolves to the exact same set on both sides.
|
||||
*/
|
||||
export function filterPostings(
|
||||
postings: CareerPosting[],
|
||||
team: string,
|
||||
location: string
|
||||
): CareerPosting[] {
|
||||
return postings.filter(
|
||||
(posting) =>
|
||||
(team === ALL_FILTER_VALUE || posting.department === team) &&
|
||||
(location === ALL_FILTER_VALUE || posting.location === location)
|
||||
)
|
||||
}
|
||||
|
||||
/** Whether either the Team or Location filter is narrowing the board. */
|
||||
export function hasActiveFilters(team: string, location: string): boolean {
|
||||
return team !== ALL_FILTER_VALUE || location !== ALL_FILTER_VALUE
|
||||
}
|
||||
|
||||
/**
|
||||
* Buckets postings by department, preserving their incoming order (the fetcher
|
||||
* pre-sorts by department then title). Shared by the interactive board and its
|
||||
* static Suspense fallback so the two can never render a different grouping.
|
||||
*/
|
||||
export function groupByDepartment(postings: CareerPosting[]): DepartmentGroup[] {
|
||||
const byDepartment = new Map<string, CareerPosting[]>()
|
||||
for (const posting of postings) {
|
||||
const bucket = byDepartment.get(posting.department)
|
||||
if (bucket) bucket.push(posting)
|
||||
else byDepartment.set(posting.department, [posting])
|
||||
}
|
||||
return Array.from(byDepartment, ([department, items]) => ({ department, postings: items }))
|
||||
}
|
||||
Reference in New Issue
Block a user