Compare commits

...

6 Commits

Author SHA1 Message Date
cesnimda aa19edbc49 feat(ui): add product preview (mockups) to landing page
CI and Deploy / test (pull_request) Successful in 2m11s
CI and Deploy / deploy (pull_request) Has been skipped
"See it in action" section showing the dashboard, pipeline board and per-job
workspace. Uses the design-mockup SVGs (small + crisp, ~27KB total) served from
public/mockups/, honestly captioned "Interface preview". Verified live: all three
render at "/".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 21:23:51 +02:00
cesnimda 96b9489d49 perf(gmail): batch the duplicate-message check in CreateSuggestedJob
CreateSuggestedJob ran one AnyAsync per message in the thread to decide
imported-vs-skip — an N+1 that scales with thread length. Replace it with a
single query that loads the already-imported ExternalMessageIds for the job,
then check in memory (identical skip/import behaviour), mirroring the batched
pattern RelinkThread already uses.

Build clean; backend suite 135/135 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 21:21:36 +02:00
cesnimda af420a7ad1 Merge pull request 'perf(analytics): project minimal columns in GetStats/GetAnalyticsOverview' (#3) from perf/wave1-perf into main
CI and Deploy / test (push) Successful in 2m14s
CI and Deploy / deploy (push) Failing after 1m0s
2026-07-05 21:18:55 +02:00
cesnimda 3d5ab8f32c feat(ui): add pricing section to landing page
CI and Deploy / test (pull_request) Successful in 2m8s
CI and Deploy / deploy (pull_request) Has been skipped
Three honest tiers (Free / Pro £9-mo / Bring-your-own-key £3-mo) billed monthly
or yearly — never by the week (the anti-Teal positioning from
docs/remaster/RESEARCH_COMPETITORS.md), with a "Most popular" highlight and the
assistive-not-autonomous trust note. Prices are indicative placeholders for the
SaaS direction.

Verified live: pricing section + all three tiers render at "/".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 20:45:20 +02:00
cesnimda c53d7978bb feat(email): introduce IEmailProvider seam + GmailProvider adapter
First slice toward multi-provider email (Gmail + Microsoft Graph + IMAP +
manual/free-text, per docs/remaster/PRODUCT_DIRECTION.md). Adds a provider-
neutral contract (search / list-thread / get-message / get-connection) with
neutral DTOs, a registry to resolve providers by key, and a GmailProvider that
adapts the existing IGmailOAuthService to it.

No behaviour change: the seam is registered in DI but not yet consumed. Follow-up
slices migrate GmailController's read paths onto IEmailProvider (folding in the
N+1 fixes) and add MicrosoftGraphProvider / ImapProvider / a manual provider.

Build clean; backend suite 135/135 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 20:42:46 +02:00
cesnimda 919f61dde6 feat(ui): public marketing landing page at "/" for logged-out visitors
CI and Deploy / test (pull_request) Successful in 2m7s
CI and Deploy / deploy (pull_request) Has been skipped
Add a LandingPage (hero + features + how-it-works + CTAs) served at "/" so
visitors learn about the product before signing in — matching the JobTrack
mockups (indigo/cyan, dark hero + light sections). If the visitor already has a
session, LandingPage redirects into the app (/jobs); otherwise it shows the
marketing page with "Sign in" CTAs. The "/" route is public (outside the
auth-gated Shell), so logged-out users no longer bounce straight to /login.

