Compare commits
No commits in common. "master" and "main" have entirely different histories.
74 changed files with 48 additions and 6572 deletions
12
.env.example
12
.env.example
|
|
@ -1,12 +0,0 @@
|
|||
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
56
.gitignore
vendored
|
|
@ -1,10 +1,50 @@
|
|||
.env
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.venv/
|
||||
pgdata/
|
||||
# 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
|
||||
node_modules/
|
||||
frontend/dist/
|
||||
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
|
||||
.DS_Store
|
||||
deploy/config.sh
|
||||
.env.prod
|
||||
|
||||
# Generated by Windows
|
||||
Thumbs.db
|
||||
|
||||
# Applications
|
||||
*.app
|
||||
*.exe
|
||||
*.war
|
||||
|
||||
# Large media files
|
||||
*.mp4
|
||||
*.tiff
|
||||
*.avi
|
||||
*.flv
|
||||
*.mov
|
||||
*.wmv
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +0,0 @@
|
|||
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
171
README.md
|
|
@ -1,171 +0,0 @@
|
|||
# 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 **8100–8199** 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 8100–8199. 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
36
alembic.ini
|
|
@ -1,36 +0,0 @@
|
|||
[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
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
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())
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
"""${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"}
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
"""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")
|
||||
|
|
@ -1,90 +0,0 @@
|
|||
"""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'")
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
"""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")
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
"""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")
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
"""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'")
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
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()
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
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
22
app/deps.py
|
|
@ -1,22 +0,0 @@
|
|||
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
29
app/main.py
|
|
@ -1,29 +0,0 @@
|
|||
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
116
app/models.py
|
|
@ -1,116 +0,0 @@
|
|||
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")
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
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)
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
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)
|
||||
|
|
@ -1,130 +0,0 @@
|
|||
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}
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
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()
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
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
|
||||
]
|
||||
|
|
@ -1,54 +0,0 @@
|
|||
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)
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
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}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
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"]]}
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
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()
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
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]}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
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"]
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
# 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>
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
# 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=
|
||||
|
|
@ -1,142 +0,0 @@
|
|||
#!/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
183
deploy/deploy.sh
|
|
@ -1,183 +0,0 @@
|
|||
#!/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)"
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
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
24
frontend/.gitignore
vendored
|
|
@ -1,24 +0,0 @@
|
|||
# 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?
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
# 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.
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
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_]' }],
|
||||
},
|
||||
},
|
||||
])
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
<!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
2662
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,28 +0,0 @@
|
|||
{
|
||||
"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
|
Before Width: | Height: | Size: 9.3 KiB |
|
|
@ -1,24 +0,0 @@
|
|||
<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>
|
||||
|
Before Width: | Height: | Size: 4.9 KiB |
|
|
@ -1 +0,0 @@
|
|||
/* Component styles below */
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
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)
|
||||
}
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
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.
|
Before Width: | Height: | Size: 44 KiB |
|
|
@ -1 +0,0 @@
|
|||
<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>
|
||||
|
Before Width: | Height: | Size: 4 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 8.5 KiB |
|
|
@ -1,114 +0,0 @@
|
|||
.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);
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
.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;
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
.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);
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
.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;
|
||||
}
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,111 +0,0 @@
|
|||
.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);
|
||||
}
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
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 +0,0 @@
|
|||
/* All styles in theme.css */
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
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>,
|
||||
)
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
.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;
|
||||
}
|
||||
|
|
@ -1,73 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,137 +0,0 @@
|
|||
.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;
|
||||
}
|
||||
|
|
@ -1,245 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
@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);
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
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
235
index (7).html
|
|
@ -1,235 +0,0 @@
|
|||
<!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 Project Manager Data Analyst 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>
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
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
|
||||
Loading…
Add table
Reference in a new issue