diff --git a/.env.example b/.env.example index 3c13cfb..c304924 100644 --- a/.env.example +++ b/.env.example @@ -6,16 +6,16 @@ # OpenRouter API for AI scoring and summaries # Get your key at: https://openrouter.ai/keys OPENROUTER_API_KEY=your_openrouter_api_key_here -MODEL=openai/gpt-4o-mini +MODEL=google/gemini-3-flash-preview # RXResume credentials for PDF generation -# Create an account at: https://rxresu.me -# for reference: https://docs.rxresu.me/guides/using-the-api -RXRESUME_API_KEY= +# Create an account at: https://v4.rxresu.me +RXRESUME_EMAIL=your_email@example.com +RXRESUME_PASSWORD=your_password_here -# Optional: Basic Auth for write access (read-only without auth) +# Optional: Basic Auth for write access +# the app is fully unauthenticated if this isn't set, which is the default # When set, all write actions (POST/PATCH/DELETE) require Basic Auth. -# Browsing remains public and read-only. BASIC_AUTH_USER= BASIC_AUTH_PASSWORD= diff --git a/README.md b/README.md index 7209044..66b5cef 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ AI-powered job discovery and application pipeline. Automatically finds jobs, sco 1. **Search**: Scrapes Gradcracker, Indeed, LinkedIn, and UK Visa Sponsorship jobs. 2. **Score**: AI ranks jobs by suitability using OpenRouter. 3. **Tailor**: Generates a custom resume summary for top-tier matches. -4. **Export**: Automates [RxResume](https://rxresu.me) to create tailored PDFs. +4. **Export**: Automates [RxResume](https://v4.rxresu.me) to create tailored PDFs. 5. **Manage**: Review and mark jobs as "Applied" via the dashboard (syncs to Notion). ## Example of generating a tailored resume for a job @@ -17,20 +17,17 @@ https://github.com/user-attachments/assets/06e5e782-47f5-42d0-8b28-b89102d7ea1b ## Quick Start ```bash -# 1. Setup environment -cp .env.example .env - -# 2. Run with Docker +# 1. Run with Docker docker compose up -d --build -# 3. Access Dashboard +# 2. Open the dashboard # http://localhost:3005 ``` -## Setup -Essential variables in `.env`: -- `OPENROUTER_API_KEY`: For job scoring and tailoring. -- `RXRESUME_EMAIL`/`PASSWORD`: To automate PDF exports. +The app will guide you through setup on first launch. The onboarding wizard helps you: +- Connect your OpenRouter API key (for AI scoring/tailoring) +- Add your RxResume credentials (for PDF export) +- Upload your base resume JSON (exported from RxResume) ## Structure - `/orchestrator`: React frontend + Node.js backend & pipeline. @@ -43,14 +40,8 @@ Orchestrator docs here: `documentation/orchestrator.md` ## Read-only mode (Basic Auth) -Set `BASIC_AUTH_USER` and `BASIC_AUTH_PASSWORD` in `.env` to make the app read-only for the public. +You can make the app read-only for the public by setting a username and password in the **Settings** page. After this, all write actions (POST/PATCH/DELETE) require Basic Auth; browsing and viewing remain public. -2. Put your exported RXResume JSON at `resume-generator/base.json`. -3. Start: `docker compose up -d --build` -4. Open: - - Dashboard/UI: `http://localhost:3005` - - API: `http://localhost:3005/api` - - Health: `http://localhost:3005/health` Persistent data lives in `./data` (bind-mounted into the container). @@ -97,6 +88,10 @@ Dev URLs: - **Pipeline config knobs**: `POST /api/pipeline/run` accepts `{ topN, minSuitabilityScore }`; `PIPELINE_TOP_N`/`PIPELINE_MIN_SCORE` are used by `npm run pipeline:run` (CLI runner). - **Anti-bot reality**: crawling is headless + "humanized", but sites can still block; expect occasional flakiness. +Note on Analytics: The current alpha version includes anonymous analytics (Umami) to help me debug performance. This will be made opt-in only in the upcoming updates. If you want to disable it now, block umami.dakheera47.com in your firewall. + +[![Star History Chart](https://app.repohistory.com/api/svg?repo=DaKheera47/job-ops&type=Date&background=0D1117&color=b562f8)](https://app.repohistory.com/star-history) + ## License AGPLv3 diff --git a/docker-compose.yml b/docker-compose.yml index d29e177..5859374 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,10 +14,6 @@ services: volumes: # Persist database and generated PDFs - ./data:/app/data - # Base resume JSON (read-only) - - ./resume-generator/base.json:/app/resume-generator/base.json:ro - env_file: - - .env environment: # Server config - NODE_ENV=production diff --git a/documentation/orchestrator.md b/documentation/orchestrator.md index f235461..e9f57e0 100644 --- a/documentation/orchestrator.md +++ b/documentation/orchestrator.md @@ -38,7 +38,7 @@ Once a job is `ready`, the Ready panel is the "shipping lane": The PDF is generated from: -- The base resume JSON (`resume-generator/base.json`). +- The base resume JSON (uploaded via the Onboarding UI or Settings). - The job description (used for AI tailoring and project selection). - Your tailored summary/headline/skills and selected projects. diff --git a/documentation/self-hosting.md b/documentation/self-hosting.md index 778b074..45dd831 100644 --- a/documentation/self-hosting.md +++ b/documentation/self-hosting.md @@ -1,61 +1,40 @@ # Self-Hosting (Docker Compose) -This project is designed to be self-hostable with a single Docker Compose command. +The easiest way to run JobOps is via Docker Compose. The app is self-configuring and will guide you through the setup on your first visit. ## Prereqs - Docker Desktop or Docker Engine + Compose v2 -- An OpenRouter API key (required for AI scoring and summaries) -- RXResume credentials (only if you want PDF exports) -## 1) Clone and set up environment +## 1) Start the stack -```bash -cp .env.example .env -``` - -Open `.env` and set at least: -- `OPENROUTER_API_KEY` - -Optional but commonly used: -- `RXRESUME_EMAIL`, `RXRESUME_PASSWORD` (for CV PDF generation) -- `UKVISAJOBS_EMAIL`, `UKVISAJOBS_PASSWORD` (if you want to scrape UKVisaJobs) -- `BASIC_AUTH_USER`, `BASIC_AUTH_PASSWORD` (read-only public, auth required for writes) - -## 2) Provide a base resume JSON - -The container mounts a base resume JSON at `resume-generator/base.json`. - -- Create or copy your exported RXResume JSON to: - - `resume-generator/base.json` - -If you do not plan to generate PDFs, you can still provide a minimal JSON file to satisfy the mount. - -## 3) Start the stack +No environment variables are strictly required to start. Simply run: ```bash docker compose up -d --build ``` -This will build a single container that runs the API, UI, scrapers, and resume generator. +This builds a single container that runs the API, UI, scrapers, and resume generator. -## 4) Access the app +## 2) Access the app and Onboard -- Dashboard: http://localhost:3005 -- API: http://localhost:3005/api -- Health: http://localhost:3005/health +Open your browser to: +- **Dashboard**: http://localhost:3005 + +On first launch, you will be greeted by an **Onboarding Wizard**. The app will help you validate and save your configuration: + +1. **Connect AI**: Add your OpenRouter API key (required for job scoring and summaries). +2. **PDF Export**: Add your RxResume credentials (if you want to generate tailored PDFs). +3. **Resume JSON**: Upload your base resume JSON (exported from RxResume). + +The app saves these to its persistent database, so you don't need to manage `.env` files for basic setup. All other settings (like search terms, job sources, and more) can also be configured directly in the UI. ## Persistent data `./data` is bind-mounted into the container. It stores: -- SQLite DB: `data/jobs.db` +- SQLite DB: `data/jobs.db` (contains your API keys and configuration) - Generated PDFs: `data/pdfs/` - -## Common issues - -- First build is slow: Playwright + Camoufox download Firefox during the image build. -- Scraping can be blocked by target sites (LinkedIn/Indeed/UKVisa). Retry or adjust sources. -- Missing `resume-generator/base.json` will break PDF generation (and the mount). +- Resume JSON: Stored internally after upload. ## Updating diff --git a/orchestrator/README.md b/orchestrator/README.md index 12085b3..1adaf7b 100644 --- a/orchestrator/README.md +++ b/orchestrator/README.md @@ -33,7 +33,7 @@ orchestrator/ 2. **Set up environment:** ```bash cp .env.example .env - # Edit .env with your API keys + # The app is self-configuring. You can add keys via the UI Onboarding. ``` 3. **Initialize database:** diff --git a/orchestrator/package-lock.json b/orchestrator/package-lock.json index 7bc8e6f..8437329 100644 --- a/orchestrator/package-lock.json +++ b/orchestrator/package-lock.json @@ -8,16 +8,22 @@ "name": "job-ops-orchestrator", "version": "1.0.0", "dependencies": { + "@hookform/resolvers": "^5.2.2", + "@paralleldrive/cuid2": "^3.0.6", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15", + "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", + "@tailwindcss/vite": "^4.1.18", "better-sqlite3": "^11.6.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -27,13 +33,13 @@ "express": "^4.18.2", "lucide-react": "^0.561.0", "next-themes": "^0.4.6", + "react-hook-form": "^7.71.1", "react-markdown": "^10.1.0", "react-transition-group": "^4.4.5", "remark-gfm": "^4.0.1", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "tailwindcss-animate": "^1.0.7", - "tw-animate-css": "^1.4.0", "vaul": "^1.1.2", "zod": "^3.23.8" }, @@ -58,7 +64,9 @@ "react-dom": "^18.3.1", "react-router-dom": "^7.0.2", "tailwindcss": "^4.1.18", + "tsc-alias": "^1.8.16", "tsx": "^4.19.2", + "tw-animate-css": "^1.4.0", "typescript": "^5.7.2", "vite": "^6.0.3", "vitest": "^4.0.16" @@ -1240,7 +1248,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1274,7 +1281,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1308,7 +1314,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1420,11 +1425,22 @@ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==" }, + "node_modules/@hookform/resolvers": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", + "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -1435,7 +1451,6 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -1446,7 +1461,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -1456,20 +1470,80 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-3.0.6.tgz", + "integrity": "sha512-ujtxTTvr4fwPrzuQT7o6VLKs5BzdWetR9+/zRQ0SyK9hVIwZQllEccxgcHYXN6I3Z429y1yg3F6+uiVxMDPrLQ==", + "dependencies": { + "@noble/hashes": "^2.0.1", + "bignumber.js": "^9.3.1", + "error-causes": "^3.0.2" + }, + "bin": { + "cuid2": "bin/cuid2.js" + } + }, "node_modules/@petamoriken/float16": { "version": "3.9.3", "resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz", @@ -1522,6 +1596,7 @@ "version": "1.1.15", "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz", "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==", + "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", @@ -1887,6 +1962,52 @@ } } }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz", + "integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-menu": { "version": "2.1.16", "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", @@ -2118,6 +2239,38 @@ } } }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", + "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-roving-focus": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", @@ -2213,6 +2366,7 @@ "version": "1.1.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==", + "license": "MIT", "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, @@ -2257,6 +2411,7 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, @@ -2299,6 +2454,58 @@ } } }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.8.tgz", + "integrity": "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", @@ -2469,7 +2676,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2483,7 +2689,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2497,7 +2702,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2511,7 +2715,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2525,7 +2728,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2539,7 +2741,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2553,7 +2754,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2567,7 +2767,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2581,7 +2780,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2595,7 +2793,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2609,7 +2806,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2623,7 +2819,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2637,7 +2832,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2651,7 +2845,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2665,7 +2858,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2679,7 +2871,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2693,7 +2884,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2707,7 +2897,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2721,7 +2910,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2735,7 +2923,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2749,7 +2936,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2763,7 +2949,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2776,11 +2961,16 @@ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "dev": true }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@tailwindcss/node": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", - "dev": true, "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", @@ -2795,7 +2985,6 @@ "version": "4.1.18", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", - "dev": true, "engines": { "node": ">= 10" }, @@ -2821,7 +3010,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "android" @@ -2837,7 +3025,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "darwin" @@ -2853,7 +3040,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "darwin" @@ -2869,7 +3055,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "freebsd" @@ -2885,7 +3070,6 @@ "cpu": [ "arm" ], - "dev": true, "optional": true, "os": [ "linux" @@ -2901,7 +3085,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -2917,7 +3100,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -2933,7 +3115,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -2949,7 +3130,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -2973,7 +3153,6 @@ "cpu": [ "wasm32" ], - "dev": true, "optional": true, "dependencies": { "@emnapi/core": "^1.7.1", @@ -2994,7 +3173,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "win32" @@ -3010,7 +3188,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "win32" @@ -3032,6 +3209,20 @@ "tailwindcss": "4.1.18" } }, + "node_modules/@tailwindcss/vite": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz", + "integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "tailwindcss": "4.1.18" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -3571,6 +3762,33 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/aria-hidden": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", @@ -3597,6 +3815,16 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "license": "MIT" }, + "node_modules/array-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -3699,6 +3927,27 @@ "prebuild-install": "^7.1.1" } }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "engines": { + "node": "*" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/bindings": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", @@ -3758,6 +4007,19 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -3966,6 +4228,31 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, "node_modules/chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", @@ -3976,6 +4263,7 @@ "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", "dependencies": { "clsx": "^2.1.1" }, @@ -4002,6 +4290,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", "engines": { "node": ">=6" } @@ -4047,6 +4336,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || >=14" + } + }, "node_modules/concurrently": { "version": "9.2.1", "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", @@ -4293,6 +4592,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/dir-glob": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/dom-accessibility-api": { "version": "0.5.16", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", @@ -4519,7 +4831,6 @@ "version": "5.18.4", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==", - "dev": true, "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" @@ -4553,6 +4864,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/error-causes": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/error-causes/-/error-causes-3.0.2.tgz", + "integrity": "sha512-i0B8zq1dHL6mM85FGoxaJnVtx6LD5nL2v0hlpGdntg5FOSyzQ46c9lmz5qx0xRS2+PWHGOHcYxGIBC5Le2dRMw==" + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -4794,11 +5110,37 @@ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -4818,6 +5160,19 @@ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", "license": "MIT" }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -4908,7 +5263,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -5031,7 +5385,7 @@ "version": "4.13.0", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "resolve-pkg-maps": "^1.0.0" @@ -5046,6 +5400,40 @@ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", "license": "MIT" }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -5061,8 +5449,7 @@ "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" }, "node_modules/has-flag": { "version": "4.0.0", @@ -5250,6 +5637,16 @@ ], "license": "BSD-3-Clause" }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/indent-string": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", @@ -5307,6 +5704,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-decimal": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", @@ -5316,6 +5726,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -5326,6 +5746,19 @@ "node": ">=8" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-hexadecimal": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", @@ -5335,6 +5768,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/is-plain-obj": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", @@ -5366,7 +5809,6 @@ "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "dev": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -5447,7 +5889,6 @@ "version": "1.30.2", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", - "dev": true, "dependencies": { "detect-libc": "^2.0.3" }, @@ -5479,7 +5920,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "android" @@ -5499,7 +5939,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "darwin" @@ -5519,7 +5958,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "darwin" @@ -5539,7 +5977,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "freebsd" @@ -5559,7 +5996,6 @@ "cpu": [ "arm" ], - "dev": true, "optional": true, "os": [ "linux" @@ -5579,7 +6015,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -5599,7 +6034,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -5619,7 +6053,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -5639,7 +6072,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "linux" @@ -5659,7 +6091,6 @@ "cpu": [ "arm64" ], - "dev": true, "optional": true, "os": [ "win32" @@ -5679,7 +6110,6 @@ "cpu": [ "x64" ], - "dev": true, "optional": true, "os": [ "win32" @@ -5727,6 +6157,7 @@ "version": "0.561.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.561.0.tgz", "integrity": "sha512-Y59gMY38tl4/i0qewcqohPdEbieBy7SovpBL9IFebhc2mDd8x4PZSOsiFRkpPcOq6bj1r/mjH/Rk73gSlIJP2A==", + "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } @@ -5745,7 +6176,6 @@ "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } @@ -6041,6 +6471,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", @@ -6585,6 +7025,33 @@ } ] }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/mime": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", @@ -6660,11 +7127,24 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/mylas": { + "version": "2.1.14", + "resolved": "https://registry.npmjs.org/mylas/-/mylas-2.1.14.tgz", + "integrity": "sha512-BzQguy9W9NJgoVn2mRWzbFrFWWztGCcng2QI9+41frfk+Athwgx3qhqhvStz7ExeUUu7Kzw427sNzHpEZNINog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/raouldeheer" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, "funding": [ { "type": "github", @@ -6734,6 +7214,16 @@ "dev": true, "license": "MIT" }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/normalize-range": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", @@ -6851,6 +7341,16 @@ "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", "license": "MIT" }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -6861,14 +7361,12 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -6877,11 +7375,23 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/plimit-lit": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/plimit-lit/-/plimit-lit-1.6.1.tgz", + "integrity": "sha512-B7+VDyb8Tl6oMJT9oSO2CW8XC/T4UcJGrwOVoNGwOQsQYhlpfajmrMj5xeejqaASq3V/EqThyOeATEOMuSEXiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "queue-lit": "^1.5.1" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", - "dev": true, "funding": [ { "type": "opencollective", @@ -7036,6 +7546,37 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/queue-lit": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/queue-lit/-/queue-lit-1.5.2.tgz", + "integrity": "sha512-tLc36IOPeMAubu8BkW8YDBV+WyIgKlYU7zUNs0J5Vk9skSZ4JfGlPOqplP0aHdfv7HL0B2Pg6nwiq60Qc6M2Hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -7100,6 +7641,22 @@ "react": "^18.3.1" } }, + "node_modules/react-hook-form": { + "version": "7.71.1", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.1.tgz", + "integrity": "sha512-9SUJKCGKo8HUSsCO+y0CtqkqI5nNuaDqTxyqPsZPqIwudpj4rCrAz/jZV+jn57bx5gtZKOh3neQu94DXMc+w5w==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -7292,6 +7849,32 @@ "node": ">= 6" } }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -7381,17 +7964,27 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", - "dev": true, + "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.53.3", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.53.3.tgz", "integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -7435,6 +8028,30 @@ "integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==", "dev": true }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, "node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -7803,6 +8420,16 @@ "simple-concat": "^1.0.0" } }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/sonner": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", @@ -7826,7 +8453,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -7986,6 +8612,7 @@ "version": "3.4.0", "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==", + "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/dcastil" @@ -7994,7 +8621,8 @@ "node_modules/tailwindcss": { "version": "4.1.18", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", - "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==" + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "license": "MIT" }, "node_modules/tailwindcss-animate": { "version": "1.0.7", @@ -8008,7 +8636,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", - "dev": true, "engines": { "node": ">=6" }, @@ -8064,7 +8691,6 @@ "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", - "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -8104,6 +8730,19 @@ "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", "dev": true }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -8165,6 +8804,28 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/tsc-alias": { + "version": "1.8.16", + "resolved": "https://registry.npmjs.org/tsc-alias/-/tsc-alias-1.8.16.tgz", + "integrity": "sha512-QjCyu55NFyRSBAl6+MTFwplpFcnm2Pq01rR/uxfqJoLMm6X3O14KEGtaSDZpJYaE1bJBGDjD0eSuiIWPe2T58g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.3", + "commander": "^9.0.0", + "get-tsconfig": "^4.10.0", + "globby": "^11.0.4", + "mylas": "^2.1.9", + "normalize-path": "^3.0.0", + "plimit-lit": "^1.2.6" + }, + "bin": { + "tsc-alias": "dist/bin/index.js" + }, + "engines": { + "node": ">=16.20.2" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -8175,7 +8836,7 @@ "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "esbuild": "~0.27.0", @@ -8198,7 +8859,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8215,7 +8875,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8232,7 +8891,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8249,7 +8907,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8266,7 +8923,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8283,7 +8939,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8300,7 +8955,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8317,7 +8971,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8334,7 +8987,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8351,7 +9003,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8368,7 +9019,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8385,7 +9035,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8402,7 +9051,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8419,7 +9067,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8436,7 +9083,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8453,7 +9099,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8470,7 +9115,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8487,7 +9131,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8504,7 +9147,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8521,7 +9163,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8538,7 +9179,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8555,7 +9195,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8572,7 +9211,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -8586,7 +9224,7 @@ "version": "0.27.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.1.tgz", "integrity": "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -8640,6 +9278,8 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", + "dev": true, + "license": "MIT", "funding": { "url": "https://github.com/sponsors/Wombosvideo" } @@ -8906,7 +9546,6 @@ "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", - "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.25.0", @@ -8984,7 +9623,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9001,7 +9639,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9018,7 +9655,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9035,7 +9671,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9052,7 +9687,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9069,7 +9703,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9086,7 +9719,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9103,7 +9735,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9120,7 +9751,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9137,7 +9767,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9154,7 +9783,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9171,7 +9799,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9188,7 +9815,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9205,7 +9831,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9222,7 +9847,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9239,7 +9863,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9256,7 +9879,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9273,7 +9895,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9290,7 +9911,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9307,7 +9927,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9324,7 +9943,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9341,7 +9959,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9358,7 +9975,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9375,7 +9991,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9392,7 +10007,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9409,7 +10023,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -9423,7 +10036,6 @@ "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { diff --git a/orchestrator/package.json b/orchestrator/package.json index ca480a0..f2e2c95 100644 --- a/orchestrator/package.json +++ b/orchestrator/package.json @@ -9,7 +9,7 @@ "dev:server": "tsx watch src/server/index.ts", "dev:client": "vite --host", "build": "npm run build:client && npm run build:server", - "build:server": "tsc -p tsconfig.server.json", + "build:server": "tsc -p tsconfig.server.json && tsc-alias -p tsconfig.server.json", "build:client": "vite build", "start": "node dist/server/index.js", "db:migrate": "tsx src/server/db/migrate.ts", @@ -20,16 +20,22 @@ "test:run": "vitest run" }, "dependencies": { + "@hookform/resolvers": "^5.2.2", + "@paralleldrive/cuid2": "^3.0.6", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.15", + "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-progress": "^1.1.8", + "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-tooltip": "^1.2.8", + "@tailwindcss/vite": "^4.1.18", "better-sqlite3": "^11.6.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -39,13 +45,13 @@ "express": "^4.18.2", "lucide-react": "^0.561.0", "next-themes": "^0.4.6", + "react-hook-form": "^7.71.1", "react-markdown": "^10.1.0", "react-transition-group": "^4.4.5", "remark-gfm": "^4.0.1", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "tailwindcss-animate": "^1.0.7", - "tw-animate-css": "^1.4.0", "vaul": "^1.1.2", "zod": "^3.23.8" }, @@ -70,7 +76,9 @@ "react-dom": "^18.3.1", "react-router-dom": "^7.0.2", "tailwindcss": "^4.1.18", + "tsc-alias": "^1.8.16", "tsx": "^4.19.2", + "tw-animate-css": "^1.4.0", "typescript": "^5.7.2", "vite": "^6.0.3", "vitest": "^4.0.16" diff --git a/orchestrator/src/client/App.tsx b/orchestrator/src/client/App.tsx index e6ec43b..c5df563 100644 --- a/orchestrator/src/client/App.tsx +++ b/orchestrator/src/client/App.tsx @@ -11,6 +11,7 @@ import { OrchestratorPage } from "./pages/OrchestratorPage"; import { SettingsPage } from "./pages/SettingsPage"; import { UkVisaJobsPage } from "./pages/UkVisaJobsPage"; import { VisaSponsorsPage } from "./pages/VisaSponsorsPage"; +import { OnboardingGate } from "./components/OnboardingGate"; export const App: React.FC = () => { const location = useLocation(); @@ -27,6 +28,7 @@ export const App: React.FC = () => { return ( <> + { }); } +export async function checkSponsor(id: string): Promise { + return fetchApi(`/jobs/${id}/check-sponsor`, { + method: 'POST', + }); +} + export async function markAsApplied(id: string): Promise { return fetchApi(`/jobs/${id}/apply`, { method: 'POST', @@ -168,6 +176,38 @@ export async function getProfileProjects(): Promise return fetchApi('/profile/projects'); } +export async function getProfile(): Promise { + return fetchApi('/profile'); +} + +export async function getProfileStatus(): Promise { + return fetchApi('/profile/status'); +} + +export async function uploadProfile(profile: ResumeProfile): Promise { + return fetchApi('/profile/upload', { + method: 'POST', + body: JSON.stringify({ profile }), + }); +} + +export async function validateOpenrouter(apiKey?: string): Promise { + return fetchApi('/onboarding/validate/openrouter', { + method: 'POST', + body: JSON.stringify({ apiKey }), + }); +} + +export async function validateRxresume(email?: string, password?: string): Promise { + return fetchApi('/onboarding/validate/rxresume', { + method: 'POST', + body: JSON.stringify({ email, password }), + }); +} + +export async function validateResumeJson(): Promise { + return fetchApi('/onboarding/validate/resume'); +} export async function updateSettings(update: { model?: string | null @@ -186,7 +226,15 @@ export async function updateSettings(update: { jobspyCountryIndeed?: string | null jobspySites?: string[] | null jobspyLinkedinFetchDescription?: boolean | null - rxResumeBaseResumeId?: string | null + showSponsorInfo?: boolean | null + openrouterApiKey?: string | null + rxresumeEmail?: string | null + rxresumePassword?: string | null + basicAuthUser?: string | null + basicAuthPassword?: string | null + ukvisajobsEmail?: string | null + ukvisajobsPassword?: string | null + webhookSecret?: string | null }): Promise { return fetchApi('/settings', { method: 'PATCH', diff --git a/orchestrator/src/client/components/JobHeader.test.tsx b/orchestrator/src/client/components/JobHeader.test.tsx new file mode 100644 index 0000000..a96407c --- /dev/null +++ b/orchestrator/src/client/components/JobHeader.test.tsx @@ -0,0 +1,104 @@ +import React from "react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { JobHeader } from "./JobHeader"; +import { useSettings } from "../hooks/useSettings"; +import type { Job } from "../../shared/types"; + +// Mock useSettings +vi.mock("../hooks/useSettings", () => ({ + useSettings: vi.fn(), +})); + +// Mock api +vi.mock("../api", () => ({ + checkSponsor: vi.fn(), +})); + +// Mock Tooltip components to simplify testing +vi.mock("@/components/ui/tooltip", () => ({ + TooltipProvider: ({ children }: { children: React.ReactNode }) => <>{children}, + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}, + TooltipContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +const mockJob: Job = { + id: "job-1", + title: "Software Engineer", + employer: "Tech Corp", + location: "London", + salary: "£60,000", + deadline: "2025-12-31", + status: "discovered", + source: "linkedin", + suitabilityScore: 85, + suitabilityReason: "Strong match", + sponsorMatchScore: null, + sponsorMatchNames: null, + // Other fields... +} as Job; + +describe("JobHeader", () => { + beforeEach(() => { + vi.clearAllMocks(); + (useSettings as any).mockReturnValue({ + showSponsorInfo: true, + }); + }); + + it("renders basic job information", () => { + render(); + expect(screen.getByText("Software Engineer")).toBeInTheDocument(); + expect(screen.getByText("Tech Corp")).toBeInTheDocument(); + expect(screen.getByText("London")).toBeInTheDocument(); + expect(screen.getByText("£60,000")).toBeInTheDocument(); + }); + + it("shows 'Check Sponsorship Status' button when sponsorMatchScore is null", async () => { + const onCheckSponsor = vi.fn().mockResolvedValue(undefined); + render(); + + const button = screen.getByText("Check Sponsorship Status"); + expect(button).toBeInTheDocument(); + + fireEvent.click(button); + + expect(onCheckSponsor).toHaveBeenCalled(); + }); + + it("shows 'Confirmed Sponsor' when score >= 95", () => { + const jobWithSponsor = { ...mockJob, sponsorMatchScore: 98, sponsorMatchNames: '["Tech Corp Ltd"]' }; + render(); + + expect(screen.getByText("Confirmed Sponsor")).toBeInTheDocument(); + }); + + it("shows 'Potential Sponsor' when score is between 80 and 94", () => { + const jobWithPotential = { ...mockJob, sponsorMatchScore: 85, sponsorMatchNames: '["Techy Corp"]' }; + render(); + + expect(screen.getByText("Potential Sponsor")).toBeInTheDocument(); + }); + + it("shows 'Sponsor Not Found' when score < 80", () => { + const jobNoSponsor = { ...mockJob, sponsorMatchScore: 40, sponsorMatchNames: '["Other Corp"]' }; + render(); + + expect(screen.getByText("Sponsor Not Found")).toBeInTheDocument(); + }); + + it("hides sponsor info when showSponsorInfo is false", () => { + (useSettings as any).mockReturnValue({ + showSponsorInfo: false, + }); + + const jobWithSponsor = { ...mockJob, sponsorMatchScore: 98 }; + render(); + + expect(screen.queryByText("Confirmed Sponsor")).not.toBeInTheDocument(); + expect(screen.queryByText("Check Sponsorship Status")).not.toBeInTheDocument(); + }); +}); diff --git a/orchestrator/src/client/components/JobHeader.tsx b/orchestrator/src/client/components/JobHeader.tsx index 3d1f9be..2498049 100644 --- a/orchestrator/src/client/components/JobHeader.tsx +++ b/orchestrator/src/client/components/JobHeader.tsx @@ -1,13 +1,18 @@ -import React from "react"; -import { Calendar, DollarSign, MapPin } from "lucide-react"; +import React, { useMemo, useState } from "react"; +import { Calendar, DollarSign, Loader2, MapPin, Search } from "lucide-react"; import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { cn, formatDate, sourceLabel } from "@/lib/utils"; import type { Job, JobStatus } from "../../shared/types"; import { defaultStatusToken, statusTokens } from "../pages/orchestrator/constants"; +import { useSettings } from "../hooks/useSettings"; + interface JobHeaderProps { job: Job; className?: string; + onCheckSponsor?: () => Promise; } const StatusPill: React.FC<{ status: JobStatus }> = ({ status }) => { @@ -42,7 +47,101 @@ const ScoreMeter: React.FC<{ score: number | null }> = ({ score }) => { ); }; -export const JobHeader: React.FC = ({ job, className }) => { +interface SponsorPillProps { + score: number | null; + names: string | null; + onCheck?: () => Promise; +} + +const SponsorPill: React.FC = ({ score, names, onCheck }) => { + const [isChecking, setIsChecking] = useState(false); + + const parsedNames = useMemo(() => { + if (!names) return []; + try { + return JSON.parse(names) as string[]; + } catch { + return []; + } + }, [names]); + + const handleCheck = async () => { + if (!onCheck) return; + setIsChecking(true); + try { + await onCheck(); + } finally { + setIsChecking(false); + } + }; + + // Show "Check" button if no score and callback provided + if (score == null && onCheck) { + return ( + + + + + + +