Verified live: renders at "/" with headline, feature grid, how-it-works steps
and CTAs; no console errors; type-clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 20:25:43 +02:00
9 changed files with 641 additions and 2 deletions
+7 -2
View File
@@ -641,12 +641,17 @@ public sealed class GmailController : ControllerBase
var threadMessages = await _gmail.ListThreadMessagesAsync(ownerUserId, request.ThreadId.Trim(), cancellationToken);
var distinctMessageIds = threadMessages.Select(message => message.Id).Where(static id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.Ordinal).ToList();
// Batch the "already imported?" check with a single query instead of one
// AnyAsync per message (N+1), mirroring RelinkThread below.
var existingMessageIds = await _db.Correspondences
.Where(message => message.JobApplicationId == job.Id && message.ExternalMessageId != null && distinctMessageIds.Contains(message.ExternalMessageId))
.Select(message => message.ExternalMessageId!)
.ToListAsync(cancellationToken);
var imported = 0;
var skipped = 0;
foreach (var messageId in distinctMessageIds)
{
var existing = await _db.Correspondences.AnyAsync(message => message.JobApplicationId == job.Id && message.ExternalMessageId == messageId, cancellationToken);
if (existing)
if (existingMessageIds.Contains(messageId, StringComparer.Ordinal))
{
skipped++;
continue;
+4
View File
@@ -166,6 +166,10 @@ builder.Services.AddScoped<IGmailOAuthService, GmailOAuthService>();
builder.Services.AddSingleton<IGmailJobMatchingService, GmailJobMatchingService>();
builder.Services.AddSingleton<IGmailCorrespondenceEnrichmentService, NoOpGmailCorrespondenceEnrichmentService>();
// Provider-neutral email seam (multi-provider: Gmail today; Microsoft Graph / IMAP / manual next).
builder.Services.AddScoped<JobTrackerApi.Services.EmailProviders.IEmailProvider, JobTrackerApi.Services.EmailProviders.GmailProvider>();
builder.Services.AddScoped<JobTrackerApi.Services.EmailProviders.IEmailProviderRegistry, JobTrackerApi.Services.EmailProviders.EmailProviderRegistry>();
builder.Services.AddIdentityCore<ApplicationUser>(options =>
{
options.User.RequireUniqueEmail = true;
@@ -0,0 +1,63 @@
using JobTrackerApi.Services;
namespace JobTrackerApi.Services.EmailProviders
{
/// <summary>
/// Gmail implementation of <see cref="IEmailProvider"/>. Adapts the existing
/// <see cref="IGmailOAuthService"/> (Gmail REST client) to the provider-neutral contract,
/// mapping Gmail DTOs to the neutral shapes.
/// </summary>
public sealed class GmailProvider : IEmailProvider
{
private readonly IGmailOAuthService _gmail;
public GmailProvider(IGmailOAuthService gmail)
{
_gmail = gmail;
}
public string ProviderKey => "gmail";
public async Task<EmailConnectionInfo?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken)
{
var connection = await _gmail.GetConnectionAsync(ownerUserId, cancellationToken);
return connection is null ? null : new EmailConnectionInfo("gmail", connection.GmailAddress ?? "");
}
public async Task<IReadOnlyList<EmailMessageSummary>> SearchAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken)
{
var messages = await _gmail.ListMessagesAsync(ownerUserId, query, maxResults, cancellationToken);
return messages.Select(ToSummary).ToList();
}
public async Task<IReadOnlyList<EmailMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string threadId, CancellationToken cancellationToken)
{
var messages = await _gmail.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken);
return messages.Select(ToSummary).ToList();
}
public async Task<EmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken)
{
var detail = await _gmail.GetMessageAsync(ownerUserId, messageId, cancellationToken);
var attachments = detail.Attachments
.Select(a => new EmailAttachmentRef(a.FileName, a.MimeType, a.SizeBytes, a.GmailAttachmentId, a.Inline))
.ToList();
return new EmailMessageDetail(
detail.Id,
detail.ThreadId,
detail.Subject,
detail.From,
detail.To,
detail.Date,
detail.Snippet,
detail.BodyText,
detail.BodyHtml,
detail.Labels,
attachments);
}
private static EmailMessageSummary ToSummary(GmailMessageSummary m)
=> new(m.Id, m.ThreadId, m.Subject, m.From, m.To, m.Date, m.Snippet);
}
}
@@ -0,0 +1,69 @@
namespace JobTrackerApi.Services.EmailProviders
{
/// <summary>
/// Provider-neutral email operations so job correspondence can be sourced from Gmail,
/// Microsoft Graph, generic IMAP, or manual/free-text entry behind a single seam.
/// See docs/remaster/PRODUCT_DIRECTION.md (multi-provider email). Gmail is the first
/// implementation (<see cref="GmailProvider"/>); the controller migration and additional
/// providers land in follow-up slices.
/// </summary>
public interface IEmailProvider
{
/// <summary>Stable key: "gmail" | "microsoft" | "imap" | "manual".</summary>
string ProviderKey { get; }
/// <summary>The user's active connection for this provider, or null if not connected.</summary>
Task<EmailConnectionInfo?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken);
/// <summary>Search the user's mailbox. <paramref name="query"/> is provider-specific syntax.</summary>
Task<IReadOnlyList<EmailMessageSummary>> SearchAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken);
/// <summary>All messages in a thread/conversation.</summary>
Task<IReadOnlyList<EmailMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string threadId, CancellationToken cancellationToken);
/// <summary>Full message content (body + attachments metadata).</summary>
Task<EmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken);
}
public sealed record EmailConnectionInfo(string ProviderKey, string Address);
public sealed record EmailMessageSummary(string Id, string ThreadId, string Subject, string From, string To, DateTimeOffset? Date, string Snippet);
public sealed record EmailAttachmentRef(string? FileName, string? MimeType, long? SizeBytes, string? ExternalAttachmentId, bool Inline);
public sealed record EmailMessageDetail(
string Id,
string ThreadId,
string Subject,
string From,
string To,
DateTimeOffset? Date,
string Snippet,
string BodyText,
string? BodyHtml,
IReadOnlyList<string> Labels,
IReadOnlyList<EmailAttachmentRef> Attachments);
/// <summary>Resolves a registered <see cref="IEmailProvider"/> by its key.</summary>
public interface IEmailProviderRegistry
{
IReadOnlyList<IEmailProvider> All { get; }
IEmailProvider? Get(string? providerKey);
}
public sealed class EmailProviderRegistry : IEmailProviderRegistry
{
private readonly Dictionary<string, IEmailProvider> _byKey;
public EmailProviderRegistry(IEnumerable<IEmailProvider> providers)
{
All = providers.ToList();
_byKey = All.ToDictionary(p => p.ProviderKey, StringComparer.OrdinalIgnoreCase);
}
public IReadOnlyList<IEmailProvider> All { get; }
public IEmailProvider? Get(string? providerKey)
=> !string.IsNullOrWhiteSpace(providerKey) && _byKey.TryGetValue(providerKey, out var p) ? p : null;
}
}
@@ -0,0 +1,82 @@
<svg width="1440" height="900" viewBox="0 0 1440 900" xmlns="http://www.w3.org/2000/svg" font-family="Inter, 'Segoe UI', system-ui, sans-serif">
<defs>
<linearGradient id="side" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#0f172a"/><stop offset="1" stop-color="#111a33"/></linearGradient>
<linearGradient id="ac" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#6366f1"/><stop offset="1" stop-color="#22d3ee"/></linearGradient>
<filter id="sh" x="-20%" y="-20%" width="140%" height="140%"><feDropShadow dx="0" dy="8" stdDeviation="14" flood-color="#0f172a" flood-opacity="0.10"/></filter>
</defs>
<rect width="1440" height="900" fill="#f4f6fb"/>
<!-- sidebar -->
<rect width="248" height="900" fill="url(#side)"/>
<g transform="translate(28,40)">
<rect x="0" y="0" width="32" height="32" rx="8" fill="url(#ac)"/><path d="M8 16 l5 5 l10 -11" stroke="#0b1020" stroke-width="3.2" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
<text x="44" y="22" fill="#fff" font-size="20" font-weight="700">JobTrack</text>
</g>
<g transform="translate(20,120)" font-size="15">
<rect x="0" y="0" width="208" height="44" rx="10" fill="#6366f1" opacity="0.18"/><rect x="18" y="16" width="12" height="12" rx="3" fill="#a5b4fc"/><text x="42" y="28" fill="#c7d2fe" font-weight="600">Dashboard</text>
<text x="20" y="88" fill="#94a3b8">Applications</text>
<text x="20" y="140" fill="#94a3b8">Pipeline</text>
<text x="20" y="192" fill="#94a3b8">Reminders</text>
<text x="20" y="244" fill="#94a3b8">Correspondence</text>
<text x="20" y="296" fill="#94a3b8">Companies</text>
<text x="20" y="348" fill="#94a3b8">Profile &amp; CV</text>
</g>
<g transform="translate(20,820)"><rect width="208" height="52" rx="10" fill="#ffffff" opacity="0.06"/><circle cx="30" cy="26" r="15" fill="#6366f1"/><text x="30" y="31" fill="#fff" font-size="13" text-anchor="middle" font-weight="700">DC</text><text x="56" y="23" fill="#e2e8f0" font-size="13">dj@cesnimda.co.uk</text><text x="56" y="40" fill="#64748b" font-size="11">Personal workspace</text></g>
<!-- header -->
<g transform="translate(288,44)">
<text x="0" y="26" fill="#0f172a" font-size="28" font-weight="800">Dashboard</text>
<text x="0" y="52" fill="#64748b" font-size="15">Your job search at a glance — 34 active applications</text>
<rect x="740" y="6" width="180" height="42" rx="10" fill="#fff" filter="url(#sh)"/><text x="762" y="32" fill="#64748b" font-size="14">Last 30 days ▾</text>
<rect x="936" y="6" width="168" height="42" rx="10" fill="#0f172a"/><text x="1020" y="32" fill="#fff" font-size="14" text-anchor="middle" font-weight="600">+ Add job</text>
</g>
<!-- KPI row -->
<g transform="translate(288,120)">
<g filter="url(#sh)"><rect width="266" height="112" rx="14" fill="#fff"/><text x="22" y="36" fill="#64748b" font-size="14">Active applications</text><text x="22" y="80" fill="#0f172a" font-size="38" font-weight="800">34</text><text x="150" y="80" fill="#16a34a" font-size="14">▲ 8 this week</text></g>
<g transform="translate(282,0)" filter="url(#sh)"><rect width="266" height="112" rx="14" fill="#fff"/><text x="22" y="36" fill="#64748b" font-size="14">Response rate</text><text x="22" y="80" fill="#0f172a" font-size="38" font-weight="800">28%</text><text x="150" y="80" fill="#16a34a" font-size="14">▲ 4%</text></g>
<g transform="translate(564,0)" filter="url(#sh)"><rect width="266" height="112" rx="14" fill="#fff"/><text x="22" y="36" fill="#64748b" font-size="14">Interviews</text><text x="22" y="80" fill="#0f172a" font-size="38" font-weight="800">6</text><text x="150" y="80" fill="#64748b" font-size="14">2 upcoming</text></g>
<g transform="translate(846,0)" filter="url(#sh)"><rect width="270" height="112" rx="14" fill="#fff"/><text x="22" y="36" fill="#64748b" font-size="14">Needs follow-up</text><text x="22" y="80" fill="#dc2626" font-size="38" font-weight="800">3</text><text x="150" y="80" fill="#dc2626" font-size="14">overdue</text></g>
</g>
<!-- response trend chart -->
<g transform="translate(288,256)" filter="url(#sh)">
<rect width="700" height="300" rx="14" fill="#fff"/>
<text x="24" y="40" fill="#0f172a" font-size="17" font-weight="700">Applications &amp; responses</text>
<g stroke="#eef2f7" stroke-width="1">
<line x1="24" y1="90" x2="676" y2="90"/><line x1="24" y1="150" x2="676" y2="150"/><line x1="24" y1="210" x2="676" y2="210"/><line x1="24" y1="255" x2="676" y2="255"/>
</g>
<polyline points="40,240 140,200 240,210 340,150 440,160 540,110 640,120" fill="none" stroke="#6366f1" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="40,252 140,244 240,238 340,224 440,214 540,196 640,182" fill="none" stroke="#22c55e" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/>
<g fill="#6366f1"><circle cx="340" cy="150" r="4.5"/><circle cx="540" cy="110" r="4.5"/></g>
<g font-size="12" fill="#94a3b8"><text x="34" y="278">Wk1</text><text x="134" y="278">Wk2</text><text x="234" y="278">Wk3</text><text x="334" y="278">Wk4</text><text x="434" y="278">Wk5</text><text x="534" y="278">Wk6</text><text x="628" y="278">Wk7</text></g>
<g font-size="12"><rect x="500" y="16" width="12" height="12" rx="3" fill="#6366f1"/><text x="518" y="26" fill="#64748b">Applied</text><rect x="590" y="16" width="12" height="12" rx="3" fill="#22c55e"/><text x="608" y="26" fill="#64748b">Responses</text></g>
</g>
<!-- time in stage -->
<g transform="translate(1004,256)" filter="url(#sh)">
<rect width="404" height="300" rx="14" fill="#fff"/>
<text x="24" y="40" fill="#0f172a" font-size="17" font-weight="700">Median time in stage</text>
<g font-size="13" fill="#334155">
<text x="24" y="82">Applied → Waiting</text><rect x="24" y="92" width="356" height="10" rx="5" fill="#eef2f7"/><rect x="24" y="92" width="120" height="10" rx="5" fill="#6366f1"/><text x="352" y="86" fill="#64748b">2d</text>
<text x="24" y="132">Waiting → Interview</text><rect x="24" y="142" width="356" height="10" rx="5" fill="#eef2f7"/><rect x="24" y="142" width="300" height="10" rx="5" fill="#818cf8"/><text x="348" y="136" fill="#64748b">9d</text>
<text x="24" y="182">Interview → Offer</text><rect x="24" y="192" width="356" height="10" rx="5" fill="#eef2f7"/><rect x="24" y="192" width="200" height="10" rx="5" fill="#22d3ee"/><text x="348" y="186" fill="#64748b">6d</text>
</g>
<text x="24" y="240" fill="#0f172a" font-size="15" font-weight="700">Top skill demand</text>
<g font-size="12"><rect x="24" y="252" width="70" height="26" rx="13" fill="#eef2ff"/><text x="59" y="269" fill="#4338ca" text-anchor="middle">React ·18</text>
<rect x="102" y="252" width="70" height="26" rx="13" fill="#eef2ff"/><text x="137" y="269" fill="#4338ca" text-anchor="middle">Azure ·12</text>
<rect x="180" y="252" width="86" height="26" rx="13" fill="#eef2ff"/><text x="223" y="269" fill="#4338ca" text-anchor="middle">Docker ·10</text>
<rect x="274" y="252" width="64" height="26" rx="13" fill="#eef2ff"/><text x="306" y="269" fill="#4338ca" text-anchor="middle">SQL ·9</text></g>
</g>
<!-- reminders strip -->
<g transform="translate(288,580)" filter="url(#sh)">
<rect width="1120" height="278" rx="14" fill="#fff"/>
<text x="24" y="40" fill="#0f172a" font-size="17" font-weight="700">Needs your attention</text>
<g transform="translate(24,60)">
<g><rect width="1072" height="60" rx="10" fill="#fef2f2"/><circle cx="28" cy="30" r="6" fill="#dc2626"/><text x="52" y="27" fill="#0f172a" font-size="15" font-weight="600">Senior Frontend Engineer · Vercel</text><text x="52" y="46" fill="#64748b" font-size="13">No reply in 9 days — follow-up drafted from your last thread</text><rect x="900" y="16" width="150" height="30" rx="8" fill="#0f172a"/><text x="975" y="36" fill="#fff" font-size="13" text-anchor="middle">Review draft</text></g>
<g transform="translate(0,72)"><rect width="1072" height="60" rx="10" fill="#fffbeb"/><circle cx="28" cy="30" r="6" fill="#f59e0b"/><text x="52" y="27" fill="#0f172a" font-size="15" font-weight="600">Platform Engineer · Finn.no</text><text x="52" y="46" fill="#64748b" font-size="13">Interview tomorrow 14:00 — prep pack ready</text><rect x="900" y="16" width="150" height="30" rx="8" fill="#eef2ff"/><text x="975" y="36" fill="#4338ca" font-size="13" text-anchor="middle">Open workspace</text></g>
<g transform="translate(0,144)"><rect width="1072" height="60" rx="10" fill="#f0fdf4"/><circle cx="28" cy="30" r="6" fill="#22c55e"/><text x="52" y="27" fill="#0f172a" font-size="15" font-weight="600">Backend Developer · NAV</text><text x="52" y="46" fill="#64748b" font-size="13">Offer received — compare against saved package</text><rect x="900" y="16" width="150" height="30" rx="8" fill="#dcfce7"/><text x="975" y="36" fill="#166534" font-size="13" text-anchor="middle">View offer</text></g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.8 KiB

@@ -0,0 +1,55 @@
<svg width="1440" height="900" viewBox="0 0 1440 900" xmlns="http://www.w3.org/2000/svg" font-family="Inter, 'Segoe UI', system-ui, sans-serif">
<defs>
<linearGradient id="ac" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#6366f1"/><stop offset="1" stop-color="#22d3ee"/></linearGradient>
<filter id="c" x="-30%" y="-30%" width="160%" height="160%"><feDropShadow dx="0" dy="4" stdDeviation="8" flood-color="#1e293b" flood-opacity="0.10"/></filter>
</defs>
<rect width="1440" height="900" fill="#f4f6fb"/>
<!-- header -->
<g transform="translate(48,44)">
<rect x="0" y="0" width="30" height="30" rx="8" fill="url(#ac)"/><path d="M8 15 l4 4 l10 -10" stroke="#0b1020" stroke-width="3" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
<text x="40" y="22" fill="#0f172a" font-size="22" font-weight="800">Pipeline</text>
<text x="150" y="22" fill="#94a3b8" font-size="15">Drag cards to move a job between stages</text>
<rect x="1150" y="-4" width="192" height="40" rx="10" fill="#fff" filter="url(#c)"/><text x="1170" y="21" fill="#64748b" font-size="14">Search &amp; filter…</text>
</g>
<!-- columns -->
<g transform="translate(48,110)">
<!-- column template values -->
<!-- Applied -->
<g transform="translate(0,0)">
<rect width="256" height="740" rx="14" fill="#eef2f7"/>
<circle cx="24" cy="30" r="6" fill="#6366f1"/><text x="40" y="35" fill="#0f172a" font-size="15" font-weight="700">Applied</text><text x="220" y="35" fill="#94a3b8" font-size="14" text-anchor="end">12</text>
<g transform="translate(14,52)" filter="url(#c)"><rect width="228" height="112" rx="12" fill="#fff"/><rect width="4" height="112" rx="2" fill="#6366f1"/><text x="18" y="30" fill="#0f172a" font-size="15" font-weight="700">Frontend Engineer</text><text x="18" y="52" fill="#64748b" font-size="13">Vercel · Remote</text><rect x="18" y="66" width="66" height="22" rx="11" fill="#eef2ff"/><text x="51" y="81" fill="#4338ca" font-size="11" text-anchor="middle">React</text><rect x="90" y="66" width="60" height="22" rx="11" fill="#eef2ff"/><text x="120" y="81" fill="#4338ca" font-size="11" text-anchor="middle">TS</text><text x="18" y="104" fill="#94a3b8" font-size="12">Applied 3d ago</text></g>
<g transform="translate(14,176)" filter="url(#c)"><rect width="228" height="96" rx="12" fill="#fff"/><rect width="4" height="96" rx="2" fill="#6366f1"/><text x="18" y="30" fill="#0f172a" font-size="15" font-weight="700">Product Engineer</text><text x="18" y="52" fill="#64748b" font-size="13">Linear · Remote</text><text x="18" y="80" fill="#94a3b8" font-size="12">Applied 5d ago · CV 81%</text></g>
<g transform="translate(14,284)" filter="url(#c)"><rect width="228" height="96" rx="12" fill="#fff"/><rect width="4" height="96" rx="2" fill="#6366f1"/><text x="18" y="30" fill="#0f172a" font-size="15" font-weight="700">Fullstack Dev</text><text x="18" y="52" fill="#64748b" font-size="13">Cognite · Oslo</text><text x="18" y="80" fill="#94a3b8" font-size="12">Applied 1w ago</text></g>
</g>
<!-- Waiting -->
<g transform="translate(276,0)">
<rect width="256" height="740" rx="14" fill="#eef2f7"/>
<circle cx="24" cy="30" r="6" fill="#818cf8"/><text x="40" y="35" fill="#0f172a" font-size="15" font-weight="700">Waiting</text><text x="220" y="35" fill="#94a3b8" font-size="14" text-anchor="end">9</text>
<g transform="translate(14,52)" filter="url(#c)"><rect width="228" height="112" rx="12" fill="#fff"/><rect width="4" height="112" rx="2" fill="#818cf8"/><text x="18" y="30" fill="#0f172a" font-size="15" font-weight="700">Platform Engineer</text><text x="18" y="52" fill="#64748b" font-size="13">Finn.no · Oslo</text><rect x="18" y="66" width="150" height="22" rx="11" fill="#fef9c3"/><text x="24" y="81" fill="#854d0e" font-size="11">⏳ Reply due in 2d</text><text x="18" y="104" fill="#94a3b8" font-size="12">Emailed 5d ago</text></g>
<g transform="translate(14,176)" filter="url(#c)"><rect width="228" height="96" rx="12" fill="#fff"/><rect width="4" height="96" rx="2" fill="#f59e0b"/><text x="18" y="30" fill="#0f172a" font-size="15" font-weight="700">DevOps Engineer</text><text x="18" y="52" fill="#64748b" font-size="13">Kahoot! · Oslo</text><rect x="18" y="66" width="120" height="22" rx="11" fill="#fee2e2"/><text x="24" y="81" fill="#991b1b" font-size="11">⚠ Follow up now</text></g>
</g>
<!-- Interview -->
<g transform="translate(552,0)">
<rect width="256" height="740" rx="14" fill="#eef2f7"/>
<circle cx="24" cy="30" r="6" fill="#22d3ee"/><text x="40" y="35" fill="#0f172a" font-size="15" font-weight="700">Interview</text><text x="220" y="35" fill="#94a3b8" font-size="14" text-anchor="end">4</text>
<g transform="translate(14,52)" filter="url(#c)"><rect width="228" height="128" rx="12" fill="#fff"/><rect width="4" height="128" rx="2" fill="#22d3ee"/><text x="18" y="30" fill="#0f172a" font-size="15" font-weight="700">Sr. Backend Dev</text><text x="18" y="52" fill="#64748b" font-size="13">NAV · Oslo</text><rect x="18" y="66" width="180" height="22" rx="11" fill="#cffafe"/><text x="24" y="81" fill="#155e75" font-size="11">📅 Tomorrow 14:00</text><rect x="18" y="94" width="90" height="22" rx="11" fill="#f0fdf4"/><text x="24" y="109" fill="#166534" font-size="11">Prep ready</text></g>
<g transform="translate(14,192)" filter="url(#c)"><rect width="228" height="96" rx="12" fill="#fff"/><rect width="4" height="96" rx="2" fill="#22d3ee"/><text x="18" y="30" fill="#0f172a" font-size="15" font-weight="700">Cloud Engineer</text><text x="18" y="52" fill="#64748b" font-size="13">Visma · Bergen</text><text x="18" y="80" fill="#94a3b8" font-size="12">Round 2 scheduled</text></g>
</g>
<!-- Offer -->
<g transform="translate(828,0)">
<rect width="256" height="740" rx="14" fill="#eef2f7"/>
<circle cx="24" cy="30" r="6" fill="#22c55e"/><text x="40" y="35" fill="#0f172a" font-size="15" font-weight="700">Offer</text><text x="220" y="35" fill="#94a3b8" font-size="14" text-anchor="end">2</text>
<g transform="translate(14,52)" filter="url(#c)"><rect width="228" height="112" rx="12" fill="#fff"/><rect width="4" height="112" rx="2" fill="#22c55e"/><text x="18" y="30" fill="#0f172a" font-size="15" font-weight="700">Backend Developer</text><text x="18" y="52" fill="#64748b" font-size="13">NAV · Oslo</text><rect x="18" y="66" width="150" height="22" rx="11" fill="#dcfce7"/><text x="24" y="81" fill="#166534" font-size="11">🎉 720k NOK / yr</text><text x="18" y="104" fill="#94a3b8" font-size="12">Respond by Fri</text></g>
</g>
<!-- Closed -->
<g transform="translate(1104,0)">
<rect width="240" height="740" rx="14" fill="#eef2f7"/>
<circle cx="24" cy="30" r="6" fill="#94a3b8"/><text x="40" y="35" fill="#0f172a" font-size="15" font-weight="700">Rejected / Ghosted</text><text x="216" y="35" fill="#94a3b8" font-size="14" text-anchor="end">7</text>
<g transform="translate(14,52)" filter="url(#c)"><rect width="212" height="88" rx="12" fill="#fff" opacity="0.75"/><rect width="4" height="88" rx="2" fill="#94a3b8"/><text x="18" y="30" fill="#475569" font-size="15" font-weight="700">Data Engineer</text><text x="18" y="52" fill="#94a3b8" font-size="13">Spotify · Remote</text><text x="18" y="74" fill="#cbd5e1" font-size="12">Rejected · logged</text></g>
<g transform="translate(14,152)" filter="url(#c)"><rect width="212" height="88" rx="12" fill="#fff" opacity="0.75"/><rect width="4" height="88" rx="2" fill="#cbd5e1"/><text x="18" y="30" fill="#475569" font-size="15" font-weight="700">iOS Engineer</text><text x="18" y="52" fill="#94a3b8" font-size="13">Tise · Oslo</text><text x="18" y="74" fill="#cbd5e1" font-size="12">Ghosted · 30d</text></g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.7 KiB

