diff --git a/apps/sim/app/(landing)/careers/careers.tsx b/apps/sim/app/(landing)/careers/careers.tsx new file mode 100644 index 00000000000..8cd927f390f --- /dev/null +++ b/apps/sim/app/(landing)/careers/careers.tsx @@ -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 +} + +/** + * 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 `

` + * (containing "Sim" and "AI workspace") plus an sr-only product summary for AI + * citation (landing CLAUDE.md → GEO); the roles section owns its own `

`. + * + * Because {@link JobBoard} reads the URL via nuqs (`useSearchParams`), it sits under + * a `` 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 ( +
+
+

+ 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. +

+ +

+ Help build Sim, the AI workspace for teams. +

+

+ 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. +

+
+ +
+

+ Open roles +

+ + + } + > + + + + +
+
+ ) +} diff --git a/apps/sim/app/(landing)/careers/components/job-board/index.ts b/apps/sim/app/(landing)/careers/components/job-board/index.ts new file mode 100644 index 00000000000..df3091dfa1d --- /dev/null +++ b/apps/sim/app/(landing)/careers/components/job-board/index.ts @@ -0,0 +1,2 @@ +export { JobBoard } from './job-board' +export { filterPostings, groupByDepartment, hasActiveFilters, JobGroups } from './job-groups' diff --git a/apps/sim/app/(landing)/careers/components/job-board/job-board.tsx b/apps/sim/app/(landing)/careers/components/job-board/job-board.tsx new file mode 100644 index 00000000000..70e3478cc18 --- /dev/null +++ b/apps/sim/app/(landing)/careers/components/job-board/job-board.tsx @@ -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 ( +
+
+ setFilters({ team: value })} + aria-label='Filter roles by team' + /> + setFilters({ location: value })} + aria-label='Filter roles by location' + /> +
+ + +
+ ) +} diff --git a/apps/sim/app/(landing)/careers/components/job-board/job-groups.tsx b/apps/sim/app/(landing)/careers/components/job-board/job-groups.tsx new file mode 100644 index 00000000000..885f4ae487f --- /dev/null +++ b/apps/sim/app/(landing)/careers/components/job-board/job-groups.tsx @@ -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() + 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 ( +

+ {filtersActive ? NO_MATCHING_ROLES_MESSAGE : NO_OPEN_ROLES_MESSAGE} +

+ ) + } + + return ( +
+ {groups.map((group) => ( +
+

{group.department}

+
    + {group.postings.map((posting) => ( +
  • + +
  • + ))} +
+
+ ))} +
+ ) +} + +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 ( + +
+

+ {posting.title} +

+
+ {meta.map((item, index) => ( + + {index > 0 && ( + + · + + )} + {item} + + ))} +
+
+ + + Apply + + +
+ ) +} diff --git a/apps/sim/app/(landing)/careers/page.tsx b/apps/sim/app/(landing)/careers/page.tsx new file mode 100644 index 00000000000..b9a3e5ad55f --- /dev/null +++ b/apps/sim/app/(landing)/careers/page.tsx @@ -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 }) { + return +} diff --git a/apps/sim/app/(landing)/careers/search-params.ts b/apps/sim/app/(landing)/careers/search-params.ts new file mode 100644 index 00000000000..282c0c8835e --- /dev/null +++ b/apps/sim/app/(landing)/careers/search-params.ts @@ -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) diff --git a/apps/sim/app/(landing)/components/footer/footer.tsx b/apps/sim/app/(landing)/components/footer/footer.tsx index 6eddc8f736e..7be79223df4 100644 --- a/apps/sim/app/(landing)/components/footer/footer.tsx +++ b/apps/sim/app/(landing)/components/footer/footer.tsx @@ -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' }, ] diff --git a/apps/sim/app/sitemap.ts b/apps/sim/app/sitemap.ts index 668ff239095..417a9bc28a8 100644 --- a/apps/sim/app/sitemap.ts +++ b/apps/sim/app/sitemap.ts @@ -45,6 +45,9 @@ export default async function sitemap(): Promise { { url: `${baseUrl}/contact`, }, + { + url: `${baseUrl}/careers`, + }, { url: `${baseUrl}/enterprise`, }, diff --git a/apps/sim/lib/ashby/jobs.ts b/apps/sim/lib/ashby/jobs.ts new file mode 100644 index 00000000000..e25dbb9d072 --- /dev/null +++ b/apps/sim/lib/ashby/jobs.ts @@ -0,0 +1,173 @@ +import { createLogger } from '@sim/logger' +import { z } from 'zod' + +const logger = createLogger('AshbyJobs') + +/** + * The Ashby-hosted job board slug for Sim — the final path segment of + * `https://jobs.ashbyhq.com/sim`. Drives the public, no-auth job posting API. + */ +const ASHBY_JOB_BOARD_NAME = 'sim' + +/** Public job posting API — returns every listed posting in one payload, no auth. */ +const ASHBY_JOB_BOARD_URL = `https://api.ashbyhq.com/posting-api/job-board/${ASHBY_JOB_BOARD_NAME}?includeCompensation=true` + +/** Revalidate the board hourly, shared across every render (build/revalidate-time cache). */ +const REVALIDATE_SECONDS = 3600 + +/** + * An `http(s)`-only URL. `z.string().url()` alone accepts `javascript:`/`data:` + * (both parse as valid URLs), which would render as a live link, so the scheme is + * pinned explicitly — a posting whose `jobUrl` fails this is dropped rather than + * published as a clickable Apply link. + */ +const httpUrlSchema = z + .string() + .url() + .refine((value) => /^https?:\/\//i.test(value), 'Only http(s) URLs are allowed') + +/** + * Tolerant schema for a single Ashby posting. The public board omits several + * fields depending on the posting, so everything beyond the identity/title is + * optional or nullable — Ashby is a third party and its payload varies per board. + */ +const ashbyPostingSchema = z + .object({ + id: z.string(), + title: z.string(), + department: z.string().nullish(), + team: z.string().nullish(), + location: z.string().nullish(), + employmentType: z.string().nullish(), + workplaceType: z.string().nullish(), + isListed: z.boolean().nullish(), + isRemote: z.boolean().nullish(), + publishedAt: z.string().nullish(), + jobUrl: httpUrlSchema, + applyUrl: z.string().nullish(), + shouldDisplayCompensationOnJobPostings: z.boolean().nullish(), + compensation: z + .object({ + compensationTierSummary: z.string().nullish(), + }) + .nullish(), + }) + .passthrough() + +/** + * The board envelope validates loosely — each posting is validated individually + * in {@link getAshbyJobs} so a single malformed row is skipped rather than + * emptying the entire board. + */ +const ashbyJobBoardSchema = z.object({ + apiVersion: z.string().nullish(), + jobs: z.array(z.unknown()), +}) + +/** Human-friendly labels for Ashby's enum-ish string fields. */ +const EMPLOYMENT_TYPE_LABELS: Record = { + FullTime: 'Full-time', + PartTime: 'Part-time', + Intern: 'Internship', + Contract: 'Contract', + Temporary: 'Temporary', +} + +const WORKPLACE_TYPE_LABELS: Record = { + OnSite: 'On-site', + Remote: 'Remote', + Hybrid: 'Hybrid', +} + +/** A normalized, presentation-ready job posting for the careers page. */ +export interface CareerPosting { + id: string + title: string + /** Grouping bucket — the posting's department (falls back to team, then "Other"). */ + department: string + /** Display location, e.g. "San Francisco" or "Remote". */ + location: string + /** Human employment type, e.g. "Full-time". Empty string when unknown. */ + employmentType: string + /** Human workplace type, e.g. "On-site". Empty string when unknown. */ + workplaceType: string + /** Compensation range summary when the posting opts to display it, else null. */ + compensationSummary: string | null + /** Public detail URL on `jobs.ashbyhq.com`. */ + jobUrl: string +} + +/** + * Fetches the listed Sim job postings from Ashby's public job board API and + * normalizes them for the careers page. Cached at build/revalidate time and + * shared across renders. Mirrors {@link getGitHubStars}: it never throws — on any + * transport, status, or shape error it logs a warning and returns an empty list + * so the page renders its "no open roles" state instead of erroring. + */ +export async function getAshbyJobs(): Promise { + try { + const response = await fetch(ASHBY_JOB_BOARD_URL, { + headers: { Accept: 'application/json' }, + next: { revalidate: REVALIDATE_SECONDS }, + cache: 'force-cache', + }) + + if (!response.ok) { + logger.warn('Ashby job board request failed', { status: response.status }) + return [] + } + + const envelope = ashbyJobBoardSchema.safeParse(await response.json()) + if (!envelope.success) { + logger.warn('Ashby job board response failed validation', { issues: envelope.error.issues }) + return [] + } + + const postings: CareerPosting[] = [] + for (const raw of envelope.data.jobs) { + const parsed = ashbyPostingSchema.safeParse(raw) + if (!parsed.success) { + logger.warn('Skipping malformed Ashby posting', { issues: parsed.error.issues }) + continue + } + if (parsed.data.isListed === false) continue + postings.push(normalizePosting(parsed.data)) + } + + return postings.sort(comparePostings) + } catch (error) { + logger.warn('Ashby job board request threw', { error }) + return [] + } +} + +/** Maps a raw Ashby posting to the presentation-ready {@link CareerPosting} shape. */ +function normalizePosting(job: z.infer): CareerPosting { + const employmentType = job.employmentType + ? (EMPLOYMENT_TYPE_LABELS[job.employmentType] ?? job.employmentType) + : '' + const workplaceType = job.workplaceType + ? (WORKPLACE_TYPE_LABELS[job.workplaceType] ?? job.workplaceType) + : '' + const location = job.location?.trim() || (job.isRemote ? 'Remote' : '') + const compensationSummary = + job.shouldDisplayCompensationOnJobPostings && job.compensation?.compensationTierSummary + ? job.compensation.compensationTierSummary + : null + + return { + id: job.id, + title: job.title, + department: job.department?.trim() || job.team?.trim() || 'Other', + location, + employmentType, + workplaceType, + compensationSummary, + jobUrl: job.jobUrl, + } +} + +/** Orders postings by department, then title — the render order and grouping key. */ +function comparePostings(a: CareerPosting, b: CareerPosting): number { + return a.department.localeCompare(b.department) || a.title.localeCompare(b.title) +}