Compare commits

...

No commits in common. "main" and "master" have entirely different histories.
main ... master

74 changed files with 6572 additions and 48 deletions

12
.env.example Normal file
View file

@ -0,0 +1,12 @@
DB_USER=salary_user
DB_PASSWORD=salary_pass
DB_HOST=db
DB_PORT=5432
DB_NAME=salary_benchmark
SERPER_API_KEY=your_serper_key_here
FIRECRAWL_API_KEY=your_firecrawl_key_here
COHERE_API_KEY=your_cohere_key_here
ANTHROPIC_API_KEY=your_anthropic_key_here
JWT_SECRET=change-this-in-production

56
.gitignore vendored
View file

@ -1,50 +1,10 @@
# These are some examples of commonly ignored file patterns.
# You should customize this list as applicable to your project.
# Learn more about .gitignore:
# https://www.atlassian.com/git/tutorials/saving-changes/gitignore
# Node artifact files
.env
__pycache__/
*.pyc
.venv/
pgdata/
node_modules/
dist/
# Compiled Java class files
*.class
# Compiled Python bytecode
*.py[cod]
# Log files
*.log
# Package files
*.jar
# Maven
target/
dist/
# JetBrains IDE
.idea/
# Unit test reports
TEST*.xml
# Generated by MacOS
frontend/dist/
.DS_Store
# Generated by Windows
Thumbs.db
# Applications
*.app
*.exe
*.war
# Large media files
*.mp4
*.tiff
*.avi
*.flv
*.mov
*.wmv
deploy/config.sh
.env.prod

7
Dockerfile Normal file
View file

@ -0,0 +1,7 @@
FROM python:3.12-slim
WORKDIR /code
ENV PYTHONPATH=/code
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

171
README.md Normal file
View file

@ -0,0 +1,171 @@
# Salary Benchmark Tool
FastAPI + React + PostgreSQL app with an AI research pipeline (Serper + Firecrawl + Cohere + Claude) for benchmarking salaries.
---
## Local development
Requires Docker.
```bash
cp .env.example .env # fill in API keys
docker compose up -d
```
- Frontend: http://localhost:5179
- Backend: http://localhost:8009
- DB: 127.0.0.1:5436 (inside docker network: `db:5432`)
**Login** (seeded by migration 004): `admin@oliver.agency` / `Oliver2026!`
Migrations run automatically on app container start (`alembic upgrade head`). If your local DB drifts from the migration chain, reset it with `docker compose down -v && docker compose up -d`.
---
## Deploying to optical-dev
The target server is `optical-dev.oliver.solutions`. The app is served at **https://optical-dev.oliver.solutions/salary-benchmark/**.
### Which deploy script?
Two scripts ship with the repo — use one or the other, not both:
- **`deploy/deploy-local.sh`** — run this **on the server**, from the cloned repo at `/opt/salary-benchmark/`. This is the path we use in practice. Requires being SSH'd in as root (or a sudoer). Builds the frontend in a node container so the server doesn't need npm.
- **`deploy/deploy.sh`** — alternative: run on your laptop, it SSHes in and rsyncs. Needs `deploy/config.sh` with `SSH_TARGET` set. Handy if you don't want to commit-push-pull for every change, but not needed if you're already on the server.
The rest of this section assumes **`deploy-local.sh`** (on-server).
### Architecture on the server
- Code: `/opt/salary-benchmark/` (git checkout)
- Built frontend: `/var/www/html/salary-benchmark/` (served directly by Apache)
- Backend: Docker container bound to `127.0.0.1:<free-port>`; Apache proxies `/salary-benchmark/api/` to it
- Postgres: Docker container, no host port
- Apache fragment: `/opt/salary-benchmark/deploy/apache-salary-benchmark.conf`, included from the main vhost
### First-time deploy (on the server as root)
```bash
cd /opt
git clone https://bitbucket.org/zlalani/salary-benchmark.git
cd salary-benchmark
cp .env.example .env # fill in SERPER / FIRECRAWL / COHERE / ANTHROPIC API keys
./deploy/deploy-local.sh
```
What `deploy-local.sh` does:
1. Scans `ss -tlnH` for a free port in **81008199** for the backend
2. Builds the frontend inside a `node:20-alpine` container with `VITE_BASE=/salary-benchmark/` — no npm on server required
3. Rsyncs `frontend/dist/` to `/var/www/html/salary-benchmark/`
4. Writes `deploy/.env.prod` (generates `JWT_SECRET` + `DB_PASSWORD` on first run; preserves them on re-runs; pulls API keys from `.env`)
5. Builds + starts the Docker stack (`docker compose -f deploy/docker-compose.prod.yml up -d`)
6. Alembic migrations run on app container start
7. Renders `deploy/apache-salary-benchmark.conf` with the chosen port
8. Adds `Include /opt/salary-benchmark/deploy/apache-salary-benchmark.conf` to the vhost (first run only; backs it up first), runs `apache2ctl configtest`, reloads Apache
9. Curls `/salary-benchmark/api/health` to verify
### Redeploys
```bash
cd /opt/salary-benchmark
git pull
./deploy/deploy-local.sh
```
Idempotent — safe to re-run. Existing `JWT_SECRET`, `DB_PASSWORD`, and DB data are preserved. Frontend is rebuilt; backend image is rebuilt; containers are recreated only if their config changes.
### Configuration overrides
Environment variables you can set when calling `deploy-local.sh`:
| Variable | Default |
|-------------------|----------------------------------------------------------------|
| `URL_SUBPATH` | `/salary-benchmark/` |
| `WEB_DIR` | `/var/www/html/salary-benchmark` |
| `VHOST_FILE` | `/etc/apache2/sites-enabled/optical-dev.oliver.solutions.conf` |
| `PORT_SCAN_START` | `8100` |
| `PORT_SCAN_END` | `8199` |
Example:
```bash
PORT_SCAN_START=8200 PORT_SCAN_END=8299 ./deploy/deploy-local.sh
```
### Server prerequisites (already in place on optical-dev)
- Docker + Docker Compose
- Apache with `proxy`, `proxy_http`, `rewrite`, `headers` modules
- Existing vhost at `/etc/apache2/sites-enabled/optical-dev.oliver.solutions.conf`
- Outbound HTTPS (to pull node/python base images, npm/pip packages)
### Compose project name — important on shared servers
On `optical-dev`, multiple apps each live at `/opt/<app>/deploy/` with their own `docker-compose.prod.yml`. Docker Compose derives the project name from the parent dir unless told otherwise — so **every** app would default to project `deploy`, sharing container names and volume namespaces. That's how another app on this box recently lost 2 days of data.
We pin it two ways (belt-and-braces):
1. `name: salary-benchmark` at the top of `deploy/docker-compose.prod.yml`
2. `-p salary-benchmark` on every `docker compose` invocation in `deploy-local.sh`
When running compose commands manually on the server, always include `-p salary-benchmark`:
```bash
cd /opt/salary-benchmark/deploy
docker compose -p salary-benchmark -f docker-compose.prod.yml --env-file .env.prod ps
docker compose -p salary-benchmark -f docker-compose.prod.yml --env-file .env.prod logs app --tail 50
```
**Never run `docker compose down -v`** — `-v` deletes volumes. If another app is (or was) sharing the `deploy_*` volume namespace you can destroy their data. If you genuinely need to wipe this app's DB, target the specific volume: `docker volume rm salary-benchmark_pgdata`.
### Rollback
```bash
# restore the previous vhost (backup was created with a timestamp suffix)
ls /etc/apache2/sites-enabled/optical-dev.oliver.solutions.conf.bak.*
sudo cp <that-file> /etc/apache2/sites-enabled/optical-dev.oliver.solutions.conf
sudo apache2ctl configtest && sudo systemctl reload apache2
# stop containers
cd /opt/salary-benchmark/deploy
docker compose -f docker-compose.prod.yml --env-file .env.prod down
```
---
## Troubleshooting
**Backend container won't start** — check logs:
```bash
cd /opt/salary-benchmark/deploy
docker compose -f docker-compose.prod.yml --env-file .env.prod logs app --tail 50
```
**Apache config test fails after the Include is added** — restore the backup:
```bash
sudo cp /etc/apache2/sites-enabled/optical-dev.oliver.solutions.conf.bak.<timestamp> /etc/apache2/sites-enabled/optical-dev.oliver.solutions.conf
sudo apache2ctl configtest
```
**404 on `/salary-benchmark/` but `/salary-benchmark/api/health` works** — frontend build didn't copy. Re-run the deploy script; check that `/var/www/html/salary-benchmark/index.html` exists.
**Login fails with correct password** — migration 004 may not have run. Check:
```bash
docker compose -f docker-compose.prod.yml --env-file .env.prod exec db \
psql -U salary_user -d salary_benchmark -c "SELECT email FROM users;"
```
**Port conflict** — the script scans 81008199. To see what's bound: `ss -tlnp`. Override the scan range via `PORT_SCAN_START`/`PORT_SCAN_END`.
---
## Later: Azure SSO
When moving from local auth to Microsoft Azure SSO, the isolated swap points are:
- `app/routers/auth.py` — replace `/login` with an OIDC callback that validates the Microsoft token and issues the same internal JWT
- `frontend/src/pages/LoginPage.jsx` — replace the form with an MSAL "Sign in with Microsoft" button
The `AuthContext`, `ProtectedRoute`, JWT middleware, and API Authorization header plumbing stay unchanged.

36
alembic.ini Normal file
View file

@ -0,0 +1,36 @@
[alembic]
script_location = alembic
sqlalchemy.url = postgresql+asyncpg://salary_user:salary_pass@db:5432/salary_benchmark
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

40
alembic/env.py Normal file
View file

@ -0,0 +1,40 @@
import asyncio
from logging.config import fileConfig
from alembic import context
from sqlalchemy.ext.asyncio import create_async_engine
from app.config import settings
from app.models import Base
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline():
url = settings.database_url
context.configure(url=url, target_metadata=target_metadata, literal_binds=True)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection):
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online():
connectable = create_async_engine(settings.database_url)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())

24
alembic/script.py.mako Normal file
View file

@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View file

