810485ecc8
---ci--- phase: 7 milestone: v0.1 status: complete requirements: covered: [REQ-001, REQ-002, REQ-003, REQ-004, REQ-005, REQ-006, REQ-007, REQ-008, REQ-009, REQ-010, REQ-011, REQ-012, REQ-013, REQ-014, REQ-015, REQ-016, REQ-017, REQ-018, REQ-019, REQ-020, REQ-021, REQ-022, REQ-023, REQ-024, REQ-025, REQ-026, REQ-027, REQ-028] partial: [] ---/ci--- Milestone v0.1 (nextcraft-ui-prototype) complete. Summary: - 7 phases (P0 pre-execution + P1-P6 execution + P7 final review) - 28 requirements covered (all complete) - 4 surfaces: Learner (7 pages), Marketplace (5), Employer Dashboard (4), Admin (4) - 19 routes, 104 TypeScript files - Shared component library (5 primitives + design tokens) - Mock data: 5 competency stacks (70 competencies), 20 jobs, 15 candidates, 10 employers - Tech: Next.js 15, Tailwind CSS v4, lucide-react, recharts, @xyflow/react, Inter font - Storybook with 6 stories - Dark mode, breadcrumbs, role switcher, responsive design - Build passes, typecheck passes, Storybook build passes Phases: P0 pre-execution → v0.0.1 P1 project-scaffolding → v0.0.2 P2 learner-surface → v0.0.3 P3 marketplace-surface → v0.0.4 P4 employer-dashboard → v0.0.5 P5 admin-surface → v0.0.6 P6 polish-integration → v0.0.7 P7 final-review-ship → v0.1.0 (milestone release)
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('');
|
||
} |