432049041c
---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
84 lines
2.8 KiB
TypeScript
84 lines
2.8 KiB
TypeScript
/**
|
||
* 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('');
|
||
} |