feat: public demo experience (Vue SPA with demo mode) #103
21 changed files with 942 additions and 32 deletions
|
|
@ -15,19 +15,21 @@
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|
||||||
app:
|
api:
|
||||||
build: .
|
build: .
|
||||||
ports:
|
command: >
|
||||||
- "8504:8501"
|
bash -c "uvicorn dev_api:app --host 0.0.0.0 --port 8601"
|
||||||
volumes:
|
volumes:
|
||||||
- ./demo/config:/app/config
|
- ./demo/config:/app/config
|
||||||
- ./demo/data:/app/data
|
- ./demo:/app/demo:ro # seed.sql lives here; read-only
|
||||||
# No /docs mount — demo has no personal documents
|
# /app/data is tmpfs — ephemeral, resets on every container start
|
||||||
|
tmpfs:
|
||||||
|
- /app/data
|
||||||
environment:
|
environment:
|
||||||
- DEMO_MODE=true
|
- DEMO_MODE=true
|
||||||
- STAGING_DB=/app/data/staging.db
|
- STAGING_DB=/app/data/staging.db
|
||||||
|
- DEMO_SEED_FILE=/app/demo/seed.sql
|
||||||
- DOCS_DIR=/tmp/demo-docs
|
- DOCS_DIR=/tmp/demo-docs
|
||||||
- STREAMLIT_SERVER_BASE_URL_PATH=peregrine
|
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
- PYTHONLOGGING=WARNING
|
- PYTHONLOGGING=WARNING
|
||||||
# No API keys — inference is blocked by DEMO_MODE before any key is needed
|
# No API keys — inference is blocked by DEMO_MODE before any key is needed
|
||||||
|
|
@ -37,6 +39,7 @@ services:
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
# No host port — nginx proxies /api/ → api:8601 internally
|
||||||
|
|
||||||
web:
|
web:
|
||||||
build:
|
build:
|
||||||
|
|
@ -45,7 +48,9 @@ services:
|
||||||
args:
|
args:
|
||||||
VITE_BASE_PATH: /peregrine/
|
VITE_BASE_PATH: /peregrine/
|
||||||
ports:
|
ports:
|
||||||
- "8507:80"
|
- "8504:80" # demo.circuitforge.tech/peregrine* → host:8504
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
searxng:
|
searxng:
|
||||||
|
|
|
||||||
54
demo/seed.sql
Normal file
54
demo/seed.sql
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
-- jobs
|
||||||
|
INSERT INTO jobs (title, company, url, source, location, is_remote, salary, match_score, status, date_found, date_posted, cover_letter, applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, interview_date, rejection_stage, hired_feedback) VALUES ('UX Designer', 'Spotify', 'https://www.linkedin.com/jobs/view/1000001', 'linkedin', 'Remote', '1', '$110k–$140k', '94.0', 'approved', '2026-04-14', '2026-04-12', 'Dear Hiring Manager,
|
||||||
|
|
||||||
|
I''m excited to apply for the UX Designer role at Spotify. With five years of
|
||||||
|
experience designing for music discovery and cross-platform experiences, I''ve
|
||||||
|
consistently shipped features that make complex audio content feel effortless to
|
||||||
|
navigate. At my last role I led a redesign of the playlist creation flow that
|
||||||
|
reduced drop-off by 31%.
|
||||||
|
|
||||||
|
Spotify''s commitment to artist and listener discovery — and its recent push into
|
||||||
|
audiobooks and podcast tooling — aligns directly with the kind of cross-format
|
||||||
|
design challenges I''m most energised by.
|
||||||
|
|
||||||
|
I''d love to bring that focus to your product design team.
|
||||||
|
|
||||||
|
Warm regards,
|
||||||
|
[Your name]
|
||||||
|
', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO jobs (title, company, url, source, location, is_remote, salary, match_score, status, date_found, date_posted, cover_letter, applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, interview_date, rejection_stage, hired_feedback) VALUES ('Product Designer', 'Duolingo', 'https://www.linkedin.com/jobs/view/1000002', 'linkedin', 'Pittsburgh, PA', '0', '$95k–$120k', '87.0', 'approved', '2026-04-13', '2026-04-10', 'Draft in progress — cover letter generating…', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO jobs (title, company, url, source, location, is_remote, salary, match_score, status, date_found, date_posted, cover_letter, applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, interview_date, rejection_stage, hired_feedback) VALUES ('UX Lead', 'NPR', 'https://www.indeed.com/viewjob?jk=1000003', 'indeed', 'Washington, DC', '1', '$120k–$150k', '81.0', 'approved', '2026-04-12', '2026-04-08', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO jobs (title, company, url, source, location, is_remote, salary, match_score, status, date_found, date_posted, cover_letter, applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, interview_date, rejection_stage, hired_feedback) VALUES ('Senior UX Designer', 'Mozilla', 'https://www.linkedin.com/jobs/view/1000004', 'linkedin', 'Remote', '1', '$105k–$130k', '81.0', 'pending', '2026-04-13', '2026-03-12', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO jobs (title, company, url, source, location, is_remote, salary, match_score, status, date_found, date_posted, cover_letter, applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, interview_date, rejection_stage, hired_feedback) VALUES ('Interaction Designer', 'Figma', 'https://www.indeed.com/viewjob?jk=1000005', 'indeed', 'San Francisco, CA', '1', '$115k–$145k', '78.0', 'pending', '2026-04-11', '2026-04-09', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO jobs (title, company, url, source, location, is_remote, salary, match_score, status, date_found, date_posted, cover_letter, applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, interview_date, rejection_stage, hired_feedback) VALUES ('Product Designer II', 'Notion', 'https://www.linkedin.com/jobs/view/1000006', 'linkedin', 'Remote', '1', '$100k–$130k', '76.0', 'pending', '2026-04-10', '2026-04-07', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO jobs (title, company, url, source, location, is_remote, salary, match_score, status, date_found, date_posted, cover_letter, applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, interview_date, rejection_stage, hired_feedback) VALUES ('UX Designer', 'Stripe', 'https://www.linkedin.com/jobs/view/1000007', 'linkedin', 'Remote', '1', '$120k–$150k', '74.0', 'pending', '2026-04-09', '2026-04-06', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO jobs (title, company, url, source, location, is_remote, salary, match_score, status, date_found, date_posted, cover_letter, applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, interview_date, rejection_stage, hired_feedback) VALUES ('UI/UX Designer', 'Canva', 'https://www.indeed.com/viewjob?jk=1000008', 'indeed', 'Remote', '1', '$90k–$115k', '72.0', 'pending', '2026-04-08', '2026-04-05', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO jobs (title, company, url, source, location, is_remote, salary, match_score, status, date_found, date_posted, cover_letter, applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, interview_date, rejection_stage, hired_feedback) VALUES ('Senior Product Designer', 'Asana', 'https://www.linkedin.com/jobs/view/1000009', 'linkedin', 'San Francisco, CA', '1', '$125k–$155k', '69.0', 'pending', '2026-04-07', '2026-04-04', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO jobs (title, company, url, source, location, is_remote, salary, match_score, status, date_found, date_posted, cover_letter, applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, interview_date, rejection_stage, hired_feedback) VALUES ('UX Researcher', 'Intercom', 'https://www.indeed.com/viewjob?jk=1000010', 'indeed', 'Remote', '1', '$95k–$120k', '67.0', 'pending', '2026-04-06', '2026-04-03', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO jobs (title, company, url, source, location, is_remote, salary, match_score, status, date_found, date_posted, cover_letter, applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, interview_date, rejection_stage, hired_feedback) VALUES ('Product Designer', 'Linear', 'https://www.linkedin.com/jobs/view/1000011', 'linkedin', 'Remote', '1', '$110k–$135k', '65.0', 'pending', '2026-04-05', '2026-04-02', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO jobs (title, company, url, source, location, is_remote, salary, match_score, status, date_found, date_posted, cover_letter, applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, interview_date, rejection_stage, hired_feedback) VALUES ('UX Designer', 'Loom', 'https://www.indeed.com/viewjob?jk=1000012', 'indeed', 'Remote', '1', '$90k–$110k', '62.0', 'pending', '2026-04-04', '2026-04-01', NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL, NULL);
|
||||||
|
INSERT INTO jobs (title, company, url, source, location, is_remote, salary, match_score, status, date_found, date_posted, cover_letter, applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, interview_date, rejection_stage, hired_feedback) VALUES ('Senior Product Designer', 'Asana', 'https://www.asana.com/jobs/1000013', 'linkedin', 'San Francisco, CA', '1', '$125k–$155k', '91.0', 'phone_screen', '2026-04-01', '2026-03-30', NULL, '2026-04-08', '2026-04-15', NULL, NULL, NULL, '2026-04-15T14:00:00', NULL, NULL);
|
||||||
|
INSERT INTO jobs (title, company, url, source, location, is_remote, salary, match_score, status, date_found, date_posted, cover_letter, applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, interview_date, rejection_stage, hired_feedback) VALUES ('Product Designer', 'Notion', 'https://www.notion.so/jobs/1000014', 'indeed', 'Remote', '1', '$100k–$130k', '88.0', 'interviewing', '2026-03-25', '2026-03-23', NULL, '2026-04-01', '2026-04-05', '2026-04-12', NULL, NULL, '2026-04-22T10:00:00', NULL, NULL);
|
||||||
|
INSERT INTO jobs (title, company, url, source, location, is_remote, salary, match_score, status, date_found, date_posted, cover_letter, applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, interview_date, rejection_stage, hired_feedback) VALUES ('Design Systems Designer', 'Figma', 'https://www.figma.com/jobs/1000015', 'linkedin', 'San Francisco, CA', '1', '$130k–$160k', '96.0', 'hired', '2026-03-01', '2026-02-27', NULL, '2026-03-08', '2026-03-14', '2026-03-21', '2026-04-01', '2026-04-08', NULL, NULL, '{"factors":["clear_scope","great_manager","mission_aligned"],"notes":"Excited about design systems work. Salary met expectations."}');
|
||||||
|
INSERT INTO jobs (title, company, url, source, location, is_remote, salary, match_score, status, date_found, date_posted, cover_letter, applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, interview_date, rejection_stage, hired_feedback) VALUES ('UX Designer', 'Slack', 'https://slack.com/jobs/1000016', 'indeed', 'Remote', '1', '$115k–$140k', '79.0', 'applied', '2026-03-18', '2026-03-16', NULL, '2026-03-28', NULL, NULL, NULL, NULL, NULL, NULL, NULL);
|
||||||
|
|
||||||
|
-- job_contacts
|
||||||
|
INSERT INTO job_contacts (job_id, direction, subject, from_addr, to_addr, received_at, stage_signal) VALUES (1, 'inbound', 'Excited to connect — UX Designer role at Spotify', 'jamie.chen@spotify.com', 'you@example.com', '2026-04-12', 'positive_response');
|
||||||
|
INSERT INTO job_contacts (job_id, direction, subject, from_addr, to_addr, received_at, stage_signal) VALUES (1, 'outbound', 'Re: Excited to connect — UX Designer role at Spotify', 'you@example.com', 'jamie.chen@spotify.com', '2026-04-13', NULL);
|
||||||
|
INSERT INTO job_contacts (job_id, direction, subject, from_addr, to_addr, received_at, stage_signal) VALUES (13, 'inbound', 'Interview Confirmation — Senior Product Designer', 'recruiting@asana.com', 'you@example.com', '2026-04-13', 'interview_scheduled');
|
||||||
|
INSERT INTO job_contacts (job_id, direction, subject, from_addr, to_addr, received_at, stage_signal) VALUES (14, 'inbound', 'Your panel interview is confirmed for Apr 22', 'recruiting@notion.so', 'you@example.com', '2026-04-12', 'interview_scheduled');
|
||||||
|
INSERT INTO job_contacts (job_id, direction, subject, from_addr, to_addr, received_at, stage_signal) VALUES (14, 'inbound', 'Pre-interview prep resources', 'marcus.webb@notion.so', 'you@example.com', '2026-04-13', 'positive_response');
|
||||||
|
INSERT INTO job_contacts (job_id, direction, subject, from_addr, to_addr, received_at, stage_signal) VALUES (15, 'inbound', 'Figma Design Systems — Offer Letter', 'offers@figma.com', 'you@example.com', '2026-04-01', 'offer_received');
|
||||||
|
INSERT INTO job_contacts (job_id, direction, subject, from_addr, to_addr, received_at, stage_signal) VALUES (15, 'outbound', 'Re: Figma Design Systems — Offer Letter (acceptance)', 'you@example.com', 'offers@figma.com', '2026-04-05', NULL);
|
||||||
|
INSERT INTO job_contacts (job_id, direction, subject, from_addr, to_addr, received_at, stage_signal) VALUES (15, 'inbound', 'Welcome to Figma! Onboarding next steps', 'onboarding@figma.com', 'you@example.com', '2026-04-08', NULL);
|
||||||
|
INSERT INTO job_contacts (job_id, direction, subject, from_addr, to_addr, received_at, stage_signal) VALUES (16, 'inbound', 'Thanks for applying to Slack', 'noreply@slack.com', 'you@example.com', '2026-03-28', NULL);
|
||||||
|
|
||||||
|
-- references_
|
||||||
|
INSERT INTO references_ (name, email, role, company, relationship, notes, tags, prep_email) VALUES ('Dr. Priya Nair', 'priya.nair@example.com', 'Director of Design', 'Acme Corp', 'former_manager', 'Managed me for 3 years on the consumer app redesign. Enthusiastic reference.', '["manager","design"]', 'Hi Priya,
|
||||||
|
|
||||||
|
I hope you''re doing well! I''m currently interviewing for a few senior UX roles and would be so grateful if you''d be willing to serve as a reference.
|
||||||
|
|
||||||
|
Thank you!
|
||||||
|
[Your name]');
|
||||||
|
INSERT INTO references_ (name, email, role, company, relationship, notes, tags, prep_email) VALUES ('Sam Torres', 'sam.torres@example.com', 'Senior Product Designer', 'Acme Corp', 'former_colleague', 'Worked together on design systems. Great at speaking to collaborative process.', '["colleague","design_systems"]', NULL);
|
||||||
|
INSERT INTO references_ (name, email, role, company, relationship, notes, tags, prep_email) VALUES ('Jordan Kim', 'jordan.kim@example.com', 'VP of Product', 'Streamline Inc', 'former_manager', 'Led the product team I was embedded in. Can speak to business impact of design work.', '["manager","product"]', NULL);
|
||||||
31
dev-api.py
31
dev-api.py
|
|
@ -46,18 +46,37 @@ DB_PATH = os.environ.get("STAGING_DB", "/devl/job-seeker/staging.db")
|
||||||
_CLOUD_MODE = os.environ.get("CLOUD_MODE", "").lower() in ("1", "true")
|
_CLOUD_MODE = os.environ.get("CLOUD_MODE", "").lower() in ("1", "true")
|
||||||
_CLOUD_DATA_ROOT = Path(os.environ.get("CLOUD_DATA_ROOT", "/devl/menagerie-data"))
|
_CLOUD_DATA_ROOT = Path(os.environ.get("CLOUD_DATA_ROOT", "/devl/menagerie-data"))
|
||||||
_DIRECTUS_SECRET = os.environ.get("DIRECTUS_JWT_SECRET", "")
|
_DIRECTUS_SECRET = os.environ.get("DIRECTUS_JWT_SECRET", "")
|
||||||
|
IS_DEMO: bool = os.environ.get("DEMO_MODE", "").lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
# Per-request DB path — set by cloud_session_middleware; falls back to DB_PATH
|
# Per-request DB path — set by cloud_session_middleware; falls back to DB_PATH
|
||||||
_request_db: ContextVar[str | None] = ContextVar("_request_db", default=None)
|
_request_db: ContextVar[str | None] = ContextVar("_request_db", default=None)
|
||||||
|
|
||||||
|
def _load_demo_seed(db_path: str, seed_file: str) -> None:
|
||||||
|
"""Load seed SQL into the demo DB if it is empty (no jobs rows yet)."""
|
||||||
|
import sqlite3 as _sqlite3
|
||||||
|
seed_path = Path(seed_file)
|
||||||
|
if not seed_path.exists():
|
||||||
|
return
|
||||||
|
con = _sqlite3.connect(db_path)
|
||||||
|
try:
|
||||||
|
count = con.execute("SELECT COUNT(*) FROM jobs").fetchone()[0]
|
||||||
|
if count == 0:
|
||||||
|
con.executescript(seed_path.read_text())
|
||||||
|
con.commit()
|
||||||
|
finally:
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
"""Load .env then run pending SQLite migrations on startup."""
|
"""Load .env, run migrations, and (in demo mode) seed the demo DB."""
|
||||||
# Load .env before any runtime env reads — safe because lifespan doesn't run
|
# Load .env before any runtime env reads — safe because lifespan doesn't run
|
||||||
# when dev_api is imported by tests (only when uvicorn actually starts).
|
# when dev_api is imported by tests (only when uvicorn actually starts).
|
||||||
_load_env(PEREGRINE_ROOT / ".env")
|
_load_env(PEREGRINE_ROOT / ".env")
|
||||||
from scripts.db_migrate import migrate_db
|
from scripts.db_migrate import migrate_db
|
||||||
migrate_db(Path(DB_PATH))
|
migrate_db(Path(DB_PATH))
|
||||||
|
if IS_DEMO and (seed_file := os.environ.get("DEMO_SEED_FILE")):
|
||||||
|
_load_demo_seed(DB_PATH, seed_file)
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -81,6 +100,12 @@ app.include_router(_feedback_router, prefix="/api/feedback")
|
||||||
|
|
||||||
_log = logging.getLogger("peregrine.session")
|
_log = logging.getLogger("peregrine.session")
|
||||||
|
|
||||||
|
|
||||||
|
def _demo_guard() -> None:
|
||||||
|
"""Raise 403 if running in demo mode. Call at the top of any write endpoint."""
|
||||||
|
if IS_DEMO:
|
||||||
|
raise HTTPException(status_code=403, detail="demo-write-blocked")
|
||||||
|
|
||||||
def _resolve_cf_user_id(cookie_str: str) -> str | None:
|
def _resolve_cf_user_id(cookie_str: str) -> str | None:
|
||||||
"""Extract cf_session JWT from Cookie string and return Directus user_id.
|
"""Extract cf_session JWT from Cookie string and return Directus user_id.
|
||||||
|
|
||||||
|
|
@ -296,6 +321,7 @@ def job_counts():
|
||||||
|
|
||||||
@app.post("/api/jobs/{job_id}/approve")
|
@app.post("/api/jobs/{job_id}/approve")
|
||||||
def approve_job(job_id: int):
|
def approve_job(job_id: int):
|
||||||
|
_demo_guard()
|
||||||
db = _get_db()
|
db = _get_db()
|
||||||
db.execute("UPDATE jobs SET status = 'approved' WHERE id = ?", (job_id,))
|
db.execute("UPDATE jobs SET status = 'approved' WHERE id = ?", (job_id,))
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
@ -307,6 +333,7 @@ def approve_job(job_id: int):
|
||||||
|
|
||||||
@app.post("/api/jobs/{job_id}/reject")
|
@app.post("/api/jobs/{job_id}/reject")
|
||||||
def reject_job(job_id: int):
|
def reject_job(job_id: int):
|
||||||
|
_demo_guard()
|
||||||
db = _get_db()
|
db = _get_db()
|
||||||
db.execute("UPDATE jobs SET status = 'rejected' WHERE id = ?", (job_id,))
|
db.execute("UPDATE jobs SET status = 'rejected' WHERE id = ?", (job_id,))
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
@ -396,6 +423,7 @@ def save_cover_letter(job_id: int, body: CoverLetterBody):
|
||||||
|
|
||||||
@app.post("/api/jobs/{job_id}/cover_letter/generate")
|
@app.post("/api/jobs/{job_id}/cover_letter/generate")
|
||||||
def generate_cover_letter(job_id: int):
|
def generate_cover_letter(job_id: int):
|
||||||
|
_demo_guard()
|
||||||
try:
|
try:
|
||||||
from scripts.task_runner import submit_task
|
from scripts.task_runner import submit_task
|
||||||
task_id, is_new = submit_task(
|
task_id, is_new = submit_task(
|
||||||
|
|
@ -1520,6 +1548,7 @@ class HiredFeedbackPayload(BaseModel):
|
||||||
|
|
||||||
@app.post("/api/jobs/{job_id}/hired-feedback")
|
@app.post("/api/jobs/{job_id}/hired-feedback")
|
||||||
def save_hired_feedback(job_id: int, payload: HiredFeedbackPayload):
|
def save_hired_feedback(job_id: int, payload: HiredFeedbackPayload):
|
||||||
|
_demo_guard()
|
||||||
db = _get_db()
|
db = _get_db()
|
||||||
row = db.execute("SELECT status FROM jobs WHERE id = ?", (job_id,)).fetchone()
|
row = db.execute("SELECT status FROM jobs WHERE id = ?", (job_id,)).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
|
|
|
||||||
22
migrations/006_missing_columns.sql
Normal file
22
migrations/006_missing_columns.sql
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
-- Migration 006: Add columns and tables present in the live DB but missing from migrations
|
||||||
|
-- These were added via direct ALTER TABLE after the v0.8.5 baseline was written.
|
||||||
|
|
||||||
|
-- date_posted: used for ghost-post shadow-score detection
|
||||||
|
ALTER TABLE jobs ADD COLUMN date_posted TEXT;
|
||||||
|
|
||||||
|
-- hired_feedback: JSON blob saved when a job reaches the 'hired' outcome
|
||||||
|
ALTER TABLE jobs ADD COLUMN hired_feedback TEXT;
|
||||||
|
|
||||||
|
-- references_ table: contacts who can provide references for applications
|
||||||
|
CREATE TABLE IF NOT EXISTS references_ (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
relationship TEXT,
|
||||||
|
company TEXT,
|
||||||
|
email TEXT,
|
||||||
|
phone TEXT,
|
||||||
|
notes TEXT,
|
||||||
|
tags TEXT,
|
||||||
|
prep_email TEXT,
|
||||||
|
role TEXT
|
||||||
|
);
|
||||||
254
scripts/generate_demo_seed.py
Normal file
254
scripts/generate_demo_seed.py
Normal file
|
|
@ -0,0 +1,254 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Generate demo/seed.sql — committed seed INSERT statements for the demo DB.
|
||||||
|
|
||||||
|
Run whenever seed data needs to change:
|
||||||
|
conda run -n cf python scripts/generate_demo_seed.py
|
||||||
|
|
||||||
|
Outputs pure INSERT SQL (no DDL). Schema migrations are handled by db_migrate.py
|
||||||
|
at container startup. The seed SQL is loaded after migrations complete.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
OUT_PATH = Path(__file__).parent.parent / "demo" / "seed.sql"
|
||||||
|
|
||||||
|
TODAY = date.today()
|
||||||
|
|
||||||
|
|
||||||
|
def _dago(n: int) -> str:
|
||||||
|
return (TODAY - timedelta(days=n)).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _dfrom(n: int) -> str:
|
||||||
|
return (TODAY + timedelta(days=n)).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
COVER_LETTER_SPOTIFY = """\
|
||||||
|
Dear Hiring Manager,
|
||||||
|
|
||||||
|
I'm excited to apply for the UX Designer role at Spotify. With five years of
|
||||||
|
experience designing for music discovery and cross-platform experiences, I've
|
||||||
|
consistently shipped features that make complex audio content feel effortless to
|
||||||
|
navigate. At my last role I led a redesign of the playlist creation flow that
|
||||||
|
reduced drop-off by 31%.
|
||||||
|
|
||||||
|
Spotify's commitment to artist and listener discovery — and its recent push into
|
||||||
|
audiobooks and podcast tooling — aligns directly with the kind of cross-format
|
||||||
|
design challenges I'm most energised by.
|
||||||
|
|
||||||
|
I'd love to bring that focus to your product design team.
|
||||||
|
|
||||||
|
Warm regards,
|
||||||
|
[Your name]
|
||||||
|
"""
|
||||||
|
|
||||||
|
SQL_PARTS: list[str] = []
|
||||||
|
|
||||||
|
# ── Jobs ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# Columns: title, company, url, source, location, is_remote, salary,
|
||||||
|
# match_score, status, date_found, date_posted, cover_letter,
|
||||||
|
# applied_at, phone_screen_at, interviewing_at, offer_at, hired_at,
|
||||||
|
# interview_date, rejection_stage, hired_feedback
|
||||||
|
JOBS: list[tuple] = [
|
||||||
|
# ---- Review queue (12 jobs — mix of pending + approved) ------------------
|
||||||
|
("UX Designer",
|
||||||
|
"Spotify", "https://www.linkedin.com/jobs/view/1000001",
|
||||||
|
"linkedin", "Remote", 1, "$110k–$140k",
|
||||||
|
94.0, "approved", _dago(1), _dago(3), COVER_LETTER_SPOTIFY,
|
||||||
|
None, None, None, None, None, None, None, None),
|
||||||
|
|
||||||
|
("Product Designer",
|
||||||
|
"Duolingo", "https://www.linkedin.com/jobs/view/1000002",
|
||||||
|
"linkedin", "Pittsburgh, PA", 0, "$95k–$120k",
|
||||||
|
87.0, "approved", _dago(2), _dago(5), "Draft in progress — cover letter generating…",
|
||||||
|
None, None, None, None, None, None, None, None),
|
||||||
|
|
||||||
|
("UX Lead",
|
||||||
|
"NPR", "https://www.indeed.com/viewjob?jk=1000003",
|
||||||
|
"indeed", "Washington, DC", 1, "$120k–$150k",
|
||||||
|
81.0, "approved", _dago(3), _dago(7), None,
|
||||||
|
None, None, None, None, None, None, None, None),
|
||||||
|
|
||||||
|
# Ghost post — date_posted 34 days ago → shadow indicator
|
||||||
|
("Senior UX Designer",
|
||||||
|
"Mozilla", "https://www.linkedin.com/jobs/view/1000004",
|
||||||
|
"linkedin", "Remote", 1, "$105k–$130k",
|
||||||
|
81.0, "pending", _dago(2), _dago(34), None,
|
||||||
|
None, None, None, None, None, None, None, None),
|
||||||
|
|
||||||
|
("Interaction Designer",
|
||||||
|
"Figma", "https://www.indeed.com/viewjob?jk=1000005",
|
||||||
|
"indeed", "San Francisco, CA", 1, "$115k–$145k",
|
||||||
|
78.0, "pending", _dago(4), _dago(6), None,
|
||||||
|
None, None, None, None, None, None, None, None),
|
||||||
|
|
||||||
|
("Product Designer II",
|
||||||
|
"Notion", "https://www.linkedin.com/jobs/view/1000006",
|
||||||
|
"linkedin", "Remote", 1, "$100k–$130k",
|
||||||
|
76.0, "pending", _dago(5), _dago(8), None,
|
||||||
|
None, None, None, None, None, None, None, None),
|
||||||
|
|
||||||
|
("UX Designer",
|
||||||
|
"Stripe", "https://www.linkedin.com/jobs/view/1000007",
|
||||||
|
"linkedin", "Remote", 1, "$120k–$150k",
|
||||||
|
74.0, "pending", _dago(6), _dago(9), None,
|
||||||
|
None, None, None, None, None, None, None, None),
|
||||||
|
|
||||||
|
("UI/UX Designer",
|
||||||
|
"Canva", "https://www.indeed.com/viewjob?jk=1000008",
|
||||||
|
"indeed", "Remote", 1, "$90k–$115k",
|
||||||
|
72.0, "pending", _dago(7), _dago(10), None,
|
||||||
|
None, None, None, None, None, None, None, None),
|
||||||
|
|
||||||
|
("Senior Product Designer",
|
||||||
|
"Asana", "https://www.linkedin.com/jobs/view/1000009",
|
||||||
|
"linkedin", "San Francisco, CA", 1, "$125k–$155k",
|
||||||
|
69.0, "pending", _dago(8), _dago(11), None,
|
||||||
|
None, None, None, None, None, None, None, None),
|
||||||
|
|
||||||
|
("UX Researcher",
|
||||||
|
"Intercom", "https://www.indeed.com/viewjob?jk=1000010",
|
||||||
|
"indeed", "Remote", 1, "$95k–$120k",
|
||||||
|
67.0, "pending", _dago(9), _dago(12), None,
|
||||||
|
None, None, None, None, None, None, None, None),
|
||||||
|
|
||||||
|
("Product Designer",
|
||||||
|
"Linear", "https://www.linkedin.com/jobs/view/1000011",
|
||||||
|
"linkedin", "Remote", 1, "$110k–$135k",
|
||||||
|
65.0, "pending", _dago(10), _dago(13), None,
|
||||||
|
None, None, None, None, None, None, None, None),
|
||||||
|
|
||||||
|
("UX Designer",
|
||||||
|
"Loom", "https://www.indeed.com/viewjob?jk=1000012",
|
||||||
|
"indeed", "Remote", 1, "$90k–$110k",
|
||||||
|
62.0, "pending", _dago(11), _dago(14), None,
|
||||||
|
None, None, None, None, None, None, None, None),
|
||||||
|
|
||||||
|
# ---- Pipeline jobs (applied → hired) ------------------------------------
|
||||||
|
("Senior Product Designer",
|
||||||
|
"Asana", "https://www.asana.com/jobs/1000013",
|
||||||
|
"linkedin", "San Francisco, CA", 1, "$125k–$155k",
|
||||||
|
91.0, "phone_screen", _dago(14), _dago(16), None,
|
||||||
|
_dago(7), _dfrom(0), None, None, None,
|
||||||
|
f"{_dfrom(0)}T14:00:00", None, None),
|
||||||
|
|
||||||
|
("Product Designer",
|
||||||
|
"Notion", "https://www.notion.so/jobs/1000014",
|
||||||
|
"indeed", "Remote", 1, "$100k–$130k",
|
||||||
|
88.0, "interviewing", _dago(21), _dago(23), None,
|
||||||
|
_dago(14), _dago(10), _dago(3), None, None,
|
||||||
|
f"{_dfrom(7)}T10:00:00", None, None),
|
||||||
|
|
||||||
|
("Design Systems Designer",
|
||||||
|
"Figma", "https://www.figma.com/jobs/1000015",
|
||||||
|
"linkedin", "San Francisco, CA", 1, "$130k–$160k",
|
||||||
|
96.0, "hired", _dago(45), _dago(47), None,
|
||||||
|
_dago(38), _dago(32), _dago(25), _dago(14), _dago(7),
|
||||||
|
None, None,
|
||||||
|
'{"factors":["clear_scope","great_manager","mission_aligned"],"notes":"Excited about design systems work. Salary met expectations."}'),
|
||||||
|
|
||||||
|
("UX Designer",
|
||||||
|
"Slack", "https://slack.com/jobs/1000016",
|
||||||
|
"indeed", "Remote", 1, "$115k–$140k",
|
||||||
|
79.0, "applied", _dago(28), _dago(30), None,
|
||||||
|
_dago(18), None, None, None, None, None, None, None),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _q(v: object) -> str:
|
||||||
|
"""SQL-quote a Python value."""
|
||||||
|
if v is None:
|
||||||
|
return "NULL"
|
||||||
|
return "'" + str(v).replace("'", "''") + "'"
|
||||||
|
|
||||||
|
|
||||||
|
_JOB_COLS = (
|
||||||
|
"title, company, url, source, location, is_remote, salary, "
|
||||||
|
"match_score, status, date_found, date_posted, cover_letter, "
|
||||||
|
"applied_at, phone_screen_at, interviewing_at, offer_at, hired_at, "
|
||||||
|
"interview_date, rejection_stage, hired_feedback"
|
||||||
|
)
|
||||||
|
|
||||||
|
SQL_PARTS.append("-- jobs")
|
||||||
|
for job in JOBS:
|
||||||
|
vals = ", ".join(_q(v) for v in job)
|
||||||
|
SQL_PARTS.append(f"INSERT INTO jobs ({_JOB_COLS}) VALUES ({vals});")
|
||||||
|
|
||||||
|
# ── Contacts ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# (job_id, direction, subject, from_addr, to_addr, received_at, stage_signal)
|
||||||
|
CONTACTS: list[tuple] = [
|
||||||
|
(1, "inbound", "Excited to connect — UX Designer role at Spotify",
|
||||||
|
"jamie.chen@spotify.com", "you@example.com", _dago(3), "positive_response"),
|
||||||
|
(1, "outbound", "Re: Excited to connect — UX Designer role at Spotify",
|
||||||
|
"you@example.com", "jamie.chen@spotify.com", _dago(2), None),
|
||||||
|
(13, "inbound", "Interview Confirmation — Senior Product Designer",
|
||||||
|
"recruiting@asana.com", "you@example.com", _dago(2), "interview_scheduled"),
|
||||||
|
(14, "inbound", "Your panel interview is confirmed for Apr 22",
|
||||||
|
"recruiting@notion.so", "you@example.com", _dago(3), "interview_scheduled"),
|
||||||
|
(14, "inbound", "Pre-interview prep resources",
|
||||||
|
"marcus.webb@notion.so", "you@example.com", _dago(2), "positive_response"),
|
||||||
|
(15, "inbound", "Figma Design Systems — Offer Letter",
|
||||||
|
"offers@figma.com", "you@example.com", _dago(14), "offer_received"),
|
||||||
|
(15, "outbound", "Re: Figma Design Systems — Offer Letter (acceptance)",
|
||||||
|
"you@example.com", "offers@figma.com", _dago(10), None),
|
||||||
|
(15, "inbound", "Welcome to Figma! Onboarding next steps",
|
||||||
|
"onboarding@figma.com", "you@example.com", _dago(7), None),
|
||||||
|
(16, "inbound", "Thanks for applying to Slack",
|
||||||
|
"noreply@slack.com", "you@example.com", _dago(18), None),
|
||||||
|
]
|
||||||
|
|
||||||
|
SQL_PARTS.append("\n-- job_contacts")
|
||||||
|
for c in CONTACTS:
|
||||||
|
job_id, direction, subject, from_addr, to_addr, received_at, stage_signal = c
|
||||||
|
SQL_PARTS.append(
|
||||||
|
f"INSERT INTO job_contacts "
|
||||||
|
f"(job_id, direction, subject, from_addr, to_addr, received_at, stage_signal) "
|
||||||
|
f"VALUES ({job_id}, {_q(direction)}, {_q(subject)}, {_q(from_addr)}, "
|
||||||
|
f"{_q(to_addr)}, {_q(received_at)}, {_q(stage_signal)});"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── References ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
# (name, email, role, company, relationship, notes, tags, prep_email)
|
||||||
|
REFERENCES: list[tuple] = [
|
||||||
|
("Dr. Priya Nair", "priya.nair@example.com", "Director of Design", "Acme Corp",
|
||||||
|
"former_manager",
|
||||||
|
"Managed me for 3 years on the consumer app redesign. Enthusiastic reference.",
|
||||||
|
'["manager","design"]',
|
||||||
|
"Hi Priya,\n\nI hope you're doing well! I'm currently interviewing for a few senior UX roles "
|
||||||
|
"and would be so grateful if you'd be willing to serve as a reference.\n\nThank you!\n[Your name]"),
|
||||||
|
|
||||||
|
("Sam Torres", "sam.torres@example.com", "Senior Product Designer", "Acme Corp",
|
||||||
|
"former_colleague",
|
||||||
|
"Worked together on design systems. Great at speaking to collaborative process.",
|
||||||
|
'["colleague","design_systems"]', None),
|
||||||
|
|
||||||
|
("Jordan Kim", "jordan.kim@example.com", "VP of Product", "Streamline Inc",
|
||||||
|
"former_manager",
|
||||||
|
"Led the product team I was embedded in. Can speak to business impact of design work.",
|
||||||
|
'["manager","product"]', None),
|
||||||
|
]
|
||||||
|
|
||||||
|
SQL_PARTS.append("\n-- references_")
|
||||||
|
for ref in REFERENCES:
|
||||||
|
name, email, role, company, relationship, notes, tags, prep_email = ref
|
||||||
|
SQL_PARTS.append(
|
||||||
|
f"INSERT INTO references_ "
|
||||||
|
f"(name, email, role, company, relationship, notes, tags, prep_email) "
|
||||||
|
f"VALUES ({_q(name)}, {_q(email)}, {_q(role)}, {_q(company)}, "
|
||||||
|
f"{_q(relationship)}, {_q(notes)}, {_q(tags)}, {_q(prep_email)});"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Write output ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
output = "\n".join(SQL_PARTS) + "\n"
|
||||||
|
OUT_PATH.write_text(output, encoding="utf-8")
|
||||||
|
print(
|
||||||
|
f"Wrote {OUT_PATH} "
|
||||||
|
f"({len(JOBS)} jobs, {len(CONTACTS)} contacts, {len(REFERENCES)} references)"
|
||||||
|
)
|
||||||
89
tests/test_demo_guard.py
Normal file
89
tests/test_demo_guard.py
Normal file
|
|
@ -0,0 +1,89 @@
|
||||||
|
"""IS_DEMO write-block guard tests."""
|
||||||
|
import importlib
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
_SCHEMA = """
|
||||||
|
CREATE TABLE jobs (
|
||||||
|
id INTEGER PRIMARY KEY, title TEXT, company TEXT, url TEXT,
|
||||||
|
location TEXT, is_remote INTEGER DEFAULT 0, salary TEXT,
|
||||||
|
match_score REAL, keyword_gaps TEXT, status TEXT DEFAULT 'pending',
|
||||||
|
date_found TEXT, cover_letter TEXT, interview_date TEXT,
|
||||||
|
rejection_stage TEXT, applied_at TEXT, phone_screen_at TEXT,
|
||||||
|
interviewing_at TEXT, offer_at TEXT, hired_at TEXT,
|
||||||
|
survey_at TEXT, date_posted TEXT, hired_feedback TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE background_tasks (
|
||||||
|
id INTEGER PRIMARY KEY, task_type TEXT, job_id INTEGER,
|
||||||
|
status TEXT DEFAULT 'queued', finished_at TEXT
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _make_db(path: str) -> None:
|
||||||
|
con = sqlite3.connect(path)
|
||||||
|
con.executescript(_SCHEMA)
|
||||||
|
con.execute(
|
||||||
|
"INSERT INTO jobs (id, title, company, url, status) VALUES (1,'UX Designer','Spotify','https://ex.com/1','pending')"
|
||||||
|
)
|
||||||
|
con.execute(
|
||||||
|
"INSERT INTO jobs (id, title, company, url, status) VALUES (2,'Designer','Figma','https://ex.com/2','hired')"
|
||||||
|
)
|
||||||
|
con.commit()
|
||||||
|
con.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def demo_client(tmp_path, monkeypatch):
|
||||||
|
db_path = str(tmp_path / "staging.db")
|
||||||
|
_make_db(db_path)
|
||||||
|
monkeypatch.setenv("DEMO_MODE", "true")
|
||||||
|
monkeypatch.setenv("STAGING_DB", db_path)
|
||||||
|
import dev_api
|
||||||
|
importlib.reload(dev_api)
|
||||||
|
return TestClient(dev_api.app)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def normal_client(tmp_path, monkeypatch):
|
||||||
|
db_path = str(tmp_path / "staging.db")
|
||||||
|
_make_db(db_path)
|
||||||
|
monkeypatch.delenv("DEMO_MODE", raising=False)
|
||||||
|
monkeypatch.setenv("STAGING_DB", db_path)
|
||||||
|
import dev_api
|
||||||
|
importlib.reload(dev_api)
|
||||||
|
return TestClient(dev_api.app)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDemoWriteBlock:
|
||||||
|
def test_approve_blocked_in_demo(self, demo_client):
|
||||||
|
r = demo_client.post("/api/jobs/1/approve")
|
||||||
|
assert r.status_code == 403
|
||||||
|
assert r.json()["detail"] == "demo-write-blocked"
|
||||||
|
|
||||||
|
def test_reject_blocked_in_demo(self, demo_client):
|
||||||
|
r = demo_client.post("/api/jobs/1/reject")
|
||||||
|
assert r.status_code == 403
|
||||||
|
assert r.json()["detail"] == "demo-write-blocked"
|
||||||
|
|
||||||
|
def test_cover_letter_generate_blocked_in_demo(self, demo_client):
|
||||||
|
r = demo_client.post("/api/jobs/1/cover_letter/generate")
|
||||||
|
assert r.status_code == 403
|
||||||
|
assert r.json()["detail"] == "demo-write-blocked"
|
||||||
|
|
||||||
|
def test_hired_feedback_blocked_in_demo(self, demo_client):
|
||||||
|
r = demo_client.post("/api/jobs/2/hired-feedback", json={"factors": [], "notes": ""})
|
||||||
|
assert r.status_code == 403
|
||||||
|
assert r.json()["detail"] == "demo-write-blocked"
|
||||||
|
|
||||||
|
def test_approve_allowed_in_normal_mode(self, normal_client):
|
||||||
|
r = normal_client.post("/api/jobs/1/approve")
|
||||||
|
assert r.status_code != 403
|
||||||
|
|
||||||
|
def test_config_reports_is_demo_true(self, demo_client):
|
||||||
|
r = demo_client.get("/api/config/app")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["isDemo"] is True
|
||||||
|
|
@ -7,10 +7,9 @@
|
||||||
<!-- Skip to main content link (screen reader / keyboard nav) -->
|
<!-- Skip to main content link (screen reader / keyboard nav) -->
|
||||||
<a href="#main-content" class="skip-link">Skip to main content</a>
|
<a href="#main-content" class="skip-link">Skip to main content</a>
|
||||||
|
|
||||||
<!-- Demo mode banner — sticky top bar, visible on all pages -->
|
<!-- Demo mode banner + welcome modal — rendered when isDemo -->
|
||||||
<div v-if="config.isDemo" class="demo-banner" role="status" aria-live="polite">
|
<DemoBanner v-if="config.isDemo" />
|
||||||
👁 Demo mode — changes are not saved and AI features are disabled.
|
<WelcomeModal v-if="config.isDemo" />
|
||||||
</div>
|
|
||||||
|
|
||||||
<RouterView />
|
<RouterView />
|
||||||
|
|
||||||
|
|
@ -32,6 +31,8 @@ import { useHackerMode, useKonamiCode } from './composables/useEasterEgg'
|
||||||
import { useTheme } from './composables/useTheme'
|
import { useTheme } from './composables/useTheme'
|
||||||
import { useToast } from './composables/useToast'
|
import { useToast } from './composables/useToast'
|
||||||
import AppNav from './components/AppNav.vue'
|
import AppNav from './components/AppNav.vue'
|
||||||
|
import DemoBanner from './components/DemoBanner.vue'
|
||||||
|
import WelcomeModal from './components/WelcomeModal.vue'
|
||||||
import { useAppConfigStore } from './stores/appConfig'
|
import { useAppConfigStore } from './stores/appConfig'
|
||||||
import { useDigestStore } from './stores/digest'
|
import { useDigestStore } from './stores/digest'
|
||||||
|
|
||||||
|
|
@ -128,20 +129,6 @@ body {
|
||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Demo mode banner — sticky top bar */
|
|
||||||
.demo-banner {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 200;
|
|
||||||
background: var(--color-warning);
|
|
||||||
color: #1a1a1a; /* forced dark — warning bg is always light enough */
|
|
||||||
text-align: center;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 6px var(--space-4, 16px);
|
|
||||||
letter-spacing: 0.01em;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Global toast — bottom-center, above tab bar */
|
/* Global toast — bottom-center, above tab bar */
|
||||||
.global-toast {
|
.global-toast {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|
|
||||||
79
web/src/components/DemoBanner.vue
Normal file
79
web/src/components/DemoBanner.vue
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
<template>
|
||||||
|
<div class="demo-banner" role="status" aria-live="polite">
|
||||||
|
<span class="demo-banner__label">👁 Demo mode — changes are not saved</span>
|
||||||
|
<div class="demo-banner__ctas">
|
||||||
|
<a
|
||||||
|
href="https://circuitforge.tech/peregrine"
|
||||||
|
class="demo-banner__cta demo-banner__cta--primary"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>Get free key</a>
|
||||||
|
<a
|
||||||
|
href="https://git.opensourcesolarpunk.com/Circuit-Forge/peregrine"
|
||||||
|
class="demo-banner__cta demo-banner__cta--secondary"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>Self-host</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// No props — DemoBanner is only rendered when config.isDemo is true (App.vue)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.demo-banner {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 200;
|
||||||
|
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-surface-raised));
|
||||||
|
border-bottom: 1px solid color-mix(in srgb, var(--color-primary) 20%, var(--color-border));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 6px var(--space-4);
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-banner__label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-banner__ctas {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-banner__cta {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: opacity var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-banner__cta:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-banner__cta--primary {
|
||||||
|
background: var(--color-primary);
|
||||||
|
color: var(--color-surface); /* surface is dark in dark mode, light in light mode — always contrasts primary */
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-banner__cta--secondary {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.demo-banner__label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
63
web/src/components/HintChip.vue
Normal file
63
web/src/components/HintChip.vue
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
<template>
|
||||||
|
<div v-if="!dismissed" class="hint-chip" role="status">
|
||||||
|
<span aria-hidden="true" class="hint-chip__icon">💡</span>
|
||||||
|
<span class="hint-chip__message">{{ message }}</span>
|
||||||
|
<button
|
||||||
|
class="hint-chip__dismiss"
|
||||||
|
@click="dismiss"
|
||||||
|
:aria-label="`Dismiss hint for ${viewKey}`"
|
||||||
|
>✕</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
viewKey: string // used for localStorage key — e.g. 'home', 'review'
|
||||||
|
message: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const LS_KEY = `peregrine_hint_${props.viewKey}`
|
||||||
|
const dismissed = ref(!!localStorage.getItem(LS_KEY))
|
||||||
|
|
||||||
|
function dismiss(): void {
|
||||||
|
localStorage.setItem(LS_KEY, '1')
|
||||||
|
dismissed.value = true
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.hint-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-2, 8px);
|
||||||
|
background: var(--color-surface, #0d1829);
|
||||||
|
border: 1px solid var(--app-primary, #2B6CB0);
|
||||||
|
border-radius: var(--radius-md, 8px);
|
||||||
|
padding: var(--space-2, 8px) var(--space-3, 12px);
|
||||||
|
margin-bottom: var(--space-3, 12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-chip__icon { flex-shrink: 0; font-size: 0.9rem; }
|
||||||
|
|
||||||
|
.hint-chip__message {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text, #1a202c);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-chip__dismiss {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--color-text-muted, #8898aa);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0 2px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hint-chip__dismiss:hover { color: var(--color-text, #eaeff8); }
|
||||||
|
</style>
|
||||||
|
|
@ -216,7 +216,23 @@ watch(() => props.job.id, () => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
defineExpose({ dismissApprove, dismissReject, dismissSkip })
|
/** Restore card to its neutral state — used when an action is blocked (e.g. demo guard). */
|
||||||
|
function resetCard() {
|
||||||
|
dx.value = 0
|
||||||
|
dy.value = 0
|
||||||
|
isExiting.value = false
|
||||||
|
isHeld.value = false
|
||||||
|
if (wrapperEl.value) {
|
||||||
|
wrapperEl.value.style.transition = 'none'
|
||||||
|
wrapperEl.value.style.transform = ''
|
||||||
|
wrapperEl.value.style.opacity = ''
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (wrapperEl.value) wrapperEl.value.style.transition = ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ dismissApprove, dismissReject, dismissSkip, resetCard })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
||||||
160
web/src/components/WelcomeModal.vue
Normal file
160
web/src/components/WelcomeModal.vue
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="visible" class="welcome-modal-overlay" @click.self="dismiss">
|
||||||
|
<div
|
||||||
|
class="welcome-modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="welcome-modal-title"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" class="welcome-modal__icon">🦅</span>
|
||||||
|
<h2 id="welcome-modal-title" class="welcome-modal__heading">
|
||||||
|
Welcome to Peregrine
|
||||||
|
</h2>
|
||||||
|
<p class="welcome-modal__desc">
|
||||||
|
A live demo with realistic job search data. Explore freely — nothing you do here is saved.
|
||||||
|
</p>
|
||||||
|
<ul class="welcome-modal__features" aria-label="What to try">
|
||||||
|
<li>📋 Review & rate matched jobs</li>
|
||||||
|
<li>✏ Draft a cover letter with AI</li>
|
||||||
|
<li>📅 Track your interview pipeline</li>
|
||||||
|
<li>🎉 See a hired outcome</li>
|
||||||
|
</ul>
|
||||||
|
<button class="welcome-modal__explore" @click="dismiss">
|
||||||
|
Explore the demo →
|
||||||
|
</button>
|
||||||
|
<div class="welcome-modal__links">
|
||||||
|
<a
|
||||||
|
href="https://circuitforge.tech/account"
|
||||||
|
class="welcome-modal__link welcome-modal__link--primary"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>Get a free key</a>
|
||||||
|
<a
|
||||||
|
href="https://git.opensourcesolarpunk.com/Circuit-Forge/peregrine"
|
||||||
|
class="welcome-modal__link welcome-modal__link--secondary"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>Self-host →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const LS_KEY = 'peregrine_demo_visited'
|
||||||
|
|
||||||
|
const emit = defineEmits<{ dismissed: [] }>()
|
||||||
|
|
||||||
|
const visible = ref(!localStorage.getItem(LS_KEY))
|
||||||
|
|
||||||
|
function dismiss(): void {
|
||||||
|
localStorage.setItem(LS_KEY, '1')
|
||||||
|
visible.value = false
|
||||||
|
emit('dismissed')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.welcome-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: var(--space-4, 16px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-modal {
|
||||||
|
background: var(--color-surface-raised, #1e2d45);
|
||||||
|
border: 1px solid var(--color-border, #2a3a56);
|
||||||
|
border-radius: var(--radius-lg, 12px);
|
||||||
|
padding: var(--space-6, 24px);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 360px;
|
||||||
|
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3, 12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-modal__icon { font-size: 2rem; }
|
||||||
|
|
||||||
|
.welcome-modal__heading {
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text, #eaeff8);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-modal__desc {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-muted, #8898aa);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-modal__features {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
border-top: 1px solid var(--color-border, #2a3a56);
|
||||||
|
padding-top: var(--space-3, 12px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2, 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-modal__features li {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-muted, #8898aa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-modal__explore {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--app-primary, #2B6CB0);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md, 8px);
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 150ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-modal__explore:hover { opacity: 0.85; }
|
||||||
|
|
||||||
|
.welcome-modal__links {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: var(--space-2, 8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-modal__link {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 6px;
|
||||||
|
border-radius: var(--radius-sm, 4px);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: opacity 150ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-modal__link:hover { opacity: 0.85; }
|
||||||
|
|
||||||
|
.welcome-modal__link--primary {
|
||||||
|
border: 1px solid var(--app-primary, #2B6CB0);
|
||||||
|
color: var(--app-primary-light, #68A8D8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-modal__link--secondary {
|
||||||
|
border: 1px solid var(--color-border, #2a3a56);
|
||||||
|
color: var(--color-text-muted, #8898aa);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
22
web/src/components/__tests__/DemoBanner.test.ts
Normal file
22
web/src/components/__tests__/DemoBanner.test.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import DemoBanner from '../DemoBanner.vue'
|
||||||
|
|
||||||
|
describe('DemoBanner', () => {
|
||||||
|
it('renders the demo label', () => {
|
||||||
|
const w = mount(DemoBanner)
|
||||||
|
expect(w.text()).toContain('Demo mode')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a free key link', () => {
|
||||||
|
const w = mount(DemoBanner)
|
||||||
|
expect(w.find('a.demo-banner__cta--primary').exists()).toBe(true)
|
||||||
|
expect(w.find('a.demo-banner__cta--primary').text()).toContain('free key')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders a self-host link', () => {
|
||||||
|
const w = mount(DemoBanner)
|
||||||
|
expect(w.find('a.demo-banner__cta--secondary').exists()).toBe(true)
|
||||||
|
expect(w.find('a.demo-banner__cta--secondary').text()).toContain('Self-host')
|
||||||
|
})
|
||||||
|
})
|
||||||
28
web/src/components/__tests__/HintChip.test.ts
Normal file
28
web/src/components/__tests__/HintChip.test.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import HintChip from '../HintChip.vue'
|
||||||
|
|
||||||
|
beforeEach(() => { localStorage.clear() })
|
||||||
|
|
||||||
|
const factory = (viewKey = 'home', message = 'Test hint') =>
|
||||||
|
mount(HintChip, { props: { viewKey, message } })
|
||||||
|
|
||||||
|
describe('HintChip', () => {
|
||||||
|
it('renders the message', () => {
|
||||||
|
const w = factory()
|
||||||
|
expect(w.text()).toContain('Test hint')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is hidden when localStorage key is already set', () => {
|
||||||
|
localStorage.setItem('peregrine_hint_home', '1')
|
||||||
|
const w = factory()
|
||||||
|
expect(w.find('.hint-chip').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides and sets localStorage when dismiss button is clicked', async () => {
|
||||||
|
const w = factory()
|
||||||
|
await w.find('.hint-chip__dismiss').trigger('click')
|
||||||
|
expect(w.find('.hint-chip').exists()).toBe(false)
|
||||||
|
expect(localStorage.getItem('peregrine_hint_home')).toBe('1')
|
||||||
|
})
|
||||||
|
})
|
||||||
35
web/src/components/__tests__/WelcomeModal.test.ts
Normal file
35
web/src/components/__tests__/WelcomeModal.test.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { describe, it, expect, beforeEach } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import WelcomeModal from '../WelcomeModal.vue'
|
||||||
|
|
||||||
|
const LS_KEY = 'peregrine_demo_visited'
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('WelcomeModal', () => {
|
||||||
|
it('is visible when localStorage key is absent', () => {
|
||||||
|
const w = mount(WelcomeModal, { global: { stubs: { Teleport: true } } })
|
||||||
|
expect(w.find('.welcome-modal').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is hidden when localStorage key is set', () => {
|
||||||
|
localStorage.setItem(LS_KEY, '1')
|
||||||
|
const w = mount(WelcomeModal, { global: { stubs: { Teleport: true } } })
|
||||||
|
expect(w.find('.welcome-modal').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('dismisses and sets localStorage on primary CTA click', async () => {
|
||||||
|
const w = mount(WelcomeModal, { global: { stubs: { Teleport: true } } })
|
||||||
|
await w.find('.welcome-modal__explore').trigger('click')
|
||||||
|
expect(w.find('.welcome-modal').exists()).toBe(false)
|
||||||
|
expect(localStorage.getItem(LS_KEY)).toBe('1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits dismissed event on close', async () => {
|
||||||
|
const w = mount(WelcomeModal, { global: { stubs: { Teleport: true } } })
|
||||||
|
await w.find('.welcome-modal__explore').trigger('click')
|
||||||
|
expect(w.emitted('dismissed')).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
|
import { showToast } from './useToast'
|
||||||
|
|
||||||
export type ApiError =
|
export type ApiError =
|
||||||
| { kind: 'network'; message: string }
|
| { kind: 'network'; message: string }
|
||||||
| { kind: 'http'; status: number; detail: string }
|
| { kind: 'http'; status: number; detail: string }
|
||||||
|
| { kind: 'demo-blocked' }
|
||||||
|
|
||||||
// Strip trailing slash so '/peregrine/' + '/api/...' → '/peregrine/api/...'
|
// Strip trailing slash so '/peregrine/' + '/api/...' → '/peregrine/api/...'
|
||||||
const _apiBase = import.meta.env.BASE_URL.replace(/\/$/, '')
|
const _apiBase = import.meta.env.BASE_URL.replace(/\/$/, '')
|
||||||
|
|
@ -12,8 +15,20 @@ export async function useApiFetch<T>(
|
||||||
try {
|
try {
|
||||||
const res = await fetch(_apiBase + url, opts)
|
const res = await fetch(_apiBase + url, opts)
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const detail = await res.text().catch(() => '')
|
const rawText = await res.text().catch(() => '')
|
||||||
return { data: null, error: { kind: 'http', status: res.status, detail } }
|
// Demo mode: show toast and swallow the error so callers don't need to handle it
|
||||||
|
if (res.status === 403) {
|
||||||
|
try {
|
||||||
|
const body = JSON.parse(rawText) as { detail?: string }
|
||||||
|
if (body.detail === 'demo-write-blocked') {
|
||||||
|
showToast('Demo mode — sign in to save changes')
|
||||||
|
// Return a truthy error so callers bail early (no optimistic UI update),
|
||||||
|
// but the toast is already shown so no additional error handling needed.
|
||||||
|
return { data: null, error: { kind: 'demo-blocked' as const } }
|
||||||
|
}
|
||||||
|
} catch { /* not JSON — fall through to normal error */ }
|
||||||
|
}
|
||||||
|
return { data: null, error: { kind: 'http', status: res.status, detail: rawText } }
|
||||||
}
|
}
|
||||||
const data = await res.json() as T
|
const data = await res.json() as T
|
||||||
return { data, error: null }
|
return { data, error: null }
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
<template>
|
<template>
|
||||||
<!-- ── Mobile: full-width list ──────────────────────────────────── -->
|
<!-- ── Mobile: full-width list ──────────────────────────────────── -->
|
||||||
<div v-if="isMobile" class="apply-list">
|
<div v-if="isMobile" class="apply-list">
|
||||||
|
<HintChip
|
||||||
|
v-if="config.isDemo"
|
||||||
|
view-key="apply"
|
||||||
|
message="The Spotify cover letter is ready — open it to see how AI drafts from your resume"
|
||||||
|
/>
|
||||||
<header class="apply-list__header">
|
<header class="apply-list__header">
|
||||||
<h1 class="apply-list__title">Apply</h1>
|
<h1 class="apply-list__title">Apply</h1>
|
||||||
<p class="apply-list__subtitle">Approved jobs ready for applications</p>
|
<p class="apply-list__subtitle">Approved jobs ready for applications</p>
|
||||||
|
|
@ -50,6 +55,11 @@
|
||||||
<div v-else class="apply-split" :class="{ 'has-selection': selectedJobId !== null }" ref="splitEl">
|
<div v-else class="apply-split" :class="{ 'has-selection': selectedJobId !== null }" ref="splitEl">
|
||||||
<!-- Left: narrow job list -->
|
<!-- Left: narrow job list -->
|
||||||
<div class="apply-split__list">
|
<div class="apply-split__list">
|
||||||
|
<HintChip
|
||||||
|
v-if="config.isDemo"
|
||||||
|
view-key="apply"
|
||||||
|
message="The Spotify cover letter is ready — open it to see how AI drafts from your resume"
|
||||||
|
/>
|
||||||
<div class="split-list__header">
|
<div class="split-list__header">
|
||||||
<h1 class="split-list__title">Apply</h1>
|
<h1 class="split-list__title">Apply</h1>
|
||||||
<span v-if="coverLetterCount >= 5" class="marathon-badge" title="You're on a roll!">
|
<span v-if="coverLetterCount >= 5" class="marathon-badge" title="You're on a roll!">
|
||||||
|
|
@ -124,6 +134,10 @@ import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
import { useApiFetch } from '../composables/useApi'
|
import { useApiFetch } from '../composables/useApi'
|
||||||
import ApplyWorkspace from '../components/ApplyWorkspace.vue'
|
import ApplyWorkspace from '../components/ApplyWorkspace.vue'
|
||||||
|
import HintChip from '../components/HintChip.vue'
|
||||||
|
import { useAppConfigStore } from '../stores/appConfig'
|
||||||
|
|
||||||
|
const config = useAppConfigStore()
|
||||||
|
|
||||||
// ── Responsive ───────────────────────────────────────────────────────────────
|
// ── Responsive ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { useApiFetch } from '../composables/useApi'
|
import { useApiFetch } from '../composables/useApi'
|
||||||
|
import HintChip from '../components/HintChip.vue'
|
||||||
|
import { useAppConfigStore } from '../stores/appConfig'
|
||||||
|
|
||||||
|
const config = useAppConfigStore()
|
||||||
|
|
||||||
interface Contact {
|
interface Contact {
|
||||||
id: number
|
id: number
|
||||||
|
|
@ -79,6 +83,11 @@ onMounted(fetchContacts)
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="contacts-view">
|
<div class="contacts-view">
|
||||||
|
<HintChip
|
||||||
|
v-if="config.isDemo"
|
||||||
|
view-key="contacts"
|
||||||
|
message="Peregrine logs every recruiter email automatically — no manual entry needed"
|
||||||
|
/>
|
||||||
<header class="contacts-header">
|
<header class="contacts-header">
|
||||||
<h1 class="contacts-title">Contacts</h1>
|
<h1 class="contacts-title">Contacts</h1>
|
||||||
<span class="contacts-count" v-if="total > 0">{{ total }} total</span>
|
<span class="contacts-count" v-if="total > 0">{{ total }} total</span>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="home">
|
<div class="home">
|
||||||
|
<HintChip
|
||||||
|
v-if="config.isDemo"
|
||||||
|
view-key="home"
|
||||||
|
message="Start in Job Review — 12 jobs are waiting for your verdict"
|
||||||
|
/>
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="home__header">
|
<header class="home__header">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -371,6 +376,10 @@ import { RouterLink } from 'vue-router'
|
||||||
import { useJobsStore } from '../stores/jobs'
|
import { useJobsStore } from '../stores/jobs'
|
||||||
import { useApiFetch } from '../composables/useApi'
|
import { useApiFetch } from '../composables/useApi'
|
||||||
import WorkflowButton from '../components/WorkflowButton.vue'
|
import WorkflowButton from '../components/WorkflowButton.vue'
|
||||||
|
import HintChip from '../components/HintChip.vue'
|
||||||
|
import { useAppConfigStore } from '../stores/appConfig'
|
||||||
|
|
||||||
|
const config = useAppConfigStore()
|
||||||
|
|
||||||
const store = useJobsStore()
|
const store = useJobsStore()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,10 @@ import { useApiFetch } from '../composables/useApi'
|
||||||
import InterviewCard from '../components/InterviewCard.vue'
|
import InterviewCard from '../components/InterviewCard.vue'
|
||||||
import MoveToSheet from '../components/MoveToSheet.vue'
|
import MoveToSheet from '../components/MoveToSheet.vue'
|
||||||
import CompanyResearchModal from '../components/CompanyResearchModal.vue'
|
import CompanyResearchModal from '../components/CompanyResearchModal.vue'
|
||||||
|
import HintChip from '../components/HintChip.vue'
|
||||||
|
import { useAppConfigStore } from '../stores/appConfig'
|
||||||
|
|
||||||
|
const config = useAppConfigStore()
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const store = useInterviewsStore()
|
const store = useInterviewsStore()
|
||||||
|
|
@ -347,6 +351,11 @@ function formatRejectionDate(job: PipelineJob): string {
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="interviews-view">
|
<div class="interviews-view">
|
||||||
|
<HintChip
|
||||||
|
v-if="config.isDemo"
|
||||||
|
view-key="interviews"
|
||||||
|
message="Figma sent an offer — open it to see the hired outcome and post-hire feedback"
|
||||||
|
/>
|
||||||
<canvas ref="confettiCanvas" class="confetti-canvas" aria-hidden="true" />
|
<canvas ref="confettiCanvas" class="confetti-canvas" aria-hidden="true" />
|
||||||
|
|
||||||
<Transition name="toast">
|
<Transition name="toast">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="review">
|
<div class="review">
|
||||||
|
<HintChip
|
||||||
|
v-if="config.isDemo"
|
||||||
|
view-key="review"
|
||||||
|
message="Swipe right to approve, left to skip. One of these jobs is a ghost post — can you spot it?"
|
||||||
|
/>
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="review__header">
|
<header class="review__header">
|
||||||
<div class="review__title-row">
|
<div class="review__title-row">
|
||||||
|
|
@ -214,6 +219,10 @@ import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useReviewStore } from '../stores/review'
|
import { useReviewStore } from '../stores/review'
|
||||||
import JobCardStack from '../components/JobCardStack.vue'
|
import JobCardStack from '../components/JobCardStack.vue'
|
||||||
|
import HintChip from '../components/HintChip.vue'
|
||||||
|
import { useAppConfigStore } from '../stores/appConfig'
|
||||||
|
|
||||||
|
const config = useAppConfigStore()
|
||||||
|
|
||||||
const store = useReviewStore()
|
const store = useReviewStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
@ -265,7 +274,8 @@ function capitalize(s: string) { return s.charAt(0).toUpperCase() + s.slice(1) }
|
||||||
async function onApprove() {
|
async function onApprove() {
|
||||||
const job = store.currentJob
|
const job = store.currentJob
|
||||||
if (!job) return
|
if (!job) return
|
||||||
await store.approve(job)
|
const ok = await store.approve(job)
|
||||||
|
if (!ok) { stackRef.value?.resetCard(); return }
|
||||||
showUndoToast('approved')
|
showUndoToast('approved')
|
||||||
checkStoopSpeed()
|
checkStoopSpeed()
|
||||||
}
|
}
|
||||||
|
|
@ -273,7 +283,8 @@ async function onApprove() {
|
||||||
async function onReject() {
|
async function onReject() {
|
||||||
const job = store.currentJob
|
const job = store.currentJob
|
||||||
if (!job) return
|
if (!job) return
|
||||||
await store.reject(job)
|
const ok = await store.reject(job)
|
||||||
|
if (!ok) { stackRef.value?.resetCard(); return }
|
||||||
showUndoToast('rejected')
|
showUndoToast('rejected')
|
||||||
checkStoopSpeed()
|
checkStoopSpeed()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ export default defineConfig({
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:8601',
|
target: process.env.VITE_API_TARGET || 'http://localhost:8601',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue