mirror of
https://github.com/better-auth/better-auth.git
synced 2026-05-21 14:00:08 -05:00
docs(careers): replace hardcoded roles with gem job board api (#9426)
This commit is contained in:
@@ -1,250 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import Footer from "@/components/landing/footer";
|
||||
import { HalftoneBackground } from "@/components/landing/halftone-bg";
|
||||
import type { GemJobPost } from "@/lib/gem";
|
||||
import { formatGemEnum } from "@/lib/gem";
|
||||
|
||||
const roles = [
|
||||
{
|
||||
title: "Founding Design Engineer",
|
||||
type: "Full-time",
|
||||
location: "San Francisco",
|
||||
description:
|
||||
"Craft the visual identity and UI of Better Auth's products — dashboard, docs, landing pages. You'll own design end-to-end.",
|
||||
requirements: [
|
||||
"Strong portfolio with shipped product work",
|
||||
"React / Next.js proficiency",
|
||||
"CSS mastery and responsive design",
|
||||
"Eye for detail and micro-interactions",
|
||||
"Experience shipping design systems",
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Senior Developer Relations",
|
||||
type: "Full-time",
|
||||
location: "San Francisco",
|
||||
description:
|
||||
"Be the bridge between Better Auth and its developer community. Create content, speak at events, build demos, and shape the developer experience.",
|
||||
requirements: [
|
||||
"Developer background with public repos or OSS contributions",
|
||||
"Content creation experience (blogs, videos, tutorials)",
|
||||
"Public speaking and conference experience",
|
||||
"Community building track record",
|
||||
"Familiarity with auth / security space",
|
||||
],
|
||||
},
|
||||
];
|
||||
type Role = Omit<GemJobPost, "content" | "content_plain">;
|
||||
|
||||
function ApplyDialog({
|
||||
role,
|
||||
onClose,
|
||||
}: {
|
||||
role: (typeof roles)[number];
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div className="absolute inset-0 bg-black/40 backdrop-blur-md dark:bg-black/72" />
|
||||
|
||||
{/* Dialog */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 10, scale: 0.98 }}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||
className="relative w-full max-w-md overflow-hidden border border-foreground/[0.14] bg-background/95 shadow-2xl shadow-black/12 ring-1 ring-black/5 backdrop-blur dark:border-white/[0.08] dark:bg-[#050505]/95 dark:shadow-black/65 dark:ring-white/[0.04]"
|
||||
>
|
||||
{/* Corner marks */}
|
||||
<span className="absolute top-2 left-2 text-[9px] font-mono text-foreground/25 dark:text-foreground/18 select-none leading-none">
|
||||
+
|
||||
</span>
|
||||
<span className="absolute top-2 right-2 text-[9px] font-mono text-foreground/25 dark:text-foreground/18 select-none leading-none">
|
||||
+
|
||||
</span>
|
||||
<span className="absolute bottom-2 left-2 text-[9px] font-mono text-foreground/25 dark:text-foreground/18 select-none leading-none">
|
||||
+
|
||||
</span>
|
||||
<span className="absolute bottom-2 right-2 text-[9px] font-mono text-foreground/25 dark:text-foreground/18 select-none leading-none">
|
||||
+
|
||||
</span>
|
||||
|
||||
<div className="p-6 sm:p-8">
|
||||
{/* Close button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close dialog"
|
||||
className="absolute top-4 right-4 text-foreground/50 hover:text-foreground/80 transition-colors"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{submitted ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="text-center py-6 space-y-3"
|
||||
>
|
||||
<p className="text-sm text-foreground/92 dark:text-foreground/88">
|
||||
Application sent
|
||||
</p>
|
||||
<p className="text-xs text-foreground/60 dark:text-foreground/52 leading-relaxed max-w-xs mx-auto">
|
||||
Thanks for applying for {role.title}. We'll review your
|
||||
application and get back to you soon.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="mt-4 inline-flex items-center px-4 py-2 border border-foreground/[0.18] bg-foreground/[0.03] text-foreground/72 hover:text-foreground/90 hover:border-foreground/28 hover:bg-foreground/[0.06] transition-all font-mono text-[10px] uppercase tracking-widest dark:border-white/[0.1] dark:bg-white/[0.03] dark:text-foreground/68 dark:hover:text-foreground/84 dark:hover:bg-white/[0.05]"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</motion.div>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-6 space-y-1">
|
||||
<p className="text-[10px] uppercase tracking-widest text-foreground/55 dark:text-foreground/50 font-mono">
|
||||
# Apply
|
||||
</p>
|
||||
<h3 className="text-sm text-foreground/92 dark:text-foreground/86">
|
||||
{role.title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<span className="text-[8px] font-mono uppercase tracking-widest text-foreground/60 dark:text-foreground/50 border border-foreground/[0.15] bg-foreground/[0.03] px-1.5 py-0.5 leading-none dark:border-white/[0.08] dark:bg-white/[0.03]">
|
||||
{role.type}
|
||||
</span>
|
||||
<span className="text-[8px] font-mono uppercase tracking-widest text-foreground/60 dark:text-foreground/50 border border-foreground/[0.15] bg-foreground/[0.03] px-1.5 py-0.5 leading-none dark:border-white/[0.08] dark:bg-white/[0.03]">
|
||||
{role.location}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setSubmitted(true);
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
htmlFor="careers-name"
|
||||
className="text-[9px] text-foreground/58 dark:text-foreground/48 uppercase tracking-widest font-mono"
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="careers-name"
|
||||
type="text"
|
||||
required
|
||||
className="w-full border border-foreground/[0.15] bg-foreground/[0.025] px-3 py-2 text-[12px] text-foreground/88 placeholder:text-foreground/38 focus:outline-none focus:border-foreground/38 dark:border-white/[0.1] dark:bg-white/[0.03] dark:text-foreground/78 dark:placeholder:text-foreground/28 dark:focus:border-white/[0.2] transition-colors"
|
||||
placeholder="Your full name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
htmlFor="careers-email"
|
||||
className="text-[9px] text-foreground/58 dark:text-foreground/48 uppercase tracking-widest font-mono"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
id="careers-email"
|
||||
type="email"
|
||||
required
|
||||
className="w-full border border-foreground/[0.15] bg-foreground/[0.025] px-3 py-2 text-[12px] text-foreground/88 placeholder:text-foreground/38 focus:outline-none focus:border-foreground/38 dark:border-white/[0.1] dark:bg-white/[0.03] dark:text-foreground/78 dark:placeholder:text-foreground/28 dark:focus:border-white/[0.2] transition-colors"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
htmlFor="careers-portfolio"
|
||||
className="text-[9px] text-foreground/58 dark:text-foreground/48 uppercase tracking-widest font-mono"
|
||||
>
|
||||
Portfolio / GitHub
|
||||
</label>
|
||||
<input
|
||||
id="careers-portfolio"
|
||||
type="url"
|
||||
required
|
||||
className="w-full border border-foreground/[0.15] bg-foreground/[0.025] px-3 py-2 text-[12px] text-foreground/88 placeholder:text-foreground/38 focus:outline-none focus:border-foreground/38 dark:border-white/[0.1] dark:bg-white/[0.03] dark:text-foreground/78 dark:placeholder:text-foreground/28 dark:focus:border-white/[0.2] transition-colors"
|
||||
placeholder="https://"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
htmlFor="careers-why"
|
||||
className="text-[9px] text-foreground/58 dark:text-foreground/48 uppercase tracking-widest font-mono"
|
||||
>
|
||||
Why Better Auth?
|
||||
</label>
|
||||
<textarea
|
||||
id="careers-why"
|
||||
required
|
||||
rows={3}
|
||||
className="w-full border border-foreground/[0.15] bg-foreground/[0.025] px-3 py-2 text-[12px] text-foreground/88 placeholder:text-foreground/38 focus:outline-none focus:border-foreground/38 dark:border-white/[0.1] dark:bg-white/[0.03] dark:text-foreground/78 dark:placeholder:text-foreground/28 dark:focus:border-white/[0.2] transition-colors resize-none"
|
||||
placeholder="Tell us why you want to join..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="inline-flex items-center gap-1.5 px-4 py-2 bg-foreground text-background hover:opacity-90 transition-all"
|
||||
>
|
||||
<span className="font-mono text-[10px] uppercase tracking-widest">
|
||||
Submit Application
|
||||
</span>
|
||||
<svg
|
||||
className="h-2.5 w-2.5 opacity-80"
|
||||
viewBox="0 0 10 10"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M1 9L9 1M9 1H3M9 1V7"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function CareersHero() {
|
||||
function CareersHero({ openRoles }: { openRoles: number }) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
@@ -266,7 +30,7 @@ function CareersHero() {
|
||||
<div className="border-t border-foreground/10 pt-4 space-y-0">
|
||||
{[
|
||||
{ label: "Location", value: "San Francisco" },
|
||||
{ label: "Open roles", value: `${roles.length}` },
|
||||
{ label: "Open Positions", value: `${openRoles}` },
|
||||
].map((item, i) => (
|
||||
<motion.div
|
||||
key={item.label}
|
||||
@@ -293,7 +57,7 @@ function CareersHero() {
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<a
|
||||
href="mailto:careers@better-auth.com"
|
||||
className="inline-flex items-center gap-1.5 text-[13px] text-foreground/40 hover:text-foreground/70 font-mono uppercase tracking-wider transition-colors"
|
||||
className="inline-flex items-center gap-1.5 text-xs text-foreground/40 hover:text-foreground/70 font-mono tracking-wider transition-colors"
|
||||
>
|
||||
careers@better-auth.com
|
||||
<svg
|
||||
@@ -314,119 +78,123 @@ function CareersHero() {
|
||||
);
|
||||
}
|
||||
|
||||
function RoleCard({
|
||||
role,
|
||||
index,
|
||||
onApply,
|
||||
}: {
|
||||
role: (typeof roles)[number];
|
||||
index: number;
|
||||
onApply: () => void;
|
||||
}) {
|
||||
function groupByDepartment(roles: Role[]): [string, Role[]][] {
|
||||
const groups = new Map<string, Role[]>();
|
||||
for (const role of roles) {
|
||||
const dept = role.departments[0]?.name ?? "Other";
|
||||
const existing = groups.get(dept);
|
||||
if (existing) existing.push(role);
|
||||
else groups.set(dept, [role]);
|
||||
}
|
||||
return Array.from(groups);
|
||||
}
|
||||
|
||||
function RoleRow({ role, index }: { role: Role; index: number }) {
|
||||
const location =
|
||||
role.location_type === "remote"
|
||||
? "Remote"
|
||||
: (role.location?.name ?? formatGemEnum(role.location_type));
|
||||
const meta = [location, formatGemEnum(role.employment_type)]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
<motion.a
|
||||
href={role.absolute_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
initial={{ opacity: 0, y: 4 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{
|
||||
duration: 0.3,
|
||||
delay: 0.2 + index * 0.08,
|
||||
duration: 0.25,
|
||||
delay: 0.05 + index * 0.04,
|
||||
ease: "easeOut",
|
||||
}}
|
||||
className="group relative overflow-hidden border border-foreground/[0.1] bg-foreground/[0.018] shadow-sm shadow-black/[0.03] transition-all duration-300 hover:z-10 hover:-translate-y-px hover:border-foreground/[0.18] hover:bg-foreground/[0.03] hover:shadow-lg hover:shadow-black/[0.06] dark:border-white/[0.07] dark:bg-white/[0.015] dark:shadow-black/[0.2] dark:hover:border-white/[0.12] dark:hover:bg-white/[0.03] dark:hover:shadow-black/[0.35]"
|
||||
className="group flex flex-col sm:flex-row sm:items-baseline sm:justify-between gap-1 sm:gap-6 border-b border-dashed border-foreground/[0.08] dark:border-white/[0.06] py-4 last:border-0 transition-colors"
|
||||
>
|
||||
<div className="absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-foreground/18 to-transparent dark:via-white/[0.14]" />
|
||||
|
||||
{/* Corner marks */}
|
||||
<span className="absolute top-2 left-2 text-[9px] font-mono text-foreground/22 dark:text-foreground/16 select-none leading-none">
|
||||
+
|
||||
</span>
|
||||
<span className="absolute top-2 right-2 text-[9px] font-mono text-foreground/22 dark:text-foreground/16 select-none leading-none">
|
||||
+
|
||||
</span>
|
||||
<span className="absolute bottom-2 left-2 text-[9px] font-mono text-foreground/22 dark:text-foreground/16 select-none leading-none">
|
||||
+
|
||||
</span>
|
||||
<span className="absolute bottom-2 right-2 text-[9px] font-mono text-foreground/22 dark:text-foreground/16 select-none leading-none">
|
||||
+
|
||||
</span>
|
||||
|
||||
<div className="p-5 sm:p-6 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-base sm:text-lg text-foreground/92 dark:text-foreground/86 tracking-tight">
|
||||
{role.title}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="border border-foreground/[0.14] bg-foreground/[0.03] px-1.5 py-0.5 text-[8px] font-mono uppercase tracking-widest text-foreground/58 dark:border-white/[0.08] dark:bg-white/[0.03] dark:text-foreground/48 leading-none">
|
||||
{role.type}
|
||||
</span>
|
||||
<span className="border border-foreground/[0.14] bg-foreground/[0.03] px-1.5 py-0.5 text-[8px] font-mono uppercase tracking-widest text-foreground/58 dark:border-white/[0.08] dark:bg-white/[0.03] dark:text-foreground/48 leading-none">
|
||||
{role.location}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="max-w-lg text-[14px] leading-relaxed text-foreground/66 dark:text-foreground/56">
|
||||
{role.description}
|
||||
</p>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="border-t border-foreground/[0.08] dark:border-white/[0.06]" />
|
||||
|
||||
{/* Requirements */}
|
||||
<div>
|
||||
<p className="mb-3 text-[9px] font-mono uppercase tracking-[0.18em] text-foreground/52 dark:text-foreground/44">
|
||||
Requirements
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{role.requirements.map((req) => (
|
||||
<li
|
||||
key={req}
|
||||
className="flex items-start gap-2 text-[14px] leading-relaxed text-foreground/56 transition-colors duration-300 group-hover:text-foreground/68 dark:text-foreground/48 dark:group-hover:text-foreground/60"
|
||||
>
|
||||
<span className="mt-px shrink-0 select-none font-mono text-[9px] leading-none text-foreground/60 dark:text-foreground/46">
|
||||
+
|
||||
</span>
|
||||
<span>{req}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Apply CTA */}
|
||||
<div className="pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onApply}
|
||||
className="inline-flex items-center gap-1.5 bg-foreground px-3.5 py-2 text-background transition-all hover:opacity-90 cursor-pointer"
|
||||
>
|
||||
<span className="font-mono text-[10px] uppercase tracking-widest">
|
||||
Apply
|
||||
</span>
|
||||
<svg
|
||||
className="h-2.5 w-2.5 opacity-80"
|
||||
viewBox="0 0 10 10"
|
||||
fill="none"
|
||||
>
|
||||
<path
|
||||
d="M1 9L9 1M9 1H3M9 1V7"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/* Title row (with mobile arrow on the right) */}
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="text-[15px] sm:text-base text-foreground/85 dark:text-foreground/75 group-hover:text-foreground dark:group-hover:text-foreground/95 transition-colors">
|
||||
{role.title}
|
||||
</span>
|
||||
<svg
|
||||
className="sm:hidden h-2.5 w-2.5 shrink-0 text-foreground/30 group-hover:text-foreground/70 group-hover:translate-x-0.5 transition-all"
|
||||
viewBox="0 0 10 10"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M1 9L9 1M9 1H3M9 1V7"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Meta (with desktop arrow inline) */}
|
||||
<div className="flex items-baseline gap-3 sm:shrink-0">
|
||||
<span className="text-[12px] text-foreground/45 dark:text-foreground/35 group-hover:text-foreground/70 dark:group-hover:text-foreground/55 transition-colors sm:text-right">
|
||||
{meta}
|
||||
</span>
|
||||
<svg
|
||||
className="hidden sm:block h-2.5 w-2.5 text-foreground/30 group-hover:text-foreground/70 group-hover:translate-x-0.5 transition-all"
|
||||
viewBox="0 0 10 10"
|
||||
fill="none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
d="M1 9L9 1M9 1H3M9 1V7"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</motion.a>
|
||||
);
|
||||
}
|
||||
|
||||
export function CareersPageClient() {
|
||||
const [applyingRole, setApplyingRole] = useState<
|
||||
(typeof roles)[number] | null
|
||||
>(null);
|
||||
function RolesList({ roles }: { roles: Role[] }) {
|
||||
const groups = groupByDepartment(roles);
|
||||
let rowIndex = 0;
|
||||
return (
|
||||
<div className="space-y-10">
|
||||
{groups.map(([dept, deptRoles]) => (
|
||||
<section key={dept}>
|
||||
<h3 className="text-[11px] font-mono uppercase tracking-widest text-foreground/55 dark:text-foreground/45 mb-1">
|
||||
{dept}
|
||||
</h3>
|
||||
<div>
|
||||
{deptRoles.map((role) => (
|
||||
<RoleRow key={role.id} role={role} index={rowIndex++} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState() {
|
||||
return (
|
||||
<div className="border border-dashed border-foreground/[0.1] p-8 text-center">
|
||||
<p className="text-md text-foreground/60 dark:text-foreground/50 leading-relaxed">
|
||||
No open positions right now.
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-foreground/45 leading-relaxed">
|
||||
We are still happy to hear from you. Reach out at{" "}
|
||||
<a
|
||||
href="mailto:careers@better-auth.com"
|
||||
className="underline decoration-foreground/30 underline-offset-2 hover:text-foreground/70 transition-colors"
|
||||
>
|
||||
careers@better-auth.com
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CareersPageClient({ roles }: { roles: Role[] }) {
|
||||
return (
|
||||
<div className="relative min-h-dvh pt-14 lg:pt-0">
|
||||
<div className="relative text-foreground">
|
||||
@@ -436,7 +204,7 @@ export function CareersPageClient() {
|
||||
<div className="hidden lg:block">
|
||||
<HalftoneBackground />
|
||||
</div>
|
||||
<CareersHero />
|
||||
<CareersHero openRoles={roles.length} />
|
||||
</div>
|
||||
|
||||
{/* Right side */}
|
||||
@@ -481,36 +249,30 @@ export function CareersPageClient() {
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.05 }}
|
||||
className="space-y-5 max-w-2xl"
|
||||
>
|
||||
<div className="relative border border-dashed border-foreground/[0.08] overflow-hidden">
|
||||
<div className="px-5 py-5 sm:px-8 sm:py-8 space-y-5 max-w-2xl">
|
||||
<p className="text-[15px] text-foreground/60 leading-relaxed">
|
||||
Better Auth is built with the idea of{" "}
|
||||
<span className="text-foreground/80">
|
||||
democratizing access to high quality software
|
||||
</span>
|
||||
. We're a small, focused team shaping how auth works
|
||||
for millions of developers.
|
||||
</p>
|
||||
<p className="text-md text-foreground/60 leading-relaxed">
|
||||
Better Auth is built with the idea of{" "}
|
||||
<span className="text-foreground/80">
|
||||
democratizing access to high quality software
|
||||
</span>
|
||||
. We're a small, focused team shaping how auth works for
|
||||
millions of developers.
|
||||
</p>
|
||||
|
||||
<p className="text-[15px] text-foreground/60 leading-relaxed">
|
||||
Every line of code we write gets used in production by
|
||||
thousands of projects — from solo indie hackers to
|
||||
large-scale enterprises. The work here has{" "}
|
||||
<span className="text-foreground/80">
|
||||
outsized impact
|
||||
</span>
|
||||
.
|
||||
</p>
|
||||
<p className="text-md text-foreground/60 leading-relaxed">
|
||||
Every line of code we write gets used in production by
|
||||
thousands of projects, from solo indie hackers to large-scale
|
||||
enterprises. The work here has{" "}
|
||||
<span className="text-foreground/80">outsized impact</span>.
|
||||
</p>
|
||||
|
||||
<p className="text-[15px] text-foreground/60 leading-relaxed">
|
||||
We work in the open, move fast, and care deeply about
|
||||
developer experience. If you want to do the best work of
|
||||
your career on a problem that matters, we'd love to
|
||||
hear from you.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-md text-foreground/60 leading-relaxed">
|
||||
We work in the open, move fast, and care deeply about
|
||||
developer experience. If you want to do the best work of your
|
||||
career on a problem that matters, we'd love to hear from
|
||||
you.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Section: Open positions */}
|
||||
@@ -518,33 +280,19 @@ export function CareersPageClient() {
|
||||
initial={{ opacity: 0, y: 6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3, delay: 0.15 }}
|
||||
className="pt-10"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{roles.map((role, i) => (
|
||||
<RoleCard
|
||||
key={role.title}
|
||||
role={role}
|
||||
index={i}
|
||||
onApply={() => setApplyingRole(role)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{roles.length === 0 ? (
|
||||
<EmptyState />
|
||||
) : (
|
||||
<RolesList roles={roles} />
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Apply dialog */}
|
||||
<AnimatePresence>
|
||||
{applyingRole && (
|
||||
<ApplyDialog
|
||||
role={applyingRole}
|
||||
onClose={() => setApplyingRole(null)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Metadata } from "next";
|
||||
import { fetchGemJobPosts } from "@/lib/gem";
|
||||
import { createMetadata } from "@/lib/metadata";
|
||||
import { CareersPageClient } from "./careers-client";
|
||||
|
||||
@@ -7,6 +8,10 @@ export const metadata: Metadata = createMetadata({
|
||||
description: "Join the Better Auth team — open positions and how to apply.",
|
||||
});
|
||||
|
||||
export default function CareersPage() {
|
||||
return <CareersPageClient />;
|
||||
export default async function CareersPage() {
|
||||
// Strip large `content` / `content_plain` fields that the careers UI never reads.
|
||||
const roles = (await fetchGemJobPosts()).map(
|
||||
({ content, content_plain, ...rest }) => rest,
|
||||
);
|
||||
return <CareersPageClient roles={roles} />;
|
||||
}
|
||||
|
||||
92
docs/lib/gem.ts
Normal file
92
docs/lib/gem.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
// Gem ATS Job Board API client.
|
||||
// Reference: https://api.gem.com/job_board/v0/reference
|
||||
// OpenAPI spec: https://api.gem.com/job_board/v0/openapi.json
|
||||
|
||||
// Open unions: spec lists known values, but the API is v0 and may add more.
|
||||
// `(string & {})` keeps autocomplete on the literals while accepting any string.
|
||||
export type GemEmploymentType =
|
||||
| "contract"
|
||||
| "full_time"
|
||||
| "intern"
|
||||
| "part_time"
|
||||
| "temporary"
|
||||
| (string & {});
|
||||
|
||||
export type GemLocationType = "in_office" | "hybrid" | "remote" | (string & {});
|
||||
|
||||
export interface GemLocationName {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface GemDepartment {
|
||||
id: string;
|
||||
name: string;
|
||||
parent_id: string | null;
|
||||
child_ids: string[];
|
||||
}
|
||||
|
||||
export interface GemOffice {
|
||||
id: string;
|
||||
name: string;
|
||||
location: GemLocationName;
|
||||
parent_id: string | null;
|
||||
child_ids: string[];
|
||||
parent_office_external_id: string | null;
|
||||
child_office_external_ids: string[];
|
||||
deleted_at?: string;
|
||||
}
|
||||
|
||||
export interface GemJobPost {
|
||||
id: string;
|
||||
title: string;
|
||||
first_published_at: string;
|
||||
internal_job_id: string;
|
||||
content: string;
|
||||
content_plain: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
departments: GemDepartment[];
|
||||
offices: GemOffice[];
|
||||
absolute_url: string;
|
||||
location?: GemLocationName;
|
||||
location_type: GemLocationType;
|
||||
employment_type: GemEmploymentType;
|
||||
requisition_id: string;
|
||||
}
|
||||
|
||||
const VANITY_URL_PATH = "better-auth";
|
||||
const JOB_POSTS_URL = `https://api.gem.com/job_board/v0/${VANITY_URL_PATH}/job_posts/`;
|
||||
|
||||
export async function fetchGemJobPosts(): Promise<GemJobPost[]> {
|
||||
try {
|
||||
const response = await fetch(JOB_POSTS_URL, {
|
||||
next: { revalidate: 3600 },
|
||||
});
|
||||
if (!response.ok) {
|
||||
console.error("Failed to fetch Gem job posts:", response.status);
|
||||
return [];
|
||||
}
|
||||
const data = await response.json();
|
||||
if (!Array.isArray(data)) {
|
||||
console.error("Unexpected Gem response shape:", typeof data);
|
||||
return [];
|
||||
}
|
||||
return data as GemJobPost[];
|
||||
} catch (error) {
|
||||
console.error("Error fetching Gem job posts:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Title-case a snake_case enum value from Gem (e.g. `full_time` → `Full Time`)
|
||||
*
|
||||
* @see https://api.gem.com/job_board/v0/openapi.json
|
||||
*/
|
||||
export function formatGemEnum(value: string): string {
|
||||
return value
|
||||
.split("_")
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
Reference in New Issue
Block a user