@ -0,0 +1,105 @@
"""Initial schema
Revision ID: 001
Revises:
Create Date: 2026-04-02
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID, JSONB
revision: str = "001"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"locations",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("city", sa.String(255), nullable=False),
sa.Column("country", sa.String(100), server_default="US"),
sa.Column("normalized_name", sa.String(255), unique=True, nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
),
)
op.create_table(
"roles",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("title", sa.String(255), nullable=False),
sa.Column("normalized_title", sa.String(255), unique=True, nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
),
)
op.create_table(
"benchmarks",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("role_id", sa.Integer(), sa.ForeignKey("roles.id"), nullable=False),
sa.Column(
"location_id",
sa.Integer(),
sa.ForeignKey("locations.id"),
nullable=False,
),
sa.Column("level", sa.String(20), nullable=False),
sa.Column("salary_low", sa.Integer(), nullable=False),
sa.Column("salary_median", sa.Integer(), nullable=False),
sa.Column("salary_high", sa.Integer(), nullable=False),
sa.Column("source", sa.String(50), server_default="seed"),
sa.Column("confidence_score", sa.Float(), nullable=True),
sa.Column("validated", sa.Boolean(), server_default="false"),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
),
sa.UniqueConstraint("role_id", "location_id", "level", name="uq_benchmark"),
)
op.create_table(
"research_sessions",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("role_id", sa.Integer(), sa.ForeignKey("roles.id"), nullable=False),
sa.Column(
"location_id",
sa.Integer(),
sa.ForeignKey("locations.id"),
nullable=False,
),
sa.Column("status", sa.String(30), server_default="searching"),
sa.Column("serper_results", JSONB, nullable=True),
sa.Column("firecrawl_results", JSONB, nullable=True),
sa.Column("cohere_ranked", JSONB, nullable=True),
sa.Column("claude_analysis", JSONB, nullable=True),
sa.Column("proposed_benchmarks", JSONB, nullable=True),
sa.Column("error_message", sa.String(), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
)
def downgrade() -> None:
op.drop_table("research_sessions")
op.drop_table("benchmarks")
op.drop_table("roles")
op.drop_table("locations")

View file

@ -0,0 +1,90 @@
"""Seed NY benchmark data
Revision ID: 002
Revises: 001
Create Date: 2026-04-02
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "002"
down_revision: Union[str, None] = "001"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
SEED_DATA = [
# (title, level, salary) — all New York
("Project Manager", "mid", 110000),
("Project Manager", "senior", 140000),
("Studio Manager", "mid", 145000),
("Studio Manager", "senior", 160000),
("Account Director", "senior", 165000),
("Digital Designer", "junior", 80000),
("Digital Designer", "mid", 100000),
("Digital Designer", "senior", 120000),
("Strategy Director", "senior", 220000),
("Art Director", "junior", 95000),
("Art Director", "mid", 120000),
("Art Director", "senior", 140000),
("Integrated Designer", "junior", 80000),
("Integrated Designer", "senior", 130000),
("Motion Designer", "junior", 90000),
("Motion Designer", "mid", 115000),
("Motion Designer", "senior", 140000),
("Copywriter", "junior", 85000),
("Copywriter", "mid", 120000),
("Copywriter", "senior", 145000),
("Social Media Manager", "junior", 85000),
("Social Media Manager", "mid", 110000),
("Social Media Manager", "senior", 130000),
("Community Manager", "junior", 75000),
("Community Manager", "mid", 95000),
("Community Manager", "senior", 115000),
]
def upgrade() -> None:
# Insert location
op.execute(
"INSERT INTO locations (city, country, normalized_name) "
"VALUES ('New York', 'US', 'new york')"
)
# Collect unique titles
titles = sorted(set(title for title, _, _ in SEED_DATA))
for title in titles:
norm = title.strip().lower()
op.execute(
sa.text(
"INSERT INTO roles (title, normalized_title) VALUES (:t, :n)"
).bindparams(t=title, n=norm)
)
# Insert benchmarks — use median as the single value, derive low/high as -10%/+10%
for title, level, salary in SEED_DATA:
salary_low = int(salary * 0.90)
salary_high = int(salary * 1.10)
op.execute(
sa.text(
"INSERT INTO benchmarks (role_id, location_id, level, salary_low, salary_median, salary_high, source, validated) "
"VALUES ("
" (SELECT id FROM roles WHERE normalized_title = :norm_title),"
" (SELECT id FROM locations WHERE normalized_name = 'new york'),"
" :level, :low, :median, :high, 'seed', true"
")"
).bindparams(
norm_title=title.strip().lower(),
level=level,
low=salary_low,
median=salary,
high=salary_high,
)
)
def downgrade() -> None:
op.execute("DELETE FROM benchmarks WHERE source = 'seed'")
op.execute("DELETE FROM roles")
op.execute("DELETE FROM locations WHERE normalized_name = 'new york'")

View file

@ -0,0 +1,36 @@
"""Simplify to single salary column
Revision ID: 003
Revises: 002
Create Date: 2026-04-02
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "003"
down_revision: Union[str, None] = "002"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add new salary column, populate from salary_median, drop old columns
op.add_column("benchmarks", sa.Column("salary", sa.Integer(), nullable=True))
op.execute("UPDATE benchmarks SET salary = salary_median")
op.alter_column("benchmarks", "salary", nullable=False)
op.drop_column("benchmarks", "salary_low")
op.drop_column("benchmarks", "salary_median")
op.drop_column("benchmarks", "salary_high")
def downgrade() -> None:
op.add_column("benchmarks", sa.Column("salary_high", sa.Integer(), nullable=True))
op.add_column("benchmarks", sa.Column("salary_median", sa.Integer(), nullable=True))
op.add_column("benchmarks", sa.Column("salary_low", sa.Integer(), nullable=True))
op.execute("UPDATE benchmarks SET salary_median = salary, salary_low = salary * 0.9, salary_high = salary * 1.1")
op.alter_column("benchmarks", "salary_low", nullable=False)
op.alter_column("benchmarks", "salary_median", nullable=False)
op.alter_column("benchmarks", "salary_high", nullable=False)
op.drop_column("benchmarks", "salary")

View file

@ -0,0 +1,45 @@
"""Add users table and seed admin
Revision ID: 004
Revises: 003
Create Date: 2026-04-17
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from passlib.context import CryptContext
revision: str = "004"
down_revision: Union[str, None] = "003"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"users",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("email", sa.String(255), nullable=False, unique=True),
sa.Column("password_hash", sa.String(255), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
nullable=False,
server_default=sa.func.now(),
),
)
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
password_hash = pwd_context.hash("Oliver2026!")
op.execute(
sa.text(
"INSERT INTO users (email, password_hash) VALUES (:email, :hash)"
).bindparams(email="admin@oliver.agency", hash=password_hash)
)
def downgrade() -> None:
op.drop_table("users")

View file

