feat: complete release readiness work #28
@@ -202,3 +202,4 @@ Output was reduced to filenames and commit counts. The token artifact appears un
|
||||
| V-168 | Failing renderer contrast test; focused/full backend; CV Builder/public Jest; real Chromium computed-style/overflow probe; pathological A4 PDF export and text inspection | Repository root / `job-tracker-ui` | Make header/sidebar contact text readable for theme and custom palettes while keeping public renderer settings inert | PASS — renderer/settings 25/25, backend 644/644 and CV UI 22/22. Chromium computed white on Modern blue, black on `#f8fafc`, white on Technical sidebar, with zero element overflow. A 14-role/75-skill fixture produced a 17-page 259,447-byte A4 PDF with 1,685 final-page characters. CSS-like accent/font payloads normalize to null | Synthetic local data/browser only; authenticated application journey and production browser binary remain unverified. A direct PowerShell assembly probe failed to load dependencies before the compiled temporary test probe passed; temporary proof cleanup was blocked by execution policy | Renderer contrast/public-setting boundary verified locally |
|
||||
| V-169 | Legacy-vs-dedicated workspace trace; failing package DTO/UI regressions; focused/full backend and frontend; optimized build; authenticated Playwright application journey; diff review | Repository root / `job-tracker-ui` | Close JOBS-002 application-package parity, marker encapsulation, dirty navigation, focus return, responsive/theme/history/error and tenant-safety gaps | PASS — focused backend 30/30, focused frontend 28/28 plus route/focus 6/6, backend 647/647, frontend 54 suites/227 tests, build and Playwright 7/7. Chromium covers keyboard row entry, Back/Forward, saved refresh, dirty-edit cancel, focus return, missing job and long data at 375/768/1440 in explicit light/dark with zero overflow. Cross-owner workspace read and draft write return not found | Synthetic local SQLite/account/browser only; no production/provider/private data. Two targeted browser iterations corrected locators, and a transient SQLite company-create 500 led to bounded idempotent setup retry; behavior assertions were not weakened. Existing GSI and Jest open-handle warnings remain | JOBS-002 repository/browser scope verified; production/native assistive-device gates remain |
|
||||
| V-170 | Accessibility code audit; focused/full frontend; optimized build; full and targeted authenticated Playwright; computed-style contrast and nested-frame overflow probes | Repository root / `job-tracker-ui` | Close confirmed icon-name, keyboard CV-card, dark Alert contrast and fixed-width public-CV accessibility defects | PASS — static audit finds no `IconButton` without an explicit name; focused 4 suites/9 tests, full frontend 54 suites/228 tests, build, full Playwright 7/7 and final targeted 2/2. Chromium measures dark missing-job Alert contrast >= 4.5:1 and proves a 375px public CV retains a full A4 inner viewport with no inner or outer horizontal overflow | Local synthetic account/CV only; no native screen reader, operating-system high-contrast mode or production environment. Jest retains the known force-exit/open-handle warning; GSI repeats its existing initialization warning in development | Scoped cross-application accessibility repository/browser work verified; native AT and production remain |
|
||||
| V-171 | Public/product claim inventory; plan/notice/usage focused Jest; current entitlement/billing-policy backend slice; full frontend; optimized build; full Playwright | Repository root / `job-tracker-ui` | Replace contradictory plan/commercial claims and prove respectful Free/Pro promotion without changing billing or enforcement | PASS — exactly Free/Pro comes from one catalogue; focused frontend 7 suites/30 tests, policy/billing backend 30/30, full frontend 57 suites/232 tests, build and Playwright 8/8. Chromium proves retired claims absent, explicit Light/Dark, 375/768/1440 no overflow and keyboard Free/Pro actions | Local synthetic account only; no Stripe checkout/webhook/portal, native AT or production. Commercial terms remain intentionally absent until configured Checkout; existing Jest/GSI warnings remain | PRODUCT-001 repository/browser scope verified; configured billing lifecycle and production remain |
|
||||
|
||||
@@ -279,24 +279,21 @@ Confirms the Architecture Proposal with the teardown's amendments. Decision summ
|
||||
|
||||
## 9. Monetisation Opportunities
|
||||
|
||||
**Context decision first:** this is currently a self-hosted personal/OSS-style product; Stripe work (Wave 5) is deferred pending product decisions. Monetisation strategy is therefore designed now, implemented only if/when the product goes multi-user SaaS. Design it now anyway — pricing architecture shapes feature boundaries.
|
||||
**Current product contract:** the implemented public offer is exactly Free and Pro. Current surfaces do not publish a price, trial, billing interval or unlimited claim; configured Stripe Checkout owns commercial terms.
|
||||
|
||||
**The model, if/when SaaS: FlowCV's seam, our loop.** Free = full quality, singular. Paid = multiplicity + intelligence depth.
|
||||
|
||||
| | Free forever | Plus (~£4–6/mo — undercut Teal/Novoresume 3–5×) |
|
||||
| Capability | Free | Pro |
|
||||
|---|---|---|
|
||||
| Profile | Full, versioned, provenance | Same |
|
||||
| Variants | 1 | Unlimited |
|
||||
| Tailored applications | 3 active | Unlimited |
|
||||
| Themes | All core themes | Same (+ future marketplace) |
|
||||
| Export | PDF+JSON, unlimited, no watermark — **always** | + DOCX |
|
||||
| AI | Metered monthly allowance, **visible quota** | High allowance, still visible |
|
||||
| Tracker + Gmail | Full | Full |
|
||||
| Public profile | — | Custom-slug live profile |
|
||||
| Job tracker, reminders and correspondence | Included | Included |
|
||||
| Career Profile and CV editing | Manual tools included | Manual tools plus AI-assisted import and rewriting |
|
||||
| CV variants and export | Included; own data remains exportable | Included |
|
||||
| Deterministic CV-to-job match | Included | Included |
|
||||
| AI generation and strategy tools | Not available | Available within configured safety/usage policy |
|
||||
| CV themes | Core themes | Core and Pro themes |
|
||||
| Attachment storage | 250 MB | 5 GB |
|
||||
|
||||
**Monetise:** variant multiplicity (the proven seam — value scales with search intensity, exactly when willingness-to-pay peaks, without degrading free quality), AI volume (real marginal cost; honest metering), public profile (ongoing hosted value), later marketplace themes (rev-share).
|
||||
**Commercial model:** core tracking and user-owned data remain useful on Free. Pro funds cost-bearing AI assistance and adds Pro themes without removing access to existing records or exports after downgrade.
|
||||
|
||||
**Never monetise (the trust spec):** export of your own data, watermark removal (never watermark), the tracker (Teal proved free-tracker acquisition; ours feeds the loop), re-access to documents after cancellation (the anti-Novoresume guarantee — put it on the pricing page verbatim: *"Cancel and keep everything you made."*), secret AI caps (Kickresume's one-star engine).
|
||||
**Never monetise (the trust spec):** export of your own data, watermark removal (never watermark), the tracker (Teal proved free-tracker acquisition; ours feeds the loop), re-access to documents after cancellation, or secret AI caps.
|
||||
|
||||
**Not now:** coaching/human services (different business), auto-apply (never), premium template *tiers* before a marketplace exists (6 themes is too thin to split).
|
||||
|
||||
|
||||
@@ -191,13 +191,13 @@ Goal: commercialise. Last, per the guide's "do not over-engineer before needed.
|
||||
|---|---|---|---|---|---|
|
||||
| 7.1 | **IMPLEMENTED AND CONFIGURED; browser verification required** — password signup and sign-in use Cloudflare Turnstile with mandatory server-side Siteverify validation. Production reports registration and Turnstile enabled; a real signup still needs interactive verification. | **P2** | **M** | 2.4, 7.3 | The code and configuration are active without weakening abuse controls. |
|
||||
| 7.2 | **DONE (2026-07-30)** — existing Identity roles are the plan model: `Premium` (and `Admin`) receives `advancedAi`, `premiumThemes`, `automation`, `analytics`, and 5 GB storage capabilities; free accounts receive core features and 250 MB. `/auth/me` exposes plan and entitlements. | **P3** | **M** | none | Reuses the existing role system and avoids a second billing-state table before Stripe exists. |
|
||||
| 7.3 | **DONE (2026-07-30)** — existing AI interaction metering now enforces monthly generation limits: 25 for free accounts and 250 for Premium/Admin. Usage responses expose the active plan and limit. | **P3** | **M** | 5.2, 7.2 | Cost-bearing AI now has a clear monthly ceiling before registration opens. |
|
||||
| 7.3 | **SUPERSEDED BY POL-001 (2026-08-02)** — Free has no AI generation; Pro/Admin retain the existing 250-call ceiling. Usage responses expose the public Free/Pro plan and limit. | **P3** | **M** | 5.2, 7.2 | Server policy, not landing-page copy, owns admission. Complete cross-feature accounting remains a rollout gate. |
|
||||
| 7.4 | **DONE (2026-07-30)** — attachment uploads enforce total per-user storage entitlements (250 MB free, 5 GB Premium/Admin) in addition to the existing 10 MB per-file cap. | **P3** | **S** | 7.2 | Storage limits match the exposed capability model. |
|
||||
| 7.5 | **IMPLEMENTED; configuration required (2026-07-31)** — hosted subscription Checkout, customer portal, signed subscription webhooks, persisted Stripe state, and idempotent Premium-role provisioning. | **P3** | **L** | 7.2 | Activation needs the operator-created monthly price, portal, webhook registration, and three deployment secrets in `BLOCKERS.md`. |
|
||||
| 7.6 | ✅ **DONE (2026-07-31)** — public CV (`/cv/{guid}`), privacy-first random links, revoke/rotate sharing, recruiter PDF download | **P3** | **M** | 3.4, 4.2 | Anonymous rendering is isolated behind an explicit public flag, served with `noindex`; revoked links cannot be restored accidentally, and rate-limited PDF export uses the same visibility check. |
|
||||
| 7.7 | ✅ **DONE (2026-07-30)** — three free CV themes plus five Premium themes, enforced by account entitlement and clearly locked in the picker | **P3** | **S** | 4.3, 7.2 | Existing Premium-theme CVs remain editable and exportable after downgrade so user data is never held hostage. |
|
||||
| 7.8 | **DONE (2026-07-31)** — CI runs NuGet transitive vulnerability reporting and blocks high/critical npm findings across production and test/browser tooling. The documented React Router baseline is moderate. | **P2** | **S** | none | Vulnerable dependencies are visible and high-severity regressions stop deployment. |
|
||||
| 7.9 | ✅ **DONE (2026-07-30)** — per-user monthly AI token ceilings (100k free, 1M Premium/Admin) enforced alongside generation limits and exposed in usage totals | **P3** | **S** | 5.2, 7.2 | Existing metering is the single accounting source; paid-provider spend now has both request and token ceilings. |
|
||||
| 7.9 | **SUPERSEDED BY POL-001 (2026-08-02)** — Free has a zero-token AI allowance; Pro/Admin retain the 1M-token ceiling exposed in usage totals | **P3** | **S** | 5.2, 7.2 | Current accounting is complete only for AI Workspace; universal limit claims remain prohibited until all producers share the ledger. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
# business-model
|
||||
|
||||
Jobjakt uses capability-based plans rather than limiting the number of jobs or documents. Free accounts
|
||||
receive core tracking, basic Career Profile/CV features, 250 MB storage, and bounded AI usage.
|
||||
Premium/Admin accounts receive advanced AI, Premium themes, automation, analytics, 5 GB storage, and
|
||||
higher request/token ceilings.
|
||||
Jobjakt exposes exactly two public plans. Free accounts receive core non-AI job tracking, manual Career
|
||||
Profile/CV features, deterministic matching, exports and 250 MB attachment storage. Pro accounts receive
|
||||
AI-assisted features, Pro CV themes, 5 GB attachment storage and the configured request/token ceilings.
|
||||
`Premium` remains an internal compatibility role name; it is presented publicly as Pro. Administrators
|
||||
receive Pro capabilities for operation and support, but are not a third public plan.
|
||||
|
||||
Stripe hosted Checkout, customer-portal sessions, signed subscription webhooks, persisted customer /
|
||||
subscription state, and Premium-role provisioning are implemented. Production activation still needs
|
||||
the operator-created monthly price, portal configuration, webhook registration, and credentials in
|
||||
`BLOCKERS.md`. Existing user documents remain readable and exportable after downgrade.
|
||||
subscription state, and Premium-role provisioning are implemented. The public site does not invent or
|
||||
display prices, billing intervals, trials or unlimited usage. Commercial terms come from configured
|
||||
Stripe Checkout. Production activation still needs the operator-created monthly price, portal
|
||||
configuration, webhook registration, and credentials in `BLOCKERS.md`. Existing user documents remain
|
||||
readable and exportable after downgrade.
|
||||
|
||||
@@ -48,6 +48,10 @@ This is the rolling action-level evidence index. `PASS (automated/runtime)` is n
|
||||
| Accessibility | icon control names and keyboard-operable CV cards | PASS (components + static audit) | PASS/PARTIAL — Chromium keyboard activation | NOT RUN | `accessibility-evidence.md`, V-170 |
|
||||
| Accessibility | dark semantic Alert text contrast | PASS (theme ownership) | PASS — computed Chromium contrast >= 4.5:1 | NOT RUN | `ux-002-deterministic-theme-state.md`, V-170 |
|
||||
| Public CV | responsive A4/multi-page framing without inner or outer overflow | PASS (component) | PASS — 375px Chromium | NOT RUN | `accessibility-evidence.md`, V-170 |
|
||||
| Public plans | exactly Free/Pro; no invented tier, price, interval, trial or unlimited claim | PASS (catalogue + landing components) | PASS — Light/Dark at 375/768/1440 | NOT RUN | `product-001-honest-plans.md`, V-171 |
|
||||
| Public plans | Free registration and Pro sign-in-to-Settings actions | PASS (component) | PASS — keyboard activation | NOT RUN | `product-001-honest-plans.md`, V-171 |
|
||||
| Pro promotion | benefit-specific locked notice, preserved-data copy and session dismissal | PASS (components) | NOT RUN on every contextual surface | NOT RUN | `product-001-honest-plans.md`, V-171 |
|
||||
| Billing presentation | checkout/portal only when server status permits; unconfigured deployment disclosed | PASS (components + policy slice) | NOT RUN with configured Stripe | NOT RUN | `product-001-honest-plans.md`, V-171 |
|
||||
| Job match terms | bilingual/noisy/short/technology-heavy/filler extraction and honest labels | PASS (seven fixtures + component tests) | NOT RUN | NOT RUN | `qa-001-job-term-quality.md` |
|
||||
| Career Workspace | first/returning/incomplete/loading/import processing/review/failure and recent CV actions | PASS (components; approval gate regression) | NOT RUN | NOT RUN | `career-001-career-workspace.md` |
|
||||
| CV Builder | edit/collapse/add/delete/reorder/hide, save states/latest-data retry, navigation warning and preview failure/retry | PASS (components) | NOT RUN | NOT RUN | `career-002-cv-builder.md` |
|
||||
|
||||
@@ -52,7 +52,7 @@ Status: `IMPLEMENTED — NOT VERIFIED`. The server policy, worker rechecks, Free
|
||||
- Browser localhost access is denied by the available browser policy, so 375/768/1440, keyboard, themes and actual navigation to the upgrade action are not claimed.
|
||||
- Stripe webhook transitions were code-inspected and existing status tests cover active/trialing vs expired states, but no real or mocked end-to-end checkout/webhook cycle ran in this package.
|
||||
- MariaDB and production were not changed or tested.
|
||||
- Landing-page prices, a third “Bring your own key” tier and “Unlimited AI” claims remain assigned to PRODUCT-001; they are not presented as resolved by POL-001.
|
||||
- PRODUCT-001 removed landing-page prices, the third “Bring your own key” tier, Free AI allowance and “Unlimited AI” claims. Public capability copy now comes from one two-plan catalogue; commercial terms remain in configured Stripe Checkout.
|
||||
- Full cross-feature usage accounting is incomplete. It must be centralized with AI operation execution before provider rollout; current numeric ceilings must not be advertised as universal until then.
|
||||
|
||||
## Rollback
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# PRODUCT-001 honest Free/Pro surfaces
|
||||
|
||||
Updated: 2026-08-15
|
||||
|
||||
Status: `IMPLEMENTED — NOT VERIFIED`. Repository and local Chromium scope passes. Configured Stripe checkout, expiry/downgrade browser behavior, native assistive technology and production remain external.
|
||||
|
||||
## Confirmed problem
|
||||
|
||||
The public homepage contradicted the server policy. It advertised three tiers, fixed prices and billing intervals, a Free AI allowance, a bring-your-own-key tier and unlimited AI. The authoritative `AccountPlans` model exposes only Free and Pro, gives Free no AI admission, and applies finite Pro ceilings. Commercial terms are owned by configured Stripe Checkout, not application copy.
|
||||
|
||||
## Implemented contract
|
||||
|
||||
- `planCatalog.ts` is the single frontend source for public plan names and capability copy. It contains exactly `free` and `pro`; the server remains authoritative for enforcement and numeric entitlements.
|
||||
- Free describes core non-AI tracking, correspondence/documents, manual Career Profile/CV editing, deterministic matching and data export/retention.
|
||||
- Pro describes the existing AI-assisted application/CV/Career capabilities and Pro themes. It does not claim an invented price, interval, trial or unlimited usage.
|
||||
- The homepage uses two semantic plan sections, sends the Free action to registration and the Pro action through sign-in to Settings. Branding is consistently Jobjakt.
|
||||
- Settings shows Checkout/portal actions only when the authenticated billing status permits them. A Free user sees an honest deployment-unavailable explanation when Stripe is not configured.
|
||||
- Active AI surfaces reuse a dismissible `ProFeatureNotice`. Copy is benefit-specific, preserves access to manual/existing content and never implies that work was generated.
|
||||
- Product/business-model and implementation-roadmap claims now match POL-001. Historical competitor pricing remains research, not current product copy.
|
||||
|
||||
No backend behavior, database, dependency, billing configuration or production state changed.
|
||||
|
||||
## Verification
|
||||
|
||||
- Public catalogue, landing, reusable notice, usage card and active AI surfaces: 7 suites, 30/30 tests.
|
||||
- Current server entitlement/billing-policy slice: 30/30 tests, including Free, Pro, Admin, stale-role downgrade and subscription-status behavior.
|
||||
- Full frontend: 57/57 suites, 232/232 tests.
|
||||
- Optimized production frontend build/TypeScript: pass.
|
||||
- Full Playwright: 8/8. The public plan page shows exactly Free/Pro, contains none of the retired claims, persists explicit Light/Dark, has no horizontal overflow at 375/768/1440, and both plan actions work from the keyboard.
|
||||
- `git diff --check`: pass; line-ending notices only.
|
||||
|
||||
## Remaining gates
|
||||
|
||||
- Production Stripe price/interval/trial text must continue to come from hosted Checkout. No commercial term is claimed until the configured product is inspected in the authorized production account.
|
||||
- Exercise a configured Free checkout, successful webhook/role transition, portal, cancellation/expiry/downgrade and existing-data access in an authorized synthetic production account.
|
||||
- Native screen-reader and switch-control spot checks remain external.
|
||||
- Complete cross-feature AI usage accounting remains an AI rollout gate; the public site deliberately makes no universal numeric or unlimited claim.
|
||||
|
||||
## Rollback
|
||||
|
||||
Revert the PRODUCT-001 frontend/documentation commit. No migration or data rollback is required; server-side Free/Pro enforcement is unchanged and must not be reverted with presentation copy.
|
||||
@@ -719,3 +719,13 @@
|
||||
- **Consequences:** public browser viewing preserves the same A4 layout used by PDF/export while fitting narrow screens; multi-page content reserves its scaled height. PDF output is unchanged.
|
||||
- **User approval required:** No; this is a requested responsive/accessibility correction without schema, dependency or production change.
|
||||
- **Reversible:** Revert the `PublicCvPage` frame wrapper; no stored data changed.
|
||||
|
||||
## DEC-073 — Keep public plan copy capability-based and checkout-owned
|
||||
|
||||
- **Date:** 2026-08-15
|
||||
- **Decision:** Publish exactly Free and Pro from one frontend capability catalogue, keep `AccountPlans` authoritative for enforcement, and let configured Stripe Checkout own price, interval and any trial terms. Reuse one dismissible contextual Pro notice rather than scattering promotional Alert implementations.
|
||||
- **Reason/evidence:** the homepage advertised three tiers, fixed prices, Free AI and unlimited AI while the server allowed only Free-without-AI and finite Pro entitlements. Central copy and conditional billing actions remove that contradiction without changing secure server policy.
|
||||
- **Alternatives considered:** hardcode the current Stripe price; query Stripe anonymously; retain a speculative BYOK tier; show an upgrade button when billing is unavailable; interrupt Free users with repeated dialogs. These leak/change commercial ownership, create false actions or use dark patterns.
|
||||
- **Consequences:** public claims stay stable across deployment-specific commercial configuration; Free users see honest locked states and retain manual/existing content; checkout terms remain inspectable at the payment boundary.
|
||||
- **User approval required:** No; this implements the approved master-plan requirement without external billing action.
|
||||
- **Reversible:** Revert the PRODUCT-001 presentation commit. Server entitlement and stored billing state are unchanged.
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
|
||||
Updated: 2026-08-15
|
||||
|
||||
- **Overall programme status:** Active. Seven packages are locally verified; twenty-three are implemented with verification incomplete; PRODUCT-001 is in progress. The prioritized admin-only version indicator, JOBS-002 workspace and scoped cross-application accessibility pass are implemented on the release branch; remote and production verification remain.
|
||||
- **Current work package:** `PRODUCT-001` — honest Free/Pro homepage and contextual upgrade surfaces (`IN PROGRESS`). The prerequisite claim inventory is next; no price, trial or limit will be invented.
|
||||
- **Overall programme status:** Active. Seven packages are locally verified; twenty-four are implemented with verification incomplete; VER-001 is in progress. The prioritized admin-only version indicator, JOBS-002 workspace, scoped accessibility pass and honest Free/Pro product surfaces are implemented on the release branch; remote and production verification remain.
|
||||
- **Current work package:** `VER-001` — complete application action matrix and regression pass (`IN PROGRESS`). Reconcile the matrix against the current tree, run every local gate and classify external checks without overstating them.
|
||||
- **Completed work packages:** None are `DONE`; all repository security/AI packages still have applicable browser, provider and/or production gates.
|
||||
- **Locally verified work:** SEC-001, SEC-002, SEC-003, SEC-005A, CORE-001, PROD-002 and DEP-001 (`VERIFIED LOCALLY`).
|
||||
- **Implemented, verification incomplete:** SEC-004, SEC-005B, SEC-008, CORE-002, BG-001, OPS-001A/B/C, POL-001/002, AI-001/002/003/004, UX-001/002/003, QA-001, CAREER-001/002, MAIL-001 and JOBS-001/002 (`IMPLEMENTED — NOT VERIFIED`). JOBS-002, UX-002 and UX-003 safe local/browser scope is implemented; production/native-device gates remain.
|
||||
- **Implemented, verification incomplete:** SEC-004, SEC-005B, SEC-008, CORE-002, BG-001, OPS-001A/B/C, POL-001/002, AI-001/002/003/004, UX-001/002/003, QA-001, CAREER-001/002, MAIL-001, JOBS-001/002 and PRODUCT-001 (`IMPLEMENTED — NOT VERIFIED`). Their safe repository/browser scope is implemented; production/native-device/provider gates remain where recorded.
|
||||
- **Production-verified work:** None.
|
||||
- **Blocked work:** SEC-006 parser upgrades remain outside the scoped frontend advisory permission; PROD-001/003/004 and REL-001 require documented production access and unfinished dependencies. Real provider, SMTP/MariaDB and production environments are unavailable; DEP-001 awaits approved merge/live verification. The in-app browser is available for local UI checks.
|
||||
- **Deferred work:** None. Conditional multi-replica coordination, model deletion, realtime operation delivery and unrelated production changes remain outside current packages.
|
||||
- **Immediate order:** PRODUCT-001; VER-001; tracking/blocker reconciliation. Admin version (`a6cffe0`), Career lossless persistence (`f0b9b22`), CV contact contrast (`3b86ea2`), JOBS-002 (`deed948`) and the cross-app accessibility pass are locally complete. External-only work remains skipped, not allowed to stall this queue.
|
||||
- **Status counts:** 7 `VERIFIED LOCALLY`; 23 `IMPLEMENTED — NOT VERIFIED`; 1 `IN PROGRESS`; 3 `NOT STARTED`; 5 `BLOCKED`; 0 `DONE`; 0 `DEFERRED`.
|
||||
- **Test status:** backend 647/647; frontend 54/54 suites and 228/228 tests; optimized production build/TypeScript pass; Playwright 7/7 plus final targeted 2/2. The application workspace journey passes keyboard entry, Back/Forward, focus return, unsaved navigation, saved-draft refresh, missing-job handling, long data, explicit light/dark 375/768/1440 no-overflow checks and computed dark-Alert AA contrast. The public CV preserves its full A4 viewport while scaling without inner/outer mobile overflow. CV renderer/settings 25/25, CV Builder/public UI 22/22, AI sidecar 22/22 and npm audit 0 evidence remain current. Historical JT-019 and Jest force-exit/open-handle behavior remain recorded.
|
||||
- **Immediate order:** VER-001; tracking/blocker reconciliation. Admin version (`a6cffe0`), Career lossless persistence (`f0b9b22`), CV contact contrast (`3b86ea2`), JOBS-002 (`deed948`), accessibility (`a7c2549`) and PRODUCT-001 repository/browser scope are locally complete. External-only work remains skipped, not allowed to stall this queue.
|
||||
- **Status counts:** 7 `VERIFIED LOCALLY`; 24 `IMPLEMENTED — NOT VERIFIED`; 1 `IN PROGRESS`; 2 `NOT STARTED`; 5 `BLOCKED`; 0 `DONE`; 0 `DEFERRED`.
|
||||
- **Test status:** backend 647/647 remains current; PRODUCT policy/billing slice 30/30; frontend 57/57 suites and 232/232 tests; optimized production build/TypeScript pass; Playwright 8/8. Chromium now also proves exactly two honest public plans, explicit light/dark at 375/768/1440, no retired commercial claims, no overflow and keyboard plan actions. The application workspace and responsive public-CV evidence remains current. CV renderer/settings 25/25, AI sidecar 22/22 and npm audit 0 evidence remain current. Historical JT-019 and Jest force-exit/open-handle behavior remain recorded.
|
||||
- **Deployment status:** Gitea pull-request run 609 passed the complete CI job in 4m20s. Deploy was intentionally skipped because the workflow deploys only a `push` to `main`; live remains unchanged. No merge/deployment was performed directly, no production migrations were run and the AI operation worker remains disabled by default.
|
||||
- **Production status:** Unchanged and unverified. No provider/model call, model pull, external request or paid API occurred.
|
||||
- **Known regressions:** None found by automated/local browser checks. Jest still needs `--forceExit` and reports its existing open-handle notice. Email-provider/send tests are fake/local only; real delivery is not claimed. Current MAIL browser evidence is 1280×720 only because the browser surface could not resize or perform native Tab traversal. Interrupted attempts are aged after 15 minutes and notified without retry; the five-minute scan is unmeasured on a large ledger. Direct clean EF-only SQLite migration still hits the pre-existing historical blank-chain defect before later migrations; normal startup owns reconciliation. Cross-feature monthly AI usage accounting remains a rollout gap.
|
||||
@@ -49,5 +49,6 @@ Updated: 2026-08-15
|
||||
- `docs/verification/mail-001-job-email-hub.md`
|
||||
- `docs/verification/jobs-001-job-discovery.md`
|
||||
- `docs/verification/ux-003-kanban-theme.md`
|
||||
- `docs/verification/product-001-honest-plans.md`
|
||||
- `docs/verification/prod-002-ai-evaluation.md`
|
||||
- `docs/work-programmes/master-work-plan.md`
|
||||
|
||||
@@ -16,7 +16,7 @@ Allowed statuses are `NOT STARTED`, `IN PROGRESS`, `IMPLEMENTED — NOT VERIFIED
|
||||
|
||||
`DONE` requires every applicable acceptance criterion, focused and regression tests, browser/accessibility/theme/mobile checks, tenant and entitlement checks, documentation, migration/rollback evidence, and production verification. Repository-only work that still requires production is at most `VERIFIED LOCALLY`.
|
||||
|
||||
Exactly one implementation item may be `IN PROGRESS`. As of this revision it is **PRODUCT-001**.
|
||||
Exactly one implementation item may be `IN PROGRESS`. As of this revision it is **VER-001**.
|
||||
|
||||
## Consolidated dependency order
|
||||
|
||||
@@ -59,8 +59,8 @@ This queue records the highest-value work that can proceed without production cr
|
||||
| 3 | CV contact/header/sidebar contrast correction | CAREER-002 | Implemented and locally verified. Header/custom-accent and sidebar palettes own readable foregrounds; real Chromium computed-style/overflow checks and a 17-page A4 PDF proof pass. |
|
||||
| 4 | Dedicated Job Details parity and JOBS-002 closure | JOBS-002 | Locally complete. Application answers/recruiter drafts now live on the dedicated page, note markers are encapsulated, edits are lossless, dirty navigation is guarded, focus returns to the row, tenant regressions pass, and Chromium covers 375/768/1440 light/dark/history/error/long data. Production smoke remains. |
|
||||
| 5 | Cross-application contrast/accessibility pass | UX-002, UX-003, VER-001 | Locally complete. All icon controls own programmatic names; CV cards are keyboard links; the A4 public CV scales without inner/outer mobile overflow; dark Alert contrast is measured in Chromium at WCAG AA; full frontend/build/Playwright pass. Native assistive-technology and a general CI crawler remain external/future gates. |
|
||||
| 6 | Honest Free/Pro homepage and upgrade surfaces | PRODUCT-001 | In progress. Inventory existing claims first; do not invent pricing, limits or trial terms before billing configuration is real. |
|
||||
| 7 | Complete application action matrix and full regression | VER-001 | Not started. Populate incrementally, then run the complete backend/frontend/sidecar/E2E gates and accurately classify external production/provider checks. |
|
||||
| 6 | Honest Free/Pro homepage and upgrade surfaces | PRODUCT-001 | Locally complete. One catalogue drives exactly Free/Pro; retired tier/price/Free-AI/unlimited claims are gone; configured billing state controls the real upgrade action; contextual notices are reusable/dismissible. Frontend 232/232, policy/billing 30/30, build and Chromium 8/8 pass. |
|
||||
| 7 | Complete application action matrix and full regression | VER-001 | In progress. Reconcile the rolling matrix, run the complete backend/frontend/sidecar/E2E/configuration gates and accurately classify external production/provider checks. |
|
||||
| 8 | Tracking and blocker reconciliation | All | Keep this plan, progress, handoff, verification log and `BLOCKERS.md` aligned after every logical increment; remove stale CI/dependency statements only when current evidence proves them obsolete. |
|
||||
|
||||
## Requirement coverage index
|
||||
@@ -398,7 +398,7 @@ This queue records the highest-value work that can proceed without production cr
|
||||
- **Blocker:** browser localhost is denied; Stripe/MariaDB/production are unavailable. Usage accounting is complete only for AI Workspace, so provider rollout remains blocked until durable execution centralizes it.
|
||||
- **Evidence:** `docs/verification/pol-001-free-pro-entitlements.md`; focused backend 74/74; full backend 568/568; focused frontend 22/22; full frontend 47 suites/157 tests; production build.
|
||||
- **Commit:** none.
|
||||
- **Remaining work:** browser locked/Pro state checks; mocked Stripe expiry/downgrade lifecycle; central all-task usage accounting through AI-003/004 producers; production role/config smoke. PRODUCT-001 separately removes the known landing-page price/third-tier/unlimited claims.
|
||||
- **Remaining work:** browser locked/Pro state checks; mocked Stripe expiry/downgrade lifecycle; central all-task usage accounting through AI-003/004 producers; production role/config smoke. PRODUCT-001 has removed the former landing-page price/third-tier/unlimited claims.
|
||||
|
||||
### POL-002 — AI privacy, consent and external-fallback policy
|
||||
|
||||
@@ -736,11 +736,11 @@ This queue records the highest-value work that can proceed without production cr
|
||||
- **Required tests:** copy/capability consistency, Free/Pro/expired/downgraded locked states, dismissal/no false generation.
|
||||
- **Required browser verification:** homepage and contextual prompts at three widths/themes/keyboard/accessibility.
|
||||
- **Required production verification:** configured price text only if actual billing product exists; otherwise no invented values.
|
||||
- **Status:** `IN PROGRESS`.
|
||||
- **Blocker:** public plan behavior depends on POL-001 compatibility decision; real billing activation is external.
|
||||
- **Evidence:** source requirement.
|
||||
- **Status:** `IMPLEMENTED — NOT VERIFIED`.
|
||||
- **Blocker:** configured Stripe lifecycle, native assistive technology and production checks remain external; repository behavior no longer depends on a product decision.
|
||||
- **Evidence:** `docs/verification/product-001-honest-plans.md`; V-171. Focused frontend 30/30, current policy/billing 30/30, full frontend 57 suites/232 tests, build and Playwright 8/8.
|
||||
- **Commit:** none.
|
||||
- **Remaining work:** inventory all current contradictory plan claims, then implement only centrally supported honest Free/Pro copy and contextual upgrade surfaces; billing values remain external.
|
||||
- **Remaining work:** configured synthetic Checkout/webhook/portal/expiry/downgrade browser journey, native assistive-technology spot check and production smoke. Commercial terms remain owned by Stripe Checkout.
|
||||
|
||||
### VER-001 — Complete application action matrix and regression pass
|
||||
|
||||
@@ -754,7 +754,7 @@ This queue records the highest-value work that can proceed without production cr
|
||||
- **Required tests:** full backend/frontend/Python/E2E plus regressions for confirmed defects.
|
||||
- **Required browser verification:** running app, synthetic users/data, 375/768/1440, themes/keyboard/focus/refresh/back/tabs/slow/error; no real email/paid provider/destructive production action.
|
||||
- **Required production verification:** applicable smoke actions only after deployment; local and production classifications remain distinct.
|
||||
- **Status:** `NOT STARTED`.
|
||||
- **Status:** `IN PROGRESS`.
|
||||
- **Blocker:** browser tooling/access and external providers may block individual rows, not the matrix.
|
||||
- **Evidence:** audit user-journey/action gaps.
|
||||
- **Commit:** none.
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
|
||||
Updated: 2026-08-15
|
||||
|
||||
- **Exact current task:** start PRODUCT-001 claim inventory after committing/pushing the completed cross-application accessibility increment.
|
||||
- **Last completed step:** named all icon controls, made CV cards keyboard-operable, preserved/scaled the full A4 public-CV viewport on mobile and added real dark-Alert contrast proof.
|
||||
- **Files currently modified:** focused frontend accessibility semantics, public-CV responsive rendering, unit/Chromium regressions and programme evidence.
|
||||
- **Commands already run:** accessibility-focused frontend 9/9; full frontend 54 suites/228 tests; optimized build; full Playwright 7/7; final targeted workspace/public-CV Chromium 2/2; static icon-control audit and diff review.
|
||||
- **Test results:** all repository tests listed above pass. Chromium measures dark Alert contrast at WCAG AA, proves the public CV has no inner or outer overflow at 375px, and retains the prior workspace light/dark/history/focus evidence. Jest retains the documented force-exit/open-handle notice.
|
||||
- **Exact current task:** commit/push PRODUCT-001, then execute VER-001 full action-matrix and regression reconciliation.
|
||||
- **Last completed step:** replaced contradictory three-tier/fixed-price/Free-AI/unlimited claims with exactly Free/Pro, centralized public capability copy and reused dismissible contextual upgrade notices.
|
||||
- **Files currently modified:** plan catalogue, homepage, active AI notices, billing-state explanation, focused tests, product documentation and programme evidence.
|
||||
- **Commands already run:** PRODUCT frontend 30/30; policy/billing backend 30/30; full frontend 57 suites/232 tests; optimized build; full Playwright 8/8; claim searches and diff review.
|
||||
- **Test results:** all listed tests pass. Chromium covers exactly two plans, retired-claim absence, explicit light/dark, 375/768/1440 no-overflow and keyboard actions. Jest retains the documented force-exit/open-handle notice.
|
||||
- **Services currently running:** none on task-owned ports 3000/5202. Playwright stopped its disposable API/Next servers. Pre-existing Docker services were not changed.
|
||||
- **Temporary files or processes:** no task-owned process is running and the failed disposable migration database was removed. Existing synthetic browser evidence/account and startup-created local backup remain documented. No provider account, real email, private content, paid service or production service was accessed.
|
||||
- **Production changes currently active:** none. No deployment, migration, provider connection/sync/send or production payload occurred.
|
||||
- **Rollback status:** downgrade `20260810080858_AddEmailDraftClientRequestId`, then `20260810075206_AddEmailDrafts`, before reverting draft commits; then follow the existing MAIL rollback order (`ee5ef7e`, `449faeb`, `123fc55`/`e9937ac`, ledger downgrade before `653f011`). No production migration/deploy/provider grant occurred.
|
||||
- **Uncommitted changes:** V-170 accessibility/responsive-public-CV increment; no dependency/schema/config/migration change. V-166 through V-169 are pushed as `a6cffe0`, `f0b9b22`, `3b86ea2` and `deed948`.
|
||||
- **Uncommitted changes:** V-171 PRODUCT-001 presentation/documentation increment; no backend/dependency/schema/config/migration change. V-166 through V-170 are pushed as `a6cffe0`, `f0b9b22`, `3b86ea2`, `deed948` and `a7c2549`.
|
||||
- **Known failures:** live deployment is not verified because PR deploy is intentionally skipped and the active branch is not approved for merge. Draft export/API/UI, full thread/category actions and non-Gmail review remain; existing accounts need re-consent and IMAP stays read-only. A clean full-chain SQLite apply fails in the pre-existing JT-019 migration before the new draft migration. Browser/provider/MariaDB/production unavailable or unverified; recovery scan performance is unmeasured at large ledger scale; Jest open handles; SEC-006 parser dependency work is still separately gated; parser isolation remains SEC-007.
|
||||
- **Exact next action:** commit/push V-170, inventory Free/Pro claims and implement the supported PRODUCT-001 surface without invented commercial terms.
|
||||
- **Work that can continue independently:** PRODUCT-001, VER-001 and tracking reconciliation. UX/JOBS production, MAIL provider mutations, SEC-006/007 and PROD packages retain their recorded external gates.
|
||||
- **Exact next action:** commit/push V-171, reconcile `application-action-matrix.md` against the current repository and run the complete local VER-001 gate set.
|
||||
- **Work that can continue independently:** VER-001 and tracking reconciliation. UX/JOBS/PRODUCT production, MAIL provider mutations, SEC-006/007 and PROD packages retain their recorded external gates.
|
||||
- **Decisions still required from the user:** none for synthetic/code-inspected repository work. Any provider connection or send test, internet/package upgrades, private data, external/paid providers and production actions retain explicit approval/safety gates; SEC-009 retention/legal policy remains unresolved.
|
||||
|
||||
@@ -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