=== SSC Career Hub ===
Contributors: ssc
Tags: security jobs, cv review, careers, woocommerce
Requires at least: 6.0
Requires PHP: 7.4
Stable tag: 1.8.8
License: GPLv2 or later

Curated NY/NJ security job directory with full application pipeline tracking, CSV/Google Sheet sync, CV paste/upload review queue with shareable readiness reports, CV resubmit/version flow, public CV PDF sharing (external URL or hosted), FC-deck-style view analytics, attribution tracking, user credit ledger, mobile bottom tab bar, optional OpenRouter LLM enrichment for CV reviews and job-specific cover letters, and admin pages for SSC Security Guard Training.

V1.8.8 — Jobs page modern-utility pass + bot view filter + cost-tracking tripwire:
* Jobs hero collapsed from 2-col title-aside (~150px tall) to single inline row "Security jobs · NY DCJS + NJ SORA · 12 active · 4 NY/DCJS · 8 NJ/SORA" (~50px). Same information, ~100px reclaimed for results.
* Filter strip "Filter the board · Type to filter…" header copy hidden — form labels (STATE TRACK / CITIES / ROLE TYPE / TRAINING) are self-explanatory; this was marketing-style decorative copy on a utility module that competed with results for visual attention. The Indeed/LinkedIn/Wellfound pattern: filters are quiet, results dominate. Saves another ~70px of vertical space.
* Filter inputs aggressively compacted: input padding 10/14 → 7/11, font 14 → 13, grid gap 14/18 → 8/12, section padding 20-34 → 12-22, label font 12 → 10.5. Per-input height drops from ~44px to ~32px; 2x2 grid + actions row now ~140px instead of ~210px. Total filter+hero now ~190px (~22% of an 800px viewport) instead of the previous ~470px (~55%) — closer to the modern job-board norm.
* Email intro button on job cards no longer wraps onto two lines in narrow actions columns. Was inheriting the default browser word-wrap behaviour when the actions column got narrow (multiple CTAs per row: Star, Details, Apply, Email intro, Save, Applied). Added `white-space: nowrap` + smaller font (11.5px) + tighter padding so the icon + label stay on one line.
* Bot user-agent exclusion on CV view tracking. When a candidate posted their share link in WhatsApp, Slack, Discord, LinkedIn, Twitter, Telegram, Facebook etc., each platform's link-preview crawler hit the page to generate an unfurl preview — and every unfurl was counted as a view. So a candidate sharing once in a WhatsApp group could see "5 views" before any human eyeballs hit the page. New `SSC_CH_DB::is_bot_user_agent()` helper detects 20+ common preview/crawler UAs; bots get neither a `report_views` row nor a `share_views`/`share_pdf_views` counter bump. Real human visitors still tracked exactly as before. Conservative: empty UA also treated as bot (real browsers always send one).
* OpenRouter cost-tracking tripwire. The v1.8.7 fix (`usage: { include: true }`) added cost reporting — but if OpenRouter ever silently changes the contract again, we'd regress to $0 without noticing (exactly what happened from v1.4.2 to v1.8.6). New check in `SSC_CH_OpenRouter::call()`: if a successful response carries `total_tokens > 0` but `cost = 0`, log `openrouter_cost_missing` to Abuse Events. Next regression surfaces in admin within minutes instead of 13 releases.
* `SSC_CH_Credits::review_economics()` now returns `cost_status='unknown'` (was always `'healthy'`) when ai_cost is exactly $0. The healthy badge previously fired trivially at $0 because the heuristic was `cost <= 0.08`, which is true at zero — so "tracking broken" and "tracking working but tiny" looked identical. Only a positive recorded cost gets the healthy tone now.

V1.8.7 — real spending tracking + revenue/profit visibility + UX cleanups:
* CRITICAL — OpenRouter API requests now include `usage: { include: true }` in the body, so the response's `usage.cost` field actually populates. Without this flag the API only returns token counts and `cost` is absent — meaning every value in `ssc_ch_ai_usage.cost_usd` since v1.4.2 has been $0.00 regardless of actual spend, breaking every cost cap, every profit calculation, and the "AI cost" card on Credit Analytics. Existing rows stay at $0 (no backfill — OpenRouter's per-generation lookup costs another API call per row); new calls from 1.8.7 onward record the real model-priced USD figure.
* Credit Analytics page now has a full "Revenue & profit" section above the legacy KPI strip. Four headline cards: Revenue $ (credits sold × $1 — pack pricing is 1:1, defined in SSC_CH_Credits::packs()), OpenRouter spend $, Gross profit $ (tone-coded green when positive, amber when negative), Margin % (tone-coded: healthy ≥70%, watch 40-70%, thin <40%). Honours the date-range pills/picker, so admin can scope to last 7/30/90 days or a custom window. When OpenRouter isn't configured, an inline note explains why AI cost is $0 (no half-broken state).
* Daily activity right-hand chart upgraded from a single "credits purchased" bar series to a dual-axis chart: revenue bars on the left axis ($), AI cost line on the right axis ($). Gap between the two visualises gross profit day by day. New `SSC_CH_DB::time_series_ai_cost()` feeds the second series.
* CV Reviews admin page adds an "AI cost" column with per-CV OpenRouter spend, call count, and total tokens. Batched in one query (no N+1) using new `SSC_CH_DB::ai_cost_by_cv()`. Admin can now see exactly which CVs cost what — e.g. spot a runaway cover-letter generation, or confirm a 10-credit ($10) priority review only cost $0.0034 in OpenRouter spend (healthy ~3000× markup).
* UX — anonymous CV submitters now redirect back to /cv-optimizer/ on success (the form already supports the success card per v1.4.7), not to /my-dashboard/. The dashboard's personalized greeting, KPI cards, score trend, recent-CVs grid and credit balance are all logged-in-only, so sending anon users there was showing them a half-empty page with the status panel floating at the top. Logged-in users still land on the dashboard hub.
* UX — delete_cv endpoint replaced all four wp_die() failure paths (not logged in, bad nonce, rate-limited, CV not found) with redirects back to /my-cvs/ carrying a `delete_error` param. The My CVs page renders each error code as a friendly inline banner ("That CV could not be found — it may have already been deleted", "You hit the delete limit (20/hour)…") instead of slamming the user into a bare WP error page mid-flow.

