docs(P03): complete marketplace-surface phase
---ci--- phase: 3 milestone: v0.1 status: complete requirements: covered: [REQ-013, REQ-014, REQ-015, REQ-016, REQ-017] partial: [] ---/ci--- Phase 3 (marketplace-surface) complete: - Job board with 20 mock listings, match scores, skill tags - Client-side search and filter (skills, seniority, remote, salary) - Job detail with competencies, AI-matched skills breakdown, employer info, related jobs - Employer profile with culture section, open positions - Pricing page with 6 tiers and feature comparison table - All pages responsive, dark mode supported, build passes
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"phase": 2,
|
||||
"phase": 3,
|
||||
"stage": "execute",
|
||||
"milestone": "v0.1",
|
||||
"phase_role": "execution",
|
||||
"attempts": 0,
|
||||
"updated_at": "2026-09-10T21:45:00Z"
|
||||
"updated_at": "2026-09-10T22:00:00Z"
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { ArrowLeft, Briefcase, Github, Globe, Linkedin, MapPin, Twitter, Users } from 'lucide-react';
|
||||
import { employers, jobs } from '@nextcraft/mock-data';
|
||||
import { Badge, Button, Card, CardBody } from '@nextcraft/ui';
|
||||
import { formatSalaryRange, initials as initialsOf, relativeTime } from '../../../../../lib/format';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ employerId: string }>;
|
||||
}
|
||||
|
||||
const CULTURE_CARDS = [
|
||||
{ label: 'Open Workspace', tone: 'bg-primary-100 text-primary-700 dark:bg-primary-900/40 dark:text-primary-300' },
|
||||
{ label: 'Team Collaboration', tone: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300' },
|
||||
{ label: 'Innovation Lab', tone: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300' },
|
||||
{ label: 'Learning Culture', tone: 'bg-rose-100 text-rose-700 dark:bg-rose-900/40 dark:text-rose-300' },
|
||||
{ label: 'Global Team', tone: 'bg-teal-100 text-teal-700 dark:bg-teal-900/40 dark:text-teal-300' },
|
||||
{ label: 'Mission First', tone: 'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-300' },
|
||||
] as const;
|
||||
|
||||
export default async function EmployerProfilePage({ params }: PageProps) {
|
||||
const { employerId } = await params;
|
||||
const employer = employers.find((e) => e.id === employerId);
|
||||
if (!employer) notFound();
|
||||
|
||||
const openPositions = jobs.filter((j) => j.employerId === employer.id);
|
||||
|
||||
const aboutParagraphs = [
|
||||
employer.description,
|
||||
`Headquartered in ${employer.location}, ${employer.name} operates in the ${employer.industry} space with a team of ${employer.size} people. Our culture is best described as: ${employer.culture}`,
|
||||
`We are actively hiring across roles that pair AI engineering rigor with real customer outcomes. Browse our open positions below and apply through the Nextcraft marketplace to connect directly with our hiring team.`,
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<Link
|
||||
href="/marketplace"
|
||||
className="inline-flex items-center gap-1.5 text-sm font-medium text-slate-600 hover:text-slate-900 dark:text-slate-300 dark:hover:text-slate-100"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to job board
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<Card>
|
||||
<CardBody className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex h-16 w-16 shrink-0 items-center justify-center rounded-full bg-primary-100 text-lg font-semibold text-primary-700 dark:bg-primary-900/40 dark:text-primary-200"
|
||||
>
|
||||
{initialsOf(employer.name)}
|
||||
</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{employer.name}
|
||||
</h1>
|
||||
<p className="text-sm font-medium text-primary-700 dark:text-primary-300">
|
||||
{employer.industry}
|
||||
</p>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-slate-600 dark:text-slate-400">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
{employer.size}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<MapPin className="h-3.5 w-3.5" />
|
||||
{employer.location}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<SocialIcon href={employer.socialLinks.twitter ?? '#'} label="Twitter">
|
||||
<Twitter className="h-4 w-4" />
|
||||
</SocialIcon>
|
||||
<SocialIcon href={employer.socialLinks.linkedin ?? '#'} label="LinkedIn">
|
||||
<Linkedin className="h-4 w-4" />
|
||||
</SocialIcon>
|
||||
<SocialIcon href={employer.socialLinks.github ?? '#'} label="GitHub">
|
||||
<Github className="h-4 w-4" />
|
||||
</SocialIcon>
|
||||
<SocialIcon href={employer.website} label="Website">
|
||||
<Globe className="h-4 w-4" />
|
||||
</SocialIcon>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* About */}
|
||||
<Card>
|
||||
<CardBody className="flex flex-col gap-3">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">About</h2>
|
||||
<div className="flex flex-col gap-3 text-sm leading-relaxed text-slate-700 dark:text-slate-300">
|
||||
{aboutParagraphs.map((p, i) => (
|
||||
<p key={i}>{p}</p>
|
||||
))}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* Culture */}
|
||||
<Card>
|
||||
<CardBody className="flex flex-col gap-4">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">Culture</h2>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{CULTURE_CARDS.map((c, i) => (
|
||||
<div
|
||||
key={c.label}
|
||||
className={`flex h-24 flex-col items-center justify-center gap-2 rounded-lg border border-slate-200 text-center dark:border-slate-800 ${c.tone}`}
|
||||
>
|
||||
<span className="text-xs font-medium uppercase tracking-wide opacity-70">#{i + 1}</span>
|
||||
<span className="text-sm font-semibold">{c.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* Open positions */}
|
||||
<Card>
|
||||
<CardBody className="flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
Open positions
|
||||
</h2>
|
||||
<Badge variant="default">
|
||||
<Briefcase className="h-3 w-3" />
|
||||
{openPositions.length}
|
||||
</Badge>
|
||||
</div>
|
||||
{openPositions.length === 0 ? (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">No current openings.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{openPositions.map((job) => (
|
||||
<Link
|
||||
key={job.id}
|
||||
href={`/marketplace/jobs/${job.id}`}
|
||||
className="group flex flex-col gap-1 rounded-md border border-slate-200 p-4 transition-colors hover:bg-slate-50 dark:border-slate-800 dark:hover:bg-slate-800 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm font-semibold text-slate-900 group-hover:text-primary-700 dark:text-slate-100">
|
||||
{job.title}
|
||||
</span>
|
||||
<span className="text-xs text-slate-600 dark:text-slate-400">
|
||||
{job.location} · {formatSalaryRange(job.salaryMin, job.salaryMax)} · Posted{' '}
|
||||
{relativeTime(job.postedAt)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="inline-flex h-8 w-fit items-center justify-center rounded-md border border-slate-300 px-3 text-xs font-medium text-slate-800 dark:border-slate-700 dark:text-slate-100">
|
||||
View
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SocialIcon({
|
||||
href,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
aria-label={label}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-md border border-slate-300 text-slate-700 transition-colors hover:bg-slate-100 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { ArrowLeft, Building2, Check, MapPin, Wallet } from 'lucide-react';
|
||||
import { employers, jobs } from '@nextcraft/mock-data';
|
||||
import { Badge, Button, Card, CardBody } from '@nextcraft/ui';
|
||||
import type { Job } from '@nextcraft/types';
|
||||
import {
|
||||
formatSalaryRange,
|
||||
matchBarClasses,
|
||||
matchBadgeClasses,
|
||||
matchTone,
|
||||
relativeTime,
|
||||
seniorityLabel,
|
||||
initials as initialsOf,
|
||||
} from '../../../../../lib/format';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ jobId: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a stable mock "AI-matched skills" breakdown from a job's skill list.
|
||||
* The first skill is given the highest score (close to the job's matchScore)
|
||||
* and subsequent skills taper slightly so the visualization reads naturally.
|
||||
*/
|
||||
function aiMatchedSkills(job: Job): Array<{ skill: string; score: number }> {
|
||||
const top = job.skills.slice(0, 5);
|
||||
return top.map((skill, idx) => {
|
||||
const taper = Math.round(idx * 4);
|
||||
const score = Math.max(55, Math.min(99, job.matchScore - taper));
|
||||
return { skill, score };
|
||||
});
|
||||
}
|
||||
|
||||
export default async function JobDetailPage({ params }: PageProps) {
|
||||
const { jobId } = await params;
|
||||
const job = jobs.find((j) => j.id === jobId);
|
||||
if (!job) notFound();
|
||||
|
||||
const employer = employers.find((e) => e.id === job.employerId);
|
||||
const employerName = employer?.name ?? 'Unknown employer';
|
||||
|
||||
const related = jobs
|
||||
.filter((j) => j.id !== job.id && j.employerId === job.employerId)
|
||||
.slice(0, 3);
|
||||
const relatedFallback =
|
||||
related.length > 0
|
||||
? related
|
||||
: jobs
|
||||
.filter((j) => j.id !== job.id && j.seniority === job.seniority)
|
||||
.slice(0, 3);
|
||||
const relatedJobs = (related.length > 0 ? related : relatedFallback).slice(0, 3);
|
||||
|
||||
const matchedSkills = aiMatchedSkills(job);
|
||||
|
||||
// Build out 3–4 paragraph rich description.
|
||||
const descriptionParagraphs = [
|
||||
job.description,
|
||||
`${employerName} is looking for a ${seniorityLabel(job.seniority)}-level ${
|
||||
job.title.toLowerCase().includes('engineer') ||
|
||||
job.title.toLowerCase().includes('designer') ||
|
||||
job.title.toLowerCase().includes('researcher')
|
||||
? 'practitioner'
|
||||
: 'professional'
|
||||
} who can own outcomes end-to-end. You will partner with product, research, and platform teams to ship reliable AI-powered features, instrumented with the evaluation harnesses this role is responsible for.`,
|
||||
`Day-to-day you will design systems around ${job.skills
|
||||
.slice(0, 3)
|
||||
.join(', ')}, write production code, review pull requests, run evaluations, and contribute to a blameless postmortem culture. We expect strong written communication and comfort working in a fast-moving, evidence-driven environment.`,
|
||||
`This role is ${job.remote ? 'remote-friendly' : `on-site in ${job.location}`} and reports into the ${employer?.industry ?? 'AI'} practice. Compensation ranges from ${formatSalaryRange(
|
||||
job.salaryMin,
|
||||
job.salaryMax,
|
||||
)} plus equity and benefits. We are an equal-opportunity employer and actively seek candidates from non-traditional backgrounds who have built real AI systems.`,
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Back link */}
|
||||
<div>
|
||||
<Link
|
||||
href="/marketplace"
|
||||
className="inline-flex items-center gap-1.5 text-sm font-medium text-slate-600 hover:text-slate-900 dark:text-slate-300 dark:hover:text-slate-100"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to job board
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<Card>
|
||||
<CardBody className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div className="flex items-start gap-4">
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex h-14 w-14 shrink-0 items-center justify-center rounded-full bg-primary-100 text-base font-semibold text-primary-700 dark:bg-primary-900/40 dark:text-primary-200"
|
||||
>
|
||||
{initialsOf(employerName)}
|
||||
</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{job.title}
|
||||
</h1>
|
||||
<Link
|
||||
href={`/marketplace/employers/${job.employerId}`}
|
||||
className="text-sm font-medium text-primary-700 hover:underline dark:text-primary-300"
|
||||
>
|
||||
{employerName}
|
||||
</Link>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-slate-600 dark:text-slate-400">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<MapPin className="h-3.5 w-3.5" />
|
||||
{job.location}
|
||||
</span>
|
||||
{job.remote && <Badge variant="success">Remote</Badge>}
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Wallet className="h-3.5 w-3.5" />
|
||||
{formatSalaryRange(job.salaryMin, job.salaryMax)}
|
||||
</span>
|
||||
<Badge variant="default">{seniorityLabel(job.seniority)}</Badge>
|
||||
<span>Posted {relativeTime(job.postedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`inline-flex h-12 w-12 items-center justify-center rounded-full text-sm font-bold ring-2 ${matchBadgeClasses(
|
||||
job.matchScore,
|
||||
)}`}
|
||||
title={`${job.matchScore}% AI match`}
|
||||
>
|
||||
{job.matchScore}%
|
||||
</span>
|
||||
<Button size="lg" type="button">
|
||||
Apply Now
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
{/* Main content */}
|
||||
<div className="flex flex-col gap-6 lg:col-span-2">
|
||||
<Card>
|
||||
<CardBody className="flex flex-col gap-4">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
Job description
|
||||
</h2>
|
||||
<div className="flex flex-col gap-3 text-sm leading-relaxed text-slate-700 dark:text-slate-300">
|
||||
{descriptionParagraphs.map((p, i) => (
|
||||
<p key={i}>{p}</p>
|
||||
))}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardBody className="flex flex-col gap-4">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
Required competencies
|
||||
</h2>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{job.requiredCompetencies.map((c) => (
|
||||
<li
|
||||
key={c}
|
||||
className="flex items-start gap-2 text-sm text-slate-700 dark:text-slate-300"
|
||||
>
|
||||
<span className="mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">
|
||||
<Check className="h-3 w-3" />
|
||||
</span>
|
||||
<span>{humanizeCompetency(c)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardBody className="flex flex-col gap-4">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
AI-Matched Skills
|
||||
</h2>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">
|
||||
How your competency profile maps to this role, computed by Nextcraft's match
|
||||
engine.
|
||||
</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
{matchedSkills.map(({ skill, score }) => (
|
||||
<div key={skill} className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="font-medium text-slate-800 dark:text-slate-200">{skill}</span>
|
||||
<span className="text-slate-600 dark:text-slate-400">{score}%</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-slate-100 dark:bg-slate-800">
|
||||
<div
|
||||
className={`h-full rounded-full ${matchBarClasses(score)}`}
|
||||
style={{ width: `${score}%` }}
|
||||
role="progressbar"
|
||||
aria-valuenow={score}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label={`${skill} match ${score} percent`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2 text-xs text-slate-500 dark:text-slate-500">
|
||||
<span
|
||||
className={`inline-block h-2 w-2 rounded-full ${
|
||||
matchTone(job.matchScore) === 'high'
|
||||
? 'bg-emerald-500'
|
||||
: matchTone(job.matchScore) === 'mid'
|
||||
? 'bg-amber-500'
|
||||
: 'bg-orange-500'
|
||||
}`}
|
||||
/>
|
||||
Overall match score {job.matchScore}%
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div className="flex flex-col gap-6">
|
||||
<Card>
|
||||
<CardBody className="flex flex-col gap-3">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||
Employer
|
||||
</h2>
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex h-12 w-12 items-center justify-center rounded-full bg-primary-100 text-sm font-semibold text-primary-700 dark:bg-primary-900/40 dark:text-primary-200"
|
||||
>
|
||||
{initialsOf(employerName)}
|
||||
</span>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-semibold text-slate-900 dark:text-slate-100">
|
||||
{employerName}
|
||||
</span>
|
||||
<span className="text-sm text-slate-600 dark:text-slate-400">
|
||||
{employer?.industry ?? 'AI'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<dl className="flex flex-col gap-1.5 text-sm">
|
||||
{employer && (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-slate-500 dark:text-slate-400">Size</dt>
|
||||
<dd className="text-slate-800 dark:text-slate-200">{employer.size}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-slate-500 dark:text-slate-400">Location</dt>
|
||||
<dd className="text-slate-800 dark:text-slate-200">{employer.location}</dd>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
{employer && (
|
||||
<Link href={`/marketplace/employers/${employer.id}`}>
|
||||
<Button variant="outline" size="sm" icon={<Building2 className="h-3.5 w-3.5" />} type="button">
|
||||
View Company
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardBody className="flex flex-col gap-3">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||
Related jobs
|
||||
</h2>
|
||||
<div className="flex flex-col gap-3">
|
||||
{relatedJobs.map((r) => (
|
||||
<Link
|
||||
key={r.id}
|
||||
href={`/marketplace/jobs/${r.id}`}
|
||||
className="group flex flex-col gap-1 rounded-md border border-slate-200 p-3 transition-colors hover:bg-slate-50 dark:border-slate-800 dark:hover:bg-slate-800"
|
||||
>
|
||||
<span className="text-sm font-semibold text-slate-900 group-hover:text-primary-700 dark:text-slate-100">
|
||||
{r.title}
|
||||
</span>
|
||||
<span className="text-xs text-slate-600 dark:text-slate-400">{r.location}</span>
|
||||
<span className="text-xs text-slate-600 dark:text-slate-400">
|
||||
{formatSalaryRange(r.salaryMin, r.salaryMax)} · {r.matchScore}% match
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Turn a competency code like "stack-orchestration-c001" into readable prose. */
|
||||
function humanizeCompetency(code: string): string {
|
||||
const parts = code.split('-');
|
||||
if (parts.length >= 3) {
|
||||
const stack = parts[1];
|
||||
const capLabel = `Capability ${parts[parts.length - 1].toUpperCase()}`;
|
||||
const stackPretty = stack.charAt(0).toUpperCase() + stack.slice(1);
|
||||
return `${stackPretty} · ${capLabel}`;
|
||||
}
|
||||
return code;
|
||||
}
|
||||
@@ -1,15 +1,26 @@
|
||||
import { Card, CardBody, Badge } from '@nextcraft/ui';
|
||||
import { Badge } from '@nextcraft/ui';
|
||||
import { employers, jobs } from '@nextcraft/mock-data';
|
||||
import { JobFilters } from '../../../components/marketplace/job-filters';
|
||||
|
||||
export default function MarketplaceHomePage() {
|
||||
const employerNames: Record<string, string> = Object.fromEntries(
|
||||
employers.map((e) => [e.id, e.name]),
|
||||
);
|
||||
|
||||
export default function MarketplaceHome() {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody className="flex flex-col gap-3">
|
||||
<Badge variant="success">Marketplace surface</Badge>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">AI-era job board</h1>
|
||||
<p className="text-slate-600 dark:text-slate-400">
|
||||
Placeholder — job listings, filters, and match scores arrive in Phase 3.
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-col gap-2">
|
||||
<Badge variant="info">Marketplace · Job Board</Badge>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100">
|
||||
AI-Native Job Board
|
||||
</h1>
|
||||
<p className="max-w-2xl text-slate-600 dark:text-slate-400">
|
||||
AI-credentialed talent meets AI-era employers. Every listing shows a live AI match
|
||||
score based on the skills recruiters need most right now.
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</header>
|
||||
|
||||
<JobFilters jobs={jobs} employerNames={employerNames} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,286 @@
|
||||
import { Card, CardBody, Badge } from '@nextcraft/ui';
|
||||
import { Check, Crown, Star, X, Zap } from 'lucide-react';
|
||||
import { Badge, Button, Card, CardBody } from '@nextcraft/ui';
|
||||
|
||||
interface JobPricingPlan {
|
||||
id: string;
|
||||
name: string;
|
||||
price: string;
|
||||
cadence: string;
|
||||
features: string[];
|
||||
cta: string;
|
||||
popular?: boolean;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
interface TalentPricingPlan {
|
||||
id: string;
|
||||
name: string;
|
||||
price: string;
|
||||
cadence: string;
|
||||
features: string[];
|
||||
cta: string;
|
||||
popular?: boolean;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
const JOB_PLANS: JobPricingPlan[] = [
|
||||
{
|
||||
id: 'single',
|
||||
name: 'Single Post',
|
||||
price: '$49',
|
||||
cadence: 'one-time',
|
||||
icon: <Zap className="h-4 w-4" />,
|
||||
features: ['1 job posting', '30 days live', 'Basic analytics', 'Standard placement'],
|
||||
cta: 'Post a Job',
|
||||
},
|
||||
{
|
||||
id: 'bundle',
|
||||
name: 'Bundle',
|
||||
price: '$399',
|
||||
cadence: 'one-time',
|
||||
icon: <Star className="h-4 w-4" />,
|
||||
features: ['10 job postings', '90 days each', 'Advanced analytics', 'Priority placement'],
|
||||
cta: 'Buy Bundle',
|
||||
popular: true,
|
||||
},
|
||||
{
|
||||
id: 'enterprise-jobs',
|
||||
name: 'Enterprise',
|
||||
price: 'Custom',
|
||||
cadence: 'annual',
|
||||
icon: <Crown className="h-4 w-4" />,
|
||||
features: [
|
||||
'Unlimited postings',
|
||||
'Dedicated account manager',
|
||||
'API access',
|
||||
'Custom branding',
|
||||
],
|
||||
cta: 'Contact Sales',
|
||||
},
|
||||
];
|
||||
|
||||
const TALENT_PLANS: TalentPricingPlan[] = [
|
||||
{
|
||||
id: 'starter',
|
||||
name: 'Starter',
|
||||
price: '$99',
|
||||
cadence: '/mo',
|
||||
icon: <Zap className="h-4 w-4" />,
|
||||
features: ['Browse talent database', '10 candidate views/mo', 'Basic filters'],
|
||||
cta: 'Get Starter',
|
||||
},
|
||||
{
|
||||
id: 'pro',
|
||||
name: 'Pro',
|
||||
price: '$299',
|
||||
cadence: '/mo',
|
||||
icon: <Star className="h-4 w-4" />,
|
||||
features: [
|
||||
'Unlimited views',
|
||||
'AI matching',
|
||||
'Advanced filters',
|
||||
'Saved searches',
|
||||
],
|
||||
cta: 'Get Pro',
|
||||
popular: true,
|
||||
},
|
||||
{
|
||||
id: 'enterprise-talent',
|
||||
name: 'Enterprise',
|
||||
price: 'Custom',
|
||||
cadence: 'annual',
|
||||
icon: <Crown className="h-4 w-4" />,
|
||||
features: ['Everything in Pro', 'API access', 'Bulk export', 'Dedicated CSM'],
|
||||
cta: 'Contact Sales',
|
||||
},
|
||||
];
|
||||
|
||||
interface FeatureRow {
|
||||
feature: string;
|
||||
values: Array<string | boolean>;
|
||||
}
|
||||
|
||||
const COMPARISON: FeatureRow[] = [
|
||||
{ feature: 'Job postings', values: ['1', '10', 'Unlimited'] },
|
||||
{ feature: 'Posting duration', values: ['30 days', '90 days', 'Custom'] },
|
||||
{ feature: 'Analytics', values: ['Basic', 'Advanced', 'Advanced + Custom'] },
|
||||
{ feature: 'Priority placement', values: [false, true, true] },
|
||||
{ feature: 'Account manager', values: [false, false, true] },
|
||||
{ feature: 'API access', values: [false, false, true] },
|
||||
{ feature: 'Custom branding', values: [false, false, true] },
|
||||
{ feature: 'Talent views', values: ['10/mo', 'Unlimited', 'Unlimited'] },
|
||||
{ feature: 'AI matching', values: [false, true, true] },
|
||||
{ feature: 'Saved searches', values: [false, true, true] },
|
||||
{ feature: 'Bulk export', values: [false, false, true] },
|
||||
];
|
||||
|
||||
export default function PricingPage() {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody className="flex flex-col gap-3">
|
||||
<Badge variant="success">Marketplace · Pricing</Badge>
|
||||
<h1 className="text-2xl font-bold text-slate-900 dark:text-slate-100">Pricing</h1>
|
||||
<p className="text-slate-600 dark:text-slate-400">
|
||||
Placeholder — employer subscription tiers arrive in a later phase.
|
||||
<div className="flex flex-col gap-10">
|
||||
<header className="flex flex-col gap-2">
|
||||
<Badge variant="info">Marketplace · Pricing</Badge>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100">
|
||||
Pricing
|
||||
</h1>
|
||||
<p className="max-w-2xl text-slate-600 dark:text-slate-400">
|
||||
Hire AI-credentialed talent. Post roles, unlock the talent database, and let AI matching
|
||||
find the right practitioner for your stack.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Job posting packages */}
|
||||
<section className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl font-semibold text-slate-900 dark:text-slate-100">
|
||||
Job Posting Packages
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{JOB_PLANS.map((plan) => (
|
||||
<PricingCard key={plan.id} plan={plan} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Talent access plans */}
|
||||
<section className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-xl font-semibold text-slate-900 dark:text-slate-100">
|
||||
Talent Access Plans
|
||||
</h2>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
|
||||
{TALENT_PLANS.map((plan) => (
|
||||
<PricingCard key={plan.id} plan={plan} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Feature comparison */}
|
||||
<section className="flex flex-col gap-4">
|
||||
<h2 className="text-xl font-semibold text-slate-900 dark:text-slate-100">
|
||||
Compare features
|
||||
</h2>
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[640px] text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 dark:border-slate-800">
|
||||
<th className="p-4 text-left font-semibold text-slate-900 dark:text-slate-100">
|
||||
Feature
|
||||
</th>
|
||||
{COMPARISON_COLUMNS.map((c) => (
|
||||
<th
|
||||
key={c.key}
|
||||
className="p-4 text-left font-semibold text-slate-900 dark:text-slate-100"
|
||||
>
|
||||
{c.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{COMPARISON.map((row, i) => (
|
||||
<tr
|
||||
key={row.feature}
|
||||
className={i % 2 === 0 ? '' : 'bg-slate-50 dark:bg-slate-800/40'}
|
||||
>
|
||||
<td className="p-4 text-slate-700 dark:text-slate-300">{row.feature}</td>
|
||||
{row.values.map((v, idx) => (
|
||||
<td key={idx} className="p-4 text-slate-700 dark:text-slate-300">
|
||||
{typeof v === 'boolean' ? (
|
||||
v ? (
|
||||
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">
|
||||
<Check className="h-3 w-3" />
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-slate-100 text-slate-400 dark:bg-slate-800 dark:text-slate-500">
|
||||
<X className="h-3 w-3" />
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<span>{v}</span>
|
||||
)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const COMPARISON_COLUMNS = [
|
||||
{ key: 'single', label: 'Single Post' },
|
||||
{ key: 'bundle', label: 'Bundle' },
|
||||
{ key: 'enterprise', label: 'Enterprise' },
|
||||
] as const;
|
||||
|
||||
function PricingCard({
|
||||
plan,
|
||||
}: {
|
||||
plan: JobPricingPlan | TalentPricingPlan;
|
||||
}) {
|
||||
const isPopular = plan.popular;
|
||||
return (
|
||||
<Card
|
||||
className={[
|
||||
'flex h-full flex-col',
|
||||
isPopular
|
||||
? 'border-primary-500 ring-2 ring-primary-500/30 dark:border-primary-400'
|
||||
: '',
|
||||
].join(' ')}
|
||||
>
|
||||
<CardBody className="flex flex-1 flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="inline-flex h-9 w-9 items-center justify-center rounded-lg bg-primary-100 text-primary-700 dark:bg-primary-900/40 dark:text-primary-300">
|
||||
{plan.icon}
|
||||
</span>
|
||||
{isPopular && (
|
||||
<Badge variant="info">
|
||||
<Star className="h-3 w-3" />
|
||||
Most Popular
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
{plan.name}
|
||||
</h3>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-3xl font-bold text-slate-900 dark:text-slate-100">
|
||||
{plan.price}
|
||||
</span>
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400">{plan.cadence}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{plan.features.map((f) => (
|
||||
<li
|
||||
key={f}
|
||||
className="flex items-start gap-2 text-sm text-slate-700 dark:text-slate-300"
|
||||
>
|
||||
<span className="mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">
|
||||
<Check className="h-3 w-3" />
|
||||
</span>
|
||||
<span>{f}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-auto pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={isPopular ? 'primary' : 'outline'}
|
||||
size="md"
|
||||
className="w-full"
|
||||
>
|
||||
{plan.cta}
|
||||
</Button>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Bookmark, Search, X } from 'lucide-react';
|
||||
import type { Job, Seniority } from '@nextcraft/types';
|
||||
import { Button, Input } from '@nextcraft/ui';
|
||||
import {
|
||||
formatSalaryK,
|
||||
matchBadgeClasses,
|
||||
relativeTime,
|
||||
seniorityLabel,
|
||||
} from '../../lib/format';
|
||||
|
||||
const POPULAR_SKILLS = [
|
||||
'LLM',
|
||||
'RAG',
|
||||
'Agents',
|
||||
'Python',
|
||||
'TypeScript',
|
||||
'React',
|
||||
'Vector DB',
|
||||
'Fine-tuning',
|
||||
] as const;
|
||||
|
||||
const SENIORITY_OPTIONS: Array<Seniority | 'all'> = [
|
||||
'all',
|
||||
'entry',
|
||||
'mid',
|
||||
'senior',
|
||||
'staff',
|
||||
'principal',
|
||||
];
|
||||
|
||||
export interface JobFiltersProps {
|
||||
jobs: Job[];
|
||||
employerNames: Record<string, string>;
|
||||
}
|
||||
|
||||
interface Filters {
|
||||
query: string;
|
||||
skills: string[];
|
||||
seniority: Seniority | 'all';
|
||||
remoteOnly: boolean;
|
||||
salaryMin: string;
|
||||
salaryMax: string;
|
||||
}
|
||||
|
||||
const EMPTY_FILTERS: Filters = {
|
||||
query: '',
|
||||
skills: [],
|
||||
seniority: 'all',
|
||||
remoteOnly: false,
|
||||
salaryMin: '',
|
||||
salaryMax: '',
|
||||
};
|
||||
|
||||
function matchesJob(job: Job, f: Filters, employerName: string): boolean {
|
||||
// Text query — match title, company, or any skill.
|
||||
if (f.query.trim()) {
|
||||
const q = f.query.trim().toLowerCase();
|
||||
const haystack = [
|
||||
job.title,
|
||||
employerName,
|
||||
job.location,
|
||||
...job.skills,
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
if (!haystack.includes(q)) return false;
|
||||
}
|
||||
|
||||
// Skill filters — job must include every selected skill (case-insensitive).
|
||||
if (f.skills.length > 0) {
|
||||
const jobSkillsLower = job.skills.map((s) => s.toLowerCase());
|
||||
const ok = f.skills.every((sel) =>
|
||||
jobSkillsLower.some((s) => s.includes(sel.toLowerCase())),
|
||||
);
|
||||
if (!ok) return false;
|
||||
}
|
||||
|
||||
// Seniority.
|
||||
if (f.seniority !== 'all' && job.seniority !== f.seniority) return false;
|
||||
|
||||
// Remote only.
|
||||
if (f.remoteOnly && !job.remote) return false;
|
||||
|
||||
// Salary range.
|
||||
if (f.salaryMin.trim()) {
|
||||
const min = Number(f.salaryMin);
|
||||
if (!Number.isNaN(min) && job.salaryMax < min) return false;
|
||||
}
|
||||
if (f.salaryMax.trim()) {
|
||||
const max = Number(f.salaryMax);
|
||||
if (!Number.isNaN(max) && job.salaryMin > max) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function JobFilters({ jobs, employerNames }: JobFiltersProps) {
|
||||
const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS);
|
||||
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
jobs.filter((job) =>
|
||||
matchesJob(job, filters, employerNames[job.employerId] ?? 'Unknown employer'),
|
||||
),
|
||||
[jobs, filters, employerNames],
|
||||
);
|
||||
|
||||
function toggleSkill(skill: string) {
|
||||
setFilters((f) => ({
|
||||
...f,
|
||||
skills: f.skills.includes(skill)
|
||||
? f.skills.filter((s) => s !== skill)
|
||||
: [...f.skills, skill],
|
||||
}));
|
||||
}
|
||||
|
||||
function resetAll() {
|
||||
setFilters(EMPTY_FILTERS);
|
||||
}
|
||||
|
||||
const activeCount =
|
||||
(filters.query.trim() ? 1 : 0) +
|
||||
filters.skills.length +
|
||||
(filters.seniority !== 'all' ? 1 : 0) +
|
||||
(filters.remoteOnly ? 1 : 0) +
|
||||
(filters.salaryMin.trim() ? 1 : 0) +
|
||||
(filters.salaryMax.trim() ? 1 : 0);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Search row */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<Input
|
||||
name="marketplace-search"
|
||||
placeholder="Search AI-era roles, skills, companies..."
|
||||
icon={<Search className="h-4 w-4" />}
|
||||
value={filters.query}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, query: e.target.value }))}
|
||||
aria-label="Search jobs"
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={<Bookmark className="h-3.5 w-3.5" />}
|
||||
type="button"
|
||||
>
|
||||
Save Search
|
||||
</Button>
|
||||
{activeCount > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={<X className="h-3.5 w-3.5" />}
|
||||
type="button"
|
||||
onClick={resetAll}
|
||||
>
|
||||
Clear ({activeCount})
|
||||
</Button>
|
||||
)}
|
||||
<span className="ml-auto text-sm text-slate-500 dark:text-slate-400">
|
||||
Showing <span className="font-semibold text-slate-700 dark:text-slate-200">{filtered.length}</span> of {jobs.length} jobs
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[260px_1fr]">
|
||||
{/* Sidebar filters */}
|
||||
<aside className="flex flex-col gap-6 rounded-lg border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">Seniority</h3>
|
||||
<select
|
||||
aria-label="Seniority filter"
|
||||
value={filters.seniority}
|
||||
onChange={(e) =>
|
||||
setFilters((f) => ({
|
||||
...f,
|
||||
seniority: e.target.value as Seniority | 'all',
|
||||
}))
|
||||
}
|
||||
className="h-10 w-full rounded-md border border-slate-300 bg-white px-3 text-sm text-slate-900 focus:border-primary-500 focus:ring-2 focus:ring-primary-500/30 focus:outline-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
|
||||
>
|
||||
{SENIORITY_OPTIONS.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{s === 'all' ? 'All seniorities' : seniorityLabel(s as Seniority)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">Popular skills</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{POPULAR_SKILLS.map((skill) => {
|
||||
const active = filters.skills.includes(skill);
|
||||
return (
|
||||
<button
|
||||
key={skill}
|
||||
type="button"
|
||||
onClick={() => toggleSkill(skill)}
|
||||
aria-pressed={active}
|
||||
className={[
|
||||
'rounded-full border px-2.5 py-1 text-xs font-medium transition-colors',
|
||||
active
|
||||
? 'border-primary-600 bg-primary-600 text-white'
|
||||
: 'border-slate-300 bg-white text-slate-700 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700',
|
||||
].join(' ')}
|
||||
>
|
||||
{skill}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">Salary (USD)</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={1000}
|
||||
placeholder="Min"
|
||||
aria-label="Minimum salary"
|
||||
value={filters.salaryMin}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, salaryMin: e.target.value }))}
|
||||
className="h-9 w-full rounded-md border border-slate-300 bg-white px-2.5 text-sm text-slate-900 focus:border-primary-500 focus:ring-2 focus:ring-primary-500/30 focus:outline-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
|
||||
/>
|
||||
<span className="text-slate-400">–</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={1000}
|
||||
placeholder="Max"
|
||||
aria-label="Maximum salary"
|
||||
value={filters.salaryMax}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, salaryMax: e.target.value }))}
|
||||
className="h-9 w-full rounded-md border border-slate-300 bg-white px-2.5 text-sm text-slate-900 focus:border-primary-500 focus:ring-2 focus:ring-primary-500/30 focus:outline-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-500">Enter values in USD (e.g. 120000).</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">Work location</h3>
|
||||
<label className="flex cursor-pointer items-center justify-between gap-2 text-sm text-slate-700 dark:text-slate-200">
|
||||
<span>Remote only</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={filters.remoteOnly}
|
||||
onClick={() => setFilters((f) => ({ ...f, remoteOnly: !f.remoteOnly }))}
|
||||
className={[
|
||||
'relative h-6 w-11 rounded-full transition-colors',
|
||||
filters.remoteOnly ? 'bg-primary-600' : 'bg-slate-300 dark:bg-slate-700',
|
||||
].join(' ')}
|
||||
>
|
||||
<span
|
||||
className={[
|
||||
'absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform',
|
||||
filters.remoteOnly ? 'translate-x-5' : 'translate-x-0.5',
|
||||
].join(' ')}
|
||||
/>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Results grid */}
|
||||
<div className="flex flex-col gap-4">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-slate-300 p-12 text-center text-slate-500 dark:border-slate-700 dark:text-slate-400">
|
||||
No jobs match your filters. Try clearing some filters.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{filtered.map((job) => (
|
||||
<JobCard
|
||||
key={job.id}
|
||||
job={job}
|
||||
employerName={employerNames[job.employerId] ?? 'Unknown employer'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function JobCard({ job, employerName }: { job: Job; employerName: string }) {
|
||||
return (
|
||||
<article className="flex flex-col gap-3 rounded-lg border border-slate-200 bg-white p-5 shadow-sm transition-shadow hover:shadow-md dark:border-slate-800 dark:bg-slate-900">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<span
|
||||
aria-hidden
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary-100 text-sm font-semibold text-primary-700 dark:bg-primary-900/40 dark:text-primary-200"
|
||||
>
|
||||
{initialsOf(employerName)}
|
||||
</span>
|
||||
<div className="flex flex-col">
|
||||
<h3 className="text-base font-semibold text-slate-900 dark:text-slate-100">
|
||||
{job.title}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400">{employerName}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-full text-xs font-bold ring-2 ${matchBadgeClasses(
|
||||
job.matchScore,
|
||||
)}`}
|
||||
title={`${job.matchScore}% AI match`}
|
||||
>
|
||||
{job.matchScore}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-slate-600 dark:text-slate-400">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="text-slate-400">📍</span>
|
||||
{job.location}
|
||||
</span>
|
||||
{job.remote && (
|
||||
<span className="inline-flex items-center rounded-full bg-emerald-100 px-2 py-0.5 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300">
|
||||
Remote
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span className="text-slate-400">$</span>
|
||||
{formatSalaryK(job.salaryMin)}–{formatSalaryK(job.salaryMax)}
|
||||
</span>
|
||||
<span>{relativeTime(job.postedAt)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{job.skills.slice(0, 5).map((skill) => (
|
||||
<span
|
||||
key={skill}
|
||||
className="rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-700 dark:bg-slate-800 dark:text-slate-200"
|
||||
>
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto pt-1">
|
||||
<a
|
||||
href={`/marketplace/jobs/${job.id}`}
|
||||
className="inline-flex h-9 w-full items-center justify-center rounded-md border border-slate-300 bg-transparent text-sm font-medium text-slate-800 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:text-slate-100 dark:hover:bg-slate-800"
|
||||
>
|
||||
View Details
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
// small inline helper so the card does not depend on the lib file twice
|
||||
function initialsOf(name: string): string {
|
||||
return name
|
||||
.split(' ')
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((p) => p[0]?.toUpperCase() ?? '')
|
||||
.join('');
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Marketplace formatting helpers — pure functions shared across marketplace pages.
|
||||
* Mock-data only; no backend calls.
|
||||
*/
|
||||
|
||||
import type { Seniority } from '@nextcraft/types';
|
||||
|
||||
/** Format a salary value as `$160K` (divides by 1000). */
|
||||
export function formatSalaryK(value: number): string {
|
||||
return `$${Math.round(value / 1000)}K`;
|
||||
}
|
||||
|
||||
/** Format a salary range as `$160K–$240K`. */
|
||||
export function formatSalaryRange(min: number, max: number): string {
|
||||
return `${formatSalaryK(min)}–${formatSalaryK(max)}`;
|
||||
}
|
||||
|
||||
const SENIORITY_LABELS: Record<Seniority, string> = {
|
||||
entry: 'Entry',
|
||||
mid: 'Mid',
|
||||
senior: 'Senior',
|
||||
staff: 'Lead',
|
||||
principal: 'Principal',
|
||||
};
|
||||
|
||||
export function seniorityLabel(s: Seniority): string {
|
||||
return SENIORITY_LABELS[s] ?? s;
|
||||
}
|
||||
|
||||
/**
|
||||
* Relative time label like "2 days ago" / "3 weeks ago".
|
||||
* Computed against a fixed "today" so the prototype renders deterministically
|
||||
* regardless of when it is built.
|
||||
*/
|
||||
export function relativeTime(iso: string, now: Date = new Date('2026-09-10T00:00:00Z')): string {
|
||||
const then = new Date(iso).getTime();
|
||||
const diffMs = now.getTime() - then;
|
||||
const days = Math.max(0, Math.round(diffMs / (1000 * 60 * 60 * 24)));
|
||||
if (days <= 0) return 'today';
|
||||
if (days === 1) return '1 day ago';
|
||||
if (days < 30) return `${days} days ago`;
|
||||
const weeks = Math.round(days / 7);
|
||||
if (weeks === 1) return '1 week ago';
|
||||
if (weeks < 5) return `${weeks} weeks ago`;
|
||||
const months = Math.round(days / 30);
|
||||
return months === 1 ? '1 month ago' : `${months} months ago`;
|
||||
}
|
||||
|
||||
/** Color-coded bucket for a match score (0–100). */
|
||||
export type MatchTone = 'high' | 'mid' | 'low';
|
||||
|
||||
export function matchTone(score: number): MatchTone {
|
||||
if (score > 85) return 'high';
|
||||
if (score >= 70) return 'mid';
|
||||
return 'low';
|
||||
}
|
||||
|
||||
/** Tailwind class bundle for the circular match badge background/text. */
|
||||
export function matchBadgeClasses(score: number): string {
|
||||
const tone = matchTone(score);
|
||||
if (tone === 'high')
|
||||
return 'bg-emerald-100 text-emerald-700 ring-emerald-300 dark:bg-emerald-900/40 dark:text-emerald-300 dark:ring-emerald-700';
|
||||
if (tone === 'mid')
|
||||
return 'bg-amber-100 text-amber-700 ring-amber-300 dark:bg-amber-900/40 dark:text-amber-300 dark:ring-amber-700';
|
||||
return 'bg-orange-100 text-orange-700 ring-orange-300 dark:bg-orange-900/40 dark:text-orange-300 dark:ring-orange-700';
|
||||
}
|
||||
|
||||
/** Tailwind class bundle for a horizontal skill match bar fill. */
|
||||
export function matchBarClasses(score: number): string {
|
||||
const tone = matchTone(score);
|
||||
if (tone === 'high') return 'bg-emerald-500';
|
||||
if (tone === 'mid') return 'bg-amber-500';
|
||||
return 'bg-orange-500';
|
||||
}
|
||||
|
||||
/** Initials from a name, e.g. "OpenAI Labs" → "OL". */
|
||||
export function initials(name: string): string {
|
||||
return name
|
||||
.split(' ')
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((p) => p[0]?.toUpperCase() ?? '')
|
||||
.join('');
|
||||
}
|
||||
Reference in New Issue
Block a user