diff --git a/README.md b/README.md index 0f2fa480c..e9d97952a 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,7 @@ These use the latest TinyFish SDK and are deployed with live demos you can try r | [logistics-sentry](./logistics-sentry) | Port congestion and carrier-risk tracking | | [tenders-finder](./tenders-finder) | Singapore government tender discovery across multiple portals | | [finsight](./finsight) | Vietnamese B2B market intel — leasing, competitor pricing, SME loans, SBV regulatory briefs with source preflight | +| [monai](./monai) | Vietnam F&B trend intelligence — menu gaps, forecasts, regional compare, supplier discovery ([live demo](https://mon-ai-alpha.vercel.app/)) | ### Education & Discovery diff --git a/monai/.env.example b/monai/.env.example new file mode 100644 index 000000000..c347e0967 --- /dev/null +++ b/monai/.env.example @@ -0,0 +1,12 @@ +# Get your API key at https://agent.tinyfish.ai/api-keys +TINYFISH_API_KEY= +# Optional search locale (used by the official TinyFish Python SDK) +# TINYFISH_SEARCH_LOCATION=VN +# TINYFISH_SEARCH_LANGUAGE=vi + +# Optional — richer AI synthesis when set; otherwise routes use live TinyFish SDK search/fetch data +OPENAI_API_KEY= + +ALLOWED_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 +# Production example (comma-separated, no trailing slashes): +# ALLOWED_ORIGINS=https://mon-ai-alpha.vercel.app diff --git a/monai/.gitignore b/monai/.gitignore new file mode 100644 index 000000000..788b752f8 --- /dev/null +++ b/monai/.gitignore @@ -0,0 +1,15 @@ +.env +.env.* +!.env.example +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ +.pytest_cache/ +.mypy_cache/ + +# Ad-hoc local testing / scratch output (scripts/smoke_test.py stays tracked) +_test_*.py +*_out.json +trends_out.json diff --git a/monai/.python-version b/monai/.python-version new file mode 100644 index 000000000..04e207918 --- /dev/null +++ b/monai/.python-version @@ -0,0 +1 @@ +3.12.8 diff --git a/monai/README.md b/monai/README.md new file mode 100644 index 000000000..c139b71b1 --- /dev/null +++ b/monai/README.md @@ -0,0 +1,553 @@ +# MónAI + +**Vietnam's AI-Powered Food Trend Intelligence** + +MónAI helps F&B brands spot viral dishes, forecast adoption, analyze menu gaps, compare regional trends, and discover suppliers — before competitors launch. The repo is a monorepo: a **Vietnamese street-food–inspired marketing landing page** (React) and a **FastAPI backend** wired for TinyFish search + OpenAI analysis. + +--- + +## Table of contents + +- [Overview](#overview) +- [Architecture](#architecture) +- [Product workflow](#product-workflow) +- [Tech stack](#tech-stack) +- [Project structure](#project-structure) +- [Getting started](#getting-started) +- [Environment variables](#environment-variables) +- [API reference](#api-reference) +- [Landing page](#landing-page) +- [Design system](#design-system) +- [Deployment](#deployment) +- [Project status](#project-status) + +--- + +## Overview + +```mermaid +mindmap + root((MónAI)) + Detect + TikTok signals + Facebook food groups + Delivery apps + Analyze + Menu gap analysis + Regional comparison + Growth forecasting + Source + Supplier discovery + RFQ generation + Launch + Coffee chains + Restaurant groups + Food manufacturers +``` + +| Layer | Role | Status | +|-------|------|--------| +| **Frontend** (`frontend/`) | Bánh Mì–themed marketing landing page + shadcn/ui component library for future dashboard | Landing page **live-ready** | +| **Backend** (`main.py`, `app/`) | REST API — TinyFish search/fetch → OpenAI structured analysis | Implemented; needs env vars on deploy | +| **Integrations** | TinyFish (web signals), OpenAI GPT-4o (analysis) | Requires API keys | + +--- + +## Architecture + +### System context + +```mermaid +flowchart TB + subgraph Users + U1[Coffee chains] + U2[Restaurant groups] + U3[Food manufacturers] + end + + subgraph Frontend["Frontend — Vercel"] + LP[Landing page
TanStack Router + React 19] + UI[shadcn/ui components
future dashboard] + API_CLIENT[src/lib/api.ts] + end + + subgraph Backend["Backend — Render"] + FAST[FastAPI
main.py] + TR[trends router] + AN[analysis router] + SU[suppliers router] + end + + subgraph External + TF[(TinyFish API
search + fetch)] + OAI[(OpenAI API
GPT-4o)] + end + + U1 & U2 & U3 --> LP + LP -.->|optional VITE_API_BASE_URL| API_CLIENT + API_CLIENT -->|HTTPS + CORS| FAST + FAST --> TR & AN & SU + TR & AN & SU --> TF + TR & AN & SU --> OAI +``` + +### API request flow + +Every intelligence endpoint follows the same pipeline: **gather signals → analyze with AI → return structured JSON**. + +```mermaid +sequenceDiagram + participant Client as Frontend / API client + participant API as FastAPI + participant TF as TinyFish + participant AI as OpenAI + + Client->>API: GET /api/trends/emerging?location=Hà Nội + API->>TF: Search — viral drinks + street food signals + TF-->>API: Raw search results + API-->>API: Filter titles, rank from search positions + API-->>Client: { location, emerging_trends: [...] } +``` + +Forecast path (optional OpenAI prose only — no invented scores): + +```mermaid +sequenceDiagram + participant Client as Frontend / API client + participant API as FastAPI + participant TF as TinyFish + participant AI as OpenAI + + Client->>API: GET /api/trends/forecast?trend_name=...&location=... + API->>TF: POST /search + TF-->>API: Raw search results + opt OpenAI configured + API->>AI: Summarize sources only + AI-->>API: reasoning + key_drivers + end + API-->>Client: { forecast: { signal_count, reasoning, sources } } +``` + +### Deployment topology + +```mermaid +flowchart LR + subgraph GitHub + REPO[MonAI repo] + end + + subgraph Vercel + BUILD_FE[npm run build] + CDN[Static SPA
index.html + assets] + end + + subgraph Render + BUILD_BE[pip install -r requirements.txt] + UVICORN[uvicorn main:app] + end + + REPO -->|Root: frontend/| BUILD_FE --> CDN + REPO -->|render.yaml| BUILD_BE --> UVICORN + + CDN -->|VITE_API_BASE_URL| UVICORN + UVICORN -->|ALLOWED_ORIGINS| CDN +``` + +--- + +## Product workflow + +The landing page visualizes MónAI's end-to-end flow as a **bánh mì assembled layer by layer**: + +```mermaid +flowchart TD + A[🍞 Detect
Monitor TikTok, Facebook, GrabFood, ShopeeFood] --> B[🫙 Validate
Cross-reference social signals with sales data] + B --> C[🥩 Analyze
Menu gap vs regional competitors] + C --> D[🥒 Rank
Evidence from live search signals] + D --> E[🌿 Source
Discover verified suppliers] + E --> F[🌶️ RFQ
Automated outreach + sample negotiation] + F --> G[🥖 Launch
Ship before competitors] + + style A fill:#E8B04B,color:#3A2418 + style G fill:#C8322C,color:#F5EBD3 +``` + +| Step | API support | +|------|-------------| +| Detect | `GET /api/trends/emerging` | +| Validate / Rank | `GET /api/trends/forecast` | +| Analyze | `POST /api/analysis/menu-gap`, `GET /api/trends/regional` | +| Source | `POST /api/suppliers/discover` | +| RFQ | `POST /api/suppliers/outreach` | + +--- + +## Tech stack + +### Frontend + +| Tool | Purpose | +|------|---------| +| [Vite 8](https://vite.dev/) | Dev server + production build | +| [React 19](https://react.dev/) | UI | +| [TanStack Router](https://tanstack.com/router) | File-based routing | +| [Tailwind CSS v4](https://tailwindcss.com/) | Utility styling + `@theme` tokens | +| [Radix UI](https://www.radix-ui.com/) + [shadcn/ui](https://ui.shadcn.com/) | Accessible component primitives | +| [Lucide React](https://lucide.dev/) | Icons | + +**Fonts:** Playfair Display · Bebas Neue · Be Vietnam Pro + +### Backend + +| Tool | Purpose | +|------|---------| +| [FastAPI](https://fastapi.tiangolo.com/) | Async REST API | +| [Uvicorn](https://www.uvicorn.org/) | ASGI server | +| [tinyfish](https://docs.tinyfish.ai/quick-start) | Official TinyFish Python SDK (Search + Fetch) | +| [OpenAI Python SDK](https://github.com/openai/openai-python) | GPT-4o analysis | +| [Pydantic v2](https://docs.pydantic.dev/) | Request/response models | + +**Runtime:** Python 3.12.8 (`runtime.txt`) + +--- + +## Project structure + +``` +MonAI/ +├── main.py # FastAPI app entry + CORS +├── requirements.txt +├── runtime.txt +├── render.yaml # Render deploy blueprint +├── .env.example # Backend env template +│ +├── app/ +│ ├── routes/ +│ │ ├── trends.py # Emerging, forecast, regional +│ │ ├── analysis.py # Menu gap analysis +│ │ └── suppliers.py # Discovery + RFQ outreach +│ └── services/ +│ ├── tinyfish_client.py # Search + fetch wrappers +│ ├── ai_client.py # OpenAI chat completions +│ ├── report_builder.py # Report generation +│ ├── response_parser.py # Response parsing + metric stripping +│ └── search_analysis.py # Search result analysis +│ +└── frontend/ + ├── vercel.json # SPA rewrites + ├── .env.example # VITE_API_BASE_URL + ├── src/ + │ ├── main.tsx + │ ├── styles.css # MónAI design tokens (oklch) + │ ├── routes/ + │ │ ├── __root.tsx # Fonts, meta, og tags + │ │ └── index.tsx # Landing page composition + │ ├── components/ + │ │ ├── monai/ # Landing sections + │ │ └── ui/ # shadcn/ui primitives + │ ├── lib/ + │ │ ├── api.ts # Backend client (env-driven) + │ │ └── utils.ts + │ └── assets/ # Food illustrations (.webp) + leaf texture + └── package.json +``` + +### Frontend component map + +```mermaid +flowchart TB + subgraph routes + ROOT[__root.tsx
HeadContent + fonts] + INDEX[index.tsx] + end + + subgraph monai["components/monai/"] + H[HeroSection] + P[ProblemTicker] + F[FeaturesSection] + T[TrendsBoard] + U[UsersSection] + W[WorkflowSection] + C[CtaFooter] + SH[SiteHeader / SiteFooter] + end + + INDEX --> SH & H & P & F & T & U & W & C + ROOT --> INDEX +``` + +--- + +## Getting started + +### Prerequisites + +- **Node.js** 20+ and npm +- **Python** 3.12+ +- API keys (for backend only): OpenAI, TinyFish + +### 1. Clone and install + +```bash +git clone +cd MonAI +``` + +**Frontend** + +```bash +cd frontend +npm install +cp .env.example .env # optional for landing-only preview +npm run dev +``` + +Open [http://localhost:5173](http://localhost:5173). + +**Backend** + +```bash +cd .. # repo root +python -m venv .venv + +# Windows +.venv\Scripts\activate +# macOS / Linux +source .venv/bin/activate + +pip install -r requirements.txt +cp .env.example .env # fill in values — see below +uvicorn main:app --reload --port 8000 +``` + +Open [http://localhost:8000/docs](http://localhost:8000/docs) for interactive Swagger UI. + +**Required:** Set `TINYFISH_API_KEY` in `.env`. Search and Fetch go through the [official TinyFish Python SDK](https://docs.tinyfish.ai/quick-start). `OPENAI_API_KEY` is optional for richer synthesis. + +### 2. Connect frontend to backend (optional) + +In `frontend/.env`: + +```env +VITE_API_BASE_URL=http://localhost:8000 +VITE_DEFAULT_LOCATION=TP.HCM +``` + +In root `.env`: + +```env +ALLOWED_ORIGINS=http://localhost:5173 +``` + +> The landing page and dashboard call the live API via `src/lib/api.ts` (Vite dev proxy or `VITE_API_BASE_URL`). + +--- + +## Environment variables + +### Backend (`.env` at repo root) + +| Variable | Required | Description | +|----------|----------|-------------| +| `ALLOWED_ORIGINS` | **Yes** | Comma-separated CORS origins (e.g. `http://localhost:5173,https://your-app.vercel.app`). App **will not start** without this. | +| `TINYFISH_API_KEY` | **Yes** | TinyFish API key from [agent.tinyfish.ai/api-keys](https://agent.tinyfish.ai/api-keys) | +| `OPENAI_API_KEY` | No | Optional GPT-4o enrichment; without it, routes structure live TinyFish search/fetch results | +| `TINYFISH_SEARCH_LOCATION` | No | Defaults to `VN` | +| `TINYFISH_SEARCH_LANGUAGE` | No | Defaults to `vi` | +| `PORT` | No | Set automatically on Render | +| `PYTHON_VERSION` | No | Pin Python on Render (also set via `.python-version` → `3.12.8`) | + +### Frontend (`frontend/.env`) + +| Variable | Required | Description | +|----------|----------|-------------| +| `VITE_API_BASE_URL` | No (landing only) | Backend URL for dashboard / live API calls | +| `VITE_DEFAULT_LOCATION` | For live trend board | Market passed to `/api/trends/emerging` (e.g. `TP.HCM`) | +| `VITE_DEFAULT_CATEGORY` | No | Category for emerging-trends search (default: `food and beverage`) | + +--- + +## API reference + +Base URL: `http://localhost:8000` (local) or your Render service URL. + +### Trends + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/api/trends/emerging` | Top emerging trends for a location | +| `GET` | `/api/trends/forecast` | Adoption forecast for a named trend | +| `GET` | `/api/trends/regional` | Compare trends between two regions | + +**Example — emerging trends** + +```bash +curl "http://localhost:8000/api/trends/emerging?location=TP.HCM&category=beverage" +``` + +### Analysis + +| Method | Path | Description | +|--------|------|-------------| +| `POST` | `/api/analysis/menu-gap` | Menu gap vs trends + optional competitor URLs | + +```json +{ + "current_menu_items": ["Cà phê sữa đá", "Bạc xỉu"], + "location": "Hà Nội", + "competitor_urls": ["https://example.com/menu"] +} +``` + +### Suppliers + +| Method | Path | Description | +|--------|------|-------------| +| `POST` | `/api/suppliers/discover` | Find wholesalers for trend ingredients | +| `POST` | `/api/suppliers/outreach` | Generate bilingual RFQ email template | + +### Health + +| Method | Path | Description | +|--------|------|-------------| +| `GET` | `/` | API welcome JSON (Render health check) | +| `GET` | `/docs` | Swagger UI | + +--- + +## Landing page + +The home route (`frontend/src/routes/index.tsx`) is a single-page marketing site with seven sections: + +| Section | Component | Highlights | +|---------|-----------|------------| +| Hero | `HeroSection` | MónAI wordmark, food collage, steam animation, CTAs | +| Problem | `ProblemTicker` | Scrolling TikTok · GrabFood · ShopeeFood sources | +| Features | `FeaturesSection` | 6-card bento grid themed by Vietnamese dishes | +| Live trends | `TrendsBoard` | Chalkboard-style live trends from API | +| Users | `UsersSection` | Stamped audience cards | +| Workflow | `WorkflowSection` | Bánh mì layer timeline | +| CTA | `CtaFooter` | Banana-leaf bordered “Bắt đầu ngay” block | + +Illustrations live in `frontend/src/assets/` (`pho.png`, `banhmi.png`, `capthrung.png`, `banhxeo.png`, `bunbohue.png`, `leaf.jpg`). + +--- + +## Design system + +Tokens are defined in `frontend/src/styles.css` using **oklch** and semantic aliases — no hardcoded hex in components. + +| Token | Role | Inspiration | +|-------|------|-------------| +| `cream` | Background | Pickled daikon | +| `crust` / `toasted` | Secondary golds | Baguette crust | +| `chili` | Primary / CTAs | Chili red | +| `cilantro` | Accent | Banana leaf green | +| `nuoc` | Text | Nước mắm brown | +| `foam` | Highlights | Cà phê trứng foam | + +**Typography:** `--font-display` (Playfair Display) · `--font-punch` (Bebas Neue) · `--font-vn` (Be Vietnam Pro) + +**Motifs:** rice-paper texture, banana-leaf dividers, rubber-stamp seals, Vietnamese star bullets, chopstick underlines, steam wisps. + +--- + +## Deployment + +### Frontend → Vercel + +1. Import the GitHub repo in [Vercel](https://vercel.com). +2. Set **Root Directory** to `frontend`. +3. Framework preset: **Vite** (auto-detect). +4. Build command: `npm run build` · Output: `dist`. +5. Add env var when connecting to backend: + ``` + VITE_API_BASE_URL=https://.onrender.com + ``` + +`frontend/vercel.json` handles SPA routing: + +```json +{ "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] } +``` + +### Backend → Render (free tier) + +**Option A — Manual (recommended for free):** + +1. [Render Dashboard](https://dashboard.render.com) → **New → Web Service** → connect repo. +2. **Do not use Blueprint** if it prompts for paid workspace — create the service manually instead. +3. Settings: + - **Root directory:** leave empty (repo root) + - **Runtime:** Python + - **Instance type:** **Free** (not Starter) + - **Build command:** `pip install -r requirements.txt` + - **Start command:** `uvicorn main:app --host 0.0.0.0 --port $PORT` + - **Health check path:** `/health` +4. Environment variables: + + | Key | Example | + |-----|---------| + | `ALLOWED_ORIGINS` | `https://your-app.vercel.app` | + | `TINYFISH_API_KEY` | your key from [agent.tinyfish.ai/api-keys](https://agent.tinyfish.ai/api-keys) | + | `OPENAI_API_KEY` | optional | + +**Option B — Blueprint (`render.yaml`):** + +`render.yaml` includes `plan: free` and `runtime: python`. Without `plan: free`, Render defaults to **Starter ($7/mo)**. + +1. **New → Blueprint** → select repo. +2. Confirm the service shows **Free** instance before applying. + +```mermaid +flowchart LR + USER[Browser] --> VERCEL[Vercel CDN
Landing SPA] + VERCEL -->|API calls| RENDER[Render
FastAPI] + RENDER --> TF[TinyFish] + RENDER --> OAI[OpenAI] +``` + +### Post-deploy checklist + +- [ ] Backend `/` returns JSON on Render +- [ ] Backend `/docs` loads +- [ ] `ALLOWED_ORIGINS` includes exact Vercel URL (no trailing slash) +- [ ] Frontend builds without `VITE_API_BASE_URL` (landing works standalone) +- [ ] CORS preflight succeeds from Vercel origin + +--- + +## Project status + +| Area | State | +|------|-------| +| Marketing landing page | ✅ Complete | +| AI Analysis console (`/dashboard` + `#analysis` on home) | ✅ Wired to API | +| Live trend board | ✅ Fetches from live API | +| TinyFish Search + Fetch | ✅ Required (`TINYFISH_API_KEY` only) | +| OpenAI synthesis | Optional enrichment | +| shadcn/ui component library | ✅ Ready for expansion | +| Auth / database | ❌ Out of scope | + +### Known considerations + +- **Backend deps:** Use a virtualenv with pinned `requirements.txt` (`tinyfish`, `fastapi`, etc.). +- **`.env` syntax:** Ensure no unquoted special characters; dotenv warns on malformed lines. + +--- + +## Scripts reference + +| Location | Command | Description | +|----------|---------|-------------| +| `frontend/` | `npm run dev` | Vite dev server | +| `frontend/` | `npm run build` | Typecheck + production build | +| `frontend/` | `npm run preview` | Preview production build | +| repo root | `uvicorn main:app --reload` | Backend dev server | + +--- + +