V1.8.6 — logic-pass hotfix (privacy leak + view dedup + delete rate-limit):
* CRITICAL — CV report OG/Twitter meta tags were being injected on ANY page whose URL carried `?cv=X&t=Y` query params, not just the cv-report page itself. Because v1.5.0 redirects successful submits to `/my-dashboard/?submitted=1&cv=N&t=TOKEN&score=N`, the dashboard page picked up OG tags advertising the candidate's score and name ("Edward scored 75/100 on SSC Career Hub"). Sharing the dashboard URL on social media then leaked the CV score in the link preview. Fixed by gating `inject_report_meta()` on `is_singular()` + `has_shortcode($post->post_content, 'ssc_cv_report')` so OG injection only fires on the actual report page. Also drops a needless DB query on every other page that happened to carry those params.
* Public CV PDF share view counter no longer inflates on reloads. `cv_share()` was inserting a report_views row AND bumping `share_pdf_views` on every page load, so a recruiter hitting refresh 5 times turned 1 real view into a 5-count the candidate saw on /my-cvs/. Now matches `record_report_view()` behaviour — counter bumps only on the first visit per viewer_hash, repeat visits still log a row for analytics but don't double-count.
* `delete_cv` endpoint is now rate-limited (20/hour per user-IP) — every other state-changing endpoint (job_action, bulk_job_action, toggle_cv_share, pipeline_update) already had this. Each call does a filesystem unlink + DB write, so an authed user could previously hammer it with prepared nonces.

V1.8.5 — Horizontal filter layout + universal hero slim:
* Jobs filter form rewritten from a single-column vertical stack (state → cities → role → training, each on its own row) to a 2-column grid on desktop (state + cities on row 1, role + training on row 2). Search/Clear buttons span both columns. Single-column collapse below 720px. Cuts filter section height roughly in half on desktop.
* CV report hero slimmed. Was inheriting the global .ssc-ch h2 rule at clamp(32px, 5vw, 58px) — a 58px screamer on a transactional results page. Now clamp(22px, 2.8vw, 30px) so the big score dial on the right stays the magnetic centrepiece, with the headline as supporting copy.
* CV optimizer hero brought down from clamp(26px, 3.4vw, 38px) to clamp(24px, 3vw, 32px) — matches the universal 22-32px workspace size used by Dashboard, Jobs, My CVs, and My Credits. Every member page now reads with the same calm-on-the-eyes workspace typography instead of three different sizes across three different pages.

V1.8.4 — Tag-pill multi-select replaces verbose chip-grid:
* City and Training filters on the Jobs page rewritten from a vertical chip-grid (~360px tall when open with 34 cities + 12 training options) to a single tag-pill multi-select input (~52px when empty, +28px per row of selected pills). User types or picks from a native <datalist> autocomplete; each pick becomes a removable blue pill above the search box. Backspace from empty input removes the last pill (Linear/Wise pattern). Cleaner space usage and faster scan-to-search flow.
* State filter narrows the datalist. Pick "NY" and the autocomplete only suggests NY cities/training; pick "NJ" and only NJ; leave blank for both.
* Defensive validation: only values present in the datalist are accepted as tags. Prevents typos from polluting the filter query and stops users from injecting arbitrary city strings via a forged form. Duplicate-add is silently ignored.
* Form submission unchanged — hidden <input name="city[]"> and <input name="training[]"> still carry selections to the server, so all the existing job-filter logic and `Clear` button work as-is. Clear now also tears down the pill list.
* Kept all 34 cities and all 12 training options — wider catalog means smaller markets (Niagara Falls, Princeton, etc.) aren't locked out. Real-estate cost stays low because users only see what they've picked.

