Redesign UI: modern SaaS dashboard styling with indigo/violet accents
This commit is contained in:
@@ -1,9 +1,25 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
|
||||
type Parsed = Record<string, string | number | null>;
|
||||
|
||||
const FIELD_LABELS: Record<string, string> = {
|
||||
title: "Job Title",
|
||||
company: "Company Name",
|
||||
url: "Listing URL",
|
||||
applyUrl: "Application / Portal URL",
|
||||
location: "Location",
|
||||
remoteType: "Remote Type (e.g. remote, hybrid, onsite)",
|
||||
phEligibility: "PH Eligibility (yes / no / unclear)",
|
||||
payMin: "Minimum Pay",
|
||||
payMax: "Maximum Pay",
|
||||
payCurrency: "Currency (USD, PHP, etc.)",
|
||||
payPeriod: "Pay Period (hour, month, year)",
|
||||
tags: "Tags (comma-separated)",
|
||||
};
|
||||
|
||||
export default function NewJobPage() {
|
||||
const router = useRouter();
|
||||
const [url, setUrl] = useState("");
|
||||
@@ -13,7 +29,8 @@ export default function NewJobPage() {
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
async function parse() {
|
||||
setBusy(true); setErr("");
|
||||
setBusy(true);
|
||||
setErr("");
|
||||
try {
|
||||
const res = await fetch("/api/ai/parse", {
|
||||
method: "POST",
|
||||
@@ -21,62 +38,222 @@ export default function NewJobPage() {
|
||||
body: JSON.stringify({ url: url || undefined, text: text || undefined }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error ?? "parse failed");
|
||||
if (!res.ok) throw new Error(data.error ?? "Parse failed");
|
||||
setParsed(data);
|
||||
} catch (e) { setErr(String(e)); } finally { setBusy(false); }
|
||||
} catch (e) {
|
||||
setErr(String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!parsed) return;
|
||||
setBusy(true);
|
||||
const res = await fetch("/api/jobs", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(parsed),
|
||||
});
|
||||
const job = await res.json();
|
||||
router.push(`/jobs/${job.id}`);
|
||||
try {
|
||||
const res = await fetch("/api/jobs", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(parsed),
|
||||
});
|
||||
const job = await res.json();
|
||||
router.push(`/jobs/${job.id}`);
|
||||
} catch (e) {
|
||||
setErr(String(e));
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function set(k: string, v: string) { setParsed((p) => ({ ...p!, [k]: v || null })); }
|
||||
function set(k: string, v: string) {
|
||||
setParsed((p) => ({ ...p!, [k]: v || null }));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-4">
|
||||
<h1 className="text-2xl font-bold">Add Job</h1>
|
||||
{!parsed && (
|
||||
<>
|
||||
<input value={url} onChange={(e) => setUrl(e.target.value)} placeholder="Job URL (may fail on Cloudflare sites)"
|
||||
className="w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-2 text-sm" />
|
||||
<div className="text-center text-zinc-400 text-sm">— or paste job text (more reliable) —</div>
|
||||
<textarea value={text} onChange={(e) => setText(e.target.value)} rows={10} placeholder="Paste the full job posting text here"
|
||||
className="w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-2 text-sm" />
|
||||
<button onClick={parse} disabled={busy || (!url && !text)}
|
||||
className="rounded bg-blue-600 text-white px-4 py-2 text-sm disabled:opacity-50 hover:bg-blue-700">
|
||||
{busy ? "Parsing with AI…" : "Parse with AI"}
|
||||
</button>
|
||||
</>
|
||||
<div className="max-w-3xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<Link
|
||||
href="/jobs"
|
||||
className="inline-flex items-center gap-1 text-xs font-medium text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100 transition-colors mb-2"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18" />
|
||||
</svg>
|
||||
<span>Back to Jobs</span>
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold tracking-tight text-zinc-900 dark:text-white">Add New Job</h1>
|
||||
<p className="text-sm text-zinc-500 dark:text-zinc-400 mt-1">
|
||||
Provide a job URL or paste the job posting description. AI will automatically extract key details and structured fields.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{err && (
|
||||
<div className="rounded-xl bg-rose-50 dark:bg-rose-950/40 border border-rose-200 dark:border-rose-800/60 p-4 text-sm text-rose-800 dark:text-rose-200 flex items-start gap-3">
|
||||
<svg className="w-5 h-5 text-rose-500 shrink-0 mt-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
|
||||
</svg>
|
||||
<div className="flex-1">{err}</div>
|
||||
</div>
|
||||
)}
|
||||
{err && <div className="rounded bg-red-100 dark:bg-red-900/40 text-red-800 dark:text-red-200 p-3 text-sm">{err}</div>}
|
||||
{parsed && (
|
||||
<div className="space-y-3">
|
||||
<h2 className="font-semibold">Review parsed fields</h2>
|
||||
{["title", "company", "url", "applyUrl", "location", "remoteType", "phEligibility", "payMin", "payMax", "payCurrency", "payPeriod", "tags"].map((k) => (
|
||||
<label key={k} className="block text-sm">
|
||||
<span className="text-zinc-500">{k}</span>
|
||||
<input value={String(parsed[k] ?? "")} onChange={(e) => set(k, e.target.value)}
|
||||
className="mt-1 w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-2" />
|
||||
|
||||
{!parsed ? (
|
||||
<div className="rounded-2xl border border-zinc-200/80 dark:border-zinc-800/80 bg-white dark:bg-zinc-900 p-6 shadow-xs space-y-6">
|
||||
{/* URL Input */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-semibold text-zinc-800 dark:text-zinc-200">
|
||||
Job Listing URL
|
||||
</label>
|
||||
))}
|
||||
<div className="relative">
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-zinc-400">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M13.19 8.688a4.5 4.5 0 011.242 7.244l-4.5 4.5a4.5 4.5 0 01-6.364-6.364l1.757-1.757m13.35-.622l1.757-1.757a4.5 4.5 0 00-6.364-6.364l-4.5 4.5a4.5 4.5 0 001.242 7.244" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://example.com/careers/senior-engineer..."
|
||||
className="w-full rounded-xl border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-950/60 pl-10 pr-4 py-2.5 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:border-indigo-500 focus:outline-hidden focus:ring-2 focus:ring-indigo-500/20 transition-all"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-zinc-400 dark:text-zinc-500">
|
||||
Note: Some sites with anti-bot or Cloudflare protection may block scraping; pasting raw text below is recommended.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="relative flex items-center justify-center">
|
||||
<div className="border-t border-zinc-200 dark:border-zinc-800 w-full" />
|
||||
<span className="bg-white dark:bg-zinc-900 px-3 text-xs font-medium text-zinc-400 uppercase tracking-wider absolute">
|
||||
or paste job text
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Textarea */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-semibold text-zinc-800 dark:text-zinc-200">
|
||||
Job Description / Posting Text
|
||||
</label>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
rows={9}
|
||||
placeholder="Paste the full job posting, responsibilities, requirements, and salary details here..."
|
||||
className="w-full rounded-xl border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-950/60 p-4 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:border-indigo-500 focus:outline-hidden focus:ring-2 focus:ring-indigo-500/20 transition-all leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Parse Button */}
|
||||
<div className="flex justify-end pt-2">
|
||||
<button
|
||||
onClick={parse}
|
||||
disabled={busy || (!url && !text)}
|
||||
className="inline-flex items-center gap-2 px-5 py-2.5 rounded-xl text-sm font-semibold text-white bg-gradient-to-r from-indigo-600 to-violet-600 hover:from-indigo-500 hover:to-violet-500 shadow-sm shadow-indigo-500/20 disabled:opacity-50 disabled:cursor-not-allowed active:scale-98 transition-all"
|
||||
>
|
||||
{busy ? (
|
||||
<>
|
||||
<svg className="animate-spin w-4 h-4 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
<span>Parsing with AI…</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z" />
|
||||
</svg>
|
||||
<span>Parse with AI</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-zinc-200/80 dark:border-zinc-800/80 bg-white dark:bg-zinc-900 p-6 shadow-xs space-y-6">
|
||||
<div className="flex items-center justify-between pb-4 border-b border-zinc-200/80 dark:border-zinc-800/80">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-zinc-900 dark:text-white flex items-center gap-2">
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-950/80 text-emerald-600 dark:text-emerald-400 text-xs">
|
||||
✓
|
||||
</span>
|
||||
Review Extracted Fields
|
||||
</h2>
|
||||
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">
|
||||
Verify and edit the structured data before saving to your tracker.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setParsed(null)}
|
||||
className="text-xs font-medium text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-100"
|
||||
>
|
||||
← Edit Raw Text
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{["title", "company", "url", "applyUrl", "location", "remoteType", "phEligibility", "payMin", "payMax", "payCurrency", "payPeriod", "tags"].map((k) => (
|
||||
<div key={k} className="space-y-1">
|
||||
<label className="block text-xs font-semibold text-zinc-600 dark:text-zinc-300">
|
||||
{FIELD_LABELS[k] || k}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={String(parsed[k] ?? "")}
|
||||
onChange={(e) => set(k, e.target.value)}
|
||||
className="w-full rounded-xl border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-950/60 px-3.5 py-2 text-sm text-zinc-900 dark:text-zinc-100 focus:border-indigo-500 focus:outline-hidden focus:ring-2 focus:ring-indigo-500/20 transition-all"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{["description", "requirements"].map((k) => (
|
||||
<label key={k} className="block text-sm">
|
||||
<span className="text-zinc-500">{k}</span>
|
||||
<textarea value={String(parsed[k] ?? "")} onChange={(e) => set(k, e.target.value)} rows={4}
|
||||
className="mt-1 w-full rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 p-2" />
|
||||
</label>
|
||||
<div key={k} className="space-y-1">
|
||||
<label className="block text-xs font-semibold text-zinc-600 dark:text-zinc-300 capitalize">
|
||||
{k}
|
||||
</label>
|
||||
<textarea
|
||||
value={String(parsed[k] ?? "")}
|
||||
onChange={(e) => set(k, e.target.value)}
|
||||
rows={5}
|
||||
className="w-full rounded-xl border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-950/60 p-3.5 text-sm text-zinc-900 dark:text-zinc-100 focus:border-indigo-500 focus:outline-hidden focus:ring-2 focus:ring-indigo-500/20 transition-all leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-2">
|
||||
<button onClick={save} disabled={busy} className="rounded bg-green-600 text-white px-4 py-2 text-sm hover:bg-green-700">Save Job</button>
|
||||
<button onClick={() => setParsed(null)} className="rounded border border-zinc-300 dark:border-zinc-700 px-4 py-2 text-sm">Back</button>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-zinc-200/80 dark:border-zinc-800/80">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setParsed(null)}
|
||||
className="px-4 py-2 rounded-xl text-sm font-medium text-zinc-700 dark:text-zinc-300 bg-white dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-all"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={save}
|
||||
disabled={busy}
|
||||
className="inline-flex items-center gap-2 px-5 py-2 rounded-xl text-sm font-semibold text-white bg-gradient-to-r from-emerald-600 to-teal-600 hover:from-emerald-500 hover:to-teal-500 shadow-sm shadow-emerald-500/20 disabled:opacity-50 active:scale-98 transition-all"
|
||||
>
|
||||
{busy ? (
|
||||
<>
|
||||
<svg className="animate-spin w-4 h-4 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
<span>Saving Job…</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.5 12.75l6 6 9-13.5" />
|
||||
</svg>
|
||||
<span>Save Job</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user