feat(plans): publish honest Free and Pro
This commit is contained in:
@@ -53,6 +53,36 @@ test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => window.localStorage.setItem("uiLanguage", "en"));
|
||||
});
|
||||
|
||||
test("public plans stay honest, responsive and keyboard accessible", async ({ page }) => {
|
||||
for (const scheme of ["light", "dark"] as const) {
|
||||
await page.addInitScript((value) => window.localStorage.setItem("jobtracker.themeMode", value), scheme);
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("heading", { name: "Two clear plans" })).toBeVisible();
|
||||
await expect(page.locator('section[aria-labelledby^="plan-"]')).toHaveCount(2);
|
||||
await expect(page.getByRole("heading", { name: "Free", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Pro", exact: true })).toBeVisible();
|
||||
await expect(page.getByText(/Bring your own key|Unlimited AI|Billed monthly|Most popular/i)).toHaveCount(0);
|
||||
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", scheme);
|
||||
|
||||
for (const width of [375, 768, 1440]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
|
||||
expect(overflow).toBeLessThanOrEqual(1);
|
||||
}
|
||||
}
|
||||
|
||||
const freeAction = page.getByRole("button", { name: "Create a Free account" });
|
||||
await freeAction.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page).toHaveURL(/\/register$/);
|
||||
|
||||
await page.goBack();
|
||||
const proAction = page.getByRole("button", { name: "Sign in to view Pro" });
|
||||
await proAction.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page).toHaveURL(/\/login$/);
|
||||
});
|
||||
|
||||
test("login establishes an authenticated session", async ({ page }) => {
|
||||
await login(page);
|
||||
await expect(page.getByText("e2e@example.test").first()).toBeVisible();
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { createContext, useContext } from "react";
|
||||
import type { PublicPlanId } from "./planCatalog";
|
||||
|
||||
export type AccountPlan = {
|
||||
plan: "free" | "pro";
|
||||
plan: PublicPlanId;
|
||||
canUseAi: boolean;
|
||||
canUseProThemes: boolean;
|
||||
};
|
||||
|
||||
@@ -16,10 +16,13 @@ jest.mock("./i18n/I18nProvider", () => ({ useI18n: () => ({ t: (key: string, par
|
||||
settingsUsageReset: "AI limits reset at the start of each calendar month.",
|
||||
settingsUsageNoAi: "The Free plan includes core job tracking without AI. Upgrade to Pro to use AI features.",
|
||||
settingsBillingUpgrade: "Upgrade to Pro",
|
||||
settingsBillingNotConfigured: "Pro upgrades are not available in this deployment yet.",
|
||||
};
|
||||
return Object.entries(params ?? {}).reduce((text, [name, value]) => text.replace(`{${name}}`, String(value)), messages[key] ?? key);
|
||||
} }) }));
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
test("shows monthly AI call and token limits", async () => {
|
||||
(api.get as jest.Mock).mockImplementation((url: string) => Promise.resolve({
|
||||
data: url === "/billing/status" ? { enabled: true, canCheckout: true, canManage: false } : {
|
||||
@@ -39,3 +42,21 @@ test("shows monthly AI call and token limits", async () => {
|
||||
expect(screen.queryByLabelText("Monthly AI generations used")).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Upgrade to Pro" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("does not offer a false upgrade action when billing is not configured", async () => {
|
||||
(api.get as jest.Mock).mockImplementation((url: string) => Promise.resolve({
|
||||
data: url === "/billing/status" ? { enabled: false, canCheckout: false, canManage: false } : {
|
||||
currentMonth: { calls: 0, estimatedTokens: 0 },
|
||||
plan: "free",
|
||||
monthlyCallLimit: 0,
|
||||
monthlyTokenLimit: 0,
|
||||
storageUsedBytes: 0,
|
||||
storageLimitBytes: 250000000,
|
||||
},
|
||||
}));
|
||||
|
||||
render(<AiUsageCard />);
|
||||
|
||||
expect(await screen.findByText(/not available in this deployment yet/i)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Upgrade to Pro" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -77,7 +77,8 @@ test("free users see a locked state and cannot start generation", async () => {
|
||||
</ToastProvider>,
|
||||
);
|
||||
|
||||
expect(await screen.findByText(/AI generation is a Pro feature/)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/Build application drafts with Pro/)).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "View Pro" })).toHaveAttribute("href", "/settings");
|
||||
expect(screen.getByRole("button", { name: "Pro required" })).toBeDisabled();
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -80,6 +80,11 @@ export default function AiUsageCard() {
|
||||
{billing?.canCheckout ? <Button sx={{ mt: 2 }} variant="contained" disabled={billingBusy} onClick={() => void openBilling("checkout")}>{t("settingsBillingUpgrade")}</Button> : null}
|
||||
{billing?.canManage ? <Button sx={{ mt: 2 }} variant="outlined" disabled={billingBusy} onClick={() => void openBilling("portal")}>{t("settingsBillingManage")}</Button> : null}
|
||||
{billingFailed ? <Alert severity="error" sx={{ mt: 2 }}>{t("settingsBillingUnavailable")}</Alert> : null}
|
||||
{usage.plan === "free" && billing?.enabled === false ? (
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 1.5 }}>
|
||||
{t("settingsBillingNotConfigured")}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { useToast } from "../toast";
|
||||
import Markdown from "./Markdown";
|
||||
import { AI_MODULES, AiInteraction, AiUsage, COVER_LETTER_MODES, aiWorkspaceApi } from "../aiWorkspace";
|
||||
import { useAccountPlan } from "../accountPlan";
|
||||
import ProFeatureNotice from "./ProFeatureNotice";
|
||||
|
||||
// Phase 5 — the central AI Workspace for one job application. Every result is a suggestion the user
|
||||
// reviews and copies; nothing is applied automatically.
|
||||
@@ -89,7 +90,9 @@ export default function AiWorkspacePanel({ jobId }: { jobId: number }) {
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 300px" }, gap: 2 }}>
|
||||
<Stack spacing={2}>
|
||||
{!canUseAi && (
|
||||
<Alert severity="info" action={<Button href="/settings" size="small">View Pro</Button>}>AI generation is a Pro feature. Your existing AI history remains available.</Alert>
|
||||
<ProFeatureNotice featureKey="application-ai" title="Build application drafts with Pro.">
|
||||
Generate job analysis, cover letters and strategy suggestions while keeping your existing AI history available.
|
||||
</ProFeatureNotice>
|
||||
)}
|
||||
<Alert severity="info" sx={{ py: 0.5 }}>
|
||||
AI suggestions never change your profile, CVs, or this application. Review, then copy what you want to keep.
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import React, { useState } from "react";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
import { Alert, Button, IconButton, Stack, Typography } from "@mui/material";
|
||||
|
||||
type Props = {
|
||||
featureKey: string;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
function storageKey(featureKey: string) {
|
||||
return `jobtracker.proNoticeDismissed.${featureKey}`;
|
||||
}
|
||||
|
||||
export default function ProFeatureNotice({ featureKey, title, children }: Props) {
|
||||
const [dismissed, setDismissed] = useState(() => {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
return window.sessionStorage.getItem(storageKey(featureKey)) === "true";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (dismissed) return null;
|
||||
|
||||
const dismiss = () => {
|
||||
try {
|
||||
window.sessionStorage.setItem(storageKey(featureKey), "true");
|
||||
} catch {
|
||||
// The notice can still be dismissed for this render when storage is unavailable.
|
||||
}
|
||||
setDismissed(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Alert
|
||||
severity="info"
|
||||
action={(
|
||||
<Stack direction="row" spacing={0.5} alignItems="center">
|
||||
<Button href="/settings" size="small">View Pro</Button>
|
||||
<IconButton size="small" aria-label={`Dismiss ${title}`} onClick={dismiss}>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
)}
|
||||
>
|
||||
<Typography component="span" sx={{ fontWeight: 800 }}>{title}</Typography>{" "}
|
||||
{children}
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -168,6 +168,7 @@ export const translations = {
|
||||
settingsBillingUpgrade: "Upgrade to Pro",
|
||||
settingsBillingManage: "Manage billing",
|
||||
settingsBillingUnavailable: "Billing is temporarily unavailable.",
|
||||
settingsBillingNotConfigured: "Pro upgrades are not available in this deployment yet.",
|
||||
settingsSubtitle: "Preferences and admin tools.",
|
||||
settingsTabGeneral: "General",
|
||||
settingsTabFollowUps: "Follow-ups",
|
||||
@@ -1320,6 +1321,7 @@ export const translations = {
|
||||
settingsBillingUpgrade: "Oppgrader til Pro",
|
||||
settingsBillingManage: "Administrer betaling",
|
||||
settingsBillingUnavailable: "Betaling er midlertidig utilgjengelig.",
|
||||
settingsBillingNotConfigured: "Pro-oppgraderinger er ikke tilgjengelige i denne installasjonen ennå.",
|
||||
settingsSubtitle: "Preferanser og adminverktøy.",
|
||||
settingsTabGeneral: "Generelt",
|
||||
settingsTabFollowUps: "Oppfølging",
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
|
||||
import { api } from "./api";
|
||||
import LandingPage from "./views/LandingPage";
|
||||
|
||||
const mockNavigate = jest.fn();
|
||||
jest.mock("react-router-dom", () => ({
|
||||
...jest.requireActual("react-router-dom"),
|
||||
useNavigate: () => mockNavigate,
|
||||
}));
|
||||
|
||||
jest.mock("./api", () => ({ api: { get: jest.fn() } }));
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(api.get as jest.Mock).mockRejectedValue({ response: { status: 401 } });
|
||||
});
|
||||
|
||||
test("shows exactly Free and Pro without invented commercial terms", async () => {
|
||||
render(
|
||||
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
<LandingPage />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Two clear plans" })).toBeInTheDocument();
|
||||
expect(screen.getAllByRole("heading", { level: 3 }).map((heading) => heading.textContent)).toEqual(["Free", "Pro"]);
|
||||
expect(screen.queryByText(/bring your own key|unlimited AI|billed monthly|trial|£9|£3/i)).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/Commercial terms appear only in configured checkout/i)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create a Free account" }));
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/register");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Sign in to view Pro" }));
|
||||
expect(mockNavigate).toHaveBeenCalledWith("/login", { state: { from: "/settings" } });
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { PUBLIC_PLANS, publicPlan } from "./planCatalog";
|
||||
|
||||
test("publishes only capability-based Free and Pro plans", () => {
|
||||
expect(PUBLIC_PLANS.map((plan) => plan.id)).toEqual(["free", "pro"]);
|
||||
expect(publicPlan("free").summary).toMatch(/without AI generation/i);
|
||||
expect(publicPlan("pro").features).toContain("Everything in Free");
|
||||
|
||||
const publicCopy = JSON.stringify(PUBLIC_PLANS);
|
||||
expect(publicCopy).not.toMatch(/[£$€]|unlimited|trial|billed|per month|per year/i);
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
export type PublicPlanId = "free" | "pro";
|
||||
|
||||
export type PublicPlanDefinition = {
|
||||
id: PublicPlanId;
|
||||
name: string;
|
||||
summary: string;
|
||||
features: readonly string[];
|
||||
cta: string;
|
||||
};
|
||||
|
||||
// Public copy mirrors the server-owned AccountPlans policy. It describes capabilities only:
|
||||
// billing supplies commercial terms at checkout when that deployment is configured.
|
||||
export const PUBLIC_PLANS: readonly PublicPlanDefinition[] = [
|
||||
{
|
||||
id: "free",
|
||||
name: "Free",
|
||||
summary: "Core job-search organisation without AI generation.",
|
||||
features: [
|
||||
"Track applications in table and Kanban views",
|
||||
"Keep notes, correspondence and documents with each job",
|
||||
"Edit your Career Profile and CVs manually",
|
||||
"Use the deterministic CV-to-job match score",
|
||||
"Export and retain your own data",
|
||||
],
|
||||
cta: "Create a Free account",
|
||||
},
|
||||
{
|
||||
id: "pro",
|
||||
name: "Pro",
|
||||
summary: "AI assistance and Pro CV themes, with you in control.",
|
||||
features: [
|
||||
"Everything in Free",
|
||||
"AI-assisted CV and cover-letter drafting",
|
||||
"AI job analysis and application strategy",
|
||||
"AI interview and follow-up preparation",
|
||||
"AI-assisted Career Profile import and rewriting",
|
||||
"Pro CV themes",
|
||||
],
|
||||
cta: "Sign in to view Pro",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function publicPlan(id: PublicPlanId) {
|
||||
return PUBLIC_PLANS.find((plan) => plan.id === id)!;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
import ProFeatureNotice from "./components/ProFeatureNotice";
|
||||
|
||||
beforeEach(() => window.sessionStorage.clear());
|
||||
|
||||
test("shows a respectful upgrade action and remembers dismissal for the session", () => {
|
||||
const { unmount } = render(
|
||||
<ProFeatureNotice featureKey="test-feature" title="Draft faster with Pro.">
|
||||
Existing content stays editable.
|
||||
</ProFeatureNotice>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("link", { name: "View Pro" })).toHaveAttribute("href", "/settings");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Dismiss Draft faster with Pro." }));
|
||||
expect(screen.queryByText("Draft faster with Pro.")).not.toBeInTheDocument();
|
||||
|
||||
unmount();
|
||||
render(
|
||||
<ProFeatureNotice featureKey="test-feature" title="Draft faster with Pro.">
|
||||
Existing content stays editable.
|
||||
</ProFeatureNotice>,
|
||||
);
|
||||
expect(screen.queryByText("Draft faster with Pro.")).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import { useToast } from "../toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { useAccountPlan } from "../accountPlan";
|
||||
import ProFeatureNotice from "../components/ProFeatureNotice";
|
||||
import type { UserOperation } from "../types";
|
||||
import {
|
||||
emptyStructuredCv,
|
||||
@@ -411,7 +412,13 @@ export default function CareerProfilePage() {
|
||||
|
||||
|
||||
<Box id="career-cv-import" sx={{ gridColumn: "1 / -1", p: { xs: 1.5, sm: 2 }, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default", display: careerOnly ? "block" : "none", scrollMarginTop: 96 }}>
|
||||
{!canUseAi && <Alert severity="info" sx={{ mb: 2 }} action={<Button href="/settings" size="small">View Pro</Button>}>AI CV import, rebuilding, improvement, and reprocessing require Pro. Manual profile editing remains available.</Alert>}
|
||||
{!canUseAi && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<ProFeatureNotice featureKey="career-ai" title="Build your Career Profile faster with Pro.">
|
||||
Import, rebuild and improve CV content with AI. Manual profile editing remains available on Free.
|
||||
</ProFeatureNotice>
|
||||
</Box>
|
||||
)}
|
||||
{profileDirty ? <Alert severity="warning" sx={{ mb: 2 }}>You have unsaved career edits. Save them before running actions that use the stored profile.</Alert> : null}
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.5 }}>
|
||||
<Box>
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
cvBuilderApi, getCvPageCount, getCvPageMetrics, moveItem,
|
||||
} from "../cvBuilder";
|
||||
import { useAccountPlan } from "../accountPlan";
|
||||
import ProFeatureNotice from "../components/ProFeatureNotice";
|
||||
import { useDialogActions } from "../dialogs";
|
||||
|
||||
const FONTS = [
|
||||
@@ -865,7 +866,13 @@ function AiToolsTab() {
|
||||
|
||||
return (
|
||||
<Stack spacing={1.5}>
|
||||
<Alert severity="info" sx={{ py: 0.5 }} action={!canUseAi ? <Button href="/settings" size="small">View Pro</Button> : undefined}>{canUseAi ? "AI suggestions never change your profile automatically. Copy what you like back into your CV." : "AI writing assistance requires Pro. Your CV content remains editable."}</Alert>
|
||||
{canUseAi ? (
|
||||
<Alert severity="info" sx={{ py: 0.5 }}>AI suggestions never change your profile automatically. Copy what you like back into your CV.</Alert>
|
||||
) : (
|
||||
<ProFeatureNotice featureKey="cv-writing-ai" title="Refine CV wording with Pro.">
|
||||
Generate optional writing suggestions while keeping all CV content editable on Free.
|
||||
</ProFeatureNotice>
|
||||
)}
|
||||
<TextField label="Text to improve" multiline minRows={4} fullWidth size="small" value={text} onChange={(e) => setText(e.target.value)}
|
||||
placeholder="Paste a summary, a bullet, or a whole section…" />
|
||||
<TextField label="Target role (optional)" size="small" fullWidth value={role} onChange={(e) => setRole(e.target.value)} />
|
||||
|
||||
@@ -13,15 +13,16 @@ import InsightsIcon from "@mui/icons-material/InsightsOutlined";
|
||||
|
||||
import { api } from "../api";
|
||||
import GradientButton from "../components/GradientButton";
|
||||
import { PUBLIC_PLANS, PublicPlanId } from "../planCatalog";
|
||||
|
||||
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: <AlarmIcon />, title: "Smart follow-ups", body: "Reminders surface what needs attention next; Pro can help draft a response for you to review." },
|
||||
{ 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: <MailIcon />, title: "Email correspondence", body: "Keep linked Gmail and Microsoft messages in the context of the right application." },
|
||||
{ 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." },
|
||||
];
|
||||
@@ -29,18 +30,12 @@ const FEATURES: { icon: React.ReactNode; title: string; body: string }[] = [
|
||||
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: 3, title: "Tailor", body: "With Pro, AI can draft 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 } };
|
||||
@@ -60,6 +55,13 @@ export default function LandingPage() {
|
||||
// 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 });
|
||||
const goToPlan = (plan: PublicPlanId) => {
|
||||
if (plan === "free") {
|
||||
navigate("/register");
|
||||
return;
|
||||
}
|
||||
navigate("/login", { state: { from: "/settings" } });
|
||||
};
|
||||
|
||||
if (checking) {
|
||||
return (
|
||||
@@ -83,7 +85,7 @@ export default function LandingPage() {
|
||||
<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>
|
||||
<Typography sx={{ color: "#fff", fontWeight: 800, fontSize: 20 }}>Jobbjakt</Typography>
|
||||
</Stack>
|
||||
<GradientButton onClick={goToLogin} sx={{ color: "#0b1020", fontWeight: 700 }}>
|
||||
Sign in
|
||||
@@ -128,7 +130,7 @@ export default function LandingPage() {
|
||||
<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 component="img" src="/mockups/dashboard.svg" alt="Jobbjakt 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" }}>
|
||||
@@ -180,61 +182,62 @@ export default function LandingPage() {
|
||||
</Container>
|
||||
</Box>
|
||||
|
||||
{/* Pricing */}
|
||||
<Container id="pricing" maxWidth="lg" sx={{ py: { xs: 7, md: 10 } }}>
|
||||
{/* Plans */}
|
||||
<Container id="plans" 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: "primary.main", fontWeight: 700, letterSpacing: 1, fontSize: 13 }}>PLANS</Typography>
|
||||
<Typography component="h2" sx={{ fontWeight: 800, fontSize: { xs: 28, md: 38 }, mt: 1 }}>Two clear plans</Typography>
|
||||
<Typography sx={{ color: "text.secondary", fontSize: 18, mt: 1.5 }}>
|
||||
Billed monthly or yearly — never by the week. Cancel anytime.
|
||||
Free covers the core job search. Pro adds AI assistance and Pro CV themes.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(3, 1fr)" }, gap: 3, alignItems: "start" }}>
|
||||
{PRICING.map((tier) => (
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(2, minmax(0, 1fr))" }, gap: 3, alignItems: "stretch", maxWidth: 900, mx: "auto" }}>
|
||||
{PUBLIC_PLANS.map((plan) => (
|
||||
<Box
|
||||
key={tier.name}
|
||||
component="section"
|
||||
aria-labelledby={`plan-${plan.id}`}
|
||||
key={plan.id}
|
||||
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,
|
||||
border: "1px solid",
|
||||
borderColor: plan.id === "pro" ? "primary.main" : "divider",
|
||||
boxShadow: plan.id === "pro" ? 5 : 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
{tier.highlight && (
|
||||
{plan.id === "pro" && (
|
||||
<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
|
||||
AI ASSISTANCE
|
||||
</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>
|
||||
<Typography id={`plan-${plan.id}`} component="h3" sx={{ fontWeight: 800, fontSize: 24 }}>{plan.name}</Typography>
|
||||
<Typography sx={{ color: "text.secondary", mt: 1 }}>{plan.summary}</Typography>
|
||||
<Stack spacing={1.25} sx={{ my: 2.5, flex: 1 }}>
|
||||
{plan.features.map((feature) => (
|
||||
<Stack key={feature} direction="row" spacing={1.25} alignItems="flex-start">
|
||||
<Box aria-hidden="true" sx={{ color: "success.main", fontWeight: 900, lineHeight: 1.4 }}>✓</Box>
|
||||
<Typography sx={{ fontSize: 15, color: "text.secondary" }}>{feature}</Typography>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
{tier.highlight ? (
|
||||
<GradientButton fullWidth onClick={goToLogin} sx={{ color: "#0b1020", fontWeight: 800 }}>
|
||||
{tier.cta}
|
||||
{plan.id === "pro" ? (
|
||||
<GradientButton fullWidth onClick={() => goToPlan(plan.id)} sx={{ color: "#0b1020", fontWeight: 800 }}>
|
||||
{plan.cta}
|
||||
</GradientButton>
|
||||
) : (
|
||||
<Button fullWidth variant="outlined" onClick={goToLogin} sx={{ fontWeight: 700 }}>
|
||||
{tier.cta}
|
||||
<Button fullWidth variant="outlined" onClick={() => goToPlan(plan.id)} sx={{ fontWeight: 700 }}>
|
||||
{plan.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.
|
||||
Commercial terms appear only in configured checkout. Your applications, documents and exports remain accessible if your plan changes.
|
||||
</Typography>
|
||||
</Container>
|
||||
|
||||
@@ -255,7 +258,7 @@ export default function LandingPage() {
|
||||
<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>
|
||||
<Typography sx={{ color: "text.secondary", fontSize: 14 }}>© {new Date().getFullYear()} Jobbjakt — a focused workspace for the modern job search.</Typography>
|
||||
<Button variant="text" onClick={goToLogin} sx={{ fontWeight: 700 }}>Sign in</Button>
|
||||
</Stack>
|
||||
</Container>
|
||||
|
||||
Reference in New Issue
Block a user