V1.8.3 — admin polish + Jobs hero slim + visual hardening:
* Suppressed 3rd-party admin_notices output on our plugin pages. TGM Plugin Activation prompts ("Activate eForm / Contact Form 7 / MailChimp / Safe SVG"), WPBakery anniversary banners, and similar plugin-install nags were rendering inside the SSC Career Hub admin pages and visually crashing into the dark gradient hero. Hooked into current_screen → if the screen ID contains 'ssc-career-hub', remove_all_actions on admin_notices/all_admin_notices/user_admin_notices/network_admin_notices. Our own inline <div class="notice"> messages survive because they are emitted directly inside each page method (not via the hook).
* Real Dashboard page. Previously the SSC Career Hub top-level menu page was an almost-empty single hero box, which left enough whitespace below it for injected 3rd-party banners to fill in and clash. Now the Dashboard page renders a full overview: hero with 4 live KPIs (active jobs, CV reviews, credits outstanding, awaiting review), 6 status cards (review queue with warn/good tinting, applied, apply clicks, paid credits, credits spent, OpenRouter on/off), and a 6-card "Jump to" quick-link grid linking to CV Reviews, Jobs, Import Jobs, Credit Analytics, Settings, and Abuse Events.
* Jobs page hero slimmed. Old verbose 3-line copy + 4-chip pathway aside was overwhelming the search use case. Replaced with one-line "Security jobs · NY DCJS + NJ SORA" headline + a single subhead, keeping the 3-stat aside (active matches, NY/DCJS, NJ/SORA). Faster scan-to-filter time for returning candidates.
* Admin hero text-color hardening. All white-text rules inside .ssc-ch-admin-hero (h1, p, a, KPI labels, KPI numbers) marked !important so WordPress admin theme overrides or 3rd-party plugins injecting color rules cannot make text invisible inside the gradient. Hex background (#0f4cbd) stamped first so a CSS-var failure or gradient-unsupported renderer still gets a solid blue background instead of a transparent box. Same defensive pattern as v1.7.1's frontend account-nav fix.

V1.8.2 — 20-scenario self-audit hotfix:
* CV share template's inline view-tracking beacon was reading window.SSC_CH.ajaxUrl, which is set by the localized ssc-ch-frontend script loaded in the footer. The inline script could execute first → ajaxUrl undefined → beacon silently broken → owner sees no view counts on shared CVs. Fixed by passing admin_url('admin-ajax.php') directly via PHP echo. Now view tracking on cv-share is independent of script load order.
* cv_share() now verifies the uploaded PDF file actually exists on disk BEFORE generating the iframe URL. Previously, if the file had been removed (filesystem cleanup, manual deletion, etc.), the iframe would load /admin-post.php that returns 404 — recruiter saw a broken page. Now we show a clear "CV file missing — ask the candidate to re-upload" message at the template level.
* toggle_cv_share for share_mode='both' now requires at least ONE source (external URL OR uploaded PDF). Previously, enabling share with mode=both and neither source would succeed in the DB but the share page would just say "not available" — misleading. New 'need_source' error code with a clear message on /my-cvs/: "You need either an HTTPS link to a hosted PDF OR an uploaded PDF on file before you can enable share."
* Documented 20 NEW scenario checks in the handoff doc (cumulative with the previous 20 = 40 audited what-if cases). Real bugs found and fixed inline; soft gaps documented as known limitations.

V1.8.1 — honest manual review + PDF deep security + self-audit fixes:
* Manual-review queue is now an explicit admin opt-in (Settings → Enable manual review queue). DEFAULT OFF. The plugin no longer silently promises "we'll email you when ready" when there's no human reviewer doing the work. When OFF and an uploaded PDF/DOCX can't be auto-read, the candidate gets a clear inline error: "We couldn't auto-read your file. Please paste your CV text below for an instant score." The orphan file is cleaned up; the failure is logged to abuse_events for admin diagnostics.
* When manual review IS enabled, admin gets an email per queued submission (with the candidate's name, email, CV reference number, and a direct link to the CV Reviews admin page). Notification email configurable in Settings — falls back to wp_get_option('admin_email') if blank.
* Deep PDF active-content security scan. New SSC_CH_File_Validator::is_safe_pdf() scans the entire uploaded PDF (capped at 8MB) for /JavaScript, /JS, /Launch, /SubmitForm, /EmbeddedFile, /RichMedia, /XFA markers — the PDF malware vectors. Conservative by design: any hit rejects with new 'pdf_active_content_rejected' error code. User is told to re-export as a plain PDF.
* Dashboard "Recent CVs" pending-state visual fixed. Was "—queued" stacked awkwardly; now a clean amber-tinted tile with ⏳ glyph and "Awaiting" label.
* Share-error banner on /my-cvs/. Toggle-handler errors (rate limit, need URL, need upload) now display as a clear red banner above the CV list instead of vanishing silently.
* New cv-form error messages for the new validator rejections (pdf_signature_failed, docx_signature_failed, active_content_rejected, pdf_active_content_rejected, file_too_large) — all give the user a concrete next step instead of a generic "upload failed".

V1.8.0 — public CV PDF share:
* New [ssc_cv_share] shortcode — public, token-shareable page where recruiters view a candidate's CV PDF. Sticky top bar carries candidate name, target role, NY-DCJS / NJ-SORA track, location, score badge (if scored), one-click Contact button (mailto with prefilled subject), copy-link, and breakdown-link. PDF rendered in a generous viewport iframe below.
* Per-CV opt-in toggle on /my-cvs/ — candidate chooses which CV(s) to make public. Off by default. View counter visible to candidate.
* Admin share_mode global setting (Settings page): url_only (recommended — candidate hosts on Drive/Dropbox/OneDrive, less SSC liability) / upload_only (SSC hosts) / both / off.
* Practical hosting tutorial collapsible in the candidate's share-toggle UI: exact Drive /view → /preview swap, Dropbox ?dl=0 → ?raw=1 swap, and OneDrive embed-URL extraction.
* Security: External URL sanitiser rejects non-HTTPS, javascript:/data: schemes, URLs with embedded credentials (parse_url rebuild strips them), and URLs > 500 chars. Local PDF serve endpoint validates token via hash_equals, basename()s the stored filename (no path traversal), magic-byte check on every serve (rejects anything not starting with %PDF-), responds with Content-Type: application/pdf + X-Content-Type-Options: nosniff + inline Content-Disposition. External-URL iframe gets sandbox="allow-scripts allow-same-origin" (no popups, no top-nav escape). Toggle handler rate-limited at 15/hour per user.
* Settings-conflict warning surfaced to candidate when admin switches share_mode after they've enabled sharing in the opposite mode — tells them exactly what to fix (e.g. "Site is set to URL-only sharing — paste an external URL").
* Engagement tracking reused from [ssc_cv_report] beacon (visibilitychange + pagehide → sendBeacon with cv_id + token + seconds + scroll%). New view_type='cv_pdf' column on ssc_ch_report_views so CV-share views are separable from score-report views.
* New DB columns on ssc_ch_cv_reviews: public_share_enabled (default 0), external_pdf_url (max 500 chars), share_pdf_views. New column on ssc_ch_report_views: view_type (default 'report'). dbDelta auto-applies via maybe_upgrade on init priority 1.
* Auto-create /cv-share/ page on activation/upgrade (joins the existing 6 auto-pages).

V1.7.0 — closing deferred gaps:
* Per-CV view-count column on admin CV Reviews. Total views + unique viewers + last-view timestamp per CV, rendered in a new column. Single batch query (COUNT, COUNT DISTINCT, MAX created_at GROUP BY cv_id) avoids N+1 on the admin row render.
* CV resubmit / version flow. "Improve this CV →" button on Dashboard Recent CVs cards and on [ssc_my_cvs] cards. Clicking it goes to /cv-optimizer/?from=N. Form prefills cv_text, state, training, target_role, city from the user's previous CV (verified via wp_user_id match — no cross-user leakage). Hidden parent_cv_id field passed through upload handler → stored in new cv_reviews.parent_cv_id column. Yellow "Improving CV-#NNNN · previous score X/100" banner at top of form.
* Mobile bottom tab bar. Below 720px the sticky-top account nav switches to a fixed-bottom 5-tab bar with icon glyphs (⌂ Home, ▤ Jobs, + Submit, ☰ CVs, ★ Credits) and short labels. Active tab gets a pill-style background highlight. Spotify / Wise / Robinhood pattern. Body padding scoped via body:has(.ssc-ch-account-nav) so non-plugin pages aren't affected.
* Cover-letter LLM generator (admin-only V1). New SSC_CH_OpenRouter::generate_cover_letter() method follows the same prompt-injection-safe pattern as the CV narrative (fixed system role, ===CV BEGINS=== / ===JOB DESCRIPTION BEGINS=== markers, 200-260 word cap, no AI self-disclosure). Admin opens a <details> panel on the CV Reviews row, pastes the job description (min 80 chars), clicks Generate. Result stored in new cv_reviews.cover_letter_text / cover_letter_at columns. Admin reviews + manually shares with candidate. Cost logged to ai_usage with :cover_letter model suffix for separate tracking.
* New DB columns: cover_letter_text MEDIUMTEXT, cover_letter_at DATETIME, parent_cv_id BIGINT UNSIGNED. dbDelta auto-applies via maybe_upgrade on init priority 1 (no deactivate/reactivate needed).

V1.6.1 — final sanity-pass hotfix on top of 1.6.0:
* maybe_upgrade now runs on the `init` hook (priority 1) instead of `admin_init`. The previous wiring only triggered schema auto-upgrade when an admin visited wp-admin — so a public user submitting a CV after a plugin upload but before any admin pageload would hit the stale-schema cv=0 bug again. Now schema check fires on every request (one cached get_option + version_compare; cost is negligible). Closes a real race-window hole in 1.4.9's self-healing install.
* SSC_CH_DB::user_application_pipeline() SQL fixed for MySQL 5.7+ strict mode. The previous query had MAX(a.created_at) as an aggregate alongside non-aggregated j.title, j.company, etc., with GROUP BY j.id, a.activity_type — which ONLY_FULL_GROUP_BY (default in modern MySQL/MariaDB) rejects, returning an empty pipeline silently. The inner subquery already constrains the result to exactly one activity row per job, so the outer MAX() and GROUP BY were both redundant. Now uses a.created_at directly with no outer GROUP BY. Pipeline kanban will populate correctly on production MySQL.

V1.6.0 — page-by-page parity pass:
* FC-deck-tracker parity on the CV report. Owner-only "Who's viewed your report" panel: total views, unique viewers, last viewed (relative time), average time-on-page, deepest scroll, plus a collapsible "Recent views" list (date, viewer hash short, referrer host, seconds, scroll percent). A JavaScript beacon on the report page tracks time-on-page + max scroll depth and fires once on visibilitychange='hidden' / pagehide via navigator.sendBeacon (fallback fetch keepalive). Engagement is appended to the existing ssc_ch_report_views row matched by cv_id + viewer_hash. Token verified server-side with hash_equals before any write.
* New ajax endpoint: ssc_ch_track_report_engagement (logged-in + anonymous variants). Token check, length validation, hash_equals, sanitised payload (max 7200s, max 100% scroll).
* Print-friendly CV report. New @media print stylesheet hides nav, share box, engagement panel, WP admin bar; preserves score dial + dimension bars via print-color-adjust; appends URL after each link except in-app navigation. "Print / Save as PDF" button on the owner-facing share row uses window.print(). Recipients (employers, mentors) can save a clean PDF copy of the report without any extra plugin.
* Application pipeline on the Dashboard. New Trello/Linear-style kanban board with 6 columns: Saved, Applied, Heard back, Interview, Offer, Hired. Each saved job appears as a card in its current stage; user changes stage from a per-card dropdown which auto-submits via admin_post (nonce + rate-limit protected, 30 transitions/hour per user). New activity_type values added to record_activity allowlist: heard_back, interview, offer, rejected, hired, withdrew. New SSC_CH_DB::user_application_pipeline() returns the latest stage per (user, job) pair joined to the jobs table.
* User-facing credit ledger on /my-credits/. Shows last 25 credit transactions in a clean table (date, activity type, +/- credits with tone colour, note). Friendly labels for purchase / spend / admin_adjustment / refund. Empty state guides first-time users to top up.
* Copy share-link button on /my-cvs/ cards (parity with the Dashboard Recent CVs section).
* "Email intro" button on every job card. Generates a polished pre-written intro (user's first name, target role, company, location, NY/NJ track, available start date) and opens the user's mail client via mailto:. Detects whether apply_url is a mailto: (uses that recipient) or a regular URL (recipient blank so user can paste into the employer's form).
* New SSC_CH_DB helpers: report_engagement(), update_report_engagement(), user_credit_ledger(), user_application_pipeline().

V1.5.1 friction reducers + magnetic UX:
* CV form now prefills name, email, phone, and city from the logged-in WP user profile (first/last name → name, user_email → email, billing_phone + billing_city meta if WooCommerce). A small green note tells the user "Prefilled from your account — edit if you want different details". Removes 4 fields of friction for returning members.
* Personalized greeting on Dashboard. "Good morning, Edward" / "Good afternoon, Edward" / "Good evening, Edward" based on time of day. H2 also flips from generic "My Security Career Dashboard" to "Edward's career dashboard" when first name is known.
* Smart "Next move" card on Dashboard. Single CTA that adapts to where the user is in their journey:
  - No CV yet → "Submit your first CV" → /cv-optimizer/
  - Has a scored CV but score < 65 → "Push your score to 80+" → opens their latest report
  - Has CV but no saved jobs → "Save 3 realistic jobs" → /jobs/
  - Has saves but no applies → "Apply to 1 today" → /jobs/
  - Active user → "Refresh your CV monthly" → /cv-optimizer/
  Bold gradient banner with a radial glow accent, single white pill CTA. Never a dead end — always one specific thing to do next.
* Smooth hover micro-polish applied to every card across pages (recent CVs, side cards, submission tiles, top-up cards, quick actions). Unified .15s ease transition so the whole UI feels cohesive when the user moves through it.

V1.5.0 unified account hub + flow consolidation:
* CV submission now redirects to the Dashboard (not back to the CV form). After submit, the user lands on /my-dashboard/?submitted=1&cv=N&t=TOKEN[&score=N] with the same status panel (icon, reference number, timeline, primary actions). This answers "what happens after submit" — the user is on their account hub with the score front-and-centre, not stranded on the form page.
* Dashboard catches the submission params at the top and renders the status panel above the rest of the dashboard content. Stats, score trend, recent CVs, and saved jobs remain below — so the user sees their result AND their broader account state in one place.
* Account nav is now sticky at the top of the viewport across every member page. Sits below the WP admin bar (32px or 46px on small screens). User always knows where they are and can jump to any section without scrolling back up.
* Dashboard hero dialed down from the full marketing treatment to a workspace-sized header (clamp(24px,3vw,34px) instead of clamp(32px,5vw,58px), tighter padding, smaller reminder note). The dashboard is a transactional hub, not a landing page — content should dominate, not the hero.

V1.4.9 self-healing install + cv=0 hotfix:
* CRITICAL FIX — Share-link URLs were being generated with cv=0 when CV inserts silently failed. Root cause: users uploading a new zip via WP Plugins → Add New → Replace, without deactivating+reactivating the plugin, leave dbDelta stale; new columns added in 1.4.x (candidate_feedback, ai_narrative, ai_narrative_at) never get created, so $wpdb->insert returned 0 and the redirect URL contained ?cv=0. The user then clicked the email link and saw "page not found" (or a theme redirect to home).
* SSC_CH_Activator::maybe_upgrade() now runs on every admin_init. Compares the plugin's compiled SSC_CH_DB_VERSION constant against the stored ssc_ch_db_version option; on mismatch, re-runs the full activator (dbDelta picks up new columns idempotently). The activator now uses update_option for the version stamp instead of add_option so the value actually changes on subsequent runs.
* SSC_CH_Activator::ensure_pages() auto-creates the 6 required WordPress pages on activation if they don't already exist: /jobs/, /cv-optimizer/, /cv-report/, /my-dashboard/, /my-cvs/, /my-credits/. Each gets its matching shortcode as content. Resulting page URLs are stamped into ssc_ch_settings so the account nav + cv_report_url always resolve. Safe to re-run (checks by slug first).
* Upload handler now refuses to send a broken URL. If SSC_CH_DB::insert_cv returns 0, it logs the wpdb->last_error to abuse_events and redirects with a new save_failed error code instead of building cv=0. The cv-form template renders a clear "we could not save your CV; admin has been notified — please try again" message.

V1.4.8 post-submission flow + analyzing UX:
* Redesigned the post-submission state from a small text card into a proper transactional confirmation panel (Stripe / Linear / Wise pattern). Shows: tone-coded status icon (green check for scored, amber clock for queued), big headline, SSC-CV-#NNNN reference number, "saved for [email]" account context, 3-step visual timeline (Saved → Reviewed → Report ready) with pulsing active dot, primary actions (Open report / Copy share link), and 4 next-step tiles (My CVs / Dashboard / Browse jobs / Submit another).
* Form is now hidden when the page renders in submission state — the user knows the action completed and is given clear next moves instead of an empty form. "Submit another" tile reloads the bare URL to bring the form back.
* New "Scored by" badge on every result: blue "Auto-scored by SSC readiness engine" for free local submissions, red "Local engine score · AI review running" when credits-spent priority review fires the OpenRouter cron, amber "Queued for human reviewer" for manual-review fallback. Users always know what evaluated their CV.
* When AI review isn't active (no key configured OR no credits spent), the success panel now shows an explicit upgrade prompt with a direct top-up link, so users know AI review is an option they can unlock.
* New full-page "Analyzing your CV…" overlay during form submission — animated three-dot pulse, cycling status messages ("Reading your submission / Extracting key signals / Scoring readiness / Saving to your account"), backdrop blur. Pure UX feedback (no AJAX); disappears naturally when the browser navigates to the success page.
* CV Optimizer hero h2 dialed down from clamp(32px,5vw,58px) to clamp(26px,3.4vw,38px) — the page is transactional, not marketing, so a 58px header was overwhelming.

V1.4.7 logic + navigation pass:
* Fixed success-card after CV submission. The v1.4.3 guard required score>0 to show the "scored" card; a legitimate 0/100 was falling through to the "manual review" message and confusing users into thinking their CV wasn't saved. New guard tests for the presence of ?cv= and ?t= in the URL (which only the upload handler sets when scoring actually happened). All branches now explicitly say "saved to your account" so the user knows the data persisted regardless of which message displays.
* Fixed CV analyzer keyword matching. The v1.4.0 tight word-boundary regex required BOTH sides of a keyword to be non-alphanumeric, so "guards" (plural), "patrolling", "licensed", "supervisors" all failed to match — artificially deflating scores. New regex requires only the start-of-word boundary; matches morphological variants but still blocks substring false positives like "rearguard" matching "guard".
* Cities + Training filters now grouped by state inside their collapsible panels — "New York" sub-section above "New Jersey" sub-section, with small uppercase headers. State filter hides the entire opposite-state sub-section (previously it only hid individual chips, leaving an orphaned section title).
* New account-nav tab bar (Dashboard / Jobs / Submit CV / My CVs / Credits) rendered at the top of every member shortcode. One consistent place to navigate; current page gets a solid SSC-blue pill. Horizontally scrollable on mobile.
* Dashboard upgraded to a real account hub:
  - Quick Actions row: "Submit a new CV" (primary gradient), "Browse jobs", "Top up credits" — clickable tiles with icon + title + subtitle.
  - New "Recent CVs" cards grid below the score trend. Each card shows score with tone-coloured number (green/amber/red), status pill, role, date, plus [View report] and [Copy link] buttons. Pending/manual-review CVs show an "Awaiting manual review" note.
* New generic data-ssc-copy-link clipboard handler. Used by Recent-CVs cards and the post-submission success card. Native Clipboard API with hidden-textarea fallback; brief "✓ Copied" confirmation flash.

V1.4.6 CV form vertical stretch fix:
* The form card was rendering ~1200px tall with only ~370px of content, producing huge dead space between the wizard chips, the step heading, the fields, and the Continue button. Root cause: the parent .ssc-ch-cv-shell is a 2-column grid (form + aside) with default align-items:stretch, so the form was being stretched to match the taller aside (credit balance + 4 top-up cards). The form is itself display:grid, so it then distributed the extra height across its internal rows — pushing chips, legend, fields, and nav far apart.
* Fix: align-items:start on the parent shell (form sizes to its own content), plus align-self:start + align-content:start on the form (defensive, pack rows at top regardless of container height). Both gaps collapse to the intended 14px row gap.

V1.4.5 dashboard hero + KPI layout fix:
* The dashboard hero was rendering "My Security Career Dashboard" as four stacked words ("My / Security / Career / Dashboard") at giant size — root cause: the hero is a 2-column grid (minmax(0,1fr) 320px) but the template loose-leafed three direct children (kicker, h2, reminder p), so the h2 landed in the 320px-wide column and word-wrapped.
* Restructured dashboard.php to match the Jobs hero pattern — content wrapped in a <div> + new <aside class="ssc-ch-hero-card"> that holds the stats (tracked / applied / CV reviews / credits) and the pathway chips. Now they live INSIDE the dark hero where the existing white-on-dark styling actually works.
* Added defensive CSS: any hero without an aside/hero-card child collapses to a single column (fixes [ssc_my_credits], [ssc_my_cvs], and the logged-out dashboard, all of which have no aside).
* Forced kicker pill to sizeof-content with higher specificity in case a parent theme applies "p { width: 100% }".
* Saved/tracked jobs table got proper styling — uppercase muted column headers, hover state, status pill in SSC blue, "View" link in SSC blue instead of inheriting the theme's anchor red.
* Stats grid switched to 2x2 inside the hero aside so the 4th stat doesn't orphan.

V1.4.4 jobs-page filter strip visual fix:
* Filter labels (State track / Role type / Training) were rendering as white-on-white because the v1.4.0 chip styles assumed the filter strip was on the dark hero gradient (legacy 1.3.x layout). Rewrote the entire chip/label/collapse block for the actual light background — labels are now uppercase muted ink, chips are blue-ink-on-white pills with solid SSC blue when active, collapsible City/Training panels have a clean +/− toggle.
* Broke the legacy 6-column grid (.ssc-ch-filters at line 425) for chip-mode forms — the chip form is now a clean vertical flex column so State / City / Role / Training / Search-Clear stack naturally instead of sprawling across the row.
* Search/Clear actions row has a top border + proper button styling so it reads as the form's commit zone.
* Improved focus state on inputs/selects (3px blue outline + ink border).

V1.4.3 spacing + UX patch on the CV Optimizer form:
* Fixed ~150px of dead vertical space inside the wizard fieldset by resetting Chrome's default fieldset min-inline-size and the legend's implicit top anchoring. Wizard steps now sit flush below the progress chips.
* Tightened wizard progress chips bottom spacing (was 22+24+border, now 14+16+border).
* Empty submit-row container no longer takes vertical space on non-last wizard steps (hidden via data-wizard-last="0").
* Success card no longer renders "Your CV scored 0/100" when the URL has ?submitted=1 without a real score. Falls through to a generic "received" message unless cv, t, and a positive score are all present.

V1.4.2 adds secure OpenRouter integration:
* New SSC_CH_OpenRouter helper. The API key is read ONLY from a wp-config.php constant (SSC_CH_OPENROUTER_KEY) — never from wp_options, so it does not appear in DB backups (JetBackup → Google Drive in this hosting setup). Optional SSC_CH_OPENROUTER_MODEL constant overrides the default model (anthropic/claude-haiku-4.5). Optional SSC_CH_OPENROUTER_HOST for self-hosted gateways.
* When a candidate spends credits for an ultimate CV review (status → priority_review), a WP-cron event fires SSC_CH_OpenRouter::run_for_cv() which calls OpenRouter, stores the draft narrative in cv_reviews.ai_narrative, and logs token usage + cost in ssc_ch_ai_usage.
* Admin gate: the AI draft is NEVER auto-published. It appears as an admin-only details panel on the CV Reviews row with a "Use this as candidate feedback" button that copies the draft into the candidate_feedback textarea. The admin reviews, edits, and saves before anything reaches the candidate.
* Per-call cost cap (openrouter_cost_cap_usd setting) is enforced as a 100× daily ceiling. If today's cumulative AI cost exceeds the ceiling, requests are skipped and logged to abuse_events.
* Settings page shows "Configured via wp-config.php (…last4)" or "Not configured" — the key value itself is never echoed to the page.
* Prompt-injection defence: CV text is wrapped in explicit ===CV BEGINS=== / ===CV ENDS=== markers, system prompt fixes the role, output is capped at 600 tokens, and the model is instructed not to mention being AI.
* New columns on ssc_ch_cv_reviews: ai_narrative, ai_narrative_at. Existing CVs are unaffected.
* If the constant is absent, the plugin degrades to the prior behaviour (local heuristic + human-fulfilled priority reviews). No errors, no half-broken state.

V1.4.1 sanity-check pass on top of 1.4.0:
* CV form wizard is now progressive enhancement — without JS the form renders as a single page with a working submit button; with JS the multi-step nav + per-step validation kicks in.
* [ssc_my_cvs] cards now expose a "View report" button per scored CV (was missing the link in v1.4.0).
* Candidate confirmation email goes out automatically after CV submission, with the shareable report link and clear NY/NJ disclaimer.
* New candidate_feedback field on CV reviews — admins can write public-facing reviewer notes (rendered on the CV report) separately from internal admin_notes, and can tick "Email the candidate" to push the update.
* Candidate dashboard now includes a "CV score trend" bar chart of the last 5 reviews, with score-delta vs previous and a one-click link to the latest report.
* Chart.js admin enqueue keeps the CDN dependency but the placeholder SRI was removed (a wrong digest would brick the charts); doc-comment explains how to pin the real hash post-install.
* New DB columns on ssc_ch_cv_reviews: candidate_feedback, candidate_notified_at.

V1.4.0 was a major design + capability pass:
* Shareable token-based CV report at [ssc_cv_report] — full score breakdown, six dimension bars, strengths, next-step checklist, recommendation CTA, score-over-time delta, and OG/Twitter share-card metadata.
* Pure-PHP PDF and DOCX text extraction (no Composer deps, no shelling out) — text-based uploads now get scored automatically; only scanned/encrypted PDFs fall through to manual review.
* UTM/source attribution capture (cookie-based, sendBeacon ingest) joins to CV submissions and credit purchases, surfacing a "where candidates come from" widget on the analytics page.
* Admin analytics rebuilt: date-range filter with 7/30/90/all presets, Chart.js daily activity + credits-purchased charts, conversion funnel (saves → applied → CV → credits), CSV export of the credit ledger, and tone-coded ledger rows.
* Mobile CV form is now a 3-step wizard with progress chips and per-step validation.
* Mobile jobs filter swaps native multi-select for tap-friendly chip groups in collapsible details panels.
* Bug fixes: word-boundary keyword matching in the CV analyzer (no more "patrol" inside "patrol-style"), removed all three unused legacy CSS files, dropped legacy 0000-00-00 deleted_at fallbacks, hardened error reflection on the CV form.
* New DB tables: ssc_ch_attribution (UTM/referrer/device per session) and ssc_ch_report_views (CV report engagement). New columns on ssc_ch_cv_reviews: share_token, share_views, share_first_viewed_at, extraction_source, attribution_id. Existing CVs are backfilled with share tokens on upgrade.

V1.3.4 added admin manual credit adjustments, a fuller analytics control room, recent credit ledger visibility, CV status breakdowns, and stronger candidate dashboard stats. V1.3.3 tightened the CV/credit flow review: empty submissions are blocked, file-only CVs are queued for manual review, priority credit spend uses the logged-in account identity. V1.3.2 preserved manually uploaded WooCommerce product images. V1.3.1 fixed WooCommerce variation checkout links. V1.3.0 added credit top-ups, My Credits/My CVs shortcodes, CV scoring, safer PDF/DOCX/TXT uploads, credit analytics, and API-cost tracking.

== Description ==
SSC Career Hub provides shortcodes:
* [ssc_jobs] — searchable job directory from local DB synced from CSV/published Google Sheet.
* [ssc_job_dashboard] — simple seeker dashboard for saved/applied activity.
* [ssc_cv_optimizer] — three-step CV intake wizard with readiness score and admin review.
* [ssc_cv_report] — public token-shareable CV readiness report (place on its own page, e.g. /cv-report/).
* [ssc_my_credits] — logged-in candidate credit balance and smooth top-up cards.
* [ssc_my_cvs] — logged-in candidate CV review tracker.

Important: SSC provides training and career preparation support. Job listings are informational and employment is not guaranteed. New York DCJS training and New Jersey SORA are distinct requirements.

== Installation ==
Upload/activate the plugin, place the shortcodes on pages, configure CSV URL under SSC Career Hub > Sync Jobs, then run Manual Sync.

== CSV Columns ==
Supports common headers: id, title/job_title, company/employer, city/location, state, job_type/type, pay/pay_text, schedule, training_requirement/training, tags, summary/description, requirements, apply_url/url, source_name, source_url, verified_at, expires_at.

== Security ==
Uses nonces/capabilities, fixed file allowlist (txt/pdf/docx only; legacy .doc is blocked), private upload folder protection, hashed IP/email for rate/abuse events, formula-safe CSV helper, and WooCommerce buyer detection only when WooCommerce functions exist.