+102
View File
@@ -0,0 +1,102 @@
<svg width="1440" height="900" viewBox="0 0 1440 900" xmlns="http://www.w3.org/2000/svg" font-family="Inter, 'Segoe UI', system-ui, sans-serif">
<defs>
<linearGradient id="ac" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#6366f1"/><stop offset="1" stop-color="#22d3ee"/></linearGradient>
<filter id="c" x="-30%" y="-30%" width="160%" height="160%"><feDropShadow dx="0" dy="6" stdDeviation="12" flood-color="#1e293b" flood-opacity="0.10"/></filter>
</defs>
<rect width="1440" height="900" fill="#f4f6fb"/>
<!-- top bar -->
<g transform="translate(48,40)">
<text x="0" y="16" fill="#94a3b8" font-size="14">Pipeline / Waiting /</text>
<text x="0" y="52" fill="#0f172a" font-size="30" font-weight="800">Platform Engineer</text>
<text x="330" y="52" fill="#64748b" font-size="18">· Finn.no</text>
<rect x="0" y="70" width="112" height="30" rx="15" fill="#fef9c3"/><text x="56" y="90" fill="#854d0e" font-size="13" text-anchor="middle">● Waiting</text>
<text x="128" y="90" fill="#94a3b8" font-size="14">Oslo · 780920k NOK · Applied 5 days ago</text>
</g>
<!-- tabs -->
<g transform="translate(48,148)" font-size="14">
<text x="0" y="0" fill="#4338ca" font-weight="700">Overview</text><rect x="-4" y="10" width="66" height="3" rx="2" fill="#6366f1"/>
<text x="90" y="0" fill="#64748b">Correspondence</text>
<text x="230" y="0" fill="#64748b">Attachments</text>
<text x="352" y="0" fill="#64748b">Candidate Fit</text>
<text x="470" y="0" fill="#64748b">Timeline</text>
</g>
<!-- left: summary + description -->
<g transform="translate(48,180)" filter="url(#c)">
<rect width="600" height="300" rx="14" fill="#fff"/>
<text x="24" y="40" fill="#0f172a" font-size="17" font-weight="700">AI summary</text>
<rect x="480" y="24" width="96" height="26" rx="13" fill="#eef2ff"/><text x="528" y="41" fill="#4338ca" font-size="12" text-anchor="middle">↻ regenerate</text>
<text x="24" y="72" fill="#475569" font-size="14">Platform team building internal developer tooling on Kubernetes.</text>
<text x="24" y="96" fill="#475569" font-size="14">Owns CI/CD, observability, and cloud cost. Strong Go + Terraform</text>
<text x="24" y="120" fill="#475569" font-size="14">focus; hybrid, 2 days in Oslo office.</text>
<line x1="24" y1="146" x2="576" y2="146" stroke="#eef2f7"/>
<text x="24" y="178" fill="#0f172a" font-size="15" font-weight="700">Next action</text>
<rect x="24" y="192" width="552" height="52" rx="10" fill="#fff7ed"/><circle cx="46" cy="218" r="6" fill="#f59e0b"/>
<text x="66" y="214" fill="#0f172a" font-size="14" font-weight="600">Follow up on application status</text>
<text x="66" y="234" fill="#94a3b8" font-size="12">Due in 2 days · draft prepared from your last email</text>
<rect x="452" y="202" width="112" height="32" rx="8" fill="#0f172a"/><text x="508" y="223" fill="#fff" font-size="13" text-anchor="middle">Review draft</text>
<text x="24" y="278" fill="#64748b" font-size="13">Skills detected: Go · Kubernetes · Terraform · AWS · CI/CD</text>
</g>
<!-- left lower: correspondence -->
<g transform="translate(48,500)" filter="url(#c)">
<rect width="600" height="358" rx="14" fill="#fff"/>
<text x="24" y="40" fill="#0f172a" font-size="17" font-weight="700">Correspondence</text>
<rect x="410" y="22" width="166" height="28" rx="8" fill="#eef2ff"/><text x="493" y="41" fill="#4338ca" font-size="12" text-anchor="middle">Linked Gmail thread ✓</text>
<g transform="translate(24,58)">
<rect width="552" height="82" rx="10" fill="#f8fafc"/><circle cx="26" cy="26" r="14" fill="#6366f1"/><text x="26" y="31" fill="#fff" font-size="12" text-anchor="middle" font-weight="700">R</text>
<text x="52" y="24" fill="#0f172a" font-size="14" font-weight="600">Recruiter · Finn.no</text><text x="530" y="24" fill="#94a3b8" font-size="12" text-anchor="end">5d ago</text>
<text x="52" y="46" fill="#64748b" font-size="13">Thanks for applying! We're reviewing and will be in touch</text><text x="52" y="64" fill="#64748b" font-size="13">within two weeks.</text>
</g>
<g transform="translate(24,150)">
<rect width="552" height="70" rx="10" fill="#eef2ff"/><circle cx="26" cy="26" r="14" fill="#0f172a"/><text x="26" y="31" fill="#fff" font-size="12" text-anchor="middle" font-weight="700">You</text>
<text x="52" y="24" fill="#0f172a" font-size="14" font-weight="600">You · sent reply</text><text x="530" y="24" fill="#94a3b8" font-size="12" text-anchor="end">5d ago</text>
<text x="52" y="46" fill="#64748b" font-size="13">Thank you — looking forward to hearing about next steps.</text>
</g>
<rect x="24" y="234" width="552" height="46" rx="10" fill="#f0fdf4"/><text x="40" y="262" fill="#166534" font-size="13">↻ Thread auto-refreshes — new replies appear here without re-importing</text>
<line x1="24" y1="298" x2="576" y2="298" stroke="#eef2f7"/>
<rect x="24" y="312" width="440" height="34" rx="8" fill="#f1f5f9"/><text x="40" y="334" fill="#94a3b8" font-size="13">Draft a grounded follow-up…</text>
<rect x="476" y="312" width="100" height="34" rx="8" fill="url(#ac)"/><text x="526" y="334" fill="#0b1020" font-size="13" text-anchor="middle" font-weight="700">AI draft</text>
</g>
<!-- right: match + attachments + tailor -->
<g transform="translate(672,180)" filter="url(#c)">
<rect width="720" height="300" rx="14" fill="#fff"/>
<text x="28" y="40" fill="#0f172a" font-size="17" font-weight="700">Candidate fit — keyword coverage</text>
<circle cx="110" cy="150" r="64" fill="none" stroke="#e2e8f0" stroke-width="16"/>
<circle cx="110" cy="150" r="64" fill="none" stroke="#22c55e" stroke-width="16" stroke-linecap="round" stroke-dasharray="309 402" transform="rotate(-90 110 150)"/>
<text x="110" y="146" fill="#0f172a" font-size="34" font-weight="800" text-anchor="middle">77%</text>
<text x="110" y="172" fill="#64748b" font-size="12" text-anchor="middle">coverage</text>
<text x="110" y="238" fill="#94a3b8" font-size="12" text-anchor="middle">deterministic · explainable</text>
<g transform="translate(230,72)" font-size="13">
<text x="0" y="0" fill="#166534" font-weight="700">Matched</text>
<g><rect x="0" y="12" width="80" height="26" rx="13" fill="#dcfce7"/><text x="40" y="29" fill="#166534" text-anchor="middle">AWS</text></g>
<g><rect x="88" y="12" width="84" height="26" rx="13" fill="#dcfce7"/><text x="130" y="29" fill="#166534" text-anchor="middle">CI/CD</text></g>
<g><rect x="180" y="12" width="110" height="26" rx="13" fill="#dcfce7"/><text x="235" y="29" fill="#166534" text-anchor="middle">Terraform</text></g>
<g><rect x="300" y="12" width="70" height="26" rx="13" fill="#dcfce7"/><text x="335" y="29" fill="#166534" text-anchor="middle">Go</text></g>
<text x="0" y="78" fill="#991b1b" font-weight="700">Missing — add if you have it</text>
<g><rect x="0" y="90" width="118" height="26" rx="13" fill="#fee2e2"/><text x="59" y="107" fill="#991b1b" text-anchor="middle">Kubernetes ✕</text></g>
<g><rect x="126" y="90" width="96" height="26" rx="13" fill="#fee2e2"/><text x="174" y="107" fill="#991b1b" text-anchor="middle">Grafana ✕</text></g>
<rect x="0" y="132" width="220" height="42" rx="10" fill="url(#ac)"/><text x="110" y="159" fill="#0b1020" font-size="14" font-weight="700" text-anchor="middle">✨ Tailor my CV for this role</text>
<rect x="234" y="132" width="150" height="42" rx="10" fill="none" stroke="#cbd5e1"/><text x="309" y="159" fill="#475569" font-size="14" text-anchor="middle">Keep original</text>
</g>
</g>
<!-- attachments -->
<g transform="translate(672,500)" filter="url(#c)">
<rect width="720" height="358" rx="14" fill="#fff"/>
<text x="28" y="40" fill="#0f172a" font-size="17" font-weight="700">Attachments</text>
<rect x="560" y="22" width="132" height="30" rx="8" fill="#eef2ff"/><text x="626" y="42" fill="#4338ca" font-size="12" text-anchor="middle">⤒ Drop files</text>
<g transform="translate(28,60)">
<g><rect width="330" height="88" rx="12" fill="#f8fafc" stroke="#e2e8f0"/><rect x="16" y="20" width="40" height="48" rx="6" fill="#6366f1"/><text x="36" y="49" fill="#fff" font-size="11" text-anchor="middle">CV</text><text x="70" y="38" fill="#0f172a" font-size="14" font-weight="600">CV_Platform_v3.pdf</text><text x="70" y="60" fill="#94a3b8" font-size="12">Tailored · 214 KB · submitted</text><rect x="70" y="66" width="70" height="16" rx="8" fill="#dcfce7"/></g>
<g transform="translate(350,0)"><rect width="330" height="88" rx="12" fill="#f8fafc" stroke="#e2e8f0"/><rect x="16" y="20" width="40" height="48" rx="6" fill="#22d3ee"/><text x="36" y="49" fill="#083344" font-size="10" text-anchor="middle">DOC</text><text x="70" y="38" fill="#0f172a" font-size="14" font-weight="600">Cover_Letter.pdf</text><text x="70" y="60" fill="#94a3b8" font-size="12">AI-assisted · 98 KB</text></g>
<g transform="translate(0,104)"><rect width="330" height="88" rx="12" fill="#f8fafc" stroke="#e2e8f0"/><rect x="16" y="20" width="40" height="48" rx="6" fill="#f59e0b"/><text x="36" y="49" fill="#fff" font-size="10" text-anchor="middle">PNG</text><text x="70" y="38" fill="#0f172a" font-size="14" font-weight="600">Portfolio.png</text><text x="70" y="60" fill="#94a3b8" font-size="12">1.2 MB</text></g>
<g transform="translate(350,104)"><rect width="330" height="88" rx="12" fill="#fff" stroke="#cbd5e1" stroke-dasharray="5 5"/><text x="165" y="44" fill="#94a3b8" font-size="13" text-anchor="middle">Drag &amp; drop or click</text><text x="165" y="64" fill="#cbd5e1" font-size="12" text-anchor="middle">resume · cover letter · portfolio</text></g>
</g>
<line x1="28" y1="268" x2="692" y2="268" stroke="#eef2f7"/>
<text x="28" y="300" fill="#0f172a" font-size="14" font-weight="700">Checklist</text>
<g font-size="13" transform="translate(28,320)"><text x="0" y="0" fill="#16a34a">✓ Resume</text><text x="110" y="0" fill="#16a34a">✓ Cover letter</text><text x="250" y="0" fill="#16a34a">✓ Portfolio</text><text x="370" y="0" fill="#cbd5e1">○ References</text></g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 10 KiB