+ MónAI — spot the trend, source the supplier, launch first.
+ Built for Vietnam's F&B innovators. +

diff --git a/monai/app/__init__.py b/monai/app/__init__.py new file mode 100644 index 000000000..143f486c0 --- /dev/null +++ b/monai/app/__init__.py @@ -0,0 +1 @@ +# __init__.py diff --git a/monai/app/http_errors.py b/monai/app/http_errors.py new file mode 100644 index 000000000..ecba2b0a1 --- /dev/null +++ b/monai/app/http_errors.py @@ -0,0 +1,13 @@ +import logging + +from fastapi import HTTPException + +logger = logging.getLogger(__name__) + + +def raise_internal_error(exc: Exception, *, context: str = "request") -> None: + logger.exception("%s failed", context) + raise HTTPException( + status_code=500, + detail="An internal error occurred. Please try again later.", + ) from exc diff --git a/monai/app/routes/__init__.py b/monai/app/routes/__init__.py new file mode 100644 index 000000000..143f486c0 --- /dev/null +++ b/monai/app/routes/__init__.py @@ -0,0 +1 @@ +# __init__.py diff --git a/monai/app/routes/analysis.py b/monai/app/routes/analysis.py new file mode 100644 index 000000000..ef0e385fb --- /dev/null +++ b/monai/app/routes/analysis.py @@ -0,0 +1,94 @@ +from fastapi import APIRouter +from pydantic import BaseModel + +from app.http_errors import raise_internal_error +from app.services.ai_client import analyze_data, has_openai +from app.services.report_builder import build_menu_gap_report +from app.services.response_parser import normalize_payload, parse_llm_json, strip_fabricated_metrics +from app.services.search_analysis import as_json_text, menu_gap_from_search +from app.services.tinyfish_client import fetch_tinyfish, search_tinyfish + +router = APIRouter(prefix="/api/analysis", tags=["Analysis"]) + +SYSTEM_JSON = ( + "You are a senior Vietnam F&B menu strategist. Output ONLY valid JSON. " + "Write recommendations as actionable, specific prose." +) + + +class MenuGapRequest(BaseModel): + current_menu_items: list[str] + location: str + competitor_urls: list[str] = [] + + +@router.post("/menu-gap") +async def analyze_menu_gap(request: MenuGapRequest): + """Compares current menu with competitors and emerging trends to find gaps.""" + try: + trends_query = ( + f"viral trending drinks and street food {request.location} Vietnam " + f"TikTok GrabFood ShopeeFood menu" + ) + trends_results = await search_tinyfish(trends_query) + + competitor_menus = [] + for url in request.competitor_urls[:2]: + try: + fetched = await fetch_tinyfish(url) + page = (fetched.get("results") or [{}])[0] + competitor_menus.append( + { + "url": url, + "title": page.get("title"), + "text": (page.get("text") or "")[:4000], + } + ) + except Exception as fetch_err: + print(f"Failed to fetch {url}: {fetch_err}") + + if has_openai(): + prompt = f""" + Menu gap analysis for {request.location}. + + Current menu: {request.current_menu_items} + Trend signals: {as_json_text(trends_results)} + Competitor pages: {as_json_text(competitor_menus)} + + Return JSON with keys: + location, current_menu_items, executive_summary (2 sentences), + missing_opportunities (array max 5). Each opportunity needs: + trend, priority (High/Medium/Low based on search order only), evidence (from snippets), + competitor_adoption (from competitor page text when available), recommendation (actionable, no invented timelines). + Do not invent confidence scores, percentages, or day counts. + """ + ai_response = await analyze_data(prompt, SYSTEM_JSON) + analysis = parse_llm_json(ai_response) + if not isinstance(analysis, dict): + analysis = menu_gap_from_search( + request.current_menu_items, + request.location, + trends_results, + competitor_menus, + ) + else: + analysis = strip_fabricated_metrics(analysis) + analysis.setdefault("trend_signals", [ + {"title": r.get("title"), "snippet": r.get("snippet"), "url": r.get("url")} + for r in trends_results.get("results", [])[:8] + ]) + else: + analysis = menu_gap_from_search( + request.current_menu_items, + request.location, + trends_results, + competitor_menus, + ) + + analysis = normalize_payload(analysis) + return { + "menu_gap_analysis": analysis, + "report": build_menu_gap_report(analysis if isinstance(analysis, dict) else {}), + } + except Exception as e: + raise_internal_error(e, context="menu gap analysis") diff --git a/monai/app/routes/suppliers.py b/monai/app/routes/suppliers.py new file mode 100644 index 000000000..70b02e0a7 --- /dev/null +++ b/monai/app/routes/suppliers.py @@ -0,0 +1,88 @@ +from fastapi import APIRouter +from pydantic import BaseModel + +from app.http_errors import raise_internal_error +from app.services.ai_client import analyze_data, has_openai +from app.services.report_builder import build_outreach_report, build_suppliers_report +from app.services.response_parser import normalize_payload, parse_llm_json, strip_fabricated_metrics +from app.services.search_analysis import outreach_template, suppliers_from_search +from app.services.tinyfish_client import search_tinyfish + +router = APIRouter(prefix="/api/suppliers", tags=["Suppliers"]) + +SYSTEM_JSON = ( + "You are a senior Vietnam F&B procurement manager. Output ONLY valid JSON. " + "Write polished, professional copy suitable for immediate business use." +) + + +class SupplierRequest(BaseModel): + trend_name: str + ingredients: list[str] + location: str + + +class OutreachRequest(BaseModel): + supplier_info: str + product_needs: str + + +@router.post("/discover") +async def discover_suppliers(request: SupplierRequest): + """Finds suppliers for specific ingredients related to a trend.""" + try: + ingredient_str = ", ".join(request.ingredients) + query = f"wholesale suppliers distributors for {ingredient_str} in {request.location} Vietnam" + search_results = await search_tinyfish( + query, + max_results=15, + purpose=f"Find suppliers for {ingredient_str} near {request.location}", + ) + + suppliers = suppliers_from_search(search_results) + + suppliers = normalize_payload(suppliers) + if not isinstance(suppliers, list): + suppliers = [] + + return { + "trend": request.trend_name, + "ingredients": request.ingredients, + "location": request.location, + "suppliers": suppliers, + "report": build_suppliers_report(request.trend_name, suppliers), + } + except Exception as e: + raise_internal_error(e, context="supplier discovery") + + +@router.post("/outreach") +async def generate_outreach(request: OutreachRequest): + """Generates an RFQ and outreach email in Vietnamese and English.""" + try: + if has_openai(): + prompt = f""" + Write a bilingual RFQ email (English + Vietnamese). + Supplier: {request.supplier_info} + Products needed: {request.product_needs} + + Return JSON with keys: subject_en, body_en, subject_vi, body_vi. + Each body must have: greeting, context, bullet list of asks (MOQ, pricing, samples, delivery), + timeline expectation, professional sign-off. Use paragraph breaks (\\n\\n). + """ + ai_response = await analyze_data(prompt, SYSTEM_JSON) + rfq = parse_llm_json(ai_response) + if not isinstance(rfq, dict): + rfq = outreach_template(request.supplier_info, request.product_needs) + else: + rfq = strip_fabricated_metrics(rfq) + else: + rfq = outreach_template(request.supplier_info, request.product_needs) + + rfq = normalize_payload(rfq) + return { + "rfq_template": rfq, + "report": build_outreach_report(rfq if isinstance(rfq, dict) else {}), + } + except Exception as e: + raise_internal_error(e, context="supplier outreach") diff --git a/monai/app/routes/trends.py b/monai/app/routes/trends.py new file mode 100644 index 000000000..3949e7017 --- /dev/null +++ b/monai/app/routes/trends.py @@ -0,0 +1,148 @@ +import asyncio + +from fastapi import APIRouter + +from app.http_errors import raise_internal_error +from app.services.ai_client import analyze_data, has_openai +from app.services.report_builder import build_emerging_report, build_forecast_report, build_regional_report +from app.services.response_parser import normalize_payload, parse_llm_json, strip_fabricated_metrics +from app.services.search_analysis import ( + as_json_text, + build_emerging_search_queries, + emerging_trends_from_search, + forecast_from_search, + merge_search_results, + regional_comparison_from_search, +) +from app.services.tinyfish_client import search_tinyfish + +router = APIRouter(prefix="/api/trends", tags=["Trends"]) + +SYSTEM_JSON = ( + "You are a senior Vietnam F&B market analyst. Output ONLY valid JSON. " + "Write in clear, executive-ready English. Use vivid but professional language." +) + + +@router.get("/emerging") +async def get_emerging_trends(location: str, category: str = "food and beverage"): + """Detects rapidly growing food trends in a specific location.""" + try: + queries = build_emerging_search_queries(location, category) + datasets = await asyncio.gather( + *[search_tinyfish(query, max_results=10) for query in queries] + ) + search_results = merge_search_results(*datasets) + trends = emerging_trends_from_search(search_results, location) + + return { + "location": location, + "category": category, + "emerging_trends": trends, + "report": build_emerging_report(location, trends, category=category), + } + + except Exception as e: + raise_internal_error(e, context="emerging trends") + + +@router.get("/forecast") +async def forecast_trend(trend_name: str, location: str): + """Predicts mainstream adoption timeline for an emerging trend.""" + try: + query = f"{trend_name} popularity adoption in {location} Vietnam" + search_results = await search_tinyfish(query, purpose=f"Forecast adoption of {trend_name} in {location}") + + if has_openai(): + prompt = ( + f"Forecast mainstream adoption of '{trend_name}' in {location}.\n" + f"Search data:\n{as_json_text(search_results)}\n\n" + "Return JSON with keys:\n" + "reasoning (3 paragraphs citing only the supplied search results), " + "key_drivers (array of short strings taken from source titles/snippets).\n" + "Do not invent confidence scores, percentages, or adoption day counts.\n" + ) + ai_response = await analyze_data(prompt, SYSTEM_JSON) + forecast = parse_llm_json(ai_response) + if not isinstance(forecast, dict): + forecast = forecast_from_search(search_results, trend_name, location) + else: + forecast = strip_fabricated_metrics(forecast) + forecast["signal_count"] = len(search_results.get("results", [])) + else: + forecast = forecast_from_search(search_results, trend_name, location) + + forecast = normalize_payload(forecast) + if isinstance(forecast, dict): + forecast.setdefault("location", location) + forecast.setdefault("trend", trend_name) + if not forecast.get("sources"): + forecast["sources"] = [ + {"title": r.get("title"), "snippet": r.get("snippet"), "url": r.get("url")} + for r in search_results.get("results", [])[:5] + ] + + return { + "trend": trend_name, + "location": location, + "forecast": forecast, + "report": build_forecast_report(trend_name, location, forecast if isinstance(forecast, dict) else {}), + } + except Exception as e: + raise_internal_error(e, context="trend forecast") + + +@router.get("/regional") +async def compare_regional_trends(region_a: str, region_b: str, category: str = "beverage"): + """Compares trends between two regions (e.g., Hanoi vs HCMC).""" + try: + results_a, results_b = await asyncio.gather( + search_tinyfish( + f"{category} trends in {region_a} Vietnam", + max_results=5, + purpose=f"Find {category} trends in {region_a}", + ), + search_tinyfish( + f"{category} trends in {region_b} Vietnam", + max_results=5, + purpose=f"Find {category} trends in {region_b}", + ), + ) + + if has_openai(): + prompt = ( + f"Compare {category} trends: {region_a} vs {region_b}.\n" + f"{region_a} data:\n{as_json_text(results_a)}\n\n" + f"{region_b} data:\n{as_json_text(results_b)}\n\n" + "Return JSON with keys:\n" + "summary (2-3 paragraphs), region_a_lead_trend, region_b_lead_trend, " + "region_a_summary (1 paragraph), region_b_summary (1 paragraph), " + "expansion_opportunities (array of 3 actionable strings)." + ) + ai_response = await analyze_data(prompt, SYSTEM_JSON) + comparison = parse_llm_json(ai_response) + if isinstance(comparison, dict): + comparison = strip_fabricated_metrics(comparison) + comparison.setdefault("region_a", region_a) + comparison.setdefault("region_b", region_b) + comparison.setdefault("category", category) + comparison.setdefault("region_a_signals", [ + {"title": r.get("title"), "snippet": r.get("snippet"), "url": r.get("url")} + for r in results_a.get("results", [])[:5] + ]) + comparison.setdefault("region_b_signals", [ + {"title": r.get("title"), "snippet": r.get("snippet"), "url": r.get("url")} + for r in results_b.get("results", [])[:5] + ]) + else: + comparison = regional_comparison_from_search(region_a, region_b, results_a, results_b, category) + else: + comparison = regional_comparison_from_search(region_a, region_b, results_a, results_b, category) + + comparison = normalize_payload(comparison) + return { + "comparison": comparison, + "report": build_regional_report(comparison if isinstance(comparison, dict) else {}), + } + except Exception as e: + raise_internal_error(e, context="regional comparison") diff --git a/monai/app/services/__init__.py b/monai/app/services/__init__.py new file mode 100644 index 000000000..143f486c0 --- /dev/null +++ b/monai/app/services/__init__.py @@ -0,0 +1 @@ +# __init__.py diff --git a/monai/app/services/ai_client.py b/monai/app/services/ai_client.py new file mode 100644 index 000000000..f52e3cf7d --- /dev/null +++ b/monai/app/services/ai_client.py @@ -0,0 +1,40 @@ +import os + +from dotenv import load_dotenv +from openai import AsyncOpenAI + +load_dotenv() + +_client: AsyncOpenAI | None = None + + +def has_openai() -> bool: + return bool(os.getenv("OPENAI_API_KEY")) + + +def _get_client() -> AsyncOpenAI: + global _client + if _client is None: + api_key = os.getenv("OPENAI_API_KEY") + if not api_key: + raise ValueError("OPENAI_API_KEY environment variable not set") + _client = AsyncOpenAI(api_key=api_key, timeout=60.0) + return _client + + +async def analyze_data( + prompt: str, + system_prompt: str = "You are a food industry analyst.", + model: str = "gpt-4o", +) -> str: + """Optional OpenAI enrichment when OPENAI_API_KEY is configured.""" + client = _get_client() + response = await client.chat.completions.create( + model=model, + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": prompt}, + ], + temperature=0.7, + ) + return response.choices[0].message.content or "" diff --git a/monai/app/services/report_builder.py b/monai/app/services/report_builder.py new file mode 100644 index 000000000..5da803bdf --- /dev/null +++ b/monai/app/services/report_builder.py @@ -0,0 +1,445 @@ +"""Build human-readable intelligence reports with ready-to-use copy.""" + +from __future__ import annotations + +from typing import Any + + +def _paragraphs(text: str) -> list[str]: + if not text: + return [] + chunks = [part.strip() for part in text.replace("\r", "").split("\n") if part.strip()] + if len(chunks) > 1: + return chunks + sentences = [s.strip() for s in text.split(". ") if s.strip()] + if len(sentences) <= 2: + return [text.strip()] if text.strip() else [] + grouped: list[str] = [] + buffer: list[str] = [] + for sentence in sentences: + chunk = sentence if sentence.endswith(".") else f"{sentence}." + buffer.append(chunk) + if len(buffer) >= 2: + grouped.append(" ".join(buffer)) + buffer = [] + if buffer: + grouped.append(" ".join(buffer)) + return grouped + + +def _report_shell( + headline: str, + *, + subtitle: str = "", + paragraphs: list[str] | None = None, + metrics: list[dict[str, str]] | None = None, + sections: list[dict[str, Any]] | None = None, + bullets: list[str] | None = None, + sources: list[dict[str, str]] | None = None, + actions: list[str] | None = None, + cards: list[dict[str, str]] | None = None, + ready_to_use: list[dict[str, str]] | None = None, +) -> dict[str, Any]: + return { + "headline": headline, + "subtitle": subtitle, + "paragraphs": paragraphs or [], + "metrics": metrics or [], + "sections": sections or [], + "bullets": bullets or [], + "sources": sources or [], + "actions": actions or [], + "cards": cards or [], + "ready_to_use": ready_to_use or [], + } + + +def _sources_from_payload(items: list[dict[str, Any]] | None) -> list[dict[str, str]]: + if not items: + return [] + out = [] + for item in items[:6]: + out.append( + { + "title": str(item.get("title") or item.get("trend_name") or "Source"), + "excerpt": str(item.get("excerpt") or item.get("snippet") or item.get("description") or ""), + "url": str(item.get("url") or item.get("source_url") or item.get("contact_info") or ""), + } + ) + return out + + +def _rank_label(item: dict[str, Any]) -> str: + rank = item.get("display_rank") or item.get("search_rank") + if rank is not None: + return f"#{rank}" + return "—" + + +def build_emerging_report( + location: str, + trends: list[dict[str, Any]], + *, + category: str = "food and beverage", +) -> dict[str, Any]: + top = trends[0] if trends else {} + top_name = top.get("trend_name", "—") + sources = _sources_from_payload(trends) + + cards = [ + { + "title": t.get("trend_name", "Trend"), + "subtitle": f"{_rank_label(t)} · {t.get('publisher', 'web')}", + "body": t.get("description") or t.get("why_it_matters") or "", + "tag": t.get("region", location), + } + for t in trends[:5] + ] + + bullets = [ + f"**{t.get('trend_name')}** ({_rank_label(t)}) — {t.get('description', '')}" + for t in trends[:5] + ] + + why_text = _paragraphs(str(top.get("why_it_matters") or top.get("description") or "")) + summary_paragraphs = why_text or ( + [f"Live TinyFish search returned **{len(trends)}** signals for *{location}*."] + if trends + else [f"No live search signals were returned for *{location}*."] + ) + + return _report_shell( + headline=f"Emerging F&B Trends — {location}", + subtitle=f"{len(trends)} {category} signals from live TinyFish search", + paragraphs=summary_paragraphs, + metrics=[ + {"label": "Market", "value": location}, + {"label": "Category", "value": category}, + {"label": "Signals", "value": str(len(trends))}, + {"label": "Top signal", "value": str(top_name)}, + {"label": "Top rank", "value": _rank_label(top)}, + ], + sections=[ + { + "title": f"Top signal — {top_name}", + "paragraphs": why_text[:1], + }, + ] if top and why_text else [], + cards=cards, + bullets=bullets, + sources=sources, + actions=[ + f"Review source evidence for *{top_name}* before menu changes." if top else "Run another search with a narrower location or category.", + "Validate signals against your POS and delivery app data.", + ], + ready_to_use=[ + { + "label": "Trend digest (email)", + "text": ( + f"Subject: F&B trend signals — {location}\n\n" + f"Team,\n\n" + f"Live TinyFish search returned {len(trends)} signals for *{location}*:\n\n" + + "\n".join(f"• {b.replace('**', '')}" for b in bullets) + + (f"\n\nTop signal: {top_name} ({_rank_label(top)})." if top else "") + + "\n\n— MónAI Intelligence" + ), + }, + ], + ) + + +def build_forecast_report(trend: str, location: str, forecast: dict[str, Any]) -> dict[str, Any]: + signal_count = forecast.get("signal_count") + reasoning = str(forecast.get("reasoning") or forecast.get("summary") or "") + drivers = forecast.get("key_drivers") or [] + sources = _sources_from_payload(forecast.get("sources")) + + evidence_label = f"{signal_count} signals" if signal_count is not None else "Live search" + paragraphs = _paragraphs(reasoning) or [ + f"TinyFish search returned signals for *{trend}* in *{location}*.", + "Review linked sources and validate timing with your own store data.", + ] + + metrics = [ + {"label": "Trend", "value": trend}, + {"label": "Location", "value": location}, + ] + if signal_count is not None: + metrics.append({"label": "Signals", "value": str(signal_count)}) + + return _report_shell( + headline=f"{trend} — Adoption Signal Summary", + subtitle=f"Market: {location} · Evidence: {evidence_label}", + paragraphs=paragraphs, + metrics=metrics, + sections=[ + { + "title": "Source-derived drivers", + "bullets": [str(driver) for driver in drivers if driver], + }, + ] if drivers else [], + sources=sources, + actions=[ + "Review linked sources before changing procurement or menu plans.", + "Cross-check social mentions with your own sales data.", + ], + ready_to_use=[ + { + "label": "Internal product memo", + "text": ( + f"Subject: {trend} — adoption signals ({location})\n\n" + f"Team,\n\n" + f"TinyFish returned {evidence_label} for this trend.\n\n" + f"{paragraphs[0]}\n\n" + f"— Product Strategy" + ), + }, + { + "label": "Leadership update", + "text": ( + f"*{trend}* — {evidence_label} in *{location}*. " + f"{paragraphs[0]}" + ), + }, + ], + bullets=[ + "Use source excerpts below as evidence, not as a forecast timeline.", + "Confirm supplier availability before committing to an LTO.", + ], + ) + + +def build_regional_report(comparison: dict[str, Any]) -> dict[str, Any]: + region_a = comparison.get("region_a", "Region A") + region_b = comparison.get("region_b", "Region B") + category = comparison.get("category", "F&B") + lead_a = comparison.get("region_a_lead_trend", "—") + lead_b = comparison.get("region_b_lead_trend", "—") + summary = str(comparison.get("summary") or comparison.get("analysis") or "") + opportunities = comparison.get("expansion_opportunities") or [] + + paragraphs = _paragraphs(summary) if summary else [ + f"Side-by-side TinyFish search signals for *{category}* in *{region_a}* versus *{region_b}*.", + ] + + sections = [ + { + "title": f"{region_a} — local signals", + "paragraphs": _paragraphs(str(comparison.get("region_a_summary", ""))), + "bullets": [s.get("title", "") for s in (comparison.get("region_a_signals") or [])[:3] if s.get("title")], + }, + { + "title": f"{region_b} — local signals", + "paragraphs": _paragraphs(str(comparison.get("region_b_summary", ""))), + "bullets": [s.get("title", "") for s in (comparison.get("region_b_signals") or [])[:3] if s.get("title")], + }, + ] + + sources = _sources_from_payload(comparison.get("region_a_signals")) + _sources_from_payload( + comparison.get("region_b_signals") + ) + + return _report_shell( + headline=f"Regional Comparison — {region_a} vs {region_b}", + subtitle=f"Category: {category} · Lead trends: {lead_a} / {lead_b}", + paragraphs=paragraphs, + metrics=[ + {"label": "Region A", "value": str(region_a)}, + {"label": "Region B", "value": str(region_b)}, + {"label": "Category", "value": str(category)}, + {"label": "A lead trend", "value": str(lead_a)}, + {"label": "B lead trend", "value": str(lead_b)}, + ], + sections=sections, + sources=sources[:8], + bullets=opportunities if isinstance(opportunities, list) else [], + actions=[ + f"Compare lead signals between {region_a} ({lead_a}) and {region_b} ({lead_b}).", + "Use source links to validate whether a lead trend fits your brand.", + ], + ready_to_use=[ + { + "label": "Regional signal brief", + "text": ( + f"Subject: Regional search signals — {region_a} vs {region_b}\n\n" + f"**Category:** {category}\n" + f"**{region_a} lead:** {lead_a}\n" + f"**{region_b} lead:** {lead_b}\n\n" + f"{paragraphs[0]}\n\n" + f"— Expansion Team" + ), + }, + ], + ) + + +def build_menu_gap_report(analysis: dict[str, Any]) -> dict[str, Any]: + location = analysis.get("location", "your market") + missing = analysis.get("missing_opportunities") or [] + menu = analysis.get("current_menu_items") or [] + executive = analysis.get("executive_summary") or "" + + cards = [ + { + "title": str(item.get("trend", "Opportunity")), + "subtitle": f"{item.get('priority', 'Medium')} priority", + "body": str(item.get("recommendation") or item.get("evidence") or ""), + "tag": location, + } + for item in missing[:5] + ] + + bullets = [ + f"**[{item.get('priority', 'Medium')}]** {item.get('trend')}: {item.get('recommendation', item.get('evidence', ''))}" + for item in missing[:5] + ] + + paragraphs = _paragraphs(executive) if executive else [ + f"Compared **{len(menu)}** menu items against live TinyFish signals in *{location}*.", + f"Found **{len(missing)}** gaps where search signals do not overlap your current menu.", + ] + + sources = _sources_from_payload(analysis.get("trend_signals")) + + return _report_shell( + headline="Menu Gap Analysis", + subtitle=f"Location: {location} · {len(missing)} opportunities · {len(menu)} items reviewed", + paragraphs=paragraphs, + metrics=[ + {"label": "Menu items reviewed", "value": str(len(menu))}, + {"label": "Gap opportunities", "value": str(len(missing))}, + {"label": "High priority", "value": str(sum(1 for m in missing if m.get("priority") == "High"))}, + {"label": "Market", "value": str(location)}, + ], + sections=[ + { + "title": "Current menu baseline", + "bullets": menu[:10], + }, + ], + cards=cards, + bullets=bullets, + sources=sources, + actions=[ + "Review the highest-priority gap using the linked source evidence.", + "Compare recommendations against your current menu and margin targets.", + ], + ready_to_use=[ + { + "label": "Chef & R&D briefing", + "text": ( + f"Chef brief — menu gap review ({location})\n\n" + f"**Current core menu:**\n" + + "\n".join(f"• {item}" for item in menu) + + "\n\n**Gaps from live search:**\n" + + "\n".join(f"• {b.replace('**', '')}" for b in bullets) + ), + }, + ], + ) + + +def build_suppliers_report(trend: str, suppliers: list[dict[str, Any]]) -> dict[str, Any]: + top = suppliers[0] if suppliers else {} + cards = [ + { + "title": str(s.get("name", "Supplier")), + "subtitle": f"Search rank #{s.get('search_rank', '—')}", + "body": str(s.get("products_offered") or s.get("next_step") or ""), + "tag": trend, + } + for s in suppliers[:6] + ] + + bullets = [ + f"**{s.get('name')}** (rank #{s.get('search_rank', '—')}) — {s.get('products_offered', '')}" + for s in suppliers[:8] + ] + + return _report_shell( + headline=f"Supplier Shortlist — {trend}", + subtitle=f"{len(suppliers)} candidates ranked · Top pick: {top.get('name', '—')}", + paragraphs=[ + f"TinyFish search returned **{len(suppliers)}** supplier results for *{trend}*.", + "Use search rank and snippets below to shortlist outreach targets.", + ], + metrics=[ + {"label": "Trend", "value": trend}, + {"label": "Suppliers found", "value": str(len(suppliers))}, + {"label": "Top pick", "value": str(top.get("name", "—"))}, + {"label": "Top search rank", "value": f"#{top.get('search_rank', '—')}"}, + ], + cards=cards, + bullets=bullets, + sources=_sources_from_payload( + [{"title": s.get("name"), "url": s.get("contact_info"), "snippet": s.get("products_offered")} for s in suppliers] + ), + actions=[ + "Contact suppliers using the RFQ tab when you are ready to source.", + "Request quotes and sample policy from the highest-ranked results first.", + ], + ready_to_use=[ + { + "label": "Procurement outreach (short)", + "text": ( + f"Hello,\n\n" + f"We are reviewing suppliers for **{trend}**.\n\n" + f"Could you share MOQ, tiered pricing, sample policy, and delivery capacity?\n\n" + f"Best regards" + ), + }, + { + "label": "Internal sourcing memo", + "text": ( + f"Sourcing memo — {trend}\n\n" + f"TinyFish returned {len(suppliers)} supplier results. " + f"Top listed result: **{top.get('name', '—')}**.\n\n" + + "\n".join(f"• {b.replace('**', '')}" for b in bullets[:5]) + ), + }, + ], + ) + + +def build_outreach_report(rfq: dict[str, Any]) -> dict[str, Any]: + subject_en = rfq.get("subject_en", "RFQ — Ingredient supply") + body_en = rfq.get("body_en", "") + subject_vi = rfq.get("subject_vi", "Yêu cầu báo giá") + body_vi = rfq.get("body_vi", "") + + return _report_shell( + headline="RFQ & Supplier Outreach", + subtitle="Bilingual, send-ready templates · Personalize quantities before sending", + paragraphs=[ + "Bilingual RFQ templates generated from your product needs and supplier details.", + "Personalize quantities and delivery terms before sending.", + ], + sections=[ + { + "title": "What to ask for", + "bullets": [ + "Bulk pricing tiers (FOB and delivered)", + "MOQ and reorder lead time", + "Sample policy and certification (if applicable)", + "Payment terms and monthly capacity", + ], + }, + ], + actions=[ + "Send the RFQ to your shortlisted suppliers.", + "Log responses in your procurement tracker.", + ], + ready_to_use=[ + {"label": "Email — English", "text": f"Subject: {subject_en}\n\n{body_en}"}, + {"label": "Email — Tiếng Việt", "text": f"Subject: {subject_vi}\n\n{body_vi}"}, + { + "label": "Follow-up", + "text": ( + f"Subject: Re: {subject_en}\n\n" + f"Hi,\n\nFollowing up on our RFQ below. " + f"Could you confirm MOQ and pricing tiers when convenient?\n\n" + f"Thank you" + ), + }, + ], + ) diff --git a/monai/app/services/response_parser.py b/monai/app/services/response_parser.py new file mode 100644 index 000000000..cedcb4f5f --- /dev/null +++ b/monai/app/services/response_parser.py @@ -0,0 +1,69 @@ +"""Parse and normalize LLM / API responses into structured JSON.""" + +import json +import re +from typing import Any + +FABRICATED_METRIC_KEYS = frozenset( + { + "confidence_score", + "projected_mainstream_days", + "timeline", + "growth_rate", + "adoption_score", + "suitability_score", + } +) + + +def parse_llm_json(text: str) -> Any: + """Extract JSON from raw LLM text, including ```json fenced blocks.""" + if not isinstance(text, str): + return text + + cleaned = text.strip() + fence_match = re.match(r"^```(?:json)?\s*\n?(.*?)\n?```\s*$", cleaned, re.DOTALL | re.IGNORECASE) + if fence_match: + cleaned = fence_match.group(1).strip() + + if cleaned.startswith("```"): + lines = cleaned.splitlines() + if lines and lines[0].startswith("```"): + lines = lines[1:] + if lines and lines[-1].strip() == "```": + lines = lines[:-1] + cleaned = "\n".join(lines).strip() + + try: + return json.loads(cleaned) + except json.JSONDecodeError: + return {"raw_text": text} + + +def normalize_payload(value: Any) -> Any: + """Recursively unwrap JSON strings embedded in API payloads.""" + if isinstance(value, str): + stripped = value.strip() + if stripped.startswith("{") or stripped.startswith("[") or stripped.startswith("```"): + parsed = parse_llm_json(stripped) + if parsed != {"raw_text": value}: + return normalize_payload(parsed) + return value + if isinstance(value, list): + return [normalize_payload(item) for item in value] + if isinstance(value, dict): + return {key: normalize_payload(item) for key, item in value.items()} + return value + + +def strip_fabricated_metrics(value: Any) -> Any: + """Remove invented scores/timelines from LLM payloads.""" + if isinstance(value, list): + return [strip_fabricated_metrics(item) for item in value] + if isinstance(value, dict): + return { + key: strip_fabricated_metrics(item) + for key, item in value.items() + if key not in FABRICATED_METRIC_KEYS + } + return value diff --git a/monai/app/services/search_analysis.py b/monai/app/services/search_analysis.py new file mode 100644 index 000000000..4d00247da --- /dev/null +++ b/monai/app/services/search_analysis.py @@ -0,0 +1,1048 @@ +"""Structure TinyFish Search/Fetch responses for API routes (no LLM required).""" + +import json +import re +import unicodedata + +GENERIC_TITLE_RE = re.compile( + r"\b(" + r"innovative|solutions?|industry|ingredients?|resilient|thriving|" + r"expansion|digital content|market report|f&b industry|sector|economy|" + r"beverage solutions|natural ingredients|content creation|" + r"delivery service|giao hàng|đặt món|order đồ ăn|đồ ăn in|" + r"hot trend trên|món ăn hot trend|world of|thế giới" + r")\b", + re.I, +) + +EDITORIAL_TITLE_RE = re.compile( + r"(?:^|\b)(thế giới|world of|khám phá|discover)\b" + r"|(?:hot trend|đang hot|món ăn hot trend)\b.*\b(trên|tại|in|at)\b" + r"|^top\s+món\b" + r"|(?:order|đặt)\s+.+?(?:delivery|giao)" + r"|delivery\s+service" + r"|&\s*delivery", + re.I, +) + +PLATFORM_LISTICLE_RE = re.compile( + r"^\s*top\s+.+?(?:hot trend|trending).*(?:shopee|grab|foody|tiktok)", + re.I, +) + +PUBLISHER_SUFFIX_RE = re.compile( + r"\b(instagram|tiktok|youtube|facebook|shopee\s*food|grab\s*food|foody|reels?|shorts?)\b", + re.I, +) + +JUNK_LABEL_RE = re.compile( + r"(https?://|www\.|watch\?v=)" + r"|→|->" + r"|^\s*from\s" + r"|\babout this epis" + r"|\.com/?" + r"|\.(net|org|vn|io)\b" + r"|\binstagram\b" + r"|\btiktok\b", + re.I, +) + +VLOG_TITLE_RE = re.compile( + r"\$\d" + r"|\bmenu in\b" + r"|china town" + r"|street food.*vietnam" + r"|street food streets?\b" + r"|top\s*10\b" + r"|\btop\s+\d+\s+best\b" + r"|!\s*!", + re.I, +) + +CAPTION_TITLE_RE = re.compile( + r"^\s*ping\b" + r"|\bđừng bỏ lỡ\b" + r"|\bđừng bỏ\b" + r"|\bly trà\b" + r"|\bkhám phá\b" + r"|\bthưởng thức\b" + r"|\btrải nghiệm\b" + r"|\bmón ăn mới\b" + r"|\bphiên\b" + r"|\bpha chế\b" + r"|\bpha che\b" + r"|\bfollow\b" + r"|\bsubscribe\b" + r"|\bvà lớp\b" + r"|\bthơm lừng\b", + re.I, +) + +DESCRIPTIVE_LABEL_RE = re.compile( + r"\b(thơm|ngon|lừng|đặc biệt)\b.+\b(kem|sữa|trà)\b" + r"|\bvà lớp kem\b", + re.I, +) + +LOCATION_IN_LABEL_RE = re.compile( + r"\b(ho chi minh|hồ chí minh|tp\.?hcm|sài gòn|saigon|vietnam|hà nội|ha noi)\b" + r"|\b(streets?|district|quận)\b", + re.I, +) + +STRONG_DISH_RE = re.compile( + r"\b(banh|bánh|pho|phở|matcha|cà phê|ca phe|salt coffee|bún|bun bo|bánh mì|banh mi|" + r"latte|smoothie|trà sữa|tra sua|khot|kem chuối|xiu mai|spring roll|bubble)\b", + re.I, +) + +TRENDY_SINGLE_WORDS = frozenset( + {"matcha", "pho", "phở", "latte", "pizza", "burger", "khot", "donut", "doughnut"} +) + +GENERIC_STAPLE_PHRASES = frozenset( + { + "cà phê", + "ca phe", + "coffee", + "tea", + "tra", + "trà", + "cơm", + "com", + "banh", + "bánh", + "bun", + "bún", + "mi", + "mì", + "kem", + "che", + "chè", + } +) + +COMPOUND_DISH_PHRASES = frozenset( + { + "banh mi", + "bánh mì", + "bun bo", + "bún bò", + "banh khot", + "bánh khot", + "banh xeo", + "bánh xèo", + "ca phe", + "cà phê", + "salt coffee", + "tra sua", + "trà sữa", + "milk tea", + } +) + +CANONICAL_DISH_LABELS = { + "pho": "Phở", + "phở": "Phở", + "banh mi": "Bánh Mì", + "bun bo": "Bún Bò", + "bánh mì": "Bánh Mì", + "bún bò": "Bún Bò", + "banh khot": "Bánh Khot", + "bánh khot": "Bánh Khot", + "banh xeo": "Bánh Xèo", + "bánh xèo": "Bánh Xèo", + "ca phe": "Cà Phê", + "cà phê": "Cà Phê", + "tra sua": "Trà Sữa", + "trà sữa": "Trà Sữa", +} + +EMOJI_RE = re.compile(r"[\U0001F300-\U0001FAFF\u2600-\u27BF]+") + +HASHTAG_RE = re.compile(r"#\w+", re.UNICODE) + +ENGLISH_DESC_RE = re.compile( + r"\b(crispy|stuffed|baguette|sandwich|delicious|amazing|with|recipe|review)\b", + re.I, +) + +DISH_SEPARATOR_RE = re.compile(r"\s[-–—|·]\s") + +MULTI_DISH_AND_RE = re.compile( + r"(?:best|top)\s+(.+?)\s+and\s+(.+)", + re.I, +) + +PLAIN_AND_DISH_RE = re.compile( + r"([\wÀ-ỹ][\wÀ-ỹ'’\s-]{1,40}?)\s+(?:and|và)\s+([\wÀ-ỹ][\wÀ-ỹ'’\s-]{1,40})", + re.I, +) + +DOMAIN_RE = re.compile(r"\b[\w-]+\.(com|net|org|vn|io|co)(?:/\S*)?", re.I) + +LISTICLE_TITLE_RE = re.compile( + r"^\s*(?:best|top)\s+(.+?)\s+(?:spots?|places?|restaurants?|cafés?|cafes?)\s+in\b" + r"|^\s*top\s+món\s+ăn\b" + r"|^\s*top\s+.+?\s+hot trend\b" + r"|^\s*\d+\s+street food\b" + r"|street food streets?\b" + r"|^\s*top\s*\d+\s+best\b" + r"|^\s*(?:vietnamese\s+)?top\s*\d+\s+best\b" + r"|^\s*\d+\s+must[- ]try\b", + re.I, +) + +FOOD_HINT_RE = re.compile( + r"\b(" + r"pho|banh|banh mi|bánh mì|banh khot|bánh khot|bun bo|bún bò|banh xeo|" + r"ca phe|cà phê|salt coffee|coffee|tea|matcha|tra|trà|" + r"com|cơm|bun|bún|mi|mì|kem|che|chè|" + r"latte|smoothie|bubble|milk tea|sua|sữa|trà sữa|tra sua|cacao|chocolate|" + r"donut|doughnut|" + r"coconut|yogurt|chicken|ga|gà|pizza|burger|hot pot|lau|lẩu|" + r"korean|k-food|yangnyeom|fried chicken|xiu mai|spring roll" + r")\b", + re.I, +) + + +def _strip_urls_and_domains(text: str) -> str: + text = EMOJI_RE.sub(" ", text or "") + text = HASHTAG_RE.sub(" ", text) + text = re.sub(r"https?://\S+", " ", text) + text = DOMAIN_RE.sub(" ", text) + return re.sub(r"\s+", " ", text).strip() + + +def _food_hint_search_text(text: str) -> str: + return _strip_urls_and_domains(text).replace("-", " ") + + +def _has_food_hint(text: str) -> bool: + return bool(FOOD_HINT_RE.search(_food_hint_search_text(text))) + + +def _food_hint_matches(text: str) -> list[re.Match[str]]: + return list(FOOD_HINT_RE.finditer(_food_hint_search_text(text))) + + +def _clean_trend_name(title: str) -> str: + cleaned = re.sub(r"\s*[\|·]\s*.*$", "", title or "").strip() + cleaned = re.sub(r"^\d+\.\s*", "", cleaned) + cleaned = re.sub(r"https?://\S+", "", cleaned).strip() + + suffix = re.search(r"\s+[-–—]\s+(.+)$", cleaned) + if suffix: + left = cleaned[: suffix.start()].strip() + right = suffix.group(1).strip() + left_food = _has_food_hint(left) + right_food = _has_food_hint(right) + if PUBLISHER_SUFFIX_RE.search(right) or (left_food and not right_food): + cleaned = left + elif right_food and not left_food: + cleaned = right + + return cleaned[:80] or "" + + +def _is_too_generic_label(label: str) -> bool: + text = _finalize_label(label) + folded = text.casefold() + if folded in GENERIC_STAPLE_PHRASES: + return True + if folded in COMPOUND_DISH_PHRASES: + return False + words = text.split() + if len(words) >= 2: + if words[-1].casefold() in GENERIC_STAPLE_PHRASES: + return True + if all(word.casefold() in GENERIC_STAPLE_PHRASES for word in words): + return True + if len(words) != 1: + return False + if folded in TRENDY_SINGLE_WORDS: + return False + if STRONG_DISH_RE.search(text): + return False + return _has_food_hint(text) + + +def _is_descriptive_label(label: str) -> bool: + if CAPTION_TITLE_RE.search(label): + return True + if DESCRIPTIVE_LABEL_RE.search(label): + return True + if not _has_food_hint(label): + return False + if STRONG_DISH_RE.search(label): + return False + return len(label.split()) >= 4 + + +def _is_generic_headline(title: str) -> bool: + if not title: + return False + if LISTICLE_TITLE_RE.search(title): + return True + if CAPTION_TITLE_RE.search(title): + return True + if EDITORIAL_TITLE_RE.search(title): + return True + if PLATFORM_LISTICLE_RE.search(title): + return True + if GENERIC_TITLE_RE.search(title): + return True + if VLOG_TITLE_RE.search(title): + return True + return False + + +def _primary_food_term(text: str) -> str | None: + matches = _food_hint_matches(text) + if not matches: + return None + ranked = sorted(matches, key=lambda match: len(match.group(0)), reverse=True) + for match in ranked: + term = _finalize_label(match.group(0)) + if not term or _is_junk_label(term): + continue + presented = _present_trend_label(term) + if presented: + return presented + return None + + +def _label_from_colon_title(title: str) -> str | None: + if ":" not in title: + return None + left, right = title.rsplit(":", 1) + candidates: list[str] = [] + for part in (left, right): + dish = _finalize_label(part) + if not dish or DOMAIN_RE.search(dish) or JUNK_LABEL_RE.search(dish): + continue + if dish and _has_food_hint(dish) and not _is_junk_label(dish): + presented = _present_trend_label(dish) + if presented: + candidates.append(presented) + if not candidates: + return None + return min( + candidates, + key=lambda name: (0 if STRONG_DISH_RE.search(name) else 1, len(name.split()), len(name)), + ) + + +def _label_from_hashtags(text: str) -> str | None: + if not HASHTAG_RE.search(text): + return None + for tag in HASHTAG_RE.findall(text): + term = tag.lstrip("#").replace("_", " ") + if term and _has_food_hint(term) and len(term) >= 3: + presented = _present_trend_label(term) + if presented: + return presented + return _primary_food_term(text) + + +def _label_from_multi_dish_and(text: str) -> str | None: + cleaned = _strip_urls_and_domains(text) + best_label: str | None = None + for match in MULTI_DISH_AND_RE.finditer(cleaned): + first = _finalize_label(match.group(1)) + if first and _has_food_hint(first) and not _is_junk_label(first) and len(first.split()) <= 5: + best_label = _present_trend_label(first) or best_label + return best_label + + +def _label_from_comma_segments(text: str) -> str | None: + cleaned = _strip_urls_and_domains(text) + if "," not in cleaned: + return None + segments = [ + re.sub(r"\s+(?:và|and)\s*$", "", seg.strip(), flags=re.I) + for seg in cleaned.split(",") + ] + candidates = [ + seg + for seg in (_finalize_label(part) for part in segments) + if seg and _has_food_hint(seg) and not _is_junk_label(seg) and len(seg.split()) <= 5 + ] + if not candidates: + return None + best = min( + candidates, + key=lambda seg: (0 if STRONG_DISH_RE.search(seg) else 1, len(seg.split()), len(seg)), + ) + return _present_trend_label(best) + + +def _label_from_plain_and_list(text: str) -> str | None: + cleaned = _strip_urls_and_domains(text) + best_label: str | None = None + for match in PLAIN_AND_DISH_RE.finditer(cleaned): + first = _finalize_label(match.group(1)) + if first and _has_food_hint(first) and not _is_junk_label(first) and len(first.split()) <= 5: + best_label = _present_trend_label(first) or best_label + if best_label: + return best_label + if re.search(r"\s+và\s+", cleaned, re.I): + first = re.split(r"\s+và\s+", cleaned, maxsplit=1, flags=re.I)[0].strip(" .,;:-—–|") + hyphen = re.search(r"[\wÀ-ỹ]+(?:-[\wÀ-ỹ]+)+", first) + candidate = hyphen.group(0) if hyphen else first + if candidate and _has_food_hint(candidate) and not _is_junk_label(candidate): + return _present_trend_label(candidate) + return None + + +def _label_from_dash_segments(title: str) -> str | None: + if not re.search(r"\s[-–—]\s", title): + return None + segments = [_finalize_label(part) for part in re.split(r"\s[-–—]\s", title)] + candidates = [ + seg + for seg in segments + if seg and _has_food_hint(seg) and not _is_junk_label(seg) and len(seg.split()) <= 6 + ] + if not candidates: + return None + best = min(candidates, key=lambda seg: (len(seg.split()), len(seg))) + return _present_trend_label(best) + + +def _trim_to_dish_head(label: str) -> str: + parts = DISH_SEPARATOR_RE.split(label or "", maxsplit=1) + if len(parts) < 2: + return label or "" + left, right = parts[0].strip(), parts[1].strip() + if not left or not right: + return label or "" + if STRONG_DISH_RE.search(left) or (_has_food_hint(left) and len(left.split()) <= 4): + if ENGLISH_DESC_RE.search(right) or len(right.split()) > 4: + return left + return label or "" + + +def _finalize_label(label: str) -> str: + text = _trim_to_dish_head(label) + text = EMOJI_RE.sub(" ", text or "") + text = HASHTAG_RE.sub(" ", text) + text = re.sub(r"https?://\S+", "", text) + text = re.sub(r"\s+(?:và|and)\s*$", "", text, flags=re.I) + text = re.sub(r"\s+", " ", text).strip(" .,;:-→|") + return text[:72] + + +def _present_trend_label(label: str) -> str | None: + """Normalize and capitalize the first letter for chalkboard display.""" + text = _finalize_label(label) + if not text or _is_junk_label(text): + return None + canonical = CANONICAL_DISH_LABELS.get(text.casefold()) + if canonical: + return canonical + return text[0].upper() + text[1:] + + +def _is_junk_label(label: str) -> bool: + if not label or len(label) < 3: + return True + if HASHTAG_RE.search(label): + return True + if ( + len(label) <= 3 + and label.casefold() not in TRENDY_SINGLE_WORDS + and not STRONG_DISH_RE.search(label) + ): + return True + if _is_too_generic_label(label): + return True + if JUNK_LABEL_RE.search(label): + return True + if re.search(r"\babout\b", label, re.I): + return True + if CAPTION_TITLE_RE.search(label): + return True + if PUBLISHER_SUFFIX_RE.search(label) and len(label.split()) >= 3: + return True + if len(label.split()) >= 6 and _has_food_hint(label) and not STRONG_DISH_RE.search(label): + return True + if DESCRIPTIVE_LABEL_RE.search(label): + return True + if _is_descriptive_label(label): + return True + if LOCATION_IN_LABEL_RE.search(label): + return True + if re.search(r"\b(viral|trending|review|keywords)\b", label, re.I) and len(label.split()) >= 2: + return True + if re.search( + r"^(?:coffee|tea|ca phe|cà phê|tra|trà|cơm|com|kem|che|chè)\s+\w+", + label, + re.I, + ): + return True + if EDITORIAL_TITLE_RE.search(label): + return True + if VLOG_TITLE_RE.search(label): + return True + if PLATFORM_LISTICLE_RE.search(label): + return True + if MULTI_DISH_AND_RE.search(label) and len(_food_hint_matches(label)) >= 2: + return True + if PLAIN_AND_DISH_RE.search(label) and len(_food_hint_matches(label)) >= 2: + return True + if re.search(r"\s+(?:và|and)\s*$", label, re.I): + return True + if "," in label and len(_food_hint_matches(label)) >= 2: + return True + if label.count(",") >= 3: + return True + if LISTICLE_TITLE_RE.search(label): + return True + if len(label) > 48: + return True + if len(label) > 55 and label.count(",") >= 2: + return True + if len(label.split()) > 9: + return True + if re.search(r"\s[-–—|·]\s", label) and ENGLISH_DESC_RE.search(label): + return True + if len(label) > 24 and re.search(r"\s[a-z]$", label, re.I): + return True + if re.search(r"\s[-–—]\s", label) and len(_food_hint_matches(label)) >= 2: + return True + return False + + +def _label_from_listicle_title(title: str) -> str | None: + match = LISTICLE_TITLE_RE.search(title) + if not match: + return None + if match.lastindex is not None: + subject = _finalize_label(match.group(1)) + if subject and _has_food_hint(subject) and not _is_junk_label(subject): + presented = _present_trend_label(subject) + if presented: + return presented + return _primary_food_term(title) + + +def _first_dish_from_list_snippet(snippet: str) -> str: + if not re.match(r"^\s*from\s", snippet, re.I): + return "" + tail = re.sub(r"^\s*from\s+", "", snippet, flags=re.I) + first = re.split(r"[,.\n]", tail, maxsplit=1)[0].strip() + if first and _has_food_hint(first) and len(first.split()) <= 5: + return _present_trend_label(first) or "" + return "" + + +def _publisher_label(result: dict) -> str: + raw = (result.get("site_name") or result.get("url") or "").casefold() + if "instagram" in raw: + return "Instagram" + if "tiktok" in raw: + return "TikTok" + if "youtube" in raw: + return "YouTube" + if "facebook" in raw: + return "Facebook" + if "grabfood" in raw or "food.grab" in raw: + return "GrabFood" + if "shopeefood" in raw: + return "ShopeeFood" + if "foody" in raw: + return "Foody" + site = (result.get("site_name") or "").strip() + if site and not site.startswith("http"): + return site + url = (result.get("url") or "").strip() + if url: + host = re.sub(r"^https?://(www\.)?", "", url).split("/")[0] + if host.startswith("www."): + host = host[4:] + return host or "web" + return "web" + + +def _is_weak_title(title: str) -> bool: + if not title: + return True + words = title.split() + if not words: + return True + if len(words) == 1 and not _has_food_hint(title): + return True + weak_titles = { + "episode", + "video", + "reel", + "post", + "watch", + "menu", + "home", + "viral", + "trending", + "food", + "update", + } + lowered = title.casefold() + if lowered in weak_titles: + return True + if _finalize_label(title).casefold() in GENERIC_STAPLE_PHRASES: + return True + if len(words) <= 2 and words[0].casefold() in weak_titles: + return True + return False + + +def _phrase_around_food_hint(text: str) -> str: + text = _strip_urls_and_domains(text) + text = re.sub(r"\babout this episode\b", " ", text, flags=re.I) + text = re.sub(r"\s*->\s*", " ", text) + + if re.match(r"^\s*from\s", text, re.I): + list_label = _first_dish_from_list_snippet(text) + if list_label: + return list_label + stripped = re.sub(r"^\s*from\s+[^,.\n]+[,.\n]\s*", "", text, count=1, flags=re.I) + if stripped and stripped != text: + text = stripped + + multi = _label_from_multi_dish_and(text) + if multi: + return multi + plain = _label_from_plain_and_list(text) + if plain: + return plain + + matches = _food_hint_matches(text) + if not matches: + return "" + match = matches[-1] + if len(match.group(0)) < 3: + return "" + start = max(0, match.start() - 20) + end = min(len(text), match.end() + 28) + phrase = text[start:end].strip(" .,;:-→|!") + phrase = PUBLISHER_SUFFIX_RE.sub("", phrase) + phrase = re.sub(r"\bInstagram:\s*$", "", phrase, flags=re.I).strip(" :,-") + phrase = re.sub(r"\s+", " ", phrase).strip() + words = phrase.split() + while words and (len(words[0]) <= 2 or words[0].casefold() in {"et", "top", "best", "g"}): + words = words[1:] + phrase = " ".join(words) + multi = _label_from_multi_dish_and(phrase) + if multi: + return multi + plain = _label_from_plain_and_list(phrase) + if plain: + return plain + if len(phrase) < 4 or _is_junk_label(phrase): + return "" + return phrase[:72] + + +def _trend_label_from_result(result: dict) -> str | None: + raw_title = result.get("title", "") or "" + title = _clean_trend_name(raw_title) + snippet = result.get("snippet") or "" + + for candidate_title in (title, raw_title): + if not candidate_title: + continue + for extractor in ( + _label_from_listicle_title, + _label_from_colon_title, + _label_from_hashtags, + _label_from_dash_segments, + _label_from_multi_dish_and, + _label_from_plain_and_list, + _label_from_comma_segments, + ): + label = extractor(candidate_title) + if label: + return label + + if title and _is_generic_headline(title): + keyword = _primary_food_term(title) + if keyword: + return keyword + + if ( + title + and ":" not in title + and not HASHTAG_RE.search(title) + and not GENERIC_TITLE_RE.search(title) + and not _is_generic_headline(title) + and not _is_junk_label(title) + and not _is_weak_title(title) + ): + if _has_food_hint(title) and title.count(",") <= 1 and len(title.split()) <= 8: + title_label = _present_trend_label(title) + if title_label: + return title_label + + snippet_label = _phrase_around_food_hint(snippet) + snippet_presented = None + if snippet_label: + snippet_presented = _present_trend_label(snippet_label) + if not snippet_presented: + snippet_presented = _primary_food_term(snippet_label) + if not snippet_presented and snippet: + snippet_presented = _primary_food_term(snippet) + if snippet_presented: + return snippet_presented + + if ( + title + and _has_food_hint(snippet) + and len(title) <= 45 + and not GENERIC_TITLE_RE.search(title) + and not _is_generic_headline(title) + and not _is_junk_label(title) + and not _is_weak_title(title) + and title.count(",") <= 1 + ): + title_label = _present_trend_label(title) + if title_label: + return title_label + + return None + + +def build_emerging_search_queries(location: str, category: str) -> list[str]: + """Build TinyFish queries for emerging trends based on location and category.""" + market = location.strip() + focus = (category or "food and beverage").strip() + focus_lower = focus.casefold() + + platforms = "Vietnam TikTok GrabFood ShopeeFood Foody menu" + + if "food and beverage" in focus_lower or focus_lower in {"f&b", "fb"}: + return [ + f"món viral đồ uống cà phê trà matcha {market} TikTok GrabFood", + f"viral trending street food dishes {market} Vietnam ShopeeFood Foody menu", + f"món ăn đường phố viral {market} Vietnam Foody ShopeeFood", + ] + + if focus_lower in {"beverage", "beverages", "drinks", "drink"} or ( + "beverage" in focus_lower and "food" not in focus_lower + ): + return [ + f"viral trending {focus} coffee tea matcha {market} {platforms}", + f"viral trending {focus} bubble milk tea drinks {market} {platforms}", + ] + + if "food" in focus_lower: + dish_term = "dishes" if "street food" in focus_lower else "street food dishes" + return [ + f"viral trending {focus} {dish_term} {market} {platforms}", + f"viral {focus} menu {market} trending", + ] + + return [ + f"viral trending {focus} {market} {platforms}", + f"viral trending {focus} menu items {market} trending", + ] + + +def _merge_dedup_key(result: dict) -> str: + url = (result.get("url") or "").strip() + if url: + return f"url:{url}" + title = re.sub(r"\s+", " ", (result.get("title") or "").strip().casefold()) + snippet = re.sub(r"\s+", " ", (result.get("snippet") or "").strip().casefold())[:160] + return f"text:{title}|{snippet}" + + +def merge_search_results(*datasets: dict) -> dict: + seen_keys: set[str] = set() + merged: list[dict] = [] + for data in datasets: + for result in data.get("results", []): + key = _merge_dedup_key(result) + if key in seen_keys: + continue + seen_keys.add(key) + merged.append(result) + merged.sort(key=lambda item: item.get("position") or 999) + return {"results": merged, "total_results": len(merged)} + + +def _sources_from_results(data: dict, limit: int = 5) -> list[dict]: + return [ + { + "title": r.get("title"), + "excerpt": r.get("snippet"), + "url": r.get("url"), + "publisher": r.get("site_name"), + } + for r in data.get("results", [])[:limit] + ] + + +def _trend_name_key(name: str) -> str: + folded = unicodedata.normalize("NFD", name or "") + stripped = "".join(ch for ch in folded if unicodedata.category(ch) != "Mn") + return re.sub(r"\s+", " ", stripped).casefold().strip() + + +def emerging_trends_from_search(data: dict, location: str, limit: int = 5) -> list[dict]: + candidates: list[dict] = [] + seen_names: set[str] = set() + for result in data.get("results", []): + name = _trend_label_from_result(result) + if not name: + continue + name_key = _trend_name_key(name) + if name_key in seen_names: + continue + seen_names.add(name_key) + serp_rank = result.get("position") or 999 + candidates.append( + { + "result": result, + "name": name, + "serp_rank": serp_rank, + } + ) + + candidates.sort(key=lambda item: item["serp_rank"]) + + trends = [] + for display_rank, item in enumerate(candidates[:limit], start=1): + result = item["result"] + snippet = result.get("snippet") or "" + publisher = _publisher_label(result) + serp_rank = item["serp_rank"] if item["serp_rank"] != 999 else None + trend: dict = { + "trend_name": item["name"], + "display_rank": display_rank, + "description": snippet, + "why_it_matters": ( + f"Listed at position {display_rank} from live TinyFish search in {location} " + f"({publisher})." + ), + "source_url": result.get("url"), + "publisher": publisher, + "region": location, + } + if serp_rank is not None: + trend["search_rank"] = serp_rank + trends.append(trend) + return trends + + +def normalize_emerging_trends(trends: list, location: str, limit: int = 5) -> list[dict]: + """Kept for compatibility — ranks are assigned from response order only.""" + normalized: list[dict] = [] + for index, item in enumerate(trends[:limit]): + if not isinstance(item, dict): + continue + entry = {**item, "region": item.get("region") or location} + if entry.get("display_rank") is None and entry.get("search_rank") is None: + entry["display_rank"] = index + 1 + normalized.append(entry) + return normalized + + +def forecast_from_search(data: dict, trend_name: str, location: str) -> dict: + sources = _sources_from_results(data, 5) + count = len(sources) + + reasoning_parts = [] + if sources: + reasoning_parts.append( + f"TinyFish Search returned **{count}** relevant signals for *{trend_name}* in *{location}*." + ) + top = sources[0] + if top.get("excerpt"): + reasoning_parts.append( + f"Top result: *{top.get('title')}* ({top.get('publisher', 'web')}) — {top.get('excerpt')}" + ) + reasoning_parts.append( + "Review the linked sources below and validate adoption timing with your own POS and store data." + ) + else: + reasoning_parts.append( + f"No public web signals were returned for *{trend_name}* in {location}. " + f"Try a broader trend label or add competitor URLs to menu-gap analysis." + ) + + return { + "trend": trend_name, + "location": location, + "signal_count": count, + "reasoning": " ".join(reasoning_parts), + "key_drivers": [s.get("title") for s in sources[:3] if s.get("title")], + "sources": sources, + } + + +def regional_comparison_from_search( + region_a: str, + region_b: str, + data_a: dict, + data_b: dict, + category: str, +) -> dict: + signals_a = _sources_from_results(data_a, 5) + signals_b = _sources_from_results(data_b, 5) + + def lead_signal(signals: list[dict]) -> str: + if not signals: + return "No dominant signal" + return _clean_trend_name(signals[0].get("title") or "") + + lead_a = lead_signal(signals_a) + lead_b = lead_signal(signals_b) + + summary = ( + f"*{region_a}* top TinyFish signal: **{lead_a}**. " + f"*{region_b}* top TinyFish signal: **{lead_b}** ({category}). " + f"Use these live search leaders to localize LTOs per city cluster." + ) + + return { + "category": category, + "region_a": region_a, + "region_b": region_b, + "region_a_signals": signals_a, + "region_b_signals": signals_b, + "region_a_lead_trend": lead_a, + "region_b_lead_trend": lead_b, + "summary": summary, + "region_a_summary": ( + f"{region_a}: {signals_a[0]['excerpt']}" if signals_a else f"{region_a}: limited signal." + ), + "region_b_summary": ( + f"{region_b}: {signals_b[0]['excerpt']}" if signals_b else f"{region_b}: limited signal." + ), + "expansion_opportunities": [ + f"Review whether {lead_b} appears in {region_a} search signals.", + f"Review whether {lead_a} appears in {region_b} search signals.", + f"Compare {category} source excerpts between {region_a} and {region_b}.", + ], + } + + +def menu_gap_from_search( + current_menu: list[str], + location: str, + trends_data: dict, + competitor_menus: list[dict], +) -> dict: + trend_signals = _sources_from_results(trends_data, 8) + menu_words: set[str] = set() + for item in current_menu: + menu_words.update(w for w in re.split(r"\W+", item.lower()) if len(w) > 3) + + missing = [] + for index, result in enumerate(trends_data.get("results", [])[:8]): + signal = { + "title": result.get("title"), + "excerpt": result.get("snippet"), + "url": result.get("url"), + "publisher": result.get("site_name"), + } + title = _clean_trend_name(signal.get("title") or "") + title_lower = title.lower() + title_words = {w for w in re.split(r"\W+", title_lower) if len(w) > 3} + overlap = title_words & menu_words + if title and len(overlap) < max(1, len(title_words) // 3): + list_rank = index + 1 + serp_rank = result.get("position") + rank = serp_rank if serp_rank is not None else list_rank + priority = "High" if rank <= 2 else "Medium" if rank <= 5 else "Low" + gap: dict = { + "trend": title, + "priority": priority, + "evidence": signal.get("excerpt"), + "source_url": signal.get("url"), + "competitor_adoption": signal.get("excerpt") or "See linked source.", + "recommendation": ( + f"Evaluate *{title}* for {location} using the linked search evidence." + ), + } + if serp_rank is not None: + gap["search_rank"] = serp_rank + missing.append(gap) + + competitor_summary = [ + f"{c.get('title') or c.get('url')}" for c in competitor_menus if c.get("title") or c.get("url") + ] + + return { + "location": location, + "current_menu_items": current_menu, + "missing_opportunities": missing[:5], + "competitor_menus": competitor_menus, + "competitor_summary": competitor_summary, + "trend_signals": trend_signals, + "executive_summary": ( + f"Compared {len(current_menu)} menu items against {len(trend_signals)} live TinyFish signals " + f"in {location}. Found {len(missing[:5])} actionable gaps." + ), + } + + +def suppliers_from_search(data: dict) -> list[dict]: + suppliers = [] + for index, result in enumerate(data.get("results", [])[:10], start=1): + rank = result.get("position") + supplier: dict = { + "name": result.get("site_name") or _clean_trend_name(result.get("title") or ""), + "contact_info": result.get("url"), + "products_offered": result.get("snippet"), + "next_step": "Review this search result and contact if relevant.", + } + if rank is not None: + supplier["search_rank"] = rank + suppliers.append(supplier) + return suppliers + + +def outreach_template(supplier_info: str, product_needs: str) -> dict: + product_label = product_needs.split(",")[0].strip() or product_needs.strip() + + body_en = ( + f"Dear Supplier Partner,\n\n" + f"I hope this message finds you well. We are expanding our F&B product line and are " + f"currently sourcing the following:\n\n" + f"**Product requirements:** {product_needs}\n\n" + f"**Supplier under review:** {supplier_info}\n\n" + f"Could you please provide:\n" + f"• Bulk pricing tiers (FOB and delivered)\n" + f"• Minimum order quantity (MOQ)\n" + f"• Sample policy and lead time\n" + f"• Monthly delivery capacity and payment terms\n\n" + f"We are evaluating partners and would appreciate a response at your earliest convenience.\n\n" + f"Best regards" + ) + + body_vi = ( + f"Kính gửi Quý Nhà Cung Cấp,\n\n" + f"Chúng tôi đang mở rộng danh mục F&B và cần tìm nguồn cung ổn định cho:\n\n" + f"**Yêu cầu sản phẩm:** {product_needs}\n\n" + f"**Thông tin NCC:** {supplier_info}\n\n" + f"Quý công ty vui lòng gửi:\n" + f"• Bảng giá sỉ (FOB và giao tận nơi)\n" + f"• MOQ (số lượng đặt hàng tối thiểu)\n" + f"• Chính sách mẫu và thời gian cung mẫu\n" + f"• Năng lực giao hàng hàng tháng và điều khoản thanh toán\n\n" + f"Chúng tôi đang đánh giá đối tác và mong nhận phản hồi khi thuận tiện.\n\n" + f"Trân trọng" + ) + + return { + "subject_en": f"RFQ — {product_label} (Bulk Supply Inquiry)", + "body_en": body_en, + "subject_vi": f"Yêu cầu báo giá — {product_label}", + "body_vi": body_vi, + } + + +def as_json_text(data: object) -> str: + return json.dumps(data, ensure_ascii=False, indent=2) diff --git a/monai/app/services/tinyfish_client.py b/monai/app/services/tinyfish_client.py new file mode 100644 index 000000000..ac735935b --- /dev/null +++ b/monai/app/services/tinyfish_client.py @@ -0,0 +1,50 @@ +import os + +from dotenv import load_dotenv +from tinyfish import AsyncTinyFish + +load_dotenv() + +DEFAULT_LOCATION = os.getenv("TINYFISH_SEARCH_LOCATION", "VN") +DEFAULT_LANGUAGE = os.getenv("TINYFISH_SEARCH_LANGUAGE", "vi") + +_client: AsyncTinyFish | None = None + + +def _get_client() -> AsyncTinyFish: + global _client + if _client is None: + if not os.getenv("TINYFISH_API_KEY"): + raise ValueError("TINYFISH_API_KEY environment variable not set") + _client = AsyncTinyFish(timeout=60.0) + return _client + + +async def search_tinyfish( + query: str, + max_results: int = 10, + *, + location: str | None = None, + language: str | None = None, + purpose: str | None = None, +) -> dict: + """Search the web via the official TinyFish Python SDK.""" + del purpose # SDK search accepts query/location/language only + client = _get_client() + response = await client.search.query( + query, + location=location or DEFAULT_LOCATION, + language=language or DEFAULT_LANGUAGE, + ) + data = response.model_dump() + results = data.get("results", []) + if max_results and len(results) > max_results: + data = {**data, "results": results[:max_results]} + return data + + +async def fetch_tinyfish(url: str, *, format: str = "markdown") -> dict: + """Fetch page content via the official TinyFish Python SDK.""" + client = _get_client() + response = await client.fetch.get_contents([url], format=format) # type: ignore[arg-type] + return response.model_dump() diff --git a/monai/frontend/.env.example b/monai/frontend/.env.example new file mode 100644 index 000000000..34b4634a2 --- /dev/null +++ b/monai/frontend/.env.example @@ -0,0 +1,10 @@ +# Production: your Render backend URL (NOT your Vercel URL) +# Example: https://monai-backend.onrender.com +VITE_API_BASE_URL= + +# Default market for the live trend board and analysis form defaults +# Example: TP.HCM +VITE_DEFAULT_LOCATION= + +# Optional default category for emerging-trends search +# VITE_DEFAULT_CATEGORY=food and beverage diff --git a/monai/frontend/.gitignore b/monai/frontend/.gitignore new file mode 100644 index 000000000..8d834ed60 --- /dev/null +++ b/monai/frontend/.gitignore @@ -0,0 +1,30 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local +.env +.env.* +!.env.example + +# TanStack Router generated types +src/routeTree.gen.ts + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/monai/frontend/index.html b/monai/frontend/index.html new file mode 100644 index 000000000..716a1430a --- /dev/null +++ b/monai/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + MónAI — Vietnam Food Trend Intelligence + + +
+ + + diff --git a/monai/frontend/package-lock.json b/monai/frontend/package-lock.json new file mode 100644 index 000000000..f302497ac --- /dev/null +++ b/monai/frontend/package-lock.json @@ -0,0 +1,2736 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@radix-ui/react-label": "^2.1.11", + "@radix-ui/react-slot": "^1.3.0", + "@radix-ui/react-tabs": "^1.1.16", + "@tailwindcss/vite": "^4.3.2", + "@tanstack/react-router": "^1.170.17", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.23.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "tailwind-merge": "^3.6.0", + "tailwindcss": "^4.3.2" + }, + "devDependencies": { + "@tanstack/router-cli": "^1.167.18", + "@tanstack/router-plugin": "^1.168.19", + "@types/node": "^26.1.0", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^6.0.3", + "typescript": "~6.0.2", + "vite": "^8.1.1" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.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==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "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==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.138.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", + "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz", + "integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.11.tgz", + "integrity": "sha512-djW9+zeg137KQdlPtmE8xnaD+K2rcXXMWFrSg0hsmYZ6HRbdTA7tDHFgpaW9+huWVEu0RCabL+985T4TA0BE7g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-slot": "1.3.0" + }, + "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-compose-refs": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", + "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", + "license": "MIT", + "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-context": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz", + "integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==", + "license": "MIT", + "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-direction": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.2.tgz", + "integrity": "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==", + "license": "MIT", + "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-id": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz", + "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "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-label": { + "version": "2.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.11.tgz", + "integrity": "sha512-3PKvDDxOn62k0oV1n4QtNtD2vpu+zYjXR7ojLBPaO6SPvhy53yg0vAmgNeBQeJW5rV3dffoRG+HYfLBZuzw0CQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.7" + }, + "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-presence": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.6.tgz", + "integrity": "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "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-primitive": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.7.tgz", + "integrity": "sha512-bC3NiwsprbxKjuon9l7X6BUTw7FPVzEYaL92MPEY5SCd/9hUTPXVFtVwRix7778wtRsVao+zE062gL79FZleeQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.0" + }, + "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.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.14.tgz", + "integrity": "sha512-8Qcnx9447tx/aCBgw6Jenfqg4Skq+vqab9mCBmuGNipIS5YXvL275wbKEu7+ICYHIlAPgCduUMJH1XOYewKF6Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.11", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "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-slot": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.0.tgz", + "integrity": "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3" + }, + "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-tabs": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.16.tgz", + "integrity": "sha512-v3Ab2l7z6U7tRB4xA0IyKdq0OsqaO1o9ZjsIEoKKnSZ/l96mZz8aCTX0NCXw+YVHJXr8Km4d+Mn6/Q8YjXa+gw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.7", + "@radix-ui/react-roving-focus": "1.1.14", + "@radix-ui/react-use-controllable-state": "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-use-callback-ref": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz", + "integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==", + "license": "MIT", + "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-controllable-state": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz", + "integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-layout-effect": "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-effect-event": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz", + "integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "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-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", + "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", + "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", + "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", + "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", + "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", + "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", + "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", + "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", + "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", + "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", + "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", + "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", + "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", + "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", + "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", + "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", + "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", + "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-x64": "4.3.2", + "@tailwindcss/oxide-freebsd-x64": "4.3.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-x64-musl": "4.3.2", + "@tailwindcss/oxide-wasm32-wasi": "4.3.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", + "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", + "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", + "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", + "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", + "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", + "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", + "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", + "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz", + "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", + "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", + "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", + "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.2.tgz", + "integrity": "sha512-eHpMeX4JXfVNJDEcsouTeCBubJBTcTLigeaw/NTUW6PB5ATKKXdyonnXgTBX2VuRbjz1hjfz6C5XAhr52ImQXA==", + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.2", + "@tailwindcss/oxide": "4.3.2", + "tailwindcss": "4.3.2" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/history": { + "version": "1.162.0", + "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.162.0.tgz", + "integrity": "sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==", + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-router": { + "version": "1.170.17", + "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.170.17.tgz", + "integrity": "sha512-ppLkjCfSMaeug9rmFRYzOd4TIqWV+yTE7tzIny7alJsSnM7w4lzEZm6eqCehG0SPetpZ0R3K+UnanSmBgOAVcQ==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.162.0", + "@tanstack/react-store": "^0.9.3", + "@tanstack/router-core": "1.171.14", + "isbot": "^5.1.22" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=18.0.0 || >=19.0.0", + "react-dom": ">=18.0.0 || >=19.0.0" + } + }, + "node_modules/@tanstack/react-store": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.9.3.tgz", + "integrity": "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.9.3", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/router-cli": { + "version": "1.167.18", + "resolved": "https://registry.npmjs.org/@tanstack/router-cli/-/router-cli-1.167.18.tgz", + "integrity": "sha512-R3DwK6uZY1x97EC/CsjGY/xCVUXmeZZhlaaFf+EYgJ4gb5ozxZ5mmDh304g+Q9Lw/oWt5azDJXL8TPZiTAwHzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tanstack/router-generator": "1.167.18", + "chokidar": "^5.0.0", + "yargs": "^17.7.2" + }, + "bin": { + "tsr": "bin/tsr.cjs" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/router-core": { + "version": "1.171.14", + "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.171.14.tgz", + "integrity": "sha512-Mo3hwx0qB0cJsVYGDjG0+Ouf7VV74h/vsoDMGztdlyzDanp4gBA2s7IVvm6hFrmQM6GpD9F0Z7SqD7OldfLE7g==", + "license": "MIT", + "dependencies": { + "@tanstack/history": "1.162.0", + "cookie-es": "^3.0.0", + "seroval": "^1.5.4", + "seroval-plugins": "^1.5.4" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/router-generator": { + "version": "1.167.18", + "resolved": "https://registry.npmjs.org/@tanstack/router-generator/-/router-generator-1.167.18.tgz", + "integrity": "sha512-kFvM4caRds9Q3EXg64bZubJ6rbDxyV0YDSBSGvOGzmKspQPdz5Xrh0uj5T1Ov8avUUg+c761u04VQAaEzSBXRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.5", + "@tanstack/router-core": "1.171.14", + "@tanstack/router-utils": "1.162.2", + "@tanstack/virtual-file-routes": "1.162.0", + "jiti": "^2.7.0", + "magic-string": "^0.30.21", + "prettier": "^3.5.0", + "zod": "^4.4.3" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/router-plugin": { + "version": "1.168.19", + "resolved": "https://registry.npmjs.org/@tanstack/router-plugin/-/router-plugin-1.168.19.tgz", + "integrity": "sha512-aFglwLc+bbPTgZlkXn3PvOwpjJAfgUyPGSuql4MP3XrqTTh6WkBiy2RYb6oaG5h0s7EKwivEuq85K3Y4V0Mt1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "@tanstack/router-core": "1.171.14", + "@tanstack/router-generator": "1.167.18", + "@tanstack/router-utils": "1.162.2", + "chokidar": "^5.0.0", + "unplugin": "^3.0.0", + "zod": "^4.4.3" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@rsbuild/core": ">=1.0.2 || ^2.0.0", + "@tanstack/react-router": "^1.170.17", + "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0", + "vite-plugin-solid": "^2.11.10 || ^3.0.0-0", + "webpack": ">=5.92.0" + }, + "peerDependenciesMeta": { + "@rsbuild/core": { + "optional": true + }, + "@tanstack/react-router": { + "optional": true + }, + "vite": { + "optional": true + }, + "vite-plugin-solid": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/@tanstack/router-utils": { + "version": "1.162.2", + "resolved": "https://registry.npmjs.org/@tanstack/router-utils/-/router-utils-1.162.2.tgz", + "integrity": "sha512-hTWqJtqIFFdvuCl8WXNyrodp2L9zo2G37xKRrcVmVRWpAB2h+U1LuRAfS4tsFTiWOIoE/B+WDVFB8JpoEdw6jQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/generator": "^7.28.5", + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "ansis": "^4.1.0", + "babel-dead-code-elimination": "^1.0.12", + "diff": "^8.0.2", + "pathe": "^2.0.3", + "tinyglobby": "^0.2.15" + }, + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/store": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.3.tgz", + "integrity": "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/virtual-file-routes": { + "version": "1.162.0", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-file-routes/-/virtual-file-routes-1.162.0.tgz", + "integrity": "sha512-uhOeFyxLcU41HzvrxsGpiWdcMbScY1EDgbZ5K7DVRMYInbLYWAC0EA/kx9wXAoSM8q82bUG2hRl8+EAjE6XAbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.19" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/node": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", + "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ansis": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.3.1.tgz", + "integrity": "sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "node_modules/babel-dead-code-elimination": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.12.tgz", + "integrity": "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.23.7", + "@babel/parser": "^7.23.6", + "@babel/traverse": "^7.23.7", + "@babel/types": "^7.23.6" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.40", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", + "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001800", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz", + "integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/class-variance-authority": { + "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" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie-es": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz", + "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.384", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.384.tgz", + "integrity": "sha512-g6KAKY1vkYsADvSPWvdJsuYT0ixdcu6lUtD9P/wJKGBEDlZVXh2AX42j1mPqqaQPDluWjara9ziQ7xqAeXCt5A==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "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==", + "license": "ISC" + }, + "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", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isbot": { + "version": "5.1.44", + "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.1.44.tgz", + "integrity": "sha512-PGEHtwMnKbZpeSEXW2Utx+/JWed7dp6DiH0WWg33vGSDA7RUvpUeJSVlLrVkQ1RCpvDOUc/eH9ql7VsdbBZ8pA==", + "license": "Unlicense", + "engines": { + "node": ">=18" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.23.0.tgz", + "integrity": "sha512-38BpJcD0JhFosxHApP/BYsBetLpQFRoTRzEzstM/XCc3jsAG7wqaY1lgVwxiUe3xqYE+lNxo2PkCmYwXWrwwIw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prettier": { + "version": "3.9.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.4.tgz", + "integrity": "sha512-yWG/o/4oJfo036EKAfK6ACAoDOfHeRHx4tuxkfBZiauURiaSmYwlpOr5LQqKtIkRD2z1PLteme2WoxEnj4tHTg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", + "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.138.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.4", + "@rolldown/binding-darwin-arm64": "1.1.4", + "@rolldown/binding-darwin-x64": "1.1.4", + "@rolldown/binding-freebsd-x64": "1.1.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", + "@rolldown/binding-linux-arm64-gnu": "1.1.4", + "@rolldown/binding-linux-arm64-musl": "1.1.4", + "@rolldown/binding-linux-ppc64-gnu": "1.1.4", + "@rolldown/binding-linux-s390x-gnu": "1.1.4", + "@rolldown/binding-linux-x64-gnu": "1.1.4", + "@rolldown/binding-linux-x64-musl": "1.1.4", + "@rolldown/binding-openharmony-arm64": "1.1.4", + "@rolldown/binding-wasm32-wasi": "1.1.4", + "@rolldown/binding-win32-arm64-msvc": "1.1.4", + "@rolldown/binding-win32-x64-msvc": "1.1.4" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/seroval": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.4.tgz", + "integrity": "sha512-46uFvgrXTVxZcUorgSSRZ4y+ieqLLQRMlG4bnCZKW3qI6BZm7Rg4ntMW4p1mILEEBZWrFlcpp0AyIIlM6jD9iw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/seroval-plugins": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.4.tgz", + "integrity": "sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "seroval": "^1.0" + } + }, + "node_modules/source-map-js": { + "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==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", + "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/unplugin": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.3.0.tgz", + "integrity": "sha512-qa66K+crbfyE6JK10GjvbJeRrOsuC/JpbnHctfyp/i4oBTxWOzJfRZyDiOk1PtErMFRu8JhsU/wPvOdBNWe5Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "picomatch": "^4.0.4", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@farmfe/core": "*", + "@rspack/core": "*", + "bun-types-no-globals": "*", + "esbuild": "*", + "rolldown": "*", + "rollup": "*", + "unloader": "*", + "vite": "*", + "webpack": "*" + }, + "peerDependenciesMeta": { + "@farmfe/core": { + "optional": true + }, + "@rspack/core": { + "optional": true + }, + "bun-types-no-globals": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "rolldown": { + "optional": true + }, + "rollup": { + "optional": true + }, + "unloader": { + "optional": true + }, + "vite": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/vite": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", + "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/monai/frontend/package.json b/monai/frontend/package.json new file mode 100644 index 000000000..2ee4709a9 --- /dev/null +++ b/monai/frontend/package.json @@ -0,0 +1,36 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "generate-routes": "tsr generate", + "build": "tsr generate && tsc -p tsconfig.app.json && vite build", + "preview": "vite preview" + }, + "devDependencies": { + "@tanstack/router-cli": "^1.167.18", + "@tanstack/router-plugin": "^1.168.19", + "@types/node": "^26.1.0", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@vitejs/plugin-react": "^6.0.3", + "typescript": "~6.0.2", + "vite": "^8.1.1" + }, + "dependencies": { + "@radix-ui/react-label": "^2.1.11", + "@radix-ui/react-slot": "^1.3.0", + "@radix-ui/react-tabs": "^1.1.16", + "@tailwindcss/vite": "^4.3.2", + "@tanstack/react-router": "^1.170.17", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.23.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "tailwind-merge": "^3.6.0", + "tailwindcss": "^4.3.2" + } +} diff --git a/monai/frontend/public/favicon.svg b/monai/frontend/public/favicon.svg new file mode 100644 index 000000000..6893eb132 --- /dev/null +++ b/monai/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/monai/frontend/public/icons.svg b/monai/frontend/public/icons.svg new file mode 100644 index 000000000..e9522193d --- /dev/null +++ b/monai/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/monai/frontend/src/assets/banhmi.webp b/monai/frontend/src/assets/banhmi.webp new file mode 100644 index 000000000..a1d3c7271 Binary files /dev/null and b/monai/frontend/src/assets/banhmi.webp differ diff --git a/monai/frontend/src/assets/banhxeo.webp b/monai/frontend/src/assets/banhxeo.webp new file mode 100644 index 000000000..a1660e4d7 Binary files /dev/null and b/monai/frontend/src/assets/banhxeo.webp differ diff --git a/monai/frontend/src/assets/bunbohue.webp b/monai/frontend/src/assets/bunbohue.webp new file mode 100644 index 000000000..883333606 Binary files /dev/null and b/monai/frontend/src/assets/bunbohue.webp differ diff --git a/monai/frontend/src/assets/capthrung.webp b/monai/frontend/src/assets/capthrung.webp new file mode 100644 index 000000000..6e66d5ee0 Binary files /dev/null and b/monai/frontend/src/assets/capthrung.webp differ diff --git a/monai/frontend/src/assets/leaf.webp b/monai/frontend/src/assets/leaf.webp new file mode 100644 index 000000000..2617aed7b Binary files /dev/null and b/monai/frontend/src/assets/leaf.webp differ diff --git a/monai/frontend/src/assets/pho.webp b/monai/frontend/src/assets/pho.webp new file mode 100644 index 000000000..c5c00311b Binary files /dev/null and b/monai/frontend/src/assets/pho.webp differ diff --git a/monai/frontend/src/components/monai/AnalysisPanel.tsx b/monai/frontend/src/components/monai/AnalysisPanel.tsx new file mode 100644 index 000000000..79d1b4c30 --- /dev/null +++ b/monai/frontend/src/components/monai/AnalysisPanel.tsx @@ -0,0 +1,362 @@ +import { useId, useRef, useState } from "react"; +import { Loader2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Textarea } from "@/components/ui/textarea"; +import { + ANALYSIS_EXAMPLES, + type AnalysisCategory, +} from "@/components/monai/analysisExamples"; +import { ExampleReportPreview, IntelligenceReportView } from "@/components/monai/IntelligenceReport"; +import { buildClientReport } from "@/lib/buildClientReport"; +import { normalizeResult } from "@/lib/normalizeResult"; +import { getAnalysisPrefills } from "@/lib/analysisDefaults"; +import { + discoverSuppliers, + generateOutreach, + isApiConfigured, + runMenuGapAnalysis, + runRegionalComparison, + runTrendForecast, +} from "@/lib/api"; + +function AnalysisResult({ data }: { data: unknown }) { + const normalized = normalizeResult(data); + const report = buildClientReport(normalized); + + if (report) { + return ; + } + + return ( +
+      {JSON.stringify(normalized, null, 2)}
+    
+ ); +} + +function RunButton({ + tab, + loadingTab, + label, + onClick, +}: { + tab: AnalysisCategory; + loadingTab: AnalysisCategory | null; + label: string; + onClick: () => void; +}) { + const busy = loadingTab === tab; + return ( + + ); +} + +export function AnalysisPanel() { + const fieldId = useId(); + const [loadingTab, setLoadingTab] = useState(null); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + const [resultVersion, setResultVersion] = useState(0); + const [activeTab, setActiveTab] = useState("menu-gap"); + const [showExample, setShowExample] = useState(true); + + const prefill = getAnalysisPrefills(); + + const [menuItems, setMenuItems] = useState(prefill.menuItems); + const [menuLocation, setMenuLocation] = useState(prefill.menuLocation); + const [competitorUrls, setCompetitorUrls] = useState(""); + + const [trendName, setTrendName] = useState(prefill.trendName); + const [forecastLocation, setForecastLocation] = useState(prefill.forecastLocation); + + const [regionA, setRegionA] = useState(prefill.regionA); + const [regionB, setRegionB] = useState(prefill.regionB); + const [category, setCategory] = useState(prefill.category); + + const [supplierTrend, setSupplierTrend] = useState(prefill.supplierTrend); + const [ingredients, setIngredients] = useState(prefill.ingredients); + const [supplierLocation, setSupplierLocation] = useState(prefill.supplierLocation); + + const [supplierInfo, setSupplierInfo] = useState(prefill.supplierInfo); + const [productNeeds, setProductNeeds] = useState(prefill.productNeeds); + + const example = ANALYSIS_EXAMPLES[activeTab]; + const id = (name: string) => `${fieldId}-${name}`; + const requestIdRef = useRef(0); + + async function runAnalysis(tab: AnalysisCategory, fn: () => Promise>) { + if (!isApiConfigured()) { + setError("API not configured. Set VITE_API_BASE_URL to your Render backend URL."); + return; + } + const requestId = ++requestIdRef.current; + setLoadingTab(tab); + setError(null); + setResult(null); + setShowExample(false); + try { + const data = await fn(); + if (requestId !== requestIdRef.current) { + return; + } + setResult(data); + setResultVersion((v) => v + 1); + } catch (e) { + if (requestId !== requestIdRef.current) { + return; + } + setError(e instanceof Error ? e.message : "Request failed"); + } finally { + if (requestId === requestIdRef.current) { + setLoadingTab(null); + } + } + } + + function handleTabChange(value: string) { + requestIdRef.current += 1; + setActiveTab(value as AnalysisCategory); + setResult(null); + setError(null); + setShowExample(true); + setLoadingTab(null); + } + + return ( +
+
+
+ Run Analysis +

+ AI Trend Intelligence Console +

+

+ Menu gaps · forecasts · regional compare · supplier discovery · RFQ outreach +

+
+ +
+ {example.title}. {example.description} +
+ + + + Menu Gap + Forecast + Regional + Suppliers + RFQ + + + {activeTab === "menu-gap" && ( + +
+
+ +