Check if employer is a visa sponsor

+
+
+
+ ); + } + + if (score == null) { + return null; + } + + const getStatus = (s: number) => { + if (s >= 95) return { label: "Confirmed Sponsor", dot: "bg-emerald-500", color: "text-emerald-400" }; + if (s >= 80) return { label: "Potential Sponsor", dot: "bg-amber-500", color: "text-amber-400" }; + return { label: "Sponsor Not Found", dot: "bg-slate-500", color: "text-slate-400" }; + }; + + const status = getStatus(score); + const tooltipContent = `${score}% match`; + + return ( + + + + + + {status.label} + + + + {parsedNames.length > 0 && ( +

+ Matched + {parsedNames.join(", ")} +

+ )} +

{tooltipContent}

+
+
+
+ ); +}; + +export const JobHeader: React.FC = ({ job, className, onCheckSponsor }) => { + const { showSponsorInfo } = useSettings(); const deadline = formatDate(job.deadline); return ( @@ -51,7 +150,9 @@ export const JobHeader: React.FC = ({ job, className }) => {
{job.title}
-
{job.employer}
+
+ {job.employer} +
{sourceLabel[job.source]} @@ -82,7 +183,16 @@ export const JobHeader: React.FC = ({ job, className }) => { {/* Status and score: single line, subdued */}
- +
+ + {showSponsorInfo && ( + + )} +
diff --git a/orchestrator/src/client/components/OnboardingGate.tsx b/orchestrator/src/client/components/OnboardingGate.tsx new file mode 100644 index 0000000..c144a64 --- /dev/null +++ b/orchestrator/src/client/components/OnboardingGate.tsx @@ -0,0 +1,501 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" +import { Check } from "lucide-react" +import { toast } from "sonner" + +import { AlertDialog, AlertDialogContent, AlertDialogDescription, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog" +import { Button } from "@/components/ui/button" +import { Field, FieldContent, FieldDescription, FieldLabel, FieldTitle } from "@/components/ui/field" +import { Input } from "@/components/ui/input" +import { Progress } from "@/components/ui/progress" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { cn } from "@/lib/utils" +import * as api from "@client/api" +import { useSettings } from "@client/hooks/useSettings" +import { SettingsInput } from "@client/pages/settings/components/SettingsInput" +import { formatSecretHint } from "@client/pages/settings/utils" +import type { ResumeProfile, ValidationResult } from "@shared/types" + +type ValidationState = ValidationResult & { checked: boolean } + +export const OnboardingGate: React.FC = () => { + const { settings, isLoading: settingsLoading, refreshSettings } = useSettings() + const [isSavingEnv, setIsSavingEnv] = useState(false) + const [isUploadingResume, setIsUploadingResume] = useState(false) + const [isValidatingOpenrouter, setIsValidatingOpenrouter] = useState(false) + const [isValidatingRxresume, setIsValidatingRxresume] = useState(false) + const [isValidatingResume, setIsValidatingResume] = useState(false) + const [openrouterValidation, setOpenrouterValidation] = useState({ + valid: false, + message: null, + checked: false, + }) + const [rxresumeValidation, setRxresumeValidation] = useState({ + valid: false, + message: null, + checked: false, + }) + const [resumeValidation, setResumeValidation] = useState({ + valid: false, + message: null, + checked: false, + }) + const [currentStep, setCurrentStep] = useState(null) + + const [openrouterApiKey, setOpenrouterApiKey] = useState("") + const [rxresumeEmail, setRxresumeEmail] = useState("") + const [rxresumePassword, setRxresumePassword] = useState("") + const [resumeFile, setResumeFile] = useState(null) + const fileInputRef = useRef(null) + + const validateResume = useCallback(async () => { + setIsValidatingResume(true) + try { + const result = await api.validateResumeJson() + setResumeValidation({ ...result, checked: true }) + return result + } catch (error) { + const message = error instanceof Error ? error.message : "Resume validation failed" + const result = { valid: false, message } + setResumeValidation({ ...result, checked: true }) + return result + } finally { + setIsValidatingResume(false) + } + }, []) + + const validateOpenrouter = useCallback(async (apiKey?: string) => { + setIsValidatingOpenrouter(true) + try { + const result = await api.validateOpenrouter(apiKey) + setOpenrouterValidation({ ...result, checked: true }) + return result + } catch (error) { + const message = error instanceof Error ? error.message : "OpenRouter validation failed" + const result = { valid: false, message } + setOpenrouterValidation({ ...result, checked: true }) + return result + } finally { + setIsValidatingOpenrouter(false) + } + }, []) + + const validateRxresume = useCallback(async (email?: string, password?: string) => { + setIsValidatingRxresume(true) + try { + const result = await api.validateRxresume(email, password) + setRxresumeValidation({ ...result, checked: true }) + return result + } catch (error) { + const message = error instanceof Error ? error.message : "RxResume validation failed" + const result = { valid: false, message } + setRxresumeValidation({ ...result, checked: true }) + return result + } finally { + setIsValidatingRxresume(false) + } + }, []) + + const hasOpenrouterKey = Boolean(settings?.openrouterApiKeyHint) + const hasRxresumeEmail = Boolean(settings?.rxresumeEmail?.trim()) + const hasRxresumePassword = Boolean(settings?.rxresumePasswordHint) + const hasBaseResume = resumeValidation.valid + + const shouldOpen = Boolean(settings && !settingsLoading) + && !(openrouterValidation.valid && rxresumeValidation.valid && resumeValidation.valid) + + const openrouterCurrent = settings?.openrouterApiKeyHint + ? formatSecretHint(settings.openrouterApiKeyHint) + : undefined + const rxresumeEmailCurrent = settings?.rxresumeEmail?.trim() + ? settings.rxresumeEmail + : undefined + const rxresumePasswordCurrent = settings?.rxresumePasswordHint + ? formatSecretHint(settings.rxresumePasswordHint) + : undefined + + const steps = useMemo( + () => [ + { + id: "openrouter", + label: "Connect AI", + subtitle: "OpenRouter key", + complete: openrouterValidation.valid, + }, + { + id: "rxresume", + label: "PDF Export", + subtitle: "RxResume login", + complete: rxresumeValidation.valid, + }, + { + id: "resume", + label: "Resume JSON", + subtitle: "Upload your file", + complete: resumeValidation.valid, + }, + ], + [openrouterValidation.valid, resumeValidation.valid, rxresumeValidation.valid] + ) + + const defaultStep = steps.find((step) => !step.complete)?.id ?? steps[0]?.id + + useEffect(() => { + if (!shouldOpen) return + if (!currentStep && defaultStep) { + setCurrentStep(defaultStep) + } + }, [currentStep, defaultStep, shouldOpen]) + + const runAllValidations = useCallback(async () => { + if (!settings) return + const results = await Promise.allSettled([ + validateOpenrouter(), + validateRxresume(), + validateResume(), + ]) + + const failed = results.find((result) => result.status === "rejected") + if (failed) { + const reason = failed.status === "rejected" ? failed.reason : null + const message = reason instanceof Error ? reason.message : "Validation checks failed" + toast.error(message) + } + }, [settings, validateOpenrouter, validateRxresume, validateResume]) + + useEffect(() => { + if (!settings || settingsLoading) return + if (openrouterValidation.checked || rxresumeValidation.checked || resumeValidation.checked) return + void runAllValidations() + }, [settings, settingsLoading, openrouterValidation.checked, rxresumeValidation.checked, resumeValidation.checked, runAllValidations]) + + const handleRefresh = async () => { + const results = await Promise.allSettled([refreshSettings(), runAllValidations()]) + const failed = results.find((result) => result.status === "rejected") + if (failed) { + const reason = failed.status === "rejected" ? failed.reason : null + const message = reason instanceof Error ? reason.message : "Failed to refresh setup" + toast.error(message) + } + } + + const handleSaveOpenrouter = async (): Promise => { + const openrouterValue = openrouterApiKey.trim() + if (!openrouterValue && !hasOpenrouterKey) { + toast.info("Add your OpenRouter API key to continue") + return false + } + + try { + const validation = await validateOpenrouter(openrouterValue || undefined) + if (!validation.valid) { + toast.error(validation.message || "OpenRouter validation failed") + return false + } + + if (openrouterValue) { + setIsSavingEnv(true) + await api.updateSettings({ openrouterApiKey: openrouterValue }) + await refreshSettings() + setOpenrouterApiKey("") + } + + toast.success("OpenRouter connected") + return true + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to save OpenRouter key" + toast.error(message) + return false + } finally { + setIsSavingEnv(false) + } + } + + const handleSaveRxresume = async (): Promise => { + const emailValue = rxresumeEmail.trim() + const passwordValue = rxresumePassword.trim() + const missing: string[] = [] + + if (!hasRxresumeEmail && !emailValue) missing.push("RxResume email") + if (!hasRxresumePassword && !passwordValue) missing.push("RxResume password") + + if (missing.length > 0) { + toast.info("Almost there", { + description: `Missing: ${missing.join(", ")}`, + }) + return false + } + + try { + const validation = await validateRxresume(emailValue || undefined, passwordValue || undefined) + if (!validation.valid) { + toast.error(validation.message || "RxResume validation failed") + return false + } + + const update: { rxresumeEmail?: string; rxresumePassword?: string } = {} + if (emailValue) update.rxresumeEmail = emailValue + if (passwordValue) update.rxresumePassword = passwordValue + + if (Object.keys(update).length > 0) { + setIsSavingEnv(true) + await api.updateSettings(update) + await refreshSettings() + setRxresumePassword("") + } + + toast.success("RxResume connected") + return true + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to save RxResume credentials" + toast.error(message) + return false + } finally { + setIsSavingEnv(false) + } + } + + const handleUploadResume = async (): Promise => { + if (!resumeFile) { + const validation = await validateResume() + if (!validation.valid) { + toast.info(validation.message || "Upload your resume JSON to continue") + return false + } + + return true + } + + try { + setIsUploadingResume(true) + const text = await resumeFile.text() + let parsed: ResumeProfile + try { + parsed = JSON.parse(text) as ResumeProfile + } catch { + throw new Error("Resume JSON is invalid. Export the base.json from RxResume.") + } + + await api.uploadProfile(parsed) + await validateResume() + setResumeFile(null) + if (fileInputRef.current) { + fileInputRef.current.value = "" + } + toast.success("Resume uploaded") + return true + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to upload resume" + toast.error(message) + return false + } finally { + setIsUploadingResume(false) + } + } + + const resumeFileName = resumeFile?.name || "" + const resolvedStepIndex = currentStep ? steps.findIndex((step) => step.id === currentStep) : 0 + const stepIndex = resolvedStepIndex >= 0 ? resolvedStepIndex : 0 + const completedSteps = steps.filter((step) => step.complete).length + const progressValue = steps.length > 0 ? Math.round((completedSteps / steps.length) * 100) : 0 + const isBusy = isSavingEnv || isUploadingResume || settingsLoading || isValidatingOpenrouter || isValidatingRxresume || isValidatingResume + const canGoBack = stepIndex > 0 + const primaryLabel = currentStep === "resume" + ? (resumeValidation.valid ? "Finish" : "Upload and validate") + : currentStep === "openrouter" + ? (openrouterValidation.valid ? "Revalidate" : "Validate") + : currentStep === "rxresume" + ? (rxresumeValidation.valid ? "Revalidate" : "Validate") + : "Validate" + + const handlePrimaryAction = async () => { + if (!currentStep) return + if (currentStep === "openrouter") { + await handleSaveOpenrouter() + return + } + if (currentStep === "rxresume") { + await handleSaveRxresume() + return + } + if (currentStep === "resume") { + if (hasBaseResume) { + await handleRefresh() + return + } + await handleUploadResume() + } + } + + const handleBack = () => { + if (!canGoBack) return + setCurrentStep(steps[stepIndex - 1]?.id ?? currentStep) + } + + if (!shouldOpen || !currentStep) return null + + return ( + + event.preventDefault()} + > +
+ + Welcome to Job Ops + + Let’s get your workspace ready. Add your keys and resume once, then the pipeline can run end-to-end. + + + + + + {steps.map((step, index) => { + const isActive = step.id === currentStep + const isComplete = step.complete + + return ( + + + + + {step.label} + {step.subtitle} + + + {isComplete ? : index + 1} + + + + + ) + })} + + + +
+

Connect OpenRouter

+

Used for job scoring, summaries, and tailoring.

+
+ setOpenrouterApiKey(event.target.value), + }} + type="password" + placeholder="sk-or-v1..." + current={openrouterCurrent} + helper="Create a key at openrouter.ai" + disabled={isSavingEnv} + /> +
+ + +
+

Link your RxResume account

+

Used to export tailored PDFs.

+
+
+ setRxresumeEmail(event.target.value), + }} + placeholder="you@example.com" + current={rxresumeEmailCurrent} + disabled={isSavingEnv} + /> + setRxresumePassword(event.target.value), + }} + type="password" + placeholder="Enter password" + current={rxresumePasswordCurrent} + disabled={isSavingEnv} + /> +
+
+ + +
+

Upload your resume JSON

+

Use the JSON export you downloaded from v4.rxresu.me.

+
+
+
+ + setResumeFile(event.target.files?.[0] ?? null)} + disabled={isUploadingResume} + /> + {resumeFileName && ( +

Selected: {resumeFileName}

+ )} +
+
+
+
+ +
+ +
+ + +
+
+ + + +
+ Friendly heads-up: pipelines can be slow or a little flaky in alpha. If anything feels off, open a GitHub issue and + we will take a look.{" "} + + Open an issue + + . +
+
+
+
+ ) +} diff --git a/orchestrator/src/client/components/ReadyPanel.tsx b/orchestrator/src/client/components/ReadyPanel.tsx index c3f0de6..f2a5483 100644 --- a/orchestrator/src/client/components/ReadyPanel.tsx +++ b/orchestrator/src/client/components/ReadyPanel.tsx @@ -3,6 +3,8 @@ * * Designed for a single, fast, repeatable workflow: verify → download → apply → mark applied. * The PDF is the primary artifact, represented abstractly through an Application Kit summary. + * + * Now includes inline tailoring mode for editing and regenerating PDFs without switching tabs. */ import React, { useCallback, useEffect, useMemo, useState } from "react"; @@ -42,14 +44,16 @@ import { import { cn, copyTextToClipboard, formatJobForWebhook } from "@/lib/utils"; import * as api from "../api"; import { FitAssessment, JobHeader, TailoredSummary } from "."; +import { TailorMode } from "./discovered-panel/TailorMode"; +import { useProfile } from "../hooks/useProfile"; import type { Job, ResumeProjectCatalogItem } from "../../shared/types"; +type PanelMode = "ready" | "tailor"; + interface ReadyPanelProps { job: Job | null; onJobUpdated: () => void | Promise; onJobMoved: (jobId: string) => void; - onEditTailoring: () => void; - onEditDescription: () => void; } const safeFilenamePart = (value: string | null | undefined) => @@ -59,9 +63,8 @@ export const ReadyPanel: React.FC = ({ job, onJobUpdated, onJobMoved, - onEditTailoring, - onEditDescription, }) => { + const [mode, setMode] = useState("ready"); const [isMarkingApplied, setIsMarkingApplied] = useState(false); const [isRegenerating, setIsRegenerating] = useState(false); const [catalog, setCatalog] = useState([]); @@ -72,11 +75,18 @@ export const ReadyPanel: React.FC = ({ timeoutId: ReturnType; } | null>(null); + const { personName } = useProfile(); + // Load project catalog once useEffect(() => { api.getProfileProjects().then(setCatalog).catch(console.error); }, []); + // Reset mode when job changes + useEffect(() => { + setMode("ready"); + }, [job?.id]); + // Compute derived values const pdfHref = job ? `/pdfs/resume_${job.id}.pdf?v=${encodeURIComponent(job.updatedAt)}` @@ -141,7 +151,7 @@ export const ReadyPanel: React.FC = ({ // Revert to ready status await api.updateJob(jobId, { status: "ready" }); toast.success("Reverted to Ready"); - + if (recentlyApplied?.timeoutId) { clearTimeout(recentlyApplied.timeoutId); } @@ -198,6 +208,23 @@ export const ReadyPanel: React.FC = ({ } }, [job]); + // Handler for regenerating PDF after tailoring edits + const handleTailorFinalize = useCallback(async () => { + if (!job) return; + try { + setIsRegenerating(true); + await api.generateJobPdf(job.id); + toast.success("PDF regenerated"); + await onJobUpdated(); + setMode("ready"); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to regenerate PDF"; + toast.error(message); + } finally { + setIsRegenerating(false); + } + }, [job, onJobUpdated]); + // Empty state if (!job) { return ( @@ -213,9 +240,29 @@ export const ReadyPanel: React.FC = ({ ); } + // Tailor mode - reuse the same TailorMode component with 'ready' variant + if (mode === "tailor") { + return ( + setMode("ready")} + onFinalize={handleTailorFinalize} + isFinalizing={isRegenerating} + variant="ready" + /> + ); + } + return (
- + { + await api.checkSponsor(job.id); + await onJobUpdated(); + }} + /> {/* ───────────────────────────────────────────────────────────────────── PRIMARY ACTION CLUSTER @@ -235,7 +282,7 @@ export const ReadyPanel: React.FC = ({

- This will generate your tailored PDF and move the job to Ready. + {variant === 'ready' + ? 'This will save your changes and regenerate the tailored PDF.' + : 'This will generate your tailored PDF and move the job to Ready.'}

diff --git a/orchestrator/src/client/hooks/useProfile.ts b/orchestrator/src/client/hooks/useProfile.ts new file mode 100644 index 0000000..d8a4ec5 --- /dev/null +++ b/orchestrator/src/client/hooks/useProfile.ts @@ -0,0 +1,91 @@ +import { useEffect, useState } from 'react'; +import * as api from '../api'; +import type { ResumeProfile } from '../../shared/types'; + +let profileCache: ResumeProfile | null = null; +let profileError: Error | null = null; +let subscribers: Set<(profile: ResumeProfile | null, error: Error | null) => void> = new Set(); +let isFetching = false; + +/** + * Hook to get the full profile data from base.json. + * Caches the result to avoid re-fetching. + */ +export function useProfile() { + const [profile, setProfile] = useState(profileCache); + const [error, setError] = useState(profileError); + + useEffect(() => { + if (profileCache) { + setProfile(profileCache); + } + if (profileError) { + setError(profileError); + } + + const handleUpdate = (newProfile: ResumeProfile | null, newError: Error | null) => { + setProfile(newProfile); + setError(newError); + }; + + subscribers.add(handleUpdate); + + if (!profileCache && !isFetching) { + isFetching = true; + profileError = null; + api.getProfile() + .then((data) => { + profileCache = data; + profileError = null; + subscribers.forEach(sub => sub(data, null)); + }) + .catch((err) => { + profileError = err instanceof Error ? err : new Error(String(err)); + subscribers.forEach(sub => sub(profileCache, profileError)); + }) + .finally(() => { + isFetching = false; + }); + } + + return () => { + subscribers.delete(handleUpdate); + }; + }, []); + + const refreshProfile = async () => { + isFetching = true; + profileError = null; + subscribers.forEach(sub => sub(profileCache, null)); + + try { + const data = await api.getProfile(); + profileCache = data; + profileError = null; + subscribers.forEach(sub => sub(data, null)); + return data; + } catch (err) { + profileError = err instanceof Error ? err : new Error(String(err)); + subscribers.forEach(sub => sub(profileCache, profileError)); + throw profileError; + } finally { + isFetching = false; + } + }; + + return { + profile, + error, + isLoading: !profile && isFetching && !error, + personName: profile?.basics?.name || 'Resume', + refreshProfile, + }; +} + +/** @internal For testing only */ +export function _resetProfileCache() { + profileCache = null; + profileError = null; + isFetching = false; + subscribers.clear(); +} diff --git a/orchestrator/src/client/hooks/useSettings.test.ts b/orchestrator/src/client/hooks/useSettings.test.ts new file mode 100644 index 0000000..6fba317 --- /dev/null +++ b/orchestrator/src/client/hooks/useSettings.test.ts @@ -0,0 +1,80 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { useSettings, _resetSettingsCache } from './useSettings'; +import * as api from '../api'; + +vi.mock('../api', () => ({ + getSettings: vi.fn(), +})); + +describe('useSettings', () => { + beforeEach(() => { + vi.clearAllMocks(); + _resetSettingsCache(); + }); + + it('fetches settings on mount if not already cached', async () => { + const mockSettings = { showSponsorInfo: false }; + (api.getSettings as any).mockResolvedValue(mockSettings); + + const { result } = renderHook(() => useSettings()); + + // Should start in loading state + expect(result.current.settings).toBeNull(); + + await waitFor(() => { + expect(result.current.settings).toEqual(mockSettings); + }); + + expect(result.current.showSponsorInfo).toBe(false); + expect(api.getSettings).toHaveBeenCalledTimes(1); + }); + + it('uses default values when settings are null', async () => { + (api.getSettings as any).mockResolvedValue(null); + + const { result } = renderHook(() => useSettings()); + + await waitFor(() => { + // settings is null, so showSponsorInfo should default to true + expect(result.current.showSponsorInfo).toBe(true); + }); + }); + + it('provides a refresh function that updates settings', async () => { + const initialSettings = { showSponsorInfo: true }; + const updatedSettings = { showSponsorInfo: false }; + + (api.getSettings as any).mockResolvedValueOnce(initialSettings); + (api.getSettings as any).mockResolvedValueOnce(updatedSettings); + + const { result } = renderHook(() => useSettings()); + + await waitFor(() => { + expect(result.current.settings).toEqual(initialSettings); + }); + + let refreshed; + await waitFor(async () => { + refreshed = await result.current.refreshSettings(); + }); + + expect(refreshed).toEqual(updatedSettings); + expect(result.current.settings).toEqual(updatedSettings); + expect(result.current.showSponsorInfo).toBe(false); + }); + + it('handles errors when fetching settings', async () => { + const mockError = new Error('Failed to fetch'); + (api.getSettings as any).mockRejectedValue(mockError); + + const { result } = renderHook(() => useSettings()); + + await waitFor(() => { + expect(result.current.error).toEqual(mockError); + }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.settings).toBeNull(); + }); +}); diff --git a/orchestrator/src/client/hooks/useSettings.ts b/orchestrator/src/client/hooks/useSettings.ts new file mode 100644 index 0000000..bf1c7cd --- /dev/null +++ b/orchestrator/src/client/hooks/useSettings.ts @@ -0,0 +1,87 @@ +import { useEffect, useState } from 'react'; +import type { AppSettings } from '../../shared/types'; +import * as api from '../api'; + +let settingsCache: AppSettings | null = null; +let settingsError: Error | null = null; +let subscribers: Set<(settings: AppSettings | null, error: Error | null) => void> = new Set(); +let isFetching = false; + +export function useSettings() { + const [settings, setSettings] = useState(settingsCache); + const [error, setError] = useState(settingsError); + + useEffect(() => { + if (settingsCache) { + setSettings(settingsCache); + } + if (settingsError) { + setError(settingsError); + } + + const handleUpdate = (newSettings: AppSettings | null, newError: Error | null) => { + setSettings(newSettings); + setError(newError); + }; + + subscribers.add(handleUpdate); + + if (!settingsCache && !isFetching) { + isFetching = true; + settingsError = null; + api.getSettings() + .then((data) => { + settingsCache = data; + settingsError = null; + subscribers.forEach(sub => sub(data, null)); + }) + .catch((err) => { + settingsError = err instanceof Error ? err : new Error(String(err)); + subscribers.forEach(sub => sub(settingsCache, settingsError)); + }) + .finally(() => { + isFetching = false; + }); + } + + return () => { + subscribers.delete(handleUpdate); + }; + }, []); + + const refreshSettings = async () => { + isFetching = true; + settingsError = null; + subscribers.forEach(sub => sub(settingsCache, null)); + + try { + const data = await api.getSettings(); + settingsCache = data; + settingsError = null; + subscribers.forEach(sub => sub(data, null)); + return data; + } catch (err) { + settingsError = err instanceof Error ? err : new Error(String(err)); + subscribers.forEach(sub => sub(settingsCache, settingsError)); + throw settingsError; + } finally { + isFetching = false; + } + }; + + return { + settings, + error, + isLoading: !settings && isFetching && !error, + showSponsorInfo: settings?.showSponsorInfo ?? true, + refreshSettings, + }; +} + +/** @internal For testing only */ +export function _resetSettingsCache() { + settingsCache = null; + settingsError = null; + isFetching = false; + subscribers.clear(); +} diff --git a/orchestrator/src/client/pages/OrchestratorPage.test.tsx b/orchestrator/src/client/pages/OrchestratorPage.test.tsx index 59a10a9..186700f 100644 --- a/orchestrator/src/client/pages/OrchestratorPage.test.tsx +++ b/orchestrator/src/client/pages/OrchestratorPage.test.tsx @@ -33,6 +33,8 @@ const jobFixture: Job = { selectedProjectIds: null, pdfPath: null, notionPageId: null, + sponsorMatchScore: null, + sponsorMatchNames: null, jobType: null, salarySource: null, salaryInterval: null, @@ -198,7 +200,7 @@ describe("OrchestratorPage", () => { // Clicking job-2 should update URL const job2Button = screen.getByTestId("select-job-2"); fireEvent.click(job2Button); - + // Wait for URL to update await waitFor(() => { expect(locationText()).toContain("/all/job-2"); diff --git a/orchestrator/src/client/pages/SettingsPage.test.tsx b/orchestrator/src/client/pages/SettingsPage.test.tsx index 1a32f7d..93bdd86 100644 --- a/orchestrator/src/client/pages/SettingsPage.test.tsx +++ b/orchestrator/src/client/pages/SettingsPage.test.tsx @@ -23,14 +23,14 @@ vi.mock("sonner", () => ({ })) const baseSettings: AppSettings = { - model: "openai/gpt-4o-mini", - defaultModel: "openai/gpt-4o-mini", + model: "google/gemini-3-flash-preview", + defaultModel: "google/gemini-3-flash-preview", overrideModel: null, - modelScorer: "openai/gpt-4o-mini", + modelScorer: "google/gemini-3-flash-preview", overrideModelScorer: null, - modelTailoring: "openai/gpt-4o-mini", + modelTailoring: "google/gemini-3-flash-preview", overrideModelTailoring: null, - modelProjectSelection: "openai/gpt-4o-mini", + modelProjectSelection: "google/gemini-3-flash-preview", overrideModelProjectSelection: null, pipelineWebhookUrl: "", defaultPipelineWebhookUrl: "", @@ -92,6 +92,18 @@ const baseSettings: AppSettings = { jobspyLinkedinFetchDescription: true, defaultJobspyLinkedinFetchDescription: true, overrideJobspyLinkedinFetchDescription: null, + showSponsorInfo: true, + defaultShowSponsorInfo: true, + overrideShowSponsorInfo: null, + openrouterApiKeyHint: null, + rxresumeEmail: "", + rxresumePasswordHint: null, + basicAuthUser: "", + basicAuthPasswordHint: null, + ukvisajobsEmail: "", + ukvisajobsPasswordHint: null, + webhookSecretHint: null, + basicAuthActive: false, } const renderPage = () => { @@ -138,6 +150,28 @@ describe("SettingsPage", () => { expect(toast.success).toHaveBeenCalledWith("Settings saved") }) + it("shows validation error for too long model override", async () => { + vi.mocked(api.getSettings).mockResolvedValue(baseSettings) + + renderPage() + + const modelTrigger = await screen.findByRole("button", { name: /model/i }) + fireEvent.click(modelTrigger) + + const modelField = screen.getByText("Override model").parentElement ?? screen.getByRole("main") + const modelInput = within(modelField).getByRole("textbox") + + // Change to > 200 chars + fireEvent.change(modelInput, { target: { value: "a".repeat(201) } }) + + // Should see error message + expect(await screen.findByText(/String must contain at most 200 character\(s\)/i)).toBeInTheDocument() + + // Save button should be disabled due to validation error (isValid will be false) + const saveButton = screen.getByRole("button", { name: /^save$/i }) + expect(saveButton).toBeDisabled() + }) + it("clears jobs by status and summarizes results", async () => { vi.mocked(api.getSettings).mockResolvedValue(baseSettings) vi.mocked(api.deleteJobsByStatus).mockResolvedValue({ message: "", count: 2 }) @@ -161,4 +195,89 @@ describe("SettingsPage", () => { }) ) }) + + it("enables save button when model is changed", async () => { + vi.mocked(api.getSettings).mockResolvedValue(baseSettings) + renderPage() + const saveButton = screen.getByRole("button", { name: /^save$/i }) + expect(saveButton).toBeDisabled() + + const modelTrigger = await screen.findByRole("button", { name: /model/i }) + fireEvent.click(modelTrigger) + const modelInput = screen.getByLabelText(/override model/i) + fireEvent.change(modelInput, { target: { value: "new-model" } }) + expect(saveButton).toBeEnabled() + }) + + it("enables save button when numeric setting is changed", async () => { + vi.mocked(api.getSettings).mockResolvedValue(baseSettings) + renderPage() + const saveButton = screen.getByRole("button", { name: /^save$/i }) + + const visaTrigger = await screen.findByRole("button", { name: /ukvisajobs extractor/i }) + fireEvent.click(visaTrigger) + const maxJobsInput = screen.getByLabelText(/max jobs to fetch/i) + fireEvent.change(maxJobsInput, { target: { value: "100" } }) + expect(saveButton).toBeEnabled() + }) + + it("enables save button when display setting is changed", async () => { + vi.mocked(api.getSettings).mockResolvedValue(baseSettings) + renderPage() + const saveButton = screen.getByRole("button", { name: /^save$/i }) + + const displayTrigger = await screen.findByRole("button", { name: /display settings/i }) + fireEvent.click(displayTrigger) + const sponsorCheckbox = screen.getByLabelText(/show visa sponsor information/i) + fireEvent.click(sponsorCheckbox) + expect(saveButton).toBeEnabled() + }) + + it("enables save button when basic auth toggle is changed", async () => { + vi.mocked(api.getSettings).mockResolvedValue(baseSettings) + renderPage() + const saveButton = screen.getByRole("button", { name: /^save$/i }) + + const envTrigger = await screen.findByRole("button", { name: /environment & accounts/i }) + fireEvent.click(envTrigger) + const authCheckbox = screen.getByLabelText(/enable basic authentication/i) + fireEvent.click(authCheckbox) + expect(saveButton).toBeEnabled() + }) + + it("wipes basic auth credentials when toggle is disabled and saved", async () => { + // Initial state: Basic Auth is active + const activeSettings = { + ...baseSettings, + basicAuthActive: true, + basicAuthUser: "admin", + basicAuthPasswordHint: "pass", + } + vi.mocked(api.getSettings).mockResolvedValue(activeSettings) + vi.mocked(api.updateSettings).mockResolvedValue(baseSettings) + + renderPage() + + const envTrigger = await screen.findByRole("button", { name: /environment & accounts/i }) + fireEvent.click(envTrigger) + + const authCheckbox = screen.getByLabelText(/enable basic authentication/i) + expect(authCheckbox).toBeChecked() + + // Disable it + fireEvent.click(authCheckbox) + expect(authCheckbox).not.toBeChecked() + + const saveButton = screen.getByRole("button", { name: /^save$/i }) + expect(saveButton).toBeEnabled() + fireEvent.click(saveButton) + + await waitFor(() => expect(api.updateSettings).toHaveBeenCalled()) + expect(api.updateSettings).toHaveBeenCalledWith( + expect.objectContaining({ + basicAuthUser: null, + basicAuthPassword: null, + }) + ) + }) }) diff --git a/orchestrator/src/client/pages/SettingsPage.tsx b/orchestrator/src/client/pages/SettingsPage.tsx index febfb7e..1df8f43 100644 --- a/orchestrator/src/client/pages/SettingsPage.tsx +++ b/orchestrator/src/client/pages/SettingsPage.tsx @@ -1,52 +1,244 @@ -/** - * Settings page. - */ - -import React, { useEffect, useMemo, useState } from "react" +import React, { useEffect, useState } from "react" import { Settings } from "lucide-react" import { toast } from "sonner" +import { useForm, FormProvider } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" -import { PageHeader } from "../components/layout" +import { PageHeader } from "@client/components/layout" import { Accordion } from "@/components/ui/accordion" import { Button } from "@/components/ui/button" -import type { AppSettings, JobStatus, ResumeProjectsSettings } from "../../shared/types" -import * as api from "../api" +import type { AppSettings, JobStatus } from "@shared/types" +import { updateSettingsSchema, type UpdateSettingsInput } from "@shared/settings-schema" +import * as api from "@client/api" import { arraysEqual } from "@/lib/utils" -import { resumeProjectsEqual } from "./settings/utils" -import { DangerZoneSection } from "./settings/components/DangerZoneSection" -import { GradcrackerSection } from "./settings/components/GradcrackerSection" -import { JobCompleteWebhookSection } from "./settings/components/JobCompleteWebhookSection" -import { JobspySection } from "./settings/components/JobspySection" -import { ModelSettingsSection } from "./settings/components/ModelSettingsSection" -import { PipelineWebhookSection } from "./settings/components/PipelineWebhookSection" -import { ResumeProjectsSection } from "./settings/components/ResumeProjectsSection" -import { SearchTermsSection } from "./settings/components/SearchTermsSection" -import { UkvisajobsSection } from "./settings/components/UkvisajobsSection" -import { ReactiveResumeSection } from "./settings/components/ReactiveResumeSection" +import { resumeProjectsEqual } from "@client/pages/settings/utils" +import { DangerZoneSection } from "@client/pages/settings/components/DangerZoneSection" +import { DisplaySettingsSection } from "@client/pages/settings/components/DisplaySettingsSection" +import { EnvironmentSettingsSection } from "@client/pages/settings/components/EnvironmentSettingsSection" +import { GradcrackerSection } from "@client/pages/settings/components/GradcrackerSection" +import { JobspySection } from "@client/pages/settings/components/JobspySection" +import { ModelSettingsSection } from "@client/pages/settings/components/ModelSettingsSection" +import { WebhooksSection } from "@client/pages/settings/components/WebhooksSection" +import { ResumeProjectsSection } from "@client/pages/settings/components/ResumeProjectsSection" +import { SearchTermsSection } from "@client/pages/settings/components/SearchTermsSection" +import { UkvisajobsSection } from "@client/pages/settings/components/UkvisajobsSection" + +const DEFAULT_FORM_VALUES: UpdateSettingsInput = { + model: "", + modelScorer: "", + modelTailoring: "", + modelProjectSelection: "", + pipelineWebhookUrl: "", + jobCompleteWebhookUrl: "", + resumeProjects: null, + ukvisajobsMaxJobs: null, + gradcrackerMaxJobsPerTerm: null, + searchTerms: null, + jobspyLocation: null, + jobspyResultsWanted: null, + jobspyHoursOld: null, + jobspyCountryIndeed: null, + jobspySites: null, + jobspyLinkedinFetchDescription: null, + showSponsorInfo: null, + openrouterApiKey: "", + rxresumeEmail: "", + rxresumePassword: "", + basicAuthUser: "", + basicAuthPassword: "", + ukvisajobsEmail: "", + ukvisajobsPassword: "", + webhookSecret: "", + enableBasicAuth: false, +} + +const NULL_SETTINGS_PAYLOAD: UpdateSettingsInput = { + model: null, + modelScorer: null, + modelTailoring: null, + modelProjectSelection: null, + pipelineWebhookUrl: null, + jobCompleteWebhookUrl: null, + resumeProjects: null, + ukvisajobsMaxJobs: null, + gradcrackerMaxJobsPerTerm: null, + searchTerms: null, + jobspyLocation: null, + jobspyResultsWanted: null, + jobspyHoursOld: null, + jobspyCountryIndeed: null, + jobspySites: null, + jobspyLinkedinFetchDescription: null, + showSponsorInfo: null, + openrouterApiKey: null, + rxresumeEmail: null, + rxresumePassword: null, + basicAuthUser: null, + basicAuthPassword: null, + ukvisajobsEmail: null, + ukvisajobsPassword: null, + webhookSecret: null, + enableBasicAuth: undefined, +} + +const mapSettingsToForm = (data: AppSettings): UpdateSettingsInput => ({ + model: data.overrideModel ?? "", + modelScorer: data.overrideModelScorer ?? "", + modelTailoring: data.overrideModelTailoring ?? "", + modelProjectSelection: data.overrideModelProjectSelection ?? "", + pipelineWebhookUrl: data.overridePipelineWebhookUrl ?? "", + jobCompleteWebhookUrl: data.overrideJobCompleteWebhookUrl ?? "", + resumeProjects: data.resumeProjects, + ukvisajobsMaxJobs: data.overrideUkvisajobsMaxJobs, + gradcrackerMaxJobsPerTerm: data.overrideGradcrackerMaxJobsPerTerm, + searchTerms: data.overrideSearchTerms, + jobspyLocation: data.overrideJobspyLocation, + jobspyResultsWanted: data.overrideJobspyResultsWanted, + jobspyHoursOld: data.overrideJobspyHoursOld, + jobspyCountryIndeed: data.overrideJobspyCountryIndeed, + jobspySites: data.overrideJobspySites, + jobspyLinkedinFetchDescription: data.overrideJobspyLinkedinFetchDescription, + showSponsorInfo: data.overrideShowSponsorInfo, + openrouterApiKey: "", + rxresumeEmail: data.rxresumeEmail ?? "", + rxresumePassword: "", + basicAuthUser: data.basicAuthUser ?? "", + basicAuthPassword: "", + ukvisajobsEmail: data.ukvisajobsEmail ?? "", + ukvisajobsPassword: "", + webhookSecret: "", + enableBasicAuth: data.basicAuthActive, +}) + +const normalizeString = (value: string | null | undefined) => { + const trimmed = value?.trim() + return trimmed ? trimmed : null +} + +const normalizePrivateInput = (value: string | null | undefined) => { + const trimmed = value?.trim() + if (trimmed === "") return null + return trimmed || undefined +} + +const isSameStringList = (left: string[] | null | undefined, right: string[] | null | undefined) => { + if (!left && !right) return true + if (!left || !right) return false + return arraysEqual(left, right) +} + +const isSameSortedStringList = (left: string[] | null | undefined, right: string[] | null | undefined) => { + if (!left && !right) return true + if (!left || !right) return false + return arraysEqual(left.slice().sort(), right.slice().sort()) +} + +const nullIfSame = (value: T | null | undefined, defaultValue: T) => + value === defaultValue ? null : value ?? null + +const nullIfSameList = (value: string[] | null | undefined, defaultValue: string[]) => + isSameStringList(value, defaultValue) ? null : value ?? null + +const nullIfSameSortedList = (value: string[] | null | undefined, defaultValue: string[]) => + isSameSortedStringList(value, defaultValue) ? null : value ?? null + +const getDerivedSettings = (settings: AppSettings | null) => { + const profileProjects = settings?.profileProjects ?? [] + + return { + model: { + effective: settings?.model ?? "", + default: settings?.defaultModel ?? "", + scorer: settings?.modelScorer ?? "", + tailoring: settings?.modelTailoring ?? "", + projectSelection: settings?.modelProjectSelection ?? "", + }, + pipelineWebhook: { + effective: settings?.pipelineWebhookUrl ?? "", + default: settings?.defaultPipelineWebhookUrl ?? "", + }, + jobCompleteWebhook: { + effective: settings?.jobCompleteWebhookUrl ?? "", + default: settings?.defaultJobCompleteWebhookUrl ?? "", + }, + ukvisajobs: { + effective: settings?.ukvisajobsMaxJobs ?? 50, + default: settings?.defaultUkvisajobsMaxJobs ?? 50, + }, + gradcracker: { + effective: settings?.gradcrackerMaxJobsPerTerm ?? 50, + default: settings?.defaultGradcrackerMaxJobsPerTerm ?? 50, + }, + searchTerms: { + effective: settings?.searchTerms ?? [], + default: settings?.defaultSearchTerms ?? [], + }, + jobspy: { + location: { + effective: settings?.jobspyLocation ?? "", + default: settings?.defaultJobspyLocation ?? "", + }, + resultsWanted: { + effective: settings?.jobspyResultsWanted ?? 200, + default: settings?.defaultJobspyResultsWanted ?? 200, + }, + hoursOld: { + effective: settings?.jobspyHoursOld ?? 72, + default: settings?.defaultJobspyHoursOld ?? 72, + }, + countryIndeed: { + effective: settings?.jobspyCountryIndeed ?? "", + default: settings?.defaultJobspyCountryIndeed ?? "", + }, + sites: { + effective: settings?.jobspySites ?? ["indeed", "linkedin"], + default: settings?.defaultJobspySites ?? ["indeed", "linkedin"], + }, + linkedinFetchDescription: { + effective: settings?.jobspyLinkedinFetchDescription ?? true, + default: settings?.defaultJobspyLinkedinFetchDescription ?? true, + }, + }, + display: { + effective: settings?.showSponsorInfo ?? true, + default: settings?.defaultShowSponsorInfo ?? true, + }, + envSettings: { + readable: { + rxresumeEmail: settings?.rxresumeEmail ?? "", + ukvisajobsEmail: settings?.ukvisajobsEmail ?? "", + basicAuthUser: settings?.basicAuthUser ?? "", + }, + private: { + openrouterApiKeyHint: settings?.openrouterApiKeyHint ?? null, + rxresumePasswordHint: settings?.rxresumePasswordHint ?? null, + ukvisajobsPasswordHint: settings?.ukvisajobsPasswordHint ?? null, + basicAuthPasswordHint: settings?.basicAuthPasswordHint ?? null, + webhookSecretHint: settings?.webhookSecretHint ?? null, + }, + basicAuthActive: settings?.basicAuthActive ?? false, + }, + defaultResumeProjects: settings?.defaultResumeProjects ?? null, + + profileProjects, + maxProjectsTotal: profileProjects.length, + } +} export const SettingsPage: React.FC = () => { const [settings, setSettings] = useState(null) - const [modelDraft, setModelDraft] = useState("") - const [modelScorerDraft, setModelScorerDraft] = useState("") - const [modelTailoringDraft, setModelTailoringDraft] = useState("") - const [modelProjectSelectionDraft, setModelProjectSelectionDraft] = useState("") - const [pipelineWebhookUrlDraft, setPipelineWebhookUrlDraft] = useState("") - const [jobCompleteWebhookUrlDraft, setJobCompleteWebhookUrlDraft] = useState("") - const [resumeProjectsDraft, setResumeProjectsDraft] = useState(null) - const [ukvisajobsMaxJobsDraft, setUkvisajobsMaxJobsDraft] = useState(null) - const [gradcrackerMaxJobsPerTermDraft, setGradcrackerMaxJobsPerTermDraft] = useState(null) - const [searchTermsDraft, setSearchTermsDraft] = useState(null) - const [jobspyLocationDraft, setJobspyLocationDraft] = useState(null) - const [jobspyResultsWantedDraft, setJobspyResultsWantedDraft] = useState(null) - const [jobspyHoursOldDraft, setJobspyHoursOldDraft] = useState(null) - const [jobspyCountryIndeedDraft, setJobspyCountryIndeedDraft] = useState(null) - const [jobspySitesDraft, setJobspySitesDraft] = useState(null) - const [jobspyLinkedinFetchDescriptionDraft, setJobspyLinkedinFetchDescriptionDraft] = useState(null) - const [rxResumeBaseResumeIdDraft, setRxResumeBaseResumeIdDraft] = useState(null) const [isSaving, setIsSaving] = useState(false) const [isLoading, setIsLoading] = useState(true) const [statusesToClear, setStatusesToClear] = useState(['discovered']) + const methods = useForm({ + resolver: zodResolver(updateSettingsSchema), + mode: "onChange", + defaultValues: DEFAULT_FORM_VALUES, + }) + + const { handleSubmit, reset, setError, watch, formState: { isDirty, errors, isValid, dirtyFields } } = methods + useEffect(() => { let isMounted = true setIsLoading(true) @@ -55,23 +247,7 @@ export const SettingsPage: React.FC = () => { .then((data) => { if (!isMounted) return setSettings(data) - setModelDraft(data.overrideModel ?? "") - setModelScorerDraft(data.overrideModelScorer ?? "") - setModelTailoringDraft(data.overrideModelTailoring ?? "") - setModelProjectSelectionDraft(data.overrideModelProjectSelection ?? "") - setPipelineWebhookUrlDraft(data.overridePipelineWebhookUrl ?? "") - setJobCompleteWebhookUrlDraft(data.overrideJobCompleteWebhookUrl ?? "") - setResumeProjectsDraft(data.resumeProjects) - setUkvisajobsMaxJobsDraft(data.overrideUkvisajobsMaxJobs) - setGradcrackerMaxJobsPerTermDraft(data.overrideGradcrackerMaxJobsPerTerm) - setSearchTermsDraft(data.overrideSearchTerms) - setJobspyLocationDraft(data.overrideJobspyLocation) - setJobspyResultsWantedDraft(data.overrideJobspyResultsWanted) - setJobspyHoursOldDraft(data.overrideJobspyHoursOld) - setJobspyCountryIndeedDraft(data.overrideJobspyCountryIndeed) - setJobspySitesDraft(data.overrideJobspySites) - setJobspyLinkedinFetchDescriptionDraft(data.overrideJobspyLinkedinFetchDescription) - setRxResumeBaseResumeIdDraft(data.rxResumeBaseResumeId) + reset(mapSettingsToForm(data)) }) .catch((error) => { const message = error instanceof Error ? error.message : "Failed to load settings" @@ -85,186 +261,126 @@ export const SettingsPage: React.FC = () => { return () => { isMounted = false } - }, []) + }, [reset]) - const effectiveModel = settings?.model ?? "" - const defaultModel = settings?.defaultModel ?? "" - const overrideModel = settings?.overrideModel - const effectiveModelScorer = settings?.modelScorer ?? "" - const overrideModelScorer = settings?.overrideModelScorer - const effectiveModelTailoring = settings?.modelTailoring ?? "" - const overrideModelTailoring = settings?.overrideModelTailoring - const effectiveModelProjectSelection = settings?.modelProjectSelection ?? "" - const overrideModelProjectSelection = settings?.overrideModelProjectSelection - const effectivePipelineWebhookUrl = settings?.pipelineWebhookUrl ?? "" - const defaultPipelineWebhookUrl = settings?.defaultPipelineWebhookUrl ?? "" - const overridePipelineWebhookUrl = settings?.overridePipelineWebhookUrl - const effectiveJobCompleteWebhookUrl = settings?.jobCompleteWebhookUrl ?? "" - const defaultJobCompleteWebhookUrl = settings?.defaultJobCompleteWebhookUrl ?? "" - const overrideJobCompleteWebhookUrl = settings?.overrideJobCompleteWebhookUrl - const effectiveUkvisajobsMaxJobs = settings?.ukvisajobsMaxJobs ?? 50 - const defaultUkvisajobsMaxJobs = settings?.defaultUkvisajobsMaxJobs ?? 50 - const overrideUkvisajobsMaxJobs = settings?.overrideUkvisajobsMaxJobs - const effectiveGradcrackerMaxJobsPerTerm = settings?.gradcrackerMaxJobsPerTerm ?? 50 - const defaultGradcrackerMaxJobsPerTerm = settings?.defaultGradcrackerMaxJobsPerTerm ?? 50 - const overrideGradcrackerMaxJobsPerTerm = settings?.overrideGradcrackerMaxJobsPerTerm - const effectiveSearchTerms = settings?.searchTerms ?? [] - const defaultSearchTerms = settings?.defaultSearchTerms ?? [] - const overrideSearchTerms = settings?.overrideSearchTerms - const effectiveJobspyLocation = settings?.jobspyLocation ?? "" - const defaultJobspyLocation = settings?.defaultJobspyLocation ?? "" - const overrideJobspyLocation = settings?.overrideJobspyLocation - const effectiveJobspyResultsWanted = settings?.jobspyResultsWanted ?? 200 - const defaultJobspyResultsWanted = settings?.defaultJobspyResultsWanted ?? 200 - const overrideJobspyResultsWanted = settings?.overrideJobspyResultsWanted - const effectiveJobspyHoursOld = settings?.jobspyHoursOld ?? 72 - const defaultJobspyHoursOld = settings?.defaultJobspyHoursOld ?? 72 - const overrideJobspyHoursOld = settings?.overrideJobspyHoursOld - const effectiveJobspyCountryIndeed = settings?.jobspyCountryIndeed ?? "" - const defaultJobspyCountryIndeed = settings?.defaultJobspyCountryIndeed ?? "" - const overrideJobspyCountryIndeed = settings?.overrideJobspyCountryIndeed - const effectiveJobspySites = settings?.jobspySites ?? ["indeed", "linkedin"] - const defaultJobspySites = settings?.defaultJobspySites ?? ["indeed", "linkedin"] - const overrideJobspySites = settings?.overrideJobspySites - const effectiveJobspyLinkedinFetchDescription = settings?.jobspyLinkedinFetchDescription ?? true - const defaultJobspyLinkedinFetchDescription = settings?.defaultJobspyLinkedinFetchDescription ?? true - const overrideJobspyLinkedinFetchDescription = settings?.overrideJobspyLinkedinFetchDescription - const profileProjects = settings?.profileProjects ?? [] - const maxProjectsTotal = profileProjects.length - const lockedCount = resumeProjectsDraft?.lockedProjectIds.length ?? 0 + const derived = getDerivedSettings(settings) + const { + model, + pipelineWebhook, + jobCompleteWebhook, + ukvisajobs, + gradcracker, + searchTerms, + jobspy, + display, + envSettings, + defaultResumeProjects, + profileProjects, + maxProjectsTotal, + } = derived - const canSave = useMemo(() => { - if (!settings || !resumeProjectsDraft) return false - const next = modelDraft.trim() - const current = (overrideModel ?? "").trim() - const nextScorer = modelScorerDraft.trim() - const currentScorer = (overrideModelScorer ?? "").trim() - const nextTailoring = modelTailoringDraft.trim() - const currentTailoring = (overrideModelTailoring ?? "").trim() - const nextProjectSelection = modelProjectSelectionDraft.trim() - const currentProjectSelection = (overrideModelProjectSelection ?? "").trim() - const nextWebhook = pipelineWebhookUrlDraft.trim() - const currentWebhook = (overridePipelineWebhookUrl ?? "").trim() - const nextJobCompleteWebhook = jobCompleteWebhookUrlDraft.trim() - const currentJobCompleteWebhook = (overrideJobCompleteWebhookUrl ?? "").trim() - const ukvisajobsChanged = ukvisajobsMaxJobsDraft !== (overrideUkvisajobsMaxJobs ?? null) - const gradcrackerChanged = gradcrackerMaxJobsPerTermDraft !== (overrideGradcrackerMaxJobsPerTerm ?? null) - const searchTermsChanged = JSON.stringify(searchTermsDraft) !== JSON.stringify(overrideSearchTerms ?? null) - return ( - next !== current || - nextScorer !== currentScorer || - nextTailoring !== currentTailoring || - nextProjectSelection !== currentProjectSelection || - nextWebhook !== currentWebhook || - nextJobCompleteWebhook !== currentJobCompleteWebhook || - !resumeProjectsEqual(resumeProjectsDraft, settings.resumeProjects) || - ukvisajobsChanged || - gradcrackerChanged || - searchTermsChanged || - jobspyLocationDraft !== (overrideJobspyLocation ?? null) || - jobspyResultsWantedDraft !== (overrideJobspyResultsWanted ?? null) || - jobspyHoursOldDraft !== (overrideJobspyHoursOld ?? null) || - jobspyCountryIndeedDraft !== (overrideJobspyCountryIndeed ?? null) || - JSON.stringify((jobspySitesDraft ?? []).slice().sort()) !== JSON.stringify((overrideJobspySites ?? []).slice().sort()) || - jobspyLinkedinFetchDescriptionDraft !== (overrideJobspyLinkedinFetchDescription ?? null) || - rxResumeBaseResumeIdDraft !== (settings.rxResumeBaseResumeId ?? null) - ) - }, [ - settings, - modelDraft, - modelScorerDraft, - modelTailoringDraft, - modelProjectSelectionDraft, - pipelineWebhookUrlDraft, - jobCompleteWebhookUrlDraft, - overrideModel, - overrideModelScorer, - overrideModelTailoring, - overrideModelProjectSelection, - overridePipelineWebhookUrl, - overrideJobCompleteWebhookUrl, - resumeProjectsDraft, - ukvisajobsMaxJobsDraft, - overrideUkvisajobsMaxJobs, - gradcrackerMaxJobsPerTermDraft, - overrideGradcrackerMaxJobsPerTerm, - searchTermsDraft, - overrideSearchTerms, - jobspyLocationDraft, - jobspyResultsWantedDraft, - jobspyHoursOldDraft, - jobspyCountryIndeedDraft, - jobspySitesDraft, - jobspyLinkedinFetchDescriptionDraft, - overrideJobspyLocation, - overrideJobspyResultsWanted, - overrideJobspyHoursOld, - overrideJobspyCountryIndeed, - overrideJobspySites, - overrideJobspyLinkedinFetchDescription, - rxResumeBaseResumeIdDraft, - ]) + const watchedValues = watch() + const lockedCount = watchedValues.resumeProjects?.lockedProjectIds.length ?? 0 - const handleSave = async () => { - if (!settings || !resumeProjectsDraft) return + const canSave = isDirty && isValid + + const onSave = async (data: UpdateSettingsInput) => { + if (!settings) return + if (data.enableBasicAuth && !settings.basicAuthActive) { + const password = data.basicAuthPassword?.trim() ?? "" + if (!password) { + setError("basicAuthPassword", { + type: "manual", + message: "Password is required when basic auth is enabled", + }) + return + } + } try { setIsSaving(true) - const trimmed = modelDraft.trim() - const trimmedScorer = modelScorerDraft.trim() - const trimmedTailoring = modelTailoringDraft.trim() - const trimmedProjectSelection = modelProjectSelectionDraft.trim() - const webhookTrimmed = pipelineWebhookUrlDraft.trim() - const jobCompleteTrimmed = jobCompleteWebhookUrlDraft.trim() - const resumeProjectsOverride = resumeProjectsEqual(resumeProjectsDraft, settings.defaultResumeProjects) + + // Prepare payload: nullify if equal to default + const resumeProjectsData = data.resumeProjects + const resumeProjectsOverride = (resumeProjectsData && defaultResumeProjects && resumeProjectsEqual(resumeProjectsData, defaultResumeProjects)) ? null - : resumeProjectsDraft - const ukvisajobsMaxJobsOverride = ukvisajobsMaxJobsDraft === defaultUkvisajobsMaxJobs ? null : ukvisajobsMaxJobsDraft - const gradcrackerMaxJobsPerTermOverride = gradcrackerMaxJobsPerTermDraft === defaultGradcrackerMaxJobsPerTerm ? null : gradcrackerMaxJobsPerTermDraft - const searchTermsOverride = arraysEqual(searchTermsDraft ?? [], defaultSearchTerms) ? null : searchTermsDraft - const jobspyLocationOverride = jobspyLocationDraft === defaultJobspyLocation ? null : jobspyLocationDraft - const jobspyResultsWantedOverride = jobspyResultsWantedDraft === defaultJobspyResultsWanted ? null : jobspyResultsWantedDraft - const jobspyHoursOldOverride = jobspyHoursOldDraft === defaultJobspyHoursOld ? null : jobspyHoursOldDraft - const jobspyCountryIndeedOverride = jobspyCountryIndeedDraft === defaultJobspyCountryIndeed ? null : jobspyCountryIndeedDraft - const jobspySitesOverride = arraysEqual((jobspySitesDraft ?? []).slice().sort(), (defaultJobspySites ?? []).slice().sort()) ? null : jobspySitesDraft - const jobspyLinkedinFetchDescriptionOverride = jobspyLinkedinFetchDescriptionDraft === defaultJobspyLinkedinFetchDescription ? null : jobspyLinkedinFetchDescriptionDraft - const rxResumeBaseResumeIdOverride = rxResumeBaseResumeIdDraft - const updated = await api.updateSettings({ - model: trimmed.length > 0 ? trimmed : null, - modelScorer: trimmedScorer.length > 0 ? trimmedScorer : null, - modelTailoring: trimmedTailoring.length > 0 ? trimmedTailoring : null, - modelProjectSelection: trimmedProjectSelection.length > 0 ? trimmedProjectSelection : null, - pipelineWebhookUrl: webhookTrimmed.length > 0 ? webhookTrimmed : null, - jobCompleteWebhookUrl: jobCompleteTrimmed.length > 0 ? jobCompleteTrimmed : null, + : resumeProjectsData + + const envPayload: Partial = {} + + if (dirtyFields.rxresumeEmail || dirtyFields.rxresumePassword) { + envPayload.rxresumeEmail = normalizeString(data.rxresumeEmail) + } + + if (dirtyFields.ukvisajobsEmail || dirtyFields.ukvisajobsPassword) { + envPayload.ukvisajobsEmail = normalizeString(data.ukvisajobsEmail) + } + + if (data.enableBasicAuth === false) { + envPayload.basicAuthUser = null + envPayload.basicAuthPassword = null + } else if (dirtyFields.enableBasicAuth || dirtyFields.basicAuthUser || dirtyFields.basicAuthPassword) { + // If enabling basic auth or changing either field, ensure we send at least the username + // to keep the pair consistent in the backend. + envPayload.basicAuthUser = normalizeString(data.basicAuthUser) + + if (dirtyFields.basicAuthPassword) { + const value = normalizePrivateInput(data.basicAuthPassword) + if (value !== undefined) envPayload.basicAuthPassword = value + } + } + + if (dirtyFields.openrouterApiKey) { + const value = normalizePrivateInput(data.openrouterApiKey) + if (value !== undefined) envPayload.openrouterApiKey = value + } + + if (dirtyFields.rxresumePassword) { + const value = normalizePrivateInput(data.rxresumePassword) + if (value !== undefined) envPayload.rxresumePassword = value + } + + if (dirtyFields.ukvisajobsPassword) { + const value = normalizePrivateInput(data.ukvisajobsPassword) + if (value !== undefined) envPayload.ukvisajobsPassword = value + } + + if (dirtyFields.webhookSecret) { + const value = normalizePrivateInput(data.webhookSecret) + if (value !== undefined) envPayload.webhookSecret = value + } + + const payload: UpdateSettingsInput = { + model: normalizeString(data.model), + modelScorer: normalizeString(data.modelScorer), + modelTailoring: normalizeString(data.modelTailoring), + modelProjectSelection: normalizeString(data.modelProjectSelection), + pipelineWebhookUrl: normalizeString(data.pipelineWebhookUrl), + jobCompleteWebhookUrl: normalizeString(data.jobCompleteWebhookUrl), resumeProjects: resumeProjectsOverride, - ukvisajobsMaxJobs: ukvisajobsMaxJobsOverride, - gradcrackerMaxJobsPerTerm: gradcrackerMaxJobsPerTermOverride, - searchTerms: searchTermsOverride, - jobspyLocation: jobspyLocationOverride, - jobspyResultsWanted: jobspyResultsWantedOverride, - jobspyHoursOld: jobspyHoursOldOverride, - jobspyCountryIndeed: jobspyCountryIndeedOverride, - jobspySites: jobspySitesOverride, - jobspyLinkedinFetchDescription: jobspyLinkedinFetchDescriptionOverride, - rxResumeBaseResumeId: rxResumeBaseResumeIdOverride, - }) + ukvisajobsMaxJobs: nullIfSame(data.ukvisajobsMaxJobs, ukvisajobs.default), + gradcrackerMaxJobsPerTerm: nullIfSame(data.gradcrackerMaxJobsPerTerm, gradcracker.default), + searchTerms: nullIfSameList(data.searchTerms, searchTerms.default), + jobspyLocation: nullIfSame(data.jobspyLocation, jobspy.location.default), + jobspyResultsWanted: nullIfSame(data.jobspyResultsWanted, jobspy.resultsWanted.default), + jobspyHoursOld: nullIfSame(data.jobspyHoursOld, jobspy.hoursOld.default), + jobspyCountryIndeed: nullIfSame(data.jobspyCountryIndeed, jobspy.countryIndeed.default), + jobspySites: nullIfSameSortedList(data.jobspySites, jobspy.sites.default), + jobspyLinkedinFetchDescription: nullIfSame( + data.jobspyLinkedinFetchDescription, + jobspy.linkedinFetchDescription.default + ), + showSponsorInfo: nullIfSame(data.showSponsorInfo, display.default), + ...envPayload, + } + + // Remove virtual field because the backend doesn't expect it + // this exists only to toggle the UI + // need to track it so that the save button is enabled when it changes + delete payload.enableBasicAuth + + const updated = await api.updateSettings(payload) setSettings(updated) - setModelDraft(updated.overrideModel ?? "") - setModelScorerDraft(updated.overrideModelScorer ?? "") - setModelTailoringDraft(updated.overrideModelTailoring ?? "") - setModelProjectSelectionDraft(updated.overrideModelProjectSelection ?? "") - setPipelineWebhookUrlDraft(updated.overridePipelineWebhookUrl ?? "") - setJobCompleteWebhookUrlDraft(updated.overrideJobCompleteWebhookUrl ?? "") - setResumeProjectsDraft(updated.resumeProjects) - setUkvisajobsMaxJobsDraft(updated.overrideUkvisajobsMaxJobs) - setGradcrackerMaxJobsPerTermDraft(updated.overrideGradcrackerMaxJobsPerTerm) - setSearchTermsDraft(updated.overrideSearchTerms) - setJobspyLocationDraft(updated.overrideJobspyLocation) - setJobspyResultsWantedDraft(updated.overrideJobspyResultsWanted) - setJobspyHoursOldDraft(updated.overrideJobspyHoursOld) - setJobspyCountryIndeedDraft(updated.overrideJobspyCountryIndeed) - setJobspySitesDraft(updated.overrideJobspySites) - setJobspyLinkedinFetchDescriptionDraft(updated.overrideJobspyLinkedinFetchDescription) - setRxResumeBaseResumeIdDraft(updated.rxResumeBaseResumeId) + reset(mapSettingsToForm(updated)) toast.success("Settings saved") } catch (error) { const message = error instanceof Error ? error.message : "Failed to save settings" @@ -273,6 +389,7 @@ export const SettingsPage: React.FC = () => { setIsSaving(false) } } + const handleClearDatabase = async () => { try { setIsSaving(true) @@ -331,43 +448,9 @@ export const SettingsPage: React.FC = () => { const handleReset = async () => { try { setIsSaving(true) - const updated = await api.updateSettings({ - model: null, - modelScorer: null, - modelTailoring: null, - modelProjectSelection: null, - pipelineWebhookUrl: null, - jobCompleteWebhookUrl: null, - resumeProjects: null, - ukvisajobsMaxJobs: null, - gradcrackerMaxJobsPerTerm: null, - searchTerms: null, - jobspyLocation: null, - jobspyResultsWanted: null, - jobspyHoursOld: null, - jobspyCountryIndeed: null, - jobspySites: null, - jobspyLinkedinFetchDescription: null, - rxResumeBaseResumeId: null, - }) + const updated = await api.updateSettings(NULL_SETTINGS_PAYLOAD) setSettings(updated) - setModelDraft("") - setModelScorerDraft("") - setModelTailoringDraft("") - setModelProjectSelectionDraft("") - setPipelineWebhookUrlDraft("") - setJobCompleteWebhookUrlDraft("") - setResumeProjectsDraft(updated.resumeProjects) - setUkvisajobsMaxJobsDraft(null) - setGradcrackerMaxJobsPerTermDraft(null) - setSearchTermsDraft(null) - setJobspyLocationDraft(null) - setJobspyResultsWantedDraft(null) - setJobspyHoursOldDraft(null) - setJobspyCountryIndeedDraft(null) - setJobspySitesDraft(null) - setJobspyLinkedinFetchDescriptionDraft(null) - setRxResumeBaseResumeIdDraft(null) + reset(mapSettingsToForm(updated)) toast.success("Reset to default") } catch (error) { const message = error instanceof Error ? error.message : "Failed to reset settings" @@ -378,7 +461,7 @@ export const SettingsPage: React.FC = () => { } return ( - <> + {
- - - + @@ -499,14 +530,19 @@ export const SettingsPage: React.FC = () => {
-
+ {Object.keys(errors).length > 0 && ( +
+ Please fix the errors before saving. +
+ )}
- +
) } diff --git a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.test.tsx b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.test.tsx index 3bc3f53..a8257a7 100644 --- a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.test.tsx +++ b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.test.tsx @@ -31,6 +31,9 @@ vi.mock("../../components", () => ({ DiscoveredPanel: ({ job }: { job: Job | null }) => (
{job?.id ?? "no-job"}
), + JobHeader: () =>
, + FitAssessment: () =>
, + TailoredSummary: () =>
, })); vi.mock("../../components/ReadyPanel", () => ({ @@ -63,6 +66,7 @@ vi.mock("../../api", () => ({ generateJobPdf: vi.fn(), markAsApplied: vi.fn(), skipJob: vi.fn(), + getProfile: vi.fn().mockResolvedValue({}), })); vi.mock("sonner", () => ({ @@ -100,6 +104,8 @@ const createJob = (overrides: Partial = {}): Job => ({ selectedProjectIds: null, pdfPath: null, notionPageId: null, + sponsorMatchScore: null, + sponsorMatchNames: null, jobType: null, salarySource: null, salaryInterval: null, @@ -154,23 +160,7 @@ describe("JobDetailPanel", () => { expect(screen.getByTestId("discovered-panel")).toHaveTextContent("job-99"); }); - it("wires ready panel edit actions back to the page", () => { - const onSetActiveTab = vi.fn(); - render( - - ); - - fireEvent.click(screen.getByRole("button", { name: /edit description/i })); - expect(onSetActiveTab).toHaveBeenCalledWith("discovered"); - }); it("shows an empty state when no job is selected", () => { render( diff --git a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx index a307528..9176587 100644 --- a/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx +++ b/orchestrator/src/client/pages/orchestrator/JobDetailPanel.tsx @@ -30,6 +30,7 @@ import { copyTextToClipboard, formatJobForWebhook, safeFilenamePart, stripHtml } import { DiscoveredPanel, FitAssessment, JobHeader, TailoredSummary } from "../../components"; import { ReadyPanel } from "../../components/ReadyPanel"; import { TailoringEditor } from "../../components/TailoringEditor"; +import { useProfile } from "../../hooks/useProfile"; import * as api from "../../api"; import type { Job } from "../../../shared/types"; import type { FilterTab } from "./constants"; @@ -59,6 +60,8 @@ export const JobDetailPanel: React.FC = ({ const [processingJobId, setProcessingJobId] = useState(null); const saveTailoringRef = useRef Promise)>(null); + const { personName } = useProfile(); + useEffect(() => { setHasUnsavedTailoring(false); saveTailoringRef.current = null; @@ -243,17 +246,6 @@ export const JobDetailPanel: React.FC = ({ job={selectedJob} onJobUpdated={onJobUpdated} onJobMoved={handleJobMoved} - onEditTailoring={() => { - onSetActiveTab("discovered"); - setTimeout(() => setDetailTab("tailoring"), 50); - }} - onEditDescription={() => { - onSetActiveTab("discovered"); - setTimeout(() => { - setDetailTab("description"); - setIsEditingDescription(true); - }, 50); - }} /> ); } @@ -269,7 +261,13 @@ export const JobDetailPanel: React.FC = ({ return (
- + { + await api.checkSponsor(selectedJob.id); + await onJobUpdated(); + }} + />
-
-
Location
- setJobspyLocationDraft(event.target.value)} - placeholder={defaultJobspyLocation || "UK"} - disabled={isLoading || isSaving} - /> -
- Location to search for jobs (e.g. "UK", "London", "Remote"). -
-
- Effective: {effectiveJobspyLocation || "—"} - Default: {defaultJobspyLocation || "—"} -
-
+ -
-
Results Wanted
- { - const value = parseInt(event.target.value, 10) - if (Number.isNaN(value)) { - setJobspyResultsWantedDraft(null) - } else { - setJobspyResultsWantedDraft(Math.min(500, Math.max(1, value))) - } - }} - disabled={isLoading || isSaving} - /> -
- Number of results to fetch per term per site. Max 500. -
-
- Effective: {effectiveJobspyResultsWanted} - Default: {defaultJobspyResultsWanted} -
-
+ ( + { + const value = parseInt(event.target.value, 10) + if (Number.isNaN(value)) { + field.onChange(null) + } else { + field.onChange(Math.min(1000, Math.max(1, value))) + } + }, + }} + disabled={isLoading || isSaving} + error={errors.jobspyResultsWanted?.message as string | undefined} + helper={`Number of results to fetch per term per site. Default: ${resultsWanted.default}. Max 1000.`} + current={`Effective: ${resultsWanted.effective} | Default: ${resultsWanted.default}`} + /> + )} + /> -
-
Hours Old
- { - const value = parseInt(event.target.value, 10) - if (Number.isNaN(value)) { - setJobspyHoursOldDraft(null) - } else { - setJobspyHoursOldDraft(Math.min(168, Math.max(1, value))) - } - }} - disabled={isLoading || isSaving} - /> -
- Max age of jobs in hours (e.g. 72 for 3 days). -
-
- Effective: {effectiveJobspyHoursOld}h - Default: {defaultJobspyHoursOld}h -
-
+ ( + { + const value = parseInt(event.target.value, 10) + if (Number.isNaN(value)) { + field.onChange(null) + } else { + field.onChange(Math.min(720, Math.max(1, value))) + } + }, + }} + disabled={isLoading || isSaving} + error={errors.jobspyHoursOld?.message as string | undefined} + helper={`Max age of jobs in hours (e.g. 72 for 3 days). Default: ${hoursOld.default}. Max 720.`} + current={`Effective: ${hoursOld.effective}h | Default: ${hoursOld.default}h`} + /> + )} + /> -
-
Indeed Country
- setJobspyCountryIndeedDraft(event.target.value)} - placeholder={defaultJobspyCountryIndeed || "UK"} - disabled={isLoading || isSaving} - /> -
- Country domain for Indeed (e.g. "UK" for indeed.co.uk). -
-
- Effective: {effectiveJobspyCountryIndeed || "—"} - Default: {defaultJobspyCountryIndeed || "—"} -
-
+
- setJobspyLinkedinFetchDescriptionDraft(!!checked)} - disabled={isLoading || isSaving} + ( + field.onChange(!!checked)} + disabled={isLoading || isSaving} + /> + )} />
diff --git a/orchestrator/src/client/pages/settings/components/ModelSettingsSection.tsx b/orchestrator/src/client/pages/settings/components/ModelSettingsSection.tsx index 7373976..29a9297 100644 --- a/orchestrator/src/client/pages/settings/components/ModelSettingsSection.tsx +++ b/orchestrator/src/client/pages/settings/components/ModelSettingsSection.tsx @@ -1,44 +1,26 @@ import React from "react" +import { useFormContext } from "react-hook-form" import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" -import { Input } from "@/components/ui/input" import { Separator } from "@/components/ui/separator" +import { UpdateSettingsInput } from "@shared/settings-schema" +import type { ModelValues } from "@client/pages/settings/types" +import { SettingsInput } from "@client/pages/settings/components/SettingsInput" type ModelSettingsSectionProps = { - modelDraft: string - setModelDraft: (value: string) => void - modelScorerDraft: string - setModelScorerDraft: (value: string) => void - modelTailoringDraft: string - setModelTailoringDraft: (value: string) => void - modelProjectSelectionDraft: string - setModelProjectSelectionDraft: (value: string) => void - effectiveModel: string - effectiveModelScorer: string - effectiveModelTailoring: string - effectiveModelProjectSelection: string - defaultModel: string + values: ModelValues isLoading: boolean isSaving: boolean } export const ModelSettingsSection: React.FC = ({ - modelDraft, - setModelDraft, - modelScorerDraft, - setModelScorerDraft, - modelTailoringDraft, - setModelTailoringDraft, - modelProjectSelectionDraft, - setModelProjectSelectionDraft, - effectiveModel, - effectiveModelScorer, - effectiveModelTailoring, - effectiveModelProjectSelection, - defaultModel, + values, isLoading, isSaving, }) => { + const { effective, default: defaultModel, scorer, tailoring, projectSelection } = values + const { register, formState: { errors } } = useFormContext() + return ( @@ -46,18 +28,15 @@ export const ModelSettingsSection: React.FC = ({
-
-
Override model
- setModelDraft(event.target.value)} - placeholder={defaultModel || "openai/gpt-4o-mini"} - disabled={isLoading || isSaving} - /> -
- Leave blank to use the default from server env (`MODEL`). -
-
+ @@ -65,44 +44,32 @@ export const ModelSettingsSection: React.FC = ({
Task-Specific Overrides
-
-
Scoring Model
- setModelScorerDraft(event.target.value)} - placeholder={effectiveModel || "inherit"} - disabled={isLoading || isSaving} - /> -
- Effective: {effectiveModelScorer || effectiveModel} -
-
+ -
-
Tailoring Model
- setModelTailoringDraft(event.target.value)} - placeholder={effectiveModel || "inherit"} - disabled={isLoading || isSaving} - /> -
- Effective: {effectiveModelTailoring || effectiveModel} -
-
+ -
-
Project Selection Model
- setModelProjectSelectionDraft(event.target.value)} - placeholder={effectiveModel || "inherit"} - disabled={isLoading || isSaving} - /> -
- Effective: {effectiveModelProjectSelection || effectiveModel} -
-
+
@@ -111,7 +78,7 @@ export const ModelSettingsSection: React.FC = ({
Global Effective
-
{effectiveModel || "—"}
+
{effective || "—"}
Default (env)
diff --git a/orchestrator/src/client/pages/settings/components/PipelineWebhookSection.tsx b/orchestrator/src/client/pages/settings/components/PipelineWebhookSection.tsx deleted file mode 100644 index 750cfac..0000000 --- a/orchestrator/src/client/pages/settings/components/PipelineWebhookSection.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import React from "react" - -import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" -import { Input } from "@/components/ui/input" -import { Separator } from "@/components/ui/separator" - -type PipelineWebhookSectionProps = { - pipelineWebhookUrlDraft: string - setPipelineWebhookUrlDraft: (value: string) => void - defaultPipelineWebhookUrl: string - effectivePipelineWebhookUrl: string - isLoading: boolean - isSaving: boolean -} - -export const PipelineWebhookSection: React.FC = ({ - pipelineWebhookUrlDraft, - setPipelineWebhookUrlDraft, - defaultPipelineWebhookUrl, - effectivePipelineWebhookUrl, - isLoading, - isSaving, -}) => { - return ( - - - Pipeline Webhook - - -
-
-
Pipeline status webhook URL
- setPipelineWebhookUrlDraft(event.target.value)} - placeholder={defaultPipelineWebhookUrl || "https://..."} - disabled={isLoading || isSaving} - /> -
- When set, the server sends a POST on pipeline completion/failure. Leave blank to disable. -
-
- - - -
-
-
Effective
-
{effectivePipelineWebhookUrl || "—"}
-
-
-
Default (env)
-
{defaultPipelineWebhookUrl || "—"}
-
-
-
-
-
- ) -} diff --git a/orchestrator/src/client/pages/settings/components/ResumeProjectsSection.test.tsx b/orchestrator/src/client/pages/settings/components/ResumeProjectsSection.test.tsx index 1430b5a..c2f1aef 100644 --- a/orchestrator/src/client/pages/settings/components/ResumeProjectsSection.test.tsx +++ b/orchestrator/src/client/pages/settings/components/ResumeProjectsSection.test.tsx @@ -1,10 +1,11 @@ import { describe, it, expect } from "vitest" import { render, screen, fireEvent, waitFor } from "@testing-library/react" -import { useState } from "react" +import { useForm, FormProvider } from "react-hook-form" import { Accordion } from "@/components/ui/accordion" import { ResumeProjectsSection } from "./ResumeProjectsSection" -import type { ResumeProjectCatalogItem, ResumeProjectsSettings } from "@shared/types" +import type { ResumeProjectCatalogItem } from "@shared/types" +import { UpdateSettingsInput } from "@shared/settings-schema" const profileProjects: ResumeProjectCatalogItem[] = [ { @@ -23,25 +24,31 @@ const profileProjects: ResumeProjectCatalogItem[] = [ }, ] -const ResumeProjectsHarness = ({ initialDraft }: { initialDraft: ResumeProjectsSettings | null }) => { - const [draft, setDraft] = useState(initialDraft) - const lockedCount = draft?.lockedProjectIds.length ?? 0 +const ResumeProjectsHarness = ({ initialDraft }: { initialDraft: UpdateSettingsInput["resumeProjects"] }) => { + const methods = useForm({ + defaultValues: { + resumeProjects: initialDraft + } + }) + const watched = methods.watch() + const lockedCount = watched.resumeProjects?.lockedProjectIds.length ?? 0 return ( - - - + + + + + ) } + describe("ResumeProjectsSection", () => { it("clamps max projects to the locked count", async () => { render( diff --git a/orchestrator/src/client/pages/settings/components/ResumeProjectsSection.tsx b/orchestrator/src/client/pages/settings/components/ResumeProjectsSection.tsx index 1ff3194..92aad8f 100644 --- a/orchestrator/src/client/pages/settings/components/ResumeProjectsSection.tsx +++ b/orchestrator/src/client/pages/settings/components/ResumeProjectsSection.tsx @@ -1,16 +1,16 @@ import React from "react" +import { useFormContext, Controller } from "react-hook-form" import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" import { Checkbox } from "@/components/ui/checkbox" import { Input } from "@/components/ui/input" import { Separator } from "@/components/ui/separator" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" -import type { ResumeProjectCatalogItem, ResumeProjectsSettings } from "@shared/types" +import type { ResumeProjectCatalogItem } from "@shared/types" import { clampInt } from "@/lib/utils" +import { UpdateSettingsInput } from "@shared/settings-schema" type ResumeProjectsSectionProps = { - resumeProjectsDraft: ResumeProjectsSettings | null - setResumeProjectsDraft: (value: ResumeProjectsSettings | null) => void profileProjects: ResumeProjectCatalogItem[] lockedCount: number maxProjectsTotal: number @@ -19,14 +19,14 @@ type ResumeProjectsSectionProps = { } export const ResumeProjectsSection: React.FC = ({ - resumeProjectsDraft, - setResumeProjectsDraft, profileProjects, lockedCount, maxProjectsTotal, isLoading, isSaving, }) => { + const { control, formState: { errors } } = useFormContext() + return ( @@ -36,113 +36,126 @@ export const ResumeProjectsSection: React.FC = ({
Max projects included
- { - if (!resumeProjectsDraft) return - const next = Number(event.target.value) - const clamped = clampInt(next, lockedCount, maxProjectsTotal) - setResumeProjectsDraft({ ...resumeProjectsDraft, maxProjects: clamped }) - }} - disabled={isLoading || isSaving || !resumeProjectsDraft} + ( + { + if (!field.value) return + const next = Number(event.target.value) + const clamped = clampInt(next, lockedCount, maxProjectsTotal) + field.onChange({ ...field.value, maxProjects: clamped }) + }} + disabled={isLoading || isSaving || !field.value} + /> + )} /> + {errors.resumeProjects?.maxProjects &&

{errors.resumeProjects.maxProjects.message}

}
- Locked projects always count towards this cap. Locked: {lockedCount} · AI pool:{" "} - {resumeProjectsDraft?.aiSelectableProjectIds.length ?? 0} · Total projects: {maxProjectsTotal} + AI pool (max projects AI can use): {maxProjectsTotal}. Locked projects always count towards this cap. Locked: {lockedCount} · Total profile projects: {profileProjects.length}
- - - - Project - Base visible - Locked - AI selectable - - - - {profileProjects.map((project) => { - const locked = Boolean(resumeProjectsDraft?.lockedProjectIds.includes(project.id)) - const aiSelectable = Boolean(resumeProjectsDraft?.aiSelectableProjectIds.includes(project.id)) - const excluded = !locked && !aiSelectable - - return ( - - -
-
{project.name || project.id}
-
- {[project.description, project.date].filter(Boolean).join(" · ")} - {excluded ? " · Excluded" : ""} -
-
-
- {project.isVisibleInBase ? "Yes" : "No"} - - { - if (!resumeProjectsDraft) return - const isChecked = checked === true - const lockedIds = resumeProjectsDraft.lockedProjectIds.slice() - const selectableIds = resumeProjectsDraft.aiSelectableProjectIds.slice() - - if (isChecked) { - if (!lockedIds.includes(project.id)) lockedIds.push(project.id) - const nextSelectable = selectableIds.filter((id) => id !== project.id) - const minCap = lockedIds.length - setResumeProjectsDraft({ - ...resumeProjectsDraft, - lockedProjectIds: lockedIds, - aiSelectableProjectIds: nextSelectable, - maxProjects: Math.max(resumeProjectsDraft.maxProjects, minCap), - }) - return - } - - const nextLocked = lockedIds.filter((id) => id !== project.id) - if (!selectableIds.includes(project.id)) selectableIds.push(project.id) - setResumeProjectsDraft({ - ...resumeProjectsDraft, - lockedProjectIds: nextLocked, - aiSelectableProjectIds: selectableIds, - maxProjects: clampInt(resumeProjectsDraft.maxProjects, nextLocked.length, maxProjectsTotal), - }) - }} - /> - - - { - if (!resumeProjectsDraft) return - const isChecked = checked === true - const selectableIds = resumeProjectsDraft.aiSelectableProjectIds.slice() - const nextSelectable = isChecked - ? selectableIds.includes(project.id) - ? selectableIds - : [...selectableIds, project.id] - : selectableIds.filter((id) => id !== project.id) - setResumeProjectsDraft({ ...resumeProjectsDraft, aiSelectableProjectIds: nextSelectable }) - }} - /> - + ( +
+ + + Project + Base visible + Locked + AI selectable - ) - })} - -
+ + + {profileProjects.map((project) => { + const locked = Boolean(field.value?.lockedProjectIds.includes(project.id)) + const aiSelectable = Boolean(field.value?.aiSelectableProjectIds.includes(project.id)) + const excluded = !locked && !aiSelectable + + return ( + + +
+
{project.name || project.id}
+
+ {[project.description, project.date].filter(Boolean).join(" · ")} + {excluded ? " · Excluded" : ""} +
+
+
+ {project.isVisibleInBase ? "Yes" : "No"} + + { + if (!field.value) return + const isChecked = checked === true + const lockedIds = field.value.lockedProjectIds.slice() + const selectableIds = field.value.aiSelectableProjectIds.slice() + + if (isChecked) { + if (!lockedIds.includes(project.id)) lockedIds.push(project.id) + const nextSelectable = selectableIds.filter((id) => id !== project.id) + const minCap = lockedIds.length + field.onChange({ + ...field.value, + lockedProjectIds: lockedIds, + aiSelectableProjectIds: nextSelectable, + maxProjects: Math.max(field.value.maxProjects, minCap), + }) + return + } + + const nextLocked = lockedIds.filter((id) => id !== project.id) + if (!selectableIds.includes(project.id)) selectableIds.push(project.id) + field.onChange({ + ...field.value, + lockedProjectIds: nextLocked, + aiSelectableProjectIds: selectableIds, + maxProjects: clampInt(field.value.maxProjects, nextLocked.length, maxProjectsTotal), + }) + }} + /> + + + { + if (!field.value) return + const isChecked = checked === true + const selectableIds = field.value.aiSelectableProjectIds.slice() + const nextSelectable = isChecked + ? selectableIds.includes(project.id) + ? selectableIds + : [...selectableIds, project.id] + : selectableIds.filter((id) => id !== project.id) + field.onChange({ ...field.value, aiSelectableProjectIds: nextSelectable }) + }} + /> + +
+ ) + })} +
+ + )} + />
) } + diff --git a/orchestrator/src/client/pages/settings/components/SearchTermsSection.tsx b/orchestrator/src/client/pages/settings/components/SearchTermsSection.tsx index 5feee7d..595f517 100644 --- a/orchestrator/src/client/pages/settings/components/SearchTermsSection.tsx +++ b/orchestrator/src/client/pages/settings/components/SearchTermsSection.tsx @@ -1,25 +1,25 @@ import React from "react" +import { useFormContext, Controller } from "react-hook-form" import { AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion" import { Separator } from "@/components/ui/separator" +import { UpdateSettingsInput } from "@shared/settings-schema" +import type { SearchTermsValues } from "@client/pages/settings/types" type SearchTermsSectionProps = { - searchTermsDraft: string[] | null - setSearchTermsDraft: (value: string[] | null) => void - defaultSearchTerms: string[] - effectiveSearchTerms: string[] + values: SearchTermsValues isLoading: boolean isSaving: boolean } export const SearchTermsSection: React.FC = ({ - searchTermsDraft, - setSearchTermsDraft, - defaultSearchTerms, - effectiveSearchTerms, + values, isLoading, isSaving, }) => { + const { default: defaultSearchTerms, effective: effectiveSearchTerms } = values + const { control, formState: { errors } } = useFormContext() + return ( @@ -29,24 +29,30 @@ export const SearchTermsSection: React.FC = ({
Global search terms
-