acf60c2a07
Wave 6. Swaps react-scripts' build/dev tooling for Next.js while keeping the app's actual routing/rendering model unchanged -- the app is almost entirely behind auth with no proven SSR/SEO need, so a real App Router rewrite would touch ~90 files for zero user-visible benefit. - next.config.js: output:'export' (static HTML+JS, same "single index.html served by nginx with try_files fallback" deploy as CRA). - app/layout.tsx + app/page.tsx: root shell ports public/index.html's <head>, mounts the whole existing App tree client-only (ssr:false) since it reads window/localStorage during initial render and Next's static prerender would otherwise execute that on the server. - Renamed src/pages/ -> src/views/ (Next's Pages Router auto-detects any `pages/` dir under the app root and tried to build our React Router page components as its own routes). - REACT_APP_* -> NEXT_PUBLIC_* across code, .env.development, Dockerfile, docker-compose.yml build args. - Replaced the CRA SVGR import (`ReactComponent` from .svg, unsupported under Turbopack) with a small inline JobbjaktMark component. - TypeScript 4.9 -> 5.9 (MUI v8's type-checked build needs syntax 4.9's parser rejects; CRA never hit this because babel doesn't type-check). - Dropped CRA-only files (index.tsx, reportWebVitals, react-app-env.d.ts, public/index.html); kept react-scripts as the Jest test runner only (next/jest migration not needed -- the existing config already works). Verified: `next build` static export succeeds, `next dev` serves the landing page and client-side routes (login etc.) correctly, all 57 frontend tests + 172 backend tests still green. Known caveat: deep-linking straight to a sub-route (e.g. /login) 404s in `next dev` since there's no server route for it -- the app only ever mounts at "/". Production is unaffected: nginx's existing try_files fallback still serves index.html for any path.
264 lines
16 KiB
TypeScript
264 lines
16 KiB
TypeScript
import React, { useEffect, useState } from "react";
|
|
|
|
import { Box, Button, Container, Stack, Typography } from "@mui/material";
|
|
import { alpha } from "@mui/material/styles";
|
|
import { useLocation, useNavigate } from "react-router-dom";
|
|
|
|
import DashboardIcon from "@mui/icons-material/SpaceDashboardOutlined";
|
|
import AlarmIcon from "@mui/icons-material/NotificationsActiveOutlined";
|
|
import MatchIcon from "@mui/icons-material/FactCheckOutlined";
|
|
import MailIcon from "@mui/icons-material/MarkEmailReadOutlined";
|
|
import AttachIcon from "@mui/icons-material/DescriptionOutlined";
|
|
import InsightsIcon from "@mui/icons-material/InsightsOutlined";
|
|
|
|
import { api } from "../api";
|
|
|
|
const BRAND_DARK = "#0b1020";
|
|
const BRAND_PANEL = "#111a33";
|
|
|
|
const FEATURES: { icon: React.ReactNode; title: string; body: string }[] = [
|
|
{ icon: <DashboardIcon />, title: "Centralized pipeline", body: "Track every application across Applied, Waiting, Interview, Offer, Rejected and Ghosted — drag to update." },
|
|
{ icon: <AlarmIcon />, title: "Smart follow-ups", body: "Reminders surface what needs attention next, with a grounded draft ready to review and send." },
|
|
{ icon: <MatchIcon />, title: "Honest CV match", body: "A deterministic keyword-coverage score with matched vs missing skills — not an opaque black box." },
|
|
{ icon: <MailIcon />, title: "Email correspondence", body: "Link Gmail threads to a job; new replies appear automatically without re-importing." },
|
|
{ icon: <AttachIcon />, title: "Attachments & docs", body: "Keep resumes, cover letters and portfolios versioned per application, right where you need them." },
|
|
{ icon: <InsightsIcon />, title: "Dashboard & insights", body: "Response rates, funnel, time-in-stage and skill demand across your whole search." },
|
|
];
|
|
|
|
const STEPS: { n: number; title: string; body: string }[] = [
|
|
{ n: 1, title: "Import", body: "Paste a job URL or use the bookmarklet — we parse the role into structured fields." },
|
|
{ n: 2, title: "Match", body: "See how your CV covers the role: matched keywords and the gaps to close." },
|
|
{ n: 3, title: "Tailor", body: "AI drafts a tailored CV and cover letter — you review every word before it goes out." },
|
|
{ n: 4, title: "Track", body: "Move it through the pipeline; documents, notes and history stay attached." },
|
|
{ n: 5, title: "Follow up", body: "Linked email threads and reminders keep momentum with grounded replies." },
|
|
{ n: 6, title: "Analyze", body: "See what's working — response rate, funnel and time-in-stage — and focus your effort." },
|
|
];
|
|
|
|
const PRICING: { name: string; price: string; cadence: string; highlight: boolean; features: string[]; cta: string }[] = [
|
|
{ name: "Free", price: "£0", cadence: "forever", highlight: false, cta: "Get started", features: ["Unlimited job tracking & pipeline", "One-click capture (bookmarklet + PWA)", "Deterministic CV↔job match score", "3 AI CV tailors / month"] },
|
|
{ name: "Pro", price: "£9", cadence: "/ month · billed monthly or yearly", highlight: true, cta: "Start Pro", features: ["Everything in Free", "Unlimited AI CV & cover-letter tailoring", "CV versions + factuality guardrail", "Gmail correspondence CRM", "Analytics drill-downs"] },
|
|
{ name: "Bring your own key", price: "£3", cadence: "/ month + your AI key", highlight: false, cta: "Get started", features: ["Everything in Pro", "Use your own Gemini / Groq key", "Unlimited AI at provider cost", "Privacy-first & self-host friendly"] },
|
|
];
|
|
|
|
export default function LandingPage() {
|
|
const navigate = useNavigate();
|
|
const location = useLocation() as { state?: { from?: string } };
|
|
const [checking, setChecking] = useState(true);
|
|
|
|
// If the visitor already has a session, send them straight into the app.
|
|
useEffect(() => {
|
|
let active = true;
|
|
api
|
|
.get("/auth/me")
|
|
.then(() => { if (active) navigate("/jobs", { replace: true }); })
|
|
.catch(() => { if (active) setChecking(false); });
|
|
return () => { active = false; };
|
|
}, [navigate]);
|
|
|
|
// A protected route redirects unauthenticated visitors here with the page they
|
|
// wanted in location state; forward it to /login so sign-in returns them there
|
|
// instead of dropping them on /jobs.
|
|
const goToLogin = () => navigate("/login", { state: location.state });
|
|
|
|
if (checking) {
|
|
return (
|
|
<Box sx={{ minHeight: "100vh", display: "grid", placeItems: "center", bgcolor: BRAND_DARK }}>
|
|
<Typography sx={{ color: "#94a3b8" }}>Loading…</Typography>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
const gradientText = {
|
|
background: "linear-gradient(90deg,#6366f1,#22d3ee)",
|
|
WebkitBackgroundClip: "text",
|
|
WebkitTextFillColor: "transparent",
|
|
} as const;
|
|
|
|
return (
|
|
<Box sx={{ bgcolor: "background.default" }}>
|
|
{/* Top bar */}
|
|
<Box sx={{ position: "sticky", top: 0, zIndex: 10, bgcolor: alpha(BRAND_DARK, 0.85), backdropFilter: "blur(8px)", borderBottom: `1px solid ${alpha("#ffffff", 0.08)}` }}>
|
|
<Container maxWidth="lg">
|
|
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ height: 64 }}>
|
|
<Stack direction="row" alignItems="center" spacing={1.25}>
|
|
<Box sx={{ width: 30, height: 30, borderRadius: "8px", background: "linear-gradient(135deg,#6366f1,#22d3ee)", display: "grid", placeItems: "center", color: BRAND_DARK, fontWeight: 900 }}>✓</Box>
|
|
<Typography sx={{ color: "#fff", fontWeight: 800, fontSize: 20 }}>JobTrack</Typography>
|
|
</Stack>
|
|
<Button variant="contained" onClick={goToLogin} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 700 }}>
|
|
Sign in
|
|
</Button>
|
|
</Stack>
|
|
</Container>
|
|
</Box>
|
|
|
|
{/* Hero */}
|
|
<Box sx={{ background: `radial-gradient(1200px 500px at 80% -10%, ${alpha("#6366f1", 0.35)}, transparent), linear-gradient(180deg, ${BRAND_DARK}, ${BRAND_PANEL})`, color: "#fff", py: { xs: 8, md: 12 } }}>
|
|
<Container maxWidth="lg">
|
|
<Box sx={{ maxWidth: 760 }}>
|
|
<Box sx={{ display: "inline-block", px: 1.5, py: 0.5, borderRadius: 999, bgcolor: alpha("#ffffff", 0.08), color: "#a5b4fc", fontSize: 13, fontWeight: 600, letterSpacing: 0.5, mb: 3 }}>
|
|
AI-ASSISTED JOB SEARCH WORKSPACE
|
|
</Box>
|
|
<Typography component="h1" sx={{ fontWeight: 800, fontSize: { xs: 40, md: 60 }, lineHeight: 1.05, mb: 2 }}>
|
|
Run your job search without losing <Box component="span" sx={gradientText}>the thread</Box>.
|
|
</Typography>
|
|
<Typography sx={{ color: "#94a3b8", fontSize: { xs: 17, md: 20 }, mb: 4 }}>
|
|
Import a role, tailor your CV, track every application, and keep recruiter correspondence tied to the
|
|
right job — all in one focused workspace. Assistive, never autonomous: you approve every draft.
|
|
</Typography>
|
|
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
|
|
<Button size="large" variant="contained" onClick={goToLogin} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 800, px: 4, py: 1.25 }}>
|
|
Get started
|
|
</Button>
|
|
<Button size="large" variant="outlined" href="#features" sx={{ color: "#e2e8f0", borderColor: alpha("#ffffff", 0.25), px: 3, py: 1.25 }}>
|
|
See features
|
|
</Button>
|
|
</Stack>
|
|
<Typography sx={{ color: "#64748b", fontSize: 14, mt: 3 }}>
|
|
React · TypeScript · ASP.NET Core · EF Core · FastAPI AI · Gmail
|
|
</Typography>
|
|
</Box>
|
|
</Container>
|
|
</Box>
|
|
|
|
{/* Product preview */}
|
|
<Container maxWidth="lg" sx={{ py: { xs: 6, md: 9 } }}>
|
|
<Box sx={{ textAlign: "center", mb: 5 }}>
|
|
<Typography sx={{ color: "primary.main", fontWeight: 700, letterSpacing: 1, fontSize: 13 }}>SEE IT IN ACTION</Typography>
|
|
<Typography component="h2" sx={{ fontWeight: 800, fontSize: { xs: 26, md: 34 }, mt: 1 }}>Your whole search, at a glance</Typography>
|
|
</Box>
|
|
<Box sx={{ borderRadius: 3, overflow: "hidden", border: "1px solid", borderColor: "divider", boxShadow: 10, mb: 3, bgcolor: "background.paper" }}>
|
|
<Box component="img" src="/mockups/dashboard.svg" alt="JobTrack dashboard — KPIs, funnel, response trend and follow-ups" sx={{ width: "100%", display: "block" }} />
|
|
</Box>
|
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 3 }}>
|
|
<Box sx={{ borderRadius: 3, overflow: "hidden", border: "1px solid", borderColor: "divider", boxShadow: 6, bgcolor: "background.paper" }}>
|
|
<Box component="img" src="/mockups/pipeline.svg" alt="Drag-and-drop pipeline board" sx={{ width: "100%", display: "block" }} />
|
|
</Box>
|
|
<Box sx={{ borderRadius: 3, overflow: "hidden", border: "1px solid", borderColor: "divider", boxShadow: 6, bgcolor: "background.paper" }}>
|
|
<Box component="img" src="/mockups/workspace.svg" alt="Per-job workspace with match score, correspondence and attachments" sx={{ width: "100%", display: "block" }} />
|
|
</Box>
|
|
</Box>
|
|
<Typography sx={{ textAlign: "center", color: "text.secondary", fontSize: 13, mt: 2 }}>Interface preview.</Typography>
|
|
</Container>
|
|
|
|
{/* Features */}
|
|
<Container id="features" maxWidth="lg" sx={{ py: { xs: 7, md: 10 } }}>
|
|
<Box sx={{ textAlign: "center", mb: 6 }}>
|
|
<Typography sx={{ color: "primary.main", fontWeight: 700, letterSpacing: 1, fontSize: 13 }}>WHAT IT DOES</Typography>
|
|
<Typography component="h2" sx={{ fontWeight: 800, fontSize: { xs: 28, md: 38 }, mt: 1 }}>One workspace for the whole search</Typography>
|
|
<Typography sx={{ color: "text.secondary", fontSize: 18, mt: 1.5 }}>
|
|
Everything from a single import to the final offer — no more spreadsheets and scattered inboxes.
|
|
</Typography>
|
|
</Box>
|
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr", md: "1fr 1fr 1fr" }, gap: 3 }}>
|
|
{FEATURES.map((f) => (
|
|
<Box key={f.title} sx={{ p: 3, borderRadius: 3, border: "1px solid", borderColor: "divider", bgcolor: "background.paper", transition: "box-shadow .2s, transform .2s", "&:hover": { boxShadow: 6, transform: "translateY(-2px)" } }}>
|
|
<Box sx={{ width: 48, height: 48, borderRadius: 2.5, display: "grid", placeItems: "center", bgcolor: alpha("#6366f1", 0.12), color: "primary.main", mb: 2 }}>{f.icon}</Box>
|
|
<Typography sx={{ fontWeight: 700, fontSize: 19, mb: 0.75 }}>{f.title}</Typography>
|
|
<Typography sx={{ color: "text.secondary", fontSize: 15 }}>{f.body}</Typography>
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
</Container>
|
|
|
|
{/* How it works */}
|
|
<Box sx={{ background: `linear-gradient(180deg, ${BRAND_DARK}, ${BRAND_PANEL})`, color: "#fff", py: { xs: 7, md: 10 } }}>
|
|
<Container maxWidth="lg">
|
|
<Box sx={{ textAlign: "center", mb: 6 }}>
|
|
<Typography sx={{ color: "#a5b4fc", fontWeight: 700, letterSpacing: 1, fontSize: 13 }}>HOW IT WORKS</Typography>
|
|
<Typography component="h2" sx={{ fontWeight: 800, fontSize: { xs: 28, md: 38 }, mt: 1 }}>From a link to an offer</Typography>
|
|
</Box>
|
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "1fr 1fr", md: "1fr 1fr 1fr" }, gap: 3 }}>
|
|
{STEPS.map((s) => (
|
|
<Box key={s.n} sx={{ p: 3, borderRadius: 3, border: `1px solid ${alpha("#ffffff", 0.1)}`, bgcolor: alpha("#ffffff", 0.03) }}>
|
|
<Box sx={{ width: 40, height: 40, borderRadius: 999, display: "grid", placeItems: "center", background: "linear-gradient(135deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 900, mb: 1.5 }}>{s.n}</Box>
|
|
<Typography sx={{ fontWeight: 700, fontSize: 18, mb: 0.5 }}>{s.title}</Typography>
|
|
<Typography sx={{ color: "#94a3b8", fontSize: 15 }}>{s.body}</Typography>
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
</Container>
|
|
</Box>
|
|
|
|
{/* Pricing */}
|
|
<Container id="pricing" maxWidth="lg" sx={{ py: { xs: 7, md: 10 } }}>
|
|
<Box sx={{ textAlign: "center", mb: 6 }}>
|
|
<Typography sx={{ color: "primary.main", fontWeight: 700, letterSpacing: 1, fontSize: 13 }}>PRICING</Typography>
|
|
<Typography component="h2" sx={{ fontWeight: 800, fontSize: { xs: 28, md: 38 }, mt: 1 }}>Honest, simple pricing</Typography>
|
|
<Typography sx={{ color: "text.secondary", fontSize: 18, mt: 1.5 }}>
|
|
Billed monthly or yearly — never by the week. Cancel anytime.
|
|
</Typography>
|
|
</Box>
|
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)" }, gap: 3, alignItems: "start" }}>
|
|
{PRICING.map((tier) => (
|
|
<Box
|
|
key={tier.name}
|
|
sx={{
|
|
p: 3.5,
|
|
borderRadius: 3,
|
|
position: "relative",
|
|
bgcolor: "background.paper",
|
|
border: "2px solid",
|
|
borderColor: tier.highlight ? "primary.main" : "divider",
|
|
boxShadow: tier.highlight ? 8 : 0,
|
|
}}
|
|
>
|
|
{tier.highlight && (
|
|
<Box sx={{ position: "absolute", top: -13, left: 24, px: 1.5, py: 0.5, borderRadius: 999, background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontSize: 12, fontWeight: 800 }}>
|
|
Most popular
|
|
</Box>
|
|
)}
|
|
<Typography sx={{ fontWeight: 700, fontSize: 18 }}>{tier.name}</Typography>
|
|
<Stack direction="row" alignItems="baseline" spacing={0.75} sx={{ my: 1.5 }}>
|
|
<Typography sx={{ fontWeight: 900, fontSize: 40, lineHeight: 1 }}>{tier.price}</Typography>
|
|
<Typography sx={{ color: "text.secondary", fontSize: 14 }}>{tier.cadence}</Typography>
|
|
</Stack>
|
|
<Stack spacing={1.25} sx={{ my: 2.5 }}>
|
|
{tier.features.map((f) => (
|
|
<Stack key={f} direction="row" spacing={1.25} alignItems="flex-start">
|
|
<Box sx={{ color: "success.main", fontWeight: 900, lineHeight: 1.4 }}>✓</Box>
|
|
<Typography sx={{ fontSize: 15, color: "text.secondary" }}>{f}</Typography>
|
|
</Stack>
|
|
))}
|
|
</Stack>
|
|
<Button
|
|
fullWidth
|
|
variant={tier.highlight ? "contained" : "outlined"}
|
|
onClick={goToLogin}
|
|
sx={tier.highlight ? { background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 800 } : { fontWeight: 700 }}
|
|
>
|
|
{tier.cta}
|
|
</Button>
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
<Typography sx={{ textAlign: "center", color: "text.secondary", fontSize: 13, mt: 3 }}>
|
|
Prices indicative — assistive, never autonomous: you always review and send. No auto-apply spam.
|
|
</Typography>
|
|
</Container>
|
|
|
|
{/* CTA */}
|
|
<Container maxWidth="lg" sx={{ py: { xs: 7, md: 10 } }}>
|
|
<Box sx={{ borderRadius: 4, p: { xs: 4, md: 6 }, background: "linear-gradient(120deg,#0f172a,#1e293b)", color: "#fff", display: "flex", flexDirection: { xs: "column", md: "row" }, alignItems: { md: "center" }, justifyContent: "space-between", gap: 3 }}>
|
|
<Box>
|
|
<Typography sx={{ fontWeight: 800, fontSize: { xs: 24, md: 30 }, mb: 1 }}>Ready to organize your search?</Typography>
|
|
<Typography sx={{ color: "#94a3b8", fontSize: 17 }}>Sign in to start tracking applications, tailoring CVs, and following up with intent.</Typography>
|
|
</Box>
|
|
<Button size="large" variant="contained" onClick={goToLogin} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 800, px: 4, py: 1.25, whiteSpace: "nowrap" }}>
|
|
Sign in →
|
|
</Button>
|
|
</Box>
|
|
</Container>
|
|
|
|
{/* Footer */}
|
|
<Box sx={{ borderTop: "1px solid", borderColor: "divider", py: 4 }}>
|
|
<Container maxWidth="lg">
|
|
<Stack direction={{ xs: "column", sm: "row" }} justifyContent="space-between" alignItems="center" spacing={1}>
|
|
<Typography sx={{ color: "text.secondary", fontSize: 14 }}>© {new Date().getFullYear()} JobTrack — a focused workspace for the modern job search.</Typography>
|
|
<Button variant="text" onClick={goToLogin} sx={{ fontWeight: 700 }}>Sign in</Button>
|
|
</Stack>
|
|
</Container>
|
|
</Box>
|
|
</Box>
|
|
);
|
|
}
|