Skip to content
Merged
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
93 changes: 93 additions & 0 deletions apps/sim/app/(landing)/careers/careers.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { Suspense } from 'react'
import type { SearchParams } from 'nuqs/server'
import { getAshbyJobs } from '@/lib/ashby/jobs'
import {
filterPostings,
groupByDepartment,
hasActiveFilters,
JobBoard,
JobGroups,
} from '@/app/(landing)/careers/components/job-board'
import { careersSearchParamsCache } from '@/app/(landing)/careers/search-params'
import { TrustedBy } from '@/app/(landing)/components/trusted-by'

interface CareersProps {
searchParams: Promise<SearchParams>
}

/**
* The careers page — a mission-led hero above the live open-roles board. Roles
* are pulled from Sim's public Ashby job board at build/revalidate time
* ({@link getAshbyJobs}) and server-rendered in full, so every posting is in the
* crawlable HTML; the interactive {@link JobBoard} hydrates on top to add
* Team/Location filtering.
*
* Both sections share the landing gutter — capped and centered at `max-w-[1446px]`
* with the navbar-aligned `px-12 max-lg:px-8 max-sm:px-5` so the headline starts on
* the same vertical line as the wordmark. The hero carries the single `<h1>`
* (containing "Sim" and "AI workspace") plus an sr-only product summary for AI
* citation (landing CLAUDE.md → GEO); the roles section owns its own `<h2>`.
*
* Because {@link JobBoard} reads the URL via nuqs (`useSearchParams`), it sits under
* a `<Suspense>` boundary. The page parses the same `?team=`/`?location=` query on
* the server ({@link careersSearchParamsCache}) and pre-filters the fallback to
* match, so a deep-linked filter renders the correct roles server-side — the list
* never flashes unfiltered before the client board hydrates.
*/
export default async function Careers({ searchParams }: CareersProps) {
const { team, location } = await careersSearchParamsCache.parse(searchParams)
const postings = await getAshbyJobs()
const fallbackGroups = groupByDepartment(filterPostings(postings, team, location))

return (
<main id='main-content'>
<section
id='careers-hero'
aria-labelledby='careers-heading'
className='mx-auto flex w-full max-w-[1446px] flex-col gap-5 px-12 pt-20 pb-10 max-sm:px-5 max-sm:pt-16 max-lg:px-8'
>
<p className='sr-only'>
Careers at Sim, the open-source AI workspace where teams build, deploy, and manage AI
agents. Sim is hiring engineers, designers, and go-to-market builders to help teams
automate real work across 1,000+ integrations and every major LLM — visually,
conversationally, or with code.
</p>

<h1
id='careers-heading'
className='max-w-[24ch] text-balance text-[48px] text-[var(--text-primary)] leading-[1.1] max-sm:text-[32px] max-xl:text-[40px]'
>
Help build Sim, the AI workspace for teams.
</h1>
<p className='max-w-[60ch] text-pretty text-[var(--text-body)] text-lg leading-[1.5] max-sm:text-base'>
Sim is the open-source AI workspace where teams build, deploy, and manage AI agents. We're
a small, high-agency team shipping fast to thousands of builders. If you want to own real
work and shape the workspace teams live in, we'd love to meet you.
</p>
</section>

<section
id='open-roles'
aria-labelledby='open-roles-heading'
className='mx-auto flex w-full max-w-[1446px] flex-col gap-10 px-12 pt-6 pb-24 max-sm:px-5 max-sm:pb-16 max-lg:px-8'
>
<h2
id='open-roles-heading'
className='text-[24px] text-[var(--text-primary)] leading-[110%] tracking-[-0.02em]'
>
Open roles
</h2>

<Suspense
fallback={
<JobGroups groups={fallbackGroups} filtersActive={hasActiveFilters(team, location)} />
}
>
<JobBoard postings={postings} />
</Suspense>
Comment thread
waleedlatif1 marked this conversation as resolved.

<TrustedBy className='pt-6' />
</section>
</main>
)
}
2 changes: 2 additions & 0 deletions apps/sim/app/(landing)/careers/components/job-board/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { JobBoard } from './job-board'
export { filterPostings, groupByDepartment, hasActiveFilters, JobGroups } from './job-groups'
74 changes: 74 additions & 0 deletions apps/sim/app/(landing)/careers/components/job-board/job-board.tsx
Original file line number Diff line number Diff line change
@@ -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 {
filterPostings,
groupByDepartment,
hasActiveFilters,
JobGroups,
} from '@/app/(landing)/careers/components/job-board/job-groups'
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.map((p) => p.location).filter(Boolean)),
'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>
)
}
156 changes: 156 additions & 0 deletions apps/sim/app/(landing)/careers/components/job-board/job-groups.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { cn } from '@sim/emcn'
import { ArrowRight } from '@sim/emcn/icons'
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
}