@ -0,0 +1,137 @@
"""Wipe and reseed benchmarks with canonical NYC dataset (67 entries)
Revision ID: 005
Revises: 004
Create Date: 2026-04-17
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "005"
down_revision: Union[str, None] = "004"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
ROLES = [
"Account Manager", "Account Director", "Project Manager",
"Social Media Manager", "Community Manager", "Copywriter",
"Global Client Managing Director", "Managing Partner",
"Group Account Director", "Business Director",
"Global Programme Director", "Ops/Program Director",
"PM / Scrum Master", "Transcreation Manager",
"Chief Strategy Officer", "Global Executive Strategy Director",
"Strategy Director", "Channel Specialist Strategist",
"Head of Data Strategy", "Data Strategy Lead", "Data Planner",
"Data Analyst", "Design Lead/Director", "UX/UI Designer",
"Motion Designer", "Video Producer", "Editorial Manager",
"Linguist", "Head of Production", "Producer", "SEO Director",
"SEO Manager", "Social & Strategic Lead", "Channel Manager",
"Global Head of POSM", "Head of Studio", "E-Comm Director",
"SFMC Consultant", "SFMC Campaign Manager", "CRM Delivery Lead",
"CRM Data Strategy Director", "CRM Manager",
"CRM Senior Programme Manager", "HTML Developer", "CMS Manager",
"CRM Analyst", "Content Manager", "Designer", "Art Director",
"Strategist", "Motion Graphics", "Artworker", "Creative Director",
]
# (normalized_title, level, salary)
BENCHMARKS = [
("account manager", "mid", 100000),
("account manager", "senior", 120000),
("account director", "mid", 155000),
("account director", "senior", 165000),
("project manager", "mid", 115000),
("project manager", "senior", 140000),
("social media manager", "mid", 110000),
("social media manager", "senior", 130000),
("community manager", "mid", 95000),
("community manager", "senior", 115000),
("copywriter", "mid", 120000),
("copywriter", "senior", 145000),
("global client managing director", "mid", 315000),
("managing partner", "mid", 275000),
("group account director", "mid", 185000),
("business director", "mid", 230000),
("global programme director", "mid", 215000),
("ops/program director", "mid", 200000),
("pm / scrum master", "senior", 145000),
("transcreation manager", "mid", 150000),
("chief strategy officer", "mid", 315000),
("global executive strategy director", "mid", 235000),
("strategy director", "mid", 185000),
("channel specialist strategist", "senior", 150000),
("head of data strategy", "mid", 200000),
("data strategy lead", "mid", 170000),
("data planner", "senior", 140000),
("data analyst", "mid", 100000),
("design lead/director", "mid", 165000),
("ux/ui designer", "senior", 150000),
("motion designer", "mid", 115000),
("video producer", "mid", 130000),
("editorial manager", "mid", 130000),
("linguist", "mid", 110000),
("head of production", "mid", 185000),
("producer", "mid", 120000),
("producer", "senior", 145000),
("seo director", "mid", 170000),
("seo manager", "mid", 135000),
("social & strategic lead", "mid", 165000),
("channel manager", "mid", 140000),
("channel manager", "senior", 155000),
("global head of posm", "mid", 220000),
("head of studio", "mid", 170000),
("e-comm director", "mid", 210000),
("sfmc consultant", "mid", 165000),
("sfmc campaign manager", "mid", 150000),
("crm delivery lead", "mid", 200000),
("crm data strategy director", "mid", 185000),
("crm manager", "mid", 140000),
("crm senior programme manager", "mid", 165000),
("html developer", "mid", 100000),
("cms manager", "mid", 145000),
("crm analyst", "mid", 110000),
("content manager", "mid", 115000),
("content manager", "senior", 130000),
("designer", "mid", 100000),
("designer", "senior", 120000),
("art director", "mid", 135000),
("art director", "senior", 150000),
("strategist", "mid", 120000),
("strategist", "senior", 145000),
("motion graphics", "mid", 110000),
("motion graphics", "senior", 135000),
("artworker", "mid", 85000),
("artworker", "senior", 95000),
("creative director", "mid", 230000),
]
def upgrade() -> None:
op.execute("TRUNCATE benchmarks, research_sessions, roles, locations RESTART IDENTITY CASCADE")
op.execute(
"INSERT INTO locations (city, country, normalized_name) "
"VALUES ('New York', 'US', 'new york')"
)
for title in ROLES:
op.execute(
sa.text(
"INSERT INTO roles (title, normalized_title) VALUES (:t, :n)"
).bindparams(t=title, n=title.lower())
)
for norm_title, level, salary in BENCHMARKS:
op.execute(
sa.text(
"INSERT INTO benchmarks (role_id, location_id, level, salary, source, validated) "
"SELECT r.id, l.id, :level, :salary, 'seed', true "
"FROM roles r, locations l "
"WHERE r.normalized_title = :norm_title "
"AND l.normalized_name = 'new york'"
).bindparams(norm_title=norm_title, level=level, salary=salary)
)
def downgrade() -> None:
op.execute("DELETE FROM benchmarks WHERE source = 'seed'")

0
app/__init__.py Normal file
View file

30
app/config.py Normal file
View file

@ -0,0 +1,30 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
db_user: str = "salary_user"
db_password: str = "salary_pass"
db_host: str = "db"
db_port: int = 5432
db_name: str = "salary_benchmark"
serper_api_key: str = ""
firecrawl_api_key: str = ""
cohere_api_key: str = ""
anthropic_api_key: str = ""
jwt_secret: str = "dev-secret-change-in-prod"
jwt_algorithm: str = "HS256"
jwt_expires_minutes: int = 480
@property
def database_url(self) -> str:
return (
f"postgresql+asyncpg://{self.db_user}:{self.db_password}"
f"@{self.db_host}:{self.db_port}/{self.db_name}"
)
model_config = {"env_file": ".env"}
settings = Settings()

11
app/database.py Normal file
View file

@ -0,0 +1,11 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.config import settings
engine = create_async_engine(settings.database_url, echo=False)
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async def get_db():
async with async_session() as session:
yield session

22
app/deps.py Normal file
View file

@ -0,0 +1,22 @@
from fastapi import Depends, Header, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import User
from app.services.auth_service import decode_token, get_user_by_id
async def get_current_user(
authorization: str | None = Header(default=None),
db: AsyncSession = Depends(get_db),
) -> User:
if not authorization or not authorization.lower().startswith("bearer "):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
token = authorization.split(" ", 1)[1].strip()
user_id = decode_token(token)
if user_id is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
user = await get_user_by_id(db, user_id)
if not user or not user.is_active:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive user")
return user

29
app/main.py Normal file
View file

@ -0,0 +1,29 @@
from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from app.deps import get_current_user
from app.routers import auth, benchmarks, research
app = FastAPI(title="Salary Benchmark Tool")
app.add_middleware(
CORSMiddleware,
allow_origins=[
"http://localhost:5173",
"http://localhost:5179",
"https://optical-dev.oliver.solutions",
],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(auth.router, prefix="/api")
app.include_router(benchmarks.router, prefix="/api", dependencies=[Depends(get_current_user)])
app.include_router(research.router, prefix="/api", dependencies=[Depends(get_current_user)])
@app.get("/api/health")
async def health():
return {"status": "ok"}

116
app/models.py Normal file
View file

@ -0,0 +1,116 @@
import uuid
from datetime import datetime, timezone
from sqlalchemy import (
Boolean,
DateTime,
Float,
ForeignKey,
Integer,
String,
UniqueConstraint,
)
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
pass
class Location(Base):
__tablename__ = "locations"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
city: Mapped[str] = mapped_column(String(255))
country: Mapped[str] = mapped_column(String(100), default="US")
normalized_name: Mapped[str] = mapped_column(String(255), unique=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
benchmarks: Mapped[list["Benchmark"]] = relationship(back_populates="location")
research_sessions: Mapped[list["ResearchSession"]] = relationship(
back_populates="location"
)
class Role(Base):
__tablename__ = "roles"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
title: Mapped[str] = mapped_column(String(255))
normalized_title: Mapped[str] = mapped_column(String(255), unique=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
benchmarks: Mapped[list["Benchmark"]] = relationship(back_populates="role")
research_sessions: Mapped[list["ResearchSession"]] = relationship(
back_populates="role"
)
class Benchmark(Base):
__tablename__ = "benchmarks"
__table_args__ = (
UniqueConstraint("role_id", "location_id", "level", name="uq_benchmark"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
role_id: Mapped[int] = mapped_column(ForeignKey("roles.id"))
location_id: Mapped[int] = mapped_column(ForeignKey("locations.id"))
level: Mapped[str] = mapped_column(String(20))
salary: Mapped[int] = mapped_column(Integer)
source: Mapped[str] = mapped_column(String(50), default="seed")
confidence_score: Mapped[float | None] = mapped_column(Float, nullable=True)
validated: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)
role: Mapped["Role"] = relationship(back_populates="benchmarks")
location: Mapped["Location"] = relationship(back_populates="benchmarks")
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True)
password_hash: Mapped[str] = mapped_column(String(255))
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
class ResearchSession(Base):
__tablename__ = "research_sessions"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
role_id: Mapped[int] = mapped_column(ForeignKey("roles.id"))
location_id: Mapped[int] = mapped_column(ForeignKey("locations.id"))
status: Mapped[str] = mapped_column(String(30), default="searching")
serper_results: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
firecrawl_results: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
cohere_ranked: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
claude_analysis: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
proposed_benchmarks: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
error_message: Mapped[str | None] = mapped_column(String, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
completed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
role: Mapped["Role"] = relationship(back_populates="research_sessions")
location: Mapped["Location"] = relationship(back_populates="research_sessions")

0
app/routers/__init__.py Normal file
View file

43
app/routers/auth.py Normal file
View file

@ -0,0 +1,43 @@
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, EmailStr
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.deps import get_current_user
from app.models import User
from app.services.auth_service import authenticate, create_access_token
router = APIRouter(prefix="/auth", tags=["auth"])
class LoginRequest(BaseModel):
email: EmailStr
password: str
class LoginResponse(BaseModel):
access_token: str
token_type: str = "bearer"
email: str
class MeResponse(BaseModel):
id: int
email: str
@router.post("/login", response_model=LoginResponse)
async def login(body: LoginRequest, db: AsyncSession = Depends(get_db)):
user = await authenticate(db, body.email, body.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or password",
)
token = create_access_token(user.id)
return LoginResponse(access_token=token, email=user.email)
@router.get("/me", response_model=MeResponse)
async def me(user: User = Depends(get_current_user)):
return MeResponse(id=user.id, email=user.email)

31
app/routers/benchmarks.py Normal file
View file

@ -0,0 +1,31 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.schemas import BenchmarkOut, BulkLookupRequest, BulkLookupResponse
from app.services.benchmark_service import lookup_benchmarks
router = APIRouter(tags=["benchmarks"])
@router.get("/benchmarks", response_model=list[BenchmarkOut])
async def get_benchmarks(
title: str, location: str, db: AsyncSession = Depends(get_db)
):
results = await lookup_benchmarks(db, title, location)
if not results:
raise HTTPException(status_code=404, detail="No benchmarks found")
return results
@router.post("/benchmarks/bulk", response_model=BulkLookupResponse)
async def bulk_lookup(req: BulkLookupRequest, db: AsyncSession = Depends(get_db)):
found = {}
not_found = []
for title in req.titles:
results = await lookup_benchmarks(db, title, req.location)
if results:
found[title] = results
else:
not_found.append(title)
return BulkLookupResponse(found=found, not_found=not_found)

130
app/routers/research.py Normal file
View file

@ -0,0 +1,130 @@
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db, async_session
from app.models import Benchmark, Location, ResearchSession, Role
from app.schemas import (
BulkResearchRequest,
ResearchRequest,
ResearchStatusOut,
ValidateRequest,
)
from app.services.benchmark_service import get_or_create_role, get_or_create_location
from app.services.research_pipeline import run_research_pipeline
import uuid
from datetime import datetime, timezone
router = APIRouter(tags=["research"])
@router.post("/research")
async def start_research(
req: ResearchRequest,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
):
role = await get_or_create_role(db, req.title)
location = await get_or_create_location(db, req.location)
await db.commit()
session = ResearchSession(
role_id=role.id,
location_id=location.id,
status="searching",
)
db.add(session)
await db.commit()
await db.refresh(session)
background_tasks.add_task(run_research_pipeline, str(session.id))
return {"session_id": str(session.id)}
@router.get("/research/{session_id}", response_model=ResearchStatusOut)
async def get_research_status(session_id: str, db: AsyncSession = Depends(get_db)):
uid = uuid.UUID(session_id)
result = await db.execute(
select(ResearchSession).where(ResearchSession.id == uid)
)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(status_code=404, detail="Research session not found")
return ResearchStatusOut(
session_id=str(session.id),
status=session.status,
proposed_benchmarks=session.proposed_benchmarks,
claude_analysis=session.claude_analysis,
error_message=session.error_message,
)
@router.post("/research/{session_id}/validate")
async def validate_research(
session_id: str,
req: ValidateRequest,
db: AsyncSession = Depends(get_db),
):
uid = uuid.UUID(session_id)
result = await db.execute(
select(ResearchSession).where(ResearchSession.id == uid)
)
session = result.scalar_one_or_none()
if not session:
raise HTTPException(status_code=404, detail="Research session not found")
if session.status != "pending_validation":
raise HTTPException(status_code=400, detail="Session not ready for validation")
if not req.approved:
session.status = "rejected"
await db.commit()
return {"status": "rejected"}
proposed = session.proposed_benchmarks
if not proposed or "benchmarks" not in proposed:
raise HTTPException(status_code=400, detail="No proposed benchmarks to validate")
for entry in proposed["benchmarks"]:
level = entry["level"]
adjustment = (req.adjustments or {}).get(level)
salary = adjustment.salary if adjustment and adjustment.salary else entry["salary"]
benchmark = Benchmark(
role_id=session.role_id,
location_id=session.location_id,
level=level,
salary=salary,
source="research",
confidence_score=proposed.get("confidence_score"),
validated=True,
)
db.add(benchmark)
session.status = "completed"
session.completed_at = datetime.now(timezone.utc)
await db.commit()
return {"status": "completed"}
@router.post("/research/bulk")
async def bulk_research(
req: BulkResearchRequest,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
):
sessions = []
location = await get_or_create_location(db, req.location)
for title in req.titles[:20]:
role = await get_or_create_role(db, title)
await db.commit()
session = ResearchSession(
role_id=role.id,
location_id=location.id,
status="searching",
)
db.add(session)
await db.commit()
await db.refresh(session)
background_tasks.add_task(run_research_pipeline, str(session.id))
sessions.append({"title": title, "session_id": str(session.id)})
return {"sessions": sessions}

55
app/schemas.py Normal file
View file

@ -0,0 +1,55 @@
from pydantic import BaseModel
class BenchmarkOut(BaseModel):
role: str
location: str
level: str
salary: int
source: str
validated: bool
confidence_score: float | None = None
model_config = {"from_attributes": True}
class SingleLookupParams(BaseModel):
title: str
location: str
class BulkLookupRequest(BaseModel):
location: str
titles: list[str]
class BulkLookupResponse(BaseModel):
found: dict[str, list[BenchmarkOut]]
not_found: list[str]
class ResearchRequest(BaseModel):
title: str
location: str
class BulkResearchRequest(BaseModel):
location: str
titles: list[str]
class ResearchStatusOut(BaseModel):
session_id: str
status: str
proposed_benchmarks: dict | None = None
claude_analysis: dict | None = None
error_message: str | None = None
class BenchmarkAdjustment(BaseModel):
salary: int | None = None
class ValidateRequest(BaseModel):
approved: bool
adjustments: dict[str, BenchmarkAdjustment] | None = None

0
app/services/__init__.py Normal file
View file

View file

@ -0,0 +1,45 @@
from datetime import datetime, timedelta, timezone
from jose import JWTError, jwt
from passlib.context import CryptContext
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models import User
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def create_access_token(user_id: int) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expires_minutes)
payload = {"sub": str(user_id), "exp": expire}
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
def decode_token(token: str) -> int | None:
try:
payload = jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
sub = payload.get("sub")
return int(sub) if sub else None
except (JWTError, ValueError):
return None
async def authenticate(db: AsyncSession, email: str, password: str) -> User | None:
result = await db.execute(select(User).where(User.email == email.lower().strip()))
user = result.scalar_one_or_none()
if not user or not user.is_active:
return None
if not verify_password(password, user.password_hash):
return None
return user
async def get_user_by_id(db: AsyncSession, user_id: int) -> User | None:
result = await db.execute(select(User).where(User.id == user_id))
return result.scalar_one_or_none()

View file

@ -0,0 +1,96 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Benchmark, Location, Role
from app.schemas import BenchmarkOut
LOCATION_ALIASES = {
"nyc": "new york",
"new york city": "new york",
"manhattan": "new york",
"sf": "san francisco",
"san fran": "san francisco",
"la": "los angeles",
"chi": "chicago",
"dc": "washington dc",
"washington": "washington dc",
"philly": "philadelphia",
"atl": "atlanta",
"bos": "boston",
"dal": "dallas",
"hou": "houston",
"sea": "seattle",
"pdx": "portland",
"den": "denver",
"mia": "miami",
"det": "detroit",
"mpls": "minneapolis",
"nola": "new orleans",
"london": "london",
"ldn": "london",
}
def normalize(text: str) -> str:
cleaned = text.strip().lower()
return LOCATION_ALIASES.get(cleaned, cleaned)
def normalize_title(text: str) -> str:
return text.strip().lower()
async def get_or_create_role(db: AsyncSession, title: str) -> Role:
norm = normalize_title(title)
result = await db.execute(select(Role).where(Role.normalized_title == norm))
role = result.scalar_one_or_none()
if not role:
role = Role(title=title.strip(), normalized_title=norm)
db.add(role)
await db.flush()
return role
async def get_or_create_location(db: AsyncSession, location: str) -> Location:
norm = normalize(location)
result = await db.execute(
select(Location).where(Location.normalized_name == norm)
)
loc = result.scalar_one_or_none()
if not loc:
# Use the canonical name as the display city name
loc = Location(city=norm.title(), normalized_name=norm)
db.add(loc)
await db.flush()
return loc
async def lookup_benchmarks(
db: AsyncSession, title: str, location: str
) -> list[BenchmarkOut]:
norm_title = normalize_title(title)
norm_location = normalize(location)
result = await db.execute(
select(Benchmark, Role, Location)
.join(Role, Benchmark.role_id == Role.id)
.join(Location, Benchmark.location_id == Location.id)
.where(Role.normalized_title == norm_title)
.where(Location.normalized_name == norm_location)
.order_by(
Benchmark.level.desc()
)
)
rows = result.all()
return [
BenchmarkOut(
role=role.title,
location=loc.city,
level=bench.level,
salary=bench.salary,
source=bench.source,
validated=bench.validated,
confidence_score=bench.confidence_score,
)
for bench, role, loc in rows
]

View file

@ -0,0 +1,54 @@
import json
import anthropic
from app.config import settings
async def analyze_salary_data(
title: str, location: str, ranked_content: list[dict]
) -> dict:
sources_text = "\n\n---\n\n".join(
item["content"] for item in ranked_content if item.get("content")
)
prompt = f"""You are a compensation analyst. Based on the following salary data sources
for the role "{title}" in "{location}", produce a structured benchmark.
[SOURCES]
{sources_text}
[/SOURCES]
Return ONLY valid JSON in this exact format:
{{
"benchmarks": [
{{"level": "junior", "salary": <int>}},
{{"level": "mid", "salary": <int>}},
{{"level": "senior", "salary": <int>}}
],
"confidence_score": <float 0.0-1.0>,
"reasoning": "<Brief explanation of how you derived these numbers>",
"sources_used": ["<relevant source descriptions>"]
}}
Rules:
- Each salary value is a single annual USD integer representing the typical/median salary for that level
- Base your estimates on the provided data, not general knowledge
- If data is sparse, lower the confidence_score accordingly
- Return ONLY the JSON object, no markdown or explanation"""
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
message = await client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=1024,
messages=[{"role": "user", "content": prompt}],
)
response_text = message.content[0].text.strip()
# Strip markdown code fences if present
if response_text.startswith("```"):
response_text = response_text.split("\n", 1)[1]
if response_text.endswith("```"):
response_text = response_text[:-3].strip()
return json.loads(response_text)

View file

@ -0,0 +1,27 @@
import cohere
from app.config import settings
async def rerank_results(query: str, chunks: list[str]) -> dict:
if not chunks:
return {"top_chunks": []}
client = cohere.AsyncClientV2(api_key=settings.cohere_api_key)
response = await client.rerank(
model="rerank-v3.5",
query=query,
documents=chunks,
top_n=min(5, len(chunks)),
)
top_chunks = []
for result in response.results:
top_chunks.append(
{
"content": chunks[result.index],
"relevance_score": result.relevance_score,
}
)
return {"top_chunks": top_chunks}

View file

@ -0,0 +1,31 @@
import asyncio
import httpx
from app.config import settings
SEMAPHORE = asyncio.Semaphore(3)
async def _scrape_one(client: httpx.AsyncClient, url: str) -> dict:
async with SEMAPHORE:
try:
resp = await client.post(
"https://api.firecrawl.dev/v1/scrape",
headers={"Authorization": f"Bearer {settings.firecrawl_api_key}"},
json={"url": url, "formats": ["markdown"]},
timeout=30,
)
resp.raise_for_status()
data = resp.json()
markdown = data.get("data", {}).get("markdown", "")
# Truncate to avoid sending huge content downstream
return {"url": url, "content": markdown[:3000], "success": True}
except Exception as e:
return {"url": url, "content": "", "success": False, "error": str(e)}
async def scrape_urls(urls: list[str]) -> dict:
async with httpx.AsyncClient() as client:
tasks = [_scrape_one(client, url) for url in urls]
results = await asyncio.gather(*tasks)
return {"scraped": [r for r in results if r["success"] and r["content"]]}

View file

@ -0,0 +1,86 @@
import uuid
import logging
from datetime import datetime, timezone
from sqlalchemy import select
from app.database import async_session
from app.models import ResearchSession
from app.services.serper_client import search_salaries
from app.services.firecrawl_client import scrape_urls
from app.services.cohere_client import rerank_results
from app.services.claude_client import analyze_salary_data
logger = logging.getLogger(__name__)
async def run_research_pipeline(session_id: str):
uid = uuid.UUID(session_id)
async with async_session() as db:
result = await db.execute(
select(ResearchSession).where(ResearchSession.id == uid)
)
session = result.scalar_one_or_none()
if not session:
logger.error(f"Research session {session_id} not found")
return
# Load role and location names
from app.models import Role, Location
role_result = await db.execute(select(Role).where(Role.id == session.role_id))
role = role_result.scalar_one()
loc_result = await db.execute(
select(Location).where(Location.id == session.location_id)
)
location = loc_result.scalar_one()
title = role.title
city = location.city
try:
# Step 1: Search
session.status = "searching"
await db.commit()
serper_results = await search_salaries(title, city)
session.serper_results = serper_results
await db.commit()
# Step 2: Scrape
session.status = "scraping"
await db.commit()
urls = [r["link"] for r in serper_results.get("results", [])[:8]]
firecrawl_results = await scrape_urls(urls)
session.firecrawl_results = firecrawl_results
await db.commit()
# Step 3: Rerank
session.status = "ranking"
await db.commit()
chunks = [
item["content"]
for item in firecrawl_results.get("scraped", [])
if item.get("content")
]
query = f"salary compensation range for {title} in {city} junior mid senior levels"
ranked = await rerank_results(query, chunks)
session.cohere_ranked = ranked
await db.commit()
# Step 4: Analyze
session.status = "analyzing"
await db.commit()
top_content = ranked.get("top_chunks", [])
analysis = await analyze_salary_data(title, city, top_content)
session.claude_analysis = analysis
session.proposed_benchmarks = analysis
session.status = "pending_validation"
session.completed_at = datetime.now(timezone.utc)
await db.commit()
except Exception as e:
logger.exception(f"Research pipeline failed for session {session_id}")
session.status = "failed"
session.error_message = str(e)
await db.commit()

View file

@ -0,0 +1,37 @@
import httpx
from app.config import settings
async def search_salaries(title: str, location: str) -> dict:
queries = [
f'"{title}" salary range {location} 2025 2026',
f'"{title}" compensation {location} glassdoor levels.fyi',
]
all_results = []
seen_urls = set()
async with httpx.AsyncClient(timeout=30) as client:
for query in queries:
resp = await client.post(
"https://google.serper.dev/search",
headers={"X-API-KEY": settings.serper_api_key},
json={"q": query, "num": 10},
)
resp.raise_for_status()
data = resp.json()
for item in data.get("organic", []):
url = item.get("link", "")
if url not in seen_urls:
seen_urls.add(url)
all_results.append(
{
"title": item.get("title", ""),
"link": url,
"snippet": item.get("snippet", ""),
}
)
return {"results": all_results[:15]}

15
deploy/Dockerfile.prod Normal file
View file

@ -0,0 +1,15 @@
FROM python:3.12-slim
WORKDIR /code
ENV PYTHONPATH=/code \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY app ./app
COPY alembic ./alembic
COPY alembic.ini ./alembic.ini
CMD ["sh", "-c", "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 2"]

View file

@ -0,0 +1,20 @@
# Salary Benchmark — FastAPI backend at :__APP_PORT__, SPA at /var/www/html/salary-benchmark
# Include this file from the main optical-dev vhost.
ProxyTimeout 300
ProxyPass /salary-benchmark/api/ http://127.0.0.1:__APP_PORT__/api/ timeout=300
ProxyPassReverse /salary-benchmark/api/ http://127.0.0.1:__APP_PORT__/api/
Alias /salary-benchmark /var/www/html/salary-benchmark
<Directory /var/www/html/salary-benchmark>
Options -Indexes +FollowSymLinks
AllowOverride None
Require all granted
RewriteEngine On
RewriteBase /salary-benchmark/
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.html [L]
</Directory>

24
deploy/config.sh.example Normal file
View file

@ -0,0 +1,24 @@
# Copy this file to deploy/config.sh and fill in your values.
# config.sh is gitignored.
# SSH target — user@host (or a Host alias from ~/.ssh/config)
SSH_TARGET="user@optical-dev.oliver.solutions"
# Remote paths (usually leave as-is)
REMOTE_APP_DIR="/opt/salary-benchmark"
REMOTE_WEB_DIR="/var/www/html/salary-benchmark"
REMOTE_VHOST_FILE="/etc/apache2/sites-available/optical-dev.conf"
# URL subpath the app is mounted under (trailing slash matters for Vite)
URL_SUBPATH="/salary-benchmark/"
# Port scan range for the backend container (picks first free one)
PORT_SCAN_START=8100
PORT_SCAN_END=8199
# API keys — pulled from your local .env automatically; override here if needed.
# Leave unset to source from ./.env in the project root.
# SERPER_API_KEY=
# FIRECRAWL_API_KEY=
# COHERE_API_KEY=
# ANTHROPIC_API_KEY=

142
deploy/deploy-local.sh Executable file
View file

@ -0,0 +1,142 @@
#!/usr/bin/env bash
# Run this ON the server, from the cloned repo root (e.g. /opt/salary-benchmark).
# Assumes you've already created .env with your API keys. Idempotent.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
URL_SUBPATH="${URL_SUBPATH:-/salary-benchmark/}"
WEB_DIR="${WEB_DIR:-/var/www/html/salary-benchmark}"
VHOST_FILE="${VHOST_FILE:-/etc/apache2/sites-enabled/optical-dev.oliver.solutions.conf}"
PORT_SCAN_START="${PORT_SCAN_START:-8100}"
PORT_SCAN_END="${PORT_SCAN_END:-8199}"
# Pin compose project name. Defaulting to the parent dir name gives every app on
# this server the same project ("deploy"), sharing container + volume namespaces
# and causing cross-app data loss. Belt-and-braces with `name:` in the yaml.
COMPOSE_PROJECT="salary-benchmark"
DC="docker compose -p ${COMPOSE_PROJECT} -f docker-compose.prod.yml --env-file .env.prod"
step() { printf "\n\033[1;33m==> %s\033[0m\n" "$*"; }
info() { printf " %s\n" "$*"; }
die() { printf "\033[1;31mERROR: %s\033[0m\n" "$*" >&2; exit 1; }
[[ $EUID -eq 0 ]] || die "run as root (for apache + /var/www writes)"
command -v docker >/dev/null || die "docker not installed"
command -v apache2ctl >/dev/null || die "apache2ctl not found — is Apache installed?"
[[ -e "${VHOST_FILE}" ]] || die "vhost file not found: ${VHOST_FILE}"
# Follow symlink so we edit the real file (sites-enabled is often a symlink)
VHOST_FILE="$(readlink -f "${VHOST_FILE}")"
info "vhost: ${VHOST_FILE}"
[[ -f "${REPO_ROOT}/.env" ]] || die ".env missing in ${REPO_ROOT} (copy .env.example and fill in API keys)"
# ----------------------------------------------------------------------------
step "Pick free port in ${PORT_SCAN_START}-${PORT_SCAN_END}"
USED=$(ss -tlnH 2>/dev/null | awk '{print $4}' | awk -F: '{print $NF}' | sort -u)
APP_PORT=""
for p in $(seq "${PORT_SCAN_START}" "${PORT_SCAN_END}"); do
if ! grep -qx "${p}" <<<"${USED}"; then APP_PORT="${p}"; break; fi
done
[[ -n "${APP_PORT}" ]] || die "no free port in range"
info "APP_PORT=${APP_PORT}"
# ----------------------------------------------------------------------------
step "Build frontend (VITE_BASE=${URL_SUBPATH}) in a node container"
docker run --rm \
-v "${REPO_ROOT}/frontend":/app \
-w /app \
-e VITE_BASE="${URL_SUBPATH}" \
node:20-alpine \
sh -c "npm ci --prefer-offline --no-audit --no-fund && npm run build"
info "built ${REPO_ROOT}/frontend/dist/"
# ----------------------------------------------------------------------------
step "Deploy frontend to ${WEB_DIR}"
mkdir -p "${WEB_DIR}"
rsync -a --delete "${REPO_ROOT}/frontend/dist/" "${WEB_DIR}/"
# ----------------------------------------------------------------------------
step "Write .env.prod (preserves existing JWT_SECRET and DB_PASSWORD)"
cd "${REPO_ROOT}"
ENVF="deploy/.env.prod"
EXISTING_JWT=""; EXISTING_DBPW=""
if [[ -f "${ENVF}" ]]; then
EXISTING_JWT=$(grep -E '^JWT_SECRET=' "${ENVF}" | cut -d= -f2- || true)
EXISTING_DBPW=$(grep -E '^DB_PASSWORD=' "${ENVF}" | cut -d= -f2- || true)
fi
JWT_SECRET="${EXISTING_JWT:-$(openssl rand -hex 32)}"
DB_PASSWORD="${EXISTING_DBPW:-$(openssl rand -hex 16)}"
get_env() { grep -E "^${1}=" .env 2>/dev/null | head -1 | cut -d= -f2- || true; }
SERPER_API_KEY=$(get_env SERPER_API_KEY)
FIRECRAWL_API_KEY=$(get_env FIRECRAWL_API_KEY)
COHERE_API_KEY=$(get_env COHERE_API_KEY)
ANTHROPIC_API_KEY=$(get_env ANTHROPIC_API_KEY)
cat > "${ENVF}" <<ENV
# Generated by deploy-local.sh — do not commit
APP_PORT=${APP_PORT}
DB_USER=salary_user
DB_PASSWORD=${DB_PASSWORD}
DB_HOST=db
DB_PORT=5432
DB_NAME=salary_benchmark
JWT_SECRET=${JWT_SECRET}
JWT_ALGORITHM=HS256
JWT_EXPIRES_MINUTES=480
SERPER_API_KEY=${SERPER_API_KEY}
FIRECRAWL_API_KEY=${FIRECRAWL_API_KEY}
COHERE_API_KEY=${COHERE_API_KEY}
ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
ENV
chmod 600 "${ENVF}"
info "wrote ${REPO_ROOT}/${ENVF}"
# ----------------------------------------------------------------------------
step "Build & start containers (project=${COMPOSE_PROJECT})"
cd "${REPO_ROOT}/deploy"
${DC} build
${DC} up -d
${DC} ps
cd "${REPO_ROOT}"
# ----------------------------------------------------------------------------
step "Install Apache fragment"
FRAG_DST="${REPO_ROOT}/deploy/apache-salary-benchmark.conf"
sed "s|__APP_PORT__|${APP_PORT}|g" \
"${REPO_ROOT}/deploy/apache-salary-benchmark.conf.template" > "${FRAG_DST}"
info "rendered ${FRAG_DST}"
INCLUDE_LINE=" Include ${FRAG_DST}"
if grep -Fq "${FRAG_DST}" "${VHOST_FILE}"; then
info "Include already present in ${VHOST_FILE}"
else
cp -a "${VHOST_FILE}" "${VHOST_FILE}.bak.$(date +%s)"
sed -i "0,/<\/VirtualHost>/s|</VirtualHost>|${INCLUDE_LINE}\n</VirtualHost>|" "${VHOST_FILE}"
info "added Include line to ${VHOST_FILE}"
fi
apache2ctl configtest
systemctl reload apache2
info "Apache reloaded"
# ----------------------------------------------------------------------------
step "Verify"
sleep 3
HEALTH_URL="https://optical-dev.oliver.solutions${URL_SUBPATH}api/health"
if curl -sfk "${HEALTH_URL}" >/dev/null; then
info "OK: ${HEALTH_URL}"
else
info "WARN: health check failed (container may still be starting). Try:"
info " (cd ${REPO_ROOT}/deploy && ${DC} logs app --tail 30)"
fi
step "Done"
info "App: https://optical-dev.oliver.solutions${URL_SUBPATH}"
info "Login: admin@oliver.agency / Oliver2026!"
info "Port: 127.0.0.1:${APP_PORT} (Apache fronts it)"

183
deploy/deploy.sh Executable file
View file

@ -0,0 +1,183 @@
#!/usr/bin/env bash
# Deploy Salary Benchmark to optical-dev.oliver.solutions under /salary-benchmark/.
# Idempotent: safe to re-run for updates.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
# shellcheck disable=SC1091
if [[ ! -f "${SCRIPT_DIR}/config.sh" ]]; then
echo "ERROR: ${SCRIPT_DIR}/config.sh not found. Copy config.sh.example to config.sh and edit." >&2
exit 1
fi
source "${SCRIPT_DIR}/config.sh"
: "${SSH_TARGET:?SSH_TARGET must be set in config.sh}"
: "${REMOTE_APP_DIR:?REMOTE_APP_DIR must be set in config.sh}"
: "${REMOTE_WEB_DIR:?REMOTE_WEB_DIR must be set in config.sh}"
: "${REMOTE_VHOST_FILE:?REMOTE_VHOST_FILE must be set in config.sh}"
: "${URL_SUBPATH:?URL_SUBPATH must be set in config.sh}"
: "${PORT_SCAN_START:=8100}"
: "${PORT_SCAN_END:=8199}"
step() { printf "\n\033[1;33m==> %s\033[0m\n" "$*"; }
info() { printf " %s\n" "$*"; }
die() { printf "\033[1;31mERROR: %s\033[0m\n" "$*" >&2; exit 1; }
# ----------------------------------------------------------------------------
step "Pre-flight checks"
command -v rsync >/dev/null || die "rsync not installed locally"
command -v ssh >/dev/null || die "ssh not installed locally"
command -v npm >/dev/null || die "npm not installed locally"
ssh -o BatchMode=yes -o ConnectTimeout=5 "${SSH_TARGET}" true \
|| die "cannot ssh to ${SSH_TARGET}"
info "SSH to ${SSH_TARGET} ok"
# ----------------------------------------------------------------------------
step "Pick a free backend port on remote (range ${PORT_SCAN_START}-${PORT_SCAN_END})"
APP_PORT=$(ssh "${SSH_TARGET}" bash -s <<EOF
set -e
used=\$(ss -tlnH 2>/dev/null | awk '{print \$4}' | awk -F: '{print \$NF}' | sort -u)
for p in \$(seq ${PORT_SCAN_START} ${PORT_SCAN_END}); do
if ! grep -qx "\$p" <<<"\$used"; then echo "\$p"; exit 0; fi
done
exit 1
EOF
)
[[ -n "${APP_PORT}" ]] || die "no free port in range ${PORT_SCAN_START}-${PORT_SCAN_END}"
info "Selected APP_PORT=${APP_PORT}"
# ----------------------------------------------------------------------------
step "Build frontend with VITE_BASE=${URL_SUBPATH}"
pushd "${REPO_ROOT}/frontend" >/dev/null
npm ci --prefer-offline --no-audit --no-fund
VITE_BASE="${URL_SUBPATH}" npm run build
popd >/dev/null
info "Built: ${REPO_ROOT}/frontend/dist/"
# ----------------------------------------------------------------------------
step "Prepare remote directories"
ssh "${SSH_TARGET}" bash -s <<EOF
set -e
sudo mkdir -p "${REMOTE_APP_DIR}" "${REMOTE_WEB_DIR}"
sudo chown -R "\$USER":"\$USER" "${REMOTE_APP_DIR}"
EOF
# ----------------------------------------------------------------------------
step "Sync backend + deploy assets to ${REMOTE_APP_DIR}"
rsync -az --delete \
--exclude '.venv' --exclude '__pycache__' --exclude '*.pyc' \
--exclude 'pgdata' --exclude '.env' --exclude 'deploy/config.sh' \
"${REPO_ROOT}/app/" "${SSH_TARGET}:${REMOTE_APP_DIR}/app/"
rsync -az --delete \
--exclude '__pycache__' \
"${REPO_ROOT}/alembic/" "${SSH_TARGET}:${REMOTE_APP_DIR}/alembic/"
rsync -az \
"${REPO_ROOT}/alembic.ini" \
"${REPO_ROOT}/requirements.txt" \
"${REPO_ROOT}/deploy/Dockerfile.prod" \
"${REPO_ROOT}/deploy/docker-compose.prod.yml" \
"${SSH_TARGET}:${REMOTE_APP_DIR}/"
rsync -az \
"${REPO_ROOT}/deploy/apache-salary-benchmark.conf.template" \
"${SSH_TARGET}:${REMOTE_APP_DIR}/deploy/"
# ----------------------------------------------------------------------------
step "Sync frontend dist to ${REMOTE_WEB_DIR}"
rsync -az --delete --rsync-path="sudo rsync" \
"${REPO_ROOT}/frontend/dist/" "${SSH_TARGET}:${REMOTE_WEB_DIR}/"
# ----------------------------------------------------------------------------
step "Write .env.prod on remote (preserves existing JWT_SECRET if set)"
# Collect API keys from local .env if they exist
LOCAL_ENV="${REPO_ROOT}/.env"
get_local() { grep -E "^${1}=" "${LOCAL_ENV}" 2>/dev/null | head -1 | cut -d= -f2- || true; }
SERPER_API_KEY="${SERPER_API_KEY:-$(get_local SERPER_API_KEY)}"
FIRECRAWL_API_KEY="${FIRECRAWL_API_KEY:-$(get_local FIRECRAWL_API_KEY)}"
COHERE_API_KEY="${COHERE_API_KEY:-$(get_local COHERE_API_KEY)}"
ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-$(get_local ANTHROPIC_API_KEY)}"
ssh "${SSH_TARGET}" bash -s <<EOF
set -e
cd "${REMOTE_APP_DIR}"
ENVF=".env.prod"
if [[ -f "\${ENVF}" ]]; then
EXISTING_JWT=\$(grep -E '^JWT_SECRET=' "\${ENVF}" | cut -d= -f2- || true)
EXISTING_DBPW=\$(grep -E '^DB_PASSWORD=' "\${ENVF}" | cut -d= -f2- || true)
fi
JWT_SECRET="\${EXISTING_JWT:-\$(openssl rand -hex 32)}"
DB_PASSWORD="\${EXISTING_DBPW:-\$(openssl rand -hex 16)}"
cat > "\${ENVF}" <<ENV
# Generated by deploy.sh — do not commit
APP_PORT=${APP_PORT}
DB_USER=salary_user
DB_PASSWORD=\${DB_PASSWORD}
DB_HOST=db
DB_PORT=5432
DB_NAME=salary_benchmark
JWT_SECRET=\${JWT_SECRET}
JWT_ALGORITHM=HS256
JWT_EXPIRES_MINUTES=480
SERPER_API_KEY=${SERPER_API_KEY}
FIRECRAWL_API_KEY=${FIRECRAWL_API_KEY}
COHERE_API_KEY=${COHERE_API_KEY}
ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY}
ENV
chmod 600 "\${ENVF}"
echo " wrote ${REMOTE_APP_DIR}/\${ENVF}"
EOF
# ----------------------------------------------------------------------------
step "Build & start containers"
ssh "${SSH_TARGET}" bash -s <<EOF
set -e
cd "${REMOTE_APP_DIR}"
export APP_PORT="${APP_PORT}"
docker compose -p salary-benchmark -f docker-compose.prod.yml --env-file .env.prod build
docker compose -p salary-benchmark -f docker-compose.prod.yml --env-file .env.prod up -d
docker compose -p salary-benchmark -f docker-compose.prod.yml --env-file .env.prod ps
EOF
# ----------------------------------------------------------------------------
step "Install Apache config fragment"
ssh "${SSH_TARGET}" bash -s <<EOF
set -e
FRAG_SRC="${REMOTE_APP_DIR}/deploy/apache-salary-benchmark.conf.template"
FRAG_DST="${REMOTE_APP_DIR}/deploy/apache-salary-benchmark.conf"
sed "s|__APP_PORT__|${APP_PORT}|g" "\${FRAG_SRC}" > "\${FRAG_DST}"
echo " rendered \${FRAG_DST} (APP_PORT=${APP_PORT})"
INCLUDE_LINE=" Include \${FRAG_DST}"
if sudo grep -Fq "\${FRAG_DST}" "${REMOTE_VHOST_FILE}"; then
echo " Include already present in ${REMOTE_VHOST_FILE}"
else
sudo cp -a "${REMOTE_VHOST_FILE}" "${REMOTE_VHOST_FILE}.bak.\$(date +%s)"
sudo sed -i "0,/<\/VirtualHost>/s|</VirtualHost>|\${INCLUDE_LINE}\n</VirtualHost>|" "${REMOTE_VHOST_FILE}"
echo " added Include line to ${REMOTE_VHOST_FILE}"
fi
sudo apache2ctl configtest
sudo systemctl reload apache2
echo " Apache reloaded"
EOF
# ----------------------------------------------------------------------------
step "Verify"
sleep 2
HEALTH_URL="https://optical-dev.oliver.solutions${URL_SUBPATH}api/health"
if curl -sfk "${HEALTH_URL}" >/dev/null; then
info "Health check ok: ${HEALTH_URL}"
else
info "WARNING: health check failed at ${HEALTH_URL} (container may still be starting)"
fi
step "Done"
info "App: https://optical-dev.oliver.solutions${URL_SUBPATH}"
info "Backend port on remote: 127.0.0.1:${APP_PORT}"
info "Login: admin@oliver.agency / Oliver2026! (password is seeded by migration 004; change in DB if needed)"

View file

@ -0,0 +1,42 @@
# Pin project name — compose otherwise derives it from the parent dir ("deploy"),
# which collides with every other app on this server that also lives in /opt/<app>/deploy/.
# Shared project name → shared container + volume namespace → cross-app data loss.
name: salary-benchmark
services:
db:
image: postgres:16-alpine
container_name: salary-benchmark-db
restart: unless-stopped
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME}"]
interval: 5s
retries: 10
app:
build:
context: ..
dockerfile: deploy/Dockerfile.prod
container_name: salary-benchmark-app
restart: unless-stopped
env_file: .env.prod
depends_on:
db:
condition: service_healthy
ports:
# Bind to loopback only — Apache fronts it
- "127.0.0.1:${APP_PORT}:8000"
volumes:
pgdata:
# Volume was seeded by migrating from the old shared "deploy_pgdata".
# external: true tells compose not to try to manage lifecycle (silences
# the "not created by Docker Compose" warning).
external: true
name: salary-benchmark_pgdata

44
docker-compose.yml Normal file
View file

@ -0,0 +1,44 @@
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: ${DB_NAME:-salary_benchmark}
POSTGRES_USER: ${DB_USER:-salary_user}
POSTGRES_PASSWORD: ${DB_PASSWORD:-salary_pass}
volumes:
- pgdata:/var/lib/postgresql/data
ports:
- "5436:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-salary_user} -d ${DB_NAME:-salary_benchmark}"]
interval: 5s
retries: 5
app:
build: .
ports:
- "8009:8000"
env_file: .env
depends_on:
db:
condition: service_healthy
volumes:
- ./app:/code/app
- ./alembic:/code/alembic
- ./alembic.ini:/code/alembic.ini
command: >
sh -c "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload"
frontend:
image: node:20-alpine
working_dir: /app
ports:
- "5179:5173"
volumes:
- ./frontend:/app
command: sh -c "npm install && npm run dev -- --host 0.0.0.0"
depends_on:
- app
volumes:
pgdata:

24
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

16
frontend/README.md Normal file
View file

@ -0,0 +1,16 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.

29
frontend/eslint.config.js Normal file
View file

@ -0,0 +1,29 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
rules: {
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
},
},
])

13
frontend/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Salary Benchmark Tool</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

2662
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

28
frontend/package.json Normal file
View file

@ -0,0 +1,28 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.14.0"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"vite": "^8.0.1"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
frontend/public/icons.svg Normal file
View file

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

1
frontend/src/App.css Normal file
View file

@ -0,0 +1 @@
/* Component styles below */

30
frontend/src/App.jsx Normal file
View file

@ -0,0 +1,30 @@
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { AuthProvider } from './AuthContext'
import Layout from './components/Layout'
import ProtectedRoute from './components/ProtectedRoute'
import LookupPage from './pages/LookupPage'
import LoginPage from './pages/LoginPage'
function App() {
return (
<AuthProvider>
<BrowserRouter basename={import.meta.env.BASE_URL.replace(/\/$/, '') || '/'}>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/"
element={
<ProtectedRoute>
<Layout>
<LookupPage />
</Layout>
</ProtectedRoute>
}
/>
</Routes>
</BrowserRouter>
</AuthProvider>
)
}
export default App

View file

@ -0,0 +1,50 @@
import { createContext, useContext, useEffect, useState } from 'react'
const AuthContext = createContext(null)
const TOKEN_KEY = 'sb_token'
const EMAIL_KEY = 'sb_email'
export function AuthProvider({ children }) {
const [token, setToken] = useState(() => localStorage.getItem(TOKEN_KEY))
const [email, setEmail] = useState(() => localStorage.getItem(EMAIL_KEY))
const [ready, setReady] = useState(false)
useEffect(() => {
setReady(true)
}, [])
const login = (newToken, newEmail) => {
localStorage.setItem(TOKEN_KEY, newToken)
localStorage.setItem(EMAIL_KEY, newEmail)
setToken(newToken)
setEmail(newEmail)
}
const logout = () => {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(EMAIL_KEY)
setToken(null)
setEmail(null)
}
return (
<AuthContext.Provider value={{ token, email, ready, login, logout }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
return ctx
}
export function getToken() {
return localStorage.getItem(TOKEN_KEY)
}
export function clearAuth() {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(EMAIL_KEY)
}

68
frontend/src/api.js Normal file
View file

@ -0,0 +1,68 @@
import { clearAuth, getToken } from './AuthContext'
// In dev BASE_URL = "/", in prod it's the subpath like "/salary-benchmark/"
const BASE = `${import.meta.env.BASE_URL}api`.replace(/\/+api$/, '/api');
async function request(path, options = {}) {
const token = getToken()
const headers = {
'Content-Type': 'application/json',
...(options.headers || {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
}
const res = await fetch(`${BASE}${path}`, { ...options, headers });
if (res.status === 401) {
clearAuth();
window.location.href = `${import.meta.env.BASE_URL}login`.replace(/\/+login$/, '/login');
throw new Error('Session expired');
}
if (res.status === 404) return null;
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: 'Request failed' }));
throw new Error(err.detail || 'Request failed');
}
return res.json();
}
export async function login(email, password) {
return request('/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
}
export async function lookupBenchmarks(title, location) {
return request(`/benchmarks?title=${encodeURIComponent(title)}&location=${encodeURIComponent(location)}`);
}
export async function bulkLookup(location, titles) {
return request('/benchmarks/bulk', {
method: 'POST',
body: JSON.stringify({ location, titles }),
});
}
export async function startResearch(title, location) {
return request('/research', {
method: 'POST',
body: JSON.stringify({ title, location }),
});
}
export async function getResearchStatus(sessionId) {
return request(`/research/${sessionId}`);
}
export async function validateResearch(sessionId, approved, adjustments = null) {
return request(`/research/${sessionId}/validate`, {
method: 'POST',
body: JSON.stringify({ approved, adjustments }),
});
}
export async function bulkResearch(location, titles) {
return request('/research/bulk', {
method: 'POST',
body: JSON.stringify({ location, titles }),
});
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View file

@ -0,0 +1,114 @@
.benchmark-grid {
margin-top: 32px;
}
.grid-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.grid-title {
font-size: 18px;
font-weight: 700;
margin: 0;
}
.btn-export {
padding: 8px 16px;
font-size: 12px;
font-weight: 700;
background: var(--white);
color: var(--black);
border: 2px solid var(--black);
cursor: pointer;
text-transform: uppercase;
letter-spacing: 1px;
transition: all 0.2s;
}
.btn-export:hover {
background: var(--accent);
border-color: var(--accent);
}
.benchmark-grid table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.benchmark-grid th {
background: var(--black);
color: var(--white);
padding: 12px 16px;
text-align: left;
text-transform: uppercase;
letter-spacing: 0.5px;
font-size: 12px;
font-weight: 600;
}
.benchmark-grid td {
padding: 10px 16px;
border-bottom: 1px solid var(--gray-200);
}
.benchmark-grid tr:nth-child(even) td {
background: var(--gray-100);
}
.role-cell {
font-weight: 600;
vertical-align: top;
}
.salary {
font-variant-numeric: tabular-nums;
font-weight: 500;
}
.level-badge {
display: inline-block;
padding: 2px 10px;
border-radius: 3px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.level-badge.level-junior {
background: var(--gray-200);
color: var(--gray-600);
}
.level-badge.level-mid {
background: var(--accent);
color: var(--black);
}
.level-badge.level-senior {
background: var(--black);
color: var(--white);
}
.source-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 3px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
}
.source-badge.seed {
background: var(--gray-200);
color: var(--gray-600);
}
.source-badge.research {
background: var(--accent);
color: var(--black);
}

View file

@ -0,0 +1,78 @@
import './BenchmarkGrid.css'
function exportCSV(results) {
const header = 'Location,Role,Level,Salary,Source\n'
const rows = results.map((b) =>
`"${b.location}","${b.role}","${b.level}",${b.salary},"${b.source}"`
).join('\n')
const blob = new Blob([header + rows], { type: 'text/csv' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'salary_benchmarks.csv'
a.click()
URL.revokeObjectURL(url)
}
function BenchmarkGrid({ results, title: groupTitle }) {
if (!results || results.length === 0) return null
// Group by role
const grouped = {}
for (const r of results) {
const key = r.role
if (!grouped[key]) grouped[key] = []
grouped[key].push(r)
}
const levelOrder = { senior: 0, mid: 1, junior: 2 }
return (
<div className="benchmark-grid">
<div className="grid-header">
{groupTitle && <h3 className="grid-title">{groupTitle}</h3>}
<button className="btn-export" onClick={() => exportCSV(results)}>
Export CSV
</button>
</div>
<table>
<thead>
<tr>
<th>Role</th>
<th>Level</th>
<th>Salary</th>
<th>Source</th>
</tr>
</thead>
<tbody>
{Object.entries(grouped).map(([role, benchmarks]) =>
benchmarks
.sort((a, b) => (levelOrder[a.level] ?? 9) - (levelOrder[b.level] ?? 9))
.map((b, i) => (
<tr key={`${role}-${b.level}`}>
{i === 0 && (
<td className="role-cell" rowSpan={benchmarks.length}>
{role}
</td>
)}
<td>
<span className={`level-badge level-${b.level}`}>
{b.level}
</span>
</td>
<td className="salary">${b.salary?.toLocaleString()}</td>
<td>
<span className={`source-badge ${b.source}`}>
{b.source}
</span>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
)
}
export default BenchmarkGrid

View file

@ -0,0 +1,66 @@
.layout {
max-width: 800px;
margin: 0 auto;
padding: 40px 20px;
}
.layout-header {
margin-bottom: 48px;
}
.layout-header h1 {
font-size: 32px;
font-weight: 700;
margin-bottom: 8px;
letter-spacing: -0.5px;
}
.layout-header .subtitle {
font-size: 16px;
color: var(--gray-600);
margin-bottom: 4px;
}
.layout-header .small-subtitle {
font-size: 12px;
color: var(--gray-400);
}
.layout-main {
width: 100%;
}
.layout-header-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
}
.user-menu {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.user-email {
font-size: 13px;
color: var(--gray-600);
}
.btn-logout {
padding: 6px 12px;
background: transparent;
border: 1px solid var(--gray-300, #d4d4d4);
border-radius: 4px;
font-size: 13px;
font-family: inherit;
cursor: pointer;
}
.btn-logout:hover {
background: #000;
color: #fff;
border-color: #000;
}

View file

@ -0,0 +1,38 @@
import { useNavigate } from 'react-router-dom'
import { useAuth } from '../AuthContext'
import './Layout.css'
function Layout({ children }) {
const { email, logout } = useAuth()
const navigate = useNavigate()
const handleLogout = () => {
logout()
navigate('/login', { replace: true })
}
return (
<div className="layout">
<header className="layout-header">
<div className="layout-header-row">
<div>
<h1>Salary Benchmark Tool</h1>
<p className="subtitle">Get salary ranges for different experience levels</p>
<p className="small-subtitle">Questions? Reach out to felipeoliveira@oliver.agency</p>
</div>
{email && (
<div className="user-menu">
<span className="user-email">{email}</span>
<button className="btn-logout" onClick={handleLogout}>Sign out</button>
</div>
)}
</div>
</header>
<main className="layout-main">
{children}
</main>
</div>
)
}
export default Layout

View file

@ -0,0 +1,32 @@
.mode-toggle {
display: flex;
margin-bottom: 32px;
border: 2px solid var(--black);
}
.mode-toggle button {
flex: 1;
padding: 12px 24px;
font-size: 14px;
font-weight: 600;
background: var(--white);
color: var(--black);
border: none;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 1px;
transition: all 0.2s;
}
.mode-toggle button:first-child {
border-right: 2px solid var(--black);
}
.mode-toggle button.active {
background: var(--accent);
color: var(--black);
}
.mode-toggle button:hover:not(.active) {
background: var(--gray-100);
}

View file

@ -0,0 +1,22 @@
import './ModeToggle.css'
function ModeToggle({ mode, setMode }) {
return (
<div className="mode-toggle">
<button
className={mode === 'single' ? 'active' : ''}
onClick={() => setMode('single')}
>
Single Lookup
</button>
<button
className={mode === 'bulk' ? 'active' : ''}
onClick={() => setMode('bulk')}
>
Bulk Import
</button>
</div>
)
}
export default ModeToggle

View file

@ -0,0 +1,13 @@
import { Navigate, useLocation } from 'react-router-dom'
import { useAuth } from '../AuthContext'
function ProtectedRoute({ children }) {
const { token, ready } = useAuth()
const location = useLocation()
if (!ready) return null
if (!token) return <Navigate to="/login" state={{ from: location }} replace />
return children
}
export default ProtectedRoute

View file

@ -0,0 +1,73 @@
.research-progress {
margin: 24px 0;
padding: 24px;
border: 2px solid var(--black);
}
.research-progress h4 {
font-size: 16px;
font-weight: 700;
margin-bottom: 16px;
}
.progress-bar {
height: 6px;
background: var(--gray-200);
margin-bottom: 20px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--accent);
transition: width 0.5s ease;
}
.steps {
display: flex;
flex-direction: column;
gap: 8px;
}
.step {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
color: var(--gray-400);
}
.step.done {
color: var(--black);
}
.step.active {
color: var(--black);
font-weight: 600;
}
.step-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--gray-200);
flex-shrink: 0;
}
.step.done .step-dot {
background: var(--accent);
}
.step.active .step-dot {
background: var(--accent);
box-shadow: 0 0 0 3px rgba(255, 196, 7, 0.3);
}
.research-error {
margin-top: 16px;
padding: 12px 16px;
background: #fff0f0;
border: 1px solid #ffcdd2;
color: #c62828;
font-size: 14px;
}

View file

@ -0,0 +1,89 @@
import { useState, useEffect } from 'react'
import { getResearchStatus } from '../api'
import ValidationForm from './ValidationForm'
import './ResearchProgress.css'
const STEPS = ['searching', 'scraping', 'ranking', 'analyzing', 'pending_validation']
const STEP_LABELS = {
searching: 'Searching salary data...',
scraping: 'Scraping relevant pages...',
ranking: 'Ranking results...',
analyzing: 'AI analyzing data...',
pending_validation: 'Ready for review',
}
function ResearchProgress({ sessionId, title, location, onValidated }) {
const [status, setStatus] = useState('searching')
const [data, setData] = useState(null)
const [error, setError] = useState(null)
useEffect(() => {
if (!sessionId) return
if (status === 'completed' || status === 'failed' || status === 'rejected') return
const poll = setInterval(async () => {
try {
const res = await getResearchStatus(sessionId)
if (!res) return
setStatus(res.status)
setData(res)
if (['pending_validation', 'completed', 'failed'].includes(res.status)) {
clearInterval(poll)
}
} catch (err) {
setError(err.message)
clearInterval(poll)
}
}, 3000)
return () => clearInterval(poll)
}, [sessionId, status])
if (error) {
return <div className="research-error">Error: {error}</div>
}
const currentStep = STEPS.indexOf(status)
const progress = status === 'pending_validation' ? 100 : Math.max(0, ((currentStep + 0.5) / (STEPS.length - 1)) * 100)
return (
<div className="research-progress">
<h4>Researching: {title}</h4>
<div className="progress-bar">
<div className="progress-fill" style={{ width: `${progress}%` }} />
</div>
<div className="steps">
{STEPS.map((step, i) => (
<div
key={step}
className={`step ${i < currentStep ? 'done' : ''} ${i === currentStep ? 'active' : ''}`}
>
<span className="step-dot" />
<span className="step-label">{STEP_LABELS[step]}</span>
</div>
))}
</div>
{status === 'failed' && (
<div className="research-error">
Pipeline failed: {data?.error_message || 'Unknown error'}
</div>
)}
{status === 'pending_validation' && data?.proposed_benchmarks && (
<ValidationForm
sessionId={sessionId}
title={title}
location={location}
proposed={data.proposed_benchmarks}
analysis={data.claude_analysis}
onValidated={onValidated}
/>
)}
</div>
)
}
export default ResearchProgress

View file

@ -0,0 +1,111 @@
.validation-form {
margin-top: 20px;
padding: 20px;
background: var(--gray-100);
border: 1px solid var(--gray-200);
}
.validation-form h4 {
font-size: 15px;
font-weight: 700;
margin-bottom: 12px;
}
.reasoning {
font-size: 13px;
color: var(--gray-600);
margin-bottom: 12px;
line-height: 1.5;
}
.confidence {
font-size: 13px;
margin-bottom: 16px;
}
.validation-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 16px;
}
.validation-table th {
background: var(--black);
color: var(--white);
padding: 8px 12px;
text-align: left;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
}
.validation-table td {
padding: 8px 12px;
border-bottom: 1px solid var(--gray-200);
}
.validation-table input {
width: 100%;
padding: 8px 10px;
font-size: 14px;
border: 2px solid var(--gray-200);
background: var(--white);
font-family: var(--font);
font-variant-numeric: tabular-nums;
}
.validation-table input:focus {
outline: none;
border-color: var(--accent);
}
.level-cell {
width: 80px;
}
.validation-actions {
display: flex;
gap: 12px;
}
.btn-approve {
flex: 1;
padding: 12px 24px;
font-size: 14px;
font-weight: 700;
background: var(--accent);
color: var(--black);
border: 2px solid var(--accent);
cursor: pointer;
text-transform: uppercase;
letter-spacing: 1px;
transition: all 0.2s;
}
.btn-approve:hover:not(:disabled) {
background: var(--black);
color: var(--accent);
border-color: var(--black);
}
.btn-approve:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-reject {
padding: 12px 24px;
font-size: 14px;
font-weight: 600;
background: var(--white);
color: var(--black);
border: 2px solid var(--gray-200);
cursor: pointer;
text-transform: uppercase;
letter-spacing: 1px;
transition: all 0.2s;
}
.btn-reject:hover:not(:disabled) {
border-color: var(--black);
}

View file

@ -0,0 +1,114 @@
import { useState } from 'react'
import { validateResearch } from '../api'
import './ValidationForm.css'
function ValidationForm({ sessionId, title, location, proposed, analysis, onValidated }) {
const benchmarks = proposed?.benchmarks || []
const [values, setValues] = useState(
benchmarks.reduce((acc, b) => {
acc[b.level] = { salary: b.salary }
return acc
}, {})
)
const [saving, setSaving] = useState(false)
const [done, setDone] = useState(false)
const handleChange = (level, val) => {
setValues((prev) => ({
...prev,
[level]: { salary: parseInt(val) || 0 },
}))
}
const handleApprove = async () => {
if (saving || done) return
setSaving(true)
try {
await validateResearch(sessionId, true, values)
setDone(true)
onValidated?.()
} catch (err) {
alert('Failed to save: ' + err.message)
} finally {
setSaving(false)
}
}
const handleReject = async () => {
if (saving || done) return
setSaving(true)
try {
await validateResearch(sessionId, false)
setDone(true)
onValidated?.()
} finally {
setSaving(false)
}
}
if (done) {
return (
<div className="validation-form">
<h4>Benchmark saved for {title} in {location}</h4>
</div>
)
}
const levelOrder = ['junior', 'mid', 'senior']
return (
<div className="validation-form">
<h4>AI Benchmark Proposal for {title} in {location}</h4>
{proposed?.reasoning && (
<p className="reasoning">{proposed.reasoning}</p>
)}
{proposed?.confidence_score != null && (
<div className="confidence">
Confidence: <strong>{Math.round(proposed.confidence_score * 100)}%</strong>
</div>
)}
<table className="validation-table">
<thead>
<tr>
<th>Level</th>
<th>Salary</th>
</tr>
</thead>
<tbody>
{levelOrder.map((level) => {
const v = values[level]
if (!v) return null
return (
<tr key={level}>
<td className="level-cell">
<span className={`level-badge level-${level}`}>{level}</span>
</td>
<td>
<input
type="number"
value={v.salary}
onChange={(e) => handleChange(level, e.target.value)}
/>
</td>
</tr>
)
})}
</tbody>
</table>
<div className="validation-actions">
<button className="btn-approve" onClick={handleApprove} disabled={saving}>
{saving ? 'Saving...' : 'Approve & Save'}
</button>
<button className="btn-reject" onClick={handleReject} disabled={saving}>
Reject
</button>
</div>
</div>
)
}
export default ValidationForm

1
frontend/src/index.css Normal file
View file

@ -0,0 +1 @@
/* All styles in theme.css */

10
frontend/src/main.jsx Normal file
View file

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './theme.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)

View file

@ -0,0 +1,87 @@
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.login-card {
width: 100%;
max-width: 400px;
padding: 40px 32px;
border: 1px solid var(--gray-200, #e5e5e5);
border-radius: 8px;
background: #fff;
}
.login-card h1 {
font-size: 24px;
font-weight: 700;
margin: 0 0 4px;
letter-spacing: -0.3px;
}
.login-sub {
font-size: 14px;
color: var(--gray-600, #666);
margin: 0 0 24px;
}
.login-card .form-group {
margin-bottom: 16px;
}
.login-card .field-label {
display: block;
font-size: 13px;
font-weight: 600;
margin-bottom: 6px;
}
.login-card input {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--gray-300, #d4d4d4);
border-radius: 4px;
font-size: 14px;
font-family: inherit;
box-sizing: border-box;
}
.login-card input:focus {
outline: none;
border-color: #000;
}
.login-card .btn-primary {
width: 100%;
margin-top: 8px;
padding: 12px;
background: #000;
color: #fff;
border: none;
border-radius: 4px;
font-weight: 600;
font-size: 14px;
cursor: pointer;
}
.login-card .btn-primary:hover:not(:disabled) {
background: #FFC407;
color: #000;
}
.login-card .btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.login-card .error-banner {
padding: 10px 12px;
margin-bottom: 16px;
background: #fee;
color: #c00;
border-radius: 4px;
font-size: 13px;
}

View file

@ -0,0 +1,73 @@
import { useState } from 'react'
import { useNavigate, useLocation, Navigate } from 'react-router-dom'
import { login as apiLogin } from '../api'
import { useAuth } from '../AuthContext'
import './LoginPage.css'
function LoginPage() {
const { token, login } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const from = location.state?.from?.pathname || '/'
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
if (token) return <Navigate to={from} replace />
const handleSubmit = async (e) => {
e.preventDefault()
setLoading(true)
setError(null)
try {
const data = await apiLogin(email, password)
login(data.access_token, data.email)
navigate(from, { replace: true })
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
return (
<div className="login-page">
<div className="login-card">
<h1>Salary Benchmark Tool</h1>
<p className="login-sub">Sign in to continue</p>
{error && <div className="error-banner">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="form-group">
<label className="field-label" htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="username"
required
/>
</div>
<div className="form-group">
<label className="field-label" htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
/>
</div>
<button type="submit" className="btn-primary" disabled={loading}>
{loading ? 'Signing in...' : 'Sign In'}
</button>
</form>
</div>
</div>
)
}
export default LoginPage

View file

@ -0,0 +1,137 @@
.lookup-page {
max-width: 600px;
}
.form-section {
margin-top: 0;
}
.form-group {
margin-bottom: 24px;
}
.field-label {
display: block;
font-size: 14px;
font-weight: 600;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.lookup-page input[type="text"],
.lookup-page textarea {
width: 100%;
padding: 12px 16px;
font-size: 16px;
border: 2px solid var(--black);
background: var(--white);
color: var(--black);
transition: all 0.2s;
}
.lookup-page textarea {
min-height: 200px;
resize: vertical;
font-family: 'Monaco', 'Courier New', monospace;
}
.lookup-page input:focus,
.lookup-page textarea:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(255, 196, 7, 0.3);
}
.helper-text {
font-size: 12px;
color: var(--gray-600);
margin-top: 8px;
}
.btn-primary {
width: 100%;
padding: 14px 24px;
font-size: 16px;
font-weight: 600;
background: var(--black);
color: var(--white);
border: 2px solid var(--black);
cursor: pointer;
text-transform: uppercase;
letter-spacing: 1px;
transition: all 0.2s;
}
.btn-primary:hover:not(:disabled) {
background: var(--accent);
color: var(--black);
border-color: var(--accent);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.not-found {
margin-top: 24px;
padding: 20px;
border: 2px dashed var(--gray-200);
text-align: center;
}
.not-found p {
margin-bottom: 16px;
font-size: 15px;
}
.not-found-list {
list-style: none;
padding: 0;
margin: 0 0 16px;
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
}
.not-found-list li {
padding: 4px 12px;
background: var(--gray-100);
border: 1px solid var(--gray-200);
font-size: 13px;
font-weight: 500;
}
.btn-research {
padding: 12px 24px;
font-size: 14px;
font-weight: 700;
background: var(--accent);
color: var(--black);
border: 2px solid var(--accent);
cursor: pointer;
text-transform: uppercase;
letter-spacing: 1px;
transition: all 0.2s;
}
.btn-research:hover:not(:disabled) {
background: var(--black);
color: var(--accent);
border-color: var(--black);
}
.btn-research:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.error-banner {
padding: 12px 16px;
background: #fff0f0;
border: 1px solid #ffcdd2;
color: #c62828;
font-size: 14px;
margin-bottom: 24px;
}

View file

@ -0,0 +1,245 @@
import { useState } from 'react'
import ModeToggle from '../components/ModeToggle'
import BenchmarkGrid from '../components/BenchmarkGrid'
import ResearchProgress from '../components/ResearchProgress'
import { lookupBenchmarks, bulkLookup, startResearch, bulkResearch } from '../api'
import './LookupPage.css'
function LookupPage() {
const [mode, setMode] = useState('single')
// Single mode state
const [title, setTitle] = useState('')
const [location, setLocation] = useState('')
const [singleResults, setSingleResults] = useState(null)
const [singleNotFound, setSingleNotFound] = useState(false)
const [singleResearchSession, setSingleResearchSession] = useState(null)
// Bulk mode state
const [bulkLocation, setBulkLocation] = useState('')
const [bulkTitles, setBulkTitles] = useState('')
const [bulkFound, setBulkFound] = useState(null)
const [bulkNotFound, setBulkNotFound] = useState([])
const [bulkResearchSessions, setBulkResearchSessions] = useState([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const handleSingleLookup = async (e) => {
e.preventDefault()
setLoading(true)
setError(null)
setSingleResults(null)
setSingleNotFound(false)
setSingleResearchSession(null)
try {
const data = await lookupBenchmarks(title, location)
if (data) {
setSingleResults(data)
} else {
setSingleNotFound(true)
}
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
const handleSingleResearch = async () => {
setLoading(true)
setError(null)
try {
const data = await startResearch(title, location)
setSingleResearchSession(data.session_id)
setSingleNotFound(false)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
const handleBulkLookup = async (e) => {
e.preventDefault()
setLoading(true)
setError(null)
setBulkFound(null)
setBulkNotFound([])
setBulkResearchSessions([])
const titles = bulkTitles
.split('\n')
.map((t) => t.trim())
.filter(Boolean)
if (titles.length === 0) {
setError('Please enter at least one job title')
setLoading(false)
return
}
try {
const data = await bulkLookup(bulkLocation, titles)
if (data) {
// Flatten found results into a single array
const allFound = Object.values(data.found).flat()
setBulkFound(allFound.length > 0 ? allFound : null)
setBulkNotFound(data.not_found || [])
}
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
const handleBulkResearch = async () => {
setLoading(true)
setError(null)
try {
const data = await bulkResearch(bulkLocation, bulkNotFound)
setBulkResearchSessions(data.sessions || [])
setBulkNotFound([])
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}
const handleResearchValidated = () => {
// For single mode, re-run the lookup to show saved data
if (mode === 'single') {
setSingleResearchSession(null)
handleSingleLookup({ preventDefault: () => {} })
}
// For bulk mode, the ValidationForm shows "saved" state on its own
// No need to re-trigger anything that would cause duplicate validate calls
}
return (
<div className="lookup-page">
<ModeToggle mode={mode} setMode={setMode} />
{error && <div className="error-banner">{error}</div>}
{mode === 'single' && (
<div className="form-section">
<form onSubmit={handleSingleLookup}>
<div className="form-group">
<label className="field-label" htmlFor="title">Job Title</label>
<input
type="text"
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g., Creative Director"
required
/>
</div>
<div className="form-group">
<label className="field-label" htmlFor="location">Location</label>
<input
type="text"
id="location"
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder="e.g., New York"
required
/>
</div>
<button type="submit" className="btn-primary" disabled={loading}>
{loading ? 'Looking up...' : 'Get Benchmark'}
</button>
</form>
{singleResults && <BenchmarkGrid results={singleResults} />}
{singleNotFound && (
<div className="not-found">
<p>No benchmark found for <strong>{title}</strong> in <strong>{location}</strong>.</p>
<button className="btn-research" onClick={handleSingleResearch} disabled={loading}>
Research This Role
</button>
</div>
)}
{singleResearchSession && (
<ResearchProgress
sessionId={singleResearchSession}
title={title}
location={location}
onValidated={handleResearchValidated}
/>
)}
</div>
)}
{mode === 'bulk' && (
<div className="form-section">
<form onSubmit={handleBulkLookup}>
<div className="form-group">
<label className="field-label" htmlFor="bulkLocation">
Location (applies to all titles)
</label>
<input
type="text"
id="bulkLocation"
value={bulkLocation}
onChange={(e) => setBulkLocation(e.target.value)}
placeholder="e.g., New York"
required
/>
</div>
<div className="form-group">
<label className="field-label" htmlFor="bulkTitles">
Job Titles (one per line)
</label>
<textarea
id="bulkTitles"
value={bulkTitles}
onChange={(e) => setBulkTitles(e.target.value)}
placeholder={"Art Director\nProject Manager\nData Analyst\nUX Designer"}
required
/>
<div className="helper-text">Paste multiple job titles, each on a new line</div>
</div>
<button type="submit" className="btn-primary" disabled={loading}>
{loading ? 'Looking up...' : 'Run Bulk Lookup'}
</button>
</form>
{bulkFound && <BenchmarkGrid results={bulkFound} title="Found Benchmarks" />}
{bulkNotFound.length > 0 && (
<div className="not-found">
<p><strong>{bulkNotFound.length}</strong> role(s) not found:</p>
<ul className="not-found-list">
{bulkNotFound.map((t) => (
<li key={t}>{t}</li>
))}
</ul>
<button className="btn-research" onClick={handleBulkResearch} disabled={loading}>
Research All Missing Roles
</button>
</div>
)}
{bulkResearchSessions.map((s) => (
<ResearchProgress
key={s.session_id}
sessionId={s.session_id}
title={s.title}
location={bulkLocation}
onValidated={handleResearchValidated}
/>
))}
</div>
)}
</div>
)
}
export default LookupPage

30
frontend/src/theme.css Normal file
View file

@ -0,0 +1,30 @@
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&display=swap');
:root {
--black: #000000;
--white: #FFFFFF;
--accent: #FFC407;
--gray-100: #F5F5F5;
--gray-200: #E5E5E5;
--gray-400: #999999;
--gray-600: #666666;
--font: 'Montserrat', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--font);
background: var(--white);
color: var(--black);
line-height: 1.6;
min-height: 100vh;
}
input, textarea, button {
font-family: var(--font);
}

19
frontend/vite.config.js Normal file
View file

@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// VITE_BASE is set at build time for prod (e.g. "/salary-benchmark/"); defaults to "/" for dev
const base = process.env.VITE_BASE || '/'
export default defineConfig({
base,
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://app:8000',
changeOrigin: true,
},
},
},
})

235
index (7).html Normal file
View file

@ -0,0 +1,235 @@
<!doctype html >
<!-- Front-end only. No JS. Ready for backend integration -->
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Salary Benchmark Tool</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
background: white;
color: black;
line-height: 1.6;
padding: 40px 20px;
min-height: 100vh;
}
.container {
max-width: 600px;
margin: 0 auto;
}
h1 {
font-size: 32px;
font-weight: 700;
margin-bottom: 8px;
letter-spacing: -0.5px;
}
.subtitle {
font-size: 16px;
color: #666;
margin-bottom: 48px;
}
.small-subtitle {
font-size: 12px;
color: #666;
margin-top: -40px;
margin-bottom: 32px;
}
.form-group {
margin-bottom: 24px;
}
.field-label {
display: block;
font-size: 14px;
font-weight: 600;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
input,
textarea {
width: 100%;
padding: 12px 16px;
font-size: 16px;
border: 2px solid black;
background: white;
color: black;
font-family: inherit;
transition: all 0.2s;
}
textarea {
min-height: 200px;
resize: vertical;
font-family: 'Monaco', 'Courier New', monospace;
}
input:focus,
textarea:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.1);
}
button {
width: 100%;
padding: 14px 24px;
font-size: 16px;
font-weight: 600;
background: black;
color: white;
border: 2px solid black;
cursor: pointer;
font-family: inherit;
text-transform: uppercase;
letter-spacing: 1px;
transition: all 0.2s;
}
button:hover {
background: white;
color: black;
}
.helper-text {
font-size: 12px;
color: #666;
margin-top: 8px;
}
.mode-input {
display: none;
}
.mode-toggle {
display: flex;
margin-bottom: 32px;
border: 2px solid black;
}
.mode-toggle label {
flex: 1;
padding: 12px 24px;
font-size: 14px;
font-weight: 600;
background: white;
color: black;
text-align: center;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 1px;
transition: all 0.2s;
}
.mode-toggle label:first-of-type {
border-right: 2px solid black;
}
.form-section {
display: none;
}
#singleMode:checked ~ .container .mode-toggle label[for="singleMode"] {
background: black;
color: white;
}
#bulkMode:checked ~ .container .mode-toggle label[for="bulkMode"] {
background: black;
color: white;
}
#singleMode:checked ~ .container #singleSection {
display: block;
}
#bulkMode:checked ~ .container #bulkSection {
display: block;
}
</style>
</head>
<body>
<input class="mode-input" type="radio" name="mode" id="singleMode" checked>
<input class="mode-input" type="radio" name="mode" id="bulkMode">
<div class="container">
<h1>Salary Benchmark Tool</h1>
<p class="subtitle">Get salary ranges for different experience levels</p>
<p class="small-subtitle">Questions? Reach out to felipeoliveira@oliver.agency</p>
<div class="mode-toggle">
<label for="singleMode">Single Lookup</label>
<label for="bulkMode">Bulk Import</label>
</div>
<div id="singleSection" class="form-section">
<form id="benchmarkForm">
<div class="form-group">
<label class="field-label" for="title">Job Title</label>
<input
type="text"
id="title"
name="title"
placeholder="e.g., Creative Director"
required
/>
</div>
<div class="form-group">
<label class="field-label" for="location">Location</label>
<input
type="text"
id="location"
name="location"
placeholder="e.g., San Francisco"
required
/>
</div>
<button type="submit">Get Benchmark</button>
</form>
</div>
<div id="bulkSection" class="form-section">
<form id="bulkImportForm">
<div class="form-group">
<label class="field-label" for="bulkLocation">Location (applies to all titles)</label>
<input
type="text"
id="bulkLocation"
name="bulkLocation"
placeholder="e.g., San Francisco"
required
/>
</div>
<div class="form-group">
<label class="field-label" for="bulkTitles">Job Titles (one per line)</label>
<textarea
id="bulkTitles"
name="bulkTitles"
placeholder="Art Director&#10;Project Manager&#10;Data Analyst&#10;UX Designer"
required
></textarea>
<div class="helper-text">Paste multiple job titles, each on a new line</div>
</div>
<button type="submit">Run Bulk Lookup</button>
</form>
</div>
</div>
</body>
</html>

13
requirements.txt Normal file
View file

@ -0,0 +1,13 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy[asyncio]==2.0.36
asyncpg==0.30.0
alembic==1.14.1
pydantic-settings==2.7.1
httpx==0.28.1
anthropic==0.42.0
cohere==5.13.3
passlib==1.7.4
bcrypt==4.0.1
email-validator==2.2.0
python-jose[cryptography]==3.3.0