feat(plans): publish honest Free and Pro
CI and Deploy / test (pull_request) Successful in 5m6s
CI and Deploy / deploy (pull_request) Has been skipped

This commit is contained in:
cesnimda
2026-08-15 18:04:18 +02:00
parent a7c25499bb
commit a25c31b93a
26 changed files with 403 additions and 93 deletions
+30
View File
@@ -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();
+2 -1
View File
@@ -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;
};
+21
View File
@@ -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>
);
}
+2
View File
@@ -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",
+39
View File
@@ -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" } });
});
+10
View File
@@ -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);
});
+45
View File
@@ -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>
+8 -1
View File
@@ -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)} />
+44 -41
View File
@@ -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>