+2
View File
@@ -28,6 +28,7 @@ import JobTable from "./components/JobTable";
import type { JobTableColumns } from "./components/JobTable";
import { I18nProvider, useI18n } from "./i18n/I18nProvider";
import LoginPage from "./pages/LoginPage";
import LandingPage from "./pages/LandingPage";
import ForgotPasswordPage from "./pages/ForgotPasswordPage";
import ResetPasswordPage from "./pages/ResetPasswordPage";
import RouteErrorPage from "./pages/RouteErrorPage";
@@ -344,6 +345,7 @@ export default function App() {
});
const router = useMemo(() => createBrowserRouter([
{ path: "/", element: <LandingPage />, errorElement: <RouteErrorPage /> },
{ path: "/login", element: <LoginPage />, errorElement: <RouteErrorPage /> },
{ path: "/forgot-password", element: <ForgotPasswordPage />, errorElement: <RouteErrorPage /> },
{ path: "/reset-password", element: <ResetPasswordPage />, errorElement: <RouteErrorPage /> },
+257
View File
@@ -0,0 +1,257 @@
import React, { useEffect, useState } from "react";
import { Box, Button, Container, Stack, Typography } from "@mui/material";
import { alpha } from "@mui/material/styles";
import { 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 [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]);
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={() => navigate("/login")} 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={() => navigate("/login")} 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={() => navigate("/login")}
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={() => navigate("/login")} 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={() => navigate("/login")} sx={{ fontWeight: 700 }}>Sign in</Button>
</Stack>
</Container>
</Box>
</Box>
);
}