/** 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.'

/**
* 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 }))
}

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(
'group flex items-center justify-between gap-6 border-[var(--border)] border-t 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'>
Comment thread
waleedlatif1 marked this conversation as resolved.
{index > 0 && (
<span aria-hidden className='text-[var(--text-muted)]'>
·
</span>
)}
{item}
</span>
))}
Comment thread
waleedlatif1 marked this conversation as resolved.
</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>
)
}
17 changes: 17 additions & 0 deletions apps/sim/app/(landing)/careers/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { SearchParams } from 'nuqs/server'
import { buildLandingMetadata } from '@/lib/landing/seo'
import Careers from '@/app/(landing)/careers/careers'

export const revalidate = 3600

export const metadata = buildLandingMetadata({
title: 'Careers at Sim — Build the AI workspace for teams',
description:
'Join Sim, the open-source AI workspace where teams build, deploy, and manage AI agents. See open engineering, design, and go-to-market roles.',
path: '/careers',
keywords: 'Sim careers, Sim jobs, AI workspace jobs, AI agent engineering jobs, open source jobs',
})

export default function Page({ searchParams }: { searchParams: Promise<SearchParams> }) {
return <Careers searchParams={searchParams} />
}
35 changes: 35 additions & 0 deletions apps/sim/app/(landing)/careers/search-params.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { createSearchParamsCache, parseAsString } from 'nuqs/server'

/**
* Sentinel value for an inactive filter — matches every posting. Namespaced with
* underscores so it can never collide with a real Ashby department or location
* value (e.g. a team literally named "all").
*/
export const ALL_FILTER_VALUE = '__all__'

/**
* Co-located, typed URL query params for the careers job board's Team and
* Location filters. Shareable, deep-linkable view-state over an already-rendered
* list, so it lives in the URL (nuqs) — never in a store. The values are dynamic
* (departments/locations come from the live board), so plain string parsers with
* an `all` sentinel default rather than a fixed literal set.
*/
export const careersParsers = {
team: parseAsString.withDefault(ALL_FILTER_VALUE),
location: parseAsString.withDefault(ALL_FILTER_VALUE),
} as const

/** Clean URLs, no back-stack churn — the filters are a passive view switch. */
export const careersUrlKeys = {
history: 'replace',
shallow: true,
clearOnDefault: true,
} as const

/**
* Server-side reader for the same parser map. The page parses the request's
* query with this so the statically-rendered fallback is filtered to match a
* deep-linked `?team=`/`?location=` URL — the roles never flash unfiltered before
* the client board hydrates.
*/
export const careersSearchParamsCache = createSearchParamsCache(careersParsers)
2 changes: 1 addition & 1 deletion apps/sim/app/(landing)/components/footer/footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ const RESOURCES_LINKS: FooterItem[] = [
{ label: 'Blog', href: '/blog' },
{ label: 'Docs', href: 'https://docs.sim.ai', external: true },
{ label: 'Partners', href: '/partners' },
{ label: 'Careers', href: 'https://jobs.ashbyhq.com/sim', external: true },
{ label: 'Careers', href: '/careers' },
{ label: 'Changelog', href: '/changelog' },
{ label: 'Contact', href: '/contact' },
]
Expand Down
Loading
Loading