SPB Git

spb/vquant Public MIT

VibeQuant — AI-powered institutional-grade financial intelligence platform.

TypeScript 84.3% Python 11.7% JavaScript 1.6% CSS 1.5% HTML 0.7%

Initial public release — VibeQuant v2.1.0

AI-powered financial intelligence platform: Claude AI agent (213 tools),
265+ FMP financial data endpoints, quantitative Python engine (Monte Carlo,
GARCH, VaR, Black-Scholes, portfolio optimization), multi-source web
research, SSE streaming chat, PDF/slides export, Electron desktop app.

Live demo: https://www.vquant.ai

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed 5 days ago (Aug 5, 2026)

Showing 129 changed files with +20,001 and −0

added .editorconfig +29 −0
@@ -0,0 +1,29 @@
1 +# =============================================================================
2 +# VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
3 +# -----------------------------------------------------------------------------
4 +# File: .editorconfig
5 +#
6 +# Author: Simon-Pierre Boucher
7 +# Contact: contact@spboucher.ai
8 +# Website: https://www.spboucher.ai
9 +# Demo: https://www.vquant.ai
10 +# License: MIT (see LICENSE)
11 +#
12 +# Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
13 +# =============================================================================
14 +
15 +root = true
16 +
17 +[*]
18 +indent_style = space
19 +indent_size = 2
20 +end_of_line = lf
21 +charset = utf-8
22 +trim_trailing_whitespace = true
23 +insert_final_newline = true
24 +
25 +[*.md]
26 +trim_trailing_whitespace = false
27 +
28 +[*.py]
29 +indent_size = 4
added .env.example +35 −0
@@ -0,0 +1,35 @@
1 +# =============================================================================
2 +# VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
3 +# -----------------------------------------------------------------------------
4 +# File: .env.example
5 +#
6 +# Author: Simon-Pierre Boucher
7 +# Contact: contact@spboucher.ai
8 +# Website: https://www.spboucher.ai
9 +# Demo: https://www.vquant.ai
10 +# License: MIT (see LICENSE)
11 +#
12 +# Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
13 +# =============================================================================
14 +
15 +# ─── Required ──────────────────────────────────────────
16 +ANTHROPIC_API_KEY=sk-ant-api03-your-key-here
17 +FMP_API_KEY=your-fmp-key-here
18 +
19 +# ─── Recommended ───────────────────────────────────────
20 +FIRECRAWL_API_KEY=fc-your-key-here
21 +TAVILY_API_KEY=tvly-your-key-here
22 +
23 +# ─── Optional ──────────────────────────────────────────
24 +EXA_API_KEY=your-exa-key-here
25 +SERPAPI_API_KEY=your-serpapi-key-here
26 +ELEVENLABS_API_KEY=your-elevenlabs-key-here
27 +
28 +# ─── Server Config ─────────────────────────────────────
29 +DATABASE_URL=sqlite://local.db
30 +SESSION_SECRET=generate-a-strong-random-secret-here
31 +PORT=5000
32 +NODE_ENV=development
33 +
34 +# ─── Python (optional, auto-detected) ─────────────────
35 +# PYTHON_PATH=/path/to/.venv/bin/python
added .github/workflows/ci.yml +59 −0
@@ -0,0 +1,59 @@
1 +# =============================================================================
2 +# VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
3 +# -----------------------------------------------------------------------------
4 +# File: .github/workflows/ci.yml
5 +#
6 +# Author: Simon-Pierre Boucher
7 +# Contact: contact@spboucher.ai
8 +# Website: https://www.spboucher.ai
9 +# Demo: https://www.vquant.ai
10 +# License: MIT (see LICENSE)
11 +#
12 +# Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
13 +# =============================================================================
14 +
15 +name: CI
16 +
17 +on:
18 + push:
19 + branches: [main]
20 + pull_request:
21 + branches: [main]
22 +
23 +jobs:
24 + ci:
25 + name: Lint, Typecheck, Test & Build
26 + runs-on: ubuntu-latest
27 +
28 + strategy:
29 + matrix:
30 + node-version: [20, 22]
31 +
32 + steps:
33 + - name: Checkout
34 + uses: actions/checkout@v4
35 +
36 + - name: Setup Node.js ${{ matrix.node-version }}
37 + uses: actions/setup-node@v4
38 + with:
39 + node-version: ${{ matrix.node-version }}
40 + cache: npm
41 +
42 + - name: Install dependencies
43 + run: npm ci --legacy-peer-deps
44 +
45 + - name: Lint
46 + run: npm run lint
47 + continue-on-error: true # 572 pre-existing warnings, fixing incrementally
48 +
49 + - name: Typecheck
50 + run: npm run typecheck
51 + continue-on-error: true # 15 pre-existing errors, fixing incrementally
52 +
53 + - name: Test
54 + run: npm test
55 +
56 + - name: Build
57 + run: npm run build
58 + env:
59 + NODE_ENV: production
added .gitignore +64 −0
@@ -0,0 +1,64 @@
1 +# =============================================================================
2 +# VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
3 +# -----------------------------------------------------------------------------
4 +# File: .gitignore
5 +#
6 +# Author: Simon-Pierre Boucher
7 +# Contact: contact@spboucher.ai
8 +# Website: https://www.spboucher.ai
9 +# Demo: https://www.vquant.ai
10 +# License: MIT (see LICENSE)
11 +#
12 +# Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
13 +# =============================================================================
14 +
15 +# Dependencies
16 +node_modules/
17 +.venv/
18 +
19 +# Build output
20 +dist/
21 +
22 +# Environment variables
23 +.env
24 +.env.local
25 +.env.*.local
26 +
27 +# Database
28 +local.db
29 +local.db-shm
30 +local.db-wal
31 +
32 +# Logs
33 +logs/
34 +
35 +# Temp & caches
36 +temp/
37 +__pycache__/
38 +*.pyc
39 +
40 +# Backups
41 +backups/
42 +
43 +# Generated images
44 +/*.png
45 +public/plots/
46 +public/figures/
47 +
48 +# OS files
49 +.DS_Store
50 +Thumbs.db
51 +
52 +# IDE
53 +.vscode/
54 +.idea/
55 +*.swp
56 +*.swo
57 +*~
58 +
59 +# Lock files (keep package-lock.json, ignore uv.lock)
60 +uv.lock
61 +
62 +# Misc artifacts
63 +downloads/
64 +attached_assets/
added .prettierignore +20 −0
@@ -0,0 +1,20 @@
1 +# =============================================================================
2 +# VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
3 +# -----------------------------------------------------------------------------
4 +# File: .prettierignore
5 +#
6 +# Author: Simon-Pierre Boucher
7 +# Contact: contact@spboucher.ai
8 +# Website: https://www.spboucher.ai
9 +# Demo: https://www.vquant.ai
10 +# License: MIT (see LICENSE)
11 +#
12 +# Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
13 +# =============================================================================
14 +
15 +node_modules/
16 +dist/
17 +.venv/
18 +package-lock.json
19 +*.py
20 +*.md
added .prettierrc +9 −0
@@ -0,0 +1,9 @@
1 +{
2 + "semi": true,
3 + "singleQuote": false,
4 + "trailingComma": "all",
5 + "printWidth": 100,
6 + "tabWidth": 2,
7 + "arrowParens": "always",
8 + "endOfLine": "lf"
9 +}
added LICENSE +21 −0
@@ -0,0 +1,21 @@
1 +MIT License
2 +
3 +Copyright (c) 2026 Simon-Pierre Boucher
4 +
5 +Permission is hereby granted, free of charge, to any person obtaining a copy
6 +of this software and associated documentation files (the "Software"), to deal
7 +in the Software without restriction, including without limitation the rights
8 +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 +copies of the Software, and to permit persons to whom the Software is
10 +furnished to do so, subject to the following conditions:
11 +
12 +The above copyright notice and this permission notice shall be included in all
13 +copies or substantial portions of the Software.
14 +
15 +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 +SOFTWARE.
added README.md +689 −0
@@ -0,0 +1,689 @@
1 +<!--
2 + =============================================================================
3 + VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + -----------------------------------------------------------------------------
5 + File: README.md
6 +
7 + Author: Simon-Pierre Boucher
8 + Contact: contact@spboucher.ai
9 + Website: https://www.spboucher.ai
10 + Demo: https://www.vquant.ai
11 + License: MIT (see LICENSE)
12 +
13 + Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + =============================================================================
15 +-->
16 +
17 +<div align="center">
18 +
19 +# VibeQuant
20 +
21 +### AI-Powered Institutional-Grade Financial Intelligence Platform
22 +
23 +<br/>
24 +
25 +**🌐 Live Demo: [www.vquant.ai](https://www.vquant.ai)**
26 +
27 +[![Live](https://img.shields.io/badge/Live_Demo-www.vquant.ai-00c853?style=for-the-badge&logo=googlechrome&logoColor=white)](https://www.vquant.ai)
28 +[![License](https://img.shields.io/badge/License-MIT-22c55e?style=for-the-badge)](./LICENSE)
29 +[![Version](https://img.shields.io/badge/Version-2.1.0-7c3aed?style=for-the-badge)](./)
30 +
31 +<br/>
32 +
33 +**Stack**
34 +
35 +[![TypeScript](https://img.shields.io/badge/TypeScript-5.6-3178c6?style=flat-square&logo=typescript&logoColor=white)](https://www.typescriptlang.org/)
36 +[![React](https://img.shields.io/badge/React-18.3-61dafb?style=flat-square&logo=react&logoColor=black)](https://react.dev/)
37 +[![Vite](https://img.shields.io/badge/Vite-5-646cff?style=flat-square&logo=vite&logoColor=white)](https://vitejs.dev/)
38 +[![Express](https://img.shields.io/badge/Express-4.21-000000?style=flat-square&logo=express&logoColor=white)](https://expressjs.com/)
39 +[![Node.js](https://img.shields.io/badge/Node.js-18+-339933?style=flat-square&logo=node.js&logoColor=white)](https://nodejs.org/)
40 +[![TailwindCSS](https://img.shields.io/badge/Tailwind_CSS-3.4-06b6d4?style=flat-square&logo=tailwindcss&logoColor=white)](https://tailwindcss.com/)
41 +
42 +**AI & Data**
43 +
44 +[![Claude AI](https://img.shields.io/badge/Claude-Fable_5_(1M_ctx)-d97706?style=flat-square&logo=anthropic&logoColor=white)](https://anthropic.com/)
45 +[![Python](https://img.shields.io/badge/Python-3.11+-3776ab?style=flat-square&logo=python&logoColor=white)](https://python.org/)
46 +[![NumPy](https://img.shields.io/badge/NumPy-013243?style=flat-square&logo=numpy&logoColor=white)](https://numpy.org/)
47 +[![Pandas](https://img.shields.io/badge/Pandas-150458?style=flat-square&logo=pandas&logoColor=white)](https://pandas.pydata.org/)
48 +[![SciPy](https://img.shields.io/badge/SciPy-8caae6?style=flat-square&logo=scipy&logoColor=white)](https://scipy.org/)
49 +[![scikit--learn](https://img.shields.io/badge/scikit--learn-f7931e?style=flat-square&logo=scikitlearn&logoColor=white)](https://scikit-learn.org/)
50 +
51 +**Database & ORM**
52 +
53 +[![SQLite](https://img.shields.io/badge/SQLite-003b57?style=flat-square&logo=sqlite&logoColor=white)](https://sqlite.org/)
54 +[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-4169e1?style=flat-square&logo=postgresql&logoColor=white)](https://postgresql.org/)
55 +[![Drizzle](https://img.shields.io/badge/Drizzle_ORM-c5f74f?style=flat-square&logo=drizzle&logoColor=black)](https://orm.drizzle.team/)
56 +[![Zod](https://img.shields.io/badge/Zod-3e67b1?style=flat-square&logo=zod&logoColor=white)](https://zod.dev/)
57 +
58 +**Quality & Testing**
59 +
60 +[![ESLint](https://img.shields.io/badge/ESLint-4b32c3?style=flat-square&logo=eslint&logoColor=white)](https://eslint.org/)
61 +[![Prettier](https://img.shields.io/badge/Prettier-f7b93e?style=flat-square&logo=prettier&logoColor=black)](https://prettier.io/)
62 +[![Vitest](https://img.shields.io/badge/Vitest-6e9f18?style=flat-square&logo=vitest&logoColor=white)](https://vitest.dev/)
63 +[![Testing Library](https://img.shields.io/badge/Testing_Library-e33332?style=flat-square&logo=testinglibrary&logoColor=white)](https://testing-library.com/)
64 +
65 +**Metrics**
66 +
67 +[![Lines of Code](https://img.shields.io/badge/Lines_of_Code-36,000+-0969da?style=flat-square)](./)
68 +[![Source Files](https://img.shields.io/badge/Source_Files-150+-0969da?style=flat-square)](./)
69 +[![API Endpoints](https://img.shields.io/badge/API_Endpoints-265+-8b5cf6?style=flat-square)](./)
70 +[![Claude Tools](https://img.shields.io/badge/Claude_Tools-213-d97706?style=flat-square)](./)
71 +[![Python Libs](https://img.shields.io/badge/Python_Libs-17-3776ab?style=flat-square)](./)
72 +[![Tests](https://img.shields.io/badge/Tests-41_passing-22c55e?style=flat-square)](./)
73 +[![Test Suites](https://img.shields.io/badge/Test_Suites-6-22c55e?style=flat-square)](./)
74 +[![Components](https://img.shields.io/badge/React_Components-57-61dafb?style=flat-square)](./)
75 +[![Hooks](https://img.shields.io/badge/Custom_Hooks-5-61dafb?style=flat-square)](./)
76 +[![Route Modules](https://img.shields.io/badge/Route_Modules-6-000000?style=flat-square)](./)
77 +
78 +---
79 +
80 +[**Getting Started**](#-getting-started) &nbsp;&bull;&nbsp; [**Features**](#-features) &nbsp;&bull;&nbsp; [**Architecture**](#-architecture) &nbsp;&bull;&nbsp; [**API Reference**](#-api-reference) &nbsp;&bull;&nbsp; [**Contributing**](#-contributing)
81 +
82 +</div>
83 +
84 +---
85 +
86 +## Overview
87 +
88 +VibeQuant is a production-grade full-stack financial analysis platform that combines **Claude AI (Fable 5 default, 6 selectable models, up to 1M token context)** with **265+ financial data endpoints**, **quantitative Python analysis** (Monte Carlo, GARCH, VaR, Black-Scholes, portfolio optimization), and **multi-source web research** (Tavily, Firecrawl, Exa, SerpAPI) to deliver institutional-grade insights through a real-time streaming conversational interface.
89 +
90 +Built with a modern TypeScript stack (React 18 + Vite + Express + Drizzle ORM), validated with Zod schemas, tested with Vitest, and linted with ESLint + Prettier.
91 +
92 +---
93 +
94 +## Features
95 +
96 +### Financial Data Engine
97 +
98 +[![FMP API](https://img.shields.io/badge/FMP_API-230_endpoints-8b5cf6?style=flat-square)](https://financialmodelingprep.com/)
99 +
100 +| Category | Endpoints | Details |
101 +|----------|-----------|---------|
102 +| **Company Fundamentals** | 40+ | Profiles, income statements, balance sheets, cash flows, key metrics, financial ratios, growth rates |
103 +| **Market Data** | 30+ | Real-time quotes, historical prices, intraday data, batch quotes, market hours, gainers/losers/actives |
104 +| **Technical Analysis** | 15+ | RSI, MACD, EMA, SMA, ADX, Williams %R, CCI, Stochastic, WMA, DEMA, TEMA, Bollinger Bands |
105 +| **Analyst & Ratings** | 20+ | Estimates, price targets, upgrades/downgrades, consensus, historical grades, ratings snapshots |
106 +| **Insider & Institutional** | 15+ | Insider trading, institutional holders, 13F filings, Senate/House trading, beneficial ownership |
107 +| **Economic & Macro** | 15+ | Economic calendar, Treasury rates, economic indicators, market risk premium, COT reports |
108 +| **SEC & Compliance** | 25+ | SEC filings (8-K, 10-K, 10-Q), EDGAR search, company notes, earnings transcripts |
109 +| **Alternative Data** | 20+ | ESG scores, social sentiment, Congressional trading, crowdfunding, equity offerings |
110 +| **ETFs & Indices** | 15+ | ETF holdings, sector weightings, S&P 500/NASDAQ/Dow Jones constituents |
111 +| **Valuation** | 10+ | DCF, levered DCF, custom DCF, enterprise values, owner earnings, financial scores |
112 +| **Forex & Crypto** | 15+ | Forex pairs, crypto quotes, commodity prices, historical forex data |
113 +| **Events** | 10+ | Earnings calendar, IPO calendar, dividend history, stock splits, mergers & acquisitions |
114 +
115 +### Quantitative Analysis Engine
116 +
117 +[![Python](https://img.shields.io/badge/Python-17_scientific_libraries-3776ab?style=flat-square&logo=python&logoColor=white)](https://python.org/)
118 +
119 +| Model | Description | Libraries |
120 +|-------|-------------|-----------|
121 +| **Monte Carlo Simulation** | Price path simulation with customizable parameters (10k+ paths, configurable horizon) | `numpy`, `scipy` |
122 +| **Options Pricing** | Black-Scholes model with full Greeks (delta, gamma, theta, vega, rho) | `scipy.stats` |
123 +| **GARCH Volatility** | Volatility modeling and forecasting with GARCH(1,1) | `arch`, `statsmodels` |
124 +| **Value at Risk (VaR)** | Historical, parametric, and Monte Carlo VaR at multiple confidence levels | `numpy`, `scipy` |
125 +| **Portfolio Optimization** | Mean-variance optimization (Modern Portfolio Theory), efficient frontier | `cvxpy`, `scipy.optimize` |
126 +| **Risk Metrics** | Sharpe, Sortino, Calmar, max drawdown, beta, alpha, information ratio | `numpy`, `pandas` |
127 +| **Custom Python** | Arbitrary Python code execution with access to 17 libraries and FMP data | All libraries |
128 +
129 +<details>
130 +<summary><b>Full Python library list (17 packages)</b></summary>
131 +
132 +| Library | Version | Purpose |
133 +|---------|---------|---------|
134 +| `numpy` | latest | Numerical computing |
135 +| `pandas` | latest | Data manipulation |
136 +| `scipy` | latest | Scientific computing |
137 +| `scikit-learn` | latest | Machine learning |
138 +| `statsmodels` | latest | Statistical models |
139 +| `matplotlib` | latest | Plotting & charts |
140 +| `seaborn` | latest | Statistical visualization |
141 +| `plotly` | latest | Interactive charts |
142 +| `arch` | latest | GARCH models |
143 +| `cvxpy` | latest | Convex optimization |
144 +| `yfinance` | latest | Market data |
145 +| `ta` | latest | Technical analysis |
146 +| `quantstats` | latest | Portfolio analytics |
147 +| `beautifulsoup4` | latest | Web scraping |
148 +| `requests` | latest | HTTP client |
149 +| `lxml` | latest | XML/HTML parsing |
150 +| `Pillow` | latest | Image processing |
151 +
152 +</details>
153 +
154 +### Web Research Engine
155 +
156 +| Source | Capabilities | Badge |
157 +|--------|-------------|-------|
158 +| **Tavily** | AI-powered search with generated answers, time filters, domain filtering | [![Tavily](https://img.shields.io/badge/Tavily-AI_Search-4f46e5?style=flat-square)](https://tavily.com/) |
159 +| **Firecrawl** | Web scraping, PDF extraction (up to 750 pages), intelligent crawling, structured extraction, autonomous agents | [![Firecrawl](https://img.shields.io/badge/Firecrawl-Web_Extraction-f97316?style=flat-square)](https://firecrawl.dev/) |
160 +| **Exa** | Semantic neural search, content retrieval, similar page discovery | [![Exa](https://img.shields.io/badge/Exa-Semantic_Search-6366f1?style=flat-square)](https://exa.ai/) |
161 +| **SerpAPI** | Google Search, Images, Shopping, Finance, Trends, Scholar, Videos, Flights, Hotels | [![SerpAPI](https://img.shields.io/badge/SerpAPI-Google_Search-34a853?style=flat-square)](https://serpapi.com/) |
162 +
163 +### AI Agent
164 +
165 +[![Claude](https://img.shields.io/badge/Claude_Fable_5-6_models-d97706?style=flat-square&logo=anthropic&logoColor=white)](https://anthropic.com/)
166 +
167 +| Feature | Details |
168 +|---------|---------|
169 +| **Models** | 6 selectable Claude models — **Fable 5** (default), **Opus 4.8 / 4.7 / 4.6**, **Sonnet 4.6**, **Haiku 4.5** — up to 1M token context |
170 +| **Model Selector** | Switchable from the input bar and the welcome screen (per-model icons); selection persisted in `localStorage` |
171 +| **Reasoning** | Adaptive extended thinking shown in a collapsible "Raisonnement" widget that auto-expands while the model thinks (Fable 5 / Opus / Sonnet; Haiku has no adaptive thinking) |
172 +| **Tools** | 213 specialized financial tools |
173 +| **Batching** | Intelligent tool batching (5 tools per batch with 500ms pauses) |
174 +| **Streaming** | Real-time Server-Sent Events (SSE) for instant response display |
175 +| **History** | Persistent conversation sessions with token tracking |
176 +| **Vision** | Image analysis support (charts, screenshots, documents) |
177 +| **Cost Tracking** | Per-session input/output token counting and cost estimation |
178 +
179 +### Interface & Design
180 +
181 +| Aspect | Details |
182 +|--------|---------|
183 +| **Theme** | Modern light/dark themes with an indigo-violet accent; toggle persisted in `localStorage` |
184 +| **Design** | Rounded soft-cornered components, subtle background gradient, thin rounded scrollbars |
185 +| **Welcome screen** | Categorized example prompts + dedicated model selector with per-model icons |
186 +| **Stack** | Tailwind CSS 3.4 + Radix UI primitives |
187 +
188 +### Export & Reports
189 +
190 +| Format | Description | Badge |
191 +|--------|-------------|-------|
192 +| **PDF** | Professional reports generated with Puppeteer, markdown rendering | [![PDF](https://img.shields.io/badge/PDF-Puppeteer-ea4335?style=flat-square&logo=googlechrome&logoColor=white)](./) |
193 +| **DOCX** | Word documents via Pandoc conversion | [![DOCX](https://img.shields.io/badge/DOCX-Pandoc-2b579a?style=flat-square&logo=microsoftword&logoColor=white)](./) |
194 +| **LaTeX Slides** | Beamer presentations with professional theming | [![LaTeX](https://img.shields.io/badge/LaTeX-Beamer_Slides-008080?style=flat-square&logo=latex&logoColor=white)](./) |
195 +| **Data Files** | CSV, XLSX, JSON, TXT downloads of analysis data | [![Data](https://img.shields.io/badge/Data-CSV_XLSX_JSON-217346?style=flat-square&logo=microsoftexcel&logoColor=white)](./) |
196 +| **Share Links** | Shareable report URLs with unique IDs | [![Share](https://img.shields.io/badge/Share-Unique_Links-0ea5e9?style=flat-square)](./) |
197 +
198 +---
199 +
200 +## Getting Started
201 +
202 +### Prerequisites
203 +
204 +[![Node.js](https://img.shields.io/badge/Node.js-18+-339933?style=flat-square&logo=node.js&logoColor=white)](https://nodejs.org/)
205 +[![Python](https://img.shields.io/badge/Python-3.11+-3776ab?style=flat-square&logo=python&logoColor=white)](https://python.org/)
206 +[![npm](https://img.shields.io/badge/npm-9+-cb3837?style=flat-square&logo=npm&logoColor=white)](https://npmjs.com/)
207 +
208 +### Installation
209 +
210 +```bash
211 +# Clone the repository
212 +git clone https://github.com/spboucher-ai/vquant.git
213 +cd vquant
214 +
215 +# Install Node.js dependencies
216 +npm install
217 +
218 +# Set up Python virtual environment
219 +python3 -m venv .venv
220 +source .venv/bin/activate # macOS/Linux
221 +# .venv\Scripts\activate # Windows
222 +pip install -r requirements-safe.txt
223 +
224 +# Configure environment variables
225 +cp .env.example .env
226 +# Edit .env with your API keys
227 +
228 +# Start development server
229 +npm run dev
230 +```
231 +
232 +The app will be available at `http://localhost:5000`.
233 +
234 +### API Keys
235 +
236 +All services offer generous free tiers:
237 +
238 +| Service | Sign Up | Free Tier | Required |
239 +|---------|---------|-----------|----------|
240 +| [![Anthropic](https://img.shields.io/badge/Anthropic-d97706?style=flat-square&logo=anthropic&logoColor=white)](https://console.anthropic.com) | [console.anthropic.com](https://console.anthropic.com) | $5 credit | **Yes** |
241 +| [![FMP](https://img.shields.io/badge/FMP-0969da?style=flat-square)](https://financialmodelingprep.com) | [financialmodelingprep.com](https://financialmodelingprep.com) | 250 calls/day | **Yes** |
242 +| [![Firecrawl](https://img.shields.io/badge/Firecrawl-f97316?style=flat-square)](https://firecrawl.dev) | [firecrawl.dev](https://firecrawl.dev) | 500 credits | Recommended |
243 +| [![Tavily](https://img.shields.io/badge/Tavily-4f46e5?style=flat-square)](https://app.tavily.com) | [app.tavily.com](https://app.tavily.com) | 1000 calls/month | Recommended |
244 +| [![Exa](https://img.shields.io/badge/Exa-6366f1?style=flat-square)](https://exa.ai) | [exa.ai](https://exa.ai) | 1000 searches/month | Optional |
245 +| [![SerpAPI](https://img.shields.io/badge/SerpAPI-34a853?style=flat-square)](https://serpapi.com) | [serpapi.com](https://serpapi.com) | 100 searches/month | Optional |
246 +
247 +---
248 +
249 +## Scripts
250 +
251 +| Command | Description |
252 +|---------|-------------|
253 +| `npm run dev` | Start development server (Vite HMR + Express) |
254 +| `npm run build` | Build for production (client + server) |
255 +| `npm start` | Start production server |
256 +| `npm run typecheck` | TypeScript type checking (`tsc --noEmit`) |
257 +| `npm run lint` | ESLint check |
258 +| `npm run lint:fix` | ESLint auto-fix |
259 +| `npm run format` | Prettier format all files |
260 +| `npm run format:check` | Prettier check (CI-friendly) |
261 +| `npm test` | Run all tests (Vitest) |
262 +| `npm run test:watch` | Run tests in watch mode |
263 +| `npm run test:coverage` | Run tests with coverage report |
264 +| `npm run db:push` | Push database schema changes (Drizzle Kit) |
265 +
266 +---
267 +
268 +## Architecture
269 +
270 +```
271 +vquant/
272 +├── client/src/ # Frontend (React + TypeScript)
273 +│ ├── App.tsx # Router + providers + ErrorBoundary
274 +│ ├── main.tsx # Entry point
275 +│ ├── config.ts # App config, endpoints, feature flags
276 +│ ├── components/
277 +│ │ ├── chat/ # 13 chat-specific components
278 +│ │ │ ├── search-bar.tsx # Query input with image support
279 +│ │ │ ├── streaming-answer.tsx # Real-time markdown rendering
280 +│ │ │ ├── agent-steps.tsx # Tool execution progress
281 +│ │ │ ├── tool-results.tsx # Financial data display
282 +│ │ │ ├── monte-carlo-results.tsx # Simulation visualizations
283 +│ │ │ ├── share-button.tsx # Report sharing
284 +│ │ │ ├── download-buttons.tsx # PDF/DOCX export
285 +│ │ │ └── ... # + 6 more components
286 +│ │ ├── explore/ # 7 stock explorer components
287 +│ │ ├── financial/ # Price charts, options pricing
288 +│ │ ├── ui/ # 35 shadcn/ui primitives
289 +│ │ └── error-boundary.tsx # Global error handler
290 +│ ├── hooks/
291 +│ │ ├── useChatSession.ts # All chat state & business logic
292 +│ │ ├── useChatMutation.ts # SSE streaming client
293 +│ │ ├── useAnalytics.ts # Heartbeat tracking
294 +│ │ ├── use-toast.ts # Toast notifications
295 +│ │ └── use-mobile.tsx # Responsive detection
296 +│ ├── pages/ # 9 route pages
297 +│ │ ├── home.tsx # Main chat interface (255 lines)
298 +│ │ ├── explore.tsx # Stock explorer
299 +│ │ ├── admin.tsx # Admin dashboard
300 +│ │ ├── auth.tsx # Login/register
301 +│ │ ├── showcase.tsx # Community reports
302 +│ │ ├── shared-report.tsx # Shared report viewer
303 +│ │ ├── documentation.tsx # API documentation
304 +│ │ └── ... # + privacy, terms, slides, 404
305 +│ ├── lib/
306 +│ │ ├── queryClient.ts # TanStack Query config
307 +│ │ └── utils.ts # cn() + helpers
308 +│ └── utils/
309 +│ ├── chartCapture.ts # Chart-to-image conversion
310 +│ └── pdfGenerator.ts # Client-side PDF generation
311 +
312 +├── server/ # Backend (Express + TypeScript)
313 +│ ├── index.ts # Server bootstrap, CORS, sessions
314 +│ ├── routes.ts # /api/chat (SSE streaming) + speech-to-text
315 +│ ├── routes/
316 +│ │ ├── auth.ts # /api/auth/* (register, login, logout, me)
317 +│ │ ├── admin.ts # /api/admin/* (database, CRUD) + requireAdmin
318 +│ │ ├── analytics.ts # /api/analytics/* (heartbeat, active users, metrics)
319 +│ │ ├── fmp.ts # /api/fmp/* (23 financial data endpoints)
320 +│ │ ├── reports.ts # /api/share/*, sessions, PDF/DOCX/slides, download
321 +│ │ └── validation.ts # Zod request schemas
322 +│ ├── services/
323 +│ │ ├── claude/
324 +│ │ │ ├── toolDefinitions.ts # 213 tool definitions (4,480 lines)
325 +│ │ │ └── tokenManagement.ts # Context window management
326 +│ │ ├── claudeService.ts # Claude API integration
327 +│ │ ├── fmpService.ts # 230 FMP API wrapper functions
328 +│ │ ├── python/
329 +│ │ │ ├── monteCarlo.ts # Monte Carlo simulation
330 +│ │ │ ├── optionsPricing.ts # Black-Scholes pricing
331 +│ │ │ ├── garch.ts # GARCH volatility model
332 +│ │ │ ├── var.ts # VaR + portfolio + risk metrics
333 +│ │ │ └── shared.ts # Python executor config
334 +│ │ ├── *.py # 11 Python scientific services
335 +│ │ ├── firecrawlService.ts # Web extraction
336 +│ │ ├── tavilyService.ts # AI search
337 +│ │ ├── exaService.ts # Semantic search
338 +│ │ └── serpapiService.ts # Google search
339 +│ ├── db.ts # Drizzle ORM (SQLite + PostgreSQL)
340 +│ ├── storage.ts # Typed data access layer
341 +│ ├── types/
342 +│ │ └── modules.d.ts # Module declarations
343 +│ └── utils/
344 +│ └── logger.ts # Structured logging
345 +
346 +├── shared/ # Shared between client & server
347 +│ ├── schema.ts # PostgreSQL schema (Drizzle)
348 +│ ├── schema-sqlite.ts # SQLite schema (Drizzle)
349 +│ └── types.ts # API interfaces (SearchResult, etc.)
350 +
351 +├── vitest.config.ts # Test configuration
352 +├── eslint.config.js # ESLint flat config
353 +├── vite.config.ts # Vite build config
354 +├── tsconfig.json # TypeScript config
355 +├── tailwind.config.ts # Tailwind CSS config
356 +├── .prettierrc # Prettier config
357 +├── .editorconfig # Editor config
358 +├── .env.example # Environment template
359 +└── .gitignore # Git ignore rules
360 +```
361 +
362 +### System Architecture
363 +
364 +```
365 +┌──────────────────────────────────────────────────────────────────┐
366 +│ CLIENT (React 18) │
367 +│ ┌──────────┐ ┌──────────────┐ ┌────────────┐ ┌───────────┐ │
368 +│ │ Chat UI │ │ Explore Page │ │ Admin Panel│ │ Auth/Docs │ │
369 +│ │ (SSE) │ │ (FMP data) │ │ (Analytics)│ │ (Sessions)│ │
370 +│ └────┬─────┘ └──────┬───────┘ └─────┬──────┘ └─────┬─────┘ │
371 +│ │ │ │ │ │
372 +│ ┌────┴───────────────┴────────────────┴───────────────┴─────┐ │
373 +│ │ TanStack Query + useChatSession │ │
374 +│ └───────────────────────────┬───────────────────────────────┘ │
375 +└──────────────────────────────┼──────────────────────────────────┘
376 + │ HTTP / SSE
377 +┌──────────────────────────────┼──────────────────────────────────┐
378 +│ SERVER (Express 4) │
379 +│ ┌───────────────────────────┴───────────────────────────────┐ │
380 +│ │ Route Modules (6) │ │
381 +│ │ auth │ admin │ analytics │ fmp │ reports │ chat (SSE) │ │
382 +│ └───┬───────┬──────────┬──────────┬──────────┬──────────────┘ │
383 +│ │ │ │ │ │ │
384 +│ ┌───┴───┐ ┌─┴──────┐ ┌┴────────┐ │ ┌─────┴──────────────┐ │
385 +│ │Storage│ │Analytics│ │Zod Valid│ │ │ Claude Opus 4.8 │ │
386 +│ │(Drizzle)│ │Metrics │ │Schemas │ │ │ 213 tools │ │
387 +│ └───┬───┘ └────────┘ └────────┘ │ │ 5-tool batching │ │
388 +│ │ │ └──────────┬──────────┘ │
389 +│ ┌───┴──────┐ ┌─────┴──────┐ ┌─────┴──────────┐ │
390 +│ │SQLite/PG │ │ FMP Service│ │ Python Engine │ │
391 +│ │(Drizzle) │ │ 230 funcs │ │ 17 libraries │ │
392 +│ └──────────┘ └────────────┘ └────────────────┘ │
393 +│ │
394 +│ ┌────────────────────────────────────────────────────────────┐ │
395 +│ │ External APIs │ │
396 +│ │ Tavily │ Firecrawl │ Exa │ SerpAPI │ ElevenLabs │ │
397 +│ └────────────────────────────────────────────────────────────┘ │
398 +└─────────────────────────────────────────────────────────────────┘
399 +```
400 +
401 +---
402 +
403 +## API Reference
404 +
405 +### Chat (SSE Streaming)
406 +
407 +| Method | Endpoint | Description |
408 +|--------|----------|-------------|
409 +| `POST` | `/api/chat` | Main AI chat with streaming (Server-Sent Events) |
410 +| `POST` | `/api/speech-to-text` | Audio transcription (ElevenLabs Scribe) |
411 +
412 +### Authentication
413 +
414 +| Method | Endpoint | Description |
415 +|--------|----------|-------------|
416 +| `POST` | `/api/auth/register` | Create account (generates unique token) |
417 +| `POST` | `/api/auth/login` | Authenticate with token |
418 +| `POST` | `/api/auth/logout` | End session |
419 +| `GET` | `/api/auth/me` | Get current user |
420 +
421 +### Shared Reports & Sessions
422 +
423 +| Method | Endpoint | Description |
424 +|--------|----------|-------------|
425 +| `GET` | `/api/shared-reports` | List all shared reports |
426 +| `POST` | `/api/share` | Create shareable report |
427 +| `GET` | `/api/share/:shareId` | Get shared report |
428 +| `GET` | `/api/sessions` | List user sessions |
429 +| `GET` | `/api/sessions/:id` | Get session with messages |
430 +
431 +### Document Generation
432 +
433 +| Method | Endpoint | Description |
434 +|--------|----------|-------------|
435 +| `POST` | `/api/generate-pdf` | Markdown to PDF (Puppeteer) |
436 +| `POST` | `/api/generate-docx` | Markdown to DOCX (Pandoc) |
437 +| `POST` | `/api/convert-to-slides` | Markdown to Beamer LaTeX PDF |
438 +| `GET` | `/api/download/:filename` | Download data file |
439 +
440 +### Financial Data (FMP)
441 +
442 +| Method | Endpoint | Description |
443 +|--------|----------|-------------|
444 +| `GET` | `/api/fmp/quote/:symbol` | Real-time stock quote |
445 +| `GET` | `/api/fmp/company-profile/:symbol` | Company profile |
446 +| `GET` | `/api/fmp/historical-price/:symbol` | Historical prices |
447 +| `GET` | `/api/fmp/intraday/:symbol` | Intraday price data |
448 +| `GET` | `/api/fmp/income-statement/:symbol` | Income statement |
449 +| `GET` | `/api/fmp/balance-sheet/:symbol` | Balance sheet |
450 +| `GET` | `/api/fmp/cash-flow/:symbol` | Cash flow statement |
451 +| `GET` | `/api/fmp/key-metrics/:symbol` | Key financial metrics |
452 +| `GET` | `/api/fmp/financial-ratios/:symbol` | Financial ratios |
453 +| `GET` | `/api/fmp/analyst-estimates/:symbol` | Analyst estimates |
454 +| `GET` | `/api/fmp/price-target/:symbol` | Price targets |
455 +| `GET` | `/api/fmp/insider-trading/:symbol` | Insider trades |
456 +| `GET` | `/api/fmp/institutional-holders/:symbol` | Institutional holders |
457 +| `GET` | `/api/fmp/earnings-surprises/:symbol` | Earnings surprises |
458 +| `GET` | `/api/fmp/dividend-history/:symbol` | Dividend history |
459 +| `GET` | `/api/fmp/esg-score/:symbol` | ESG score |
460 +| `GET` | `/api/fmp/financial-news/:symbol` | Financial news |
461 +| `GET` | `/api/fmp/upgrades-downgrades/:symbol` | Analyst upgrades/downgrades |
462 +| `GET` | `/api/fmp/gainers` | Top market gainers |
463 +| `GET` | `/api/fmp/losers` | Top market losers |
464 +| `GET` | `/api/fmp/actives` | Most active stocks |
465 +| `GET` | `/api/fmp/market-hours` | Market hours |
466 +| `GET` | `/api/fmp/chart/light/:symbol` | Lightweight chart data |
467 +
468 +### Analytics & Admin
469 +
470 +| Method | Endpoint | Description |
471 +|--------|----------|-------------|
472 +| `POST` | `/api/analytics/heartbeat` | Update user activity |
473 +| `GET` | `/api/analytics/active-users` | Active users (admin) |
474 +| `GET` | `/api/analytics/real-time-stats` | Real-time stats |
475 +| `GET` | `/api/analytics/metrics` | Historical metrics (admin) |
476 +| `POST` | `/api/admin/login` | Admin login |
477 +| `GET` | `/api/admin/database` | Full database view (admin) |
478 +| `DELETE` | `/api/admin/users/:id` | Delete user (admin) |
479 +| `DELETE` | `/api/admin/sessions/:id` | Delete session (admin) |
480 +| `DELETE` | `/api/admin/reports/:id` | Delete report (admin) |
481 +
482 +---
483 +
484 +## Usage Examples
485 +
486 +### Financial Analysis
487 +```
488 +"Full analysis of NVDA: profile, financials, insider trades, analyst ratings, sector performance"
489 +
490 +"Compare AAPL, MSFT, GOOGL on: market cap, P/E, revenue growth, ESG scores, analyst consensus"
491 +
492 +"Due diligence on TSLA: DCF valuation, institutional holders, insider trading patterns, earnings surprises"
493 +
494 +"Show me the top gainers today with their financial ratios and analyst ratings"
495 +```
496 +
497 +### Quantitative Analysis
498 +```
499 +"Run a Monte Carlo simulation on AAPL with 10,000 paths over 252 trading days"
500 +
501 +"Calculate the correlation matrix of FAANG stocks and display a heatmap"
502 +
503 +"Backtest an RSI strategy on SPY and show the equity curve with Sharpe ratio"
504 +
505 +"Optimize a portfolio of AAPL, MSFT, GOOGL, AMZN for maximum Sharpe ratio"
506 +
507 +"Calculate Value at Risk for a $100,000 NVDA position at 95% and 99% confidence"
508 +
509 +"Estimate GARCH volatility for TSLA and forecast 30-day ahead volatility"
510 +
511 +"Price a call option on AAPL with strike $200, 30 days to expiry, and show all Greeks"
512 +```
513 +
514 +### Web Research
515 +```
516 +"Search latest AI industry news this week and summarize key developments"
517 +
518 +"Extract the last NVIDIA earnings call transcript and highlight guidance changes"
519 +
520 +"Research the impact of Fed rate decisions on tech stocks in 2024-2025"
521 +
522 +"Crawl investor.nvidia.com and extract all Q4 financial press releases"
523 +```
524 +
525 +---
526 +
527 +## Documentation
528 +
529 +Exhaustive documentation is available in the [`docs/`](./docs) directory:
530 +
531 +| Document | Description |
532 +|----------|-------------|
533 +| **[Getting Started](./docs/GETTING_STARTED.md)** | Prerequisites, installation, Python setup, env vars, first run, troubleshooting |
534 +| **[Architecture](./docs/ARCHITECTURE.md)** | System diagram, directory structure, data flow, design decisions |
535 +| **[API Reference](./docs/API_REFERENCE.md)** | All 44 endpoints with request/response schemas, SSE event types, query params |
536 +| **[Python Analysis](./docs/PYTHON_ANALYSIS.md)** | 8 built-in models (Monte Carlo, Black-Scholes, GARCH, VaR, portfolio, risk, plots, custom), all 17 libraries |
537 +| **[Database Schema](./docs/DATABASE.md)** | All 9 tables with columns, types, constraints, relationships |
538 +| **[Deployment](./docs/DEPLOYMENT.md)** | Production build, env vars, Docker example, CORS, security checklist |
539 +| **[Contributing](./docs/CONTRIBUTING.md)** | Dev setup, code conventions, commit format, how to add endpoints/tools |
540 +| **[`.env.example`](./.env.example)** | All environment variables with categories |
541 +
542 +---
543 +
544 +## Testing
545 +
546 +[![Tests](https://img.shields.io/badge/Tests-41_passing-22c55e?style=flat-square&logo=vitest&logoColor=white)](./)
547 +[![Vitest](https://img.shields.io/badge/Framework-Vitest_4-6e9f18?style=flat-square)](./)
548 +[![Duration](https://img.shields.io/badge/Duration-<1s-22c55e?style=flat-square)](./)
549 +
550 +| Suite | Tests | What it covers |
551 +|-------|-------|---------------|
552 +| `App.test.tsx` | 3 | Smoke test: renders, shows home page, search bar present |
553 +| `config.test.ts` | 5 | App config: name, domain, endpoints, limits, feature flags |
554 +| `utils.test.ts` | 5 | `cn()`: class merging, conditionals, dedup, edge cases |
555 +| `queryClient.test.ts` | 6 | Query defaults: staleTime, gcTime, retry, refetch policy |
556 +| `useChatSession.test.tsx` | 6 | Hook: initial state, reset, history toggle, mutation shape, handlers |
557 +| `validation.test.ts` | 16 | All Zod schemas: valid/invalid inputs, missing fields, max lengths |
558 +
559 +```bash
560 +npm test # Run once
561 +npm run test:watch # Watch mode
562 +npm run test:coverage # With coverage
563 +```
564 +
565 +---
566 +
567 +## Deployment
568 +
569 +### Production Build
570 +
571 +```bash
572 +npm run build # Builds client (Vite) + server (esbuild)
573 +npm start # Starts production server
574 +```
575 +
576 +### Environment Variables
577 +
578 +```bash
579 +# Required
580 +NODE_ENV=production
581 +PORT=5000
582 +SESSION_SECRET=<generate-a-strong-random-secret>
583 +ANTHROPIC_API_KEY=sk-ant-...
584 +FINANCIAL_MODELING_PREP_API_KEY=...
585 +
586 +# Database (default: SQLite)
587 +DATABASE_URL=sqlite://local.db
588 +# DATABASE_URL=postgresql://user:pass@host:5432/db
589 +
590 +# Optional APIs
591 +FIRECRAWL_API_KEY=fc-...
592 +TAVILY_API_KEY=tvly-...
593 +EXA_API_KEY=...
594 +SERPAPI_API_KEY=...
595 +```
596 +
597 +---
598 +
599 +## Contributing
600 +
601 +1. Fork the repository
602 +2. Create a feature branch (`git checkout -b feature/my-feature`)
603 +3. Run checks before committing:
604 + ```bash
605 + npm run typecheck && npm run lint && npm test
606 + ```
607 +4. Commit with a descriptive message
608 +5. Open a Pull Request
609 +
610 +### Code Conventions
611 +
612 +| Rule | Enforced By |
613 +|------|------------|
614 +| TypeScript `strict` mode | `tsconfig.json` |
615 +| ESLint (typescript-eslint + react-hooks) | `eslint.config.js` |
616 +| Prettier formatting | `.prettierrc` |
617 +| Zod validation on all API inputs | `server/routes/validation.ts` |
618 +| `logger` instead of `console.log` | ESLint `no-console` rule |
619 +| `@/` path alias (client), `@shared/` (shared) | `tsconfig.json` + `vite.config.ts` |
620 +| Components grouped by domain | `components/chat/`, `components/explore/`, etc. |
621 +| One hook per concern | `useChatSession`, `useChatMutation`, etc. |
622 +
623 +---
624 +
625 +## Authors
626 +
627 +<table>
628 + <tr>
629 + <td align="center" width="50%">
630 + <b>Simon-Pierre Boucher</b><br/>
631 + <i>Creator & Lead Developer</i><br/><br/>
632 + <a href="mailto:contact@spboucher.ai"><img src="https://img.shields.io/badge/Email-contact%40spboucher.ai-8b5cf6?style=flat-square&logo=maildotru&logoColor=white"/></a><br/>
633 + <a href="https://www.spboucher.ai"><img src="https://img.shields.io/badge/Website-spboucher.ai-3b82f6?style=flat-square&logo=safari&logoColor=white"/></a><br/>
634 + <a href="https://github.com/spboucher-ai"><img src="https://img.shields.io/badge/GitHub-spboucher--ai-181717?style=flat-square&logo=github&logoColor=white"/></a>
635 + </td>
636 + <td align="center" width="50%">
637 + <b>Claude Opus 4.8</b><br/>
638 + <i>AI Pair Programmer</i><br/><br/>
639 + <a href="https://claude.ai/claude-code"><img src="https://img.shields.io/badge/Claude_Code-Anthropic-d97706?style=flat-square&logo=anthropic&logoColor=white"/></a><br/>
640 + <a href="https://anthropic.com"><img src="https://img.shields.io/badge/Model-Opus_4.8_(1M_ctx)-d97706?style=flat-square"/></a><br/>
641 + <img src="https://img.shields.io/badge/Contribution-9_commits,_4000+_lines-22c55e?style=flat-square"/>
642 + </td>
643 + </tr>
644 +</table>
645 +
646 +> Full codebase audit, architecture restructuring, TypeScript typing, route modularization, hook extraction, ESLint/Prettier/Vitest setup, ErrorBoundary, Zod validation, and README authored via [Claude Code](https://claude.ai/claude-code).
647 +
648 +---
649 +
650 +## Acknowledgments
651 +
652 +[![Anthropic](https://img.shields.io/badge/Anthropic-Claude_AI-d97706?style=flat-square&logo=anthropic&logoColor=white)](https://anthropic.com/)
653 +[![FMP](https://img.shields.io/badge/Financial_Modeling_Prep-Financial_Data-0969da?style=flat-square)](https://financialmodelingprep.com/)
654 +[![Firecrawl](https://img.shields.io/badge/Firecrawl-Web_Extraction-f97316?style=flat-square)](https://firecrawl.dev/)
655 +[![Tavily](https://img.shields.io/badge/Tavily-AI_Search-4f46e5?style=flat-square)](https://tavily.com/)
656 +[![Exa](https://img.shields.io/badge/Exa-Semantic_Search-6366f1?style=flat-square)](https://exa.ai/)
657 +[![Radix UI](https://img.shields.io/badge/Radix_UI-Components-161618?style=flat-square)](https://radix-ui.com/)
658 +[![shadcn/ui](https://img.shields.io/badge/shadcn/ui-Design_System-000000?style=flat-square)](https://ui.shadcn.com/)
659 +[![Recharts](https://img.shields.io/badge/Recharts-Charts-22b5bf?style=flat-square)](https://recharts.org/)
660 +
661 +---
662 +
663 +## License
664 +
665 +[![MIT License](https://img.shields.io/badge/License-MIT-22c55e?style=for-the-badge)](./LICENSE)
666 +
667 +---
668 +
669 +<div align="center">
670 +
671 +<br/>
672 +
673 +[![Website](https://img.shields.io/badge/www.vquant.ai-Visit_Live_Platform-00c853?style=for-the-badge&logo=googlechrome&logoColor=white)](https://www.vquant.ai)
674 +
675 +<br/>
676 +
677 +*Where AI Meets Institutional-Grade Financial Analysis*
678 +
679 +<br/>
680 +
681 +**Built with** &nbsp;
682 +[![TypeScript](https://img.shields.io/badge/-TypeScript-3178c6?style=flat-square&logo=typescript&logoColor=white)](./)
683 +[![React](https://img.shields.io/badge/-React-61dafb?style=flat-square&logo=react&logoColor=black)](./)
684 +[![Claude](https://img.shields.io/badge/-Claude_AI-d97706?style=flat-square&logo=anthropic&logoColor=white)](./)
685 +[![Python](https://img.shields.io/badge/-Python-3776ab?style=flat-square&logo=python&logoColor=white)](./)
686 +[![Vite](https://img.shields.io/badge/-Vite-646cff?style=flat-square&logo=vite&logoColor=white)](./)
687 +[![Tailwind](https://img.shields.io/badge/-Tailwind-06b6d4?style=flat-square&logo=tailwindcss&logoColor=white)](./)
688 +
689 +</div>
added client/index.html +66 −0
@@ -0,0 +1,66 @@
1 +<!DOCTYPE html>
2 +<!--
3 + =============================================================================
4 + VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
5 + -----------------------------------------------------------------------------
6 + File: client/index.html
7 +
8 + Author: Simon-Pierre Boucher
9 + Contact: contact@spboucher.ai
10 + Website: https://www.spboucher.ai
11 + Demo: https://www.vquant.ai
12 + License: MIT (see LICENSE)
13 +
14 + Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
15 + =============================================================================
16 +-->
17 +
18 +<html lang="fr">
19 + <head>
20 + <meta charset="UTF-8" />
21 + <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1" />
22 +
23 + <!-- Primary Meta Tags -->
24 + <title>VQuant | AI-Powered Financial Intelligence Platform</title>
25 + <meta name="title" content="VQuant | AI-Powered Financial Intelligence Platform" />
26 + <meta name="description" content="Plateforme d'analyse financière de niveau institutionnel propulsée par Claude AI. 265+ sources de données, analyses quantitatives avancées, extraction de PDFs, et recherche multi-sources." />
27 + <meta name="keywords" content="analyse financière, IA, Claude AI, FMP, analyse quantitative, trading, investissement, bourse, actions, ETF, Monte Carlo, portfolio optimization" />
28 + <meta name="author" content="VQuant" />
29 +
30 + <!-- Canonical URL -->
31 + <link rel="canonical" href="https://www.vquant.ai" />
32 +
33 + <!-- Open Graph / Facebook -->
34 + <meta property="og:type" content="website" />
35 + <meta property="og:url" content="https://www.vquant.ai" />
36 + <meta property="og:title" content="VQuant | AI-Powered Financial Intelligence" />
37 + <meta property="og:description" content="Plateforme d'analyse financière propulsée par IA avec 265+ sources de données et analyses quantitatives avancées" />
38 + <meta property="og:site_name" content="VQuant" />
39 +
40 + <!-- Twitter -->
41 + <meta property="twitter:card" content="summary_large_image" />
42 + <meta property="twitter:url" content="https://www.vquant.ai" />
43 + <meta property="twitter:title" content="VQuant | AI-Powered Financial Intelligence" />
44 + <meta property="twitter:description" content="Plateforme d'analyse financière propulsée par IA avec 265+ sources de données" />
45 +
46 + <!-- Fonts -->
47 + <link rel="preconnect" href="https://fonts.googleapis.com">
48 + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
49 + <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
50 +
51 + <!-- Favicon -->
52 + <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
53 +
54 + <!-- Desktop app (Electron) title bar adaptation -->
55 + <style>
56 + @media all {
57 + .electron-drag { -webkit-app-region: drag; }
58 + .electron-no-drag { -webkit-app-region: no-drag; }
59 + }
60 + </style>
61 + </head>
62 + <body>
63 + <div id="root"></div>
64 + <script type="module" src="/src/main.tsx"></script>
65 + </body>
66 +</html>
\ No newline at end of file
added client/src/App.test.tsx +39 −0
@@ -0,0 +1,39 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/App.test.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { describe, it, expect } from "vitest";
18 +import { render, screen } from "@testing-library/react";
19 +import App from "./App";
20 +
21 +describe("App", () => {
22 + it("renders without crashing", () => {
23 + const { container } = render(<App />);
24 + expect(container).toBeTruthy();
25 + });
26 +
27 + it("renders the home page by default", () => {
28 + render(<App />);
29 + // The home page has a data-testid="page-home"
30 + expect(screen.getByTestId("page-home")).toBeInTheDocument();
31 + });
32 +
33 + it("renders the search bar", () => {
34 + render(<App />);
35 + expect(
36 + screen.getByPlaceholderText(/question.*march/i),
37 + ).toBeInTheDocument();
38 + });
39 +});
added client/src/App.tsx +67 −0
@@ -0,0 +1,67 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/App.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { Switch, Route } from "wouter";
18 +import { queryClient } from "./lib/queryClient";
19 +import { QueryClientProvider } from "@tanstack/react-query";
20 +import { Toaster } from "@/components/ui/toaster";
21 +import { TooltipProvider } from "@/components/ui/tooltip";
22 +import { ThemeProvider } from "@/components/theme-provider";
23 +import { ErrorBoundary } from "@/components/error-boundary";
24 +import Home from "@/pages/home";
25 +import Showcase from "@/pages/showcase";
26 +import Admin from "@/pages/admin";
27 +import SharedReport from "@/pages/shared-report";
28 +import Documentation from "@/pages/documentation";
29 +import Privacy from "@/pages/privacy";
30 +import Terms from "@/pages/terms";
31 +import SlidesGenerator from "@/pages/slides-generator";
32 +import Auth from "@/pages/auth";
33 +import NotFound from "@/pages/not-found";
34 +
35 +function Router() {
36 + return (
37 + <Switch>
38 + <Route path="/" component={Home} />
39 + <Route path="/auth" component={Auth} />
40 + <Route path="/showcase" component={Showcase} />
41 + <Route path="/admin" component={Admin} />
42 + <Route path="/share/:shareId" component={SharedReport} />
43 + <Route path="/docs" component={Documentation} />
44 + <Route path="/privacy" component={Privacy} />
45 + <Route path="/terms" component={Terms} />
46 + <Route path="/slides-generator" component={SlidesGenerator} />
47 + <Route component={NotFound} />
48 + </Switch>
49 + );
50 +}
51 +
52 +function App() {
53 + return (
54 + <ErrorBoundary>
55 + <QueryClientProvider client={queryClient}>
56 + <ThemeProvider defaultTheme="light" storageKey="vquant-theme-v2">
57 + <TooltipProvider>
58 + <Toaster />
59 + <Router />
60 + </TooltipProvider>
61 + </ThemeProvider>
62 + </QueryClientProvider>
63 + </ErrorBoundary>
64 + );
65 +}
66 +
67 +export default App;
added client/src/components/chat/agent-steps.tsx +57 −0
@@ -0,0 +1,57 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/chat/agent-steps.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { Check } from "lucide-react";
18 +
19 +export interface AgentStep {
20 + tool: string;
21 + status: 'pending' | 'running' | 'completed' | 'error';
22 + input?: any;
23 + error?: string;
24 + duration?: number;
25 +}
26 +
27 +interface AgentStepsProps {
28 + steps: AgentStep[];
29 + currentStep?: number;
30 +}
31 +
32 +export function AgentSteps({ steps, currentStep }: AgentStepsProps) {
33 + if (steps.length === 0) return null;
34 +
35 + // Only show completed steps with simple green circle and checkmark
36 + const completedSteps = steps.filter(s => s.status === 'completed');
37 +
38 + if (completedSteps.length === 0) return null;
39 +
40 + return (
41 + <div className="flex flex-wrap gap-2 mb-4">
42 + {completedSteps.map((step, index) => (
43 + <div
44 + key={`${step.tool}-${index}`}
45 + className="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-green-500/10 border border-green-500/20"
46 + >
47 + <div className="w-4 h-4 rounded-full bg-green-500/20 flex items-center justify-center flex-shrink-0">
48 + <Check className="w-3 h-3 text-green-500" />
49 + </div>
50 + <span className="text-xs text-muted-foreground">
51 + {step.tool.replace(/_/g, ' ')}
52 + </span>
53 + </div>
54 + ))}
55 + </div>
56 + );
57 +}
added client/src/components/chat/conversation-history.tsx +89 −0
@@ -0,0 +1,89 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/chat/conversation-history.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import {
18 + Accordion,
19 + AccordionContent,
20 + AccordionItem,
21 + AccordionTrigger,
22 +} from "@/components/ui/accordion";
23 +import { StreamingAnswer } from "@/components/streaming-answer";
24 +
25 +interface ConversationMessage {
26 + question: string;
27 + answer: string;
28 +}
29 +
30 +interface ConversationHistoryProps {
31 + history: ConversationMessage[];
32 +}
33 +
34 +export function ConversationHistory({ history }: ConversationHistoryProps) {
35 + if (history.length === 0) {
36 + return null;
37 + }
38 +
39 + return (
40 + <div className="w-full max-w-4xl mx-auto mb-8 space-y-3">
41 + {history.map((message, index) => (
42 + <Accordion
43 + key={index}
44 + type="single"
45 + collapsible
46 + className="w-full"
47 + data-testid={`accordion-conversation-${index}`}
48 + >
49 + <AccordionItem value={`item-${index}`} className="border rounded-lg">
50 + <AccordionTrigger
51 + className="px-4 py-3 hover-elevate no-underline text-left"
52 + data-testid={`trigger-conversation-${index}`}
53 + >
54 + <div className="flex items-start gap-3 w-full pr-4">
55 + <div className="flex-shrink-0 w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center text-sm font-medium text-primary">
56 + {index + 1}
57 + </div>
58 + <div className="flex-1 min-w-0">
59 + <p className="text-sm font-medium truncate">
60 + {message.question}
61 + </p>
62 + </div>
63 + </div>
64 + </AccordionTrigger>
65 + <AccordionContent className="px-4 pb-4">
66 + <div className="pl-11">
67 + <div className="mb-3 text-sm font-medium text-muted-foreground">
68 + Question:
69 + </div>
70 + <div className="mb-4 p-3 rounded-md bg-muted/50">
71 + <p className="text-sm">{message.question}</p>
72 + </div>
73 + <div className="mb-3 text-sm font-medium text-muted-foreground">
74 + Answer:
75 + </div>
76 + <div className="prose prose-sm dark:prose-invert max-w-none">
77 + <StreamingAnswer
78 + content={message.answer}
79 + isStreaming={false}
80 + />
81 + </div>
82 + </div>
83 + </AccordionContent>
84 + </AccordionItem>
85 + </Accordion>
86 + ))}
87 + </div>
88 + );
89 +}
added client/src/components/chat/custom-python-figure.tsx +220 −0
@@ -0,0 +1,220 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/chat/custom-python-figure.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
18 +import { Badge } from "@/components/ui/badge";
19 +import { useState, useEffect } from "react";
20 +import { ChevronDown, ChevronUp, Download } from "lucide-react";
21 +import { Button } from "@/components/ui/button";
22 +
23 +interface FigureBatch {
24 + id: string;
25 + figures: string[]; // URLs (/figures/xxx.png) or base64 encoded images
26 + output?: string; // Text output from Python
27 + description?: string;
28 +}
29 +
30 +function figureSrc(figure: string): string {
31 + // If it starts with / or http, it's a URL — use directly
32 + if (figure.startsWith('/') || figure.startsWith('http')) return figure;
33 + // Otherwise treat as base64
34 + return `data:image/png;base64,${figure}`;
35 +}
36 +
37 +interface CustomPythonFigureGalleryProps {
38 + figureBatches: FigureBatch[]; // Array of figure batches
39 +}
40 +
41 +export function CustomPythonFigure({ figureBatches }: CustomPythonFigureGalleryProps) {
42 + // Initialize with all batches expanded
43 + const [expandedBatches, setExpandedBatches] = useState<Set<string>>(
44 + new Set(figureBatches.map(b => b.id))
45 + );
46 +
47 + // When new batches are added, expand them automatically
48 + useEffect(() => {
49 + const allIds = new Set(figureBatches.map(b => b.id));
50 + setExpandedBatches(allIds);
51 + }, [figureBatches.length]); // Update when number of batches changes
52 +
53 + if (!figureBatches || figureBatches.length === 0) {
54 + return null;
55 + }
56 +
57 + const totalFigures = figureBatches.reduce((sum, batch) => sum + batch.figures.length, 0);
58 +
59 + const toggleBatch = (batchId: string) => {
60 + setExpandedBatches(prev => {
61 + const newSet = new Set(prev);
62 + if (newSet.has(batchId)) {
63 + newSet.delete(batchId);
64 + } else {
65 + newSet.add(batchId);
66 + }
67 + return newSet;
68 + });
69 + };
70 +
71 + return (
72 + <Card className="w-full">
73 + <CardHeader>
74 + <CardTitle className="flex items-center gap-2">
75 + <span className="text-2xl">🐍</span>
76 + Galerie d'Analyses Python
77 + <Badge variant="secondary" className="ml-auto">
78 + {totalFigures} figure{totalFigures > 1 ? 's' : ''}
79 + </Badge>
80 + </CardTitle>
81 + <CardDescription>
82 + {figureBatches.length} analyse{figureBatches.length > 1 ? 's' : ''} Python générée{figureBatches.length > 1 ? 's' : ''}
83 + </CardDescription>
84 + </CardHeader>
85 + <CardContent className="space-y-4">
86 + {figureBatches.map((batch, batchIndex) => {
87 + const isExpanded = expandedBatches.has(batch.id);
88 + const batchNumber = batchIndex + 1;
89 +
90 + return (
91 + <div key={batch.id} className="border rounded-lg overflow-hidden">
92 + {/* Batch Header - Collapsible */}
93 + <div
94 + className="flex items-center justify-between p-4 bg-muted/30 cursor-pointer hover:bg-muted/50 transition-colors"
95 + onClick={() => toggleBatch(batch.id)}
96 + >
97 + <div className="flex items-center gap-3">
98 + <Badge variant="outline">#{batchNumber}</Badge>
99 + <div>
100 + <div className="font-semibold text-sm">
101 + {batch.description || `Analyse Python #${batchNumber}`}
102 + </div>
103 + <div className="text-xs text-muted-foreground">
104 + {batch.figures.length} figure{batch.figures.length > 1 ? 's' : ''}
105 + {batch.output && ` • ${batch.output.split('\n').length} lignes de résultats`}
106 + </div>
107 + </div>
108 + </div>
109 + <Button variant="ghost" size="sm">
110 + {isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
111 + </Button>
112 + </div>
113 +
114 + {/* Batch Content - Expandable */}
115 + {isExpanded && (
116 + <div className="p-4 space-y-4">
117 + {/* Text output if available */}
118 + {batch.output && batch.output.trim() && (
119 + <div className="bg-muted/50 p-4 rounded-lg">
120 + <h4 className="font-semibold mb-2 text-sm">📊 Résultats</h4>
121 + <pre className="text-sm font-mono whitespace-pre-wrap overflow-x-auto">
122 + {batch.output}
123 + </pre>
124 + </div>
125 + )}
126 +
127 + {/* Figures Grid */}
128 + <div className="space-y-3">
129 + <div className="flex items-center justify-between">
130 + <h4 className="font-semibold text-sm">
131 + 📈 Graphique{batch.figures.length > 1 ? 's' : ''}
132 + </h4>
133 + {batch.figures.length > 1 && (
134 + <Button
135 + variant="outline"
136 + size="sm"
137 + onClick={(e) => {
138 + e.stopPropagation();
139 + // Download all figures as zip would be nice, but for now just show button
140 + }}
141 + className="text-xs"
142 + >
143 + <Download className="w-3 h-3 mr-1" />
144 + Tout télécharger
145 + </Button>
146 + )}
147 + </div>
148 +
149 + <div className={`grid gap-4 ${batch.figures.length > 1 ? 'grid-cols-1 lg:grid-cols-2' : 'grid-cols-1'}`}>
150 + {batch.figures.map((figure, figIndex) => {
151 + const figureId = `${batch.id}-${figIndex}`;
152 +
153 + return (
154 + <div key={figIndex} className="border rounded-lg overflow-hidden bg-card group relative">
155 + {/* Figure ID badge for reference */}
156 + <div className="absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity">
157 + <Badge variant="secondary" className="text-xs font-mono">
158 + {figureId}
159 + </Badge>
160 + </div>
161 +
162 + <img
163 + src={figureSrc(figure)}
164 + alt={`Figure ${batchNumber}.${figIndex + 1}`}
165 + className="w-full h-auto cursor-pointer"
166 + style={{ maxHeight: '600px', objectFit: 'contain' }}
167 + onError={(e) => {
168 + const img = e.currentTarget;
169 + if (!img.dataset.retried) {
170 + img.dataset.retried = 'true';
171 + // Retry with cache-busted URL
172 + const src = figureSrc(figure);
173 + img.src = src.includes('?') ? `${src}&t=${Date.now()}` : `${src}?t=${Date.now()}`;
174 + } else {
175 + img.style.display = 'none';
176 + const fallback = img.parentElement?.querySelector('.figure-fallback');
177 + if (fallback) (fallback as HTMLElement).style.display = 'flex';
178 + }
179 + }}
180 + onClick={() => {
181 + const win = window.open();
182 + if (win) {
183 + win.document.write(`<img src="${figureSrc(figure)}" style="max-width: 100%; height: auto;" />`);
184 + win.document.title = `Figure ${batchNumber}.${figIndex + 1}`;
185 + }
186 + }}
187 + />
188 + <div className="figure-fallback items-center justify-center p-8 bg-muted/30" style={{ display: 'none' }}>
189 + <p className="text-sm text-muted-foreground">Figure non disponible</p>
190 + </div>
191 +
192 + {/* Figure info footer */}
193 + <div className="p-2 bg-muted/30 border-t flex items-center justify-between">
194 + <div className="text-xs text-muted-foreground">
195 + Figure {batchNumber}.{figIndex + 1}
196 + </div>
197 + <a
198 + href={figureSrc(figure)}
199 + download={`vibequant-figure-${figureId}.png`}
200 + className="text-xs text-primary hover:underline flex items-center gap-1"
201 + onClick={(e) => e.stopPropagation()}
202 + >
203 + <Download className="w-3 h-3" />
204 + Télécharger
205 + </a>
206 + </div>
207 + </div>
208 + );
209 + })}
210 + </div>
211 + </div>
212 + </div>
213 + )}
214 + </div>
215 + );
216 + })}
217 + </CardContent>
218 + </Card>
219 + );
220 +}
added client/src/components/chat/download-buttons.tsx +217 −0
@@ -0,0 +1,217 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/chat/download-buttons.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { FileText, FileDown, FileType, Loader2, Presentation } from "lucide-react";
18 +import { Button } from "@/components/ui/button";
19 +import { useState } from "react";
20 +import { useToast } from "@/hooks/use-toast";
21 +import { useLocation } from "wouter";
22 +
23 +interface DownloadButtonsProps {
24 + question: string;
25 + answer: string;
26 + contentRef?: React.RefObject<HTMLElement>;
27 + figureUrls?: string[];
28 +}
29 +
30 +export function DownloadButtons({ question, answer, contentRef, figureUrls = [] }: DownloadButtonsProps) {
31 + const [isGeneratingPDF, setIsGeneratingPDF] = useState(false);
32 + const [isGeneratingDOCX, setIsGeneratingDOCX] = useState(false);
33 + const { toast } = useToast();
34 + const [, setLocation] = useLocation();
35 +
36 + const handleDownloadMarkdown = () => {
37 + const content = answer;
38 + const blob = new Blob([content], { type: 'text/markdown' });
39 + const url = URL.createObjectURL(blob);
40 + const a = document.createElement('a');
41 + a.href = url;
42 + a.download = `vibequant-${Date.now()}.md`;
43 + document.body.appendChild(a);
44 + a.click();
45 + document.body.removeChild(a);
46 + URL.revokeObjectURL(url);
47 +
48 + toast({
49 + title: "Téléchargement réussi",
50 + description: "Le fichier Markdown a été téléchargé avec succès.",
51 + });
52 + };
53 +
54 + const handleDownloadPDF = async () => {
55 + setIsGeneratingPDF(true);
56 +
57 + try {
58 + const response = await fetch('/api/generate-pdf', {
59 + method: 'POST',
60 + headers: {
61 + 'Content-Type': 'application/json',
62 + },
63 + body: JSON.stringify({
64 + question,
65 + answer
66 + }),
67 + });
68 +
69 + if (!response.ok) {
70 + throw new Error('Failed to generate PDF');
71 + }
72 +
73 + const blob = await response.blob();
74 + const url = URL.createObjectURL(blob);
75 + const a = document.createElement('a');
76 + a.href = url;
77 + a.download = `vibequant-${Date.now()}.pdf`;
78 + document.body.appendChild(a);
79 + a.click();
80 + document.body.removeChild(a);
81 + URL.revokeObjectURL(url);
82 +
83 + toast({
84 + title: "PDF généré avec succès",
85 + description: "Le rapport PDF a été téléchargé.",
86 + });
87 + } catch (error) {
88 + console.error('Error generating PDF:', error);
89 + toast({
90 + title: "Erreur de génération",
91 + description: "Une erreur s'est produite lors de la génération du PDF. Veuillez réessayer.",
92 + variant: "destructive",
93 + });
94 + } finally {
95 + setIsGeneratingPDF(false);
96 + }
97 + };
98 +
99 + const handleDownloadDOCX = async () => {
100 + setIsGeneratingDOCX(true);
101 +
102 + try {
103 + const response = await fetch('/api/generate-docx', {
104 + method: 'POST',
105 + headers: {
106 + 'Content-Type': 'application/json',
107 + },
108 + body: JSON.stringify({
109 + question,
110 + answer
111 + }),
112 + });
113 +
114 + if (!response.ok) {
115 + throw new Error('Failed to generate Word document');
116 + }
117 +
118 + const blob = await response.blob();
119 + const url = URL.createObjectURL(blob);
120 + const a = document.createElement('a');
121 + a.href = url;
122 + a.download = `vibequant-${Date.now()}.docx`;
123 + document.body.appendChild(a);
124 + a.click();
125 + document.body.removeChild(a);
126 + URL.revokeObjectURL(url);
127 +
128 + toast({
129 + title: "Word généré avec succès",
130 + description: "Le document Word a été téléchargé.",
131 + });
132 + } catch (error) {
133 + console.error('Error generating DOCX:', error);
134 + toast({
135 + title: "Erreur de génération",
136 + description: "Une erreur s'est produite lors de la génération du Word. Veuillez réessayer.",
137 + variant: "destructive",
138 + });
139 + } finally {
140 + setIsGeneratingDOCX(false);
141 + }
142 + };
143 +
144 + const handleConvertToSlides = () => {
145 + // Store content in sessionStorage to pass to slides generator page
146 + sessionStorage.setItem('slidesContent', JSON.stringify({ question, answer, figureUrls }));
147 + setLocation('/slides-generator');
148 +
149 + toast({
150 + title: "Redirection...",
151 + description: "Ouverture du générateur de slides",
152 + });
153 + };
154 +
155 + return (
156 + <>
157 + <Button
158 + variant="outline"
159 + size="sm"
160 + onClick={handleDownloadMarkdown}
161 + className="gap-2 hover:bg-muted transition-colors"
162 + disabled={isGeneratingPDF}
163 + >
164 + <FileText className="w-4 h-4" />
165 + <span className="hidden sm:inline">Télécharger MD</span>
166 + </Button>
167 + <Button
168 + variant="outline"
169 + size="sm"
170 + onClick={handleDownloadPDF}
171 + className="gap-2 hover:bg-muted transition-colors"
172 + disabled={isGeneratingPDF}
173 + >
174 + {isGeneratingPDF ? (
175 + <>
176 + <Loader2 className="w-4 h-4 animate-spin" />
177 + <span className="hidden sm:inline">Génération...</span>
178 + </>
179 + ) : (
180 + <>
181 + <FileDown className="w-4 h-4" />
182 + <span className="hidden sm:inline">Télécharger PDF</span>
183 + </>
184 + )}
185 + </Button>
186 + <Button
187 + variant="outline"
188 + size="sm"
189 + onClick={handleDownloadDOCX}
190 + className="gap-2 hover:bg-muted transition-colors"
191 + disabled={isGeneratingDOCX}
192 + >
193 + {isGeneratingDOCX ? (
194 + <>
195 + <Loader2 className="w-4 h-4 animate-spin" />
196 + <span className="hidden sm:inline">Génération...</span>
197 + </>
198 + ) : (
199 + <>
200 + <FileType className="w-4 h-4" />
201 + <span className="hidden sm:inline">Télécharger Word</span>
202 + </>
203 + )}
204 + </Button>
205 + <Button
206 + variant="outline"
207 + size="sm"
208 + onClick={handleConvertToSlides}
209 + className="gap-2 hover:bg-muted transition-colors"
210 + disabled={isGeneratingPDF || isGeneratingDOCX || !question || !answer}
211 + >
212 + <Presentation className="w-4 h-4" />
213 + <span className="hidden sm:inline">Convertir en Slides</span>
214 + </Button>
215 + </>
216 + );
217 +}
added client/src/components/chat/empty-state.tsx +422 −0
@@ -0,0 +1,422 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/chat/empty-state.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { useState } from "react";
18 +import {
19 + TrendingUp, LineChart, DollarSign, PieChart, Sparkles,
20 + BarChart3, Activity, TrendingDown, Calculator, Target,
21 + Building2, Newspaper, Users, Globe, Coins, Gauge,
22 + Wand2, Crown, Gem, Atom, Feather, Rabbit, Bot,
23 + Brain, Cpu, Zap, Rocket, Bird, Sun, Earth, Moon
24 +} from "lucide-react";
25 +import {
26 + Select,
27 + SelectContent,
28 + SelectItem,
29 + SelectTrigger,
30 + SelectValue,
31 +} from "@/components/ui/select";
32 +import { AnimatePresence, motion } from "framer-motion";
33 +import type { ClaudeModel } from "@/hooks/useChatSession";
34 +
35 +interface EmptyStateProps {
36 + onQuestionClick?: (question: string) => void;
37 + selectedModel?: ClaudeModel;
38 + onModelChange?: (model: ClaudeModel) => void;
39 +}
40 +
41 +const MODEL_OPTIONS: { value: ClaudeModel; label: string; sub: string; icon: typeof Sparkles; disabled?: boolean }[] = [
42 + { value: "claude-fable-5", label: "Fable 5", sub: "Le plus intelligent (defaut)", icon: Wand2 },
43 + { value: "claude-opus-4-8", label: "Opus 4.8", sub: "Le plus puissant", icon: Crown },
44 + { value: "claude-opus-4-7", label: "Opus 4.7", sub: "Equilibre", icon: Gem },
45 + { value: "claude-opus-4-6", label: "Opus 4.6", sub: "Rapide", icon: Atom },
46 + { value: "claude-sonnet-4-6", label: "Sonnet 4.6", sub: "Polyvalent", icon: Feather },
47 + { value: "claude-haiku-4-5-20251001", label: "Haiku 4.5", sub: "Le plus rapide", icon: Rabbit },
48 + { value: "gpt-5.6-sol", label: "GPT-5.6 Sol", sub: "OpenAI - le plus puissant", icon: Sun },
49 + { value: "gpt-5.6-terra", label: "GPT-5.6 Terra", sub: "OpenAI - equilibre", icon: Earth },
50 + { value: "gpt-5.6-luna", label: "GPT-5.6 Luna", sub: "OpenAI - rapide et econome", icon: Moon },
51 + { value: "gpt-5.5", label: "GPT-5.5", sub: "OpenAI - puissant", icon: Bot },
52 + { value: "gpt-5.4", label: "GPT-5.4", sub: "OpenAI - contexte 1M", icon: Brain },
53 + { value: "gpt-5.2", label: "GPT-5.2", sub: "OpenAI - puissant", icon: Cpu },
54 + { value: "gpt-5.1", label: "GPT-5.1", sub: "OpenAI - equilibre", icon: Zap },
55 + { value: "gpt-5", label: "GPT-5", sub: "OpenAI - classique", icon: Sparkles },
56 + { value: "gpt-5-mini", label: "GPT-5 Mini", sub: "OpenAI - rapide", icon: Rocket },
57 + { value: "gpt-5-nano", label: "GPT-5 Nano", sub: "OpenAI - le plus rapide", icon: Bird },
58 + { value: "gpt-4.1", label: "GPT-4.1", sub: "OpenAI - contexte 1M", icon: Globe },
59 + { value: "gpt-4.1-mini", label: "GPT-4.1 Mini", sub: "OpenAI - econome", icon: Feather },
60 + { value: "gemini-3.5-flash", label: "Gemini 3.5 Flash", sub: "Google - le plus recent", icon: Sparkles },
61 + { value: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro", sub: "Google - le plus puissant", icon: Gem },
62 + { value: "gemini-3-flash-preview", label: "Gemini 3 Flash", sub: "Google - equilibre", icon: Zap },
63 + { value: "gemini-3.1-flash-lite", label: "Gemini 3.1 Flash Lite", sub: "Google - le plus rapide", icon: Bird },
64 +];
65 +
66 +const QUESTION_CATEGORIES = [
67 + {
68 + category: "Analyse Fondamentale",
69 + icon: Building2,
70 + questions: [
71 + "Analyse complète d'Apple (AAPL)",
72 + "Profil et métriques clés de Microsoft (MSFT)",
73 + "Bilan financier et ratios de Tesla",
74 + "États financiers complets de Google (GOOGL)",
75 + "Croissance des revenus d'Amazon sur 5 ans",
76 + "Flux de trésorerie de Meta (META)",
77 + "Santé financière et ratios clés de Netflix",
78 + ]
79 + },
80 + {
81 + category: "Analyse Technique",
82 + icon: Activity,
83 + questions: [
84 + "Indicateurs techniques RSI et MACD pour Tesla",
85 + "Moyennes mobiles EMA et SMA pour AAPL",
86 + "Analyse ADX et tendance de NVIDIA",
87 + "Stochastique et Williams %R pour AMD",
88 + "Tous les indicateurs techniques pour SPY",
89 + "Analyse technique complète de Bitcoin",
90 + "Points d'entrée et sortie pour Amazon",
91 + ]
92 + },
93 + {
94 + category: "Performance et Historique",
95 + icon: LineChart,
96 + questions: [
97 + "Performance de Tesla sur l'année",
98 + "Historique des prix Apple sur 5 ans",
99 + "Comparaison AAPL vs MSFT sur 2 ans",
100 + "Performance du S&P 500 ce trimestre",
101 + "Évolution du NASDAQ depuis janvier",
102 + "Historique intraday de NVDA aujourd'hui",
103 + "Performance des FAANG cette année",
104 + ]
105 + },
106 + {
107 + category: "Dividendes et Actions Corporate",
108 + icon: DollarSign,
109 + questions: [
110 + "Historique des dividendes Microsoft",
111 + "Rendement des dividendes de Coca-Cola",
112 + "Splits d'actions d'Apple historique",
113 + "Rachats d'actions et dividendes de JPMorgan",
114 + "Entreprises avec les meilleurs dividendes en technologie",
115 + "Calendrier des dividendes pour ce mois",
116 + ]
117 + },
118 + {
119 + category: "Résultats et Estimations",
120 + icon: Target,
121 + questions: [
122 + "Prochains résultats trimestriels d'Apple",
123 + "Surprises de résultats pour Tesla Q3",
124 + "Estimations des analystes pour NVIDIA",
125 + "Calendrier des résultats cette semaine",
126 + "Objectifs de prix des analystes pour Meta",
127 + "Révisions à la hausse ou baisse pour Amazon",
128 + ]
129 + },
130 + {
131 + category: "Actualités et Événements",
132 + icon: Newspaper,
133 + questions: [
134 + "Dernières nouvelles sur Tesla",
135 + "Communiqués de presse Apple cette semaine",
136 + "Actualités économiques du jour",
137 + "Calendrier économique de la semaine",
138 + "Prochaines IPO à surveiller",
139 + "Événements importants en finance cette semaine",
140 + ]
141 + },
142 + {
143 + category: "Trading d'Initiés et Institutionnel",
144 + icon: Users,
145 + questions: [
146 + "Trading d'initiés récent pour Apple",
147 + "Achats d'actions par les dirigeants de Tesla",
148 + "Trading des membres du Congrès ce mois",
149 + "Positions institutionnelles dans NVIDIA",
150 + "Changements de participations dans Meta",
151 + ]
152 + },
153 + {
154 + category: "Marchés Globaux",
155 + icon: Globe,
156 + questions: [
157 + "Taux de change EUR/USD aujourd'hui",
158 + "Prix des commodités or et pétrole",
159 + "Taux du Trésor américain actuels",
160 + "Performance des indices mondiaux",
161 + "Analyse du marché Forex majeurs",
162 + "COT Report pour le pétrole",
163 + ]
164 + },
165 + {
166 + category: "Gestion de Portefeuille",
167 + icon: PieChart,
168 + questions: [
169 + "Optimiser portefeuille AAPL, MSFT, GOOGL",
170 + "Allocation optimale pour tech stocks",
171 + "Diversification avec 10 actions",
172 + "Frontière efficiente pour mon portefeuille",
173 + "Ratio risque/rendement optimal",
174 + ]
175 + },
176 + {
177 + category: "Analyse de Risque",
178 + icon: TrendingDown,
179 + questions: [
180 + "Value at Risk (VaR) pour Tesla",
181 + "Volatilité GARCH de Bitcoin",
182 + "Métriques de risque pour mon portefeuille",
183 + "Drawdown maximum de NVIDIA",
184 + "Corrélations entre FAANG stocks",
185 + "Beta et volatilité d'Amazon",
186 + ]
187 + },
188 + {
189 + category: "Simulations et Prévisions",
190 + icon: BarChart3,
191 + questions: [
192 + "Simulation Monte Carlo NVDA $100k",
193 + "Projection de croissance Apple 5 ans",
194 + "Scénarios d'investissement Tesla",
195 + "Simulation de portefeuille diversifié",
196 + "Prévisions basées sur tendances historiques",
197 + ]
198 + },
199 + {
200 + category: "Options et Dérivés",
201 + icon: Calculator,
202 + questions: [
203 + "Prix d'option call Apple strike 180",
204 + "Stratégie d'options pour Tesla",
205 + "Surface de volatilité implicite NVDA",
206 + "Greeks pour options Microsoft",
207 + "Valorisation Black-Scholes pour call",
208 + ]
209 + },
210 + {
211 + category: "Screener et Comparaisons",
212 + icon: Gauge,
213 + questions: [
214 + "Meilleures actions tech par P/E ratio",
215 + "Compagnies similaires à Apple",
216 + "Screener: dividendes > 3% et P/E < 15",
217 + "Top 10 actions momentum ce mois",
218 + "Comparaison des géants tech FAANG",
219 + ]
220 + },
221 +];
222 +
223 +export function EmptyState({ onQuestionClick, selectedModel, onModelChange }: EmptyStateProps) {
224 + const [selectedCategoryIndex, setSelectedCategoryIndex] = useState<number | null>(null);
225 +
226 + const handleCategoryChange = (value: string) => {
227 + const index = parseInt(value);
228 + setSelectedCategoryIndex(index);
229 + };
230 +
231 + const selectedCategory = selectedCategoryIndex !== null
232 + ? QUESTION_CATEGORIES[selectedCategoryIndex]
233 + : null;
234 +
235 + return (
236 + <div className="flex flex-col items-center justify-center min-h-[60vh] space-y-8 animate-fade-in">
237 + <div className="relative text-center space-y-4">
238 + <div aria-hidden className="pointer-events-none absolute left-1/2 top-[-3rem] -z-10 h-64 w-[40rem] -translate-x-1/2 bg-grid hero-mask opacity-70" />
239 + <div aria-hidden className="pointer-events-none absolute left-1/2 top-[-1rem] -z-10 h-56 w-56 -translate-x-1/2 rounded-full bg-primary/20 blur-3xl" />
240 + <div className="flex flex-col items-center justify-center gap-4 mb-2">
241 + <div className="relative grid place-items-center size-16 rounded-2xl bg-hot shadow-hot animate-float">
242 + <span className="pointer-events-none absolute inset-0 rounded-2xl bg-gradient-to-br from-white/40 to-transparent opacity-60" />
243 + <Sparkles className="relative w-8 h-8 text-white" />
244 + </div>
245 + <h1 className="text-4xl sm:text-5xl font-extrabold tracking-tight">
246 + <span className="text-foreground">V</span><span className="gradient-text">Quant</span>
247 + </h1>
248 + <div className="inline-flex items-center gap-2 rounded-full border border-border bg-card/70 px-3.5 py-1.5 text-xs font-medium text-muted-foreground backdrop-blur">
249 + <span className="size-2 rounded-full bg-primary animate-pulse" />
250 + 200+ outils financiers - Python integre - Propulse par Fable 5
251 + </div>
252 + </div>
253 + <p className="text-lg text-muted-foreground max-w-md">
254 + Votre assistant d'analyse financière propulsé par l'IA
255 + </p>
256 + </div>
257 +
258 + <div className="w-full max-w-4xl space-y-6 px-4">
259 + <div className="text-center">
260 + <p className="text-sm text-muted-foreground mb-3">
261 + Choisissez une catégorie pour découvrir des exemples de questions
262 + </p>
263 + </div>
264 +
265 + {/* Category dropdown */}
266 + <div>
267 + <Select onValueChange={handleCategoryChange}>
268 + <SelectTrigger className="w-full h-12 text-base font-medium bg-card border border-border rounded-lg transition-colors hover:border-primary/40">
269 + <div className="flex items-center gap-3 px-1">
270 + <Sparkles className="w-4 h-4 text-primary flex-shrink-0" />
271 + <SelectValue placeholder="Sélectionnez une catégorie d'analyse..." />
272 + </div>
273 + </SelectTrigger>
274 +
275 + <SelectContent className="w-[var(--radix-select-trigger-width)] max-h-[450px] bg-card border border-border rounded-lg p-1">
276 + <div className="px-3 py-2 mb-1 border-b border-border">
277 + <p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
278 + {QUESTION_CATEGORIES.length} Catégories Disponibles
279 + </p>
280 + </div>
281 +
282 + <div className="space-y-0.5">
283 + {QUESTION_CATEGORIES.map((category, idx) => {
284 + const Icon = category.icon;
285 + return (
286 + <SelectItem
287 + key={idx}
288 + value={idx.toString()}
289 + className="cursor-pointer py-3 px-3 text-base rounded-md transition-colors"
290 + >
291 + <div className="flex items-center gap-3">
292 + <div className="p-1.5 rounded-md bg-primary/10 border border-primary/20">
293 + <Icon className="w-4 h-4 text-primary" />
294 + </div>
295 + <div className="flex flex-col flex-1">
296 + <span className="font-medium text-foreground">
297 + {category.category}
298 + </span>
299 + <span className="text-xs text-muted-foreground">
300 + {category.questions.length} exemples
301 + </span>
302 + </div>
303 + </div>
304 + </SelectItem>
305 + );
306 + })}
307 + </div>
308 + </SelectContent>
309 + </Select>
310 + </div>
311 +
312 + {/* Questions for selected category */}
313 + <AnimatePresence mode="wait">
314 + {selectedCategory && (
315 + <motion.div
316 + key={selectedCategoryIndex}
317 + initial={{ opacity: 0, y: 12 }}
318 + animate={{ opacity: 1, y: 0 }}
319 + exit={{ opacity: 0, y: -12 }}
320 + transition={{ duration: 0.2 }}
321 + className="space-y-4"
322 + >
323 + {/* Category header */}
324 + <div className="flex items-center gap-3 p-4 rounded-lg bg-card border border-border">
325 + {(() => {
326 + const Icon = selectedCategory.icon;
327 + return (
328 + <div className="p-2 rounded-md bg-primary/10 border border-primary/20">
329 + <Icon className="w-5 h-5 text-primary" />
330 + </div>
331 + );
332 + })()}
333 + <h3 className="text-xl font-bold text-foreground">
334 + {selectedCategory.category}
335 + </h3>
336 + </div>
337 +
338 + {/* Questions grid */}
339 + <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
340 + {selectedCategory.questions.map((question, qIdx) => (
341 + <button
342 + key={qIdx}
343 + onClick={() => onQuestionClick?.(question)}
344 + className="group p-4 text-left rounded-lg bg-card border border-border hover:border-primary/40 transition-colors"
345 + >
346 + <div className="flex items-start gap-3">
347 + <div className="flex-shrink-0 w-7 h-7 rounded-md bg-muted flex items-center justify-center font-medium text-sm text-muted-foreground">
348 + {qIdx + 1}
349 + </div>
350 + <p className="flex-1 text-sm font-medium leading-relaxed group-hover:text-foreground transition-colors">
351 + {question}
352 + </p>
353 + </div>
354 + </button>
355 + ))}
356 + </div>
357 + </motion.div>
358 + )}
359 + </AnimatePresence>
360 +
361 + {/* Model selector */}
362 + <div className="pt-2">
363 + <p className="text-xs text-muted-foreground mb-2 text-center">
364 + Modele d IA utilise
365 + </p>
366 + <Select value={selectedModel} onValueChange={(v) => onModelChange?.(v as ClaudeModel)}>
367 + <SelectTrigger className="w-full h-12 text-base font-medium bg-card border border-border rounded-lg transition-colors hover:border-primary/40">
368 + <div className="flex items-center gap-3 px-1">
369 + {(() => {
370 + const cur = MODEL_OPTIONS.find((m) => m.value === selectedModel) ?? MODEL_OPTIONS[0];
371 + const Icon = cur.icon;
372 + return (
373 + <>
374 + <Icon className="w-4 h-4 text-primary flex-shrink-0" />
375 + <span className="text-foreground">{cur.label}</span>
376 + </>
377 + );
378 + })()}
379 + </div>
380 + </SelectTrigger>
381 + <SelectContent className="w-[var(--radix-select-trigger-width)] bg-card border border-border rounded-lg p-1">
382 + <div className="px-3 py-2 mb-1 border-b border-border">
383 + <p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
384 + Choisir un modele
385 + </p>
386 + </div>
387 + <div className="space-y-0.5">
388 + {MODEL_OPTIONS.map((m) => {
389 + const Icon = m.icon;
390 + return (
391 + <SelectItem
392 + key={m.value}
393 + value={m.value}
394 + textValue={m.label}
395 + disabled={m.disabled}
396 + className="cursor-pointer py-3 px-3 text-base rounded-md transition-colors data-[disabled]:opacity-50 data-[disabled]:cursor-not-allowed"
397 + >
398 + <div className="flex items-center gap-3">
399 + <div className="p-1.5 rounded-md bg-primary/10 border border-primary/20">
400 + <Icon className="w-4 h-4 text-primary" />
401 + </div>
402 + <div className="flex flex-col flex-1">
403 + <span className="font-medium text-foreground">{m.label}</span>
404 + <span className="text-xs text-muted-foreground">{m.sub}</span>
405 + </div>
406 + {m.disabled && (
407 + <span className="text-[10px] uppercase tracking-wider font-semibold text-amber-500 border border-amber-500/40 rounded px-1.5 py-0.5">
408 + Indispo
409 + </span>
410 + )}
411 + </div>
412 + </SelectItem>
413 + );
414 + })}
415 + </div>
416 + </SelectContent>
417 + </Select>
418 + </div>
419 + </div>
420 + </div>
421 + );
422 +}
added client/src/components/chat/error-state.tsx +56 −0
@@ -0,0 +1,56 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/chat/error-state.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { motion } from "framer-motion";
18 +import { AlertCircle, RefreshCw } from "lucide-react";
19 +import { Button } from "@/components/ui/button";
20 +
21 +interface ErrorStateProps {
22 + error: string;
23 + onRetry?: () => void;
24 +}
25 +
26 +export function ErrorState({ error, onRetry }: ErrorStateProps) {
27 + return (
28 + <motion.div
29 + initial={{ opacity: 0, y: 8 }}
30 + animate={{ opacity: 1, y: 0 }}
31 + className="w-full max-w-2xl mx-auto"
32 + data-testid="error-state"
33 + >
34 + <div className="bg-destructive/10 border border-destructive/30 rounded-xl p-8 text-center">
35 + <AlertCircle className="w-12 h-12 text-destructive mx-auto mb-4" />
36 + <h3 className="text-lg font-semibold text-destructive mb-2">
37 + Something went wrong
38 + </h3>
39 + <p className="text-sm text-muted-foreground mb-6">
40 + {error}
41 + </p>
42 + {onRetry && (
43 + <Button
44 + onClick={onRetry}
45 + variant="outline"
46 + className="hover-elevate active-elevate-2"
47 + data-testid="button-retry"
48 + >
49 + <RefreshCw className="w-4 h-4 mr-2" />
50 + Try Again
51 + </Button>
52 + )}
53 + </div>
54 + </motion.div>
55 + );
56 +}
added client/src/components/chat/monte-carlo-results.tsx +226 −0
@@ -0,0 +1,226 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/chat/monte-carlo-results.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { motion } from "framer-motion";
18 +import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
19 +import { TrendingUp, Code2, BarChart3, DollarSign } from "lucide-react";
20 +import { Badge } from "@/components/ui/badge";
21 +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
22 +
23 +interface MonteCarloResultsProps {
24 + pythonCode?: string;
25 + results?: {
26 + success: boolean;
27 + parameters?: {
28 + num_simulations: number;
29 + time_horizon: number;
30 + initial_investment: number;
31 + mean_return: number;
32 + std_return: number;
33 + };
34 + statistics?: {
35 + mean_final_value: number;
36 + median_final_value: number;
37 + std_final_value: number;
38 + min_final_value: number;
39 + max_final_value: number;
40 + percentile_5: number;
41 + percentile_25: number;
42 + percentile_75: number;
43 + percentile_95: number;
44 + probability_of_profit: number;
45 + };
46 + };
47 +}
48 +
49 +export function MonteCarloResults({ pythonCode, results }: MonteCarloResultsProps) {
50 + if (!pythonCode && !results) return null;
51 +
52 + const formatCurrency = (value: number) => {
53 + return new Intl.NumberFormat('fr-FR', {
54 + style: 'currency',
55 + currency: 'USD',
56 + minimumFractionDigits: 2,
57 + maximumFractionDigits: 2
58 + }).format(value);
59 + };
60 +
61 + const formatPercent = (value: number) => {
62 + return `${value.toFixed(2)}%`;
63 + };
64 +
65 + return (
66 + <motion.div
67 + initial={{ opacity: 0, y: 8 }}
68 + animate={{ opacity: 1, y: 0 }}
69 + transition={{ duration: 0.3 }}
70 + className="w-full max-w-5xl mx-auto"
71 + data-testid="monte-carlo-results"
72 + >
73 + <Card className="border-primary/20 shadow-lg">
74 + <CardHeader className="gap-2">
75 + <div className="flex items-center gap-2">
76 + <BarChart3 className="w-5 h-5 text-primary" />
77 + <CardTitle className="text-xl">Simulation Monte Carlo</CardTitle>
78 + </div>
79 + <p className="text-sm text-muted-foreground">
80 + Analyse de risque et projection des rendements futurs
81 + </p>
82 + </CardHeader>
83 + <CardContent>
84 + <Tabs defaultValue="results" className="w-full">
85 + <TabsList className="grid w-full grid-cols-2 mb-4" data-testid="tabs-monte-carlo">
86 + <TabsTrigger value="results" data-testid="tab-results">
87 + <TrendingUp className="w-4 h-4 mr-2" />
88 + Résultats
89 + </TabsTrigger>
90 + <TabsTrigger value="code" data-testid="tab-code">
91 + <Code2 className="w-4 h-4 mr-2" />
92 + Code Python
93 + </TabsTrigger>
94 + </TabsList>
95 +
96 + <TabsContent value="results" data-testid="content-results">
97 + {results?.success && results.statistics && results.parameters ? (
98 + <div className="space-y-6">
99 + {/* Parameters */}
100 + <div className="bg-muted/30 rounded-lg p-4 space-y-2">
101 + <h3 className="font-semibold text-sm text-muted-foreground mb-3">Paramètres de simulation</h3>
102 + <div className="grid grid-cols-2 md:grid-cols-4 gap-3">
103 + <div className="space-y-1" data-testid="param-simulations">
104 + <p className="text-xs text-muted-foreground">Simulations</p>
105 + <p className="text-sm font-medium">{results.parameters.num_simulations.toLocaleString()}</p>
106 + </div>
107 + <div className="space-y-1" data-testid="param-horizon">
108 + <p className="text-xs text-muted-foreground">Horizon (jours)</p>
109 + <p className="text-sm font-medium">{results.parameters.time_horizon}</p>
110 + </div>
111 + <div className="space-y-1" data-testid="param-investment">
112 + <p className="text-xs text-muted-foreground">Investissement initial</p>
113 + <p className="text-sm font-medium">{formatCurrency(results.parameters.initial_investment)}</p>
114 + </div>
115 + <div className="space-y-1" data-testid="param-volatility">
116 + <p className="text-xs text-muted-foreground">Volatilité</p>
117 + <p className="text-sm font-medium">{formatPercent(results.parameters.std_return * 100)}</p>
118 + </div>
119 + </div>
120 + </div>
121 +
122 + {/* Key Statistics */}
123 + <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
124 + <Card data-testid="stat-mean">
125 + <CardContent className="pt-6">
126 + <div className="flex items-center gap-2 mb-2">
127 + <DollarSign className="w-4 h-4 text-primary" />
128 + <p className="text-sm text-muted-foreground">Valeur moyenne</p>
129 + </div>
130 + <p className="text-2xl font-bold">{formatCurrency(results.statistics.mean_final_value)}</p>
131 + </CardContent>
132 + </Card>
133 +
134 + <Card data-testid="stat-median">
135 + <CardContent className="pt-6">
136 + <div className="flex items-center gap-2 mb-2">
137 + <DollarSign className="w-4 h-4 text-primary" />
138 + <p className="text-sm text-muted-foreground">Valeur médiane</p>
139 + </div>
140 + <p className="text-2xl font-bold">{formatCurrency(results.statistics.median_final_value)}</p>
141 + </CardContent>
142 + </Card>
143 +
144 + <Card data-testid="stat-profit-prob">
145 + <CardContent className="pt-6">
146 + <div className="flex items-center gap-2 mb-2">
147 + <TrendingUp className="w-4 h-4 text-primary" />
148 + <p className="text-sm text-muted-foreground">Probabilité de profit</p>
149 + </div>
150 + <p className="text-2xl font-bold">{formatPercent(results.statistics.probability_of_profit)}</p>
151 + </CardContent>
152 + </Card>
153 + </div>
154 +
155 + {/* Percentiles */}
156 + <div className="bg-muted/30 rounded-lg p-4 space-y-3">
157 + <h3 className="font-semibold text-sm text-muted-foreground mb-3">Distribution des résultats</h3>
158 + <div className="space-y-2">
159 + <div className="flex justify-between items-center" data-testid="percentile-5">
160 + <span className="text-sm text-muted-foreground">5e percentile (pire scénario probable)</span>
161 + <Badge variant="outline">{formatCurrency(results.statistics.percentile_5)}</Badge>
162 + </div>
163 + <div className="flex justify-between items-center" data-testid="percentile-25">
164 + <span className="text-sm text-muted-foreground">25e percentile</span>
165 + <Badge variant="outline">{formatCurrency(results.statistics.percentile_25)}</Badge>
166 + </div>
167 + <div className="flex justify-between items-center" data-testid="percentile-75">
168 + <span className="text-sm text-muted-foreground">75e percentile</span>
169 + <Badge variant="outline">{formatCurrency(results.statistics.percentile_75)}</Badge>
170 + </div>
171 + <div className="flex justify-between items-center" data-testid="percentile-95">
172 + <span className="text-sm text-muted-foreground">95e percentile (meilleur scénario probable)</span>
173 + <Badge variant="outline">{formatCurrency(results.statistics.percentile_95)}</Badge>
174 + </div>
175 + </div>
176 + </div>
177 +
178 + {/* Range */}
179 + <div className="bg-muted/30 rounded-lg p-4">
180 + <h3 className="font-semibold text-sm text-muted-foreground mb-3">Plage de résultats</h3>
181 + <div className="flex justify-between items-center">
182 + <div className="text-center" data-testid="min-value">
183 + <p className="text-xs text-muted-foreground mb-1">Minimum</p>
184 + <p className="text-sm font-medium">{formatCurrency(results.statistics.min_final_value)}</p>
185 + </div>
186 + <div className="flex-1 mx-4">
187 + <div className="h-2 bg-gradient-to-r from-red-500 via-yellow-500 to-green-500 rounded-full" />
188 + </div>
189 + <div className="text-center" data-testid="max-value">
190 + <p className="text-xs text-muted-foreground mb-1">Maximum</p>
191 + <p className="text-sm font-medium">{formatCurrency(results.statistics.max_final_value)}</p>
192 + </div>
193 + </div>
194 + </div>
195 + </div>
196 + ) : (
197 + <div className="text-center py-8 text-muted-foreground">
198 + Aucun résultat disponible
199 + </div>
200 + )}
201 + </TabsContent>
202 +
203 + <TabsContent value="code" data-testid="content-code">
204 + {pythonCode ? (
205 + <div className="relative">
206 + <div className="absolute top-2 right-2 z-10">
207 + <Badge variant="outline" className="text-xs">
208 + Python
209 + </Badge>
210 + </div>
211 + <pre className="bg-muted p-4 rounded-lg overflow-x-auto text-sm font-mono" data-testid="python-code-block">
212 + <code className="text-foreground">{pythonCode}</code>
213 + </pre>
214 + </div>
215 + ) : (
216 + <div className="text-center py-8 text-muted-foreground">
217 + Aucun code disponible
218 + </div>
219 + )}
220 + </TabsContent>
221 + </Tabs>
222 + </CardContent>
223 + </Card>
224 + </motion.div>
225 + );
226 +}
added client/src/components/chat/result-card.tsx +73 −0
@@ -0,0 +1,73 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/chat/result-card.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { ExternalLink } from "lucide-react";
18 +import type { SearchResult } from "@shared/types";
19 +
20 +interface ResultCardProps {
21 + result: SearchResult;
22 + index: number;
23 +}
24 +
25 +export function ResultCard({ result, index }: ResultCardProps) {
26 + return (
27 + <a
28 + href={result.url}
29 + target="_blank"
30 + rel="noopener noreferrer"
31 + className="group block bg-card border border-border rounded-xl p-5 hover:border-primary/40 transition-colors"
32 + data-testid={`link-result-${index}`}
33 + >
34 + <div className="flex items-start gap-4">
35 + <div className="flex-shrink-0 mt-1">
36 + {result.favicon ? (
37 + <img
38 + src={result.favicon}
39 + alt=""
40 + className="w-6 h-6 rounded"
41 + onError={(e) => {
42 + e.currentTarget.style.display = 'none';
43 + }}
44 + />
45 + ) : (
46 + <div className="w-6 h-6 rounded bg-primary/10 flex items-center justify-center">
47 + <span className="text-xs font-mono font-semibold text-primary">
48 + {new URL(result.url).hostname[0].toUpperCase()}
49 + </span>
50 + </div>
51 + )}
52 + </div>
53 +
54 + <div className="flex-1 min-w-0">
55 + <div className="flex items-start justify-between gap-3 mb-2">
56 + <h3 className="text-lg font-semibold text-foreground line-clamp-2 group-hover:text-primary transition-colors" data-testid={`text-result-title-${index}`}>
57 + {result.title}
58 + </h3>
59 + <ExternalLink className="w-4 h-4 text-muted-foreground flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" data-testid={`icon-external-link-${index}`} />
60 + </div>
61 +
62 + <p className="text-sm text-muted-foreground font-mono mb-2 truncate" data-testid={`text-result-domain-${index}`}>
63 + {new URL(result.url).hostname}
64 + </p>
65 +
66 + <p className="text-sm text-muted-foreground line-clamp-3 leading-relaxed" data-testid={`text-result-snippet-${index}`}>
67 + {result.snippet}
68 + </p>
69 + </div>
70 + </div>
71 + </a>
72 + );
73 +}
added client/src/components/chat/search-bar.tsx +85 −0
@@ -0,0 +1,85 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/chat/search-bar.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { useState, FormEvent } from "react";
18 +import { Send, Loader2 } from "lucide-react";
19 +import type { ClaudeModel } from "@/hooks/useChatSession";
20 +
21 +interface SearchBarProps {
22 + onSearch: (query: string, imageData?: string, imageMimeType?: string) => void;
23 + isLoading?: boolean;
24 + placeholder?: string;
25 + selectedModel?: ClaudeModel;
26 + onModelChange?: (model: ClaudeModel) => void;
27 +}
28 +
29 +export function SearchBar({ onSearch, isLoading = false, placeholder = "Ask anything..." }: SearchBarProps) {
30 + const [query, setQuery] = useState("");
31 + const [isFocused, setIsFocused] = useState(false);
32 +
33 + const handleSubmit = (e: FormEvent) => {
34 + e.preventDefault();
35 + if (query.trim() && !isLoading) {
36 + onSearch(query.trim());
37 + setQuery("");
38 + }
39 + };
40 +
41 + const canSubmit = Boolean(query.trim()) && !isLoading;
42 +
43 + return (
44 + <div className="w-full" data-testid="search-bar-container">
45 + <form onSubmit={handleSubmit} className="relative" data-testid="form-search">
46 + <div
47 + className={`relative flex items-center gap-3 px-4 py-3 bg-card border rounded-xl transition-colors ${
48 + isFocused
49 + ? "border-primary ring-2 ring-primary/10"
50 + : "border-border hover:border-primary/40"
51 + }`}
52 + data-testid="search-input-wrapper"
53 + >
54 + <input
55 + type="text"
56 + value={query}
57 + onChange={(e) => setQuery(e.target.value)}
58 + onFocus={() => setIsFocused(true)}
59 + onBlur={() => setIsFocused(false)}
60 + placeholder={placeholder}
61 + disabled={isLoading}
62 + data-testid="input-search-query"
63 + className="flex-1 min-w-0 bg-transparent border-none outline-none text-base font-normal text-foreground placeholder:text-muted-foreground disabled:opacity-50 disabled:cursor-not-allowed"
64 + />
65 +
66 + <button
67 + type="submit"
68 + disabled={!canSubmit}
69 + className={`flex-shrink-0 p-2.5 rounded-lg transition-colors ${
70 + canSubmit
71 + ? "bg-primary text-primary-foreground hover:bg-primary/90"
72 + : "bg-muted text-muted-foreground cursor-not-allowed"
73 + }`}
74 + >
75 + {isLoading ? (
76 + <Loader2 className="w-4 h-4 animate-spin" data-testid="icon-search-loading" />
77 + ) : (
78 + <Send className="w-4 h-4" />
79 + )}
80 + </button>
81 + </div>
82 + </form>
83 + </div>
84 + );
85 +}
added client/src/components/chat/searching-widget.tsx +106 −0
@@ -0,0 +1,106 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/chat/searching-widget.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { motion } from "framer-motion";
18 +import { Sparkles } from "lucide-react";
19 +
20 +export function SearchingWidget() {
21 + return (
22 + <motion.div
23 + initial={{ opacity: 0, y: 20 }}
24 + animate={{ opacity: 1, y: 0 }}
25 + exit={{ opacity: 0, y: -20 }}
26 + transition={{ duration: 0.4 }}
27 + className="w-full max-w-5xl mx-auto"
28 + data-testid="searching-widget"
29 + >
30 + <div className="flex flex-col items-center justify-center py-20 space-y-6">
31 + {/* Animated Icon */}
32 + <motion.div
33 + animate={{
34 + rotate: [0, 360],
35 + scale: [1, 1.1, 1],
36 + }}
37 + transition={{
38 + rotate: {
39 + duration: 2,
40 + repeat: Infinity,
41 + ease: "linear",
42 + },
43 + scale: {
44 + duration: 1.5,
45 + repeat: Infinity,
46 + ease: "easeInOut",
47 + },
48 + }}
49 + className="relative"
50 + >
51 + <div className="absolute inset-0 rounded-full bg-primary/20 blur-xl" />
52 + <div className="relative bg-gradient-to-br from-primary/10 to-cyan-500/10 p-6 rounded-full border border-primary/20">
53 + <Sparkles className="w-12 h-12 text-primary" />
54 + </div>
55 + </motion.div>
56 +
57 + {/* Text Content */}
58 + <div className="text-center space-y-2">
59 + <motion.h3
60 + initial={{ opacity: 0 }}
61 + animate={{ opacity: 1 }}
62 + transition={{ delay: 0.2 }}
63 + className="text-2xl font-semibold text-foreground"
64 + data-testid="text-searching-title"
65 + >
66 + Search in progress...
67 + </motion.h3>
68 + <motion.p
69 + initial={{ opacity: 0 }}
70 + animate={{ opacity: 1 }}
71 + transition={{ delay: 0.3 }}
72 + className="text-muted-foreground max-w-md"
73 + data-testid="text-searching-subtitle"
74 + >
75 + Analyzing and retrieving the best information
76 + </motion.p>
77 + </div>
78 +
79 + {/* Animated Dots */}
80 + <motion.div
81 + className="flex gap-2"
82 + initial={{ opacity: 0 }}
83 + animate={{ opacity: 1 }}
84 + transition={{ delay: 0.4 }}
85 + >
86 + {[0, 1, 2].map((i) => (
87 + <motion.div
88 + key={i}
89 + animate={{
90 + y: [0, -10, 0],
91 + opacity: [0.5, 1, 0.5],
92 + }}
93 + transition={{
94 + duration: 1.5,
95 + repeat: Infinity,
96 + delay: i * 0.2,
97 + ease: "easeInOut",
98 + }}
99 + className="w-2 h-2 bg-primary rounded-full"
100 + />
101 + ))}
102 + </motion.div>
103 + </div>
104 + </motion.div>
105 + );
106 +}
added client/src/components/chat/session-history.tsx +89 −0
@@ -0,0 +1,89 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/chat/session-history.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { useQuery } from "@tanstack/react-query";
18 +import { Button } from "@/components/ui/button";
19 +import { ScrollArea } from "@/components/ui/scroll-area";
20 +import { Clock, MessageSquare } from "lucide-react";
21 +import { formatDistanceToNow } from "date-fns";
22 +
23 +interface SessionListItem {
24 + sessionId: string;
25 + title: string;
26 + createdAt: string;
27 + updatedAt: string;
28 +}
29 +
30 +interface SessionHistoryProps {
31 + onLoadSession: (sessionId: string) => void;
32 + currentSessionId: string | null;
33 +}
34 +
35 +export function SessionHistory({ onLoadSession, currentSessionId }: SessionHistoryProps) {
36 + const { data: sessions = [], isLoading } = useQuery<SessionListItem[]>({
37 + queryKey: ['/api/sessions'],
38 + });
39 +
40 + if (isLoading) {
41 + return (
42 + <div className="p-4 text-sm text-muted-foreground">
43 + Loading sessions...
44 + </div>
45 + );
46 + }
47 +
48 + if (sessions.length === 0) {
49 + return (
50 + <div className="p-4 text-center">
51 + <MessageSquare className="w-12 h-12 mx-auto mb-3 text-muted-foreground/50" />
52 + <p className="text-sm text-muted-foreground">
53 + No conversation history yet
54 + </p>
55 + </div>
56 + );
57 + }
58 +
59 + return (
60 + <ScrollArea className="h-full">
61 + <div className="p-4 space-y-2">
62 + <h2 className="text-sm font-semibold mb-4 text-muted-foreground uppercase tracking-wide">
63 + History
64 + </h2>
65 + {sessions.map((session) => (
66 + <Button
67 + key={session.sessionId}
68 + variant={currentSessionId === session.sessionId ? "secondary" : "ghost"}
69 + className="w-full justify-start text-left h-auto py-3 px-3"
70 + onClick={() => onLoadSession(session.sessionId)}
71 + data-testid={`session-${session.sessionId}`}
72 + >
73 + <div className="flex flex-col items-start w-full gap-1">
74 + <p className="text-sm font-medium line-clamp-2">
75 + {session.title}
76 + </p>
77 + <div className="flex items-center gap-1 text-xs text-muted-foreground">
78 + <Clock className="w-3 h-3" />
79 + <span>
80 + {formatDistanceToNow(new Date(session.updatedAt), { addSuffix: true })}
81 + </span>
82 + </div>
83 + </div>
84 + </Button>
85 + ))}
86 + </div>
87 + </ScrollArea>
88 + );
89 +}
added client/src/components/chat/share-button.tsx +198 −0
@@ -0,0 +1,198 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/chat/share-button.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { Button } from "@/components/ui/button";
18 +import { Share2, Check, Copy } from "lucide-react";
19 +import { useState } from "react";
20 +import { useToast } from "@/hooks/use-toast";
21 +import {
22 + Dialog,
23 + DialogContent,
24 + DialogDescription,
25 + DialogHeader,
26 + DialogTitle,
27 +} from "@/components/ui/dialog";
28 +import { Input } from "@/components/ui/input";
29 +
30 +interface ShareButtonProps {
31 + question: string;
32 + answer: string;
33 + toolResults: any[];
34 + sources: any[];
35 + customPythonFigures?: any[];
36 +}
37 +
38 +export function ShareButton({ question, answer, toolResults, sources, customPythonFigures }: ShareButtonProps) {
39 + const [isSharing, setIsSharing] = useState(false);
40 + const [shareUrl, setShareUrl] = useState<string | null>(null);
41 + const [isCopied, setIsCopied] = useState(false);
42 + const { toast } = useToast();
43 +
44 + const handleShare = async () => {
45 + if (!question || !answer) {
46 + toast({
47 + title: "Error",
48 + description: "Cannot share an empty report",
49 + variant: "destructive",
50 + });
51 + return;
52 + }
53 +
54 + setIsSharing(true);
55 +
56 + try {
57 + const response = await fetch("/api/share", {
58 + method: "POST",
59 + headers: {
60 + "Content-Type": "application/json",
61 + },
62 + body: JSON.stringify({
63 + question,
64 + answer,
65 + toolResults,
66 + sources,
67 + customPythonFigures,
68 + }),
69 + });
70 +
71 + const data = await response.json();
72 +
73 + if (!response.ok) {
74 + throw new Error(data.error || "Failed to share");
75 + }
76 +
77 + // Créer le lien de partage
78 + const url = `${window.location.origin}/share/${data.shareId}`;
79 + setShareUrl(url);
80 + } catch (error) {
81 + console.error("Share error:", error);
82 + toast({
83 + title: "Error",
84 + description: error instanceof Error ? error.message : "Failed to create share link",
85 + variant: "destructive",
86 + });
87 + } finally {
88 + setIsSharing(false);
89 + }
90 + };
91 +
92 + const handleCopyLink = async () => {
93 + if (!shareUrl) return;
94 +
95 + try {
96 + await navigator.clipboard.writeText(shareUrl);
97 + setIsCopied(true);
98 + setTimeout(() => setIsCopied(false), 2000);
99 +
100 + toast({
101 + title: "Link copied!",
102 + description: "The link has been copied to your clipboard",
103 + });
104 + } catch (error) {
105 + // Fallback for mobile
106 + try {
107 + const textArea = document.createElement('textarea');
108 + textArea.value = shareUrl;
109 + textArea.style.position = 'fixed';
110 + textArea.style.left = '-999999px';
111 + document.body.appendChild(textArea);
112 + textArea.focus();
113 + textArea.select();
114 + document.execCommand('copy');
115 + textArea.remove();
116 +
117 + setIsCopied(true);
118 + setTimeout(() => setIsCopied(false), 2000);
119 +
120 + toast({
121 + title: "Link copied!",
122 + description: "The link has been copied to your clipboard",
123 + });
124 + } catch (fallbackError) {
125 + toast({
126 + title: "Error",
127 + description: "Cannot copy automatically. Please copy the link manually.",
128 + variant: "destructive",
129 + });
130 + }
131 + }
132 + };
133 +
134 + return (
135 + <>
136 + <Button
137 + variant="outline"
138 + size="sm"
139 + onClick={handleShare}
140 + disabled={isSharing || !question || !answer}
141 + data-testid="button-share"
142 + className="hover-elevate active-elevate-2"
143 + >
144 + {isSharing ? (
145 + <>
146 + <Share2 className="w-4 h-4 mr-2 animate-pulse" />
147 + Creating...
148 + </>
149 + ) : (
150 + <>
151 + <Share2 className="w-4 h-4 mr-2" />
152 + Share
153 + </>
154 + )}
155 + </Button>
156 +
157 + <Dialog open={!!shareUrl} onOpenChange={(open) => !open && setShareUrl(null)}>
158 + <DialogContent className="sm:max-w-md">
159 + <DialogHeader>
160 + <DialogTitle>Share link created!</DialogTitle>
161 + <DialogDescription>
162 + Copy this link to share your analysis
163 + </DialogDescription>
164 + </DialogHeader>
165 + <div className="flex items-center space-x-2">
166 + <div className="grid flex-1 gap-2">
167 + <Input
168 + readOnly
169 + value={shareUrl || ''}
170 + data-testid="input-share-url"
171 + className="font-mono text-sm"
172 + />
173 + </div>
174 + <Button
175 + type="button"
176 + size="sm"
177 + onClick={handleCopyLink}
178 + data-testid="button-copy-link"
179 + className="hover-elevate active-elevate-2"
180 + >
181 + {isCopied ? (
182 + <>
183 + <Check className="h-4 w-4 mr-1" />
184 + Copied
185 + </>
186 + ) : (
187 + <>
188 + <Copy className="h-4 w-4 mr-1" />
189 + Copy
190 + </>
191 + )}
192 + </Button>
193 + </div>
194 + </DialogContent>
195 + </Dialog>
196 + </>
197 + );
198 +}
added client/src/components/chat/thinking-widget.tsx +64 −0
@@ -0,0 +1,64 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/chat/thinking-widget.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { useState, useEffect } from "react";
18 +import { Brain, ChevronDown } from "lucide-react";
19 +
20 +interface ThinkingWidgetProps {
21 + content: string;
22 + isStreaming?: boolean;
23 +}
24 +
25 +export function ThinkingWidget({ content, isStreaming = false }: ThinkingWidgetProps) {
26 + const [open, setOpen] = useState(false);
27 +
28 + // Auto-expand while the model is thinking, collapse once done.
29 + useEffect(() => {
30 + setOpen(isStreaming);
31 + }, [isStreaming]);
32 +
33 + if (!content) return null;
34 +
35 + return (
36 + <div
37 + className="mb-4 rounded-xl border border-border bg-muted/30 overflow-hidden"
38 + data-testid="thinking-widget"
39 + >
40 + <button
41 + type="button"
42 + onClick={() => setOpen((o) => !o)}
43 + className="w-full flex items-center gap-2 px-4 py-2.5 text-left hover:bg-muted/50 transition-colors"
44 + >
45 + <Brain
46 + className={`w-4 h-4 text-violet-500 flex-shrink-0 ${isStreaming ? "animate-pulse" : ""}`}
47 + />
48 + <span className="text-sm font-medium text-foreground">
49 + {isStreaming ? "Reflexion en cours..." : "Raisonnement"}
50 + </span>
51 + <ChevronDown
52 + className={`w-4 h-4 ml-auto text-muted-foreground transition-transform ${open ? "rotate-180" : ""}`}
53 + />
54 + </button>
55 + {open && (
56 + <div className="px-4 pb-3 pt-1 border-t border-border">
57 + <pre className="whitespace-pre-wrap break-words text-xs text-muted-foreground font-sans leading-relaxed">
58 + {content}
59 + </pre>
60 + </div>
61 + )}
62 + </div>
63 + );
64 +}
added client/src/components/chat/tool-results.tsx +30 −0
@@ -0,0 +1,30 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/chat/tool-results.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +interface ToolResult {
18 + tool: string;
19 + result: any;
20 +}
21 +
22 +interface ToolResultsProps {
23 + toolResults: ToolResult[];
24 + pythonCode?: string;
25 +}
26 +
27 +export function ToolResults({ toolResults }: ToolResultsProps) {
28 + // Ne rien afficher - les steps sont déjà affichés par AgentSteps
29 + return null;
30 +}
added client/src/components/error-boundary.tsx +83 −0
@@ -0,0 +1,83 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/error-boundary.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { Component, type ErrorInfo, type ReactNode } from "react";
18 +import { Button } from "@/components/ui/button";
19 +import { AlertTriangle } from "lucide-react";
20 +
21 +interface Props {
22 + children: ReactNode;
23 + fallback?: ReactNode;
24 +}
25 +
26 +interface State {
27 + hasError: boolean;
28 + error: Error | null;
29 +}
30 +
31 +export class ErrorBoundary extends Component<Props, State> {
32 + constructor(props: Props) {
33 + super(props);
34 + this.state = { hasError: false, error: null };
35 + }
36 +
37 + static getDerivedStateFromError(error: Error): State {
38 + return { hasError: true, error };
39 + }
40 +
41 + componentDidCatch(error: Error, info: ErrorInfo) {
42 + console.error("ErrorBoundary caught:", error, info.componentStack);
43 + }
44 +
45 + handleReset = () => {
46 + this.setState({ hasError: false, error: null });
47 + };
48 +
49 + render() {
50 + if (this.state.hasError) {
51 + if (this.props.fallback) {
52 + return this.props.fallback;
53 + }
54 +
55 + return (
56 + <div className="flex flex-col items-center justify-center min-h-[50vh] gap-6 p-8">
57 + <div className="flex items-center gap-3 text-destructive">
58 + <AlertTriangle className="h-8 w-8" />
59 + <h2 className="text-xl font-semibold">Something went wrong</h2>
60 + </div>
61 + <p className="text-muted-foreground text-center max-w-md">
62 + An unexpected error occurred. You can try refreshing the page or click the button below.
63 + </p>
64 + {this.state.error && (
65 + <pre className="text-xs text-muted-foreground bg-muted p-4 rounded-lg max-w-lg overflow-auto">
66 + {this.state.error.message}
67 + </pre>
68 + )}
69 + <div className="flex gap-3">
70 + <Button variant="outline" onClick={this.handleReset}>
71 + Try again
72 + </Button>
73 + <Button onClick={() => window.location.reload()}>
74 + Reload page
75 + </Button>
76 + </div>
77 + </div>
78 + );
79 + }
80 +
81 + return this.props.children;
82 + }
83 +}
added client/src/components/explore/analyst-view.tsx +324 −0
@@ -0,0 +1,324 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/explore/analyst-view.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
18 +import { Badge } from "@/components/ui/badge";
19 +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
20 +import { TrendingUp, TrendingDown, Award, Target, DollarSign, Users } from "lucide-react";
21 +
22 +interface AnalystEstimate {
23 + date: string;
24 + symbol: string;
25 + estimatedRevenueAvg: number;
26 + estimatedRevenueLow: number;
27 + estimatedRevenueHigh: number;
28 + estimatedEbitdaAvg: number;
29 + estimatedEbitdaLow: number;
30 + estimatedEbitdaHigh: number;
31 + estimatedEbitAvg: number;
32 + estimatedEbitLow: number;
33 + estimatedEbitHigh: number;
34 + estimatedNetIncomeAvg: number;
35 + estimatedNetIncomeLow: number;
36 + estimatedNetIncomeHigh: number;
37 + estimatedSgaExpenseAvg: number;
38 + estimatedSgaExpenseLow: number;
39 + estimatedSgaExpenseHigh: number;
40 + estimatedEpsAvg: number;
41 + estimatedEpsLow: number;
42 + estimatedEpsHigh: number;
43 + numberAnalystEstimatedRevenue: number;
44 + numberAnalystsEstimatedEps: number;
45 +}
46 +
47 +interface DividendData {
48 + date: string;
49 + label: string;
50 + adjDividend: number;
51 + dividend: number;
52 + recordDate: string;
53 + paymentDate: string;
54 + declarationDate: string;
55 +}
56 +
57 +interface AnalystViewProps {
58 + analystEstimates?: AnalystEstimate[];
59 + dividends?: DividendData[];
60 +}
61 +
62 +export function AnalystView({ analystEstimates, dividends }: AnalystViewProps) {
63 + const formatCurrency = (value: number) => {
64 + if (!value) return "N/A";
65 + const absValue = Math.abs(value);
66 + if (absValue >= 1e12) return `$${(value / 1e12).toFixed(2)}T`;
67 + if (absValue >= 1e9) return `$${(value / 1e9).toFixed(2)}B`;
68 + if (absValue >= 1e6) return `$${(value / 1e6).toFixed(2)}M`;
69 + if (absValue >= 1e3) return `$${(value / 1e3).toFixed(2)}K`;
70 + return `$${value.toFixed(2)}`;
71 + };
72 +
73 + const formatDate = (dateString: string) => {
74 + return new Date(dateString).toLocaleDateString('fr-FR', {
75 + year: 'numeric',
76 + month: 'short',
77 + day: 'numeric'
78 + });
79 + };
80 +
81 + const estimateRows = [
82 + {
83 + label: "Revenu",
84 + avgKey: "estimatedRevenueAvg",
85 + lowKey: "estimatedRevenueLow",
86 + highKey: "estimatedRevenueHigh",
87 + analystKey: "numberAnalystEstimatedRevenue",
88 + isCurrency: true
89 + },
90 + {
91 + label: "EBITDA",
92 + avgKey: "estimatedEbitdaAvg",
93 + lowKey: "estimatedEbitdaLow",
94 + highKey: "estimatedEbitdaHigh",
95 + isCurrency: true
96 + },
97 + {
98 + label: "EBIT",
99 + avgKey: "estimatedEbitAvg",
100 + lowKey: "estimatedEbitLow",
101 + highKey: "estimatedEbitHigh",
102 + isCurrency: true
103 + },
104 + {
105 + label: "Revenu Net",
106 + avgKey: "estimatedNetIncomeAvg",
107 + lowKey: "estimatedNetIncomeLow",
108 + highKey: "estimatedNetIncomeHigh",
109 + isCurrency: true
110 + },
111 + {
112 + label: "Dépenses SG&A",
113 + avgKey: "estimatedSgaExpenseAvg",
114 + lowKey: "estimatedSgaExpenseLow",
115 + highKey: "estimatedSgaExpenseHigh",
116 + isCurrency: true
117 + },
118 + {
119 + label: "BPA",
120 + avgKey: "estimatedEpsAvg",
121 + lowKey: "estimatedEpsLow",
122 + highKey: "estimatedEpsHigh",
123 + analystKey: "numberAnalystsEstimatedEps",
124 + isEPS: true
125 + },
126 + ];
127 +
128 + return (
129 + <div className="space-y-6">
130 + {/* Analyst Estimates */}
131 + {analystEstimates && analystEstimates.length > 0 ? (
132 + <Card>
133 + <CardHeader>
134 + <div className="flex items-center justify-between">
135 + <div>
136 + <CardTitle className="flex items-center gap-2">
137 + <Award className="w-5 h-5" />
138 + Estimations des Analystes
139 + </CardTitle>
140 + <CardDescription>
141 + Prévisions consensuelles et fourchettes d'estimations
142 + </CardDescription>
143 + </div>
144 + <div className="flex items-center gap-2">
145 + <Users className="w-4 h-4 text-muted-foreground" />
146 + <span className="text-sm text-muted-foreground">
147 + {analystEstimates[0]?.numberAnalystsEstimatedEps || 'N/A'} analystes
148 + </span>
149 + </div>
150 + </div>
151 + </CardHeader>
152 + <CardContent>
153 + <div className="overflow-x-auto">
154 + <Table>
155 + <TableHeader>
156 + <TableRow>
157 + <TableHead className="w-[200px]">Métrique</TableHead>
158 + {analystEstimates.slice(0, 4).map((item, idx) => (
159 + <TableHead key={idx} className="text-center min-w-[200px]">
160 + <div>
161 + <div className="font-semibold">{formatDate(item.date)}</div>
162 + {item.numberAnalystEstimatedRevenue > 0 && (
163 + <div className="text-xs text-muted-foreground mt-1">
164 + {item.numberAnalystEstimatedRevenue} analystes
165 + </div>
166 + )}
167 + </div>
168 + </TableHead>
169 + ))}
170 + </TableRow>
171 + </TableHeader>
172 + <TableBody>
173 + {estimateRows.map((row, rowIdx) => (
174 + <TableRow key={rowIdx} className="hover:bg-muted/50">
175 + <TableCell className="font-medium">
176 + <div className="flex items-center gap-2">
177 + <Target className="w-4 h-4 text-muted-foreground" />
178 + {row.label}
179 + </div>
180 + </TableCell>
181 + {analystEstimates.slice(0, 4).map((item, idx) => {
182 + const avg = item[row.avgKey];
183 + const low = item[row.lowKey];
184 + const high = item[row.highKey];
185 +
186 + return (
187 + <TableCell key={idx} className="text-center">
188 + <div className="space-y-1">
189 + <div className="font-bold text-lg">
190 + {row.isCurrency ? formatCurrency(avg) : row.isEPS ? `$${avg?.toFixed(2) || 'N/A'}` : avg || 'N/A'}
191 + </div>
192 + <div className="text-xs text-muted-foreground">
193 + Fourchette:
194 + </div>
195 + <div className="flex items-center justify-center gap-2 text-sm">
196 + <span className="text-red-500 font-semibold">
197 + {row.isCurrency ? formatCurrency(low) : row.isEPS ? `$${low?.toFixed(2) || 'N/A'}` : low || 'N/A'}
198 + </span>
199 + <span className="text-muted-foreground">-</span>
200 + <span className="text-green-500 font-semibold">
201 + {row.isCurrency ? formatCurrency(high) : row.isEPS ? `$${high?.toFixed(2) || 'N/A'}` : high || 'N/A'}
202 + </span>
203 + </div>
204 + </div>
205 + </TableCell>
206 + );
207 + })}
208 + </TableRow>
209 + ))}
210 + </TableBody>
211 + </Table>
212 + </div>
213 + </CardContent>
214 + </Card>
215 + ) : (
216 + <Card>
217 + <CardHeader>
218 + <CardTitle className="flex items-center gap-2">
219 + <Award className="w-5 h-5" />
220 + Estimations des Analystes
221 + </CardTitle>
222 + </CardHeader>
223 + <CardContent>
224 + <p className="text-muted-foreground">Aucune estimation d'analyste disponible</p>
225 + </CardContent>
226 + </Card>
227 + )}
228 +
229 + {/* Dividend History */}
230 + {dividends && dividends.length > 0 ? (
231 + <Card>
232 + <CardHeader>
233 + <CardTitle className="flex items-center gap-2">
234 + <DollarSign className="w-5 h-5" />
235 + Historique des Dividendes
236 + </CardTitle>
237 + <CardDescription>
238 + Paiements de dividendes et dates importantes
239 + </CardDescription>
240 + </CardHeader>
241 + <CardContent>
242 + <div className="overflow-x-auto">
243 + <Table>
244 + <TableHeader>
245 + <TableRow>
246 + <TableHead>Date Ex-Dividend</TableHead>
247 + <TableHead>Libellé</TableHead>
248 + <TableHead className="text-right">Dividende</TableHead>
249 + <TableHead className="text-right">Dividende Ajusté</TableHead>
250 + <TableHead>Date d'Enregistrement</TableHead>
251 + <TableHead>Date de Paiement</TableHead>
252 + <TableHead>Date de Déclaration</TableHead>
253 + </TableRow>
254 + </TableHeader>
255 + <TableBody>
256 + {dividends.slice(0, 20).map((dividend, idx) => (
257 + <TableRow key={idx} className="hover:bg-muted/50">
258 + <TableCell className="font-semibold">
259 + {formatDate(dividend.date)}
260 + </TableCell>
261 + <TableCell>
262 + <Badge variant="outline">{dividend.label}</Badge>
263 + </TableCell>
264 + <TableCell className="text-right font-semibold text-green-600">
265 + ${dividend.dividend?.toFixed(4)}
266 + </TableCell>
267 + <TableCell className="text-right">
268 + ${dividend.adjDividend?.toFixed(4)}
269 + </TableCell>
270 + <TableCell className="text-sm text-muted-foreground">
271 + {dividend.recordDate ? formatDate(dividend.recordDate) : 'N/A'}
272 + </TableCell>
273 + <TableCell className="text-sm text-muted-foreground">
274 + {dividend.paymentDate ? formatDate(dividend.paymentDate) : 'N/A'}
275 + </TableCell>
276 + <TableCell className="text-sm text-muted-foreground">
277 + {dividend.declarationDate ? formatDate(dividend.declarationDate) : 'N/A'}
278 + </TableCell>
279 + </TableRow>
280 + ))}
281 + </TableBody>
282 + </Table>
283 + </div>
284 +
285 + {dividends.length > 0 && (
286 + <div className="mt-4 pt-4 border-t">
287 + <div className="grid grid-cols-3 gap-4 text-sm">
288 + <div>
289 + <div className="text-muted-foreground">Total Paiements</div>
290 + <div className="text-lg font-bold">{dividends.length}</div>
291 + </div>
292 + <div>
293 + <div className="text-muted-foreground">Dernier Dividende</div>
294 + <div className="text-lg font-bold text-green-600">
295 + ${dividends[0]?.dividend?.toFixed(4)}
296 + </div>
297 + </div>
298 + <div>
299 + <div className="text-muted-foreground">Dividende Annuel (TTM)</div>
300 + <div className="text-lg font-bold">
301 + ${dividends.slice(0, 4).reduce((sum, d) => sum + (d.dividend || 0), 0).toFixed(4)}
302 + </div>
303 + </div>
304 + </div>
305 + </div>
306 + )}
307 + </CardContent>
308 + </Card>
309 + ) : (
310 + <Card>
311 + <CardHeader>
312 + <CardTitle className="flex items-center gap-2">
313 + <DollarSign className="w-5 h-5" />
314 + Historique des Dividendes
315 + </CardTitle>
316 + </CardHeader>
317 + <CardContent>
318 + <p className="text-muted-foreground">Cette entreprise ne verse pas de dividendes</p>
319 + </CardContent>
320 + </Card>
321 + )}
322 + </div>
323 + );
324 +}
added client/src/components/explore/financial-statements-view.tsx +289 −0
@@ -0,0 +1,289 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/explore/financial-statements-view.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
18 +import { Badge } from "@/components/ui/badge";
19 +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
20 +import { TrendingUp, TrendingDown, FileText, DollarSign, Wallet, Activity } from "lucide-react";
21 +
22 +interface FinancialStatement {
23 + date: string;
24 + symbol: string;
25 + reportedCurrency: string;
26 + cik: string;
27 + fillingDate: string;
28 + acceptedDate: string;
29 + calendarYear: string;
30 + period: string;
31 + link: string;
32 + finalLink: string;
33 + [key: string]: any;
34 +}
35 +
36 +interface FinancialStatementsViewProps {
37 + incomeStatement?: FinancialStatement[];
38 + balanceSheet?: FinancialStatement[];
39 + cashFlow?: FinancialStatement[];
40 + period: "annual" | "quarter";
41 +}
42 +
43 +export function FinancialStatementsView({
44 + incomeStatement,
45 + balanceSheet,
46 + cashFlow,
47 + period
48 +}: FinancialStatementsViewProps) {
49 + const formatCurrency = (value: number) => {
50 + if (!value) return "N/A";
51 + const absValue = Math.abs(value);
52 + if (absValue >= 1e12) return `$${(value / 1e12).toFixed(2)}T`;
53 + if (absValue >= 1e9) return `$${(value / 1e9).toFixed(2)}B`;
54 + if (absValue >= 1e6) return `$${(value / 1e6).toFixed(2)}M`;
55 + if (absValue >= 1e3) return `$${(value / 1e3).toFixed(2)}K`;
56 + return `$${value.toFixed(2)}`;
57 + };
58 +
59 + const formatPercent = (value: number) => {
60 + if (!value) return "N/A";
61 + return `${(value * 100).toFixed(2)}%`;
62 + };
63 +
64 + const formatDate = (dateString: string) => {
65 + return new Date(dateString).toLocaleDateString('fr-FR', {
66 + year: 'numeric',
67 + month: 'short',
68 + day: 'numeric'
69 + });
70 + };
71 +
72 + const getChangeIndicator = (current: number, previous: number) => {
73 + if (!current || !previous) return null;
74 + const change = ((current - previous) / Math.abs(previous)) * 100;
75 + const isPositive = change >= 0;
76 +
77 + return (
78 + <span className={`text-xs flex items-center gap-1 ${isPositive ? 'text-green-500' : 'text-red-500'}`}>
79 + {isPositive ? <TrendingUp className="w-3 h-3" /> : <TrendingDown className="w-3 h-3" />}
80 + {isPositive ? '+' : ''}{change.toFixed(1)}%
81 + </span>
82 + );
83 + };
84 +
85 + // Income Statement
86 + const incomeStatementRows = [
87 + { label: "Revenu Total", key: "revenue", highlight: true },
88 + { label: "Coût des Revenus", key: "costOfRevenue" },
89 + { label: "Marge Brute", key: "grossProfit", highlight: true },
90 + { label: "Dépenses R&D", key: "researchAndDevelopmentExpenses" },
91 + { label: "Dépenses Générales", key: "generalAndAdministrativeExpenses" },
92 + { label: "Dépenses Marketing", key: "sellingAndMarketingExpenses" },
93 + { label: "Dépenses d'Exploitation", key: "operatingExpenses" },
94 + { label: "Revenu d'Exploitation", key: "operatingIncome", highlight: true },
95 + { label: "Revenu d'Intérêts", key: "interestIncome" },
96 + { label: "Dépenses d'Intérêts", key: "interestExpense" },
97 + { label: "Autres Revenus/Dépenses", key: "otherExpenses" },
98 + { label: "Revenu avant Impôts", key: "incomeBeforeTax", highlight: true },
99 + { label: "Charge d'Impôt", key: "incomeTaxExpense" },
100 + { label: "Revenu Net", key: "netIncome", highlight: true, important: true },
101 + { label: "BPA", key: "eps", isEPS: true },
102 + { label: "BPA Dilué", key: "epsdiluted", isEPS: true },
103 + ];
104 +
105 + // Balance Sheet
106 + const balanceSheetRows = [
107 + { label: "Actifs", section: true },
108 + { label: "Trésorerie", key: "cashAndCashEquivalents" },
109 + { label: "Placements Court Terme", key: "shortTermInvestments" },
110 + { label: "Comptes Clients", key: "netReceivables" },
111 + { label: "Inventaire", key: "inventory" },
112 + { label: "Actifs Courants", key: "totalCurrentAssets", highlight: true },
113 + { label: "Immobilisations", key: "propertyPlantEquipmentNet" },
114 + { label: "Goodwill", key: "goodwill" },
115 + { label: "Actifs Intangibles", key: "intangibleAssets" },
116 + { label: "Placements Long Terme", key: "longTermInvestments" },
117 + { label: "Total Actifs", key: "totalAssets", highlight: true, important: true },
118 + { label: "Passifs", section: true },
119 + { label: "Comptes Fournisseurs", key: "accountPayables" },
120 + { label: "Dette Court Terme", key: "shortTermDebt" },
121 + { label: "Passifs Courants", key: "totalCurrentLiabilities", highlight: true },
122 + { label: "Dette Long Terme", key: "longTermDebt" },
123 + { label: "Total Passifs", key: "totalLiabilities", highlight: true, important: true },
124 + { label: "Capitaux Propres", section: true },
125 + { label: "Actions Ordinaires", key: "commonStock" },
126 + { label: "Bénéfices Non Répartis", key: "retainedEarnings" },
127 + { label: "Total Capitaux Propres", key: "totalStockholdersEquity", highlight: true, important: true },
128 + ];
129 +
130 + // Cash Flow
131 + const cashFlowRows = [
132 + { label: "Activités d'Exploitation", section: true },
133 + { label: "Revenu Net", key: "netIncome", highlight: true },
134 + { label: "Dépréciation & Amortissement", key: "depreciationAndAmortization" },
135 + { label: "Variation Fonds de Roulement", key: "changeInWorkingCapital" },
136 + { label: "Flux de Trésorerie d'Exploitation", key: "operatingCashFlow", highlight: true, important: true },
137 + { label: "Activités d'Investissement", section: true },
138 + { label: "Investissements en Immobilisations", key: "capitalExpenditure" },
139 + { label: "Acquisitions", key: "acquisitionsNet" },
140 + { label: "Achats d'Investissements", key: "purchasesOfInvestments" },
141 + { label: "Ventes d'Investissements", key: "salesMaturitiesOfInvestments" },
142 + { label: "Flux de Trésorerie d'Investissement", key: "netCashUsedForInvestingActivites", highlight: true },
143 + { label: "Activités de Financement", section: true },
144 + { label: "Dette Émise", key: "debtRepayment" },
145 + { label: "Actions Rachetées", key: "commonStockRepurchased" },
146 + { label: "Dividendes Payés", key: "dividendsPaid" },
147 + { label: "Flux de Trésorerie de Financement", key: "netCashUsedProvidedByFinancingActivities", highlight: true },
148 + { label: "Variation Nette de Trésorerie", key: "netChangeInCash", highlight: true, important: true },
149 + { label: "Flux de Trésorerie Libre", key: "freeCashFlow", highlight: true, important: true },
150 + ];
151 +
152 + const renderTable = (
153 + data: FinancialStatement[] | undefined,
154 + rows: Array<{ label: string; key?: string; highlight?: boolean; important?: boolean; section?: boolean; isEPS?: boolean }>,
155 + title: string,
156 + description: string,
157 + icon: React.ReactNode
158 + ) => {
159 + if (!data || data.length === 0) {
160 + return (
161 + <Card>
162 + <CardHeader>
163 + <CardTitle className="flex items-center gap-2">
164 + {icon}
165 + {title}
166 + </CardTitle>
167 + <CardDescription>{description}</CardDescription>
168 + </CardHeader>
169 + <CardContent>
170 + <p className="text-muted-foreground">Aucune donnée disponible</p>
171 + </CardContent>
172 + </Card>
173 + );
174 + }
175 +
176 + return (
177 + <Card>
178 + <CardHeader>
179 + <CardTitle className="flex items-center gap-2">
180 + {icon}
181 + {title}
182 + </CardTitle>
183 + <CardDescription>{description}</CardDescription>
184 + </CardHeader>
185 + <CardContent>
186 + <div className="overflow-x-auto">
187 + <Table>
188 + <TableHeader>
189 + <TableRow>
190 + <TableHead className="w-[250px] sticky left-0 bg-card z-10">Élément</TableHead>
191 + {data.slice(0, 5).map((item, idx) => (
192 + <TableHead key={idx} className="text-right min-w-[150px]">
193 + <div>
194 + <div className="font-semibold">{formatDate(item.date)}</div>
195 + <Badge variant="outline" className="text-xs mt-1">
196 + {item.period}
197 + </Badge>
198 + </div>
199 + </TableHead>
200 + ))}
201 + </TableRow>
202 + </TableHeader>
203 + <TableBody>
204 + {rows.map((row, rowIdx) => {
205 + if (row.section) {
206 + return (
207 + <TableRow key={rowIdx} className="bg-muted/50">
208 + <TableCell colSpan={6} className="font-bold sticky left-0 bg-muted/50 z-10">
209 + {row.label}
210 + </TableCell>
211 + </TableRow>
212 + );
213 + }
214 +
215 + return (
216 + <TableRow
217 + key={rowIdx}
218 + className={`${row.highlight ? 'bg-primary/5' : ''} ${row.important ? 'border-l-4 border-l-primary' : ''}`}
219 + >
220 + <TableCell className={`sticky left-0 bg-card z-10 ${row.highlight ? 'font-semibold' : ''} ${row.important ? 'font-bold text-primary' : ''}`}>
221 + {row.label}
222 + </TableCell>
223 + {data.slice(0, 5).map((item, idx) => {
224 + const value = row.key ? item[row.key] : null;
225 + const prevValue = idx < data.length - 1 && row.key ? data[idx + 1][row.key] : null;
226 +
227 + return (
228 + <TableCell key={idx} className={`text-right ${row.highlight ? 'font-semibold' : ''} ${row.important ? 'font-bold' : ''}`}>
229 + <div>
230 + <div>
231 + {row.isEPS ? (value ? `$${value.toFixed(2)}` : 'N/A') : formatCurrency(value)}
232 + </div>
233 + {value && prevValue && getChangeIndicator(value, prevValue)}
234 + </div>
235 + </TableCell>
236 + );
237 + })}
238 + </TableRow>
239 + );
240 + })}
241 + </TableBody>
242 + </Table>
243 + </div>
244 +
245 + {data[0]?.link && (
246 + <div className="mt-4 pt-4 border-t">
247 + <a
248 + href={data[0].link}
249 + target="_blank"
250 + rel="noopener noreferrer"
251 + className="text-sm text-primary hover:underline"
252 + >
253 + Voir le document SEC complet →
254 + </a>
255 + </div>
256 + )}
257 + </CardContent>
258 + </Card>
259 + );
260 + };
261 +
262 + return (
263 + <div className="space-y-6">
264 + {renderTable(
265 + incomeStatement,
266 + incomeStatementRows,
267 + "Compte de Résultat",
268 + "Performance financière et rentabilité",
269 + <FileText className="w-5 h-5" />
270 + )}
271 +
272 + {renderTable(
273 + balanceSheet,
274 + balanceSheetRows,
275 + "Bilan",
276 + "Actifs, passifs et capitaux propres",
277 + <Wallet className="w-5 h-5" />
278 + )}
279 +
280 + {renderTable(
281 + cashFlow,
282 + cashFlowRows,
283 + "Tableau de Flux de Trésorerie",
284 + "Flux de trésorerie par activité",
285 + <Activity className="w-5 h-5" />
286 + )}
287 + </div>
288 + );
289 +}
added client/src/components/explore/market-overview.tsx +361 −0
@@ -0,0 +1,361 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/explore/market-overview.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
18 +import { Badge } from "@/components/ui/badge";
19 +import { TrendingUp, TrendingDown, Activity, Clock, DollarSign, BarChart3 } from "lucide-react";
20 +
21 +interface MarketIndex {
22 + symbol: string;
23 + name: string;
24 + price: number;
25 + change: number;
26 + changesPercentage: number;
27 + dayLow: number;
28 + dayHigh: number;
29 + yearHigh: number;
30 + yearLow: number;
31 + volume: number;
32 + previousClose: number;
33 +}
34 +
35 +interface MarketStatus {
36 + isTheStockMarketOpen: boolean;
37 + stockMarketHours: {
38 + openingHour: string;
39 + closingHour: string;
40 + };
41 +}
42 +
43 +interface Stock {
44 + symbol: string;
45 + name: string;
46 + price: number;
47 + changesPercentage: number;
48 + change: number;
49 + volume?: number;
50 + marketCap?: number;
51 +}
52 +
53 +interface MarketOverviewProps {
54 + marketIndices?: MarketIndex[];
55 + marketStatus?: MarketStatus;
56 + gainers?: Stock[];
57 + losers?: Stock[];
58 + activeStocks?: Stock[];
59 + onTickerClick: (ticker: string) => void;
60 +}
61 +
62 +export function MarketOverview({
63 + marketIndices,
64 + marketStatus,
65 + gainers,
66 + losers,
67 + activeStocks,
68 + onTickerClick
69 +}: MarketOverviewProps) {
70 + const formatNumber = (num: number) => {
71 + if (num >= 1e9) return `$${(num / 1e9).toFixed(2)}B`;
72 + if (num >= 1e6) return `$${(num / 1e6).toFixed(2)}M`;
73 + if (num >= 1e3) return `$${(num / 1e3).toFixed(2)}K`;
74 + return `$${num.toFixed(2)}`;
75 + };
76 +
77 + const formatVolume = (vol: number) => {
78 + if (vol >= 1e9) return `${(vol / 1e9).toFixed(2)}B`;
79 + if (vol >= 1e6) return `${(vol / 1e6).toFixed(2)}M`;
80 + if (vol >= 1e3) return `${(vol / 1e3).toFixed(2)}K`;
81 + return vol.toString();
82 + };
83 +
84 + return (
85 + <div className="space-y-8">
86 + {/* Market Status Banner */}
87 + {marketStatus && (
88 + <Card className="bg-gradient-to-br from-card via-card to-primary/5 border-2">
89 + <CardHeader>
90 + <div className="flex items-center justify-between">
91 + <div className="flex items-center gap-3">
92 + <div className="p-2 rounded-lg bg-primary/10">
93 + <Clock className="w-6 h-6 text-primary" />
94 + </div>
95 + <div>
96 + <CardTitle className="text-2xl">Statut du Marché</CardTitle>
97 + <CardDescription className="text-base">
98 + Heures d'ouverture: {marketStatus.stockMarketHours?.openingHour} - {marketStatus.stockMarketHours?.closingHour}
99 + </CardDescription>
100 + </div>
101 + </div>
102 + <Badge
103 + variant={marketStatus.isTheStockMarketOpen ? "default" : "secondary"}
104 + className="text-lg px-6 py-3"
105 + >
106 + {marketStatus.isTheStockMarketOpen ? "🟢 OUVERT" : "🔴 FERMÉ"}
107 + </Badge>
108 + </div>
109 + </CardHeader>
110 + </Card>
111 + )}
112 +
113 + {/* Major Indices */}
114 + <div>
115 + <div className="flex items-center gap-3 mb-6">
116 + <div className="p-2 rounded-lg bg-primary/10">
117 + <Activity className="w-6 h-6 text-primary" />
118 + </div>
119 + <div>
120 + <h2 className="text-2xl font-bold">Indices Majeurs</h2>
121 + <p className="text-muted-foreground">Performance en temps réel des principaux indices boursiers</p>
122 + </div>
123 + </div>
124 +
125 + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
126 + {marketIndices?.map((index) => {
127 + const isPositive = index.changesPercentage >= 0;
128 +
129 + return (
130 + <Card
131 + key={index.symbol}
132 + className="cursor-pointer hover:border-primary/50 hover:shadow-xl transition-all duration-300 hover:scale-[1.02]"
133 + onClick={() => onTickerClick(index.symbol)}
134 + >
135 + <CardHeader className="pb-3">
136 + <div className="flex items-start justify-between">
137 + <div>
138 + <CardTitle className="text-xl mb-1">{index.name}</CardTitle>
139 + <CardDescription className="text-base font-mono">{index.symbol}</CardDescription>
140 + </div>
141 + <div className={`p-2 rounded-lg ${isPositive ? 'bg-green-500/10' : 'bg-red-500/10'}`}>
142 + {isPositive ? (
143 + <TrendingUp className="w-5 h-5 text-green-500" />
144 + ) : (
145 + <TrendingDown className="w-5 h-5 text-red-500" />
146 + )}
147 + </div>
148 + </div>
149 + </CardHeader>
150 + <CardContent className="space-y-4">
151 + <div className="flex items-end justify-between">
152 + <div>
153 + <div className="text-3xl font-bold">
154 + {index.price?.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
155 + </div>
156 + <div className={`text-base font-semibold flex items-center gap-1.5 mt-1 ${
157 + isPositive ? 'text-green-500' : 'text-red-500'
158 + }`}>
159 + {isPositive ? '+' : ''}{index.change?.toFixed(2)}
160 + <span className="text-sm">({isPositive ? '+' : ''}{index.changesPercentage?.toFixed(2)}%)</span>
161 + </div>
162 + </div>
163 + </div>
164 +
165 + <div className="pt-3 border-t grid grid-cols-2 gap-3 text-sm">
166 + <div>
167 + <div className="text-muted-foreground text-xs">Jour Bas/Haut</div>
168 + <div className="font-semibold">
169 + {index.dayLow > 0 ? `${index.dayLow.toFixed(2)} / ${index.dayHigh.toFixed(2)}` : 'N/A'}
170 + </div>
171 + </div>
172 + <div>
173 + <div className="text-muted-foreground text-xs">Année Bas/Haut</div>
174 + <div className="font-semibold">
175 + {index.yearLow > 0 ? `${index.yearLow.toFixed(2)} / ${index.yearHigh.toFixed(2)}` : 'N/A'}
176 + </div>
177 + </div>
178 + <div>
179 + <div className="text-muted-foreground text-xs">Clôture Précédente</div>
180 + <div className="font-semibold">
181 + {index.previousClose > 0 ? index.previousClose.toFixed(2) : 'N/A'}
182 + </div>
183 + </div>
184 + <div>
185 + <div className="text-muted-foreground text-xs">Volume</div>
186 + <div className="font-semibold">
187 + {index.volume > 0 ? formatVolume(index.volume) : 'N/A'}
188 + </div>
189 + </div>
190 + </div>
191 + </CardContent>
192 + </Card>
193 + );
194 + })}
195 + </div>
196 + </div>
197 +
198 + {/* Market Movers */}
199 + <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
200 + {/* Top Gainers */}
201 + <Card className="border-2 hover:border-green-500/50 transition-colors">
202 + <CardHeader>
203 + <div className="flex items-center gap-3">
204 + <div className="p-2 rounded-lg bg-green-500/10">
205 + <TrendingUp className="w-5 h-5 text-green-500" />
206 + </div>
207 + <div>
208 + <CardTitle className="text-xl text-green-500">Top Hausses</CardTitle>
209 + <CardDescription>Actions les plus performantes</CardDescription>
210 + </div>
211 + </div>
212 + </CardHeader>
213 + <CardContent>
214 + <div className="space-y-3">
215 + {gainers?.slice(0, 8).map((stock, index) => (
216 + <div
217 + key={stock.symbol}
218 + className="flex items-center justify-between p-3 rounded-lg hover:bg-accent cursor-pointer transition-all hover:shadow-md border border-transparent hover:border-green-500/30"
219 + onClick={() => onTickerClick(stock.symbol)}
220 + >
221 + <div className="flex items-center gap-3 flex-1 min-w-0">
222 + <div className="flex-shrink-0 w-6 h-6 rounded-full bg-green-500/10 flex items-center justify-center text-xs font-bold text-green-500">
223 + {index + 1}
224 + </div>
225 + <div className="min-w-0 flex-1">
226 + <div className="font-bold text-base">{stock.symbol}</div>
227 + <div className="text-xs text-muted-foreground truncate">
228 + {stock.name}
229 + </div>
230 + {stock.volume && (
231 + <div className="text-xs text-muted-foreground">
232 + Vol: {formatVolume(stock.volume)}
233 + </div>
234 + )}
235 + </div>
236 + </div>
237 + <div className="text-right flex-shrink-0 ml-2">
238 + <div className="font-bold text-base">${stock.price?.toFixed(2)}</div>
239 + <div className="text-sm font-semibold text-green-500">
240 + +{stock.changesPercentage?.toFixed(2)}%
241 + </div>
242 + <div className="text-xs text-green-500">
243 + +${stock.change?.toFixed(2)}
244 + </div>
245 + </div>
246 + </div>
247 + ))}
248 + </div>
249 + </CardContent>
250 + </Card>
251 +
252 + {/* Top Losers */}
253 + <Card className="border-2 hover:border-red-500/50 transition-colors">
254 + <CardHeader>
255 + <div className="flex items-center gap-3">
256 + <div className="p-2 rounded-lg bg-red-500/10">
257 + <TrendingDown className="w-5 h-5 text-red-500" />
258 + </div>
259 + <div>
260 + <CardTitle className="text-xl text-red-500">Top Baisses</CardTitle>
261 + <CardDescription>Actions les plus en baisse</CardDescription>
262 + </div>
263 + </div>
264 + </CardHeader>
265 + <CardContent>
266 + <div className="space-y-3">
267 + {losers?.slice(0, 8).map((stock, index) => (
268 + <div
269 + key={stock.symbol}
270 + className="flex items-center justify-between p-3 rounded-lg hover:bg-accent cursor-pointer transition-all hover:shadow-md border border-transparent hover:border-red-500/30"
271 + onClick={() => onTickerClick(stock.symbol)}
272 + >
273 + <div className="flex items-center gap-3 flex-1 min-w-0">
274 + <div className="flex-shrink-0 w-6 h-6 rounded-full bg-red-500/10 flex items-center justify-center text-xs font-bold text-red-500">
275 + {index + 1}
276 + </div>
277 + <div className="min-w-0 flex-1">
278 + <div className="font-bold text-base">{stock.symbol}</div>
279 + <div className="text-xs text-muted-foreground truncate">
280 + {stock.name}
281 + </div>
282 + {stock.volume && (
283 + <div className="text-xs text-muted-foreground">
284 + Vol: {formatVolume(stock.volume)}
285 + </div>
286 + )}
287 + </div>
288 + </div>
289 + <div className="text-right flex-shrink-0 ml-2">
290 + <div className="font-bold text-base">${stock.price?.toFixed(2)}</div>
291 + <div className="text-sm font-semibold text-red-500">
292 + {stock.changesPercentage?.toFixed(2)}%
293 + </div>
294 + <div className="text-xs text-red-500">
295 + ${stock.change?.toFixed(2)}
296 + </div>
297 + </div>
298 + </div>
299 + ))}
300 + </div>
301 + </CardContent>
302 + </Card>
303 +
304 + {/* Most Active */}
305 + <Card className="border-2 hover:border-blue-500/50 transition-colors">
306 + <CardHeader>
307 + <div className="flex items-center gap-3">
308 + <div className="p-2 rounded-lg bg-blue-500/10">
309 + <Activity className="w-5 h-5 text-blue-500" />
310 + </div>
311 + <div>
312 + <CardTitle className="text-xl text-blue-500">Plus Actifs</CardTitle>
313 + <CardDescription>Volume de transactions le plus élevé</CardDescription>
314 + </div>
315 + </div>
316 + </CardHeader>
317 + <CardContent>
318 + <div className="space-y-3">
319 + {activeStocks?.slice(0, 8).map((stock, index) => {
320 + const isPositive = stock.changesPercentage >= 0;
321 + return (
322 + <div
323 + key={stock.symbol}
324 + className="flex items-center justify-between p-3 rounded-lg hover:bg-accent cursor-pointer transition-all hover:shadow-md border border-transparent hover:border-blue-500/30"
325 + onClick={() => onTickerClick(stock.symbol)}
326 + >
327 + <div className="flex items-center gap-3 flex-1 min-w-0">
328 + <div className="flex-shrink-0 w-6 h-6 rounded-full bg-blue-500/10 flex items-center justify-center text-xs font-bold text-blue-500">
329 + {index + 1}
330 + </div>
331 + <div className="min-w-0 flex-1">
332 + <div className="font-bold text-base">{stock.symbol}</div>
333 + <div className="text-xs text-muted-foreground truncate">
334 + {stock.name}
335 + </div>
336 + {stock.volume && (
337 + <div className="text-xs text-blue-500 font-semibold">
338 + Vol: {formatVolume(stock.volume)}
339 + </div>
340 + )}
341 + </div>
342 + </div>
343 + <div className="text-right flex-shrink-0 ml-2">
344 + <div className="font-bold text-base">${stock.price?.toFixed(2)}</div>
345 + <div className={`text-sm font-semibold ${isPositive ? 'text-green-500' : 'text-red-500'}`}>
346 + {isPositive ? '+' : ''}{stock.changesPercentage?.toFixed(2)}%
347 + </div>
348 + <div className={`text-xs ${isPositive ? 'text-green-500' : 'text-red-500'}`}>
349 + {isPositive ? '+' : ''}${stock.change?.toFixed(2)}
350 + </div>
351 + </div>
352 + </div>
353 + );
354 + })}
355 + </div>
356 + </CardContent>
357 + </Card>
358 + </div>
359 + </div>
360 + );
361 +}
added client/src/components/explore/metrics-view.tsx +324 −0
@@ -0,0 +1,324 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/explore/metrics-view.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
18 +import { Badge } from "@/components/ui/badge";
19 +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
20 +import {
21 + TrendingUp,
22 + TrendingDown,
23 + DollarSign,
24 + Percent,
25 + TrendingUpDown,
26 + Calculator,
27 + PieChart,
28 + Activity
29 +} from "lucide-react";
30 +
31 +interface KeyMetrics {
32 + date: string;
33 + symbol: string;
34 + period: string;
35 + revenuePerShare: number;
36 + netIncomePerShare: number;
37 + operatingCashFlowPerShare: number;
38 + freeCashFlowPerShare: number;
39 + cashPerShare: number;
40 + bookValuePerShare: number;
41 + tangibleBookValuePerShare: number;
42 + shareholdersEquityPerShare: number;
43 + interestDebtPerShare: number;
44 + marketCap: number;
45 + enterpriseValue: number;
46 + peRatio: number;
47 + priceToSalesRatio: number;
48 + pocfratio: number;
49 + pfcfRatio: number;
50 + pbRatio: number;
51 + ptbRatio: number;
52 + evToSales: number;
53 + enterpriseValueOverEBITDA: number;
54 + evToOperatingCashFlow: number;
55 + evToFreeCashFlow: number;
56 + earningsYield: number;
57 + freeCashFlowYield: number;
58 + debtToEquity: number;
59 + debtToAssets: number;
60 + netDebtToEBITDA: number;
61 + currentRatio: number;
62 + interestCoverage: number;
63 + incomeQuality: number;
64 + dividendYield: number;
65 + payoutRatio: number;
66 + salesGeneralAndAdministrativeToRevenue: number;
67 + researchAndDdevelopementToRevenue: number;
68 + intangiblesToTotalAssets: number;
69 + capexToOperatingCashFlow: number;
70 + capexToRevenue: number;
71 + capexToDepreciation: number;
72 + stockBasedCompensationToRevenue: number;
73 + grahamNumber: number;
74 + roic: number;
75 + returnOnTangibleAssets: number;
76 + grahamNetNet: number;
77 + workingCapital: number;
78 + tangibleAssetValue: number;
79 + netCurrentAssetValue: number;
80 + investedCapital: number;
81 + averageReceivables: number;
82 + averagePayables: number;
83 + averageInventory: number;
84 + daysSalesOutstanding: number;
85 + daysPayablesOutstanding: number;
86 + daysOfInventoryOnHand: number;
87 + receivablesTurnover: number;
88 + payablesTurnover: number;
89 + inventoryTurnover: number;
90 + roe: number;
91 + capexPerShare: number;
92 + [key: string]: any;
93 +}
94 +
95 +interface MetricsViewProps {
96 + keyMetrics?: KeyMetrics[];
97 +}
98 +
99 +export function MetricsView({ keyMetrics }: MetricsViewProps) {
100 + const formatNumber = (value: number) => {
101 + if (!value) return "N/A";
102 + const absValue = Math.abs(value);
103 + if (absValue >= 1e12) return `$${(value / 1e12).toFixed(2)}T`;
104 + if (absValue >= 1e9) return `$${(value / 1e9).toFixed(2)}B`;
105 + if (absValue >= 1e6) return `$${(value / 1e6).toFixed(2)}M`;
106 + if (absValue >= 1e3) return `$${(value / 1e3).toFixed(2)}K`;
107 + return `$${value.toFixed(2)}`;
108 + };
109 +
110 + const formatPercent = (value: number) => {
111 + if (!value && value !== 0) return "N/A";
112 + return `${(value * 100).toFixed(2)}%`;
113 + };
114 +
115 + const formatRatio = (value: number) => {
116 + if (!value && value !== 0) return "N/A";
117 + return value.toFixed(2);
118 + };
119 +
120 + const formatDate = (dateString: string) => {
121 + return new Date(dateString).toLocaleDateString('fr-FR', {
122 + year: 'numeric',
123 + month: 'short'
124 + });
125 + };
126 +
127 + const getChangeIndicator = (current: number, previous: number) => {
128 + if (!current || !previous) return null;
129 + const change = ((current - previous) / Math.abs(previous)) * 100;
130 + const isPositive = change >= 0;
131 +
132 + return (
133 + <span className={`text-xs flex items-center gap-1 ${isPositive ? 'text-green-500' : 'text-red-500'}`}>
134 + {isPositive ? <TrendingUp className="w-3 h-3" /> : <TrendingDown className="w-3 h-3" />}
135 + {isPositive ? '+' : ''}{change.toFixed(1)}%
136 + </span>
137 + );
138 + };
139 +
140 + if (!keyMetrics || keyMetrics.length === 0) {
141 + return (
142 + <Card>
143 + <CardHeader>
144 + <CardTitle>Métriques Clés</CardTitle>
145 + <CardDescription>Aucune donnée disponible</CardDescription>
146 + </CardHeader>
147 + </Card>
148 + );
149 + }
150 +
151 + const metricsSections = [
152 + {
153 + title: "Métriques de Valorisation",
154 + icon: <DollarSign className="w-5 h-5" />,
155 + description: "Ratios de valorisation et prix",
156 + metrics: [
157 + { label: "Capitalisation Boursière", key: "marketCap", format: "currency" },
158 + { label: "Valeur d'Entreprise", key: "enterpriseValue", format: "currency" },
159 + { label: "P/E Ratio", key: "peRatio", format: "ratio" },
160 + { label: "P/S Ratio", key: "priceToSalesRatio", format: "ratio" },
161 + { label: "P/B Ratio", key: "pbRatio", format: "ratio" },
162 + { label: "P/TBV Ratio", key: "ptbRatio", format: "ratio" },
163 + { label: "P/FCF Ratio", key: "pfcfRatio", format: "ratio" },
164 + { label: "EV/Sales", key: "evToSales", format: "ratio" },
165 + { label: "EV/EBITDA", key: "enterpriseValueOverEBITDA", format: "ratio" },
166 + { label: "EV/Operating CF", key: "evToOperatingCashFlow", format: "ratio" },
167 + { label: "EV/Free CF", key: "evToFreeCashFlow", format: "ratio" },
168 + { label: "Graham Number", key: "grahamNumber", format: "currency" },
169 + ]
170 + },
171 + {
172 + title: "Rentabilité & Rendements",
173 + icon: <TrendingUp className="w-5 h-5" />,
174 + description: "Indicateurs de rentabilité",
175 + metrics: [
176 + { label: "ROE (Return on Equity)", key: "roe", format: "percent" },
177 + { label: "ROIC (Return on Invested Capital)", key: "roic", format: "percent" },
178 + { label: "Return on Tangible Assets", key: "returnOnTangibleAssets", format: "percent" },
179 + { label: "Earnings Yield", key: "earningsYield", format: "percent" },
180 + { label: "Free Cash Flow Yield", key: "freeCashFlowYield", format: "percent" },
181 + { label: "Dividend Yield", key: "dividendYield", format: "percent" },
182 + { label: "Payout Ratio", key: "payoutRatio", format: "percent" },
183 + { label: "Income Quality", key: "incomeQuality", format: "ratio" },
184 + ]
185 + },
186 + {
187 + title: "Métriques par Action",
188 + icon: <PieChart className="w-5 h-5" />,
189 + description: "Données normalisées par action",
190 + metrics: [
191 + { label: "Revenu par Action", key: "revenuePerShare", format: "currency" },
192 + { label: "Bénéfice Net par Action", key: "netIncomePerShare", format: "currency" },
193 + { label: "Operating CF par Action", key: "operatingCashFlowPerShare", format: "currency" },
194 + { label: "Free CF par Action", key: "freeCashFlowPerShare", format: "currency" },
195 + { label: "Cash par Action", key: "cashPerShare", format: "currency" },
196 + { label: "Book Value par Action", key: "bookValuePerShare", format: "currency" },
197 + { label: "Tangible BV par Action", key: "tangibleBookValuePerShare", format: "currency" },
198 + { label: "Shareholders Equity par Action", key: "shareholdersEquityPerShare", format: "currency" },
199 + { label: "Capex par Action", key: "capexPerShare", format: "currency" },
200 + ]
201 + },
202 + {
203 + title: "Santé Financière & Dette",
204 + icon: <Calculator className="w-5 h-5" />,
205 + description: "Indicateurs de solvabilité et liquidité",
206 + metrics: [
207 + { label: "Dette/Capitaux Propres", key: "debtToEquity", format: "ratio" },
208 + { label: "Dette/Actifs", key: "debtToAssets", format: "ratio" },
209 + { label: "Dette Nette/EBITDA", key: "netDebtToEBITDA", format: "ratio" },
210 + { label: "Ratio de Liquidité", key: "currentRatio", format: "ratio" },
211 + { label: "Couverture des Intérêts", key: "interestCoverage", format: "ratio" },
212 + { label: "Fonds de Roulement", key: "workingCapital", format: "currency" },
213 + { label: "Capital Investi", key: "investedCapital", format: "currency" },
214 + { label: "Tangible Asset Value", key: "tangibleAssetValue", format: "currency" },
215 + ]
216 + },
217 + {
218 + title: "Efficacité Opérationnelle",
219 + icon: <Activity className="w-5 h-5" />,
220 + description: "Ratios de rotation et cycles d'exploitation",
221 + metrics: [
222 + { label: "Rotation des Créances", key: "receivablesTurnover", format: "ratio" },
223 + { label: "Rotation des Dettes", key: "payablesTurnover", format: "ratio" },
224 + { label: "Rotation des Stocks", key: "inventoryTurnover", format: "ratio" },
225 + { label: "Jours de Recouvrement (DSO)", key: "daysSalesOutstanding", format: "ratio" },
226 + { label: "Jours de Paiement (DPO)", key: "daysPayablesOutstanding", format: "ratio" },
227 + { label: "Jours de Stock (DIO)", key: "daysOfInventoryOnHand", format: "ratio" },
228 + { label: "Créances Moyennes", key: "averageReceivables", format: "currency" },
229 + { label: "Dettes Moyennes", key: "averagePayables", format: "currency" },
230 + { label: "Stock Moyen", key: "averageInventory", format: "currency" },
231 + ]
232 + },
233 + {
234 + title: "Dépenses & Allocation",
235 + icon: <Percent className="w-5 h-5" />,
236 + description: "Structure des dépenses en % du revenu",
237 + metrics: [
238 + { label: "SG&A / Revenu", key: "salesGeneralAndAdministrativeToRevenue", format: "percent" },
239 + { label: "R&D / Revenu", key: "researchAndDdevelopementToRevenue", format: "percent" },
240 + { label: "Stock Comp / Revenu", key: "stockBasedCompensationToRevenue", format: "percent" },
241 + { label: "Capex / Operating CF", key: "capexToOperatingCashFlow", format: "percent" },
242 + { label: "Capex / Revenu", key: "capexToRevenue", format: "percent" },
243 + { label: "Capex / Depreciation", key: "capexToDepreciation", format: "ratio" },
244 + { label: "Intangibles / Total Assets", key: "intangiblesToTotalAssets", format: "percent" },
245 + ]
246 + },
247 + ];
248 +
249 + return (
250 + <div className="space-y-6">
251 + {metricsSections.map((section, sectionIdx) => (
252 + <Card key={sectionIdx}>
253 + <CardHeader>
254 + <CardTitle className="flex items-center gap-2">
255 + {section.icon}
256 + {section.title}
257 + </CardTitle>
258 + <CardDescription>{section.description}</CardDescription>
259 + </CardHeader>
260 + <CardContent>
261 + <div className="overflow-x-auto">
262 + <Table>
263 + <TableHeader>
264 + <TableRow>
265 + <TableHead className="w-[250px] sticky left-0 bg-card z-10">Métrique</TableHead>
266 + {keyMetrics.slice(0, 5).map((item, idx) => (
267 + <TableHead key={idx} className="text-right min-w-[150px]">
268 + <div>
269 + <div className="font-semibold">{formatDate(item.date)}</div>
270 + <Badge variant="outline" className="text-xs mt-1">
271 + {item.period}
272 + </Badge>
273 + </div>
274 + </TableHead>
275 + ))}
276 + </TableRow>
277 + </TableHeader>
278 + <TableBody>
279 + {section.metrics.map((metric, metricIdx) => (
280 + <TableRow key={metricIdx} className="hover:bg-muted/50">
281 + <TableCell className="font-medium sticky left-0 bg-card z-10">
282 + {metric.label}
283 + </TableCell>
284 + {keyMetrics.slice(0, 5).map((item, idx) => {
285 + const value = item[metric.key];
286 + const prevValue = idx < keyMetrics.length - 1 ? keyMetrics[idx + 1][metric.key] : null;
287 + let formattedValue = "N/A";
288 +
289 + if (value || value === 0) {
290 + switch (metric.format) {
291 + case "currency":
292 + formattedValue = formatNumber(value);
293 + break;
294 + case "percent":
295 + formattedValue = formatPercent(value);
296 + break;
297 + case "ratio":
298 + formattedValue = formatRatio(value);
299 + break;
300 + default:
301 + formattedValue = value.toString();
302 + }
303 + }
304 +
305 + return (
306 + <TableCell key={idx} className="text-right">
307 + <div>
308 + <div className="font-semibold">{formattedValue}</div>
309 + {value && prevValue && getChangeIndicator(value, prevValue)}
310 + </div>
311 + </TableCell>
312 + );
313 + })}
314 + </TableRow>
315 + ))}
316 + </TableBody>
317 + </Table>
318 + </div>
319 + </CardContent>
320 + </Card>
321 + ))}
322 + </div>
323 + );
324 +}
added client/src/components/explore/news-view.tsx +157 −0
@@ -0,0 +1,157 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/explore/news-view.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
18 +import { Badge } from "@/components/ui/badge";
19 +import { Newspaper, ExternalLink, Calendar, TrendingUp } from "lucide-react";
20 +
21 +interface NewsArticle {
22 + title: string;
23 + text: string;
24 + url: string;
25 + site: string;
26 + publishedDate: string;
27 + symbol: string;
28 + image?: string;
29 + [key: string]: any;
30 +}
31 +
32 +interface NewsViewProps {
33 + news?: NewsArticle[];
34 +}
35 +
36 +export function NewsView({ news }: NewsViewProps) {
37 + const formatDate = (dateString: string) => {
38 + const date = new Date(dateString);
39 + const now = new Date();
40 + const diffInHours = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60));
41 +
42 + if (diffInHours < 1) return "Il y a moins d'une heure";
43 + if (diffInHours < 24) return `Il y a ${diffInHours} heure${diffInHours > 1 ? 's' : ''}`;
44 + if (diffInHours < 48) return "Hier";
45 +
46 + return date.toLocaleDateString('fr-FR', {
47 + year: 'numeric',
48 + month: 'long',
49 + day: 'numeric',
50 + hour: '2-digit',
51 + minute: '2-digit'
52 + });
53 + };
54 +
55 + if (!news || news.length === 0) {
56 + return (
57 + <Card>
58 + <CardHeader>
59 + <CardTitle className="flex items-center gap-2">
60 + <Newspaper className="w-5 h-5" />
61 + Actualités Financières
62 + </CardTitle>
63 + </CardHeader>
64 + <CardContent>
65 + <p className="text-muted-foreground">Aucune actualité disponible</p>
66 + </CardContent>
67 + </Card>
68 + );
69 + }
70 +
71 + return (
72 + <div className="space-y-6">
73 + <div className="flex items-center gap-3 mb-4">
74 + <div className="p-2 rounded-lg bg-primary/10">
75 + <Newspaper className="w-6 h-6 text-primary" />
76 + </div>
77 + <div>
78 + <h2 className="text-2xl font-bold">Actualités & Analyses</h2>
79 + <p className="text-muted-foreground">
80 + {news.length} article{news.length > 1 ? 's' : ''} récent{news.length > 1 ? 's' : ''}
81 + </p>
82 + </div>
83 + </div>
84 +
85 + <div className="grid grid-cols-1 gap-4">
86 + {news.map((article, idx) => (
87 + <Card
88 + key={idx}
89 + className="hover:shadow-lg transition-all duration-300 hover:border-primary/50 cursor-pointer"
90 + onClick={() => window.open(article.url, '_blank')}
91 + >
92 + <CardContent className="pt-6">
93 + <div className="flex gap-4">
94 + {/* Image */}
95 + {article.image && (
96 + <div className="flex-shrink-0 w-48 h-32 rounded-lg overflow-hidden bg-muted">
97 + <img
98 + src={article.image}
99 + alt={article.title}
100 + className="w-full h-full object-cover"
101 + onError={(e) => {
102 + (e.target as HTMLImageElement).style.display = 'none';
103 + }}
104 + />
105 + </div>
106 + )}
107 +
108 + {/* Content */}
109 + <div className="flex-1 min-w-0">
110 + <div className="flex items-start justify-between gap-4 mb-2">
111 + <h3 className="text-xl font-bold line-clamp-2 hover:text-primary transition-colors">
112 + {article.title}
113 + </h3>
114 + <ExternalLink className="w-5 h-5 flex-shrink-0 text-muted-foreground" />
115 + </div>
116 +
117 + <p className="text-muted-foreground line-clamp-3 mb-3">
118 + {article.text}
119 + </p>
120 +
121 + <div className="flex items-center gap-3 flex-wrap">
122 + <Badge variant="outline" className="gap-1">
123 + <Calendar className="w-3 h-3" />
124 + {formatDate(article.publishedDate)}
125 + </Badge>
126 +
127 + <Badge variant="secondary">
128 + {article.site}
129 + </Badge>
130 +
131 + {article.symbol && (
132 + <Badge variant="default">
133 + {article.symbol}
134 + </Badge>
135 + )}
136 + </div>
137 + </div>
138 + </div>
139 + </CardContent>
140 + </Card>
141 + ))}
142 + </div>
143 +
144 + {/* Load More Button */}
145 + {news.length >= 10 && (
146 + <Card className="border-dashed">
147 + <CardContent className="flex items-center justify-center py-8">
148 + <button className="text-primary hover:underline font-semibold flex items-center gap-2">
149 + <TrendingUp className="w-4 h-4" />
150 + Charger plus d'actualités
151 + </button>
152 + </CardContent>
153 + </Card>
154 + )}
155 + </div>
156 + );
157 +}
added client/src/components/explore/ownership-view.tsx +207 −0
@@ -0,0 +1,207 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/explore/ownership-view.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
18 +import { Badge } from "@/components/ui/badge";
19 +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
20 +import { Building2, TrendingUp, TrendingDown, Percent, Calendar } from "lucide-react";
21 +
22 +interface InstitutionalHolder {
23 + holder: string;
24 + shares: number;
25 + dateReported: string;
26 + change: number;
27 + [key: string]: any;
28 +}
29 +
30 +interface OwnershipViewProps {
31 + institutionalHolders?: InstitutionalHolder[];
32 +}
33 +
34 +export function OwnershipView({ institutionalHolders }: OwnershipViewProps) {
35 + const formatNumber = (value: number) => {
36 + if (!value) return "N/A";
37 + if (value >= 1e9) return `${(value / 1e9).toFixed(2)}B`;
38 + if (value >= 1e6) return `${(value / 1e6).toFixed(2)}M`;
39 + if (value >= 1e3) return `${(value / 1e3).toFixed(2)}K`;
40 + return value.toLocaleString();
41 + };
42 +
43 + const formatDate = (dateString: string) => {
44 + return new Date(dateString).toLocaleDateString('fr-FR', {
45 + year: 'numeric',
46 + month: 'long',
47 + day: 'numeric'
48 + });
49 + };
50 +
51 + if (!institutionalHolders || institutionalHolders.length === 0) {
52 + return (
53 + <Card>
54 + <CardHeader>
55 + <CardTitle className="flex items-center gap-2">
56 + <Building2 className="w-5 h-5" />
57 + Actionnariat Institutionnel
58 + </CardTitle>
59 + </CardHeader>
60 + <CardContent>
61 + <p className="text-muted-foreground">Aucune donnée d'actionnariat disponible</p>
62 + </CardContent>
63 + </Card>
64 + );
65 + }
66 +
67 + const totalShares = institutionalHolders.reduce((sum, holder) => sum + (holder.shares || 0), 0);
68 + const holdersWithIncrease = institutionalHolders.filter(h => (h.change || 0) > 0).length;
69 + const holdersWithDecrease = institutionalHolders.filter(h => (h.change || 0) < 0).length;
70 +
71 + return (
72 + <div className="space-y-6">
73 + {/* Summary Stats */}
74 + <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
75 + <Card>
76 + <CardHeader className="pb-3">
77 + <CardDescription>Total Institutions</CardDescription>
78 + </CardHeader>
79 + <CardContent>
80 + <div className="text-3xl font-bold">{institutionalHolders.length}</div>
81 + </CardContent>
82 + </Card>
83 +
84 + <Card>
85 + <CardHeader className="pb-3">
86 + <CardDescription>Actions Détenues</CardDescription>
87 + </CardHeader>
88 + <CardContent>
89 + <div className="text-3xl font-bold">{formatNumber(totalShares)}</div>
90 + </CardContent>
91 + </Card>
92 +
93 + <Card>
94 + <CardHeader className="pb-3">
95 + <CardDescription>Augmentations</CardDescription>
96 + </CardHeader>
97 + <CardContent>
98 + <div className="text-3xl font-bold text-green-500 flex items-center gap-2">
99 + <TrendingUp className="w-6 h-6" />
100 + {holdersWithIncrease}
101 + </div>
102 + </CardContent>
103 + </Card>
104 +
105 + <Card>
106 + <CardHeader className="pb-3">
107 + <CardDescription>Diminutions</CardDescription>
108 + </CardHeader>
109 + <CardContent>
110 + <div className="text-3xl font-bold text-red-500 flex items-center gap-2">
111 + <TrendingDown className="w-6 h-6" />
112 + {holdersWithDecrease}
113 + </div>
114 + </CardContent>
115 + </Card>
116 + </div>
117 +
118 + {/* Detailed Table */}
119 + <Card>
120 + <CardHeader>
121 + <CardTitle className="flex items-center gap-2">
122 + <Building2 className="w-5 h-5" />
123 + Principaux Actionnaires Institutionnels
124 + </CardTitle>
125 + <CardDescription>
126 + Détention institutionnelle et changements récents
127 + </CardDescription>
128 + </CardHeader>
129 + <CardContent>
130 + <div className="overflow-x-auto">
131 + <Table>
132 + <TableHeader>
133 + <TableRow>
134 + <TableHead className="w-[50px]">Rang</TableHead>
135 + <TableHead className="min-w-[300px]">Institution</TableHead>
136 + <TableHead className="text-right">Actions Détenues</TableHead>
137 + <TableHead className="text-right">Variation</TableHead>
138 + <TableHead className="text-right">% de Variation</TableHead>
139 + <TableHead>Date de Rapport</TableHead>
140 + </TableRow>
141 + </TableHeader>
142 + <TableBody>
143 + {institutionalHolders.map((holder, idx) => {
144 + const changeValue = holder.change || 0;
145 + const isPositive = changeValue >= 0;
146 + const percentChange = holder.shares > 0 ? (changeValue / holder.shares) * 100 : 0;
147 +
148 + return (
149 + <TableRow key={idx} className="hover:bg-muted/50">
150 + <TableCell className="font-bold text-muted-foreground">
151 + #{idx + 1}
152 + </TableCell>
153 + <TableCell>
154 + <div className="flex items-center gap-2">
155 + <div className="w-10 h-10 rounded-lg bg-primary/10 flex items-center justify-center">
156 + <Building2 className="w-5 h-5 text-primary" />
157 + </div>
158 + <div>
159 + <div className="font-semibold">{holder.holder}</div>
160 + {holder.shares > 0 && (
161 + <div className="text-xs text-muted-foreground">
162 + {((holder.shares / totalShares) * 100).toFixed(2)}% du total
163 + </div>
164 + )}
165 + </div>
166 + </div>
167 + </TableCell>
168 + <TableCell className="text-right font-semibold">
169 + {formatNumber(holder.shares)}
170 + </TableCell>
171 + <TableCell className="text-right">
172 + <div className={`flex items-center justify-end gap-1 ${isPositive ? 'text-green-500' : 'text-red-500'}`}>
173 + {isPositive ? (
174 + <TrendingUp className="w-4 h-4" />
175 + ) : (
176 + <TrendingDown className="w-4 h-4" />
177 + )}
178 + <span className="font-semibold">
179 + {isPositive ? '+' : ''}{formatNumber(changeValue)}
180 + </span>
181 + </div>
182 + </TableCell>
183 + <TableCell className="text-right">
184 + <Badge
185 + variant={isPositive ? "default" : "destructive"}
186 + className="font-semibold"
187 + >
188 + {isPositive ? '+' : ''}{percentChange.toFixed(2)}%
189 + </Badge>
190 + </TableCell>
191 + <TableCell>
192 + <div className="flex items-center gap-2 text-sm text-muted-foreground">
193 + <Calendar className="w-4 h-4" />
194 + {formatDate(holder.dateReported)}
195 + </div>
196 + </TableCell>
197 + </TableRow>
198 + );
199 + })}
200 + </TableBody>
201 + </Table>
202 + </div>
203 + </CardContent>
204 + </Card>
205 + </div>
206 + );
207 +}
added client/src/components/explore/stock-header.tsx +293 −0
@@ -0,0 +1,293 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/explore/stock-header.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { Badge } from "@/components/ui/badge";
18 +import { Button } from "@/components/ui/button";
19 +import { Card, CardContent } from "@/components/ui/card";
20 +import { ArrowLeft, TrendingUp, TrendingDown, Building2, Globe, ExternalLink, DollarSign } from "lucide-react";
21 +
22 +interface CompanyProfile {
23 + symbol: string;
24 + companyName: string;
25 + industry: string;
26 + sector: string;
27 + ceo: string;
28 + website: string;
29 + description: string;
30 + country: string;
31 + city: string;
32 + address: string;
33 + employees: number;
34 + marketCap: number;
35 + image: string;
36 + exchange: string;
37 + isin: string;
38 + cusip: string;
39 + phone: string;
40 +}
41 +
42 +interface StockQuote {
43 + symbol: string;
44 + price: number;
45 + change: number;
46 + changesPercentage: number;
47 + dayLow: number;
48 + dayHigh: number;
49 + yearHigh: number;
50 + yearLow: number;
51 + marketCap: number;
52 + priceAvg50: number;
53 + priceAvg200: number;
54 + volume: number;
55 + avgVolume: number;
56 + open: number;
57 + previousClose: number;
58 + eps: number;
59 + pe: number;
60 + earningsAnnouncement: string;
61 + sharesOutstanding: number;
62 + timestamp: number;
63 +}
64 +
65 +interface StockHeaderProps {
66 + ticker: string;
67 + companyProfile?: CompanyProfile;
68 + stockQuote?: StockQuote;
69 + onBack: () => void;
70 +}
71 +
72 +export function StockHeader({ ticker, companyProfile, stockQuote, onBack }: StockHeaderProps) {
73 + const isPositive = (stockQuote?.changesPercentage ?? 0) >= 0;
74 +
75 + const formatNumber = (num: number) => {
76 + if (num >= 1e12) return `$${(num / 1e12).toFixed(2)}T`;
77 + if (num >= 1e9) return `$${(num / 1e9).toFixed(2)}B`;
78 + if (num >= 1e6) return `$${(num / 1e6).toFixed(2)}M`;
79 + if (num >= 1e3) return `$${(num / 1e3).toFixed(2)}K`;
80 + return `$${num.toFixed(2)}`;
81 + };
82 +
83 + const formatVolume = (vol: number) => {
84 + if (vol >= 1e9) return `${(vol / 1e9).toFixed(2)}B`;
85 + if (vol >= 1e6) return `${(vol / 1e6).toFixed(2)}M`;
86 + if (vol >= 1e3) return `${(vol / 1e3).toFixed(2)}K`;
87 + return vol.toString();
88 + };
89 +
90 + return (
91 + <div className="space-y-6">
92 + {/* Back Button */}
93 + <Button
94 + variant="outline"
95 + onClick={onBack}
96 + className="gap-2 hover-elevate active-elevate-2"
97 + >
98 + <ArrowLeft className="w-4 h-4" />
99 + Retour à l'aperçu du marché
100 + </Button>
101 +
102 + {/* Main Stock Header */}
103 + <Card className="bg-gradient-to-br from-card via-card to-primary/5 border-2">
104 + <CardContent className="pt-6">
105 + <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
106 + {/* Company Info */}
107 + <div className="lg:col-span-2 space-y-4">
108 + <div className="flex items-start gap-4">
109 + {companyProfile?.image && (
110 + <div className="flex-shrink-0 w-16 h-16 rounded-xl bg-white p-2 shadow-md">
111 + <img
112 + src={companyProfile.image}
113 + alt={companyProfile.companyName}
114 + className="w-full h-full object-contain"
115 + />
116 + </div>
117 + )}
118 + <div className="flex-1 min-w-0">
119 + <div className="flex items-start justify-between gap-4">
120 + <div>
121 + <h1 className="text-3xl font-bold mb-1">
122 + {companyProfile?.companyName || ticker}
123 + </h1>
124 + <div className="flex items-center gap-3 flex-wrap">
125 + <Badge variant="secondary" className="text-base font-mono px-3 py-1">
126 + {ticker}
127 + </Badge>
128 + {companyProfile?.exchange && (
129 + <Badge variant="outline" className="text-sm">
130 + {companyProfile.exchange}
131 + </Badge>
132 + )}
133 + {companyProfile?.sector && (
134 + <Badge variant="outline" className="text-sm">
135 + {companyProfile.sector}
136 + </Badge>
137 + )}
138 + {companyProfile?.industry && (
139 + <Badge variant="outline" className="text-sm">
140 + {companyProfile.industry}
141 + </Badge>
142 + )}
143 + </div>
144 + </div>
145 + </div>
146 +
147 + {companyProfile?.description && (
148 + <p className="mt-3 text-muted-foreground line-clamp-3">
149 + {companyProfile.description}
150 + </p>
151 + )}
152 +
153 + {companyProfile && (
154 + <div className="mt-4 flex flex-wrap gap-x-6 gap-y-2 text-sm">
155 + {companyProfile.ceo && (
156 + <div>
157 + <span className="text-muted-foreground">CEO:</span>{" "}
158 + <span className="font-semibold">{companyProfile.ceo}</span>
159 + </div>
160 + )}
161 + {companyProfile.employees && (
162 + <div>
163 + <span className="text-muted-foreground">Employés:</span>{" "}
164 + <span className="font-semibold">{companyProfile.employees.toLocaleString()}</span>
165 + </div>
166 + )}
167 + {companyProfile.country && (
168 + <div>
169 + <span className="text-muted-foreground">Pays:</span>{" "}
170 + <span className="font-semibold">{companyProfile.country}</span>
171 + </div>
172 + )}
173 + {companyProfile.website && (
174 + <a
175 + href={companyProfile.website}
176 + target="_blank"
177 + rel="noopener noreferrer"
178 + className="flex items-center gap-1 text-primary hover:underline"
179 + >
180 + <Globe className="w-4 h-4" />
181 + Site Web
182 + <ExternalLink className="w-3 h-3" />
183 + </a>
184 + )}
185 + </div>
186 + )}
187 + </div>
188 + </div>
189 + </div>
190 +
191 + {/* Live Quote */}
192 + {stockQuote && (
193 + <div className="lg:border-l lg:pl-6">
194 + <div className="space-y-4">
195 + <div>
196 + <div className="text-sm text-muted-foreground mb-1">Prix Actuel</div>
197 + <div className="text-4xl font-bold">
198 + ${stockQuote.price?.toFixed(2)}
199 + </div>
200 + <div className={`text-xl font-semibold flex items-center gap-2 mt-2 ${
201 + isPositive ? 'text-green-500' : 'text-red-500'
202 + }`}>
203 + {isPositive ? (
204 + <TrendingUp className="w-5 h-5" />
205 + ) : (
206 + <TrendingDown className="w-5 h-5" />
207 + )}
208 + <span>
209 + {isPositive ? '+' : ''}{stockQuote.change?.toFixed(2)}
210 + </span>
211 + <span className="text-lg">
212 + ({isPositive ? '+' : ''}{stockQuote.changesPercentage?.toFixed(2)}%)
213 + </span>
214 + </div>
215 + </div>
216 +
217 + <div className="pt-4 border-t space-y-3">
218 + <div className="grid grid-cols-2 gap-3 text-sm">
219 + <div>
220 + <div className="text-muted-foreground">Ouverture</div>
221 + <div className="font-semibold">${stockQuote.open?.toFixed(2)}</div>
222 + </div>
223 + <div>
224 + <div className="text-muted-foreground">Clôture Préc.</div>
225 + <div className="font-semibold">${stockQuote.previousClose?.toFixed(2)}</div>
226 + </div>
227 + <div>
228 + <div className="text-muted-foreground">Jour Bas</div>
229 + <div className="font-semibold">${stockQuote.dayLow?.toFixed(2)}</div>
230 + </div>
231 + <div>
232 + <div className="text-muted-foreground">Jour Haut</div>
233 + <div className="font-semibold">${stockQuote.dayHigh?.toFixed(2)}</div>
234 + </div>
235 + <div>
236 + <div className="text-muted-foreground">52W Bas</div>
237 + <div className="font-semibold">${stockQuote.yearLow?.toFixed(2)}</div>
238 + </div>
239 + <div>
240 + <div className="text-muted-foreground">52W Haut</div>
241 + <div className="font-semibold">${stockQuote.yearHigh?.toFixed(2)}</div>
242 + </div>
243 + </div>
244 +
245 + <div className="pt-3 border-t space-y-2 text-sm">
246 + <div className="flex justify-between">
247 + <span className="text-muted-foreground">Volume</span>
248 + <span className="font-semibold">{formatVolume(stockQuote.volume)}</span>
249 + </div>
250 + <div className="flex justify-between">
251 + <span className="text-muted-foreground">Vol. Moyen</span>
252 + <span className="font-semibold">{formatVolume(stockQuote.avgVolume)}</span>
253 + </div>
254 + <div className="flex justify-between">
255 + <span className="text-muted-foreground">Cap. Boursière</span>
256 + <span className="font-semibold">{formatNumber(stockQuote.marketCap)}</span>
257 + </div>
258 + {stockQuote.pe && (
259 + <div className="flex justify-between">
260 + <span className="text-muted-foreground">P/E Ratio</span>
261 + <span className="font-semibold">{stockQuote.pe.toFixed(2)}</span>
262 + </div>
263 + )}
264 + {stockQuote.eps && (
265 + <div className="flex justify-between">
266 + <span className="text-muted-foreground">EPS</span>
267 + <span className="font-semibold">${stockQuote.eps.toFixed(2)}</span>
268 + </div>
269 + )}
270 + </div>
271 +
272 + {stockQuote.earningsAnnouncement && (
273 + <div className="pt-3 border-t">
274 + <div className="text-xs text-muted-foreground">Prochains Résultats</div>
275 + <div className="text-sm font-semibold">
276 + {new Date(stockQuote.earningsAnnouncement).toLocaleDateString('fr-FR', {
277 + year: 'numeric',
278 + month: 'long',
279 + day: 'numeric'
280 + })}
281 + </div>
282 + </div>
283 + )}
284 + </div>
285 + </div>
286 + </div>
287 + )}
288 + </div>
289 + </CardContent>
290 + </Card>
291 + </div>
292 + );
293 +}
added client/src/components/financial/advanced-price-chart.tsx +535 −0
@@ -0,0 +1,535 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/financial/advanced-price-chart.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
18 +import { Badge } from "@/components/ui/badge";
19 +import { Button } from "@/components/ui/button";
20 +import { TrendingUp, TrendingDown, Calendar, BarChart3, LineChart as LineChartIcon } from "lucide-react";
21 +import { useState } from "react";
22 +import {
23 + ComposedChart,
24 + Line,
25 + Bar,
26 + XAxis,
27 + YAxis,
28 + CartesianGrid,
29 + Tooltip,
30 + ResponsiveContainer,
31 + Area,
32 + ReferenceLine,
33 + Legend,
34 + Brush,
35 +} from "recharts";
36 +
37 +interface HistoricalPrice {
38 + date: string;
39 + open: number;
40 + high: number;
41 + low: number;
42 + close: number;
43 + adjClose: number;
44 + volume: number;
45 + unadjustedVolume: number;
46 + change: number;
47 + changePercent: number;
48 + vwap: number;
49 + label: string;
50 + changeOverTime: number;
51 +}
52 +
53 +interface AdvancedPriceChartProps {
54 + data: {
55 + symbol: string;
56 + historical: HistoricalPrice[];
57 + };
58 +}
59 +
60 +export function AdvancedPriceChart({ data }: AdvancedPriceChartProps) {
61 + const [chartType, setChartType] = useState<'area' | 'line'>('area');
62 + const [showVolume, setShowVolume] = useState(true);
63 + const [showMA, setShowMA] = useState(true);
64 + const [timeRange, setTimeRange] = useState<'1M' | '3M' | '6M' | 'YTD' | '1Y' | 'ALL'>('3M');
65 +
66 + if (!data || !data.historical || data.historical.length === 0) {
67 + return (
68 + <Card className="w-full">
69 + <CardHeader>
70 + <CardTitle>Advanced Price Chart</CardTitle>
71 + <CardDescription>No price data available</CardDescription>
72 + </CardHeader>
73 + </Card>
74 + );
75 + }
76 +
77 + const symbol = data.symbol;
78 + const allData = [...data.historical].reverse();
79 +
80 + // Filter data based on time range
81 + const filterDataByRange = () => {
82 + const now = new Date();
83 + let startDate = new Date();
84 +
85 + switch (timeRange) {
86 + case '1M':
87 + startDate.setMonth(now.getMonth() - 1);
88 + break;
89 + case '3M':
90 + startDate.setMonth(now.getMonth() - 3);
91 + break;
92 + case '6M':
93 + startDate.setMonth(now.getMonth() - 6);
94 + break;
95 + case 'YTD':
96 + startDate = new Date(now.getFullYear(), 0, 1);
97 + break;
98 + case '1Y':
99 + startDate.setFullYear(now.getFullYear() - 1);
100 + break;
101 + case 'ALL':
102 + return allData;
103 + }
104 +
105 + return allData.filter(item => new Date(item.date) >= startDate);
106 + };
107 +
108 + const filteredData = filterDataByRange();
109 +
110 + // Calculate moving averages
111 + const calculateMA = (period: number) => {
112 + return filteredData.map((item, index) => {
113 + if (index < period - 1) return null;
114 + const sum = filteredData
115 + .slice(index - period + 1, index + 1)
116 + .reduce((acc, curr) => acc + curr.close, 0);
117 + return sum / period;
118 + });
119 + };
120 +
121 + const calculateEMA = (period: number) => {
122 + const multiplier = 2 / (period + 1);
123 + const ema = [];
124 +
125 + // Start with SMA
126 + let sum = 0;
127 + for (let i = 0; i < period; i++) {
128 + if (i >= filteredData.length) break;
129 + sum += filteredData[i].close;
130 + ema.push(i === period - 1 ? sum / period : null);
131 + }
132 +
133 + // Calculate EMA
134 + for (let i = period; i < filteredData.length; i++) {
135 + const prevEMA = ema[i - 1] || filteredData[i - 1].close;
136 + ema.push(filteredData[i].close * multiplier + prevEMA * (1 - multiplier));
137 + }
138 +
139 + return ema;
140 + };
141 +
142 + const ma20 = calculateMA(20);
143 + const ma50 = calculateMA(50);
144 + const ema12 = calculateEMA(12);
145 + const ema26 = calculateEMA(26);
146 +
147 + const enrichedData = filteredData.map((item, index) => ({
148 + ...item,
149 + dateFormatted: new Date(item.date).toLocaleDateString('en-US', {
150 + month: 'short',
151 + day: 'numeric',
152 + year: '2-digit'
153 + }),
154 + ma20: ma20[index],
155 + ma50: ma50[index],
156 + ema12: ema12[index],
157 + ema26: ema26[index],
158 + isGreen: item.close >= item.open,
159 + }));
160 +
161 + const firstPrice = enrichedData[0]?.close || 0;
162 + const lastPrice = enrichedData[enrichedData.length - 1]?.close || 0;
163 + const priceChange = lastPrice - firstPrice;
164 + const priceChangePercent = (priceChange / firstPrice) * 100;
165 + const isPositive = priceChange >= 0;
166 +
167 + const minPrice = Math.min(...enrichedData.map(d => d.low));
168 + const maxPrice = Math.max(...enrichedData.map(d => d.high));
169 + const avgVolume = enrichedData.reduce((sum, d) => sum + d.volume, 0) / enrichedData.length;
170 +
171 + const high52w = Math.max(...allData.slice(-252).map(d => d.high));
172 + const low52w = Math.min(...allData.slice(-252).map(d => d.low));
173 +
174 + const CustomTooltip = ({ active, payload }: any) => {
175 + if (active && payload && payload.length) {
176 + const data = payload[0].payload;
177 + return (
178 + <div className="bg-background/95 backdrop-blur border rounded-lg p-4 shadow-lg">
179 + <div className="text-sm font-semibold mb-2">{data.label}</div>
180 + <div className="space-y-1.5 text-sm">
181 + <div className="flex justify-between gap-6">
182 + <span className="text-muted-foreground">Open:</span>
183 + <span className="font-mono font-semibold">${data.open?.toFixed(2)}</span>
184 + </div>
185 + <div className="flex justify-between gap-6">
186 + <span className="text-muted-foreground">High:</span>
187 + <span className="font-mono font-semibold text-green-600">${data.high?.toFixed(2)}</span>
188 + </div>
189 + <div className="flex justify-between gap-6">
190 + <span className="text-muted-foreground">Low:</span>
191 + <span className="font-mono font-semibold text-red-600">${data.low?.toFixed(2)}</span>
192 + </div>
193 + <div className="flex justify-between gap-6">
194 + <span className="text-muted-foreground">Close:</span>
195 + <span className="font-mono font-semibold">${data.close?.toFixed(2)}</span>
196 + </div>
197 + <div className="border-t pt-1.5 flex justify-between gap-6">
198 + <span className="text-muted-foreground">Change:</span>
199 + <span className={`font-mono ${data.changePercent >= 0 ? 'text-green-600' : 'text-red-600'}`}>
200 + {data.changePercent >= 0 ? '+' : ''}{data.changePercent?.toFixed(2)}%
201 + </span>
202 + </div>
203 + <div className="flex justify-between gap-6">
204 + <span className="text-muted-foreground">Volume:</span>
205 + <span className="font-mono">{(data.volume / 1_000_000).toFixed(2)}M</span>
206 + </div>
207 + {showMA && (
208 + <>
209 + {data.ma20 && (
210 + <div className="flex justify-between gap-6">
211 + <span className="text-muted-foreground">MA(20):</span>
212 + <span className="font-mono text-blue-600">${data.ma20?.toFixed(2)}</span>
213 + </div>
214 + )}
215 + {data.ma50 && (
216 + <div className="flex justify-between gap-6">
217 + <span className="text-muted-foreground">MA(50):</span>
218 + <span className="font-mono text-purple-600">${data.ma50?.toFixed(2)}</span>
219 + </div>
220 + )}
221 + </>
222 + )}
223 + </div>
224 + </div>
225 + );
226 + }
227 + return null;
228 + };
229 +
230 + return (
231 + <Card className="w-full">
232 + <CardHeader>
233 + <div className="flex flex-col md:flex-row md:items-start justify-between gap-4">
234 + <div>
235 + <div className="flex items-center gap-2">
236 + <BarChart3 className="h-5 w-5 text-primary" />
237 + <CardTitle className="text-2xl">{symbol} - Historical Price Chart</CardTitle>
238 + </div>
239 + <CardDescription className="mt-1">
240 + Advanced charting with technical indicators · {enrichedData.length} trading days
241 + </CardDescription>
242 + </div>
243 +
244 + <div className="flex flex-col items-end gap-2">
245 + <div className="text-right">
246 + <div className="text-3xl font-bold">${lastPrice.toFixed(2)}</div>
247 + <div className={`flex items-center gap-1 justify-end text-sm ${isPositive ? 'text-green-600' : 'text-red-600'}`}>
248 + {isPositive ? <TrendingUp className="h-4 w-4" /> : <TrendingDown className="h-4 w-4" />}
249 + <span className="font-semibold">
250 + {isPositive ? '+' : ''}{priceChange.toFixed(2)} ({isPositive ? '+' : ''}{priceChangePercent.toFixed(2)}%)
251 + </span>
252 + </div>
253 + </div>
254 + </div>
255 + </div>
256 +
257 + {/* Controls */}
258 + <div className="flex flex-wrap gap-2 mt-4">
259 + {/* Time Range Selector */}
260 + <div className="flex gap-1 border rounded-lg p-1">
261 + {(['1M', '3M', '6M', 'YTD', '1Y', 'ALL'] as const).map((range) => (
262 + <Button
263 + key={range}
264 + variant={timeRange === range ? 'default' : 'ghost'}
265 + size="sm"
266 + onClick={() => setTimeRange(range)}
267 + >
268 + {range}
269 + </Button>
270 + ))}
271 + </div>
272 +
273 + {/* Chart Type Selector */}
274 + <div className="flex gap-1 border rounded-lg p-1">
275 + <Button
276 + variant={chartType === 'area' ? 'default' : 'ghost'}
277 + size="sm"
278 + onClick={() => setChartType('area')}
279 + >
280 + <LineChartIcon className="h-4 w-4 mr-1" />
281 + Area
282 + </Button>
283 + <Button
284 + variant={chartType === 'line' ? 'default' : 'ghost'}
285 + size="sm"
286 + onClick={() => setChartType('line')}
287 + >
288 + Line
289 + </Button>
290 + </div>
291 +
292 + <Button
293 + variant={showVolume ? 'default' : 'outline'}
294 + size="sm"
295 + onClick={() => setShowVolume(!showVolume)}
296 + >
297 + Volume
298 + </Button>
299 +
300 + <Button
301 + variant={showMA ? 'default' : 'outline'}
302 + size="sm"
303 + onClick={() => setShowMA(!showMA)}
304 + >
305 + Indicators
306 + </Button>
307 + </div>
308 + </CardHeader>
309 +
310 + <CardContent className="space-y-6">
311 + {/* Key Stats */}
312 + <div className="grid grid-cols-2 md:grid-cols-5 gap-3">
313 + <div className="p-3 border rounded-lg bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-950 dark:to-blue-900">
314 + <div className="text-xs text-muted-foreground mb-1">Period High</div>
315 + <div className="text-lg font-bold text-blue-700 dark:text-blue-400">${maxPrice.toFixed(2)}</div>
316 + </div>
317 + <div className="p-3 border rounded-lg bg-gradient-to-br from-red-50 to-red-100 dark:from-red-950 dark:to-red-900">
318 + <div className="text-xs text-muted-foreground mb-1">Period Low</div>
319 + <div className="text-lg font-bold text-red-700 dark:text-red-400">${minPrice.toFixed(2)}</div>
320 + </div>
321 + <div className="p-3 border rounded-lg bg-gradient-to-br from-green-50 to-green-100 dark:from-green-950 dark:to-green-900">
322 + <div className="text-xs text-muted-foreground mb-1">52W High</div>
323 + <div className="text-lg font-bold text-green-700 dark:text-green-400">${high52w.toFixed(2)}</div>
324 + </div>
325 + <div className="p-3 border rounded-lg bg-gradient-to-br from-orange-50 to-orange-100 dark:from-orange-950 dark:to-orange-900">
326 + <div className="text-xs text-muted-foreground mb-1">52W Low</div>
327 + <div className="text-lg font-bold text-orange-700 dark:text-orange-400">${low52w.toFixed(2)}</div>
328 + </div>
329 + <div className="p-3 border rounded-lg bg-gradient-to-br from-purple-50 to-purple-100 dark:from-purple-950 dark:to-purple-900">
330 + <div className="text-xs text-muted-foreground mb-1">Avg Volume</div>
331 + <div className="text-lg font-bold text-purple-700 dark:text-purple-400">
332 + {(avgVolume / 1_000_000).toFixed(1)}M
333 + </div>
334 + </div>
335 + </div>
336 +
337 + {/* Main Price Chart */}
338 + <div className="h-[500px] w-full">
339 + <ResponsiveContainer width="100%" height="100%">
340 + <ComposedChart data={enrichedData} margin={{ top: 10, right: 10, left: 0, bottom: 20 }}>
341 + <defs>
342 + <linearGradient id="colorPriceArea" x1="0" y1="0" x2="0" y2="1">
343 + <stop offset="5%" stopColor={isPositive ? "#10b981" : "#ef4444"} stopOpacity={0.4}/>
344 + <stop offset="95%" stopColor={isPositive ? "#10b981" : "#ef4444"} stopOpacity={0.05}/>
345 + </linearGradient>
346 + </defs>
347 + <CartesianGrid strokeDasharray="3 3" opacity={0.2} vertical={false} />
348 + <XAxis
349 + dataKey="dateFormatted"
350 + tick={{ fontSize: 11 }}
351 + interval="preserveStartEnd"
352 + height={60}
353 + angle={-45}
354 + textAnchor="end"
355 + />
356 + <YAxis
357 + yAxisId="price"
358 + domain={['auto', 'auto']}
359 + tick={{ fontSize: 11 }}
360 + tickFormatter={(value) => `$${value.toFixed(0)}`}
361 + width={60}
362 + />
363 + <Tooltip content={<CustomTooltip />} />
364 + <Legend wrapperStyle={{ fontSize: '12px', paddingTop: '10px' }} />
365 +
366 + {/* 52-week high/low reference lines */}
367 + <ReferenceLine
368 + yAxisId="price"
369 + y={high52w}
370 + stroke="#10b981"
371 + strokeDasharray="3 3"
372 + strokeOpacity={0.5}
373 + label={{ value: '52W High', fontSize: 10, fill: '#10b981', position: 'right' }}
374 + />
375 + <ReferenceLine
376 + yAxisId="price"
377 + y={low52w}
378 + stroke="#ef4444"
379 + strokeDasharray="3 3"
380 + strokeOpacity={0.5}
381 + label={{ value: '52W Low', fontSize: 10, fill: '#ef4444', position: 'right' }}
382 + />
383 +
384 + {/* Chart Types */}
385 + {chartType === 'area' && (
386 + <Area
387 + yAxisId="price"
388 + type="monotone"
389 + dataKey="close"
390 + stroke={isPositive ? "#10b981" : "#ef4444"}
391 + strokeWidth={2}
392 + fill="url(#colorPriceArea)"
393 + name="Price"
394 + />
395 + )}
396 +
397 + {chartType === 'line' && (
398 + <Line
399 + yAxisId="price"
400 + type="monotone"
401 + dataKey="close"
402 + stroke={isPositive ? "#10b981" : "#ef4444"}
403 + strokeWidth={2}
404 + dot={false}
405 + name="Price"
406 + />
407 + )}
408 +
409 + {/* Moving Averages */}
410 + {showMA && (
411 + <>
412 + <Line
413 + yAxisId="price"
414 + type="monotone"
415 + dataKey="ma20"
416 + stroke="#3b82f6"
417 + strokeWidth={1.5}
418 + dot={false}
419 + name="MA(20)"
420 + strokeDasharray="5 5"
421 + />
422 + <Line
423 + yAxisId="price"
424 + type="monotone"
425 + dataKey="ma50"
426 + stroke="#8b5cf6"
427 + strokeWidth={1.5}
428 + dot={false}
429 + name="MA(50)"
430 + strokeDasharray="3 3"
431 + />
432 + </>
433 + )}
434 +
435 + <Brush
436 + dataKey="dateFormatted"
437 + height={30}
438 + stroke="#8b5cf6"
439 + fill="hsl(var(--muted))"
440 + travellerWidth={10}
441 + />
442 + </ComposedChart>
443 + </ResponsiveContainer>
444 + </div>
445 +
446 + {/* Volume Chart */}
447 + {showVolume && (
448 + <div>
449 + <div className="text-sm font-medium mb-3">Trading Volume</div>
450 + <div className="h-[150px] w-full">
451 + <ResponsiveContainer width="100%" height="100%">
452 + <ComposedChart data={enrichedData}>
453 + <CartesianGrid strokeDasharray="3 3" opacity={0.2} vertical={false} />
454 + <XAxis
455 + dataKey="dateFormatted"
456 + tick={{ fontSize: 11 }}
457 + interval="preserveStartEnd"
458 + angle={-45}
459 + textAnchor="end"
460 + height={60}
461 + />
462 + <YAxis
463 + tick={{ fontSize: 11 }}
464 + tickFormatter={(value) => `${(value / 1_000_000).toFixed(0)}M`}
465 + width={50}
466 + />
467 + <Tooltip
468 + contentStyle={{
469 + backgroundColor: 'hsl(var(--background))',
470 + border: '1px solid hsl(var(--border))',
471 + borderRadius: '8px',
472 + fontSize: '12px'
473 + }}
474 + formatter={(value: any) => [(value / 1_000_000).toFixed(2) + 'M', 'Volume']}
475 + />
476 + <Bar
477 + dataKey="volume"
478 + radius={[4, 4, 0, 0]}
479 + >
480 + {enrichedData.map((entry, index) => (
481 + <Bar
482 + key={index}
483 + fill={entry.isGreen ? '#10b981' : '#ef4444'}
484 + opacity={0.6}
485 + />
486 + ))}
487 + </Bar>
488 + <ReferenceLine
489 + y={avgVolume}
490 + stroke="#f59e0b"
491 + strokeDasharray="3 3"
492 + label={{ value: 'Avg', fontSize: 10, fill: '#f59e0b' }}
493 + />
494 + </ComposedChart>
495 + </ResponsiveContainer>
496 + </div>
497 + </div>
498 + )}
499 +
500 + {/* Price Performance Summary */}
501 + <div className="p-4 border rounded-lg bg-muted/30">
502 + <div className="text-sm font-medium mb-3">📊 Period Performance</div>
503 + <div className="grid grid-cols-2 md:grid-cols-5 gap-3 text-sm">
504 + <div>
505 + <div className="text-muted-foreground">Period Start</div>
506 + <div className="font-semibold">${firstPrice.toFixed(2)}</div>
507 + </div>
508 + <div>
509 + <div className="text-muted-foreground">Period End</div>
510 + <div className="font-semibold">${lastPrice.toFixed(2)}</div>
511 + </div>
512 + <div>
513 + <div className="text-muted-foreground">Total Return</div>
514 + <div className={`font-semibold ${isPositive ? 'text-green-600' : 'text-red-600'}`}>
515 + {isPositive ? '+' : ''}{priceChangePercent.toFixed(2)}%
516 + </div>
517 + </div>
518 + <div>
519 + <div className="text-muted-foreground">Volatility</div>
520 + <div className="font-semibold">
521 + {((maxPrice - minPrice) / firstPrice * 100).toFixed(1)}%
522 + </div>
523 + </div>
524 + <div>
525 + <div className="text-muted-foreground">Total Vol Traded</div>
526 + <div className="font-semibold">
527 + {(enrichedData.reduce((sum, d) => sum + d.volume, 0) / 1_000_000_000).toFixed(2)}B
528 + </div>
529 + </div>
530 + </div>
531 + </div>
532 + </CardContent>
533 + </Card>
534 + );
535 +}
added client/src/components/financial/options-pricing-card.tsx +246 −0
@@ -0,0 +1,246 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/financial/options-pricing-card.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
18 +import { Badge } from "@/components/ui/badge";
19 +import { TrendingUp, TrendingDown, DollarSign, Clock, Percent, Activity } from "lucide-react";
20 +
21 +interface OptionGreeks {
22 + delta: number;
23 + gamma: number;
24 + theta: number;
25 + vega: number;
26 + rho: number;
27 +}
28 +
29 +interface OptionResult {
30 + price: number;
31 + intrinsic_value: number;
32 + time_value: number;
33 + greeks: OptionGreeks;
34 + moneyness: string;
35 +}
36 +
37 +interface OptionsPricingData {
38 + parameters: {
39 + stock_price: number;
40 + strike_price: number;
41 + time_to_maturity: number;
42 + risk_free_rate: number;
43 + volatility: number;
44 + option_type: string;
45 + };
46 + call_option: OptionResult;
47 + put_option: OptionResult;
48 + parity_check: {
49 + call_minus_put: number;
50 + stock_minus_pv_strike: number;
51 + parity_holds: boolean;
52 + };
53 +}
54 +
55 +interface OptionsPricingCardProps {
56 + data: OptionsPricingData;
57 +}
58 +
59 +export function OptionsPricingCard({ data }: OptionsPricingCardProps) {
60 + const formatPrice = (value: number) => `$${value.toFixed(4)}`;
61 + const formatPercent = (value: number) => `${(value * 100).toFixed(2)}%`;
62 + const formatGreek = (value: number, decimals: number = 4) => value.toFixed(decimals);
63 +
64 + const getMoneynessColor = (moneyness: string) => {
65 + switch (moneyness) {
66 + case 'ITM':
67 + return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
68 + case 'ATM':
69 + return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200';
70 + case 'OTM':
71 + return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
72 + default:
73 + return 'bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200';
74 + }
75 + };
76 +
77 + const GreekIndicator = ({ label, value, tooltip }: { label: string; value: number; tooltip: string }) => (
78 + <div className="flex items-center justify-between py-2 border-b border-border/40 last:border-0">
79 + <div className="flex items-center gap-2">
80 + <span className="text-sm font-medium text-muted-foreground">{label}</span>
81 + <span className="text-xs text-muted-foreground/60" title={tooltip}>ⓘ</span>
82 + </div>
83 + <span className={`text-sm font-semibold ${value >= 0 ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
84 + {formatGreek(value)}
85 + </span>
86 + </div>
87 + );
88 +
89 + const OptionCard = ({ title, option, icon }: { title: string; option: OptionResult; icon: React.ReactNode }) => (
90 + <Card className="h-full">
91 + <CardHeader>
92 + <div className="flex items-center justify-between">
93 + <div className="flex items-center gap-2">
94 + {icon}
95 + <CardTitle className="text-lg">{title}</CardTitle>
96 + </div>
97 + <Badge className={getMoneynessColor(option.moneyness)}>
98 + {option.moneyness}
99 + </Badge>
100 + </div>
101 + </CardHeader>
102 + <CardContent className="space-y-4">
103 + {/* Option Price */}
104 + <div className="p-4 bg-primary/5 rounded-lg">
105 + <div className="text-sm text-muted-foreground mb-1">Option Price</div>
106 + <div className="text-3xl font-bold text-primary">{formatPrice(option.price)}</div>
107 + </div>
108 +
109 + {/* Value Breakdown */}
110 + <div className="grid grid-cols-2 gap-3">
111 + <div className="p-3 bg-muted/50 rounded-lg">
112 + <div className="text-xs text-muted-foreground mb-1">Intrinsic</div>
113 + <div className="text-lg font-semibold">{formatPrice(option.intrinsic_value)}</div>
114 + </div>
115 + <div className="p-3 bg-muted/50 rounded-lg">
116 + <div className="text-xs text-muted-foreground mb-1">Time Value</div>
117 + <div className="text-lg font-semibold">{formatPrice(option.time_value)}</div>
118 + </div>
119 + </div>
120 +
121 + {/* Greeks */}
122 + <div className="pt-2">
123 + <div className="text-sm font-semibold mb-3 flex items-center gap-2">
124 + <Activity className="h-4 w-4" />
125 + Greeks (Sensitivities)
126 + </div>
127 + <div className="space-y-1">
128 + <GreekIndicator
129 + label="Delta (Δ)"
130 + value={option.greeks.delta}
131 + tooltip="Change in option price for $1 change in stock price"
132 + />
133 + <GreekIndicator
134 + label="Gamma (Γ)"
135 + value={option.greeks.gamma}
136 + tooltip="Rate of change of Delta"
137 + />
138 + <GreekIndicator
139 + label="Theta (Θ)"
140 + value={option.greeks.theta}
141 + tooltip="Time decay - change in price per day"
142 + />
143 + <GreekIndicator
144 + label="Vega (ν)"
145 + value={option.greeks.vega}
146 + tooltip="Change in price for 1% change in volatility"
147 + />
148 + <GreekIndicator
149 + label="Rho (ρ)"
150 + value={option.greeks.rho}
151 + tooltip="Change in price for 1% change in interest rate"
152 + />
153 + </div>
154 + </div>
155 + </CardContent>
156 + </Card>
157 + );
158 +
159 + return (
160 + <div className="space-y-6">
161 + {/* Parameters Header */}
162 + <Card>
163 + <CardHeader>
164 + <CardTitle className="flex items-center gap-2">
165 + <DollarSign className="h-5 w-5" />
166 + Black-Scholes Option Pricing
167 + </CardTitle>
168 + <CardDescription>Theoretical option values and Greeks for European options</CardDescription>
169 + </CardHeader>
170 + <CardContent>
171 + <div className="grid grid-cols-2 md:grid-cols-5 gap-4">
172 + <div className="space-y-1">
173 + <div className="text-xs text-muted-foreground">Stock Price</div>
174 + <div className="text-lg font-semibold">{formatPrice(data.parameters.stock_price)}</div>
175 + </div>
176 + <div className="space-y-1">
177 + <div className="text-xs text-muted-foreground">Strike Price</div>
178 + <div className="text-lg font-semibold">{formatPrice(data.parameters.strike_price)}</div>
179 + </div>
180 + <div className="space-y-1">
181 + <div className="text-xs text-muted-foreground flex items-center gap-1">
182 + <Clock className="h-3 w-3" />
183 + Time to Expiry
184 + </div>
185 + <div className="text-lg font-semibold">{data.parameters.time_to_maturity.toFixed(4)} yr</div>
186 + <div className="text-xs text-muted-foreground">{(data.parameters.time_to_maturity * 365).toFixed(0)} days</div>
187 + </div>
188 + <div className="space-y-1">
189 + <div className="text-xs text-muted-foreground flex items-center gap-1">
190 + <Percent className="h-3 w-3" />
191 + Volatility
192 + </div>
193 + <div className="text-lg font-semibold">{formatPercent(data.parameters.volatility)}</div>
194 + </div>
195 + <div className="space-y-1">
196 + <div className="text-xs text-muted-foreground">Risk-Free Rate</div>
197 + <div className="text-lg font-semibold">{formatPercent(data.parameters.risk_free_rate)}</div>
198 + </div>
199 + </div>
200 + </CardContent>
201 + </Card>
202 +
203 + {/* Call and Put Options Side by Side */}
204 + <div className="grid md:grid-cols-2 gap-4">
205 + <OptionCard
206 + title="Call Option"
207 + option={data.call_option}
208 + icon={<TrendingUp className="h-5 w-5 text-green-600" />}
209 + />
210 + <OptionCard
211 + title="Put Option"
212 + option={data.put_option}
213 + icon={<TrendingDown className="h-5 w-5 text-red-600" />}
214 + />
215 + </div>
216 +
217 + {/* Put-Call Parity Check */}
218 + <Card>
219 + <CardHeader>
220 + <CardTitle className="text-base">Put-Call Parity Verification</CardTitle>
221 + <CardDescription className="text-xs">
222 + Verifies that C - P = S - K×e^(-rT)
223 + </CardDescription>
224 + </CardHeader>
225 + <CardContent>
226 + <div className="grid grid-cols-3 gap-4">
227 + <div className="space-y-1">
228 + <div className="text-xs text-muted-foreground">Call - Put</div>
229 + <div className="text-sm font-semibold">{formatPrice(data.parity_check.call_minus_put)}</div>
230 + </div>
231 + <div className="space-y-1">
232 + <div className="text-xs text-muted-foreground">S - PV(K)</div>
233 + <div className="text-sm font-semibold">{formatPrice(data.parity_check.stock_minus_pv_strike)}</div>
234 + </div>
235 + <div className="space-y-1">
236 + <div className="text-xs text-muted-foreground">Parity Check</div>
237 + <Badge variant={data.parity_check.parity_holds ? "default" : "destructive"}>
238 + {data.parity_check.parity_holds ? '✓ Holds' : '✗ Failed'}
239 + </Badge>
240 + </div>
241 + </div>
242 + </CardContent>
243 + </Card>
244 + </div>
245 + );
246 +}
added client/src/components/market-ticker.tsx +131 −0
@@ -0,0 +1,131 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/market-ticker.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { useQuery } from "@tanstack/react-query";
18 +import { TrendingUp, TrendingDown } from "lucide-react";
19 +
20 +interface MarketIndex {
21 + symbol: string;
22 + name: string;
23 + price: number;
24 + change: number;
25 + changesPercentage: number;
26 +}
27 +
28 +export function MarketTicker() {
29 + const { data: marketIndices } = useQuery({
30 + queryKey: ['market-ticker'],
31 + queryFn: async () => {
32 + const symbols = [
33 + '^GSPC', // S&P 500
34 + '^DJI', // Dow Jones
35 + '^IXIC', // NASDAQ
36 + '^RUT', // Russell 2000
37 + '^FTSE', // FTSE 100
38 + '^GDAXI', // DAX
39 + '^FCHI', // CAC 40
40 + '^N225', // Nikkei 225
41 + '^HSI', // Hang Seng
42 + '^GSPTSE' // S&P/TSX
43 + ];
44 +
45 + const symbolsParam = symbols.join(',');
46 + const res = await fetch(`https://financialmodelingprep.com/stable/quote?symbol=${symbolsParam}&apikey=${import.meta.env.VITE_FMP_API_KEY || 'JeWwQMjWS3H6hBGHaVxKy1WfGyU5ZeFq'}`);
47 +
48 + if (!res.ok) throw new Error('Failed to fetch market indices');
49 + const data = await res.json();
50 +
51 + return data
52 + .filter((index: any) => index && index.symbol && index.price != null)
53 + .map((index: any) => ({
54 + symbol: index.symbol,
55 + name: index.name || index.symbol,
56 + price: Number(index.price) || 0,
57 + change: Number(index.change) || 0,
58 + changesPercentage: Number(index.changesPercentage) || 0,
59 + }));
60 + },
61 + refetchInterval: 30000, // Refresh every 30 seconds
62 + });
63 +
64 + if (!marketIndices || marketIndices.length === 0) {
65 + return null;
66 + }
67 +
68 + // Duplicate the data to create seamless loop
69 + const tickerData = [...marketIndices, ...marketIndices];
70 +
71 + return (
72 + <div className="w-full bg-muted/30 border-b border-border/40 overflow-hidden backdrop-blur-sm">
73 + <div className="relative h-10 flex items-center">
74 + <div className="animate-ticker flex items-center gap-8 whitespace-nowrap">
75 + {tickerData.map((index: MarketIndex, i) => {
76 + const isPositive = index.changesPercentage >= 0;
77 + const displaySymbol = index.symbol.replace('^', '');
78 +
79 + return (
80 + <div
81 + key={`${index.symbol}-${i}`}
82 + className="flex items-center gap-2 px-4 py-1 rounded-md bg-card/50 backdrop-blur-sm border border-border/30"
83 + >
84 + <span className="font-bold text-sm text-primary">
85 + {displaySymbol}
86 + </span>
87 + <span className="text-sm font-semibold text-foreground">
88 + {index.price.toLocaleString('en-US', {
89 + minimumFractionDigits: 2,
90 + maximumFractionDigits: 2
91 + })}
92 + </span>
93 + <div className={`flex items-center gap-1 text-xs font-semibold ${
94 + isPositive ? 'text-green-500' : 'text-red-500'
95 + }`}>
96 + {isPositive ? (
97 + <TrendingUp className="w-3 h-3" />
98 + ) : (
99 + <TrendingDown className="w-3 h-3" />
100 + )}
101 + <span>
102 + {isPositive ? '+' : ''}{index.changesPercentage.toFixed(2)}%
103 + </span>
104 + </div>
105 + </div>
106 + );
107 + })}
108 + </div>
109 + </div>
110 +
111 + <style>{`
112 + @keyframes ticker {
113 + 0% {
114 + transform: translateX(0);
115 + }
116 + 100% {
117 + transform: translateX(-50%);
118 + }
119 + }
120 +
121 + .animate-ticker {
122 + animation: ticker 60s linear infinite;
123 + }
124 +
125 + .animate-ticker:hover {
126 + animation-play-state: paused;
127 + }
128 + `}</style>
129 + </div>
130 + );
131 +}
added client/src/components/streaming-answer.tsx +410 −0
@@ -0,0 +1,410 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/streaming-answer.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { useState, useEffect, useRef, useMemo } from "react";
18 +import { motion, AnimatePresence } from "framer-motion";
19 +import { ExternalLink, Check, Search, Loader2 } from "lucide-react";
20 +import { Badge } from "@/components/ui/badge";
21 +import ReactMarkdown from "react-markdown";
22 +import remarkGfm from "remark-gfm";
23 +import type { SearchResult } from "@shared/types";
24 +
25 +interface SearchQuery {
26 + query: string;
27 + results?: SearchResult[];
28 +}
29 +
30 +interface StreamingAnswerProps {
31 + content: string;
32 + sources?: SearchResult[];
33 + isStreaming?: boolean;
34 + searchQueries?: SearchQuery[];
35 + statusMessage?: string;
36 + currentSearchIndex?: number;
37 + figureRegistry?: Map<string, string>; // Map of figure IDs to base64 images
38 + figureUrls?: string[]; // Actual URLs of generated figures
39 +}
40 +
41 +export function StreamingAnswer({
42 + content,
43 + sources = [],
44 + isStreaming = false,
45 + searchQueries = [],
46 + statusMessage = "",
47 + currentSearchIndex = 0,
48 + figureRegistry = new Map(),
49 + figureUrls = []
50 +}: StreamingAnswerProps) {
51 + const streamingCursorRef = useRef<HTMLSpanElement>(null);
52 +
53 + // Build an ordered list of resolved figure sources (data URIs or URLs)
54 + // This is the single source of truth for all figure rendering
55 + const resolvedFigures = useMemo(() => {
56 + const sources: string[] = [];
57 +
58 + // Priority 1: figureUrls (may be data URIs from shared reports or file URLs from live session)
59 + if (figureUrls.length > 0) {
60 + sources.push(...figureUrls);
61 + }
62 + // Priority 2: figureRegistry values
63 + else if (figureRegistry.size > 0) {
64 + figureRegistry.forEach((value) => {
65 + if (!value) return;
66 + if (value.startsWith('data:') || value.startsWith('/') || value.startsWith('http')) {
67 + sources.push(value);
68 + } else {
69 + // Raw base64 — wrap as data URI
70 + sources.push(`data:image/png;base64,${value}`);
71 + }
72 + });
73 + }
74 +
75 + return sources;
76 + }, [figureRegistry, figureUrls]);
77 +
78 + // Replace ALL markdown image references with resolved figure sources
79 + const processedContent = useMemo(() => {
80 + if (resolvedFigures.length === 0) return content;
81 +
82 + let processed = content;
83 + const allImagePattern = /!\[([^\]]*)\]\(([^)]+)\)/gi;
84 + let counter = 0;
85 +
86 + processed = processed.replace(allImagePattern, (match, alt, url) => {
87 + // Keep external URLs (http) and already-working data URIs
88 + if (url.startsWith('http') || url.startsWith('data:')) {
89 + return match;
90 + }
91 +
92 + // Any local path (/figures/, /plots/, fig://, hallucinated, etc.)
93 + // → replace with next resolved figure source
94 + if (counter < resolvedFigures.length) {
95 + const resolved = resolvedFigures[counter];
96 + counter++;
97 + return `![${alt}](${resolved})`;
98 + }
99 + return match;
100 + });
101 +
102 + return processed;
103 + }, [content, resolvedFigures]);
104 +
105 + // Display content directly without artificial delay - streaming is already real-time from server
106 + const displayedContent = processedContent;
107 +
108 + // Auto-scroll to keep streaming cursor visible
109 + useEffect(() => {
110 + if (isStreaming && streamingCursorRef.current) {
111 + streamingCursorRef.current.scrollIntoView({
112 + behavior: 'smooth',
113 + block: 'nearest',
114 + inline: 'nearest'
115 + });
116 + }
117 + }, [content, isStreaming]);
118 +
119 + return (
120 + <motion.div
121 + initial={{ opacity: 0, y: 8 }}
122 + animate={{ opacity: 1, y: 0 }}
123 + transition={{ duration: 0.3 }}
124 + className="w-full max-w-5xl mx-auto space-y-6"
125 + data-testid="streaming-answer"
126 + >
127 + {/* Search Queries Section */}
128 + {searchQueries.length > 0 && (
129 + <motion.div
130 + initial={{ opacity: 0, y: 12, scale: 0.95 }}
131 + animate={{ opacity: 1, y: 0, scale: 1 }}
132 + transition={{ duration: 0.5, ease: [0.4, 0, 0.2, 1] }}
133 + className="bg-card border border-border rounded-xl p-6"
134 + data-testid="search-queries-section"
135 + >
136 + <div className="flex items-center gap-2 mb-4">
137 + <Search className="w-5 h-5 text-primary" />
138 + <h3 className="text-lg font-semibold">Requêtes de recherche optimisées</h3>
139 + </div>
140 + <div className="space-y-3">
141 + {searchQueries.map((searchQuery, index) => (
142 + <motion.div
143 + key={index}
144 + initial={{ opacity: 0, x: -20 }}
145 + animate={{ opacity: 1, x: 0 }}
146 + transition={{ delay: index * 0.1 }}
147 + className="flex items-start gap-3 p-4 bg-muted/50 rounded-lg border border-border/50"
148 + data-testid={`search-query-${index}`}
149 + >
150 + <div className="flex-shrink-0">
151 + {searchQuery.results ? (
152 + <Check className="w-5 h-5 text-green-500 mt-0.5" />
153 + ) : currentSearchIndex === index + 1 ? (
154 + <Loader2 className="w-5 h-5 text-primary animate-spin mt-0.5" />
155 + ) : (
156 + <div className="w-5 h-5 rounded-full bg-muted flex items-center justify-center mt-0.5">
157 + <span className="text-xs font-semibold">{index + 1}</span>
158 + </div>
159 + )}
160 + </div>
161 + <div className="flex-1 min-w-0">
162 + <p className="text-sm font-medium text-foreground">
163 + {searchQuery.query}
164 + </p>
165 + {searchQuery.results && searchQuery.results.length > 0 && (
166 + <div className="mt-2 flex flex-wrap gap-2">
167 + {searchQuery.results.slice(0, 3).map((result, resultIndex) => (
168 + <a
169 + key={resultIndex}
170 + href={result.url}
171 + target="_blank"
172 + rel="noopener noreferrer"
173 + className="text-xs text-muted-foreground hover:text-primary transition-colors flex items-center gap-1 px-3 py-1.5 rounded-full bg-background/50 hover:bg-background"
174 + data-testid={`search-result-link-${index}-${resultIndex}`}
175 + >
176 + <ExternalLink className="w-3 h-3" />
177 + {new URL(result.url).hostname}
178 + </a>
179 + ))}
180 + </div>
181 + )}
182 + </div>
183 + </motion.div>
184 + ))}
185 + </div>
186 + {statusMessage && (
187 + <motion.div
188 + initial={{ opacity: 0 }}
189 + animate={{ opacity: 1 }}
190 + className="mt-4 text-sm text-muted-foreground flex items-center gap-2"
191 + data-testid="status-message"
192 + >
193 + {statusMessage}
194 + </motion.div>
195 + )}
196 + </motion.div>
197 + )}
198 +
199 + {/* Answer Section */}
200 + {displayedContent && (
201 + <motion.div
202 + initial={{ opacity: 0, y: 12, scale: 0.95 }}
203 + animate={{ opacity: 1, y: 0, scale: 1 }}
204 + transition={{ duration: 0.5, ease: [0.4, 0, 0.2, 1], delay: 0.1 }}
205 + className="bg-card border border-border rounded-xl p-6 md:p-8">
206 + <div className="prose prose-lg max-w-none dark:prose-invert">
207 + <ReactMarkdown
208 + remarkPlugins={[remarkGfm]}
209 + components={{
210 + h1: ({ children }) => <h1 className="text-3xl font-bold text-foreground mb-4">{children}</h1>,
211 + h2: ({ children }) => <h2 className="text-2xl font-semibold text-foreground mt-6 mb-3">{children}</h2>,
212 + h3: ({ children }) => <h3 className="text-xl font-semibold text-foreground mt-4 mb-2">{children}</h3>,
213 + p: ({ children }) => <p className="text-foreground leading-relaxed mb-4">{children}</p>,
214 + ul: ({ children }) => <ul className="text-foreground list-disc list-inside space-y-2 mb-4">{children}</ul>,
215 + ol: ({ children }) => <ol className="text-foreground list-decimal list-inside space-y-2 mb-4">{children}</ol>,
216 + li: ({ children }) => <li className="text-foreground">{children}</li>,
217 + a: ({ href, children }) => {
218 + // Don't create clickable links for fig:// references (they're for images only)
219 + if (href?.startsWith('fig://')) {
220 + return <>{children}</>;
221 + }
222 +
223 + return (
224 + <a
225 + href={href}
226 + className="text-primary hover:underline font-medium"
227 + target="_blank"
228 + rel="noopener noreferrer"
229 + >
230 + {children}
231 + </a>
232 + );
233 + },
234 + strong: ({ children }) => <strong className="font-bold text-foreground">{children}</strong>,
235 + code: ({ children }) => (
236 + <code className="bg-muted px-1.5 py-0.5 rounded text-sm font-mono text-foreground">
237 + {children}
238 + </code>
239 + ),
240 + pre: ({ children }) => (
241 + <pre className="bg-muted p-4 rounded-lg overflow-x-auto mb-4">
242 + {children}
243 + </pre>
244 + ),
245 + blockquote: ({ children }) => (
246 + <blockquote className="border-l-4 border-primary pl-4 italic text-muted-foreground my-4">
247 + {children}
248 + </blockquote>
249 + ),
250 + img: ({ src, alt }) => {
251 + let imgSrc = src || '';
252 +
253 + // Step 1: Resolve fig:// from registry
254 + if (imgSrc.startsWith('fig://')) {
255 + const figureId = imgSrc.replace('fig://', '');
256 + const val = figureRegistry.get(figureId);
257 + if (val) {
258 + imgSrc = val.startsWith('/') || val.startsWith('http') || val.startsWith('data:')
259 + ? val
260 + : `data:image/png;base64,${val}`;
261 + }
262 + }
263 +
264 + // Step 2: If still unresolved (empty, fig://, or broken local path),
265 + // try resolvedFigures by figure number in alt text
266 + if (!imgSrc || imgSrc.startsWith('fig://') || (imgSrc.startsWith('/') && resolvedFigures.length > 0)) {
267 + const figMatch = (alt || '').match(/(\d+)/);
268 + if (figMatch) {
269 + const idx = parseInt(figMatch[1], 10) - 1;
270 + if (idx >= 0 && idx < resolvedFigures.length) {
271 + imgSrc = resolvedFigures[idx];
272 + }
273 + }
274 + }
275 +
276 + // Step 3: Still nothing? Take first available resolved figure
277 + if ((!imgSrc || imgSrc.startsWith('fig://')) && resolvedFigures.length > 0) {
278 + imgSrc = resolvedFigures[0];
279 + }
280 +
281 + // Render if we have a valid source
282 + if (imgSrc && !imgSrc.startsWith('fig://')) {
283 + return (
284 + <div className="my-6 border-2 border-primary/20 rounded-xl overflow-hidden shadow-lg">
285 + <img
286 + key={imgSrc}
287 + src={imgSrc}
288 + alt={alt || 'Python generated figure'}
289 + className="w-full h-auto cursor-default"
290 + style={{ maxHeight: '600px', objectFit: 'contain' }}
291 + loading="lazy"
292 + onError={(e) => {
293 + const img = e.currentTarget;
294 + const retryCount = parseInt(img.dataset.retryCount || '0', 10);
295 +
296 + // Try resolvedFigures one by one as fallback
297 + if (retryCount < resolvedFigures.length) {
298 + const candidate = resolvedFigures[retryCount];
299 + // Skip if it's the same URL that just failed
300 + if (candidate === img.src || candidate === imgSrc) {
301 + img.dataset.retryCount = String(retryCount + 1);
302 + if (retryCount + 1 < resolvedFigures.length) {
303 + img.src = resolvedFigures[retryCount + 1];
304 + img.dataset.retryCount = String(retryCount + 2);
305 + return;
306 + }
307 + } else {
308 + img.dataset.retryCount = String(retryCount + 1);
309 + img.src = candidate;
310 + return;
311 + }
312 + }
313 +
314 + // Exhausted all options — hide image
315 + img.style.display = 'none';
316 + const fallback = img.parentElement?.querySelector('.figure-fallback');
317 + if (fallback) (fallback as HTMLElement).style.display = 'flex';
318 + }}
319 + />
320 + <div className="figure-fallback items-center justify-center p-8 bg-muted/30" style={{ display: 'none' }}>
321 + <p className="text-sm text-muted-foreground">Figure non disponible</p>
322 + </div>
323 + {alt && (
324 + <div className="p-3 bg-muted/50 border-t border-primary/10">
325 + <p className="text-sm text-center font-medium">{alt}</p>
326 + </div>
327 + )}
328 + </div>
329 + );
330 + }
331 +
332 + // Truly no figure data available — hide silently
333 + // (figures are shown in the Python Gallery section below)
334 + return null;
335 + },
336 + }}
337 + >
338 + {displayedContent}
339 + </ReactMarkdown>
340 + {isStreaming && (
341 + <motion.span
342 + ref={streamingCursorRef}
343 + animate={{ opacity: [1, 0, 1] }}
344 + transition={{ duration: 0.8, repeat: Infinity }}
345 + className="inline-block w-2 h-5 bg-primary ml-1"
346 + />
347 + )}
348 + </div>
349 +
350 + {sources.length > 0 && (
351 + <div className="mt-8 pt-6 border-t border-border">
352 + <h3 className="text-sm font-medium text-muted-foreground mb-4">Sources</h3>
353 + <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
354 + <AnimatePresence>
355 + {sources.map((source, index) => (
356 + <motion.a
357 + key={source.url}
358 + href={source.url}
359 + target="_blank"
360 + rel="noopener noreferrer"
361 + initial={{ opacity: 0, x: -20 }}
362 + animate={{ opacity: 1, x: 0 }}
363 + transition={{ delay: index * 0.1 }}
364 + className="group flex items-start gap-3 p-4 bg-muted/30 hover:bg-muted/50 rounded-lg border border-transparent hover:border-primary/30 transition-all hover-elevate"
365 + data-testid={`link-source-${index}`}
366 + >
367 + <div className="flex-shrink-0 mt-1">
368 + {source.favicon ? (
369 + <img
370 + src={source.favicon}
371 + alt=""
372 + className="w-5 h-5 rounded"
373 + onError={(e) => {
374 + e.currentTarget.style.display = 'none';
375 + }}
376 + />
377 + ) : (
378 + <div className="w-5 h-5 rounded bg-primary/20 flex items-center justify-center">
379 + <span className="text-xs font-mono text-primary">
380 + {new URL(source.url).hostname[0].toUpperCase()}
381 + </span>
382 + </div>
383 + )}
384 + </div>
385 + <div className="flex-1 min-w-0">
386 + <div className="flex items-center gap-2 mb-1">
387 + <span className="text-sm font-medium text-foreground truncate">
388 + {source.title}
389 + </span>
390 + <ExternalLink className="w-3 h-3 text-muted-foreground flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity" />
391 + </div>
392 + <p className="text-xs text-muted-foreground font-mono truncate">
393 + {new URL(source.url).hostname}
394 + </p>
395 + </div>
396 + <Badge variant="secondary" className="flex-shrink-0 no-default-hover-elevate no-default-active-elevate">
397 + <Check className="w-3 h-3 mr-1" />
398 + Verified
399 + </Badge>
400 + </motion.a>
401 + ))}
402 + </AnimatePresence>
403 + </div>
404 + </div>
405 + )}
406 + </motion.div>
407 + )}
408 + </motion.div>
409 + );
410 +}
added client/src/components/theme-color-picker.tsx +80 −0
@@ -0,0 +1,80 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/theme-color-picker.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { Palette, Check } from "lucide-react";
18 +import { Button } from "@/components/ui/button";
19 +import {
20 + DropdownMenu,
21 + DropdownMenuContent,
22 + DropdownMenuItem,
23 + DropdownMenuTrigger,
24 +} from "@/components/ui/dropdown-menu";
25 +import { useTheme, type ThemeColor } from "./theme-provider";
26 +
27 +const colorOptions: { value: ThemeColor; label: string; color: string }[] = [
28 + { value: "blue", label: "Bleu", color: "hsl(210, 100%, 50%)" },
29 + { value: "green", label: "Vert", color: "hsl(142, 76%, 45%)" },
30 + { value: "purple", label: "Violet", color: "hsl(255, 85%, 62%)" },
31 + { value: "red", label: "Rouge", color: "hsl(0, 84%, 60%)" },
32 + { value: "orange", label: "Orange", color: "hsl(14, 100%, 55%)" },
33 + { value: "cyan", label: "Cyan", color: "hsl(199, 89%, 55%)" },
34 +];
35 +
36 +export function ThemeColorPicker() {
37 + const { themeColor, setThemeColor } = useTheme();
38 +
39 + const handleColorChange = (color: ThemeColor) => {
40 + try {
41 + setThemeColor(color);
42 + } catch (error) {
43 + console.error("Failed to set theme color:", error);
44 + }
45 + };
46 +
47 + return (
48 + <DropdownMenu modal={false}>
49 + <DropdownMenuTrigger asChild>
50 + <Button
51 + variant="ghost"
52 + size="icon"
53 + data-testid="button-theme-color"
54 + className="hover-elevate active-elevate-2 rounded-2xl hover:bg-primary/10 transition-all duration-300"
55 + >
56 + <Palette className="h-5 w-5" />
57 + <span className="sr-only">Choisir la couleur du thème</span>
58 + </Button>
59 + </DropdownMenuTrigger>
60 + <DropdownMenuContent align="end" className="w-48" sideOffset={5}>
61 + {colorOptions.map((option) => (
62 + <DropdownMenuItem
63 + key={option.value}
64 + onClick={() => handleColorChange(option.value)}
65 + className="flex items-center gap-3 cursor-pointer"
66 + >
67 + <div
68 + className="w-5 h-5 rounded-full border-2 border-border"
69 + style={{ backgroundColor: option.color }}
70 + />
71 + <span className="flex-1">{option.label}</span>
72 + {themeColor === option.value && (
73 + <Check className="w-4 h-4 text-primary" />
74 + )}
75 + </DropdownMenuItem>
76 + ))}
77 + </DropdownMenuContent>
78 + </DropdownMenu>
79 + );
80 +}
added client/src/components/theme-provider.tsx +149 −0
@@ -0,0 +1,149 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/theme-provider.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { createContext, useContext, useEffect, useState } from "react";
18 +
19 +type Theme = "dark" | "light";
20 +
21 +export type ThemeColor = "blue" | "green" | "purple" | "red" | "orange" | "cyan";
22 +
23 +type ThemeProviderProps = {
24 + children: React.ReactNode;
25 + defaultTheme?: Theme;
26 + storageKey?: string;
27 +};
28 +
29 +type ThemeProviderState = {
30 + theme: Theme;
31 + setTheme: (theme: Theme) => void;
32 + toggleTheme: () => void;
33 + themeColor: ThemeColor;
34 + setThemeColor: (color: ThemeColor) => void;
35 +};
36 +
37 +const initialState: ThemeProviderState = {
38 + theme: "light",
39 + setTheme: () => null,
40 + toggleTheme: () => null,
41 + themeColor: "orange",
42 + setThemeColor: () => null,
43 +};
44 +
45 +const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
46 +
47 +const themeColorMap: Record<ThemeColor, { hue: number; sat: number }> = {
48 + blue: { hue: 210, sat: 100 },
49 + green: { hue: 142, sat: 76 },
50 + purple: { hue: 255, sat: 85 },
51 + red: { hue: 0, sat: 84 },
52 + orange: { hue: 14, sat: 100 },
53 + cyan: { hue: 199, sat: 89 },
54 +};
55 +
56 +export function ThemeProvider({
57 + children,
58 + defaultTheme = "light",
59 + storageKey = "stock-theme",
60 + ...props
61 +}: ThemeProviderProps) {
62 + const [theme, setThemeState] = useState<Theme>(() => {
63 + try {
64 + return (localStorage.getItem(storageKey) as Theme) || defaultTheme;
65 + } catch {
66 + return defaultTheme;
67 + }
68 + });
69 +
70 + const [themeColor, setThemeColorState] = useState<ThemeColor>(() => {
71 + try {
72 + return (localStorage.getItem(`${storageKey}-color-v2`) as ThemeColor) || "orange";
73 + } catch {
74 + return "purple";
75 + }
76 + });
77 +
78 + useEffect(() => {
79 + const root = window.document.documentElement;
80 + root.classList.remove("light", "dark");
81 + root.classList.add(theme);
82 + }, [theme]);
83 +
84 + useEffect(() => {
85 + const root = window.document.documentElement;
86 + const colorConfig = themeColorMap[themeColor];
87 +
88 + // Update primary color variables
89 + if (theme === "light") {
90 + root.style.setProperty("--primary", `${colorConfig.hue} ${colorConfig.sat}% 50%`);
91 + root.style.setProperty("--ring", `${colorConfig.hue} ${colorConfig.sat}% 50%`);
92 + root.style.setProperty("--sidebar-primary", `${colorConfig.hue} ${colorConfig.sat}% 50%`);
93 + root.style.setProperty("--sidebar-ring", `${colorConfig.hue} ${colorConfig.sat}% 50%`);
94 + } else {
95 + root.style.setProperty("--primary", `${colorConfig.hue} ${colorConfig.sat}% 50%`);
96 + root.style.setProperty("--ring", `${colorConfig.hue} ${colorConfig.sat}% 60%`);
97 + root.style.setProperty("--sidebar-primary", `${colorConfig.hue} ${colorConfig.sat}% 60%`);
98 + root.style.setProperty("--sidebar-ring", `${colorConfig.hue} ${colorConfig.sat}% 60%`);
99 + }
100 + }, [themeColor, theme]);
101 +
102 + const value = {
103 + theme,
104 + setTheme: (theme: Theme) => {
105 + try {
106 + localStorage.setItem(storageKey, theme);
107 + setThemeState(theme);
108 + } catch (error) {
109 + console.error("Failed to save theme:", error);
110 + setThemeState(theme);
111 + }
112 + },
113 + toggleTheme: () => {
114 + try {
115 + const newTheme = theme === "dark" ? "light" : "dark";
116 + localStorage.setItem(storageKey, newTheme);
117 + setThemeState(newTheme);
118 + } catch (error) {
119 + console.error("Failed to toggle theme:", error);
120 + setThemeState(theme === "dark" ? "light" : "dark");
121 + }
122 + },
123 + themeColor,
124 + setThemeColor: (color: ThemeColor) => {
125 + try {
126 + localStorage.setItem(`${storageKey}-color-v2`, color);
127 + setThemeColorState(color);
128 + } catch (error) {
129 + console.error("Failed to save theme color:", error);
130 + setThemeColorState(color);
131 + }
132 + },
133 + };
134 +
135 + return (
136 + <ThemeProviderContext.Provider {...props} value={value}>
137 + {children}
138 + </ThemeProviderContext.Provider>
139 + );
140 +}
141 +
142 +export const useTheme = () => {
143 + const context = useContext(ThemeProviderContext);
144 +
145 + if (context === undefined)
146 + throw new Error("useTheme must be used within a ThemeProvider");
147 +
148 + return context;
149 +};
added client/src/components/theme-toggle.tsx +40 −0
@@ -0,0 +1,40 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/theme-toggle.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { Moon, Sun } from "lucide-react";
18 +import { Button } from "@/components/ui/button";
19 +import { useTheme } from "./theme-provider";
20 +
21 +export function ThemeToggle() {
22 + const { theme, toggleTheme } = useTheme();
23 +
24 + return (
25 + <Button
26 + variant="ghost"
27 + size="icon"
28 + onClick={toggleTheme}
29 + data-testid="button-theme-toggle"
30 + className="hover-elevate active-elevate-2"
31 + >
32 + {theme === "dark" ? (
33 + <Sun className="h-5 w-5" data-testid="icon-sun" />
34 + ) : (
35 + <Moon className="h-5 w-5" data-testid="icon-moon" />
36 + )}
37 + <span className="sr-only">Toggle theme</span>
38 + </Button>
39 + );
40 +}
added client/src/components/ui/accordion.tsx +72 −0
@@ -0,0 +1,72 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/ui/accordion.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import * as React from "react"
18 +import * as AccordionPrimitive from "@radix-ui/react-accordion"
19 +import { ChevronDown } from "lucide-react"
20 +
21 +import { cn } from "@/lib/utils"
22 +
23 +const Accordion = AccordionPrimitive.Root
24 +
25 +const AccordionItem = React.forwardRef<
26 + React.ElementRef<typeof AccordionPrimitive.Item>,
27 + React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Item>
28 +>(({ className, ...props }, ref) => (
29 + <AccordionPrimitive.Item
30 + ref={ref}
31 + className={cn("border-b", className)}
32 + {...props}
33 + />
34 +))
35 +AccordionItem.displayName = "AccordionItem"
36 +
37 +const AccordionTrigger = React.forwardRef<
38 + React.ElementRef<typeof AccordionPrimitive.Trigger>,
39 + React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Trigger>
40 +>(({ className, children, ...props }, ref) => (
41 + <AccordionPrimitive.Header className="flex">
42 + <AccordionPrimitive.Trigger
43 + ref={ref}
44 + className={cn(
45 + "flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180",
46 + className
47 + )}
48 + {...props}
49 + >
50 + {children}
51 + <ChevronDown className="h-4 w-4 shrink-0 transition-transform duration-200" />
52 + </AccordionPrimitive.Trigger>
53 + </AccordionPrimitive.Header>
54 +))
55 +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName
56 +
57 +const AccordionContent = React.forwardRef<
58 + React.ElementRef<typeof AccordionPrimitive.Content>,
59 + React.ComponentPropsWithoutRef<typeof AccordionPrimitive.Content>
60 +>(({ className, children, ...props }, ref) => (
61 + <AccordionPrimitive.Content
62 + ref={ref}
63 + className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
64 + {...props}
65 + >
66 + <div className={cn("pb-4 pt-0", className)}>{children}</div>
67 + </AccordionPrimitive.Content>
68 +))
69 +
70 +AccordionContent.displayName = AccordionPrimitive.Content.displayName
71 +
72 +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
added client/src/components/ui/alert-dialog.tsx +155 −0
@@ -0,0 +1,155 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/ui/alert-dialog.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import * as React from "react"
18 +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
19 +
20 +import { cn } from "@/lib/utils"
21 +import { buttonVariants } from "@/components/ui/button"
22 +
23 +const AlertDialog = AlertDialogPrimitive.Root
24 +
25 +const AlertDialogTrigger = AlertDialogPrimitive.Trigger
26 +
27 +const AlertDialogPortal = AlertDialogPrimitive.Portal
28 +
29 +const AlertDialogOverlay = React.forwardRef<
30 + React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
31 + React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
32 +>(({ className, ...props }, ref) => (
33 + <AlertDialogPrimitive.Overlay
34 + className={cn(
35 + "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
36 + className
37 + )}
38 + {...props}
39 + ref={ref}
40 + />
41 +))
42 +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
43 +
44 +const AlertDialogContent = React.forwardRef<
45 + React.ElementRef<typeof AlertDialogPrimitive.Content>,
46 + React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
47 +>(({ className, ...props }, ref) => (
48 + <AlertDialogPortal>
49 + <AlertDialogOverlay />
50 + <AlertDialogPrimitive.Content
51 + ref={ref}
52 + className={cn(
53 + "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
54 + className
55 + )}
56 + {...props}
57 + />
58 + </AlertDialogPortal>
59 +))
60 +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
61 +
62 +const AlertDialogHeader = ({
63 + className,
64 + ...props
65 +}: React.HTMLAttributes<HTMLDivElement>) => (
66 + <div
67 + className={cn(
68 + "flex flex-col space-y-2 text-center sm:text-left",
69 + className
70 + )}
71 + {...props}
72 + />
73 +)
74 +AlertDialogHeader.displayName = "AlertDialogHeader"
75 +
76 +const AlertDialogFooter = ({
77 + className,
78 + ...props
79 +}: React.HTMLAttributes<HTMLDivElement>) => (
80 + <div
81 + className={cn(
82 + "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
83 + className
84 + )}
85 + {...props}
86 + />
87 +)
88 +AlertDialogFooter.displayName = "AlertDialogFooter"
89 +
90 +const AlertDialogTitle = React.forwardRef<
91 + React.ElementRef<typeof AlertDialogPrimitive.Title>,
92 + React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
93 +>(({ className, ...props }, ref) => (
94 + <AlertDialogPrimitive.Title
95 + ref={ref}
96 + className={cn("text-lg font-semibold", className)}
97 + {...props}
98 + />
99 +))
100 +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
101 +
102 +const AlertDialogDescription = React.forwardRef<
103 + React.ElementRef<typeof AlertDialogPrimitive.Description>,
104 + React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
105 +>(({ className, ...props }, ref) => (
106 + <AlertDialogPrimitive.Description
107 + ref={ref}
108 + className={cn("text-sm text-muted-foreground", className)}
109 + {...props}
110 + />
111 +))
112 +AlertDialogDescription.displayName =
113 + AlertDialogPrimitive.Description.displayName
114 +
115 +const AlertDialogAction = React.forwardRef<
116 + React.ElementRef<typeof AlertDialogPrimitive.Action>,
117 + React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
118 +>(({ className, ...props }, ref) => (
119 + <AlertDialogPrimitive.Action
120 + ref={ref}
121 + className={cn(buttonVariants(), className)}
122 + {...props}
123 + />
124 +))
125 +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
126 +
127 +const AlertDialogCancel = React.forwardRef<
128 + React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
129 + React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
130 +>(({ className, ...props }, ref) => (
131 + <AlertDialogPrimitive.Cancel
132 + ref={ref}
133 + className={cn(
134 + buttonVariants({ variant: "outline" }),
135 + "mt-2 sm:mt-0",
136 + className
137 + )}
138 + {...props}
139 + />
140 +))
141 +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
142 +
143 +export {
144 + AlertDialog,
145 + AlertDialogPortal,
146 + AlertDialogOverlay,
147 + AlertDialogTrigger,
148 + AlertDialogContent,
149 + AlertDialogHeader,
150 + AlertDialogFooter,
151 + AlertDialogTitle,
152 + AlertDialogDescription,
153 + AlertDialogAction,
154 + AlertDialogCancel,
155 +}
added client/src/components/ui/alert.tsx +75 −0
@@ -0,0 +1,75 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/ui/alert.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import * as React from "react"
18 +import { cva, type VariantProps } from "class-variance-authority"
19 +
20 +import { cn } from "@/lib/utils"
21 +
22 +const alertVariants = cva(
23 + "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
24 + {
25 + variants: {
26 + variant: {
27 + default: "bg-background text-foreground",
28 + destructive:
29 + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
30 + },
31 + },
32 + defaultVariants: {
33 + variant: "default",
34 + },
35 + }
36 +)
37 +
38 +const Alert = React.forwardRef<
39 + HTMLDivElement,
40 + React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
41 +>(({ className, variant, ...props }, ref) => (
42 + <div
43 + ref={ref}
44 + role="alert"
45 + className={cn(alertVariants({ variant }), className)}
46 + {...props}
47 + />
48 +))
49 +Alert.displayName = "Alert"
50 +
51 +const AlertTitle = React.forwardRef<
52 + HTMLParagraphElement,
53 + React.HTMLAttributes<HTMLHeadingElement>
54 +>(({ className, ...props }, ref) => (
55 + <h5
56 + ref={ref}
57 + className={cn("mb-1 font-medium leading-none tracking-tight", className)}
58 + {...props}
59 + />
60 +))
61 +AlertTitle.displayName = "AlertTitle"
62 +
63 +const AlertDescription = React.forwardRef<
64 + HTMLParagraphElement,
65 + React.HTMLAttributes<HTMLParagraphElement>
66 +>(({ className, ...props }, ref) => (
67 + <div
68 + ref={ref}
69 + className={cn("text-sm [&_p]:leading-relaxed", className)}
70 + {...props}
71 + />
72 +))
73 +AlertDescription.displayName = "AlertDescription"
74 +
75 +export { Alert, AlertTitle, AlertDescription }
added client/src/components/ui/aspect-ratio.tsx +21 −0
@@ -0,0 +1,21 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/ui/aspect-ratio.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
18 +
19 +const AspectRatio = AspectRatioPrimitive.Root
20 +
21 +export { AspectRatio }
added client/src/components/ui/avatar.tsx +67 −0
@@ -0,0 +1,67 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/ui/avatar.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +"use client"
18 +
19 +import * as React from "react"
20 +import * as AvatarPrimitive from "@radix-ui/react-avatar"
21 +
22 +import { cn } from "@/lib/utils"
23 +
24 +const Avatar = React.forwardRef<
25 + React.ElementRef<typeof AvatarPrimitive.Root>,
26 + React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
27 +>(({ className, ...props }, ref) => (
28 + <AvatarPrimitive.Root
29 + ref={ref}
30 + className={cn(`
31 + after:content-[''] after:block after:absolute after:inset-0 after:rounded-full after:pointer-events-none after:border after:border-black/10 dark:after:border-white/10
32 + relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full`,
33 + className
34 + )}
35 + {...props}
36 + />
37 +))
38 +Avatar.displayName = AvatarPrimitive.Root.displayName
39 +
40 +const AvatarImage = React.forwardRef<
41 + React.ElementRef<typeof AvatarPrimitive.Image>,
42 + React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
43 +>(({ className, ...props }, ref) => (
44 + <AvatarPrimitive.Image
45 + ref={ref}
46 + className={cn("aspect-square h-full w-full", className)}
47 + {...props}
48 + />
49 +))
50 +AvatarImage.displayName = AvatarPrimitive.Image.displayName
51 +
52 +const AvatarFallback = React.forwardRef<
53 + React.ElementRef<typeof AvatarPrimitive.Fallback>,
54 + React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
55 +>(({ className, ...props }, ref) => (
56 + <AvatarPrimitive.Fallback
57 + ref={ref}
58 + className={cn(
59 + "flex h-full w-full items-center justify-center rounded-full bg-muted",
60 + className
61 + )}
62 + {...props}
63 + />
64 +))
65 +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
66 +
67 +export { Avatar, AvatarImage, AvatarFallback }
added client/src/components/ui/badge.tsx +56 −0
@@ -0,0 +1,56 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/ui/badge.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import * as React from "react"
18 +import { cva, type VariantProps } from "class-variance-authority"
19 +
20 +import { cn } from "@/lib/utils"
21 +
22 +const badgeVariants = cva(
23 + // Whitespace-nowrap: Badges should never wrap.
24 + "whitespace-nowrap inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2" +
25 + " hover-elevate active-elevate-2" ,
26 + {
27 + variants: {
28 + variant: {
29 + default:
30 + "border-transparent bg-primary text-primary-foreground shadow-xs",
31 + secondary: "border-transparent bg-secondary text-secondary-foreground",
32 + destructive:
33 + "border-transparent bg-destructive text-destructive-foreground shadow-xs",
34 + success: "border-transparent bg-success/10 text-success",
35 + warning: "border-transparent bg-warning/10 text-warning",
36 + info: "border-transparent bg-info/10 text-info",
37 + outline: " border [border-color:var(--badge-outline)] shadow-xs",
38 + },
39 + },
40 + defaultVariants: {
41 + variant: "default",
42 + },
43 + },
44 +)
45 +
46 +export interface BadgeProps
47 + extends React.HTMLAttributes<HTMLDivElement>,
48 + VariantProps<typeof badgeVariants> {}
49 +
50 +function Badge({ className, variant, ...props }: BadgeProps) {
51 + return (
52 + <div className={cn(badgeVariants({ variant }), className)} {...props} />
53 + );
54 +}
55 +
56 +export { Badge, badgeVariants }
added client/src/components/ui/breadcrumb.tsx +131 −0
@@ -0,0 +1,131 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/ui/breadcrumb.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import * as React from "react"
18 +import { Slot } from "@radix-ui/react-slot"
19 +import { ChevronRight, MoreHorizontal } from "lucide-react"
20 +
21 +import { cn } from "@/lib/utils"
22 +
23 +const Breadcrumb = React.forwardRef<
24 + HTMLElement,
25 + React.ComponentPropsWithoutRef<"nav"> & {
26 + separator?: React.ReactNode
27 + }
28 +>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
29 +Breadcrumb.displayName = "Breadcrumb"
30 +
31 +const BreadcrumbList = React.forwardRef<
32 + HTMLOListElement,
33 + React.ComponentPropsWithoutRef<"ol">
34 +>(({ className, ...props }, ref) => (
35 + <ol
36 + ref={ref}
37 + className={cn(
38 + "flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
39 + className
40 + )}
41 + {...props}
42 + />
43 +))
44 +BreadcrumbList.displayName = "BreadcrumbList"
45 +
46 +const BreadcrumbItem = React.forwardRef<
47 + HTMLLIElement,
48 + React.ComponentPropsWithoutRef<"li">
49 +>(({ className, ...props }, ref) => (
50 + <li
51 + ref={ref}
52 + className={cn("inline-flex items-center gap-1.5", className)}
53 + {...props}
54 + />
55 +))
56 +BreadcrumbItem.displayName = "BreadcrumbItem"
57 +
58 +const BreadcrumbLink = React.forwardRef<
59 + HTMLAnchorElement,
60 + React.ComponentPropsWithoutRef<"a"> & {
61 + asChild?: boolean
62 + }
63 +>(({ asChild, className, ...props }, ref) => {
64 + const Comp = asChild ? Slot : "a"
65 +
66 + return (
67 + <Comp
68 + ref={ref}
69 + className={cn("transition-colors hover:text-foreground", className)}
70 + {...props}
71 + />
72 + )
73 +})
74 +BreadcrumbLink.displayName = "BreadcrumbLink"
75 +
76 +const BreadcrumbPage = React.forwardRef<
77 + HTMLSpanElement,
78 + React.ComponentPropsWithoutRef<"span">
79 +>(({ className, ...props }, ref) => (
80 + <span
81 + ref={ref}
82 + role="link"
83 + aria-disabled="true"
84 + aria-current="page"
85 + className={cn("font-normal text-foreground", className)}
86 + {...props}
87 + />
88 +))
89 +BreadcrumbPage.displayName = "BreadcrumbPage"
90 +
91 +const BreadcrumbSeparator = ({
92 + children,
93 + className,
94 + ...props
95 +}: React.ComponentProps<"li">) => (
96 + <li
97 + role="presentation"
98 + aria-hidden="true"
99 + className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)}
100 + {...props}
101 + >
102 + {children ?? <ChevronRight />}
103 + </li>
104 +)
105 +BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
106 +
107 +const BreadcrumbEllipsis = ({
108 + className,
109 + ...props
110 +}: React.ComponentProps<"span">) => (
111 + <span
112 + role="presentation"
113 + aria-hidden="true"
114 + className={cn("flex h-9 w-9 items-center justify-center", className)}
115 + {...props}
116 + >
117 + <MoreHorizontal className="h-4 w-4" />
118 + <span className="sr-only">More</span>
119 + </span>
120 +)
121 +BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
122 +
123 +export {
124 + Breadcrumb,
125 + BreadcrumbList,
126 + BreadcrumbItem,
127 + BreadcrumbLink,
128 + BreadcrumbPage,
129 + BreadcrumbSeparator,
130 + BreadcrumbEllipsis,
131 +}
added client/src/components/ui/button.tsx +78 −0
@@ -0,0 +1,78 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/ui/button.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import * as React from "react"
18 +import { Slot } from "@radix-ui/react-slot"
19 +import { cva, type VariantProps } from "class-variance-authority"
20 +
21 +import { cn } from "@/lib/utils"
22 +
23 +const buttonVariants = cva(
24 + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0" +
25 + " hover-elevate active-elevate-2",
26 + {
27 + variants: {
28 + variant: {
29 + default:
30 + "bg-primary text-primary-foreground border border-primary-border",
31 + destructive:
32 + "bg-destructive text-destructive-foreground border border-destructive-border",
33 + outline:
34 + // Shows the background color of whatever card / sidebar / accent background it is inside of.
35 + // Inherits the current text color.
36 + " border [border-color:var(--button-outline)] shadow-xs active:shadow-none ",
37 + secondary: "border bg-secondary text-secondary-foreground border border-secondary-border ",
38 + // Add a transparent border so that when someone toggles a border on later, it doesn't shift layout/size.
39 + ghost: "border border-transparent",
40 + },
41 + // Heights are set as "min" heights, because sometimes Ai will place large amount of content
42 + // inside buttons. With a min-height they will look appropriate with small amounts of content,
43 + // but will expand to fit large amounts of content.
44 + size: {
45 + default: "min-h-9 px-4 py-2",
46 + sm: "min-h-8 rounded-md px-3 text-xs",
47 + lg: "min-h-10 rounded-md px-8",
48 + icon: "h-9 w-9",
49 + },
50 + },
51 + defaultVariants: {
52 + variant: "default",
53 + size: "default",
54 + },
55 + },
56 +)
57 +
58 +export interface ButtonProps
59 + extends React.ButtonHTMLAttributes<HTMLButtonElement>,
60 + VariantProps<typeof buttonVariants> {
61 + asChild?: boolean
62 +}
63 +
64 +const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
65 + ({ className, variant, size, asChild = false, ...props }, ref) => {
66 + const Comp = asChild ? Slot : "button"
67 + return (
68 + <Comp
69 + className={cn(buttonVariants({ variant, size, className }))}
70 + ref={ref}
71 + {...props}
72 + />
73 + )
74 + },
75 +)
76 +Button.displayName = "Button"
77 +
78 +export { Button, buttonVariants }
added client/src/components/ui/calendar.tsx +84 −0
@@ -0,0 +1,84 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/ui/calendar.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import * as React from "react"
18 +import { ChevronLeft, ChevronRight } from "lucide-react"
19 +import { DayPicker } from "react-day-picker"
20 +
21 +import { cn } from "@/lib/utils"
22 +import { buttonVariants } from "@/components/ui/button"
23 +
24 +export type CalendarProps = React.ComponentProps<typeof DayPicker>
25 +
26 +function Calendar({
27 + className,
28 + classNames,
29 + showOutsideDays = true,
30 + ...props
31 +}: CalendarProps) {
32 + return (
33 + <DayPicker
34 + showOutsideDays={showOutsideDays}
35 + className={cn("p-3", className)}
36 + classNames={{
37 + months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
38 + month: "space-y-4",
39 + caption: "flex justify-center pt-1 relative items-center",
40 + caption_label: "text-sm font-medium",
41 + nav: "space-x-1 flex items-center",
42 + nav_button: cn(
43 + buttonVariants({ variant: "outline" }),
44 + "h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
45 + ),
46 + nav_button_previous: "absolute left-1",
47 + nav_button_next: "absolute right-1",
48 + table: "w-full border-collapse space-y-1",
49 + head_row: "flex",
50 + head_cell:
51 + "text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
52 + row: "flex w-full mt-2",
53 + cell: "h-9 w-9 text-center text-sm p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
54 + day: cn(
55 + buttonVariants({ variant: "ghost" }),
56 + "h-9 w-9 p-0 font-normal aria-selected:opacity-100"
57 + ),
58 + day_range_end: "day-range-end",
59 + day_selected:
60 + "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
61 + day_today: "bg-accent text-accent-foreground",
62 + day_outside:
63 + "day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
64 + day_disabled: "text-muted-foreground opacity-50",
65 + day_range_middle:
66 + "aria-selected:bg-accent aria-selected:text-accent-foreground",
67 + day_hidden: "invisible",
68 + ...classNames,
69 + }}
70 + components={{
71 + IconLeft: ({ className, ...props }) => (
72 + <ChevronLeft className={cn("h-4 w-4", className)} {...props} />
73 + ),
74 + IconRight: ({ className, ...props }) => (
75 + <ChevronRight className={cn("h-4 w-4", className)} {...props} />
76 + ),
77 + }}
78 + {...props}
79 + />
80 + )
81 +}
82 +Calendar.displayName = "Calendar"
83 +
84 +export { Calendar }
added client/src/components/ui/card.tsx +101 −0
@@ -0,0 +1,101 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/ui/card.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import * as React from "react"
18 +
19 +import { cn } from "@/lib/utils"
20 +
21 +const Card = React.forwardRef<
22 + HTMLDivElement,
23 + React.HTMLAttributes<HTMLDivElement>
24 +>(({ className, ...props }, ref) => (
25 + <div
26 + ref={ref}
27 + className={cn(
28 + "shadcn-card rounded-xl border bg-card border-card-border text-card-foreground shadow-sm",
29 + className
30 + )}
31 + {...props}
32 + />
33 +));
34 +Card.displayName = "Card"
35 +
36 +const CardHeader = React.forwardRef<
37 + HTMLDivElement,
38 + React.HTMLAttributes<HTMLDivElement>
39 +>(({ className, ...props }, ref) => (
40 + <div
41 + ref={ref}
42 + className={cn("flex flex-col space-y-1.5 p-6", className)}
43 + {...props}
44 + />
45 +));
46 +CardHeader.displayName = "CardHeader"
47 +
48 +const CardTitle = React.forwardRef<
49 + HTMLDivElement,
50 + React.HTMLAttributes<HTMLDivElement>
51 +>(({ className, ...props }, ref) => (
52 + <div
53 + ref={ref}
54 + className={cn(
55 + "text-2xl font-semibold leading-none tracking-tight",
56 + className
57 + )}
58 + {...props}
59 + />
60 +))
61 +CardTitle.displayName = "CardTitle"
62 +
63 +const CardDescription = React.forwardRef<
64 + HTMLDivElement,
65 + React.HTMLAttributes<HTMLDivElement>
66 +>(({ className, ...props }, ref) => (
67 + <div
68 + ref={ref}
69 + className={cn("text-sm text-muted-foreground", className)}
70 + {...props}
71 + />
72 +));
73 +CardDescription.displayName = "CardDescription"
74 +
75 +const CardContent = React.forwardRef<
76 + HTMLDivElement,
77 + React.HTMLAttributes<HTMLDivElement>
78 +>(({ className, ...props }, ref) => (
79 + <div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
80 +))
81 +CardContent.displayName = "CardContent"
82 +
83 +const CardFooter = React.forwardRef<
84 + HTMLDivElement,
85 + React.HTMLAttributes<HTMLDivElement>
86 +>(({ className, ...props }, ref) => (
87 + <div
88 + ref={ref}
89 + className={cn("flex items-center p-6 pt-0", className)}
90 + {...props}
91 + />
92 +))
93 +CardFooter.displayName = "CardFooter"
94 +export {
95 + Card,
96 + CardHeader,
97 + CardFooter,
98 + CardTitle,
99 + CardDescription,
100 + CardContent,
101 +}
added client/src/components/ui/carousel.tsx +276 −0
@@ -0,0 +1,276 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/ui/carousel.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import * as React from "react"
18 +import useEmblaCarousel, {
19 + type UseEmblaCarouselType,
20 +} from "embla-carousel-react"
21 +import { ArrowLeft, ArrowRight } from "lucide-react"
22 +
23 +import { cn } from "@/lib/utils"
24 +import { Button } from "@/components/ui/button"
25 +
26 +type CarouselApi = UseEmblaCarouselType[1]
27 +type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
28 +type CarouselOptions = UseCarouselParameters[0]
29 +type CarouselPlugin = UseCarouselParameters[1]
30 +
31 +type CarouselProps = {
32 + opts?: CarouselOptions
33 + plugins?: CarouselPlugin
34 + orientation?: "horizontal" | "vertical"
35 + setApi?: (api: CarouselApi) => void
36 +}
37 +
38 +type CarouselContextProps = {
39 + carouselRef: ReturnType<typeof useEmblaCarousel>[0]
40 + api: ReturnType<typeof useEmblaCarousel>[1]
41 + scrollPrev: () => void
42 + scrollNext: () => void
43 + canScrollPrev: boolean
44 + canScrollNext: boolean
45 +} & CarouselProps
46 +
47 +const CarouselContext = React.createContext<CarouselContextProps | null>(null)
48 +
49 +function useCarousel() {
50 + const context = React.useContext(CarouselContext)
51 +
52 + if (!context) {
53 + throw new Error("useCarousel must be used within a <Carousel />")
54 + }
55 +
56 + return context
57 +}
58 +
59 +const Carousel = React.forwardRef<
60 + HTMLDivElement,
61 + React.HTMLAttributes<HTMLDivElement> & CarouselProps
62 +>(
63 + (
64 + {
65 + orientation = "horizontal",
66 + opts,
67 + setApi,
68 + plugins,
69 + className,
70 + children,
71 + ...props
72 + },
73 + ref
74 + ) => {
75 + const [carouselRef, api] = useEmblaCarousel(
76 + {
77 + ...opts,
78 + axis: orientation === "horizontal" ? "x" : "y",
79 + },
80 + plugins
81 + )
82 + const [canScrollPrev, setCanScrollPrev] = React.useState(false)
83 + const [canScrollNext, setCanScrollNext] = React.useState(false)
84 +
85 + const onSelect = React.useCallback((api: CarouselApi) => {
86 + if (!api) {
87 + return
88 + }
89 +
90 + setCanScrollPrev(api.canScrollPrev())
91 + setCanScrollNext(api.canScrollNext())
92 + }, [])
93 +
94 + const scrollPrev = React.useCallback(() => {
95 + api?.scrollPrev()
96 + }, [api])
97 +
98 + const scrollNext = React.useCallback(() => {
99 + api?.scrollNext()
100 + }, [api])
101 +
102 + const handleKeyDown = React.useCallback(
103 + (event: React.KeyboardEvent<HTMLDivElement>) => {
104 + if (event.key === "ArrowLeft") {
105 + event.preventDefault()
106 + scrollPrev()
107 + } else if (event.key === "ArrowRight") {
108 + event.preventDefault()
109 + scrollNext()
110 + }
111 + },
112 + [scrollPrev, scrollNext]
113 + )
114 +
115 + React.useEffect(() => {
116 + if (!api || !setApi) {
117 + return
118 + }
119 +
120 + setApi(api)
121 + }, [api, setApi])
122 +
123 + React.useEffect(() => {
124 + if (!api) {
125 + return
126 + }
127 +
128 + onSelect(api)
129 + api.on("reInit", onSelect)
130 + api.on("select", onSelect)
131 +
132 + return () => {
133 + api?.off("select", onSelect)
134 + }
135 + }, [api, onSelect])
136 +
137 + return (
138 + <CarouselContext.Provider
139 + value={{
140 + carouselRef,
141 + api: api,
142 + opts,
143 + orientation:
144 + orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
145 + scrollPrev,
146 + scrollNext,
147 + canScrollPrev,
148 + canScrollNext,
149 + }}
150 + >
151 + <div
152 + ref={ref}
153 + onKeyDownCapture={handleKeyDown}
154 + className={cn("relative", className)}
155 + role="region"
156 + aria-roledescription="carousel"
157 + {...props}
158 + >
159 + {children}
160 + </div>
161 + </CarouselContext.Provider>
162 + )
163 + }
164 +)
165 +Carousel.displayName = "Carousel"
166 +
167 +const CarouselContent = React.forwardRef<
168 + HTMLDivElement,
169 + React.HTMLAttributes<HTMLDivElement>
170 +>(({ className, ...props }, ref) => {
171 + const { carouselRef, orientation } = useCarousel()
172 +
173 + return (
174 + <div ref={carouselRef} className="overflow-hidden">
175 + <div
176 + ref={ref}
177 + className={cn(
178 + "flex",
179 + orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
180 + className
181 + )}
182 + {...props}
183 + />
184 + </div>
185 + )
186 +})
187 +CarouselContent.displayName = "CarouselContent"
188 +
189 +const CarouselItem = React.forwardRef<
190 + HTMLDivElement,
191 + React.HTMLAttributes<HTMLDivElement>
192 +>(({ className, ...props }, ref) => {
193 + const { orientation } = useCarousel()
194 +
195 + return (
196 + <div
197 + ref={ref}
198 + role="group"
199 + aria-roledescription="slide"
200 + className={cn(
201 + "min-w-0 shrink-0 grow-0 basis-full",
202 + orientation === "horizontal" ? "pl-4" : "pt-4",
203 + className
204 + )}
205 + {...props}
206 + />
207 + )
208 +})
209 +CarouselItem.displayName = "CarouselItem"
210 +
211 +const CarouselPrevious = React.forwardRef<
212 + HTMLButtonElement,
213 + React.ComponentProps<typeof Button>
214 +>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
215 + const { orientation, scrollPrev, canScrollPrev } = useCarousel()
216 +
217 + return (
218 + <Button
219 + ref={ref}
220 + variant={variant}
221 + size={size}
222 + className={cn(
223 + "absolute h-8 w-8 rounded-full",
224 + orientation === "horizontal"
225 + ? "-left-12 top-1/2 -translate-y-1/2"
226 + : "-top-12 left-1/2 -translate-x-1/2 rotate-90",
227 + className
228 + )}
229 + disabled={!canScrollPrev}
230 + onClick={scrollPrev}
231 + {...props}
232 + >
233 + <ArrowLeft className="h-4 w-4" />
234 + <span className="sr-only">Previous slide</span>
235 + </Button>
236 + )
237 +})
238 +CarouselPrevious.displayName = "CarouselPrevious"
239 +
240 +const CarouselNext = React.forwardRef<
241 + HTMLButtonElement,
242 + React.ComponentProps<typeof Button>
243 +>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
244 + const { orientation, scrollNext, canScrollNext } = useCarousel()
245 +
246 + return (
247 + <Button
248 + ref={ref}
249 + variant={variant}
250 + size={size}
251 + className={cn(
252 + "absolute h-8 w-8 rounded-full",
253 + orientation === "horizontal"
254 + ? "-right-12 top-1/2 -translate-y-1/2"
255 + : "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
256 + className
257 + )}
258 + disabled={!canScrollNext}
259 + onClick={scrollNext}
260 + {...props}
261 + >
262 + <ArrowRight className="h-4 w-4" />
263 + <span className="sr-only">Next slide</span>
264 + </Button>
265 + )
266 +})
267 +CarouselNext.displayName = "CarouselNext"
268 +
269 +export {
270 + type CarouselApi,
271 + Carousel,
272 + CarouselContent,
273 + CarouselItem,
274 + CarouselPrevious,
275 + CarouselNext,
276 +}
added client/src/components/ui/chart.tsx +381 −0
@@ -0,0 +1,381 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/ui/chart.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +"use client"
18 +
19 +import * as React from "react"
20 +import * as RechartsPrimitive from "recharts"
21 +
22 +import { cn } from "@/lib/utils"
23 +
24 +// Format: { THEME_NAME: CSS_SELECTOR }
25 +const THEMES = { light: "", dark: ".dark" } as const
26 +
27 +export type ChartConfig = {
28 + [k in string]: {
29 + label?: React.ReactNode
30 + icon?: React.ComponentType
31 + } & (
32 + | { color?: string; theme?: never }
33 + | { color?: never; theme: Record<keyof typeof THEMES, string> }
34 + )
35 +}
36 +
37 +type ChartContextProps = {
38 + config: ChartConfig
39 +}
40 +
41 +const ChartContext = React.createContext<ChartContextProps | null>(null)
42 +
43 +function useChart() {
44 + const context = React.useContext(ChartContext)
45 +
46 + if (!context) {
47 + throw new Error("useChart must be used within a <ChartContainer />")
48 + }
49 +
50 + return context
51 +}
52 +
53 +const ChartContainer = React.forwardRef<
54 + HTMLDivElement,
55 + React.ComponentProps<"div"> & {
56 + config: ChartConfig
57 + children: React.ComponentProps<
58 + typeof RechartsPrimitive.ResponsiveContainer
59 + >["children"]
60 + }
61 +>(({ id, className, children, config, ...props }, ref) => {
62 + const uniqueId = React.useId()
63 + const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
64 +
65 + return (
66 + <ChartContext.Provider value={{ config }}>
67 + <div
68 + data-chart={chartId}
69 + ref={ref}
70 + className={cn(
71 + "flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
72 + className
73 + )}
74 + {...props}
75 + >
76 + <ChartStyle id={chartId} config={config} />
77 + <RechartsPrimitive.ResponsiveContainer>
78 + {children}
79 + </RechartsPrimitive.ResponsiveContainer>
80 + </div>
81 + </ChartContext.Provider>
82 + )
83 +})
84 +ChartContainer.displayName = "Chart"
85 +
86 +const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
87 + const colorConfig = Object.entries(config).filter(
88 + ([, config]) => config.theme || config.color
89 + )
90 +
91 + if (!colorConfig.length) {
92 + return null
93 + }
94 +
95 + return (
96 + <style
97 + dangerouslySetInnerHTML={{
98 + __html: Object.entries(THEMES)
99 + .map(
100 + ([theme, prefix]) => `
101 +${prefix} [data-chart=${id}] {
102 +${colorConfig
103 + .map(([key, itemConfig]) => {
104 + const color =
105 + itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
106 + itemConfig.color
107 + return color ? ` --color-${key}: ${color};` : null
108 + })
109 + .join("\n")}
110 +}
111 +`
112 + )
113 + .join("\n"),
114 + }}
115 + />
116 + )
117 +}
118 +
119 +const ChartTooltip = RechartsPrimitive.Tooltip
120 +
121 +const ChartTooltipContent = React.forwardRef<
122 + HTMLDivElement,
123 + React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
124 + React.ComponentProps<"div"> & {
125 + hideLabel?: boolean
126 + hideIndicator?: boolean
127 + indicator?: "line" | "dot" | "dashed"
128 + nameKey?: string
129 + labelKey?: string
130 + }
131 +>(
132 + (
133 + {
134 + active,
135 + payload,
136 + className,
137 + indicator = "dot",
138 + hideLabel = false,
139 + hideIndicator = false,
140 + label,
141 + labelFormatter,
142 + labelClassName,
143 + formatter,
144 + color,
145 + nameKey,
146 + labelKey,
147 + },
148 + ref
149 + ) => {
150 + const { config } = useChart()
151 +
152 + const tooltipLabel = React.useMemo(() => {
153 + if (hideLabel || !payload?.length) {
154 + return null
155 + }
156 +
157 + const [item] = payload
158 + const key = `${labelKey || item?.dataKey || item?.name || "value"}`
159 + const itemConfig = getPayloadConfigFromPayload(config, item, key)
160 + const value =
161 + !labelKey && typeof label === "string"
162 + ? config[label as keyof typeof config]?.label || label
163 + : itemConfig?.label
164 +
165 + if (labelFormatter) {
166 + return (
167 + <div className={cn("font-medium", labelClassName)}>
168 + {labelFormatter(value, payload)}
169 + </div>
170 + )
171 + }
172 +
173 + if (!value) {
174 + return null
175 + }
176 +
177 + return <div className={cn("font-medium", labelClassName)}>{value}</div>
178 + }, [
179 + label,
180 + labelFormatter,
181 + payload,
182 + hideLabel,
183 + labelClassName,
184 + config,
185 + labelKey,
186 + ])
187 +
188 + if (!active || !payload?.length) {
189 + return null
190 + }
191 +
192 + const nestLabel = payload.length === 1 && indicator !== "dot"
193 +
194 + return (
195 + <div
196 + ref={ref}
197 + className={cn(
198 + "grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
199 + className
200 + )}
201 + >
202 + {!nestLabel ? tooltipLabel : null}
203 + <div className="grid gap-1.5">
204 + {payload.map((item, index) => {
205 + const key = `${nameKey || item.name || item.dataKey || "value"}`
206 + const itemConfig = getPayloadConfigFromPayload(config, item, key)
207 + const indicatorColor = color || item.payload.fill || item.color
208 +
209 + return (
210 + <div
211 + key={item.dataKey}
212 + className={cn(
213 + "flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
214 + indicator === "dot" && "items-center"
215 + )}
216 + >
217 + {formatter && item?.value !== undefined && item.name ? (
218 + formatter(item.value, item.name, item, index, item.payload)
219 + ) : (
220 + <>
221 + {itemConfig?.icon ? (
222 + <itemConfig.icon />
223 + ) : (
224 + !hideIndicator && (
225 + <div
226 + className={cn(
227 + "shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
228 + {
229 + "h-2.5 w-2.5": indicator === "dot",
230 + "w-1": indicator === "line",
231 + "w-0 border-[1.5px] border-dashed bg-transparent":
232 + indicator === "dashed",
233 + "my-0.5": nestLabel && indicator === "dashed",
234 + }
235 + )}
236 + style={
237 + {
238 + "--color-bg": indicatorColor,
239 + "--color-border": indicatorColor,
240 + } as React.CSSProperties
241 + }
242 + />
243 + )
244 + )}
245 + <div
246 + className={cn(
247 + "flex flex-1 justify-between leading-none",
248 + nestLabel ? "items-end" : "items-center"
249 + )}
250 + >
251 + <div className="grid gap-1.5">
252 + {nestLabel ? tooltipLabel : null}
253 + <span className="text-muted-foreground">
254 + {itemConfig?.label || item.name}
255 + </span>
256 + </div>
257 + {item.value && (
258 + <span className="font-mono font-medium tabular-nums text-foreground">
259 + {item.value.toLocaleString()}
260 + </span>
261 + )}
262 + </div>
263 + </>
264 + )}
265 + </div>
266 + )
267 + })}
268 + </div>
269 + </div>
270 + )
271 + }
272 +)
273 +ChartTooltipContent.displayName = "ChartTooltip"
274 +
275 +const ChartLegend = RechartsPrimitive.Legend
276 +
277 +const ChartLegendContent = React.forwardRef<
278 + HTMLDivElement,
279 + React.ComponentProps<"div"> &
280 + Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
281 + hideIcon?: boolean
282 + nameKey?: string
283 + }
284 +>(
285 + (
286 + { className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
287 + ref
288 + ) => {
289 + const { config } = useChart()
290 +
291 + if (!payload?.length) {
292 + return null
293 + }
294 +
295 + return (
296 + <div
297 + ref={ref}
298 + className={cn(
299 + "flex items-center justify-center gap-4",
300 + verticalAlign === "top" ? "pb-3" : "pt-3",
301 + className
302 + )}
303 + >
304 + {payload.map((item) => {
305 + const key = `${nameKey || item.dataKey || "value"}`
306 + const itemConfig = getPayloadConfigFromPayload(config, item, key)
307 +
308 + return (
309 + <div
310 + key={item.value}
311 + className={cn(
312 + "flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
313 + )}
314 + >
315 + {itemConfig?.icon && !hideIcon ? (
316 + <itemConfig.icon />
317 + ) : (
318 + <div
319 + className="h-2 w-2 shrink-0 rounded-[2px]"
320 + style={{
321 + backgroundColor: item.color,
322 + }}
323 + />
324 + )}
325 + {itemConfig?.label}
326 + </div>
327 + )
328 + })}
329 + </div>
330 + )
331 + }
332 +)
333 +ChartLegendContent.displayName = "ChartLegend"
334 +
335 +// Helper to extract item config from a payload.
336 +function getPayloadConfigFromPayload(
337 + config: ChartConfig,
338 + payload: unknown,
339 + key: string
340 +) {
341 + if (typeof payload !== "object" || payload === null) {
342 + return undefined
343 + }
344 +
345 + const payloadPayload =
346 + "payload" in payload &&
347 + typeof payload.payload === "object" &&
348 + payload.payload !== null
349 + ? payload.payload
350 + : undefined
351 +
352 + let configLabelKey: string = key
353 +
354 + if (
355 + key in payload &&
356 + typeof payload[key as keyof typeof payload] === "string"
357 + ) {
358 + configLabelKey = payload[key as keyof typeof payload] as string
359 + } else if (
360 + payloadPayload &&
361 + key in payloadPayload &&
362 + typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
363 + ) {
364 + configLabelKey = payloadPayload[
365 + key as keyof typeof payloadPayload
366 + ] as string
367 + }
368 +
369 + return configLabelKey in config
370 + ? config[configLabelKey]
371 + : config[key as keyof typeof config]
372 +}
373 +
374 +export {
375 + ChartContainer,
376 + ChartTooltip,
377 + ChartTooltipContent,
378 + ChartLegend,
379 + ChartLegendContent,
380 + ChartStyle,
381 +}
added client/src/components/ui/checkbox.tsx +44 −0
@@ -0,0 +1,44 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/ui/checkbox.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import * as React from "react"
18 +import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
19 +import { Check } from "lucide-react"
20 +
21 +import { cn } from "@/lib/utils"
22 +
23 +const Checkbox = React.forwardRef<
24 + React.ElementRef<typeof CheckboxPrimitive.Root>,
25 + React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
26 +>(({ className, ...props }, ref) => (
27 + <CheckboxPrimitive.Root
28 + ref={ref}
29 + className={cn(
30 + "peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
31 + className
32 + )}
33 + {...props}
34 + >
35 + <CheckboxPrimitive.Indicator
36 + className={cn("flex items-center justify-center text-current")}
37 + >
38 + <Check className="h-4 w-4" />
39 + </CheckboxPrimitive.Indicator>
40 + </CheckboxPrimitive.Root>
41 +))
42 +Checkbox.displayName = CheckboxPrimitive.Root.displayName
43 +
44 +export { Checkbox }
added client/src/components/ui/collapsible.tsx +27 −0
@@ -0,0 +1,27 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/ui/collapsible.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +"use client"
18 +
19 +import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
20 +
21 +const Collapsible = CollapsiblePrimitive.Root
22 +
23 +const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
24 +
25 +const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
26 +
27 +export { Collapsible, CollapsibleTrigger, CollapsibleContent }
added client/src/components/ui/command.tsx +167 −0
@@ -0,0 +1,167 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/ui/command.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import * as React from "react"
18 +import { type DialogProps } from "@radix-ui/react-dialog"
19 +import { Command as CommandPrimitive } from "cmdk"
20 +import { Search } from "lucide-react"
21 +
22 +import { cn } from "@/lib/utils"
23 +import { Dialog, DialogContent } from "@/components/ui/dialog"
24 +
25 +const Command = React.forwardRef<
26 + React.ElementRef<typeof CommandPrimitive>,
27 + React.ComponentPropsWithoutRef<typeof CommandPrimitive>
28 +>(({ className, ...props }, ref) => (
29 + <CommandPrimitive
30 + ref={ref}
31 + className={cn(
32 + "flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
33 + className
34 + )}
35 + {...props}
36 + />
37 +))
38 +Command.displayName = CommandPrimitive.displayName
39 +
40 +const CommandDialog = ({ children, ...props }: DialogProps) => {
41 + return (
42 + <Dialog {...props}>
43 + <DialogContent className="overflow-hidden p-0 shadow-lg">
44 + <Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
45 + {children}
46 + </Command>
47 + </DialogContent>
48 + </Dialog>
49 + )
50 +}
51 +
52 +const CommandInput = React.forwardRef<
53 + React.ElementRef<typeof CommandPrimitive.Input>,
54 + React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
55 +>(({ className, ...props }, ref) => (
56 + <div className="flex items-center border-b px-3" cmdk-input-wrapper="">
57 + <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
58 + <CommandPrimitive.Input
59 + ref={ref}
60 + className={cn(
61 + "flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
62 + className
63 + )}
64 + {...props}
65 + />
66 + </div>
67 +))
68 +
69 +CommandInput.displayName = CommandPrimitive.Input.displayName
70 +
71 +const CommandList = React.forwardRef<
72 + React.ElementRef<typeof CommandPrimitive.List>,
73 + React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
74 +>(({ className, ...props }, ref) => (
75 + <CommandPrimitive.List
76 + ref={ref}
77 + className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
78 + {...props}
79 + />
80 +))
81 +
82 +CommandList.displayName = CommandPrimitive.List.displayName
83 +
84 +const CommandEmpty = React.forwardRef<
85 + React.ElementRef<typeof CommandPrimitive.Empty>,
86 + React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
87 +>((props, ref) => (
88 + <CommandPrimitive.Empty
89 + ref={ref}
90 + className="py-6 text-center text-sm"
91 + {...props}
92 + />
93 +))
94 +
95 +CommandEmpty.displayName = CommandPrimitive.Empty.displayName
96 +
97 +const CommandGroup = React.forwardRef<
98 + React.ElementRef<typeof CommandPrimitive.Group>,
99 + React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
100 +>(({ className, ...props }, ref) => (
101 + <CommandPrimitive.Group
102 + ref={ref}
103 + className={cn(
104 + "overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
105 + className
106 + )}
107 + {...props}
108 + />
109 +))
110 +
111 +CommandGroup.displayName = CommandPrimitive.Group.displayName
112 +
113 +const CommandSeparator = React.forwardRef<
114 + React.ElementRef<typeof CommandPrimitive.Separator>,
115 + React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
116 +>(({ className, ...props }, ref) => (
117 + <CommandPrimitive.Separator
118 + ref={ref}
119 + className={cn("-mx-1 h-px bg-border", className)}
120 + {...props}
121 + />
122 +))
123 +CommandSeparator.displayName = CommandPrimitive.Separator.displayName
124 +
125 +const CommandItem = React.forwardRef<
126 + React.ElementRef<typeof CommandPrimitive.Item>,
127 + React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
128 +>(({ className, ...props }, ref) => (
129 + <CommandPrimitive.Item
130 + ref={ref}
131 + className={cn(
132 + "relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
133 + className
134 + )}
135 + {...props}
136 + />
137 +))
138 +
139 +CommandItem.displayName = CommandPrimitive.Item.displayName
140 +
141 +const CommandShortcut = ({
142 + className,
143 + ...props
144 +}: React.HTMLAttributes<HTMLSpanElement>) => {
145 + return (
146 + <span
147 + className={cn(
148 + "ml-auto text-xs tracking-widest text-muted-foreground",
149 + className
150 + )}
151 + {...props}
152 + />
153 + )
154 +}
155 +CommandShortcut.displayName = "CommandShortcut"
156 +
157 +export {
158 + Command,
159 + CommandDialog,
160 + CommandInput,
161 + CommandList,
162 + CommandEmpty,
163 + CommandGroup,
164 + CommandItem,
165 + CommandShortcut,
166 + CommandSeparator,
167 +}
added client/src/components/ui/context-menu.tsx +214 −0
@@ -0,0 +1,214 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/ui/context-menu.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import * as React from "react"
18 +import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
19 +import { Check, ChevronRight, Circle } from "lucide-react"
20 +
21 +import { cn } from "@/lib/utils"
22 +
23 +const ContextMenu = ContextMenuPrimitive.Root
24 +
25 +const ContextMenuTrigger = ContextMenuPrimitive.Trigger
26 +
27 +const ContextMenuGroup = ContextMenuPrimitive.Group
28 +
29 +const ContextMenuPortal = ContextMenuPrimitive.Portal
30 +
31 +const ContextMenuSub = ContextMenuPrimitive.Sub
32 +
33 +const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup
34 +
35 +const ContextMenuSubTrigger = React.forwardRef<
36 + React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
37 + React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
38 + inset?: boolean
39 + }
40 +>(({ className, inset, children, ...props }, ref) => (
41 + <ContextMenuPrimitive.SubTrigger
42 + ref={ref}
43 + className={cn(
44 + "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
45 + inset && "pl-8",
46 + className
47 + )}
48 + {...props}
49 + >
50 + {children}
51 + <ChevronRight className="ml-auto h-4 w-4" />
52 + </ContextMenuPrimitive.SubTrigger>
53 +))
54 +ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName
55 +
56 +const ContextMenuSubContent = React.forwardRef<
57 + React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
58 + React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
59 +>(({ className, ...props }, ref) => (
60 + <ContextMenuPrimitive.SubContent
61 + ref={ref}
62 + className={cn(
63 + "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]",
64 + className
65 + )}
66 + {...props}
67 + />
68 +))
69 +ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName
70 +
71 +const ContextMenuContent = React.forwardRef<
72 + React.ElementRef<typeof ContextMenuPrimitive.Content>,
73 + React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
74 +>(({ className, ...props }, ref) => (
75 + <ContextMenuPrimitive.Portal>
76 + <ContextMenuPrimitive.Content
77 + ref={ref}
78 + className={cn(
79 + "z-50 max-h-[--radix-context-menu-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]",
80 + className
81 + )}
82 + {...props}
83 + />
84 + </ContextMenuPrimitive.Portal>
85 +))
86 +ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName
87 +
88 +const ContextMenuItem = React.forwardRef<
89 + React.ElementRef<typeof ContextMenuPrimitive.Item>,
90 + React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
91 + inset?: boolean
92 + }
93 +>(({ className, inset, ...props }, ref) => (
94 + <ContextMenuPrimitive.Item
95 + ref={ref}
96 + className={cn(
97 + "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
98 + inset && "pl-8",
99 + className
100 + )}
101 + {...props}
102 + />
103 +))
104 +ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName
105 +
106 +const ContextMenuCheckboxItem = React.forwardRef<
107 + React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
108 + React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
109 +>(({ className, children, checked, ...props }, ref) => (
110 + <ContextMenuPrimitive.CheckboxItem
111 + ref={ref}
112 + className={cn(
113 + "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
114 + className
115 + )}
116 + checked={checked}
117 + {...props}
118 + >
119 + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
120 + <ContextMenuPrimitive.ItemIndicator>
121 + <Check className="h-4 w-4" />
122 + </ContextMenuPrimitive.ItemIndicator>
123 + </span>
124 + {children}
125 + </ContextMenuPrimitive.CheckboxItem>
126 +))
127 +ContextMenuCheckboxItem.displayName =
128 + ContextMenuPrimitive.CheckboxItem.displayName
129 +
130 +const ContextMenuRadioItem = React.forwardRef<
131 + React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
132 + React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
133 +>(({ className, children, ...props }, ref) => (
134 + <ContextMenuPrimitive.RadioItem
135 + ref={ref}
136 + className={cn(
137 + "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
138 + className
139 + )}
140 + {...props}
141 + >
142 + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
143 + <ContextMenuPrimitive.ItemIndicator>
144 + <Circle className="h-2 w-2 fill-current" />
145 + </ContextMenuPrimitive.ItemIndicator>
146 + </span>
147 + {children}
148 + </ContextMenuPrimitive.RadioItem>
149 +))
150 +ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName
151 +
152 +const ContextMenuLabel = React.forwardRef<
153 + React.ElementRef<typeof ContextMenuPrimitive.Label>,
154 + React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
155 + inset?: boolean
156 + }
157 +>(({ className, inset, ...props }, ref) => (
158 + <ContextMenuPrimitive.Label
159 + ref={ref}
160 + className={cn(
161 + "px-2 py-1.5 text-sm font-semibold text-foreground",
162 + inset && "pl-8",
163 + className
164 + )}
165 + {...props}
166 + />
167 +))
168 +ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName
169 +
170 +const ContextMenuSeparator = React.forwardRef<
171 + React.ElementRef<typeof ContextMenuPrimitive.Separator>,
172 + React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
173 +>(({ className, ...props }, ref) => (
174 + <ContextMenuPrimitive.Separator
175 + ref={ref}
176 + className={cn("-mx-1 my-1 h-px bg-border", className)}
177 + {...props}
178 + />
179 +))
180 +ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName
181 +
182 +const ContextMenuShortcut = ({
183 + className,
184 + ...props
185 +}: React.HTMLAttributes<HTMLSpanElement>) => {
186 + return (
187 + <span
188 + className={cn(
189 + "ml-auto text-xs tracking-widest text-muted-foreground",
190 + className
191 + )}
192 + {...props}
193 + />
194 + )
195 +}
196 +ContextMenuShortcut.displayName = "ContextMenuShortcut"
197 +
198 +export {
199 + ContextMenu,
200 + ContextMenuTrigger,
201 + ContextMenuContent,
202 + ContextMenuItem,
203 + ContextMenuCheckboxItem,
204 + ContextMenuRadioItem,
205 + ContextMenuLabel,
206 + ContextMenuSeparator,
207 + ContextMenuShortcut,
208 + ContextMenuGroup,
209 + ContextMenuPortal,
210 + ContextMenuSub,
211 + ContextMenuSubContent,
212 + ContextMenuSubTrigger,
213 + ContextMenuRadioGroup,
214 +}
added client/src/components/ui/dialog.tsx +138 −0
@@ -0,0 +1,138 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/ui/dialog.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +"use client"
18 +
19 +import * as React from "react"
20 +import * as DialogPrimitive from "@radix-ui/react-dialog"
21 +import { X } from "lucide-react"
22 +
23 +import { cn } from "@/lib/utils"
24 +
25 +const Dialog = DialogPrimitive.Root
26 +
27 +const DialogTrigger = DialogPrimitive.Trigger
28 +
29 +const DialogPortal = DialogPrimitive.Portal
30 +
31 +const DialogClose = DialogPrimitive.Close
32 +
33 +const DialogOverlay = React.forwardRef<
34 + React.ElementRef<typeof DialogPrimitive.Overlay>,
35 + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
36 +>(({ className, ...props }, ref) => (
37 + <DialogPrimitive.Overlay
38 + ref={ref}
39 + className={cn(
40 + "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
41 + className
42 + )}
43 + {...props}
44 + />
45 +))
46 +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
47 +
48 +const DialogContent = React.forwardRef<
49 + React.ElementRef<typeof DialogPrimitive.Content>,
50 + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
51 +>(({ className, children, ...props }, ref) => (
52 + <DialogPortal>
53 + <DialogOverlay />
54 + <DialogPrimitive.Content
55 + ref={ref}
56 + className={cn(
57 + "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
58 + className
59 + )}
60 + {...props}
61 + >
62 + {children}
63 + <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
64 + <X className="h-4 w-4" />
65 + <span className="sr-only">Close</span>
66 + </DialogPrimitive.Close>
67 + </DialogPrimitive.Content>
68 + </DialogPortal>
69 +))
70 +DialogContent.displayName = DialogPrimitive.Content.displayName
71 +
72 +const DialogHeader = ({
73 + className,
74 + ...props
75 +}: React.HTMLAttributes<HTMLDivElement>) => (
76 + <div
77 + className={cn(
78 + "flex flex-col space-y-1.5 text-center sm:text-left",
79 + className
80 + )}
81 + {...props}
82 + />
83 +)
84 +DialogHeader.displayName = "DialogHeader"
85 +
86 +const DialogFooter = ({
87 + className,
88 + ...props
89 +}: React.HTMLAttributes<HTMLDivElement>) => (
90 + <div
91 + className={cn(
92 + "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
93 + className
94 + )}
95 + {...props}
96 + />
97 +)
98 +DialogFooter.displayName = "DialogFooter"
99 +
100 +const DialogTitle = React.forwardRef<
101 + React.ElementRef<typeof DialogPrimitive.Title>,
102 + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
103 +>(({ className, ...props }, ref) => (
104 + <DialogPrimitive.Title
105 + ref={ref}
106 + className={cn(
107 + "text-lg font-semibold leading-none tracking-tight",
108 + className
109 + )}
110 + {...props}
111 + />
112 +))
113 +DialogTitle.displayName = DialogPrimitive.Title.displayName
114 +
115 +const DialogDescription = React.forwardRef<
116 + React.ElementRef<typeof DialogPrimitive.Description>,
117 + React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
118 +>(({ className, ...props }, ref) => (
119 + <DialogPrimitive.Description
120 + ref={ref}
121 + className={cn("text-sm text-muted-foreground", className)}
122 + {...props}
123 + />
124 +))
125 +DialogDescription.displayName = DialogPrimitive.Description.displayName
126 +
127 +export {
128 + Dialog,
129 + DialogPortal,
130 + DialogOverlay,
131 + DialogClose,
132 + DialogTrigger,
133 + DialogContent,
134 + DialogHeader,
135 + DialogFooter,
136 + DialogTitle,
137 + DialogDescription,
138 +}
added client/src/components/ui/drawer.tsx +134 −0
@@ -0,0 +1,134 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/ui/drawer.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +"use client"
18 +
19 +import * as React from "react"
20 +import { Drawer as DrawerPrimitive } from "vaul"
21 +
22 +import { cn } from "@/lib/utils"
23 +
24 +const Drawer = ({
25 + shouldScaleBackground = true,
26 + ...props
27 +}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
28 + <DrawerPrimitive.Root
29 + shouldScaleBackground={shouldScaleBackground}
30 + {...props}
31 + />
32 +)
33 +Drawer.displayName = "Drawer"
34 +
35 +const DrawerTrigger = DrawerPrimitive.Trigger
36 +
37 +const DrawerPortal = DrawerPrimitive.Portal
38 +
39 +const DrawerClose = DrawerPrimitive.Close
40 +
41 +const DrawerOverlay = React.forwardRef<
42 + React.ElementRef<typeof DrawerPrimitive.Overlay>,
43 + React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
44 +>(({ className, ...props }, ref) => (
45 + <DrawerPrimitive.Overlay
46 + ref={ref}
47 + className={cn("fixed inset-0 z-50 bg-black/80", className)}
48 + {...props}
49 + />
50 +))
51 +DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
52 +
53 +const DrawerContent = React.forwardRef<
54 + React.ElementRef<typeof DrawerPrimitive.Content>,
55 + React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
56 +>(({ className, children, ...props }, ref) => (
57 + <DrawerPortal>
58 + <DrawerOverlay />
59 + <DrawerPrimitive.Content
60 + ref={ref}
61 + className={cn(
62 + "fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background",
63 + className
64 + )}
65 + {...props}
66 + >
67 + <div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
68 + {children}
69 + </DrawerPrimitive.Content>
70 + </DrawerPortal>
71 +))
72 +DrawerContent.displayName = "DrawerContent"
73 +
74 +const DrawerHeader = ({
75 + className,
76 + ...props
77 +}: React.HTMLAttributes<HTMLDivElement>) => (
78 + <div
79 + className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
80 + {...props}
81 + />
82 +)
83 +DrawerHeader.displayName = "DrawerHeader"
84 +
85 +const DrawerFooter = ({
86 + className,
87 + ...props
88 +}: React.HTMLAttributes<HTMLDivElement>) => (
89 + <div
90 + className={cn("mt-auto flex flex-col gap-2 p-4", className)}
91 + {...props}
92 + />
93 +)
94 +DrawerFooter.displayName = "DrawerFooter"
95 +
96 +const DrawerTitle = React.forwardRef<
97 + React.ElementRef<typeof DrawerPrimitive.Title>,
98 + React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
99 +>(({ className, ...props }, ref) => (
100 + <DrawerPrimitive.Title
101 + ref={ref}
102 + className={cn(
103 + "text-lg font-semibold leading-none tracking-tight",
104 + className
105 + )}
106 + {...props}
107 + />
108 +))
109 +DrawerTitle.displayName = DrawerPrimitive.Title.displayName
110 +
111 +const DrawerDescription = React.forwardRef<
112 + React.ElementRef<typeof DrawerPrimitive.Description>,
113 + React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
114 +>(({ className, ...props }, ref) => (
115 + <DrawerPrimitive.Description
116 + ref={ref}
117 + className={cn("text-sm text-muted-foreground", className)}
118 + {...props}
119 + />
120 +))
121 +DrawerDescription.displayName = DrawerPrimitive.Description.displayName
122 +
123 +export {
124 + Drawer,
125 + DrawerPortal,
126 + DrawerOverlay,
127 + DrawerTrigger,
128 + DrawerClose,
129 + DrawerContent,
130 + DrawerHeader,
131 + DrawerFooter,
132 + DrawerTitle,
133 + DrawerDescription,
134 +}
added client/src/components/ui/dropdown-menu.tsx +214 −0
@@ -0,0 +1,214 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/ui/dropdown-menu.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import * as React from "react"
18 +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
19 +import { Check, ChevronRight, Circle } from "lucide-react"
20 +
21 +import { cn } from "@/lib/utils"
22 +
23 +const DropdownMenu = DropdownMenuPrimitive.Root
24 +
25 +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
26 +
27 +const DropdownMenuGroup = DropdownMenuPrimitive.Group
28 +
29 +const DropdownMenuPortal = DropdownMenuPrimitive.Portal
30 +
31 +const DropdownMenuSub = DropdownMenuPrimitive.Sub
32 +
33 +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
34 +
35 +const DropdownMenuSubTrigger = React.forwardRef<
36 + React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
37 + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
38 + inset?: boolean
39 + }
40 +>(({ className, inset, children, ...props }, ref) => (
41 + <DropdownMenuPrimitive.SubTrigger
42 + ref={ref}
43 + className={cn(
44 + "flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
45 + inset && "pl-8",
46 + className
47 + )}
48 + {...props}
49 + >
50 + {children}
51 + <ChevronRight className="ml-auto" />
52 + </DropdownMenuPrimitive.SubTrigger>
53 +))
54 +DropdownMenuSubTrigger.displayName =
55 + DropdownMenuPrimitive.SubTrigger.displayName
56 +
57 +const DropdownMenuSubContent = React.forwardRef<
58 + React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
59 + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
60 +>(({ className, ...props }, ref) => (
61 + <DropdownMenuPrimitive.SubContent
62 + ref={ref}
63 + className={cn(
64 + "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
65 + className
66 + )}
67 + {...props}
68 + />
69 +))
70 +DropdownMenuSubContent.displayName =
71 + DropdownMenuPrimitive.SubContent.displayName
72 +
73 +const DropdownMenuContent = React.forwardRef<
74 + React.ElementRef<typeof DropdownMenuPrimitive.Content>,
75 + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
76 +>(({ className, sideOffset = 4, ...props }, ref) => (
77 + <DropdownMenuPrimitive.Portal>
78 + <DropdownMenuPrimitive.Content
79 + ref={ref}
80 + sideOffset={sideOffset}
81 + className={cn(
82 + "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
83 + className
84 + )}
85 + {...props}
86 + />
87 + </DropdownMenuPrimitive.Portal>
88 +))
89 +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
90 +
91 +const DropdownMenuItem = React.forwardRef<
92 + React.ElementRef<typeof DropdownMenuPrimitive.Item>,
93 + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
94 + inset?: boolean
95 + }
96 +>(({ className, inset, ...props }, ref) => (
97 + <DropdownMenuPrimitive.Item
98 + ref={ref}
99 + className={cn(
100 + "relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
101 + inset && "pl-8",
102 + className
103 + )}
104 + {...props}
105 + />
106 +))
107 +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
108 +
109 +const DropdownMenuCheckboxItem = React.forwardRef<
110 + React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
111 + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
112 +>(({ className, children, checked, ...props }, ref) => (
113 + <DropdownMenuPrimitive.CheckboxItem
114 + ref={ref}
115 + className={cn(
116 + "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
117 + className
118 + )}
119 + checked={checked}
120 + {...props}
121 + >
122 + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
123 + <DropdownMenuPrimitive.ItemIndicator>
124 + <Check className="h-4 w-4" />
125 + </DropdownMenuPrimitive.ItemIndicator>
126 + </span>
127 + {children}
128 + </DropdownMenuPrimitive.CheckboxItem>
129 +))
130 +DropdownMenuCheckboxItem.displayName =
131 + DropdownMenuPrimitive.CheckboxItem.displayName
132 +
133 +const DropdownMenuRadioItem = React.forwardRef<
134 + React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
135 + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
136 +>(({ className, children, ...props }, ref) => (
137 + <DropdownMenuPrimitive.RadioItem
138 + ref={ref}
139 + className={cn(
140 + "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
141 + className
142 + )}
143 + {...props}
144 + >
145 + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
146 + <DropdownMenuPrimitive.ItemIndicator>
147 + <Circle className="h-2 w-2 fill-current" />
148 + </DropdownMenuPrimitive.ItemIndicator>
149 + </span>
150 + {children}
151 + </DropdownMenuPrimitive.RadioItem>
152 +))
153 +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
154 +
155 +const DropdownMenuLabel = React.forwardRef<
156 + React.ElementRef<typeof DropdownMenuPrimitive.Label>,
157 + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
158 + inset?: boolean
159 + }
160 +>(({ className, inset, ...props }, ref) => (
161 + <DropdownMenuPrimitive.Label
162 + ref={ref}
163 + className={cn(
164 + "px-2 py-1.5 text-sm font-semibold",
165 + inset && "pl-8",
166 + className
167 + )}
168 + {...props}
169 + />
170 +))
171 +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
172 +
173 +const DropdownMenuSeparator = React.forwardRef<
174 + React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
175 + React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
176 +>(({ className, ...props }, ref) => (
177 + <DropdownMenuPrimitive.Separator
178 + ref={ref}
179 + className={cn("-mx-1 my-1 h-px bg-muted", className)}
180 + {...props}
181 + />
182 +))
183 +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
184 +
185 +const DropdownMenuShortcut = ({
186 + className,
187 + ...props
188 +}: React.HTMLAttributes<HTMLSpanElement>) => {
189 + return (
190 + <span
191 + className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
192 + {...props}
193 + />
194 + )
195 +}
196 +DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
197 +
198 +export {
199 + DropdownMenu,
200 + DropdownMenuTrigger,
201 + DropdownMenuContent,
202 + DropdownMenuItem,
203 + DropdownMenuCheckboxItem,
204 + DropdownMenuRadioItem,
205 + DropdownMenuLabel,
206 + DropdownMenuSeparator,
207 + DropdownMenuShortcut,
208 + DropdownMenuGroup,
209 + DropdownMenuPortal,
210 + DropdownMenuSub,
211 + DropdownMenuSubContent,
212 + DropdownMenuSubTrigger,
213 + DropdownMenuRadioGroup,
214 +}
added client/src/components/ui/form.tsx +194 −0
@@ -0,0 +1,194 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/ui/form.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +"use client"
18 +
19 +import * as React from "react"
20 +import * as LabelPrimitive from "@radix-ui/react-label"
21 +import { Slot } from "@radix-ui/react-slot"
22 +import {
23 + Controller,
24 + FormProvider,
25 + useFormContext,
26 + type ControllerProps,
27 + type FieldPath,
28 + type FieldValues,
29 +} from "react-hook-form"
30 +
31 +import { cn } from "@/lib/utils"
32 +import { Label } from "@/components/ui/label"
33 +
34 +const Form = FormProvider
35 +
36 +type FormFieldContextValue<
37 + TFieldValues extends FieldValues = FieldValues,
38 + TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
39 +> = {
40 + name: TName
41 +}
42 +
43 +const FormFieldContext = React.createContext<FormFieldContextValue>(
44 + {} as FormFieldContextValue
45 +)
46 +
47 +const FormField = <
48 + TFieldValues extends FieldValues = FieldValues,
49 + TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
50 +>({
51 + ...props
52 +}: ControllerProps<TFieldValues, TName>) => {
53 + return (
54 + <FormFieldContext.Provider value={{ name: props.name }}>
55 + <Controller {...props} />
56 + </FormFieldContext.Provider>
57 + )
58 +}
59 +
60 +const useFormField = () => {
61 + const fieldContext = React.useContext(FormFieldContext)
62 + const itemContext = React.useContext(FormItemContext)
63 + const { getFieldState, formState } = useFormContext()
64 +
65 + const fieldState = getFieldState(fieldContext.name, formState)
66 +
67 + if (!fieldContext) {
68 + throw new Error("useFormField should be used within <FormField>")
69 + }
70 +
71 + const { id } = itemContext
72 +
73 + return {
74 + id,
75 + name: fieldContext.name,
76 + formItemId: `${id}-form-item`,
77 + formDescriptionId: `${id}-form-item-description`,
78 + formMessageId: `${id}-form-item-message`,
79 + ...fieldState,
80 + }
81 +}
82 +
83 +type FormItemContextValue = {
84 + id: string
85 +}
86 +
87 +const FormItemContext = React.createContext<FormItemContextValue>(
88 + {} as FormItemContextValue
89 +)
90 +
91 +const FormItem = React.forwardRef<
92 + HTMLDivElement,
93 + React.HTMLAttributes<HTMLDivElement>
94 +>(({ className, ...props }, ref) => {
95 + const id = React.useId()
96 +
97 + return (
98 + <FormItemContext.Provider value={{ id }}>
99 + <div ref={ref} className={cn("space-y-2", className)} {...props} />
100 + </FormItemContext.Provider>
101 + )
102 +})
103 +FormItem.displayName = "FormItem"
104 +
105 +const FormLabel = React.forwardRef<
106 + React.ElementRef<typeof LabelPrimitive.Root>,
107 + React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
108 +>(({ className, ...props }, ref) => {
109 + const { error, formItemId } = useFormField()
110 +
111 + return (
112 + <Label
113 + ref={ref}
114 + className={cn(error && "text-destructive", className)}
115 + htmlFor={formItemId}
116 + {...props}
117 + />
118 + )
119 +})
120 +FormLabel.displayName = "FormLabel"
121 +
122 +const FormControl = React.forwardRef<
123 + React.ElementRef<typeof Slot>,
124 + React.ComponentPropsWithoutRef<typeof Slot>
125 +>(({ ...props }, ref) => {
126 + const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
127 +
128 + return (
129 + <Slot
130 + ref={ref}
131 + id={formItemId}
132 + aria-describedby={
133 + !error
134 + ? `${formDescriptionId}`
135 + : `${formDescriptionId} ${formMessageId}`
136 + }
137 + aria-invalid={!!error}
138 + {...props}
139 + />
140 + )
141 +})
142 +FormControl.displayName = "FormControl"
143 +
144 +const FormDescription = React.forwardRef<
145 + HTMLParagraphElement,
146 + React.HTMLAttributes<HTMLParagraphElement>
147 +>(({ className, ...props }, ref) => {
148 + const { formDescriptionId } = useFormField()
149 +
150 + return (
151 + <p
152 + ref={ref}
153 + id={formDescriptionId}
154 + className={cn("text-sm text-muted-foreground", className)}
155 + {...props}
156 + />
157 + )
158 +})
159 +FormDescription.displayName = "FormDescription"
160 +
161 +const FormMessage = React.forwardRef<
162 + HTMLParagraphElement,
163 + React.HTMLAttributes<HTMLParagraphElement>
164 +>(({ className, children, ...props }, ref) => {
165 + const { error, formMessageId } = useFormField()
166 + const body = error ? String(error?.message ?? "") : children
167 +
168 + if (!body) {
169 + return null
170 + }
171 +
172 + return (
173 + <p
174 + ref={ref}
175 + id={formMessageId}
176 + className={cn("text-sm font-medium text-destructive", className)}
177 + {...props}
178 + >
179 + {body}
180 + </p>
181 + )
182 +})
183 +FormMessage.displayName = "FormMessage"
184 +
185 +export {
186 + useFormField,
187 + Form,
188 + FormItem,
189 + FormLabel,
190 + FormControl,
191 + FormDescription,
192 + FormMessage,
193 + FormField,
194 +}
added client/src/components/ui/hover-card.tsx +45 −0
@@ -0,0 +1,45 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/ui/hover-card.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +"use client"
18 +
19 +import * as React from "react"
20 +import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
21 +
22 +import { cn } from "@/lib/utils"
23 +
24 +const HoverCard = HoverCardPrimitive.Root
25 +
26 +const HoverCardTrigger = HoverCardPrimitive.Trigger
27 +
28 +const HoverCardContent = React.forwardRef<
29 + React.ElementRef<typeof HoverCardPrimitive.Content>,
30 + React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
31 +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
32 + <HoverCardPrimitive.Content
33 + ref={ref}
34 + align={align}
35 + sideOffset={sideOffset}
36 + className={cn(
37 + "z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-hover-card-content-transform-origin]",
38 + className
39 + )}
40 + {...props}
41 + />
42 +))
43 +HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
44 +
45 +export { HoverCard, HoverCardTrigger, HoverCardContent }
added client/src/components/ui/input-otp.tsx +85 −0
@@ -0,0 +1,85 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/ui/input-otp.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import * as React from "react"
18 +import { OTPInput, OTPInputContext } from "input-otp"
19 +import { Dot } from "lucide-react"
20 +
21 +import { cn } from "@/lib/utils"
22 +
23 +const InputOTP = React.forwardRef<
24 + React.ElementRef<typeof OTPInput>,
25 + React.ComponentPropsWithoutRef<typeof OTPInput>
26 +>(({ className, containerClassName, ...props }, ref) => (
27 + <OTPInput
28 + ref={ref}
29 + containerClassName={cn(
30 + "flex items-center gap-2 has-[:disabled]:opacity-50",
31 + containerClassName
32 + )}
33 + className={cn("disabled:cursor-not-allowed", className)}
34 + {...props}
35 + />
36 +))
37 +InputOTP.displayName = "InputOTP"
38 +
39 +const InputOTPGroup = React.forwardRef<
40 + React.ElementRef<"div">,
41 + React.ComponentPropsWithoutRef<"div">
42 +>(({ className, ...props }, ref) => (
43 + <div ref={ref} className={cn("flex items-center", className)} {...props} />
44 +))
45 +InputOTPGroup.displayName = "InputOTPGroup"
46 +
47 +const InputOTPSlot = React.forwardRef<
48 + React.ElementRef<"div">,
49 + React.ComponentPropsWithoutRef<"div"> & { index: number }
50 +>(({ index, className, ...props }, ref) => {
51 + const inputOTPContext = React.useContext(OTPInputContext)
52 + const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
53 +
54 + return (
55 + <div
56 + ref={ref}
57 + className={cn(
58 + "relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
59 + isActive && "z-10 ring-2 ring-ring ring-offset-background",
60 + className
61 + )}
62 + {...props}
63 + >
64 + {char}
65 + {hasFakeCaret && (
66 + <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
67 + <div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
68 + </div>
69 + )}
70 + </div>
71 + )
72 +})
73 +InputOTPSlot.displayName = "InputOTPSlot"
74 +
75 +const InputOTPSeparator = React.forwardRef<
76 + React.ElementRef<"div">,
77 + React.ComponentPropsWithoutRef<"div">
78 +>(({ ...props }, ref) => (
79 + <div ref={ref} role="separator" {...props}>
80 + <Dot />
81 + </div>
82 +))
83 +InputOTPSeparator.displayName = "InputOTPSeparator"
84 +
85 +export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
added client/src/components/ui/input.tsx +39 −0
@@ -0,0 +1,39 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/ui/input.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import * as React from "react"
18 +
19 +import { cn } from "@/lib/utils"
20 +
21 +const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
22 + ({ className, type, ...props }, ref) => {
23 + // h-9 to match icon buttons and default buttons.
24 + return (
25 + <input
26 + type={type}
27 + className={cn(
28 + "flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
29 + className
30 + )}
31 + ref={ref}
32 + {...props}
33 + />
34 + )
35 + }
36 +)
37 +Input.displayName = "Input"
38 +
39 +export { Input }
added client/src/components/ui/label.tsx +40 −0
@@ -0,0 +1,40 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/ui/label.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import * as React from "react"
18 +import * as LabelPrimitive from "@radix-ui/react-label"
19 +import { cva, type VariantProps } from "class-variance-authority"
20 +
21 +import { cn } from "@/lib/utils"
22 +
23 +const labelVariants = cva(
24 + "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
25 +)
26 +
27 +const Label = React.forwardRef<
28 + React.ElementRef<typeof LabelPrimitive.Root>,
29 + React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
30 + VariantProps<typeof labelVariants>
31 +>(({ className, ...props }, ref) => (
32 + <LabelPrimitive.Root
33 + ref={ref}
34 + className={cn(labelVariants(), className)}
35 + {...props}
36 + />
37 +))
38 +Label.displayName = LabelPrimitive.Root.displayName
39 +
40 +export { Label }
added client/src/components/ui/menubar.tsx +272 −0
@@ -0,0 +1,272 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/ui/menubar.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +"use client"
18 +
19 +import * as React from "react"
20 +import * as MenubarPrimitive from "@radix-ui/react-menubar"
21 +import { Check, ChevronRight, Circle } from "lucide-react"
22 +
23 +import { cn } from "@/lib/utils"
24 +
25 +function MenubarMenu({
26 + ...props
27 +}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
28 + return <MenubarPrimitive.Menu {...props} />
29 +}
30 +
31 +function MenubarGroup({
32 + ...props
33 +}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
34 + return <MenubarPrimitive.Group {...props} />
35 +}
36 +
37 +function MenubarPortal({
38 + ...props
39 +}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
40 + return <MenubarPrimitive.Portal {...props} />
41 +}
42 +
43 +function MenubarRadioGroup({
44 + ...props
45 +}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
46 + return <MenubarPrimitive.RadioGroup {...props} />
47 +}
48 +
49 +function MenubarSub({
50 + ...props
51 +}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
52 + return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />
53 +}
54 +
55 +const Menubar = React.forwardRef<
56 + React.ElementRef<typeof MenubarPrimitive.Root>,
57 + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
58 +>(({ className, ...props }, ref) => (
59 + <MenubarPrimitive.Root
60 + ref={ref}
61 + className={cn(
62 + "flex h-10 items-center space-x-1 rounded-md border bg-background p-1",
63 + className
64 + )}
65 + {...props}
66 + />
67 +))
68 +Menubar.displayName = MenubarPrimitive.Root.displayName
69 +
70 +const MenubarTrigger = React.forwardRef<
71 + React.ElementRef<typeof MenubarPrimitive.Trigger>,
72 + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
73 +>(({ className, ...props }, ref) => (
74 + <MenubarPrimitive.Trigger
75 + ref={ref}
76 + className={cn(
77 + "flex cursor-default select-none items-center rounded-sm px-3 py-1.5 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
78 + className
79 + )}
80 + {...props}
81 + />
82 +))
83 +MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName
84 +
85 +const MenubarSubTrigger = React.forwardRef<
86 + React.ElementRef<typeof MenubarPrimitive.SubTrigger>,
87 + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & {
88 + inset?: boolean
89 + }
90 +>(({ className, inset, children, ...props }, ref) => (
91 + <MenubarPrimitive.SubTrigger
92 + ref={ref}
93 + className={cn(
94 + "flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
95 + inset && "pl-8",
96 + className
97 + )}
98 + {...props}
99 + >
100 + {children}
101 + <ChevronRight className="ml-auto h-4 w-4" />
102 + </MenubarPrimitive.SubTrigger>
103 +))
104 +MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName
105 +
106 +const MenubarSubContent = React.forwardRef<
107 + React.ElementRef<typeof MenubarPrimitive.SubContent>,
108 + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
109 +>(({ className, ...props }, ref) => (
110 + <MenubarPrimitive.SubContent
111 + ref={ref}
112 + className={cn(
113 + "z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-menubar-content-transform-origin]",
114 + className
115 + )}
116 + {...props}
117 + />
118 +))
119 +MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName
120 +
121 +const MenubarContent = React.forwardRef<
122 + React.ElementRef<typeof MenubarPrimitive.Content>,
123 + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
124 +>(
125 + (
126 + { className, align = "start", alignOffset = -4, sideOffset = 8, ...props },
127 + ref
128 + ) => (
129 + <MenubarPrimitive.Portal>
130 + <MenubarPrimitive.Content
131 + ref={ref}
132 + align={align}
133 + alignOffset={alignOffset}
134 + sideOffset={sideOffset}
135 + className={cn(
136 + "z-50 min-w-[12rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-menubar-content-transform-origin]",
137 + className
138 + )}
139 + {...props}
140 + />
141 + </MenubarPrimitive.Portal>
142 + )
143 +)
144 +MenubarContent.displayName = MenubarPrimitive.Content.displayName
145 +
146 +const MenubarItem = React.forwardRef<
147 + React.ElementRef<typeof MenubarPrimitive.Item>,
148 + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & {
149 + inset?: boolean
150 + }
151 +>(({ className, inset, ...props }, ref) => (
152 + <MenubarPrimitive.Item
153 + ref={ref}
154 + className={cn(
155 + "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
156 + inset && "pl-8",
157 + className
158 + )}
159 + {...props}
160 + />
161 +))
162 +MenubarItem.displayName = MenubarPrimitive.Item.displayName
163 +
164 +const MenubarCheckboxItem = React.forwardRef<
165 + React.ElementRef<typeof MenubarPrimitive.CheckboxItem>,
166 + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.CheckboxItem>
167 +>(({ className, children, checked, ...props }, ref) => (
168 + <MenubarPrimitive.CheckboxItem
169 + ref={ref}
170 + className={cn(
171 + "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
172 + className
173 + )}
174 + checked={checked}
175 + {...props}
176 + >
177 + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
178 + <MenubarPrimitive.ItemIndicator>
179 + <Check className="h-4 w-4" />
180 + </MenubarPrimitive.ItemIndicator>
181 + </span>
182 + {children}
183 + </MenubarPrimitive.CheckboxItem>
184 +))
185 +MenubarCheckboxItem.displayName = MenubarPrimitive.CheckboxItem.displayName
186 +
187 +const MenubarRadioItem = React.forwardRef<
188 + React.ElementRef<typeof MenubarPrimitive.RadioItem>,
189 + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.RadioItem>
190 +>(({ className, children, ...props }, ref) => (
191 + <MenubarPrimitive.RadioItem
192 + ref={ref}
193 + className={cn(
194 + "relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
195 + className
196 + )}
197 + {...props}
198 + >
199 + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
200 + <MenubarPrimitive.ItemIndicator>
201 + <Circle className="h-2 w-2 fill-current" />
202 + </MenubarPrimitive.ItemIndicator>
203 + </span>
204 + {children}
205 + </MenubarPrimitive.RadioItem>
206 +))
207 +MenubarRadioItem.displayName = MenubarPrimitive.RadioItem.displayName
208 +
209 +const MenubarLabel = React.forwardRef<
210 + React.ElementRef<typeof MenubarPrimitive.Label>,
211 + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Label> & {
212 + inset?: boolean
213 + }
214 +>(({ className, inset, ...props }, ref) => (
215 + <MenubarPrimitive.Label
216 + ref={ref}
217 + className={cn(
218 + "px-2 py-1.5 text-sm font-semibold",
219 + inset && "pl-8",
220 + className
221 + )}
222 + {...props}
223 + />
224 +))
225 +MenubarLabel.displayName = MenubarPrimitive.Label.displayName
226 +
227 +const MenubarSeparator = React.forwardRef<
228 + React.ElementRef<typeof MenubarPrimitive.Separator>,
229 + React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
230 +>(({ className, ...props }, ref) => (
231 + <MenubarPrimitive.Separator
232 + ref={ref}
233 + className={cn("-mx-1 my-1 h-px bg-muted", className)}
234 + {...props}
235 + />
236 +))
237 +MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName
238 +
239 +const MenubarShortcut = ({
240 + className,
241 + ...props
242 +}: React.HTMLAttributes<HTMLSpanElement>) => {
243 + return (
244 + <span
245 + className={cn(
246 + "ml-auto text-xs tracking-widest text-muted-foreground",
247 + className
248 + )}
249 + {...props}
250 + />
251 + )
252 +}
253 +MenubarShortcut.displayname = "MenubarShortcut"
254 +
255 +export {
256 + Menubar,
257 + MenubarMenu,
258 + MenubarTrigger,
259 + MenubarContent,
260 + MenubarItem,
261 + MenubarSeparator,
262 + MenubarLabel,
263 + MenubarCheckboxItem,
264 + MenubarRadioGroup,
265 + MenubarRadioItem,
266 + MenubarPortal,
267 + MenubarSubContent,
268 + MenubarSubTrigger,
269 + MenubarGroup,
270 + MenubarSub,
271 + MenubarShortcut,
272 +}
added client/src/components/ui/navigation-menu.tsx +144 −0
@@ -0,0 +1,144 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/ui/navigation-menu.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import * as React from "react"
18 +import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
19 +import { cva } from "class-variance-authority"
20 +import { ChevronDown } from "lucide-react"
21 +
22 +import { cn } from "@/lib/utils"
23 +
24 +const NavigationMenu = React.forwardRef<
25 + React.ElementRef<typeof NavigationMenuPrimitive.Root>,
26 + React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
27 +>(({ className, children, ...props }, ref) => (
28 + <NavigationMenuPrimitive.Root
29 + ref={ref}
30 + className={cn(
31 + "relative z-10 flex max-w-max flex-1 items-center justify-center",
32 + className
33 + )}
34 + {...props}
35 + >
36 + {children}
37 + <NavigationMenuViewport />
38 + </NavigationMenuPrimitive.Root>
39 +))
40 +NavigationMenu.displayName = NavigationMenuPrimitive.Root.displayName
41 +
42 +const NavigationMenuList = React.forwardRef<
43 + React.ElementRef<typeof NavigationMenuPrimitive.List>,
44 + React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.List>
45 +>(({ className, ...props }, ref) => (
46 + <NavigationMenuPrimitive.List
47 + ref={ref}
48 + className={cn(
49 + "group flex flex-1 list-none items-center justify-center space-x-1",
50 + className
51 + )}
52 + {...props}
53 + />
54 +))
55 +NavigationMenuList.displayName = NavigationMenuPrimitive.List.displayName
56 +
57 +const NavigationMenuItem = NavigationMenuPrimitive.Item
58 +
59 +const navigationMenuTriggerStyle = cva(
60 + "group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=open]:text-accent-foreground data-[state=open]:bg-accent/50 data-[state=open]:hover:bg-accent data-[state=open]:focus:bg-accent"
61 +)
62 +
63 +const NavigationMenuTrigger = React.forwardRef<
64 + React.ElementRef<typeof NavigationMenuPrimitive.Trigger>,
65 + React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
66 +>(({ className, children, ...props }, ref) => (
67 + <NavigationMenuPrimitive.Trigger
68 + ref={ref}
69 + className={cn(navigationMenuTriggerStyle(), "group", className)}
70 + {...props}
71 + >
72 + {children}{" "}
73 + <ChevronDown
74 + className="relative top-[1px] ml-1 h-3 w-3 transition duration-200 group-data-[state=open]:rotate-180"
75 + aria-hidden="true"
76 + />
77 + </NavigationMenuPrimitive.Trigger>
78 +))
79 +NavigationMenuTrigger.displayName = NavigationMenuPrimitive.Trigger.displayName
80 +
81 +const NavigationMenuContent = React.forwardRef<
82 + React.ElementRef<typeof NavigationMenuPrimitive.Content>,
83 + React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Content>
84 +>(({ className, ...props }, ref) => (
85 + <NavigationMenuPrimitive.Content
86 + ref={ref}
87 + className={cn(
88 + "left-0 top-0 w-full data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 md:absolute md:w-auto ",
89 + className
90 + )}
91 + {...props}
92 + />
93 +))
94 +NavigationMenuContent.displayName = NavigationMenuPrimitive.Content.displayName
95 +
96 +const NavigationMenuLink = NavigationMenuPrimitive.Link
97 +
98 +const NavigationMenuViewport = React.forwardRef<
99 + React.ElementRef<typeof NavigationMenuPrimitive.Viewport>,
100 + React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Viewport>
101 +>(({ className, ...props }, ref) => (
102 + <div className={cn("absolute left-0 top-full flex justify-center")}>
103 + <NavigationMenuPrimitive.Viewport
104 + className={cn(
105 + "origin-top-center relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 md:w-[var(--radix-navigation-menu-viewport-width)]",
106 + className
107 + )}
108 + ref={ref}
109 + {...props}
110 + />
111 + </div>
112 +))
113 +NavigationMenuViewport.displayName =
114 + NavigationMenuPrimitive.Viewport.displayName
115 +
116 +const NavigationMenuIndicator = React.forwardRef<
117 + React.ElementRef<typeof NavigationMenuPrimitive.Indicator>,
118 + React.ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Indicator>
119 +>(({ className, ...props }, ref) => (
120 + <NavigationMenuPrimitive.Indicator
121 + ref={ref}
122 + className={cn(
123 + "top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
124 + className
125 + )}
126 + {...props}
127 + >
128 + <div className="relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm bg-border shadow-md" />
129 + </NavigationMenuPrimitive.Indicator>
130 +))
131 +NavigationMenuIndicator.displayName =
132 + NavigationMenuPrimitive.Indicator.displayName
133 +
134 +export {
135 + navigationMenuTriggerStyle,
136 + NavigationMenu,
137 + NavigationMenuList,
138 + NavigationMenuItem,
139 + NavigationMenuContent,
140 + NavigationMenuTrigger,
141 + NavigationMenuLink,
142 + NavigationMenuIndicator,
143 + NavigationMenuViewport,
144 +}
added client/src/components/ui/pagination.tsx +133 −0
@@ -0,0 +1,133 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/ui/pagination.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import * as React from "react"
18 +import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
19 +
20 +import { cn } from "@/lib/utils"
21 +import { ButtonProps, buttonVariants } from "@/components/ui/button"
22 +
23 +const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
24 + <nav
25 + role="navigation"
26 + aria-label="pagination"
27 + className={cn("mx-auto flex w-full justify-center", className)}
28 + {...props}
29 + />
30 +)
31 +Pagination.displayName = "Pagination"
32 +
33 +const PaginationContent = React.forwardRef<
34 + HTMLUListElement,
35 + React.ComponentProps<"ul">
36 +>(({ className, ...props }, ref) => (
37 + <ul
38 + ref={ref}
39 + className={cn("flex flex-row items-center gap-1", className)}
40 + {...props}
41 + />
42 +))
43 +PaginationContent.displayName = "PaginationContent"
44 +
45 +const PaginationItem = React.forwardRef<
46 + HTMLLIElement,
47 + React.ComponentProps<"li">
48 +>(({ className, ...props }, ref) => (
49 + <li ref={ref} className={cn("", className)} {...props} />
50 +))
51 +PaginationItem.displayName = "PaginationItem"
52 +
53 +type PaginationLinkProps = {
54 + isActive?: boolean
55 +} & Pick<ButtonProps, "size"> &
56 + React.ComponentProps<"a">
57 +
58 +const PaginationLink = ({
59 + className,
60 + isActive,
61 + size = "icon",
62 + ...props
63 +}: PaginationLinkProps) => (
64 + <a
65 + aria-current={isActive ? "page" : undefined}
66 + className={cn(
67 + buttonVariants({
68 + variant: isActive ? "outline" : "ghost",
69 + size,
70 + }),
71 + className
72 + )}
73 + {...props}
74 + />
75 +)
76 +PaginationLink.displayName = "PaginationLink"
77 +
78 +const PaginationPrevious = ({
79 + className,
80 + ...props
81 +}: React.ComponentProps<typeof PaginationLink>) => (
82 + <PaginationLink
83 + aria-label="Go to previous page"
84 + size="default"
85 + className={cn("gap-1 pl-2.5", className)}
86 + {...props}
87 + >
88 + <ChevronLeft className="h-4 w-4" />
89 + <span>Previous</span>
90 + </PaginationLink>
91 +)
92 +PaginationPrevious.displayName = "PaginationPrevious"
93 +
94 +const PaginationNext = ({
95 + className,
96 + ...props
97 +}: React.ComponentProps<typeof PaginationLink>) => (
98 + <PaginationLink
99 + aria-label="Go to next page"
100 + size="default"
101 + className={cn("gap-1 pr-2.5", className)}
102 + {...props}
103 + >
104 + <span>Next</span>
105 + <ChevronRight className="h-4 w-4" />
106 + </PaginationLink>
107 +)
108 +PaginationNext.displayName = "PaginationNext"
109 +
110 +const PaginationEllipsis = ({
111 + className,
112 + ...props
113 +}: React.ComponentProps<"span">) => (
114 + <span
115 + aria-hidden
116 + className={cn("flex h-9 w-9 items-center justify-center", className)}
117 + {...props}
118 + >
119 + <MoreHorizontal className="h-4 w-4" />
120 + <span className="sr-only">More pages</span>
121 + </span>
122 +)
123 +PaginationEllipsis.displayName = "PaginationEllipsis"
124 +
125 +export {
126 + Pagination,
127 + PaginationContent,
128 + PaginationEllipsis,
129 + PaginationItem,
130 + PaginationLink,
131 + PaginationNext,
132 + PaginationPrevious,
133 +}
added client/src/components/ui/popover.tsx +45 −0
@@ -0,0 +1,45 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/ui/popover.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import * as React from "react"
18 +import * as PopoverPrimitive from "@radix-ui/react-popover"
19 +
20 +import { cn } from "@/lib/utils"
21 +
22 +const Popover = PopoverPrimitive.Root
23 +
24 +const PopoverTrigger = PopoverPrimitive.Trigger
25 +
26 +const PopoverContent = React.forwardRef<
27 + React.ElementRef<typeof PopoverPrimitive.Content>,
28 + React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
29 +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
30 + <PopoverPrimitive.Portal>
31 + <PopoverPrimitive.Content
32 + ref={ref}
33 + align={align}
34 + sideOffset={sideOffset}
35 + className={cn(
36 + "z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
37 + className
38 + )}
39 + {...props}
40 + />
41 + </PopoverPrimitive.Portal>
42 +))
43 +PopoverContent.displayName = PopoverPrimitive.Content.displayName
44 +
45 +export { Popover, PopoverTrigger, PopoverContent }
added client/src/components/ui/progress.tsx +44 −0
@@ -0,0 +1,44 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/ui/progress.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +"use client"
18 +
19 +import * as React from "react"
20 +import * as ProgressPrimitive from "@radix-ui/react-progress"
21 +
22 +import { cn } from "@/lib/utils"
23 +
24 +const Progress = React.forwardRef<
25 + React.ElementRef<typeof ProgressPrimitive.Root>,
26 + React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
27 +>(({ className, value, ...props }, ref) => (
28 + <ProgressPrimitive.Root
29 + ref={ref}
30 + className={cn(
31 + "relative h-4 w-full overflow-hidden rounded-full bg-secondary",
32 + className
33 + )}
34 + {...props}
35 + >
36 + <ProgressPrimitive.Indicator
37 + className="h-full w-full flex-1 bg-primary transition-all"
38 + style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
39 + />
40 + </ProgressPrimitive.Root>
41 +))
42 +Progress.displayName = ProgressPrimitive.Root.displayName
43 +
44 +export { Progress }
added client/src/components/ui/radio-group.tsx +58 −0
@@ -0,0 +1,58 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/ui/radio-group.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import * as React from "react"
18 +import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
19 +import { Circle } from "lucide-react"
20 +
21 +import { cn } from "@/lib/utils"
22 +
23 +const RadioGroup = React.forwardRef<
24 + React.ElementRef<typeof RadioGroupPrimitive.Root>,
25 + React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
26 +>(({ className, ...props }, ref) => {
27 + return (
28 + <RadioGroupPrimitive.Root
29 + className={cn("grid gap-2", className)}
30 + {...props}
31 + ref={ref}
32 + />
33 + )
34 +})
35 +RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
36 +
37 +const RadioGroupItem = React.forwardRef<
38 + React.ElementRef<typeof RadioGroupPrimitive.Item>,
39 + React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
40 +>(({ className, ...props }, ref) => {
41 + return (
42 + <RadioGroupPrimitive.Item
43 + ref={ref}
44 + className={cn(
45 + "aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
46 + className
47 + )}
48 + {...props}
49 + >
50 + <RadioGroupPrimitive.Indicator className="flex items-center justify-center">
51 + <Circle className="h-2.5 w-2.5 fill-current text-current" />
52 + </RadioGroupPrimitive.Indicator>
53 + </RadioGroupPrimitive.Item>
54 + )
55 +})
56 +RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
57 +
58 +export { RadioGroup, RadioGroupItem }
added client/src/components/ui/resizable.tsx +61 −0
@@ -0,0 +1,61 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/ui/resizable.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +"use client"
18 +
19 +import { GripVertical } from "lucide-react"
20 +import * as ResizablePrimitive from "react-resizable-panels"
21 +
22 +import { cn } from "@/lib/utils"
23 +
24 +const ResizablePanelGroup = ({
25 + className,
26 + ...props
27 +}: React.ComponentProps<typeof ResizablePrimitive.PanelGroup>) => (
28 + <ResizablePrimitive.PanelGroup
29 + className={cn(
30 + "flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
31 + className
32 + )}
33 + {...props}
34 + />
35 +)
36 +
37 +const ResizablePanel = ResizablePrimitive.Panel
38 +
39 +const ResizableHandle = ({
40 + withHandle,
41 + className,
42 + ...props
43 +}: React.ComponentProps<typeof ResizablePrimitive.PanelResizeHandle> & {
44 + withHandle?: boolean
45 +}) => (
46 + <ResizablePrimitive.PanelResizeHandle
47 + className={cn(
48 + "relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 [&[data-panel-group-direction=vertical]>div]:rotate-90",
49 + className
50 + )}
51 + {...props}
52 + >
53 + {withHandle && (
54 + <div className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
55 + <GripVertical className="h-2.5 w-2.5" />
56 + </div>
57 + )}
58 + </ResizablePrimitive.PanelResizeHandle>
59 +)
60 +
61 +export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
added client/src/components/ui/scroll-area.tsx +62 −0
@@ -0,0 +1,62 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/ui/scroll-area.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import * as React from "react"
18 +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
19 +
20 +import { cn } from "@/lib/utils"
21 +
22 +const ScrollArea = React.forwardRef<
23 + React.ElementRef<typeof ScrollAreaPrimitive.Root>,
24 + React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
25 +>(({ className, children, ...props }, ref) => (
26 + <ScrollAreaPrimitive.Root
27 + ref={ref}
28 + className={cn("relative overflow-hidden", className)}
29 + {...props}
30 + >
31 + <ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
32 + {children}
33 + </ScrollAreaPrimitive.Viewport>
34 + <ScrollBar />
35 + <ScrollAreaPrimitive.Corner />
36 + </ScrollAreaPrimitive.Root>
37 +))
38 +ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
39 +
40 +const ScrollBar = React.forwardRef<
41 + React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
42 + React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
43 +>(({ className, orientation = "vertical", ...props }, ref) => (
44 + <ScrollAreaPrimitive.ScrollAreaScrollbar
45 + ref={ref}
46 + orientation={orientation}
47 + className={cn(
48 + "flex touch-none select-none transition-colors",
49 + orientation === "vertical" &&
50 + "h-full w-2.5 border-l border-l-transparent p-[1px]",
51 + orientation === "horizontal" &&
52 + "h-2.5 flex-col border-t border-t-transparent p-[1px]",
53 + className
54 + )}
55 + {...props}
56 + >
57 + <ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
58 + </ScrollAreaPrimitive.ScrollAreaScrollbar>
59 +))
60 +ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
61 +
62 +export { ScrollArea, ScrollBar }
added client/src/components/ui/select.tsx +176 −0
@@ -0,0 +1,176 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/ui/select.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +"use client"
18 +
19 +import * as React from "react"
20 +import * as SelectPrimitive from "@radix-ui/react-select"
21 +import { Check, ChevronDown, ChevronUp } from "lucide-react"
22 +
23 +import { cn } from "@/lib/utils"
24 +
25 +const Select = SelectPrimitive.Root
26 +
27 +const SelectGroup = SelectPrimitive.Group
28 +
29 +const SelectValue = SelectPrimitive.Value
30 +
31 +const SelectTrigger = React.forwardRef<
32 + React.ElementRef<typeof SelectPrimitive.Trigger>,
33 + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
34 +>(({ className, children, ...props }, ref) => (
35 + <SelectPrimitive.Trigger
36 + ref={ref}
37 + className={cn(
38 + "flex h-9 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
39 + className
40 + )}
41 + {...props}
42 + >
43 + {children}
44 + <SelectPrimitive.Icon asChild>
45 + <ChevronDown className="h-4 w-4 opacity-50" />
46 + </SelectPrimitive.Icon>
47 + </SelectPrimitive.Trigger>
48 +))
49 +SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
50 +
51 +const SelectScrollUpButton = React.forwardRef<
52 + React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
53 + React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
54 +>(({ className, ...props }, ref) => (
55 + <SelectPrimitive.ScrollUpButton
56 + ref={ref}
57 + className={cn(
58 + "flex cursor-default items-center justify-center py-1",
59 + className
60 + )}
61 + {...props}
62 + >
63 + <ChevronUp className="h-4 w-4" />
64 + </SelectPrimitive.ScrollUpButton>
65 +))
66 +SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
67 +
68 +const SelectScrollDownButton = React.forwardRef<
69 + React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
70 + React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
71 +>(({ className, ...props }, ref) => (
72 + <SelectPrimitive.ScrollDownButton
73 + ref={ref}
74 + className={cn(
75 + "flex cursor-default items-center justify-center py-1",
76 + className
77 + )}
78 + {...props}
79 + >
80 + <ChevronDown className="h-4 w-4" />
81 + </SelectPrimitive.ScrollDownButton>
82 +))
83 +SelectScrollDownButton.displayName =
84 + SelectPrimitive.ScrollDownButton.displayName
85 +
86 +const SelectContent = React.forwardRef<
87 + React.ElementRef<typeof SelectPrimitive.Content>,
88 + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
89 +>(({ className, children, position = "popper", ...props }, ref) => (
90 + <SelectPrimitive.Portal>
91 + <SelectPrimitive.Content
92 + ref={ref}
93 + className={cn(
94 + "relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
95 + position === "popper" &&
96 + "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
97 + className
98 + )}
99 + position={position}
100 + {...props}
101 + >
102 + <SelectScrollUpButton />
103 + <SelectPrimitive.Viewport
104 + className={cn(
105 + "p-1",
106 + position === "popper" &&
107 + "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
108 + )}
109 + >
110 + {children}
111 + </SelectPrimitive.Viewport>
112 + <SelectScrollDownButton />
113 + </SelectPrimitive.Content>
114 + </SelectPrimitive.Portal>
115 +))
116 +SelectContent.displayName = SelectPrimitive.Content.displayName
117 +
118 +const SelectLabel = React.forwardRef<
119 + React.ElementRef<typeof SelectPrimitive.Label>,
120 + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
121 +>(({ className, ...props }, ref) => (
122 + <SelectPrimitive.Label
123 + ref={ref}
124 + className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
125 + {...props}
126 + />
127 +))
128 +SelectLabel.displayName = SelectPrimitive.Label.displayName
129 +
130 +const SelectItem = React.forwardRef<
131 + React.ElementRef<typeof SelectPrimitive.Item>,
132 + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
133 +>(({ className, children, ...props }, ref) => (
134 + <SelectPrimitive.Item
135 + ref={ref}
136 + className={cn(
137 + "group relative flex w-full cursor-pointer select-none items-center rounded-lg py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent/70 hover:bg-accent/50 focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 transition-all duration-200",
138 + className
139 + )}
140 + {...props}
141 + >
142 + <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
143 + <SelectPrimitive.ItemIndicator>
144 + <Check className="h-4 w-4 text-primary" />
145 + </SelectPrimitive.ItemIndicator>
146 + </span>
147 +
148 + <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
149 + </SelectPrimitive.Item>
150 +))
151 +SelectItem.displayName = SelectPrimitive.Item.displayName
152 +
153 +const SelectSeparator = React.forwardRef<
154 + React.ElementRef<typeof SelectPrimitive.Separator>,
155 + React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
156 +>(({ className, ...props }, ref) => (
157 + <SelectPrimitive.Separator
158 + ref={ref}
159 + className={cn("-mx-1 my-1 h-px bg-muted", className)}
160 + {...props}
161 + />
162 +))
163 +SelectSeparator.displayName = SelectPrimitive.Separator.displayName
164 +
165 +export {
166 + Select,
167 + SelectGroup,
168 + SelectValue,
169 + SelectTrigger,
170 + SelectContent,
171 + SelectLabel,
172 + SelectItem,
173 + SelectSeparator,
174 + SelectScrollUpButton,
175 + SelectScrollDownButton,
176 +}
added client/src/components/ui/separator.tsx +45 −0
@@ -0,0 +1,45 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/ui/separator.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import * as React from "react"
18 +import * as SeparatorPrimitive from "@radix-ui/react-separator"
19 +
20 +import { cn } from "@/lib/utils"
21 +
22 +const Separator = React.forwardRef<
23 + React.ElementRef<typeof SeparatorPrimitive.Root>,
24 + React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
25 +>(
26 + (
27 + { className, orientation = "horizontal", decorative = true, ...props },
28 + ref
29 + ) => (
30 + <SeparatorPrimitive.Root
31 + ref={ref}
32 + decorative={decorative}
33 + orientation={orientation}
34 + className={cn(
35 + "shrink-0 bg-border",
36 + orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
37 + className
38 + )}
39 + {...props}
40 + />
41 + )
42 +)
43 +Separator.displayName = SeparatorPrimitive.Root.displayName
44 +
45 +export { Separator }
added client/src/components/ui/sheet.tsx +156 −0
@@ -0,0 +1,156 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/ui/sheet.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +"use client"
18 +
19 +import * as React from "react"
20 +import * as SheetPrimitive from "@radix-ui/react-dialog"
21 +import { cva, type VariantProps } from "class-variance-authority"
22 +import { X } from "lucide-react"
23 +
24 +import { cn } from "@/lib/utils"
25 +
26 +const Sheet = SheetPrimitive.Root
27 +
28 +const SheetTrigger = SheetPrimitive.Trigger
29 +
30 +const SheetClose = SheetPrimitive.Close
31 +
32 +const SheetPortal = SheetPrimitive.Portal
33 +
34 +const SheetOverlay = React.forwardRef<
35 + React.ElementRef<typeof SheetPrimitive.Overlay>,
36 + React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
37 +>(({ className, ...props }, ref) => (
38 + <SheetPrimitive.Overlay
39 + className={cn(
40 + "fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
41 + className
42 + )}
43 + {...props}
44 + ref={ref}
45 + />
46 +))
47 +SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
48 +
49 +const sheetVariants = cva(
50 + "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
51 + {
52 + variants: {
53 + side: {
54 + top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
55 + bottom:
56 + "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
57 + left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
58 + right:
59 + "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
60 + },
61 + },
62 + defaultVariants: {
63 + side: "right",
64 + },
65 + }
66 +)
67 +
68 +interface SheetContentProps
69 + extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
70 + VariantProps<typeof sheetVariants> {}
71 +
72 +const SheetContent = React.forwardRef<
73 + React.ElementRef<typeof SheetPrimitive.Content>,
74 + SheetContentProps
75 +>(({ side = "right", className, children, ...props }, ref) => (
76 + <SheetPortal>
77 + <SheetOverlay />
78 + <SheetPrimitive.Content
79 + ref={ref}
80 + className={cn(sheetVariants({ side }), className)}
81 + {...props}
82 + >
83 + {children}
84 + <SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
85 + <X className="h-4 w-4" />
86 + <span className="sr-only">Close</span>
87 + </SheetPrimitive.Close>
88 + </SheetPrimitive.Content>
89 + </SheetPortal>
90 +))
91 +SheetContent.displayName = SheetPrimitive.Content.displayName
92 +
93 +const SheetHeader = ({
94 + className,
95 + ...props
96 +}: React.HTMLAttributes<HTMLDivElement>) => (
97 + <div
98 + className={cn(
99 + "flex flex-col space-y-2 text-center sm:text-left",
100 + className
101 + )}
102 + {...props}
103 + />
104 +)
105 +SheetHeader.displayName = "SheetHeader"
106 +
107 +const SheetFooter = ({
108 + className,
109 + ...props
110 +}: React.HTMLAttributes<HTMLDivElement>) => (
111 + <div
112 + className={cn(
113 + "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
114 + className
115 + )}
116 + {...props}
117 + />
118 +)
119 +SheetFooter.displayName = "SheetFooter"
120 +
121 +const SheetTitle = React.forwardRef<
122 + React.ElementRef<typeof SheetPrimitive.Title>,
123 + React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
124 +>(({ className, ...props }, ref) => (
125 + <SheetPrimitive.Title
126 + ref={ref}
127 + className={cn("text-lg font-semibold text-foreground", className)}
128 + {...props}
129 + />
130 +))
131 +SheetTitle.displayName = SheetPrimitive.Title.displayName
132 +
133 +const SheetDescription = React.forwardRef<
134 + React.ElementRef<typeof SheetPrimitive.Description>,
135 + React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
136 +>(({ className, ...props }, ref) => (
137 + <SheetPrimitive.Description
138 + ref={ref}
139 + className={cn("text-sm text-muted-foreground", className)}
140 + {...props}
141 + />
142 +))
143 +SheetDescription.displayName = SheetPrimitive.Description.displayName
144 +
145 +export {
146 + Sheet,
147 + SheetPortal,
148 + SheetOverlay,
149 + SheetTrigger,
150 + SheetClose,
151 + SheetContent,
152 + SheetHeader,
153 + SheetFooter,
154 + SheetTitle,
155 + SheetDescription,
156 +}
added client/src/components/ui/sidebar.tsx +743 −0
@@ -0,0 +1,743 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/ui/sidebar.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +"use client"
18 +
19 +import * as React from "react"
20 +import { Slot } from "@radix-ui/react-slot"
21 +import { cva, VariantProps } from "class-variance-authority"
22 +import { PanelLeftIcon } from "lucide-react"
23 +
24 +import { useIsMobile } from "@/hooks/use-mobile"
25 +import { cn } from "@/lib/utils"
26 +import { Button } from "@/components/ui/button"
27 +import { Input } from "@/components/ui/input"
28 +import { Separator } from "@/components/ui/separator"
29 +import {
30 + Sheet,
31 + SheetContent,
32 + SheetDescription,
33 + SheetHeader,
34 + SheetTitle,
35 +} from "@/components/ui/sheet"
36 +import { Skeleton } from "@/components/ui/skeleton"
37 +import {
38 + Tooltip,
39 + TooltipContent,
40 + TooltipProvider,
41 + TooltipTrigger,
42 +} from "@/components/ui/tooltip"
43 +
44 +const SIDEBAR_COOKIE_NAME = "sidebar_state"
45 +const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
46 +const SIDEBAR_WIDTH = "16rem"
47 +const SIDEBAR_WIDTH_MOBILE = "18rem"
48 +const SIDEBAR_WIDTH_ICON = "3rem"
49 +const SIDEBAR_KEYBOARD_SHORTCUT = "b"
50 +
51 +type SidebarContextProps = {
52 + state: "expanded" | "collapsed"
53 + open: boolean
54 + setOpen: (open: boolean) => void
55 + openMobile: boolean
56 + setOpenMobile: (open: boolean) => void
57 + isMobile: boolean
58 + toggleSidebar: () => void
59 +}
60 +
61 +const SidebarContext = React.createContext<SidebarContextProps | null>(null)
62 +
63 +function useSidebar() {
64 + const context = React.useContext(SidebarContext)
65 + if (!context) {
66 + throw new Error("useSidebar must be used within a SidebarProvider.")
67 + }
68 +
69 + return context
70 +}
71 +
72 +function SidebarProvider({
73 + defaultOpen = true,
74 + open: openProp,
75 + onOpenChange: setOpenProp,
76 + className,
77 + style,
78 + children,
79 + ...props
80 +}: React.ComponentProps<"div"> & {
81 + defaultOpen?: boolean
82 + open?: boolean
83 + onOpenChange?: (open: boolean) => void
84 +}) {
85 + const isMobile = useIsMobile()
86 + const [openMobile, setOpenMobile] = React.useState(false)
87 +
88 + // This is the internal state of the sidebar.
89 + // We use openProp and setOpenProp for control from outside the component.
90 + const [_open, _setOpen] = React.useState(defaultOpen)
91 + const open = openProp ?? _open
92 + const setOpen = React.useCallback(
93 + (value: boolean | ((value: boolean) => boolean)) => {
94 + const openState = typeof value === "function" ? value(open) : value
95 + if (setOpenProp) {
96 + setOpenProp(openState)
97 + } else {
98 + _setOpen(openState)
99 + }
100 +
101 + // This sets the cookie to keep the sidebar state.
102 + document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
103 + },
104 + [setOpenProp, open]
105 + )
106 +
107 + // Helper to toggle the sidebar.
108 + const toggleSidebar = React.useCallback(() => {
109 + return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
110 + }, [isMobile, setOpen, setOpenMobile])
111 +
112 + // Adds a keyboard shortcut to toggle the sidebar.
113 + React.useEffect(() => {
114 + const handleKeyDown = (event: KeyboardEvent) => {
115 + if (
116 + event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
117 + (event.metaKey || event.ctrlKey)
118 + ) {
119 + event.preventDefault()
120 + toggleSidebar()
121 + }
122 + }
123 +
124 + window.addEventListener("keydown", handleKeyDown)
125 + return () => window.removeEventListener("keydown", handleKeyDown)
126 + }, [toggleSidebar])
127 +
128 + // We add a state so that we can do data-state="expanded" or "collapsed".
129 + // This makes it easier to style the sidebar with Tailwind classes.
130 + const state = open ? "expanded" : "collapsed"
131 +
132 + const contextValue = React.useMemo<SidebarContextProps>(
133 + () => ({
134 + state,
135 + open,
136 + setOpen,
137 + isMobile,
138 + openMobile,
139 + setOpenMobile,
140 + toggleSidebar,
141 + }),
142 + [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
143 + )
144 +
145 + return (
146 + <SidebarContext.Provider value={contextValue}>
147 + <TooltipProvider delayDuration={0}>
148 + <div
149 + data-slot="sidebar-wrapper"
150 + style={
151 + {
152 + "--sidebar-width": SIDEBAR_WIDTH,
153 + "--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
154 + ...style,
155 + } as React.CSSProperties
156 + }
157 + className={cn(
158 + "group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
159 + className
160 + )}
161 + {...props}
162 + >
163 + {children}
164 + </div>
165 + </TooltipProvider>
166 + </SidebarContext.Provider>
167 + )
168 +}
169 +
170 +function Sidebar({
171 + side = "left",
172 + variant = "sidebar",
173 + collapsible = "offcanvas",
174 + className,
175 + children,
176 + ...props
177 +}: React.ComponentProps<"div"> & {
178 + side?: "left" | "right"
179 + variant?: "sidebar" | "floating" | "inset"
180 + collapsible?: "offcanvas" | "icon" | "none"
181 +}) {
182 + const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
183 +
184 + if (collapsible === "none") {
185 + return (
186 + <div
187 + data-slot="sidebar"
188 + className={cn(
189 + "bg-sidebar text-sidebar-foreground flex h-full w-[var(--sidebar-width)] flex-col",
190 + className
191 + )}
192 + {...props}
193 + >
194 + {children}
195 + </div>
196 + )
197 + }
198 +
199 + if (isMobile) {
200 + return (
201 + <Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
202 + <SheetContent
203 + data-sidebar="sidebar"
204 + data-slot="sidebar"
205 + data-mobile="true"
206 + className="bg-sidebar text-sidebar-foreground w-[var(--sidebar-width)] p-0 [&>button]:hidden"
207 + style={
208 + {
209 + "--sidebar-width": SIDEBAR_WIDTH_MOBILE,
210 + } as React.CSSProperties
211 + }
212 + side={side}
213 + >
214 + <SheetHeader className="sr-only">
215 + <SheetTitle>Sidebar</SheetTitle>
216 + <SheetDescription>Displays the mobile sidebar.</SheetDescription>
217 + </SheetHeader>
218 + <div className="flex h-full w-full flex-col">{children}</div>
219 + </SheetContent>
220 + </Sheet>
221 + )
222 + }
223 +
224 + return (
225 + <div
226 + className="group peer text-sidebar-foreground hidden md:block"
227 + data-state={state}
228 + data-collapsible={state === "collapsed" ? collapsible : ""}
229 + data-variant={variant}
230 + data-side={side}
231 + data-slot="sidebar"
232 + >
233 + {/* This is what handles the sidebar gap on desktop */}
234 + <div
235 + data-slot="sidebar-gap"
236 + className={cn(
237 + "relative w-[var(--sidebar-width)] bg-transparent transition-[width] duration-200 ease-linear",
238 + "group-data-[collapsible=offcanvas]:w-0",
239 + "group-data-[side=right]:rotate-180",
240 + variant === "floating" || variant === "inset"
241 + ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+var(--spacing-4))]"
242 + : "group-data-[collapsible=icon]:w-[var(--sidebar-width-icon)]"
243 + )}
244 + />
245 + <div
246 + data-slot="sidebar-container"
247 + className={cn(
248 + "fixed inset-y-0 z-10 hidden h-svh w-[var(--sidebar-width)] transition-[left,right,width] duration-200 ease-linear md:flex",
249 + side === "left"
250 + ? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
251 + : "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
252 + // Adjust the padding for floating and inset variants.
253 + variant === "floating" || variant === "inset"
254 + ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+var(--spacing-4)+2px)]"
255 + : "group-data-[collapsible=icon]:w-[var(--sidebar-width-icon)] group-data-[side=left]:border-r group-data-[side=right]:border-l",
256 + className
257 + )}
258 + {...props}
259 + >
260 + <div
261 + data-sidebar="sidebar"
262 + data-slot="sidebar-inner"
263 + className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
264 + >
265 + {children}
266 + </div>
267 + </div>
268 + </div>
269 + )
270 +}
271 +
272 +function SidebarTrigger({
273 + className,
274 + onClick,
275 + ...props
276 +}: React.ComponentProps<typeof Button>) {
277 + const { toggleSidebar } = useSidebar()
278 +
279 + return (
280 + <Button
281 + data-sidebar="trigger"
282 + data-slot="sidebar-trigger"
283 + variant="ghost"
284 + size="icon"
285 + className={cn("h-7 w-7", className)}
286 + onClick={(event) => {
287 + onClick?.(event)
288 + toggleSidebar()
289 + }}
290 + {...props}
291 + >
292 + <PanelLeftIcon />
293 + <span className="sr-only">Toggle Sidebar</span>
294 + </Button>
295 + )
296 +}
297 +
298 +function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
299 + const { toggleSidebar } = useSidebar()
300 +
301 + // Note: Tailwind v3.4 doesn't support "in-" selectors. So the rail won't work perfectly.
302 + return (
303 + <button
304 + data-sidebar="rail"
305 + data-slot="sidebar-rail"
306 + aria-label="Toggle Sidebar"
307 + tabIndex={-1}
308 + onClick={toggleSidebar}
309 + title="Toggle Sidebar"
310 + className={cn(
311 + "hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
312 + "in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
313 + "[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
314 + "hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
315 + "[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
316 + "[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
317 + className
318 + )}
319 + {...props}
320 + />
321 + )
322 +}
323 +
324 +function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
325 + return (
326 + <main
327 + data-slot="sidebar-inset"
328 + className={cn(
329 + "bg-background relative flex w-full flex-1 flex-col",
330 + "md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
331 + className
332 + )}
333 + {...props}
334 + />
335 + )
336 +}
337 +
338 +function SidebarInput({
339 + className,
340 + ...props
341 +}: React.ComponentProps<typeof Input>) {
342 + return (
343 + <Input
344 + data-slot="sidebar-input"
345 + data-sidebar="input"
346 + className={cn("bg-background h-8 w-full shadow-none", className)}
347 + {...props}
348 + />
349 + )
350 +}
351 +
352 +function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
353 + return (
354 + <div
355 + data-slot="sidebar-header"
356 + data-sidebar="header"
357 + className={cn("flex flex-col gap-2 p-2", className)}
358 + {...props}
359 + />
360 + )
361 +}
362 +
363 +function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
364 + return (
365 + <div
366 + data-slot="sidebar-footer"
367 + data-sidebar="footer"
368 + className={cn("flex flex-col gap-2 p-2", className)}
369 + {...props}
370 + />
371 + )
372 +}
373 +
374 +function SidebarSeparator({
375 + className,
376 + ...props
377 +}: React.ComponentProps<typeof Separator>) {
378 + return (
379 + <Separator
380 + data-slot="sidebar-separator"
381 + data-sidebar="separator"
382 + className={cn("bg-sidebar-border mx-2 w-auto", className)}
383 + {...props}
384 + />
385 + )
386 +}
387 +
388 +function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
389 + return (
390 + <div
391 + data-slot="sidebar-content"
392 + data-sidebar="content"
393 + className={cn(
394 + "flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
395 + className
396 + )}
397 + {...props}
398 + />
399 + )
400 +}
401 +
402 +function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
403 + return (
404 + <div
405 + data-slot="sidebar-group"
406 + data-sidebar="group"
407 + className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
408 + {...props}
409 + />
410 + )
411 +}
412 +
413 +function SidebarGroupLabel({
414 + className,
415 + asChild = false,
416 + ...props
417 +}: React.ComponentProps<"div"> & { asChild?: boolean }) {
418 + const Comp = asChild ? Slot : "div"
419 +
420 + return (
421 + <Comp
422 + data-slot="sidebar-group-label"
423 + data-sidebar="group-label"
424 + className={cn(
425 + "text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:h-4 [&>svg]:w-4 [&>svg]:shrink-0",
426 + "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
427 + className
428 + )}
429 + {...props}
430 + />
431 + )
432 +}
433 +
434 +function SidebarGroupAction({
435 + className,
436 + asChild = false,
437 + ...props
438 +}: React.ComponentProps<"button"> & { asChild?: boolean }) {
439 + const Comp = asChild ? Slot : "button"
440 +
441 + return (
442 + <Comp
443 + data-slot="sidebar-group-action"
444 + data-sidebar="group-action"
445 + className={cn(
446 + "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
447 + // Increases the hit area of the button on mobile.
448 + "after:absolute after:-inset-2 md:after:hidden",
449 + "group-data-[collapsible=icon]:hidden",
450 + className
451 + )}
452 + {...props}
453 + />
454 + )
455 +}
456 +
457 +function SidebarGroupContent({
458 + className,
459 + ...props
460 +}: React.ComponentProps<"div">) {
461 + return (
462 + <div
463 + data-slot="sidebar-group-content"
464 + data-sidebar="group-content"
465 + className={cn("w-full text-sm", className)}
466 + {...props}
467 + />
468 + )
469 +}
470 +
471 +function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
472 + return (
473 + <ul
474 + data-slot="sidebar-menu"
475 + data-sidebar="menu"
476 + className={cn("flex w-full min-w-0 flex-col gap-1", className)}
477 + {...props}
478 + />
479 + )
480 +}
481 +
482 +function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
483 + return (
484 + <li
485 + data-slot="sidebar-menu-item"
486 + data-sidebar="menu-item"
487 + className={cn("group/menu-item relative", className)}
488 + {...props}
489 + />
490 + )
491 +}
492 +
493 +const sidebarMenuButtonVariants = cva(
494 + "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:w-8! group-data-[collapsible=icon]:h-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
495 + {
496 + variants: {
497 + variant: {
498 + default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
499 + outline:
500 + "bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
501 + },
502 + size: {
503 + default: "h-8 text-sm",
504 + sm: "h-7 text-xs",
505 + lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
506 + },
507 + },
508 + defaultVariants: {
509 + variant: "default",
510 + size: "default",
511 + },
512 + }
513 +)
514 +
515 +function SidebarMenuButton({
516 + asChild = false,
517 + isActive = false,
518 + variant = "default",
519 + size = "default",
520 + tooltip,
521 + className,
522 + ...props
523 +}: React.ComponentProps<"button"> & {
524 + asChild?: boolean
525 + isActive?: boolean
526 + tooltip?: string | React.ComponentProps<typeof TooltipContent>
527 +} & VariantProps<typeof sidebarMenuButtonVariants>) {
528 + const Comp = asChild ? Slot : "button"
529 + const { isMobile, state } = useSidebar()
530 +
531 + const button = (
532 + <Comp
533 + data-slot="sidebar-menu-button"
534 + data-sidebar="menu-button"
535 + data-size={size}
536 + data-active={isActive}
537 + className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
538 + {...props}
539 + />
540 + )
541 +
542 + if (!tooltip) {
543 + return button
544 + }
545 +
546 + if (typeof tooltip === "string") {
547 + tooltip = {
548 + children: tooltip,
549 + }
550 + }
551 +
552 + return (
553 + <Tooltip>
554 + <TooltipTrigger asChild>{button}</TooltipTrigger>
555 + <TooltipContent
556 + side="right"
557 + align="center"
558 + hidden={state !== "collapsed" || isMobile}
559 + {...tooltip}
560 + />
561 + </Tooltip>
562 + )
563 +}
564 +
565 +function SidebarMenuAction({
566 + className,
567 + asChild = false,
568 + showOnHover = false,
569 + ...props
570 +}: React.ComponentProps<"button"> & {
571 + asChild?: boolean
572 + showOnHover?: boolean
573 +}) {
574 + const Comp = asChild ? Slot : "button"
575 +
576 + return (
577 + <Comp
578 + data-slot="sidebar-menu-action"
579 + data-sidebar="menu-action"
580 + className={cn(
581 + "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
582 + // Increases the hit area of the button on mobile.
583 + "after:absolute after:-inset-2 md:after:hidden",
584 + "peer-data-[size=sm]/menu-button:top-1",
585 + "peer-data-[size=default]/menu-button:top-1.5",
586 + "peer-data-[size=lg]/menu-button:top-2.5",
587 + "group-data-[collapsible=icon]:hidden",
588 + showOnHover &&
589 + "peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
590 + className
591 + )}
592 + {...props}
593 + />
594 + )
595 +}
596 +
597 +function SidebarMenuBadge({
598 + className,
599 + ...props
600 +}: React.ComponentProps<"div">) {
601 + return (
602 + <div
603 + data-slot="sidebar-menu-badge"
604 + data-sidebar="menu-badge"
605 + className={cn(
606 + "text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
607 + "peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
608 + "peer-data-[size=sm]/menu-button:top-1",
609 + "peer-data-[size=default]/menu-button:top-1.5",
610 + "peer-data-[size=lg]/menu-button:top-2.5",
611 + "group-data-[collapsible=icon]:hidden",
612 + className
613 + )}
614 + {...props}
615 + />
616 + )
617 +}
618 +
619 +function SidebarMenuSkeleton({
620 + className,
621 + showIcon = false,
622 + ...props
623 +}: React.ComponentProps<"div"> & {
624 + showIcon?: boolean
625 +}) {
626 + // Random width between 50 to 90%.
627 + const width = React.useMemo(() => {
628 + return `${Math.floor(Math.random() * 40) + 50}%`
629 + }, [])
630 +
631 + return (
632 + <div
633 + data-slot="sidebar-menu-skeleton"
634 + data-sidebar="menu-skeleton"
635 + className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
636 + {...props}
637 + >
638 + {showIcon && (
639 + <Skeleton
640 + className="size-4 rounded-md"
641 + data-sidebar="menu-skeleton-icon"
642 + />
643 + )}
644 + <Skeleton
645 + className="h-4 max-w-[var(--skeleton-width)] flex-1"
646 + data-sidebar="menu-skeleton-text"
647 + style={
648 + {
649 + "--skeleton-width": width,
650 + } as React.CSSProperties
651 + }
652 + />
653 + </div>
654 + )
655 +}
656 +
657 +function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
658 + return (
659 + <ul
660 + data-slot="sidebar-menu-sub"
661 + data-sidebar="menu-sub"
662 + className={cn(
663 + "border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
664 + "group-data-[collapsible=icon]:hidden",
665 + className
666 + )}
667 + {...props}
668 + />
669 + )
670 +}
671 +
672 +function SidebarMenuSubItem({
673 + className,
674 + ...props
675 +}: React.ComponentProps<"li">) {
676 + return (
677 + <li
678 + data-slot="sidebar-menu-sub-item"
679 + data-sidebar="menu-sub-item"
680 + className={cn("group/menu-sub-item relative", className)}
681 + {...props}
682 + />
683 + )
684 +}
685 +
686 +function SidebarMenuSubButton({
687 + asChild = false,
688 + size = "md",
689 + isActive = false,
690 + className,
691 + ...props
692 +}: React.ComponentProps<"a"> & {
693 + asChild?: boolean
694 + size?: "sm" | "md"
695 + isActive?: boolean
696 +}) {
697 + const Comp = asChild ? Slot : "a"
698 +
699 + return (
700 + <Comp
701 + data-slot="sidebar-menu-sub-button"
702 + data-sidebar="menu-sub-button"
703 + data-size={size}
704 + data-active={isActive}
705 + className={cn(
706 + "text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline outline-2 outline-transparent outline-offset-2 focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
707 + "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
708 + size === "sm" && "text-xs",
709 + size === "md" && "text-sm",
710 + "group-data-[collapsible=icon]:hidden",
711 + className
712 + )}
713 + {...props}
714 + />
715 + )
716 +}
717 +
718 +export {
719 + Sidebar,
720 + SidebarContent,
721 + SidebarFooter,
722 + SidebarGroup,
723 + SidebarGroupAction,
724 + SidebarGroupContent,
725 + SidebarGroupLabel,
726 + SidebarHeader,
727 + SidebarInput,
728 + SidebarInset,
729 + SidebarMenu,
730 + SidebarMenuAction,
731 + SidebarMenuBadge,
732 + SidebarMenuButton,
733 + SidebarMenuItem,
734 + SidebarMenuSkeleton,
735 + SidebarMenuSub,
736 + SidebarMenuSubButton,
737 + SidebarMenuSubItem,
738 + SidebarProvider,
739 + SidebarRail,
740 + SidebarSeparator,
741 + SidebarTrigger,
742 + useSidebar,
743 +}
added client/src/components/ui/skeleton.tsx +31 −0
@@ -0,0 +1,31 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/ui/skeleton.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { cn } from "@/lib/utils"
18 +
19 +function Skeleton({
20 + className,
21 + ...props
22 +}: React.HTMLAttributes<HTMLDivElement>) {
23 + return (
24 + <div
25 + className={cn("animate-pulse rounded-md bg-muted", className)}
26 + {...props}
27 + />
28 + )
29 +}
30 +
31 +export { Skeleton }
added client/src/components/ui/slider.tsx +42 −0
@@ -0,0 +1,42 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/ui/slider.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import * as React from "react"
18 +import * as SliderPrimitive from "@radix-ui/react-slider"
19 +
20 +import { cn } from "@/lib/utils"
21 +
22 +const Slider = React.forwardRef<
23 + React.ElementRef<typeof SliderPrimitive.Root>,
24 + React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
25 +>(({ className, ...props }, ref) => (
26 + <SliderPrimitive.Root
27 + ref={ref}
28 + className={cn(
29 + "relative flex w-full touch-none select-none items-center",
30 + className
31 + )}
32 + {...props}
33 + >
34 + <SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
35 + <SliderPrimitive.Range className="absolute h-full bg-primary" />
36 + </SliderPrimitive.Track>
37 + <SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
38 + </SliderPrimitive.Root>
39 +))
40 +Slider.displayName = SliderPrimitive.Root.displayName
41 +
42 +export { Slider }
added client/src/components/ui/switch.tsx +43 −0
@@ -0,0 +1,43 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/ui/switch.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import * as React from "react"
18 +import * as SwitchPrimitives from "@radix-ui/react-switch"
19 +
20 +import { cn } from "@/lib/utils"
21 +
22 +const Switch = React.forwardRef<
23 + React.ElementRef<typeof SwitchPrimitives.Root>,
24 + React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
25 +>(({ className, ...props }, ref) => (
26 + <SwitchPrimitives.Root
27 + className={cn(
28 + "peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
29 + className
30 + )}
31 + {...props}
32 + ref={ref}
33 + >
34 + <SwitchPrimitives.Thumb
35 + className={cn(
36 + "pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
37 + )}
38 + />
39 + </SwitchPrimitives.Root>
40 +))
41 +Switch.displayName = SwitchPrimitives.Root.displayName
42 +
43 +export { Switch }
added client/src/components/ui/table.tsx +133 −0
@@ -0,0 +1,133 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/ui/table.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import * as React from "react"
18 +
19 +import { cn } from "@/lib/utils"
20 +
21 +const Table = React.forwardRef<
22 + HTMLTableElement,
23 + React.HTMLAttributes<HTMLTableElement>
24 +>(({ className, ...props }, ref) => (
25 + <div className="relative w-full overflow-auto">
26 + <table
27 + ref={ref}
28 + className={cn("w-full caption-bottom text-sm", className)}
29 + {...props}
30 + />
31 + </div>
32 +))
33 +Table.displayName = "Table"
34 +
35 +const TableHeader = React.forwardRef<
36 + HTMLTableSectionElement,
37 + React.HTMLAttributes<HTMLTableSectionElement>
38 +>(({ className, ...props }, ref) => (
39 + <thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
40 +))
41 +TableHeader.displayName = "TableHeader"
42 +
43 +const TableBody = React.forwardRef<
44 + HTMLTableSectionElement,
45 + React.HTMLAttributes<HTMLTableSectionElement>
46 +>(({ className, ...props }, ref) => (
47 + <tbody
48 + ref={ref}
49 + className={cn("[&_tr:last-child]:border-0", className)}
50 + {...props}
51 + />
52 +))
53 +TableBody.displayName = "TableBody"
54 +
55 +const TableFooter = React.forwardRef<
56 + HTMLTableSectionElement,
57 + React.HTMLAttributes<HTMLTableSectionElement>
58 +>(({ className, ...props }, ref) => (
59 + <tfoot
60 + ref={ref}
61 + className={cn(
62 + "border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
63 + className
64 + )}
65 + {...props}
66 + />
67 +))
68 +TableFooter.displayName = "TableFooter"
69 +
70 +const TableRow = React.forwardRef<
71 + HTMLTableRowElement,
72 + React.HTMLAttributes<HTMLTableRowElement>
73 +>(({ className, ...props }, ref) => (
74 + <tr
75 + ref={ref}
76 + className={cn(
77 + "border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
78 + className
79 + )}
80 + {...props}
81 + />
82 +))
83 +TableRow.displayName = "TableRow"
84 +
85 +const TableHead = React.forwardRef<
86 + HTMLTableCellElement,
87 + React.ThHTMLAttributes<HTMLTableCellElement>
88 +>(({ className, ...props }, ref) => (
89 + <th
90 + ref={ref}
91 + className={cn(
92 + "h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
93 + className
94 + )}
95 + {...props}
96 + />
97 +))
98 +TableHead.displayName = "TableHead"
99 +
100 +const TableCell = React.forwardRef<
101 + HTMLTableCellElement,
102 + React.TdHTMLAttributes<HTMLTableCellElement>
103 +>(({ className, ...props }, ref) => (
104 + <td
105 + ref={ref}
106 + className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
107 + {...props}
108 + />
109 +))
110 +TableCell.displayName = "TableCell"
111 +
112 +const TableCaption = React.forwardRef<
113 + HTMLTableCaptionElement,
114 + React.HTMLAttributes<HTMLTableCaptionElement>
115 +>(({ className, ...props }, ref) => (
116 + <caption
117 + ref={ref}
118 + className={cn("mt-4 text-sm text-muted-foreground", className)}
119 + {...props}
120 + />
121 +))
122 +TableCaption.displayName = "TableCaption"
123 +
124 +export {
125 + Table,
126 + TableHeader,
127 + TableBody,
128 + TableFooter,
129 + TableHead,
130 + TableRow,
131 + TableCell,
132 + TableCaption,
133 +}
added client/src/components/ui/tabs.tsx +69 −0
@@ -0,0 +1,69 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/ui/tabs.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import * as React from "react"
18 +import * as TabsPrimitive from "@radix-ui/react-tabs"
19 +
20 +import { cn } from "@/lib/utils"
21 +
22 +const Tabs = TabsPrimitive.Root
23 +
24 +const TabsList = React.forwardRef<
25 + React.ElementRef<typeof TabsPrimitive.List>,
26 + React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
27 +>(({ className, ...props }, ref) => (
28 + <TabsPrimitive.List
29 + ref={ref}
30 + className={cn(
31 + "inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
32 + className
33 + )}
34 + {...props}
35 + />
36 +))
37 +TabsList.displayName = TabsPrimitive.List.displayName
38 +
39 +const TabsTrigger = React.forwardRef<
40 + React.ElementRef<typeof TabsPrimitive.Trigger>,
41 + React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
42 +>(({ className, ...props }, ref) => (
43 + <TabsPrimitive.Trigger
44 + ref={ref}
45 + className={cn(
46 + "inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
47 + className
48 + )}
49 + {...props}
50 + />
51 +))
52 +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
53 +
54 +const TabsContent = React.forwardRef<
55 + React.ElementRef<typeof TabsPrimitive.Content>,
56 + React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
57 +>(({ className, ...props }, ref) => (
58 + <TabsPrimitive.Content
59 + ref={ref}
60 + className={cn(
61 + "mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
62 + className
63 + )}
64 + {...props}
65 + />
66 +))
67 +TabsContent.displayName = TabsPrimitive.Content.displayName
68 +
69 +export { Tabs, TabsList, TabsTrigger, TabsContent }
added client/src/components/ui/textarea.tsx +38 −0
@@ -0,0 +1,38 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/ui/textarea.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import * as React from "react"
18 +
19 +import { cn } from "@/lib/utils"
20 +
21 +const Textarea = React.forwardRef<
22 + HTMLTextAreaElement,
23 + React.ComponentProps<"textarea">
24 +>(({ className, ...props }, ref) => {
25 + return (
26 + <textarea
27 + className={cn(
28 + "flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
29 + className
30 + )}
31 + ref={ref}
32 + {...props}
33 + />
34 + )
35 +})
36 +Textarea.displayName = "Textarea"
37 +
38 +export { Textarea }
added client/src/components/ui/toast.tsx +143 −0
@@ -0,0 +1,143 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/ui/toast.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import * as React from "react"
18 +import * as ToastPrimitives from "@radix-ui/react-toast"
19 +import { cva, type VariantProps } from "class-variance-authority"
20 +import { X } from "lucide-react"
21 +
22 +import { cn } from "@/lib/utils"
23 +
24 +const ToastProvider = ToastPrimitives.Provider
25 +
26 +const ToastViewport = React.forwardRef<
27 + React.ElementRef<typeof ToastPrimitives.Viewport>,
28 + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
29 +>(({ className, ...props }, ref) => (
30 + <ToastPrimitives.Viewport
31 + ref={ref}
32 + className={cn(
33 + "fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
34 + className
35 + )}
36 + {...props}
37 + />
38 +))
39 +ToastViewport.displayName = ToastPrimitives.Viewport.displayName
40 +
41 +const toastVariants = cva(
42 + "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
43 + {
44 + variants: {
45 + variant: {
46 + default: "border bg-background text-foreground",
47 + destructive:
48 + "destructive group border-destructive bg-destructive text-destructive-foreground",
49 + },
50 + },
51 + defaultVariants: {
52 + variant: "default",
53 + },
54 + }
55 +)
56 +
57 +const Toast = React.forwardRef<
58 + React.ElementRef<typeof ToastPrimitives.Root>,
59 + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
60 + VariantProps<typeof toastVariants>
61 +>(({ className, variant, ...props }, ref) => {
62 + return (
63 + <ToastPrimitives.Root
64 + ref={ref}
65 + className={cn(toastVariants({ variant }), className)}
66 + {...props}
67 + />
68 + )
69 +})
70 +Toast.displayName = ToastPrimitives.Root.displayName
71 +
72 +const ToastAction = React.forwardRef<
73 + React.ElementRef<typeof ToastPrimitives.Action>,
74 + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
75 +>(({ className, ...props }, ref) => (
76 + <ToastPrimitives.Action
77 + ref={ref}
78 + className={cn(
79 + "inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
80 + className
81 + )}
82 + {...props}
83 + />
84 +))
85 +ToastAction.displayName = ToastPrimitives.Action.displayName
86 +
87 +const ToastClose = React.forwardRef<
88 + React.ElementRef<typeof ToastPrimitives.Close>,
89 + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
90 +>(({ className, ...props }, ref) => (
91 + <ToastPrimitives.Close
92 + ref={ref}
93 + className={cn(
94 + "absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
95 + className
96 + )}
97 + toast-close=""
98 + {...props}
99 + >
100 + <X className="h-4 w-4" />
101 + </ToastPrimitives.Close>
102 +))
103 +ToastClose.displayName = ToastPrimitives.Close.displayName
104 +
105 +const ToastTitle = React.forwardRef<
106 + React.ElementRef<typeof ToastPrimitives.Title>,
107 + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
108 +>(({ className, ...props }, ref) => (
109 + <ToastPrimitives.Title
110 + ref={ref}
111 + className={cn("text-sm font-semibold", className)}
112 + {...props}
113 + />
114 +))
115 +ToastTitle.displayName = ToastPrimitives.Title.displayName
116 +
117 +const ToastDescription = React.forwardRef<
118 + React.ElementRef<typeof ToastPrimitives.Description>,
119 + React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
120 +>(({ className, ...props }, ref) => (
121 + <ToastPrimitives.Description
122 + ref={ref}
123 + className={cn("text-sm opacity-90", className)}
124 + {...props}
125 + />
126 +))
127 +ToastDescription.displayName = ToastPrimitives.Description.displayName
128 +
129 +type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
130 +
131 +type ToastActionElement = React.ReactElement<typeof ToastAction>
132 +
133 +export {
134 + type ToastProps,
135 + type ToastActionElement,
136 + ToastProvider,
137 + ToastViewport,
138 + Toast,
139 + ToastTitle,
140 + ToastDescription,
141 + ToastClose,
142 + ToastAction,
143 +}
added client/src/components/ui/toaster.tsx +49 −0
@@ -0,0 +1,49 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/ui/toaster.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { useToast } from "@/hooks/use-toast"
18 +import {
19 + Toast,
20 + ToastClose,
21 + ToastDescription,
22 + ToastProvider,
23 + ToastTitle,
24 + ToastViewport,
25 +} from "@/components/ui/toast"
26 +
27 +export function Toaster() {
28 + const { toasts } = useToast()
29 +
30 + return (
31 + <ToastProvider>
32 + {toasts.map(function ({ id, title, description, action, ...props }) {
33 + return (
34 + <Toast key={id} {...props}>
35 + <div className="grid gap-1">
36 + {title && <ToastTitle>{title}</ToastTitle>}
37 + {description && (
38 + <ToastDescription>{description}</ToastDescription>
39 + )}
40 + </div>
41 + {action}
42 + <ToastClose />
43 + </Toast>
44 + )
45 + })}
46 + <ToastViewport />
47 + </ToastProvider>
48 + )
49 +}
added client/src/components/ui/toggle-group.tsx +77 −0
@@ -0,0 +1,77 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/ui/toggle-group.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +"use client"
18 +
19 +import * as React from "react"
20 +import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
21 +import { type VariantProps } from "class-variance-authority"
22 +
23 +import { cn } from "@/lib/utils"
24 +import { toggleVariants } from "@/components/ui/toggle"
25 +
26 +const ToggleGroupContext = React.createContext<
27 + VariantProps<typeof toggleVariants>
28 +>({
29 + size: "default",
30 + variant: "default",
31 +})
32 +
33 +const ToggleGroup = React.forwardRef<
34 + React.ElementRef<typeof ToggleGroupPrimitive.Root>,
35 + React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
36 + VariantProps<typeof toggleVariants>
37 +>(({ className, variant, size, children, ...props }, ref) => (
38 + <ToggleGroupPrimitive.Root
39 + ref={ref}
40 + className={cn("flex items-center justify-center gap-1", className)}
41 + {...props}
42 + >
43 + <ToggleGroupContext.Provider value={{ variant, size }}>
44 + {children}
45 + </ToggleGroupContext.Provider>
46 + </ToggleGroupPrimitive.Root>
47 +))
48 +
49 +ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
50 +
51 +const ToggleGroupItem = React.forwardRef<
52 + React.ElementRef<typeof ToggleGroupPrimitive.Item>,
53 + React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
54 + VariantProps<typeof toggleVariants>
55 +>(({ className, children, variant, size, ...props }, ref) => {
56 + const context = React.useContext(ToggleGroupContext)
57 +
58 + return (
59 + <ToggleGroupPrimitive.Item
60 + ref={ref}
61 + className={cn(
62 + toggleVariants({
63 + variant: context.variant || variant,
64 + size: context.size || size,
65 + }),
66 + className
67 + )}
68 + {...props}
69 + >
70 + {children}
71 + </ToggleGroupPrimitive.Item>
72 + )
73 +})
74 +
75 +ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
76 +
77 +export { ToggleGroup, ToggleGroupItem }
added client/src/components/ui/toggle.tsx +59 −0
@@ -0,0 +1,59 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/ui/toggle.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import * as React from "react"
18 +import * as TogglePrimitive from "@radix-ui/react-toggle"
19 +import { cva, type VariantProps } from "class-variance-authority"
20 +
21 +import { cn } from "@/lib/utils"
22 +
23 +const toggleVariants = cva(
24 + "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 gap-2",
25 + {
26 + variants: {
27 + variant: {
28 + default: "bg-transparent",
29 + outline:
30 + "border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
31 + },
32 + size: {
33 + default: "h-10 px-3 min-w-10",
34 + sm: "h-9 px-2.5 min-w-9",
35 + lg: "h-11 px-5 min-w-11",
36 + },
37 + },
38 + defaultVariants: {
39 + variant: "default",
40 + size: "default",
41 + },
42 + }
43 +)
44 +
45 +const Toggle = React.forwardRef<
46 + React.ElementRef<typeof TogglePrimitive.Root>,
47 + React.ComponentPropsWithoutRef<typeof TogglePrimitive.Root> &
48 + VariantProps<typeof toggleVariants>
49 +>(({ className, variant, size, ...props }, ref) => (
50 + <TogglePrimitive.Root
51 + ref={ref}
52 + className={cn(toggleVariants({ variant, size, className }))}
53 + {...props}
54 + />
55 +))
56 +
57 +Toggle.displayName = TogglePrimitive.Root.displayName
58 +
59 +export { Toggle, toggleVariants }
added client/src/components/ui/tooltip.tsx +46 −0
@@ -0,0 +1,46 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/components/ui/tooltip.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +"use client"
18 +
19 +import * as React from "react"
20 +import * as TooltipPrimitive from "@radix-ui/react-tooltip"
21 +
22 +import { cn } from "@/lib/utils"
23 +
24 +const TooltipProvider = TooltipPrimitive.Provider
25 +
26 +const Tooltip = TooltipPrimitive.Root
27 +
28 +const TooltipTrigger = TooltipPrimitive.Trigger
29 +
30 +const TooltipContent = React.forwardRef<
31 + React.ElementRef<typeof TooltipPrimitive.Content>,
32 + React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
33 +>(({ className, sideOffset = 4, ...props }, ref) => (
34 + <TooltipPrimitive.Content
35 + ref={ref}
36 + sideOffset={sideOffset}
37 + className={cn(
38 + "z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
39 + className
40 + )}
41 + {...props}
42 + />
43 +))
44 +TooltipContent.displayName = TooltipPrimitive.Content.displayName
45 +
46 +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
added client/src/config.test.ts +48 −0
@@ -0,0 +1,48 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/config.test.ts
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { describe, it, expect } from "vitest";
18 +import { APP_CONFIG } from "./config";
19 +
20 +describe("APP_CONFIG", () => {
21 + it("has correct app name", () => {
22 + expect(APP_CONFIG.name).toBe("VQuant");
23 + });
24 +
25 + it("has correct domain", () => {
26 + expect(APP_CONFIG.domain).toBe("www.vquant.ai");
27 + });
28 +
29 + it("has required API endpoints", () => {
30 + expect(APP_CONFIG.endpoints.chat).toBe("/api/chat");
31 + expect(APP_CONFIG.endpoints.share).toBe("/api/share");
32 + expect(APP_CONFIG.endpoints.generatePDF).toBe("/api/generate-pdf");
33 + expect(APP_CONFIG.endpoints.generateDOCX).toBe("/api/generate-docx");
34 + expect(APP_CONFIG.endpoints.download).toBe("/api/download");
35 + });
36 +
37 + it("has reasonable limits", () => {
38 + expect(APP_CONFIG.limits.maxToolsPerBatch).toBeGreaterThan(0);
39 + expect(APP_CONFIG.limits.maxQueryLength).toBeGreaterThanOrEqual(1000);
40 + expect(APP_CONFIG.limits.maxPdfChars).toBeGreaterThan(0);
41 + });
42 +
43 + it("has all feature flags as booleans", () => {
44 + for (const [, value] of Object.entries(APP_CONFIG.features)) {
45 + expect(typeof value).toBe("boolean");
46 + }
47 + });
48 +});
added client/src/config.ts +67 −0
@@ -0,0 +1,67 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/config.ts
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +/**
18 + * Application Configuration
19 + * Manages environment-specific URLs and settings
20 + */
21 +
22 +// Determine the base URL based on environment
23 +export const getBaseUrl = (): string => {
24 + if (typeof window !== 'undefined') {
25 + return window.location.origin;
26 + }
27 + return 'http://localhost:5000';
28 +};
29 +
30 +// API base URL (same as base URL since API is served from same origin)
31 +export const API_BASE_URL = getBaseUrl();
32 +
33 +// Application metadata
34 +export const APP_CONFIG = {
35 + name: 'VQuant',
36 + fullName: 'VQuant Financial Intelligence Platform',
37 + domain: 'www.vquant.ai',
38 + description: 'Plateforme d\'analyse financière de niveau institutionnel propulsée par Claude AI',
39 + version: '2.0.0',
40 +
41 + // Feature flags
42 + features: {
43 + pythonCustomCode: true,
44 + pdfDownload: true,
45 + sharing: true,
46 + exportData: true,
47 + multiSource: true,
48 + },
49 +
50 + // API Endpoints (relative paths)
51 + endpoints: {
52 + chat: '/api/chat',
53 + share: '/api/share',
54 + generatePDF: '/api/generate-pdf',
55 + generateDOCX: '/api/generate-docx',
56 + download: '/api/download',
57 + },
58 +
59 + // Limits
60 + limits: {
61 + maxToolsPerBatch: 5,
62 + maxPdfChars: 3000000, // 3M chars = ~750 pages
63 + maxQueryLength: 10000,
64 + },
65 +};
66 +
67 +export default APP_CONFIG;
added client/src/hooks/use-mobile.tsx +35 −0
@@ -0,0 +1,35 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/hooks/use-mobile.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import * as React from "react"
18 +
19 +const MOBILE_BREAKPOINT = 768
20 +
21 +export function useIsMobile() {
22 + const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
23 +
24 + React.useEffect(() => {
25 + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
26 + const onChange = () => {
27 + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
28 + }
29 + mql.addEventListener("change", onChange)
30 + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
31 + return () => mql.removeEventListener("change", onChange)
32 + }, [])
33 +
34 + return !!isMobile
35 +}
added client/src/hooks/use-toast.ts +207 −0
@@ -0,0 +1,207 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/hooks/use-toast.ts
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import * as React from "react"
18 +
19 +import type {
20 + ToastActionElement,
21 + ToastProps,
22 +} from "@/components/ui/toast"
23 +
24 +const TOAST_LIMIT = 1
25 +const TOAST_REMOVE_DELAY = 1000000
26 +
27 +type ToasterToast = ToastProps & {
28 + id: string
29 + title?: React.ReactNode
30 + description?: React.ReactNode
31 + action?: ToastActionElement
32 +}
33 +
34 +const actionTypes = {
35 + ADD_TOAST: "ADD_TOAST",
36 + UPDATE_TOAST: "UPDATE_TOAST",
37 + DISMISS_TOAST: "DISMISS_TOAST",
38 + REMOVE_TOAST: "REMOVE_TOAST",
39 +} as const
40 +
41 +let count = 0
42 +
43 +function genId() {
44 + count = (count + 1) % Number.MAX_SAFE_INTEGER
45 + return count.toString()
46 +}
47 +
48 +type ActionType = typeof actionTypes
49 +
50 +type Action =
51 + | {
52 + type: ActionType["ADD_TOAST"]
53 + toast: ToasterToast
54 + }
55 + | {
56 + type: ActionType["UPDATE_TOAST"]
57 + toast: Partial<ToasterToast>
58 + }
59 + | {
60 + type: ActionType["DISMISS_TOAST"]
61 + toastId?: ToasterToast["id"]
62 + }
63 + | {
64 + type: ActionType["REMOVE_TOAST"]
65 + toastId?: ToasterToast["id"]
66 + }
67 +
68 +interface State {
69 + toasts: ToasterToast[]
70 +}
71 +
72 +const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
73 +
74 +const addToRemoveQueue = (toastId: string) => {
75 + if (toastTimeouts.has(toastId)) {
76 + return
77 + }
78 +
79 + const timeout = setTimeout(() => {
80 + toastTimeouts.delete(toastId)
81 + dispatch({
82 + type: "REMOVE_TOAST",
83 + toastId: toastId,
84 + })
85 + }, TOAST_REMOVE_DELAY)
86 +
87 + toastTimeouts.set(toastId, timeout)
88 +}
89 +
90 +export const reducer = (state: State, action: Action): State => {
91 + switch (action.type) {
92 + case "ADD_TOAST":
93 + return {
94 + ...state,
95 + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
96 + }
97 +
98 + case "UPDATE_TOAST":
99 + return {
100 + ...state,
101 + toasts: state.toasts.map((t) =>
102 + t.id === action.toast.id ? { ...t, ...action.toast } : t
103 + ),
104 + }
105 +
106 + case "DISMISS_TOAST": {
107 + const { toastId } = action
108 +
109 + // ! Side effects ! - This could be extracted into a dismissToast() action,
110 + // but I'll keep it here for simplicity
111 + if (toastId) {
112 + addToRemoveQueue(toastId)
113 + } else {
114 + state.toasts.forEach((toast) => {
115 + addToRemoveQueue(toast.id)
116 + })
117 + }
118 +
119 + return {
120 + ...state,
121 + toasts: state.toasts.map((t) =>
122 + t.id === toastId || toastId === undefined
123 + ? {
124 + ...t,
125 + open: false,
126 + }
127 + : t
128 + ),
129 + }
130 + }
131 + case "REMOVE_TOAST":
132 + if (action.toastId === undefined) {
133 + return {
134 + ...state,
135 + toasts: [],
136 + }
137 + }
138 + return {
139 + ...state,
140 + toasts: state.toasts.filter((t) => t.id !== action.toastId),
141 + }
142 + }
143 +}
144 +
145 +const listeners: Array<(state: State) => void> = []
146 +
147 +let memoryState: State = { toasts: [] }
148 +
149 +function dispatch(action: Action) {
150 + memoryState = reducer(memoryState, action)
151 + listeners.forEach((listener) => {
152 + listener(memoryState)
153 + })
154 +}
155 +
156 +type Toast = Omit<ToasterToast, "id">
157 +
158 +function toast({ ...props }: Toast) {
159 + const id = genId()
160 +
161 + const update = (props: ToasterToast) =>
162 + dispatch({
163 + type: "UPDATE_TOAST",
164 + toast: { ...props, id },
165 + })
166 + const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
167 +
168 + dispatch({
169 + type: "ADD_TOAST",
170 + toast: {
171 + ...props,
172 + id,
173 + open: true,
174 + onOpenChange: (open) => {
175 + if (!open) dismiss()
176 + },
177 + },
178 + })
179 +
180 + return {
181 + id: id,
182 + dismiss,
183 + update,
184 + }
185 +}
186 +
187 +function useToast() {
188 + const [state, setState] = React.useState<State>(memoryState)
189 +
190 + React.useEffect(() => {
191 + listeners.push(setState)
192 + return () => {
193 + const index = listeners.indexOf(setState)
194 + if (index > -1) {
195 + listeners.splice(index, 1)
196 + }
197 + }
198 + }, [state])
199 +
200 + return {
201 + ...state,
202 + toast,
203 + dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
204 + }
205 +}
206 +
207 +export { useToast, toast }
added client/src/hooks/useAnalytics.ts +110 −0
@@ -0,0 +1,110 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/hooks/useAnalytics.ts
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { useEffect, useRef, useState } from 'react';
18 +
19 +/**
20 + * Hook to send heartbeat to analytics server
21 + * Tracks user activity and current generation status
22 + */
23 +export function useAnalytics(status: 'idle' | 'generating' | 'error', currentQuery?: string) {
24 + const sessionIdRef = useRef<string>(getOrCreateSessionId());
25 + const lastStatusRef = useRef<string>(status);
26 +
27 + useEffect(() => {
28 + const sendHeartbeat = async () => {
29 + try {
30 + await fetch('/api/analytics/heartbeat', {
31 + method: 'POST',
32 + headers: {
33 + 'Content-Type': 'application/json',
34 + },
35 + body: JSON.stringify({
36 + sessionId: sessionIdRef.current,
37 + status,
38 + currentQuery: currentQuery || null,
39 + userId: null, // TODO: Get from auth context if logged in
40 + }),
41 + });
42 + } catch (error) {
43 + console.error('Failed to send heartbeat:', error);
44 + }
45 + };
46 +
47 + // Send heartbeat immediately when status changes
48 + if (status !== lastStatusRef.current) {
49 + sendHeartbeat();
50 + lastStatusRef.current = status;
51 + }
52 +
53 + // Send heartbeat every 30 seconds while generating
54 + if (status === 'generating') {
55 + const interval = setInterval(sendHeartbeat, 30000);
56 + return () => clearInterval(interval);
57 + }
58 + }, [status, currentQuery]);
59 +
60 + return {
61 + sessionId: sessionIdRef.current,
62 + };
63 +}
64 +
65 +/**
66 + * Get or create a unique session ID for this browser session
67 + */
68 +function getOrCreateSessionId(): string {
69 + const key = 'vibequant_session_id';
70 + let sessionId = sessionStorage.getItem(key);
71 +
72 + if (!sessionId) {
73 + sessionId = `session_${Date.now()}_${Math.random().toString(36).substring(7)}`;
74 + sessionStorage.setItem(key, sessionId);
75 + }
76 +
77 + return sessionId;
78 +}
79 +
80 +/**
81 + * Hook to fetch real-time analytics stats (public endpoint)
82 + */
83 +export function useRealTimeStats(refreshInterval: number = 10000) {
84 + const [stats, setStats] = useState<any>(null);
85 + const [loading, setLoading] = useState(true);
86 +
87 + useEffect(() => {
88 + const fetchStats = async () => {
89 + try {
90 + const response = await fetch('/api/analytics/real-time-stats');
91 + if (response.ok) {
92 + const data = await response.json();
93 + setStats(data);
94 + }
95 + } catch (error) {
96 + console.error('Failed to fetch real-time stats:', error);
97 + } finally {
98 + setLoading(false);
99 + }
100 + };
101 +
102 + fetchStats();
103 + const interval = setInterval(fetchStats, refreshInterval);
104 +
105 + return () => clearInterval(interval);
106 + }, [refreshInterval]);
107 +
108 + return { stats, loading };
109 +}
110 +
added client/src/hooks/useChatMutation.ts +114 −0
@@ -0,0 +1,114 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/hooks/useChatMutation.ts
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { useMutation } from '@tanstack/react-query';
18 +import type { SearchResult } from '@shared/types';
19 +
20 +interface ConversationMessage {
21 + question: string;
22 + answer: string;
23 +}
24 +
25 +interface ChatRequest {
26 + query: string;
27 + history?: ConversationMessage[];
28 + sessionId?: string;
29 + imageData?: string;
30 + imageMimeType?: string;
31 + model?: string;
32 +}
33 +
34 +interface ChatResponse {
35 + answer: string;
36 + sources: SearchResult[];
37 + sessionId: string;
38 + error?: string;
39 +}
40 +
41 +async function streamChat(request: ChatRequest, onChunk: (data: any) => void): Promise<ChatResponse> {
42 + const response = await fetch('/api/chat', {
43 + method: 'POST',
44 + headers: {
45 + 'Content-Type': 'application/json',
46 + },
47 + body: JSON.stringify(request),
48 + });
49 +
50 + if (!response.ok) {
51 + throw new Error('Failed to connect to chat API');
52 + }
53 +
54 + const reader = response.body?.getReader();
55 + const decoder = new TextDecoder();
56 +
57 + if (!reader) {
58 + throw new Error('No response body');
59 + }
60 +
61 + let answer = '';
62 + let sources: SearchResult[] = [];
63 + let sessionId = '';
64 + let buffer = '';
65 +
66 + while (true) {
67 + const { done, value } = await reader.read();
68 +
69 + if (done) break;
70 +
71 + buffer += decoder.decode(value, { stream: true });
72 + const lines = buffer.split('\n');
73 + buffer = lines.pop() || '';
74 +
75 + for (const line of lines) {
76 + if (line.startsWith('data: ')) {
77 + const data = line.substring(6);
78 + if (!data.trim()) continue;
79 +
80 + try {
81 + const event = JSON.parse(data);
82 +
83 + if (event.type === 'python_code') {
84 + console.log('useChatMutation: Received python_code event', event);
85 + }
86 +
87 + onChunk(event);
88 +
89 + if (event.type === 'text') {
90 + answer += event.content;
91 + } else if (event.type === 'sources') {
92 + sources = event.sources;
93 + } else if (event.type === 'done') {
94 + sessionId = event.sessionId;
95 + } else if (event.type === 'error') {
96 + throw new Error(event.error);
97 + }
98 + } catch (e) {
99 + if (e instanceof Error && e.message !== 'Unexpected end of JSON input') {
100 + throw e;
101 + }
102 + }
103 + }
104 + }
105 + }
106 +
107 + return { answer, sources, sessionId };
108 +}
109 +
110 +export function useChatMutation(onChunk?: (data: any) => void) {
111 + return useMutation({
112 + mutationFn: (request: ChatRequest) => streamChat(request, onChunk || (() => {})),
113 + });
114 +}
added client/src/hooks/useChatSession.test.tsx +127 −0
@@ -0,0 +1,127 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/hooks/useChatSession.test.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { describe, it, expect } from "vitest";
18 +import { renderHook, act } from "@testing-library/react";
19 +import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
20 +import type { ReactNode } from "react";
21 +import { useChatSession } from "./useChatSession";
22 +
23 +function createWrapper() {
24 + const queryClient = new QueryClient({
25 + defaultOptions: {
26 + queries: { retry: false, gcTime: 0 },
27 + mutations: { retry: false },
28 + },
29 + });
30 +
31 + return function Wrapper({ children }: { children: ReactNode }) {
32 + return (
33 + <QueryClientProvider client={queryClient}>
34 + {children}
35 + </QueryClientProvider>
36 + );
37 + };
38 +}
39 +
40 +describe("useChatSession", () => {
41 + it("returns initial state", () => {
42 + const { result } = renderHook(() => useChatSession(), {
43 + wrapper: createWrapper(),
44 + });
45 +
46 + expect(result.current.currentUser).toBeNull();
47 + expect(result.current.currentSessionId).toBeNull();
48 + expect(result.current.conversationHistory).toEqual([]);
49 + expect(result.current.currentQuestion).toBe("");
50 + expect(result.current.streamingAnswer).toBe("");
51 + expect(result.current.sources).toEqual([]);
52 + expect(result.current.searchQueries).toEqual([]);
53 + expect(result.current.toolResults).toEqual([]);
54 + expect(result.current.agentSteps).toEqual([]);
55 + expect(result.current.pythonCode).toBe("");
56 + expect(result.current.monteCarloResults).toBeNull();
57 + expect(result.current.optionsPricingResults).toBeNull();
58 + expect(result.current.customPythonFigures).toEqual([]);
59 + expect(result.current.figureUrls).toEqual([]);
60 + expect(result.current.hasSearched).toBe(false);
61 + expect(result.current.historyOpen).toBe(false);
62 + });
63 +
64 + it("handleNewConversation resets all state", () => {
65 + const { result } = renderHook(() => useChatSession(), {
66 + wrapper: createWrapper(),
67 + });
68 +
69 + act(() => {
70 + result.current.handleNewConversation();
71 + });
72 +
73 + expect(result.current.currentSessionId).toBeNull();
74 + expect(result.current.conversationHistory).toEqual([]);
75 + expect(result.current.currentQuestion).toBe("");
76 + expect(result.current.streamingAnswer).toBe("");
77 + expect(result.current.sources).toEqual([]);
78 + expect(result.current.hasSearched).toBe(false);
79 + });
80 +
81 + it("setHistoryOpen toggles history panel", () => {
82 + const { result } = renderHook(() => useChatSession(), {
83 + wrapper: createWrapper(),
84 + });
85 +
86 + expect(result.current.historyOpen).toBe(false);
87 +
88 + act(() => {
89 + result.current.setHistoryOpen(true);
90 + });
91 +
92 + expect(result.current.historyOpen).toBe(true);
93 + });
94 +
95 + it("exposes chatMutation with expected shape", () => {
96 + const { result } = renderHook(() => useChatSession(), {
97 + wrapper: createWrapper(),
98 + });
99 +
100 + expect(result.current.chatMutation).toBeDefined();
101 + expect(typeof result.current.chatMutation.mutate).toBe("function");
102 + expect(typeof result.current.chatMutation.reset).toBe("function");
103 + expect(result.current.chatMutation.isPending).toBe(false);
104 + expect(result.current.chatMutation.isError).toBe(false);
105 + });
106 +
107 + it("exposes all action handlers as functions", () => {
108 + const { result } = renderHook(() => useChatSession(), {
109 + wrapper: createWrapper(),
110 + });
111 +
112 + expect(typeof result.current.handleSearch).toBe("function");
113 + expect(typeof result.current.handleRetry).toBe("function");
114 + expect(typeof result.current.handleNewConversation).toBe("function");
115 + expect(typeof result.current.handleLogout).toBe("function");
116 + expect(typeof result.current.handleLoadSession).toBe("function");
117 + });
118 +
119 + it("resultsRef is a valid ref object", () => {
120 + const { result } = renderHook(() => useChatSession(), {
121 + wrapper: createWrapper(),
122 + });
123 +
124 + expect(result.current.resultsRef).toBeDefined();
125 + expect(result.current.resultsRef.current).toBeNull();
126 + });
127 +});
added client/src/hooks/useChatSession.ts +356 −0
@@ -0,0 +1,356 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/hooks/useChatSession.ts
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { useState, useRef, useCallback } from "react";
18 +import { useQuery, useQueryClient } from "@tanstack/react-query";
19 +import { useChatMutation } from "./useChatMutation";
20 +import { useAnalytics } from "./useAnalytics";
21 +import type { SearchResult } from "@shared/types";
22 +import type { AgentStep } from "@/components/chat/agent-steps";
23 +
24 +export interface SearchQuery {
25 + query: string;
26 + results?: SearchResult[];
27 +}
28 +
29 +export interface ToolResult {
30 + tool: string;
31 + result: unknown;
32 +}
33 +
34 +export interface ConversationMessage {
35 + question: string;
36 + answer: string;
37 +}
38 +
39 +export interface FigureBatch {
40 + id: string;
41 + figures: string[];
42 + output?: string;
43 + description?: string;
44 +}
45 +
46 +const FMP_TOOLS = [
47 + 'get_company_profile', 'get_income_statement', 'get_balance_sheet',
48 + 'get_cash_flow', 'get_key_metrics', 'get_financial_ratios',
49 + 'get_stock_quote', 'get_historical_price', 'search_companies',
50 + 'get_stock_peers', 'get_financial_news', 'get_rsi', 'get_macd',
51 + 'get_ema', 'get_sma', 'get_adx', 'get_williams_r', 'get_cci',
52 + 'get_stochastic', 'get_economic_calendar', 'get_earnings_calendar',
53 + 'get_insider_trading', 'get_forex_quote', 'get_commodity_quotes',
54 + 'get_treasury_rates', 'get_economic_indicator', 'get_earnings_surprises',
55 + 'get_analyst_estimates', 'get_cot_report', 'get_press_releases',
56 + 'get_dividend_history', 'get_stock_split_history', 'get_ipo_calendar',
57 + 'create_plot', 'estimate_garch_volatility', 'calculate_var',
58 + 'optimize_portfolio', 'analyze_risk_metrics', 'firecrawl_scrape',
59 + 'firecrawl_crawl',
60 +] as const;
61 +
62 +function resetChatState() {
63 + return {
64 + streamingAnswer: "",
65 + thinkingContent: "",
66 + sources: [] as SearchResult[],
67 + searchQueries: [] as SearchQuery[],
68 + toolResults: [] as ToolResult[],
69 + agentSteps: [] as AgentStep[],
70 + statusMessage: "",
71 + currentSearchIndex: 0,
72 + pythonCode: "",
73 + monteCarloResults: null as unknown,
74 + optionsPricingResults: null as unknown,
75 + customPythonFigures: [] as FigureBatch[],
76 + figureRegistry: new Map<string, string>(),
77 + figureUrls: [] as string[],
78 + };
79 +}
80 +
81 +export type ClaudeModel =
82 + | 'claude-fable-5' | 'claude-opus-4-6' | 'claude-opus-4-7' | 'claude-opus-4-8' | 'claude-sonnet-4-6' | 'claude-haiku-4-5-20251001'
83 + | 'gpt-5.6-sol' | 'gpt-5.6-terra' | 'gpt-5.6-luna'
84 + | 'gpt-5.5' | 'gpt-5.4' | 'gpt-5.2' | 'gpt-5.1' | 'gpt-5' | 'gpt-5-mini' | 'gpt-5-nano' | 'gpt-4.1' | 'gpt-4.1-mini'
85 + | 'gemini-3.5-flash' | 'gemini-3.1-pro-preview' | 'gemini-3-flash-preview' | 'gemini-3.1-flash-lite';
86 +
87 +export function useChatSession() {
88 + const queryClient = useQueryClient();
89 +
90 + // ─── Session state ──────────────────────────────────────
91 + const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
92 + const [historyOpen, setHistoryOpen] = useState(false);
93 + const [conversationHistory, setConversationHistory] = useState<ConversationMessage[]>([]);
94 + const [currentQuestion, setCurrentQuestion] = useState("");
95 + const [selectedModel, setSelectedModel] = useState<ClaudeModel>(() => {
96 + const stored = localStorage.getItem('vquant-model-v2') as ClaudeModel | null;
97 + return stored ?? 'claude-fable-5';
98 + });
99 +
100 + // ─── Chat response state ────────────────────────────────
101 + const [streamingAnswer, setStreamingAnswer] = useState("");
102 + const [thinkingContent, setThinkingContent] = useState("");
103 + const [sources, setSources] = useState<SearchResult[]>([]);
104 + const [searchQueries, setSearchQueries] = useState<SearchQuery[]>([]);
105 + const [toolResults, setToolResults] = useState<ToolResult[]>([]);
106 + const [agentSteps, setAgentSteps] = useState<AgentStep[]>([]);
107 + const [statusMessage, setStatusMessage] = useState("");
108 + const [currentSearchIndex, setCurrentSearchIndex] = useState(0);
109 + const [pythonCode, setPythonCode] = useState("");
110 + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Python executor returns untyped JSON
111 + const [monteCarloResults, setMonteCarloResults] = useState<any>(null);
112 + // eslint-disable-next-line @typescript-eslint/no-explicit-any
113 + const [optionsPricingResults, setOptionsPricingResults] = useState<any>(null);
114 + const [customPythonFigures, setCustomPythonFigures] = useState<FigureBatch[]>([]);
115 + const [figureRegistry, setFigureRegistry] = useState<Map<string, string>>(new Map());
116 + const [figureUrls, setFigureUrls] = useState<string[]>([]);
117 + const resultsRef = useRef<HTMLDivElement>(null);
118 +
119 + // ─── Auth ───────────────────────────────────────────────
120 + const { data: authData } = useQuery<{ user: { id: string; displayName: string } | null }>({
121 + queryKey: ['/api/auth/me'],
122 + });
123 + const currentUser = authData?.user ?? null;
124 +
125 + // ─── SSE event handler ──────────────────────────────────
126 + const chatMutation = useChatMutation((event) => {
127 + switch (event.type) {
128 + case 'text':
129 + setStreamingAnswer(prev => prev + event.content);
130 + break;
131 + case 'thinking':
132 + setThinkingContent(prev => prev + event.content);
133 + break;
134 + case 'sources':
135 + setSources(event.sources);
136 + break;
137 + case 'done':
138 + if (event.sessionId && !currentSessionId) {
139 + setCurrentSessionId(event.sessionId);
140 + }
141 + break;
142 + case 'status':
143 + setStatusMessage(event.message);
144 + break;
145 + case 'search_queries':
146 + setSearchQueries(event.queries.map((q: string) => ({ query: q })));
147 + setStatusMessage(event.message);
148 + break;
149 + case 'search_progress':
150 + setCurrentSearchIndex(event.index);
151 + setStatusMessage(`Search ${event.index}/${event.total}: ${event.query}`);
152 + break;
153 + case 'search_result':
154 + setSearchQueries(prev => {
155 + const updated = [...prev];
156 + if (updated[event.index - 1]) {
157 + updated[event.index - 1].results = event.results;
158 + }
159 + return updated;
160 + });
161 + break;
162 + case 'tool_start':
163 + setAgentSteps(prev => [...prev, {
164 + tool: event.tool,
165 + status: 'running',
166 + input: event.input,
167 + }]);
168 + break;
169 + case 'tool_complete':
170 + setAgentSteps(prev => prev.map(step =>
171 + step.tool === event.tool && step.status === 'running'
172 + ? { ...step, status: 'completed', duration: event.duration }
173 + : step,
174 + ));
175 + break;
176 + case 'tool_error':
177 + setAgentSteps(prev => prev.map(step =>
178 + step.tool === event.tool && step.status === 'running'
179 + ? { ...step, status: 'error', error: event.error }
180 + : step,
181 + ));
182 + break;
183 + case 'python_code':
184 + setPythonCode(event.code);
185 + break;
186 + case 'custom_python_figures': {
187 + const figId = event.figureId || `fig-${Date.now()}`;
188 + const isPlotTool = figId.startsWith('plot-');
189 +
190 + if (!isPlotTool) {
191 + setCustomPythonFigures(prev => [...prev, {
192 + id: figId,
193 + figures: event.figures || [],
194 + output: event.output,
195 + description: event.description,
196 + }]);
197 + }
198 +
199 + const urls = event.figureUrls || event.figures || [];
200 + if (urls.length > 0) {
201 + setFigureRegistry(prev => {
202 + const next = new Map(prev);
203 + urls.forEach((url: string, idx: number) => next.set(`${figId}-${idx}`, url));
204 + return next;
205 + });
206 + setFigureUrls(prev => [...prev, ...urls]);
207 + }
208 + break;
209 + }
210 + case 'tool_result':
211 + if (event.tool === 'run_monte_carlo_simulation') {
212 + setMonteCarloResults(event.result);
213 + } else if (event.tool === 'calculate_options_price') {
214 + setOptionsPricingResults(event.result);
215 + } else if (FMP_TOOLS.includes(event.tool)) {
216 + setToolResults(prev => [...prev, { tool: event.tool, result: event.result }]);
217 + }
218 + break;
219 + }
220 + });
221 +
222 + // ─── Analytics ──────────────────────────────────────────
223 + const analyticsStatus = chatMutation.isPending ? 'generating' : chatMutation.isError ? 'error' : 'idle';
224 + useAnalytics(analyticsStatus, chatMutation.isPending ? currentQuestion : undefined);
225 +
226 + // ─── Derived state ──────────────────────────────────────
227 + const hasSearched = streamingAnswer.length > 0 || chatMutation.isPending || chatMutation.isError || searchQueries.length > 0;
228 +
229 + // ─── Actions ────────────────────────────────────────────
230 + const clearResponseState = useCallback(() => {
231 + setStreamingAnswer("");
232 + setThinkingContent("");
233 + setSources([]);
234 + setSearchQueries([]);
235 + setToolResults([]);
236 + setAgentSteps([]);
237 + setStatusMessage("");
238 + setCurrentSearchIndex(0);
239 + setPythonCode("");
240 + setMonteCarloResults(null);
241 + setOptionsPricingResults(null);
242 + setCustomPythonFigures([]);
243 + setFigureRegistry(new Map());
244 + setFigureUrls([]);
245 + }, []);
246 +
247 + const handleSearch = useCallback((query: string, imageData?: string, imageMimeType?: string) => {
248 + if (currentQuestion && streamingAnswer) {
249 + setConversationHistory(prev => [...prev, {
250 + question: currentQuestion,
251 + answer: streamingAnswer,
252 + }]);
253 + }
254 +
255 + setCurrentQuestion(query);
256 + clearResponseState();
257 +
258 + chatMutation.mutate({
259 + query,
260 + history: currentQuestion && streamingAnswer
261 + ? [...conversationHistory, { question: currentQuestion, answer: streamingAnswer }]
262 + : conversationHistory,
263 + sessionId: currentSessionId || undefined,
264 + imageData,
265 + imageMimeType,
266 + model: selectedModel,
267 + });
268 + }, [currentQuestion, streamingAnswer, conversationHistory, currentSessionId, clearResponseState, chatMutation, selectedModel]);
269 +
270 + const handleRetry = useCallback(() => {
271 + chatMutation.reset();
272 + clearResponseState();
273 + }, [chatMutation, clearResponseState]);
274 +
275 + const handleNewConversation = useCallback(() => {
276 + setCurrentSessionId(null);
277 + setConversationHistory([]);
278 + setCurrentQuestion("");
279 + clearResponseState();
280 + chatMutation.reset();
281 + }, [clearResponseState, chatMutation]);
282 +
283 + const handleLogout = useCallback(async () => {
284 + await fetch('/api/auth/logout', { method: 'POST' });
285 + queryClient.invalidateQueries({ queryKey: ['/api/auth/me'] });
286 + queryClient.invalidateQueries({ queryKey: ['/api/sessions'] });
287 + }, [queryClient]);
288 +
289 + const handleLoadSession = useCallback(async (sessionId: string) => {
290 + try {
291 + const response = await fetch(`/api/sessions/${sessionId}`);
292 + if (!response.ok) return;
293 + const session = await response.json();
294 + setCurrentSessionId(session.sessionId);
295 + if (session.messages) {
296 + const msgs = typeof session.messages === 'string' ? JSON.parse(session.messages) : session.messages;
297 + setConversationHistory(msgs.map((m: { question?: string; answer?: string }) => ({
298 + question: m.question || '',
299 + answer: m.answer || '',
300 + })));
301 + }
302 + setHistoryOpen(false);
303 + } catch (error) {
304 + console.error('Failed to load session:', error);
305 + }
306 + }, []);
307 +
308 + const handleModelChange = useCallback((model: ClaudeModel) => {
309 + setSelectedModel(model);
310 + localStorage.setItem('vquant-model-v2', model);
311 + }, []);
312 +
313 + return {
314 + // Auth
315 + currentUser,
316 + handleLogout,
317 +
318 + // Session
319 + currentSessionId,
320 + historyOpen,
321 + setHistoryOpen,
322 + handleLoadSession,
323 +
324 + // Chat state
325 + conversationHistory,
326 + currentQuestion,
327 + streamingAnswer,
328 + thinkingContent,
329 + sources,
330 + searchQueries,
331 + toolResults,
332 + agentSteps,
333 + statusMessage,
334 + currentSearchIndex,
335 + pythonCode,
336 + monteCarloResults,
337 + optionsPricingResults,
338 + customPythonFigures,
339 + figureRegistry,
340 + figureUrls,
341 + resultsRef,
342 +
343 + // Model selection
344 + selectedModel,
345 + handleModelChange,
346 +
347 + // Mutation state
348 + chatMutation,
349 + hasSearched,
350 +
351 + // Actions
352 + handleSearch,
353 + handleRetry,
354 + handleNewConversation,
355 + };
356 +}
added client/src/index.css +541 −0
@@ -0,0 +1,541 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/index.css
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500&display=swap');
18 +@tailwind base;
19 +@tailwind components;
20 +@tailwind utilities;
21 +
22 +
23 +/* LIGHT MODE */
24 +:root {
25 + --button-outline: rgba(0,0,0, .10);
26 + --badge-outline: rgba(0,0,0, .05);
27 +
28 + /* Automatic computation of border around primary / danger buttons */
29 + --opaque-button-border-intensity: -8; /* In terms of percentages */
30 +
31 + /* Backgrounds applied on top of other backgrounds when hovered/active */
32 + --elevate-1: rgba(0,0,0, .03);
33 + --elevate-2: rgba(0,0,0, .08);
34 +
35 + --background: 210 20% 98%;
36 +
37 + --foreground: 222 47% 11%;
38 +
39 + --border: 214 20% 88%;
40 +
41 + --card: 0 0% 100%;
42 +
43 + --card-foreground: 222 47% 11%;
44 +
45 + --card-border: 214 20% 92%;
46 +
47 + --sidebar: 210 25% 95%;
48 +
49 + --sidebar-foreground: 222 47% 11%;
50 +
51 + --sidebar-border: 214 20% 86%;
52 +
53 + --sidebar-primary: 250 84% 60%;
54 +
55 + --sidebar-primary-foreground: 210 20% 98%;
56 +
57 + --sidebar-accent: 210 25% 90%;
58 +
59 + --sidebar-accent-foreground: 222 47% 11%;
60 +
61 + --sidebar-ring: 250 84% 60%;
62 +
63 + --popover: 210 25% 93%;
64 +
65 + --popover-foreground: 222 47% 11%;
66 +
67 + --popover-border: 214 20% 84%;
68 +
69 + --primary: 250 84% 60%;
70 +
71 + --primary-foreground: 210 20% 98%;
72 +
73 + --secondary: 210 25% 88%;
74 +
75 + --secondary-foreground: 222 47% 11%;
76 +
77 + --muted: 215 16% 90%;
78 +
79 + --muted-foreground: 215 14% 45%;
80 +
81 + --accent: 210 20% 92%;
82 +
83 + --accent-foreground: 222 47% 11%;
84 +
85 + --destructive: 0 84% 50%;
86 +
87 + --destructive-foreground: 0 0% 98%;
88 +
89 + --success: 142 76% 36%;
90 +
91 + --success-foreground: 210 20% 98%;
92 +
93 + --warning: 38 92% 50%;
94 +
95 + --warning-foreground: 222 47% 11%;
96 +
97 + --info: 199 89% 48%;
98 +
99 + --info-foreground: 210 20% 98%;
100 +
101 + --input: 214 20% 75%;
102 + --ring: 250 84% 60%;
103 + --chart-1: 210 100% 45%;
104 + --chart-2: 142 76% 36%;
105 + --chart-3: 199 89% 48%;
106 + --chart-4: 38 92% 50%;
107 + --chart-5: 200 90% 50%;
108 +
109 + --font-sans: Inter, -apple-system, BlinkMacSystemFont, sans-serif;
110 + --font-display: Inter, -apple-system, BlinkMacSystemFont, sans-serif;
111 + --font-serif: Georgia, serif;
112 + --font-mono: JetBrains Mono, Menlo, monospace;
113 + --radius: 0.9rem;
114 + --shadow-2xs: 0px 1px 2px 0px hsl(222 15% 8% / 0.05);
115 + --shadow-xs: 0px 1px 3px 0px hsl(222 15% 8% / 0.08);
116 + --shadow-sm: 0px 2px 4px -1px hsl(222 15% 8% / 0.06), 0px 1px 2px -1px hsl(222 15% 8% / 0.08);
117 + --shadow: 0px 4px 6px -1px hsl(222 15% 8% / 0.08), 0px 2px 4px -2px hsl(222 15% 8% / 0.06);
118 + --shadow-md: 0px 6px 12px -2px hsl(222 15% 8% / 0.12), 0px 3px 7px -3px hsl(222 15% 8% / 0.08);
119 + --shadow-lg: 0px 12px 24px -4px hsl(222 15% 8% / 0.18), 0px 6px 12px -4px hsl(222 15% 8% / 0.12);
120 + --shadow-xl: 0px 24px 40px -8px hsl(222 15% 8% / 0.24), 0px 10px 20px -6px hsl(222 15% 8% / 0.16);
121 + --shadow-2xl: 0px 32px 64px -12px hsl(222 15% 8% / 0.30);
122 + --tracking-normal: 0em;
123 + --spacing: 0.25rem;
124 +
125 + /* Automatically computed borders - intensity can be controlled by the user by the --opaque-button-border-intensity setting */
126 +
127 + /* Fallback for older browsers */
128 + --sidebar-primary-border: hsl(var(--sidebar-primary));
129 + --sidebar-primary-border: hsl(from hsl(var(--sidebar-primary)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);
130 +
131 + /* Fallback for older browsers */
132 + --sidebar-accent-border: hsl(var(--sidebar-accent));
133 + --sidebar-accent-border: hsl(from hsl(var(--sidebar-accent)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);
134 +
135 + /* Fallback for older browsers */
136 + --primary-border: hsl(var(--primary));
137 + --primary-border: hsl(from hsl(var(--primary)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);
138 +
139 + /* Fallback for older browsers */
140 + --secondary-border: hsl(var(--secondary));
141 + --secondary-border: hsl(from hsl(var(--secondary)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);
142 +
143 + /* Fallback for older browsers */
144 + --muted-border: hsl(var(--muted));
145 + --muted-border: hsl(from hsl(var(--muted)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);
146 +
147 + /* Fallback for older browsers */
148 + --accent-border: hsl(var(--accent));
149 + --accent-border: hsl(from hsl(var(--accent)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);
150 +
151 + /* Fallback for older browsers */
152 + --destructive-border: hsl(var(--destructive));
153 + --destructive-border: hsl(from hsl(var(--destructive)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);
154 +}
155 +
156 +.dark {
157 + --button-outline: rgba(255,255,255, .10);
158 + --badge-outline: rgba(255,255,255, .05);
159 +
160 + --opaque-button-border-intensity: 9; /* In terms of percentages */
161 +
162 + /* Backgrounds applied on top of other backgrounds when hovered/active */
163 + --elevate-1: rgba(255,255,255, .04);
164 + --elevate-2: rgba(255,255,255, .09);
165 +
166 + --background: 222 15% 8%;
167 +
168 + --foreground: 210 20% 98%;
169 +
170 + --border: 217 19% 20%;
171 +
172 + --card: 222 15% 11%;
173 +
174 + --card-foreground: 210 20% 98%;
175 +
176 + --card-border: 217 19% 24%;
177 +
178 + --sidebar: 222 15% 15%;
179 +
180 + --sidebar-foreground: 210 20% 98%;
181 +
182 + --sidebar-border: 217 19% 28%;
183 +
184 + --sidebar-primary: 250 84% 66%;
185 +
186 + --sidebar-primary-foreground: 210 20% 98%;
187 +
188 + --sidebar-accent: 222 15% 18%;
189 +
190 + --sidebar-accent-foreground: 210 20% 98%;
191 +
192 + --sidebar-ring: 250 84% 66%;
193 +
194 + --popover: 222 15% 18%;
195 +
196 + --popover-foreground: 210 20% 98%;
197 +
198 + --popover-border: 217 19% 32%;
199 +
200 + --primary: 250 84% 60%;
201 +
202 + --primary-foreground: 210 20% 98%;
203 +
204 + --secondary: 222 15% 22%;
205 +
206 + --secondary-foreground: 210 20% 98%;
207 +
208 + --muted: 217 19% 20%;
209 +
210 + --muted-foreground: 215 16% 65%;
211 +
212 + --accent: 222 12% 20%;
213 +
214 + --accent-foreground: 210 20% 98%;
215 +
216 + --destructive: 0 84% 60%;
217 +
218 + --destructive-foreground: 210 20% 98%;
219 +
220 + --success: 142 76% 55%;
221 +
222 + --success-foreground: 210 20% 98%;
223 +
224 + --warning: 38 92% 65%;
225 +
226 + --warning-foreground: 222 15% 8%;
227 +
228 + --info: 199 89% 60%;
229 +
230 + --info-foreground: 210 20% 98%;
231 +
232 + --input: 217 19% 35%;
233 + --ring: 250 84% 66%;
234 + --chart-1: 210 100% 65%;
235 + --chart-2: 142 76% 55%;
236 + --chart-3: 199 89% 60%;
237 + --chart-4: 38 92% 65%;
238 + --chart-5: 200 90% 65%;
239 +
240 + --shadow-2xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.25);
241 + --shadow-xs: 0px 1px 3px 0px hsl(0 0% 0% / 0.30);
242 + --shadow-sm: 0px 2px 4px -1px hsl(0 0% 0% / 0.28), 0px 1px 2px -1px hsl(0 0% 0% / 0.35);
243 + --shadow: 0px 4px 6px -1px hsl(0 0% 0% / 0.35), 0px 2px 4px -2px hsl(0 0% 0% / 0.30);
244 + --shadow-md: 0px 6px 12px -2px hsl(0 0% 0% / 0.40), 0px 3px 7px -3px hsl(0 0% 0% / 0.35);
245 + --shadow-lg: 0px 12px 28px -4px hsl(0 0% 0% / 0.48), 0px 6px 14px -4px hsl(0 0% 0% / 0.40);
246 + --shadow-xl: 0px 24px 48px -8px hsl(0 0% 0% / 0.56), 0px 12px 24px -6px hsl(0 0% 0% / 0.48);
247 + --shadow-2xl: 0px 36px 72px -12px hsl(0 0% 0% / 0.64);
248 +
249 + /* Automatically computed borders - intensity can be controlled by the user by the --opaque-button-border-intensity setting */
250 + --sidebar-primary-border: hsl(from hsl(var(--sidebar-primary)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);
251 + --sidebar-accent-border: hsl(from hsl(var(--sidebar-accent)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);
252 + --primary-border: hsl(from hsl(var(--primary)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);
253 + --secondary-border: hsl(from hsl(var(--secondary)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);
254 + --muted-border: hsl(from hsl(var(--muted)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);
255 + --accent-border: hsl(from hsl(var(--accent)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);
256 + --destructive-border: hsl(from hsl(var(--destructive)) h s calc(l + var(--opaque-button-border-intensity)) / alpha);
257 +}
258 +
259 +@layer base {
260 + * {
261 + @apply border-border;
262 + }
263 +
264 + body {
265 + @apply font-sans antialiased bg-background text-foreground;
266 + background-image:
267 + radial-gradient(1100px 520px at 100% -8%, hsl(var(--primary) / 0.06), transparent 60%),
268 + radial-gradient(900px 480px at 0% 0%, hsl(var(--info) / 0.05), transparent 55%);
269 + background-attachment: fixed;
270 + }
271 +}
272 +
273 +/**
274 + * Using the elevate system.
275 + * Automatic contrast adjustment.
276 + *
277 + * <element className="hover-elevate" />
278 + * <element className="active-elevate-2" />
279 + *
280 + * // Using the tailwind utility when a data attribute is "on"
281 + * <element className="toggle-elevate data-[state=on]:toggle-elevated" />
282 + * // Or manually controlling the toggle state
283 + * <element className="toggle-elevate toggle-elevated" />
284 + *
285 + * Elevation systems have to handle many states.
286 + * - not-hovered, vs. hovered vs. active (three mutually exclusive states)
287 + * - toggled or not
288 + * - focused or not (this is not handled with these utilities)
289 + *
290 + * Even without handling focused or not, this is six possible combinations that
291 + * need to be distinguished from eachother visually.
292 + */
293 +@layer utilities {
294 +
295 + /* Hide ugly search cancel button in Chrome until we can style it properly */
296 + input[type="search"]::-webkit-search-cancel-button {
297 + @apply hidden;
298 + }
299 +
300 + /* Placeholder styling for contentEditable div */
301 + [contenteditable][data-placeholder]:empty::before {
302 + content: attr(data-placeholder);
303 + color: hsl(var(--muted-foreground));
304 + pointer-events: none;
305 + }
306 +
307 + /* .no-default-hover-elevate/no-default-active-elevate is an escape hatch so consumers of
308 + * buttons/badges can remove the automatic brightness adjustment on interactions
309 + * and program their own. */
310 + .no-default-hover-elevate {}
311 +
312 + .no-default-active-elevate {}
313 +
314 +
315 + /**
316 + * Toggleable backgrounds go behind the content. Hoverable/active goes on top.
317 + * This way they can stack/compound. Both will overlap the parent's borders!
318 + * So borders will be automatically adjusted both on toggle, and hover/active,
319 + * and they will be compounded.
320 + */
321 + .toggle-elevate::before,
322 + .toggle-elevate-2::before {
323 + content: "";
324 + pointer-events: none;
325 + position: absolute;
326 + inset: 0px;
327 + /*border-radius: inherit; match rounded corners */
328 + border-radius: inherit;
329 + z-index: -1;
330 + /* sits behind content but above backdrop */
331 + }
332 +
333 + .toggle-elevate.toggle-elevated::before {
334 + background-color: var(--elevate-2);
335 + }
336 +
337 + /* If there's a 1px border, adjust the inset so that it covers that parent's border */
338 + .border.toggle-elevate::before {
339 + inset: -1px;
340 + }
341 +
342 + /* Does not work on elements with overflow:hidden! */
343 + .hover-elevate:not(.no-default-hover-elevate),
344 + .active-elevate:not(.no-default-active-elevate),
345 + .hover-elevate-2:not(.no-default-hover-elevate),
346 + .active-elevate-2:not(.no-default-active-elevate) {
347 + position: relative;
348 + z-index: 0;
349 + }
350 +
351 + .hover-elevate:not(.no-default-hover-elevate)::after,
352 + .active-elevate:not(.no-default-active-elevate)::after,
353 + .hover-elevate-2:not(.no-default-hover-elevate)::after,
354 + .active-elevate-2:not(.no-default-active-elevate)::after {
355 + content: "";
356 + pointer-events: none;
357 + position: absolute;
358 + inset: 0px;
359 + /*border-radius: inherit; match rounded corners */
360 + border-radius: inherit;
361 + z-index: 999;
362 + /* sits in front of content */
363 + }
364 +
365 + .hover-elevate:hover:not(.no-default-hover-elevate)::after,
366 + .active-elevate:active:not(.no-default-active-elevate)::after {
367 + background-color: var(--elevate-1);
368 + }
369 +
370 + .hover-elevate-2:hover:not(.no-default-hover-elevate)::after,
371 + .active-elevate-2:active:not(.no-default-active-elevate)::after {
372 + background-color: var(--elevate-2);
373 + }
374 +
375 + /* If there's a 1px border, adjust the inset so that it covers that parent's border */
376 + .border.hover-elevate:not(.no-hover-interaction-elevate)::after,
377 + .border.active-elevate:not(.no-active-interaction-elevate)::after,
378 + .border.hover-elevate-2:not(.no-hover-interaction-elevate)::after,
379 + .border.active-elevate-2:not(.no-active-interaction-elevate)::after,
380 + .border.hover-elevate:not(.no-hover-interaction-elevate)::after {
381 + inset: -1px;
382 + }
383 +
384 + @keyframes fade-in {
385 + from {
386 + opacity: 0;
387 + transform: translateY(10px);
388 + }
389 + to {
390 + opacity: 1;
391 + transform: translateY(0);
392 + }
393 + }
394 +
395 + .animate-fade-in {
396 + animation: fade-in 0.6s ease-out;
397 + }
398 +
399 + /* Glass effect */
400 + .glass {
401 + background: hsl(var(--card) / 0.95);
402 + border: 1px solid hsl(var(--border));
403 + }
404 +
405 + /* Premium card effect */
406 + .card-premium {
407 + background: hsl(var(--card));
408 + border: 1px solid hsl(var(--border));
409 + transition: border-color 0.2s ease, box-shadow 0.2s ease;
410 + }
411 +
412 + .card-premium:hover {
413 + border-color: hsl(var(--primary) / 0.4);
414 + box-shadow: var(--shadow-md);
415 + }
416 +}
417 +
418 +/* ── Modern polish: thin rounded scrollbars + selection ── */
419 +* {
420 + scrollbar-width: thin;
421 + scrollbar-color: hsl(var(--muted-foreground) / 0.3) transparent;
422 +}
423 +*::-webkit-scrollbar { width: 10px; height: 10px; }
424 +*::-webkit-scrollbar-track { background: transparent; }
425 +*::-webkit-scrollbar-thumb {
426 + background: hsl(var(--muted-foreground) / 0.28);
427 + border-radius: 9999px;
428 + border: 2px solid transparent;
429 + background-clip: content-box;
430 +}
431 +*::-webkit-scrollbar-thumb:hover {
432 + background: hsl(var(--muted-foreground) / 0.45);
433 + background-clip: content-box;
434 +}
435 +::selection { background: hsl(var(--primary) / 0.22); }
436 +
437 +/* ════════════════ VQuant × hot-sunset reskin (vcrawl-inspired) ════════════════ */
438 +/* Unlayered + appended last → overrides the earlier token blocks. */
439 +:root {
440 + --background: 40 30% 97%;
441 + --foreground: 24 14% 10%;
442 + --border: 36 22% 86%;
443 + --input: 36 20% 80%;
444 + --card: 0 0% 100%;
445 + --card-foreground: 24 14% 10%;
446 + --card-border: 36 22% 89%;
447 + --popover: 0 0% 100%;
448 + --popover-foreground: 24 14% 10%;
449 + --popover-border: 36 22% 86%;
450 + --primary: 14 100% 52%;
451 + --primary-foreground: 0 0% 100%;
452 + --secondary: 38 26% 92%;
453 + --secondary-foreground: 24 14% 12%;
454 + --muted: 38 26% 93%;
455 + --muted-foreground: 28 8% 42%;
456 + --accent: 38 30% 91%;
457 + --accent-foreground: 24 14% 12%;
458 + --warning: 34 100% 50%;
459 + --warning-foreground: 24 14% 10%;
460 + --ring: 14 100% 52%;
461 + --sidebar: 40 28% 95%;
462 + --sidebar-foreground: 24 14% 12%;
463 + --sidebar-border: 36 22% 86%;
464 + --sidebar-primary: 14 100% 52%;
465 + --sidebar-primary-foreground: 0 0% 100%;
466 + --sidebar-accent: 38 26% 90%;
467 + --sidebar-accent-foreground: 24 14% 12%;
468 + --sidebar-ring: 14 100% 52%;
469 + --chart-1: 14 100% 55%;
470 + --chart-2: 34 100% 55%;
471 + --chart-3: 338 90% 58%;
472 + --chart-4: 158 64% 42%;
473 + --chart-5: 199 89% 48%;
474 +}
475 +.dark {
476 + --background: 240 22% 5%;
477 + --foreground: 240 20% 98%;
478 + --border: 240 14% 16%;
479 + --input: 240 14% 20%;
480 + --card: 240 21% 9%;
481 + --card-foreground: 240 20% 98%;
482 + --card-border: 240 14% 18%;
483 + --popover: 240 18% 12%;
484 + --popover-foreground: 240 20% 98%;
485 + --popover-border: 240 14% 20%;
486 + --primary: 14 100% 56%;
487 + --primary-foreground: 0 0% 100%;
488 + --secondary: 240 16% 13%;
489 + --secondary-foreground: 240 20% 98%;
490 + --muted: 240 14% 16%;
491 + --muted-foreground: 230 14% 62%;
492 + --accent: 240 16% 16%;
493 + --accent-foreground: 240 20% 98%;
494 + --warning: 34 100% 60%;
495 + --warning-foreground: 240 22% 6%;
496 + --ring: 14 100% 56%;
497 + --sidebar: 240 22% 7%;
498 + --sidebar-foreground: 240 20% 98%;
499 + --sidebar-border: 240 14% 16%;
500 + --sidebar-primary: 14 100% 56%;
501 + --sidebar-primary-foreground: 0 0% 100%;
502 + --sidebar-accent: 240 16% 14%;
503 + --sidebar-accent-foreground: 240 20% 98%;
504 + --sidebar-ring: 14 100% 56%;
505 + --chart-1: 14 100% 60%;
506 + --chart-2: 34 100% 60%;
507 + --chart-3: 338 100% 66%;
508 + --chart-4: 158 64% 50%;
509 + --chart-5: 199 89% 62%;
510 +}
511 +
512 +body {
513 + background-image:
514 + radial-gradient(72% 55% at 50% -8%, hsl(var(--primary) / 0.16), transparent 70%),
515 + radial-gradient(42% 42% at 100% 0%, hsl(338 100% 60% / 0.08), transparent 60%);
516 + background-attachment: fixed;
517 +}
518 +
519 +/* ── hot-sunset wow utilities ── */
520 +.bg-hot { background-image: linear-gradient(120deg, #ff9f1c 0%, #ff4d1c 46%, #ff2d78 100%); }
521 +.gradient-text {
522 + background-image: linear-gradient(120deg, #ff9f1c 0%, #ff4d1c 46%, #ff2d78 100%);
523 + -webkit-background-clip: text; background-clip: text; color: transparent;
524 +}
525 +.shadow-hot { box-shadow: 0 8px 30px -6px rgba(255,77,28,0.55); }
526 +.glow-ring { box-shadow: 0 0 0 1px hsl(var(--primary) / 0.35), 0 14px 48px -12px hsl(var(--primary) / 0.5); }
527 +.bg-grid {
528 + background-image:
529 + linear-gradient(hsl(var(--primary) / 0.12) 1px, transparent 1px),
530 + linear-gradient(90deg, hsl(var(--primary) / 0.12) 1px, transparent 1px);
531 + background-size: 44px 44px;
532 +}
533 +.hero-mask {
534 + -webkit-mask-image: radial-gradient(60% 60% at 50% 30%, black, transparent 75%);
535 + mask-image: radial-gradient(60% 60% at 50% 30%, black, transparent 75%);
536 +}
537 +@keyframes vq-float { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-5px); } }
538 +.animate-float { animation: vq-float 4s ease-in-out infinite; }
539 +@keyframes vq-pulse-ring { 0% { box-shadow: 0 0 0 0 rgba(255,77,28,0.45); } 70% { box-shadow: 0 0 0 10px rgba(255,77,28,0); } 100% { box-shadow: 0 0 0 0 rgba(255,77,28,0); } }
540 +.animate-pulse-ring { animation: vq-pulse-ring 2.2s infinite; }
541 +
added client/src/lib/queryClient.test.ts +49 −0
@@ -0,0 +1,49 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/lib/queryClient.test.ts
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { describe, it, expect } from "vitest";
18 +import { queryClient } from "./queryClient";
19 +
20 +describe("queryClient", () => {
21 + it("is defined", () => {
22 + expect(queryClient).toBeDefined();
23 + });
24 +
25 + it("has staleTime set to 5 minutes", () => {
26 + const defaults = queryClient.getDefaultOptions();
27 + expect(defaults.queries?.staleTime).toBe(5 * 60 * 1000);
28 + });
29 +
30 + it("has gcTime set to 30 minutes", () => {
31 + const defaults = queryClient.getDefaultOptions();
32 + expect(defaults.queries?.gcTime).toBe(30 * 60 * 1000);
33 + });
34 +
35 + it("has retry set to 1", () => {
36 + const defaults = queryClient.getDefaultOptions();
37 + expect(defaults.queries?.retry).toBe(1);
38 + });
39 +
40 + it("has mutation retry disabled", () => {
41 + const defaults = queryClient.getDefaultOptions();
42 + expect(defaults.mutations?.retry).toBe(false);
43 + });
44 +
45 + it("does not refetch on window focus", () => {
46 + const defaults = queryClient.getDefaultOptions();
47 + expect(defaults.queries?.refetchOnWindowFocus).toBe(false);
48 + });
49 +});
added client/src/lib/queryClient.ts +74 −0
@@ -0,0 +1,74 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/lib/queryClient.ts
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { QueryClient, type QueryFunction } from "@tanstack/react-query";
18 +
19 +async function throwIfResNotOk(res: Response) {
20 + if (!res.ok) {
21 + const text = (await res.text()) || res.statusText;
22 + throw new Error(`${res.status}: ${text}`);
23 + }
24 +}
25 +
26 +export async function apiRequest(
27 + method: string,
28 + url: string,
29 + data?: unknown,
30 +): Promise<Response> {
31 + const res = await fetch(url, {
32 + method,
33 + headers: data ? { "Content-Type": "application/json" } : {},
34 + body: data ? JSON.stringify(data) : undefined,
35 + credentials: "include",
36 + });
37 +
38 + await throwIfResNotOk(res);
39 + return res;
40 +}
41 +
42 +type UnauthorizedBehavior = "returnNull" | "throw";
43 +
44 +export const getQueryFn: <T>(options: {
45 + on401: UnauthorizedBehavior;
46 +}) => QueryFunction<T> =
47 + ({ on401: unauthorizedBehavior }) =>
48 + async ({ queryKey }) => {
49 + const res = await fetch(queryKey.join("/") as string, {
50 + credentials: "include",
51 + });
52 +
53 + if (unauthorizedBehavior === "returnNull" && res.status === 401) {
54 + return null;
55 + }
56 +
57 + await throwIfResNotOk(res);
58 + return await res.json();
59 + };
60 +
61 +export const queryClient = new QueryClient({
62 + defaultOptions: {
63 + queries: {
64 + queryFn: getQueryFn({ on401: "returnNull" }),
65 + refetchOnWindowFocus: false,
66 + staleTime: 5 * 60 * 1000, // 5 minutes — reasonable for financial data
67 + gcTime: 30 * 60 * 1000, // 30 minutes garbage collection
68 + retry: 1, // 1 retry on transient failures
69 + },
70 + mutations: {
71 + retry: false,
72 + },
73 + },
74 +});
added client/src/lib/utils.test.ts +41 −0
@@ -0,0 +1,41 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/lib/utils.test.ts
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { describe, it, expect } from "vitest";
18 +import { cn } from "./utils";
19 +
20 +describe("cn (className merge)", () => {
21 + it("merges class names", () => {
22 + expect(cn("px-2", "py-1")).toBe("px-2 py-1");
23 + });
24 +
25 + it("handles conditional classes", () => {
26 + const isHidden = false;
27 + expect(cn("base", isHidden && "hidden", "extra")).toBe("base extra");
28 + });
29 +
30 + it("deduplicates tailwind conflicts", () => {
31 + expect(cn("px-2", "px-4")).toBe("px-4");
32 + });
33 +
34 + it("handles empty inputs", () => {
35 + expect(cn()).toBe("");
36 + });
37 +
38 + it("handles undefined and null", () => {
39 + expect(cn("base", undefined, null)).toBe("base");
40 + });
41 +});
added client/src/lib/utils.ts +22 −0
@@ -0,0 +1,22 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/lib/utils.ts
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { clsx, type ClassValue } from "clsx"
18 +import { twMerge } from "tailwind-merge"
19 +
20 +export function cn(...inputs: ClassValue[]) {
21 + return twMerge(clsx(inputs))
22 +}
added client/src/main.tsx +21 −0
@@ -0,0 +1,21 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/main.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { createRoot } from "react-dom/client";
18 +import App from "./App";
19 +import "./index.css";
20 +
21 +createRoot(document.getElementById("root")!).render(<App />);
added client/src/pages/admin.tsx +791 −0
@@ -0,0 +1,791 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/pages/admin.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { useState, useEffect } from "react";
18 +import { useLocation } from "wouter";
19 +import { Shield, Database, FileText, MessageSquare, Trash2, Sparkles, DollarSign, TrendingUp, Users as UsersIcon, Activity, Zap, AlertCircle, RefreshCw } from "lucide-react";
20 +import { Button } from "@/components/ui/button";
21 +import { Input } from "@/components/ui/input";
22 +import { Label } from "@/components/ui/label";
23 +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
24 +import { Alert, AlertDescription } from "@/components/ui/alert";
25 +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
26 +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
27 +import { Badge } from "@/components/ui/badge";
28 +
29 +interface User {
30 + id: string;
31 + displayName: string;
32 + createdAt: string;
33 +}
34 +
35 +interface Session {
36 + id: string;
37 + sessionId: string;
38 + userId: string | null;
39 + title: string;
40 + createdAt: string;
41 + updatedAt: string;
42 + inputTokens: number;
43 + outputTokens: number;
44 + totalCost: number;
45 +}
46 +
47 +interface SharedReport {
48 + id: string;
49 + shareId: string;
50 + question: string;
51 + createdAt: string;
52 +}
53 +
54 +interface UserMetric {
55 + userId: string;
56 + displayName: string;
57 + inputTokens: number;
58 + outputTokens: number;
59 + totalTokens: number;
60 + cost: number;
61 + sessionCount: number;
62 +}
63 +
64 +interface Metrics {
65 + totalInputTokens: number;
66 + totalOutputTokens: number;
67 + totalTokens: number;
68 + totalCost: number;
69 + totalSessions: number;
70 + averageCostPerSession: number;
71 + userMetrics: UserMetric[];
72 +}
73 +
74 +interface DatabaseData {
75 + users: User[];
76 + sessions: Session[];
77 + sharedReports: SharedReport[];
78 + metrics: Metrics;
79 +}
80 +
81 +interface ActiveUsersData {
82 + total: number;
83 + generating: number;
84 + idle: number;
85 + error: number;
86 + users: Array<{
87 + sessionId: string;
88 + userId: string | null;
89 + status: string;
90 + currentQuery: string | null;
91 + lastHeartbeat: string;
92 + userAgent: string | null;
93 + }>;
94 +}
95 +
96 +interface RealTimeStats {
97 + activeUsers: {
98 + total: number;
99 + generating: number;
100 + };
101 + sessions: {
102 + total: number;
103 + today: number;
104 + };
105 + tokens: {
106 + total: number;
107 + input: number;
108 + output: number;
109 + };
110 + cost: {
111 + total: number;
112 + today: number;
113 + };
114 + errors: {
115 + lastHour: number;
116 + rate: number;
117 + };
118 +}
119 +
120 +export default function Admin() {
121 + const [, setLocation] = useLocation();
122 + const [isAuthenticated, setIsAuthenticated] = useState(false);
123 + const [password, setPassword] = useState("");
124 + const [error, setError] = useState("");
125 + const [isLoading, setIsLoading] = useState(true);
126 + const [databaseData, setDatabaseData] = useState<DatabaseData | null>(null);
127 + const [activeUsersData, setActiveUsersData] = useState<ActiveUsersData | null>(null);
128 + const [realTimeStats, setRealTimeStats] = useState<RealTimeStats | null>(null);
129 + const [autoRefresh, setAutoRefresh] = useState(true);
130 +
131 + // Auto-login on mount (no password required)
132 + useEffect(() => {
133 + const autoLogin = async () => {
134 + try {
135 + const response = await fetch("/api/admin/login", {
136 + method: "POST",
137 + headers: {
138 + "Content-Type": "application/json",
139 + },
140 + body: JSON.stringify({}),
141 + });
142 +
143 + if (response.ok) {
144 + setIsAuthenticated(true);
145 + await refreshAllData();
146 + }
147 + } catch (err) {
148 + console.error("Auto-login failed:", err);
149 + } finally {
150 + setIsLoading(false);
151 + }
152 + };
153 +
154 + autoLogin();
155 + }, []);
156 +
157 + // Auto-refresh data every 10 seconds if enabled
158 + useEffect(() => {
159 + if (!isAuthenticated || !autoRefresh) return;
160 +
161 + const interval = setInterval(() => {
162 + refreshAllData();
163 + }, 10000); // Refresh every 10 seconds
164 +
165 + return () => clearInterval(interval);
166 + }, [isAuthenticated, autoRefresh]);
167 +
168 + const handleLogin = async (e: React.FormEvent) => {
169 + e.preventDefault();
170 + setError("");
171 + setIsLoading(true);
172 +
173 + try {
174 + const response = await fetch("/api/admin/login", {
175 + method: "POST",
176 + headers: {
177 + "Content-Type": "application/json",
178 + },
179 + body: JSON.stringify({ password }),
180 + });
181 +
182 + const data = await response.json();
183 +
184 + if (!response.ok) {
185 + throw new Error(data.error || "Failed to login");
186 + }
187 +
188 + setIsAuthenticated(true);
189 + await loadDatabaseData();
190 + } catch (err: any) {
191 + setError(err.message || "Failed to login");
192 + } finally {
193 + setIsLoading(false);
194 + }
195 + };
196 +
197 + const loadDatabaseData = async () => {
198 + try {
199 + const response = await fetch("/api/admin/database");
200 +
201 + if (!response.ok) {
202 + if (response.status === 403) {
203 + setIsAuthenticated(false);
204 + return;
205 + }
206 + throw new Error("Failed to load database data");
207 + }
208 +
209 + const data = await response.json();
210 + setDatabaseData(data);
211 + } catch (err: any) {
212 + setError(err.message || "Failed to load database data");
213 + }
214 + };
215 +
216 + const loadActiveUsers = async () => {
217 + try {
218 + const response = await fetch("/api/analytics/active-users");
219 +
220 + if (response.ok) {
221 + const data = await response.json();
222 + setActiveUsersData(data);
223 + }
224 + } catch (err: any) {
225 + console.error("Failed to load active users:", err);
226 + }
227 + };
228 +
229 + const loadRealTimeStats = async () => {
230 + try {
231 + const response = await fetch("/api/analytics/real-time-stats");
232 +
233 + if (response.ok) {
234 + const data = await response.json();
235 + setRealTimeStats(data);
236 + }
237 + } catch (err: any) {
238 + console.error("Failed to load real-time stats:", err);
239 + }
240 + };
241 +
242 + const refreshAllData = async () => {
243 + await Promise.all([
244 + loadDatabaseData(),
245 + loadActiveUsers(),
246 + loadRealTimeStats(),
247 + ]);
248 + };
249 +
250 + const deleteSession = async (id: string) => {
251 + if (!confirm("Are you sure you want to delete this session?")) {
252 + return;
253 + }
254 +
255 + try {
256 + const response = await fetch(`/api/admin/sessions/${id}`, {
257 + method: "DELETE",
258 + });
259 +
260 + if (!response.ok) {
261 + throw new Error("Failed to delete session");
262 + }
263 +
264 + await loadDatabaseData();
265 + } catch (err: any) {
266 + setError(err.message || "Failed to delete session");
267 + }
268 + };
269 +
270 + const deleteReport = async (id: string) => {
271 + if (!confirm("Are you sure you want to delete this shared report?")) {
272 + return;
273 + }
274 +
275 + try {
276 + const response = await fetch(`/api/admin/reports/${id}`, {
277 + method: "DELETE",
278 + });
279 +
280 + if (!response.ok) {
281 + throw new Error("Failed to delete report");
282 + }
283 +
284 + await loadDatabaseData();
285 + } catch (err: any) {
286 + setError(err.message || "Failed to delete report");
287 + }
288 + };
289 +
290 + const formatDate = (dateString: string) => {
291 + return new Date(dateString).toLocaleString();
292 + };
293 +
294 + // Show loading screen during auto-login
295 + if (!isAuthenticated && isLoading) {
296 + return (
297 + <div className="min-h-screen bg-gradient-to-br from-background via-background to-primary/5 flex items-center justify-center">
298 + <div className="text-center">
299 + <div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 mb-4">
300 + <Database className="w-8 h-8 text-primary animate-pulse" />
301 + </div>
302 + <p className="text-muted-foreground">Loading admin panel...</p>
303 + </div>
304 + </div>
305 + );
306 + }
307 +
308 + if (!isAuthenticated) {
309 + return (
310 + <div className="min-h-screen bg-gradient-to-br from-background via-background to-primary/5 flex flex-col relative overflow-hidden">
311 + {/* Decorative gradient blobs */}
312 + <div className="absolute inset-0 overflow-hidden pointer-events-none">
313 + <div className="absolute top-0 right-0 w-96 h-96 bg-primary/10 rounded-full blur-3xl transform translate-x-1/2 -translate-y-1/2" />
314 + <div className="absolute bottom-0 left-0 w-96 h-96 bg-purple-500/10 rounded-full blur-3xl transform -translate-x-1/2 translate-y-1/2" />
315 + </div>
316 +
317 + {/* Header */}
318 + <header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/60 backdrop-blur-xl">
319 + <div className="container flex h-16 items-center justify-between px-4">
320 + <button
321 + onClick={() => setLocation('/')}
322 + className="flex items-center gap-2 hover-elevate active-elevate-2 px-3 py-1.5 rounded-md cursor-pointer transition-all duration-200"
323 + >
324 + <Sparkles className="w-6 h-6 text-primary" />
325 + <div className="hidden md:flex items-baseline gap-2">
326 + <span className="text-xl font-semibold bg-gradient-to-r from-primary to-purple-500 bg-clip-text text-transparent">
327 + VQuant
328 + </span>
329 + <span className="text-sm text-muted-foreground">
330 + by Agentilab.ai
331 + </span>
332 + </div>
333 + </button>
334 + </div>
335 + </header>
336 +
337 + {/* Main Content */}
338 + <main className="flex-1 flex items-center justify-center px-4 py-12 relative z-10">
339 + <div className="w-full max-w-md">
340 + <div className="text-center mb-8 animate-in fade-in slide-in-from-top-4 duration-700">
341 + <div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-primary/10 mb-4">
342 + <Shield className="w-8 h-8 text-primary" />
343 + </div>
344 + <h1 className="text-4xl font-bold mb-3 bg-gradient-to-r from-primary via-purple-500 to-primary bg-clip-text text-transparent">
345 + Admin Access
346 + </h1>
347 + <p className="text-muted-foreground">
348 + Enter the admin password to access the database panel
349 + </p>
350 + </div>
351 +
352 + <Card className="border-border/50 shadow-2xl backdrop-blur-sm bg-card/95">
353 + <CardHeader className="space-y-3 pb-6">
354 + <CardTitle className="flex items-center gap-2 text-2xl">
355 + <Database className="w-5 h-5 text-primary" />
356 + Database Administration
357 + </CardTitle>
358 + <CardDescription className="text-base">
359 + View and manage all database records
360 + </CardDescription>
361 + </CardHeader>
362 + <CardContent className="pt-0">
363 + <form onSubmit={handleLogin} className="space-y-5">
364 + <div className="space-y-2">
365 + <Label htmlFor="admin-password" className="text-sm font-medium">Admin Password</Label>
366 + <Input
367 + id="admin-password"
368 + type="password"
369 + placeholder="Enter admin password"
370 + value={password}
371 + onChange={(e) => setPassword(e.target.value)}
372 + required
373 + disabled={isLoading}
374 + className="h-11 bg-background/50 border-border/50 focus:border-primary transition-all"
375 + />
376 + </div>
377 +
378 + {error && (
379 + <Alert variant="destructive">
380 + <AlertDescription>{error}</AlertDescription>
381 + </Alert>
382 + )}
383 +
384 + <Button
385 + type="submit"
386 + className="w-full h-11 bg-gradient-to-r from-primary to-purple-500 hover:from-primary/90 hover:to-purple-500/90 text-white shadow-lg hover:shadow-xl transition-all duration-200"
387 + disabled={isLoading}
388 + >
389 + {isLoading ? "Authenticating..." : "Access Admin Panel"}
390 + </Button>
391 + </form>
392 + </CardContent>
393 + </Card>
394 + </div>
395 + </main>
396 + </div>
397 + );
398 + }
399 +
400 + return (
401 + <div className="min-h-screen bg-gradient-to-br from-background via-background to-primary/5">
402 + {/* Header */}
403 + <header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/60 backdrop-blur-xl">
404 + <div className="container flex h-16 items-center justify-between px-4">
405 + <button
406 + onClick={() => setLocation('/')}
407 + className="flex items-center gap-2 hover-elevate active-elevate-2 px-3 py-1.5 rounded-md cursor-pointer transition-all duration-200"
408 + >
409 + <Sparkles className="w-6 h-6 text-primary" />
410 + <div className="hidden md:flex items-baseline gap-2">
411 + <span className="text-xl font-semibold bg-gradient-to-r from-primary to-purple-500 bg-clip-text text-transparent">
412 + VQuant Admin
413 + </span>
414 + </div>
415 + </button>
416 + <Button
417 + variant="outline"
418 + onClick={() => {
419 + setIsAuthenticated(false);
420 + setPassword("");
421 + }}
422 + >
423 + Logout
424 + </Button>
425 + </div>
426 + </header>
427 +
428 + {/* Main Content */}
429 + <main className="container px-4 py-8">
430 + <div className="mb-8">
431 + <h1 className="text-3xl font-bold mb-2 flex items-center gap-2">
432 + <Database className="w-8 h-8 text-primary" />
433 + Database Administration
434 + </h1>
435 + <p className="text-muted-foreground">
436 + View and manage all database records
437 + </p>
438 + </div>
439 +
440 + {error && (
441 + <Alert variant="destructive" className="mb-6">
442 + <AlertDescription>{error}</AlertDescription>
443 + </Alert>
444 + )}
445 +
446 + {databaseData && (
447 + <Tabs defaultValue="metrics" className="w-full">
448 + <div className="flex items-center justify-between mb-4">
449 + <TabsList className="grid w-full max-w-2xl grid-cols-3 h-12 bg-muted/50 backdrop-blur-sm">
450 + <TabsTrigger value="metrics" className="data-[state=active]:bg-background data-[state=active]:shadow-md transition-all">
451 + <Activity className="w-4 h-4 mr-2" />
452 + Live Metrics
453 + </TabsTrigger>
454 + <TabsTrigger value="sessions" className="data-[state=active]:bg-background data-[state=active]:shadow-md transition-all">
455 + <MessageSquare className="w-4 h-4 mr-2" />
456 + Sessions ({databaseData.sessions.length})
457 + </TabsTrigger>
458 + <TabsTrigger value="reports" className="data-[state=active]:bg-background data-[state=active]:shadow-md transition-all">
459 + <FileText className="w-4 h-4 mr-2" />
460 + Shared Reports ({databaseData.sharedReports.length})
461 + </TabsTrigger>
462 + </TabsList>
463 +
464 + <div className="flex items-center gap-3">
465 + <Badge variant={autoRefresh ? "default" : "outline"} className="cursor-pointer" onClick={() => setAutoRefresh(!autoRefresh)}>
466 + <RefreshCw className={`w-3 h-3 mr-1 ${autoRefresh ? 'animate-spin' : ''}`} />
467 + Auto-refresh {autoRefresh ? 'ON' : 'OFF'}
468 + </Badge>
469 + <Button variant="outline" size="sm" onClick={refreshAllData}>
470 + <RefreshCw className="w-4 h-4 mr-2" />
471 + Refresh Now
472 + </Button>
473 + </div>
474 + </div>
475 +
476 + {/* Metrics Tab */}
477 + <TabsContent value="metrics" className="mt-6 space-y-6">
478 + {/* Real-Time Active Users Section */}
479 + {activeUsersData && (
480 + <div className="space-y-4">
481 + <div className="flex items-center justify-between">
482 + <h3 className="text-lg font-semibold flex items-center gap-2">
483 + <Zap className="w-5 h-5 text-yellow-500 animate-pulse" />
484 + Live Activity Monitor
485 + </h3>
486 + <div className="flex items-center gap-2">
487 + <Badge variant="default" className="bg-green-500 hover:bg-green-600">
488 + {activeUsersData.generating} Generating
489 + </Badge>
490 + <Badge variant="secondary">
491 + {activeUsersData.total} Active
492 + </Badge>
493 + </div>
494 + </div>
495 +
496 + {activeUsersData.users.length > 0 ? (
497 + <Card>
498 + <CardContent className="pt-6">
499 + <Table>
500 + <TableHeader>
501 + <TableRow>
502 + <TableHead>Session</TableHead>
503 + <TableHead>Status</TableHead>
504 + <TableHead>Current Query</TableHead>
505 + <TableHead>Last Activity</TableHead>
506 + </TableRow>
507 + </TableHeader>
508 + <TableBody>
509 + {activeUsersData.users.map((user) => (
510 + <TableRow key={user.sessionId}>
511 + <TableCell className="font-mono text-xs">
512 + {user.sessionId.substring(0, 12)}...
513 + </TableCell>
514 + <TableCell>
515 + <Badge
516 + variant={
517 + user.status === 'generating' ? 'default' :
518 + user.status === 'error' ? 'destructive' :
519 + 'secondary'
520 + }
521 + >
522 + {user.status === 'generating' && <Zap className="w-3 h-3 mr-1 animate-pulse" />}
523 + {user.status === 'error' && <AlertCircle className="w-3 h-3 mr-1" />}
524 + {user.status}
525 + </Badge>
526 + </TableCell>
527 + <TableCell className="max-w-md truncate">
528 + {user.currentQuery || '-'}
529 + </TableCell>
530 + <TableCell className="text-xs text-muted-foreground">
531 + {new Date(user.lastHeartbeat).toLocaleTimeString()}
532 + </TableCell>
533 + </TableRow>
534 + ))}
535 + </TableBody>
536 + </Table>
537 + </CardContent>
538 + </Card>
539 + ) : (
540 + <Card>
541 + <CardContent className="pt-6">
542 + <div className="text-center py-8 text-muted-foreground">
543 + <UsersIcon className="w-12 h-12 mx-auto mb-3 opacity-50" />
544 + <p>No active users at the moment</p>
545 + </div>
546 + </CardContent>
547 + </Card>
548 + )}
549 + </div>
550 + )}
551 +
552 + {/* Real-Time Stats Overview */}
553 + {realTimeStats && (
554 + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
555 + <Card className="border-green-500/50 bg-green-500/5">
556 + <CardHeader className="pb-3">
557 + <CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
558 + <Activity className="w-4 h-4 text-green-500" />
559 + Active Users Now
560 + </CardTitle>
561 + </CardHeader>
562 + <CardContent>
563 + <div className="text-3xl font-bold text-green-500">
564 + {realTimeStats.activeUsers.total}
565 + </div>
566 + <p className="text-xs text-muted-foreground mt-1">
567 + {realTimeStats.activeUsers.generating} generating
568 + </p>
569 + </CardContent>
570 + </Card>
571 +
572 + <Card className="border-blue-500/50 bg-blue-500/5">
573 + <CardHeader className="pb-3">
574 + <CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
575 + <MessageSquare className="w-4 h-4 text-blue-500" />
576 + Sessions Today
577 + </CardTitle>
578 + </CardHeader>
579 + <CardContent>
580 + <div className="text-3xl font-bold text-blue-500">
581 + {realTimeStats.sessions.today}
582 + </div>
583 + <p className="text-xs text-muted-foreground mt-1">
584 + of {realTimeStats.sessions.total} total
585 + </p>
586 + </CardContent>
587 + </Card>
588 +
589 + <Card className="border-purple-500/50 bg-purple-500/5">
590 + <CardHeader className="pb-3">
591 + <CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
592 + <DollarSign className="w-4 h-4 text-purple-500" />
593 + Cost Today
594 + </CardTitle>
595 + </CardHeader>
596 + <CardContent>
597 + <div className="text-3xl font-bold text-purple-500">
598 + ${realTimeStats.cost.today.toFixed(3)}
599 + </div>
600 + <p className="text-xs text-muted-foreground mt-1">
601 + ${realTimeStats.cost.total.toFixed(3)} total
602 + </p>
603 + </CardContent>
604 + </Card>
605 +
606 + <Card className={`border-${realTimeStats.errors.rate > 10 ? 'red' : 'yellow'}-500/50 bg-${realTimeStats.errors.rate > 10 ? 'red' : 'yellow'}-500/5`}>
607 + <CardHeader className="pb-3">
608 + <CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-2">
609 + <AlertCircle className={`w-4 h-4 text-${realTimeStats.errors.rate > 10 ? 'red' : 'yellow'}-500`} />
610 + Error Rate
611 + </CardTitle>
612 + </CardHeader>
613 + <CardContent>
614 + <div className={`text-3xl font-bold text-${realTimeStats.errors.rate > 10 ? 'red' : 'yellow'}-500`}>
615 + {realTimeStats.errors.rate.toFixed(1)}%
616 + </div>
617 + <p className="text-xs text-muted-foreground mt-1">
618 + {realTimeStats.errors.lastHour} errors last hour
619 + </p>
620 + </CardContent>
621 + </Card>
622 + </div>
623 + )}
624 +
625 + {/* Historical Overview Cards */}
626 + <div className="space-y-4">
627 + <h3 className="text-lg font-semibold flex items-center gap-2">
628 + <TrendingUp className="w-5 h-5 text-primary" />
629 + Cumulative Metrics
630 + </h3>
631 + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
632 + <Card>
633 + <CardHeader className="pb-3">
634 + <CardTitle className="text-sm font-medium text-muted-foreground">Total Cost</CardTitle>
635 + </CardHeader>
636 + <CardContent>
637 + <div className="text-2xl font-bold text-primary">
638 + ${databaseData.metrics.totalCost.toFixed(4)}
639 + </div>
640 + <p className="text-xs text-muted-foreground mt-1">
641 + Across {databaseData.metrics.totalSessions} sessions
642 + </p>
643 + </CardContent>
644 + </Card>
645 +
646 + <Card>
647 + <CardHeader className="pb-3">
648 + <CardTitle className="text-sm font-medium text-muted-foreground">Total Tokens</CardTitle>
649 + </CardHeader>
650 + <CardContent>
651 + <div className="text-2xl font-bold">
652 + {databaseData.metrics.totalTokens.toLocaleString()}
653 + </div>
654 + <p className="text-xs text-muted-foreground mt-1">
655 + {databaseData.metrics.totalInputTokens.toLocaleString()} in / {databaseData.metrics.totalOutputTokens.toLocaleString()} out
656 + </p>
657 + </CardContent>
658 + </Card>
659 +
660 + <Card>
661 + <CardHeader className="pb-3">
662 + <CardTitle className="text-sm font-medium text-muted-foreground">Avg Cost/Session</CardTitle>
663 + </CardHeader>
664 + <CardContent>
665 + <div className="text-2xl font-bold text-green-600">
666 + ${databaseData.metrics.averageCostPerSession.toFixed(4)}
667 + </div>
668 + <p className="text-xs text-muted-foreground mt-1">
669 + Per conversation
670 + </p>
671 + </CardContent>
672 + </Card>
673 +
674 + <Card>
675 + <CardHeader className="pb-3">
676 + <CardTitle className="text-sm font-medium text-muted-foreground">Model</CardTitle>
677 + </CardHeader>
678 + <CardContent>
679 + <div className="text-lg font-bold">
680 + Opus 4.8
681 + </div>
682 + <p className="text-xs text-muted-foreground mt-1">
683 + $5/$25 per M tokens
684 + </p>
685 + </CardContent>
686 + </Card>
687 + </div>
688 + </div>
689 +
690 + </TabsContent>
691 +
692 + {/* Sessions Tab */}
693 + <TabsContent value="sessions" className="mt-6">
694 + <Card>
695 + <CardHeader>
696 + <CardTitle>All Conversation Sessions</CardTitle>
697 + <CardDescription>Manage conversation history</CardDescription>
698 + </CardHeader>
699 + <CardContent>
700 + <Table>
701 + <TableHeader>
702 + <TableRow>
703 + <TableHead>Session ID</TableHead>
704 + <TableHead>User ID</TableHead>
705 + <TableHead>Title</TableHead>
706 + <TableHead className="text-right">Tokens</TableHead>
707 + <TableHead className="text-right">Cost</TableHead>
708 + <TableHead>Updated At</TableHead>
709 + <TableHead className="text-right">Actions</TableHead>
710 + </TableRow>
711 + </TableHeader>
712 + <TableBody>
713 + {databaseData.sessions.map((session) => (
714 + <TableRow key={session.id}>
715 + <TableCell className="font-mono text-xs">{session.sessionId.substring(0, 8)}...</TableCell>
716 + <TableCell className="font-mono text-xs">
717 + {session.userId ? `${session.userId.substring(0, 8)}...` : "Anonymous"}
718 + </TableCell>
719 + <TableCell className="max-w-md truncate">{session.title}</TableCell>
720 + <TableCell className="text-right font-mono text-xs">
721 + {(session.inputTokens + session.outputTokens).toLocaleString()}
722 + <span className="text-muted-foreground ml-1">
723 + ({session.inputTokens}↑/{session.outputTokens}↓)
724 + </span>
725 + </TableCell>
726 + <TableCell className="text-right font-medium text-primary">
727 + ${session.totalCost.toFixed(4)}
728 + </TableCell>
729 + <TableCell>{formatDate(session.updatedAt)}</TableCell>
730 + <TableCell className="text-right">
731 + <Button
732 + variant="destructive"
733 + size="sm"
734 + onClick={() => deleteSession(session.id)}
735 + >
736 + <Trash2 className="w-4 h-4" />
737 + </Button>
738 + </TableCell>
739 + </TableRow>
740 + ))}
741 + </TableBody>
742 + </Table>
743 + </CardContent>
744 + </Card>
745 + </TabsContent>
746 +
747 + {/* Shared Reports Tab */}
748 + <TabsContent value="reports" className="mt-6">
749 + <Card>
750 + <CardHeader>
751 + <CardTitle>All Shared Reports</CardTitle>
752 + <CardDescription>Manage publicly shared reports</CardDescription>
753 + </CardHeader>
754 + <CardContent>
755 + <Table>
756 + <TableHeader>
757 + <TableRow>
758 + <TableHead>Share ID</TableHead>
759 + <TableHead>Question Preview</TableHead>
760 + <TableHead>Created At</TableHead>
761 + <TableHead className="text-right">Actions</TableHead>
762 + </TableRow>
763 + </TableHeader>
764 + <TableBody>
765 + {databaseData.sharedReports.map((report) => (
766 + <TableRow key={report.id}>
767 + <TableCell className="font-mono text-xs">{report.shareId}</TableCell>
768 + <TableCell className="max-w-md truncate">{report.question}</TableCell>
769 + <TableCell>{formatDate(report.createdAt)}</TableCell>
770 + <TableCell className="text-right">
771 + <Button
772 + variant="destructive"
773 + size="sm"
774 + onClick={() => deleteReport(report.id)}
775 + >
776 + <Trash2 className="w-4 h-4" />
777 + </Button>
778 + </TableCell>
779 + </TableRow>
780 + ))}
781 + </TableBody>
782 + </Table>
783 + </CardContent>
784 + </Card>
785 + </TabsContent>
786 + </Tabs>
787 + )}
788 + </main>
789 + </div>
790 + );
791 +}
added client/src/pages/auth.tsx +331 −0
@@ -0,0 +1,331 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/pages/auth.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { useState } from "react";
18 +import { User, KeyRound, Sparkles, Copy, Check, ArrowLeft } from "lucide-react";
19 +import { Button } from "@/components/ui/button";
20 +import { Input } from "@/components/ui/input";
21 +import { Label } from "@/components/ui/label";
22 +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
23 +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
24 +import { Alert, AlertDescription } from "@/components/ui/alert";
25 +import { useLocation } from "wouter";
26 +
27 +export default function Auth() {
28 + const [, setLocation] = useLocation();
29 + const [isLoading, setIsLoading] = useState(false);
30 + const [error, setError] = useState("");
31 + const [success, setSuccess] = useState("");
32 +
33 + // Create account state
34 + const [name, setName] = useState("");
35 + const [generatedToken, setGeneratedToken] = useState("");
36 + const [copied, setCopied] = useState(false);
37 +
38 + // Login state
39 + const [loginToken, setLoginToken] = useState("");
40 +
41 + const handleCreate = async (e: React.FormEvent) => {
42 + e.preventDefault();
43 + setError("");
44 + setSuccess("");
45 + setGeneratedToken("");
46 + setIsLoading(true);
47 +
48 + try {
49 + const response = await fetch("/api/auth/register", {
50 + method: "POST",
51 + headers: { "Content-Type": "application/json" },
52 + body: JSON.stringify({ name: name.trim() }),
53 + });
54 +
55 + const data = await response.json();
56 +
57 + if (!response.ok) {
58 + throw new Error(data.error || "Erreur lors de la création du compte");
59 + }
60 +
61 + setGeneratedToken(data.token);
62 + setSuccess(`Compte créé avec succès! Bienvenue, ${data.user.displayName}!`);
63 + } catch (err: any) {
64 + setError(err.message || "Erreur lors de la création du compte");
65 + } finally {
66 + setIsLoading(false);
67 + }
68 + };
69 +
70 + const handleLogin = async (e: React.FormEvent) => {
71 + e.preventDefault();
72 + setError("");
73 + setSuccess("");
74 + setIsLoading(true);
75 +
76 + try {
77 + const response = await fetch("/api/auth/login", {
78 + method: "POST",
79 + headers: { "Content-Type": "application/json" },
80 + body: JSON.stringify({ token: loginToken.trim() }),
81 + });
82 +
83 + const data = await response.json();
84 +
85 + if (!response.ok) {
86 + throw new Error(data.error || "Token invalide");
87 + }
88 +
89 + setSuccess(`Bienvenue, ${data.user.displayName}!`);
90 +
91 + setTimeout(() => {
92 + setLocation('/');
93 + }, 1000);
94 + } catch (err: any) {
95 + setError(err.message || "Erreur de connexion");
96 + } finally {
97 + setIsLoading(false);
98 + }
99 + };
100 +
101 + const handleCopyToken = async () => {
102 + try {
103 + await navigator.clipboard.writeText(generatedToken);
104 + setCopied(true);
105 + setTimeout(() => setCopied(false), 2000);
106 + } catch {
107 + // Fallback for mobile
108 + const textarea = document.createElement('textarea');
109 + textarea.value = generatedToken;
110 + document.body.appendChild(textarea);
111 + textarea.select();
112 + document.execCommand('copy');
113 + document.body.removeChild(textarea);
114 + setCopied(true);
115 + setTimeout(() => setCopied(false), 2000);
116 + }
117 + };
118 +
119 + const handleGoHome = () => {
120 + setLocation('/');
121 + };
122 +
123 + return (
124 + <div className="min-h-screen bg-background flex flex-col relative overflow-hidden">
125 + {/* Subtle Background */}
126 + <div className="fixed inset-0 z-0 pointer-events-none">
127 + <div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,hsl(var(--primary)/0.03),transparent_70%)]" />
128 + </div>
129 +
130 + {/* Header */}
131 + <header className="sticky top-0 z-50 w-full border-b border-border bg-background/95 backdrop-blur-sm">
132 + <div className="container flex h-14 items-center justify-between px-4">
133 + <button
134 + onClick={() => setLocation('/')}
135 + className="flex items-center gap-2.5 cursor-pointer hover:opacity-80 transition-opacity"
136 + >
137 + <Sparkles className="w-5 h-5 text-primary" />
138 + <div className="hidden md:flex items-baseline gap-1.5">
139 + <span className="text-base font-bold text-foreground">
140 + VQuant
141 + </span>
142 + </div>
143 + </button>
144 + </div>
145 + </header>
146 +
147 + {/* Main Content */}
148 + <main className="flex-1 flex items-center justify-center px-4 py-12 relative z-10">
149 + <div className="w-full max-w-md">
150 + {/* Welcome message */}
151 + <div className="text-center mb-8 animate-in fade-in slide-in-from-top-4 duration-700">
152 + <h1 className="text-3xl font-bold mb-3 text-foreground">
153 + Mon Compte
154 + </h1>
155 + <p className="text-muted-foreground">
156 + Créez un compte pour garder vos analyses privées
157 + </p>
158 + </div>
159 +
160 + <Tabs defaultValue="create" className="w-full">
161 + <TabsList className="grid w-full grid-cols-2 h-12 bg-muted/50 backdrop-blur-sm">
162 + <TabsTrigger value="create" className="data-[state=active]:bg-background data-[state=active]:shadow-md transition-all">
163 + Créer un compte
164 + </TabsTrigger>
165 + <TabsTrigger value="login" className="data-[state=active]:bg-background data-[state=active]:shadow-md transition-all">
166 + Se connecter
167 + </TabsTrigger>
168 + </TabsList>
169 +
170 + {/* Create Account Tab */}
171 + <TabsContent value="create" className="mt-6">
172 + <Card className="border-border/50 shadow-2xl backdrop-blur-sm bg-card/95">
173 + <CardHeader className="space-y-3 pb-6">
174 + <CardTitle className="flex items-center gap-2 text-2xl">
175 + <div className="p-2 rounded-lg bg-primary/10">
176 + <User className="w-5 h-5 text-primary" />
177 + </div>
178 + Créer un compte
179 + </CardTitle>
180 + <CardDescription className="text-base">
181 + Entrez votre nom pour recevoir un token d'accès unique. Vos générations resteront privées.
182 + </CardDescription>
183 + </CardHeader>
184 + <CardContent className="pt-0">
185 + <form onSubmit={handleCreate} className="space-y-5">
186 + <div className="space-y-2">
187 + <Label htmlFor="create-name" className="text-sm font-medium">Votre nom</Label>
188 + <div className="relative">
189 + <User className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
190 + <Input
191 + id="create-name"
192 + type="text"
193 + placeholder="Entrez votre nom (min. 2 caractères)"
194 + value={name}
195 + onChange={(e) => setName(e.target.value)}
196 + required
197 + minLength={2}
198 + disabled={isLoading || !!generatedToken}
199 + className="pl-10 h-11 bg-background/50 border-border focus:border-primary transition-all"
200 + />
201 + </div>
202 + </div>
203 +
204 + {error && (
205 + <Alert variant="destructive">
206 + <AlertDescription>{error}</AlertDescription>
207 + </Alert>
208 + )}
209 +
210 + {success && !generatedToken && (
211 + <Alert>
212 + <AlertDescription>{success}</AlertDescription>
213 + </Alert>
214 + )}
215 +
216 + {generatedToken && (
217 + <Alert className="bg-gradient-to-br from-yellow-500/10 to-orange-500/10 border-yellow-500/50 shadow-lg">
218 + <KeyRound className="w-4 h-4 text-yellow-600 dark:text-yellow-400" />
219 + <AlertDescription>
220 + <p className="font-semibold mb-2 text-foreground">
221 + Votre token d'accès
222 + </p>
223 + <div className="flex items-center gap-2">
224 + <code className="flex-1 bg-background/80 p-3 rounded-lg text-sm break-all select-all border border-yellow-500/30 shadow-inner font-mono">
225 + {generatedToken}
226 + </code>
227 + <Button
228 + type="button"
229 + variant="outline"
230 + size="sm"
231 + onClick={handleCopyToken}
232 + className="shrink-0 h-10 w-10 p-0"
233 + >
234 + {copied ? (
235 + <Check className="w-4 h-4 text-green-500" />
236 + ) : (
237 + <Copy className="w-4 h-4" />
238 + )}
239 + </Button>
240 + </div>
241 + <p className="text-xs mt-3 text-destructive font-medium">
242 + IMPORTANT: Sauvegardez ce token! C'est votre SEUL moyen de vous reconnecter.
243 + </p>
244 + <Button
245 + type="button"
246 + onClick={handleGoHome}
247 + className="w-full mt-4 bg-primary hover:bg-primary/90 text-primary-foreground"
248 + >
249 + <ArrowLeft className="w-4 h-4 mr-2" />
250 + Aller à l'accueil
251 + </Button>
252 + </AlertDescription>
253 + </Alert>
254 + )}
255 +
256 + {!generatedToken && (
257 + <Button
258 + type="submit"
259 + className="w-full h-11 bg-primary hover:bg-primary/90 text-primary-foreground transition-colors"
260 + disabled={isLoading}
261 + >
262 + {isLoading ? "Création en cours..." : "Créer mon compte"}
263 + </Button>
264 + )}
265 + </form>
266 + </CardContent>
267 + </Card>
268 + </TabsContent>
269 +
270 + {/* Login Tab */}
271 + <TabsContent value="login" className="mt-6">
272 + <Card className="border-border/50 shadow-2xl backdrop-blur-sm bg-card/95">
273 + <CardHeader className="space-y-3 pb-6">
274 + <CardTitle className="flex items-center gap-2 text-2xl">
275 + <div className="p-2 rounded-lg bg-primary/10">
276 + <KeyRound className="w-5 h-5 text-primary" />
277 + </div>
278 + Se connecter
279 + </CardTitle>
280 + <CardDescription className="text-base">
281 + Entrez votre token pour accéder à votre historique privé
282 + </CardDescription>
283 + </CardHeader>
284 + <CardContent className="pt-0">
285 + <form onSubmit={handleLogin} className="space-y-5">
286 + <div className="space-y-2">
287 + <Label htmlFor="login-token" className="text-sm font-medium">Token d'accès</Label>
288 + <div className="relative">
289 + <KeyRound className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
290 + <Input
291 + id="login-token"
292 + type="text"
293 + placeholder="vquant-xxxxxxx"
294 + value={loginToken}
295 + onChange={(e) => setLoginToken(e.target.value)}
296 + required
297 + disabled={isLoading}
298 + className="pl-10 h-11 bg-background/50 border-border focus:border-primary transition-all font-mono"
299 + />
300 + </div>
301 + </div>
302 +
303 + {error && (
304 + <Alert variant="destructive">
305 + <AlertDescription>{error}</AlertDescription>
306 + </Alert>
307 + )}
308 +
309 + {success && (
310 + <Alert>
311 + <AlertDescription>{success}</AlertDescription>
312 + </Alert>
313 + )}
314 +
315 + <Button
316 + type="submit"
317 + className="w-full h-11 bg-primary hover:bg-primary/90 text-primary-foreground transition-colors"
318 + disabled={isLoading}
319 + >
320 + {isLoading ? "Connexion..." : "Se connecter"}
321 + </Button>
322 + </form>
323 + </CardContent>
324 + </Card>
325 + </TabsContent>
326 + </Tabs>
327 + </div>
328 + </main>
329 + </div>
330 + );
331 +}
added client/src/pages/documentation.tsx +730 −0
@@ -0,0 +1,730 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/pages/documentation.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { Sparkles, TrendingUp, Calculator, BarChart3, DollarSign, ArrowLeft, Activity, LineChart, Zap, Target, Globe, Users, Crown, Rocket, PieChart, BrainCircuit, Code, Database, Search, FileText, Briefcase, TrendingDown, AlertCircle, Newspaper, Building2, CandlestickChart } from "lucide-react";
18 +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
19 +import { Badge } from "@/components/ui/badge";
20 +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
21 +import { useLocation } from "wouter";
22 +import { Button } from "@/components/ui/button";
23 +import { ThemeToggle } from "@/components/theme-toggle";
24 +import { ThemeColorPicker } from "@/components/theme-color-picker";
25 +import { motion } from "framer-motion";
26 +
27 +export default function Documentation() {
28 + const [, setLocation] = useLocation();
29 +
30 + const aiModels = [
31 + { id: "claude-fable-5", name: "Fable 5", desc: "Raisonnement adaptatif pour analyses complexes - le plus intelligent", context: "1M contexte / 128K sortie", icon: Sparkles, badge: "Defaut" },
32 + { id: "claude-opus-4-8", name: "Opus 4.8", desc: "Le plus puissant - agentique long-horizon et code", context: "1M contexte / 128K sortie", icon: Crown, badge: "" },
33 + { id: "claude-opus-4-7", name: "Opus 4.7", desc: "Tres performant et equilibre", context: "1M contexte / 128K sortie", icon: Rocket, badge: "" },
34 + { id: "claude-opus-4-6", name: "Opus 4.6", desc: "Rapide et capable", context: "1M contexte / 128K sortie", icon: BrainCircuit, badge: "" },
35 + { id: "claude-sonnet-4-6", name: "Sonnet 4.6", desc: "Polyvalent - bon rapport vitesse / intelligence", context: "1M contexte / 64K sortie", icon: Zap, badge: "" },
36 + { id: "claude-haiku-4-5-20251001", name: "Haiku 4.5", desc: "Le plus rapide et economique (sans raisonnement etendu)", context: "200K contexte / 64K sortie", icon: Activity, badge: "" },
37 + ];
38 +
39 + const categories = [
40 + {
41 + id: "market-data",
42 + icon: TrendingUp,
43 + title: "Données de Marché en Temps Réel",
44 + color: "from-blue-500 to-cyan-500",
45 + description: "Cotations, prix historiques, indices et données de marché",
46 + tools: [
47 + { name: "get_stock_quote", desc: "Cotation en temps réel d'une action", example: "Quel est le prix actuel d'Apple?" },
48 + { name: "get_historical_price", desc: "Prix historiques sur une période", example: "Historique de Tesla sur 1 an" },
49 + { name: "get_intraday_price", desc: "Données intraday (1min, 5min, 15min, etc.)", example: "Prix de NVDA aujourd'hui par intervalle de 5 minutes" },
50 + { name: "get_forex_quote", desc: "Cotations de paires forex", example: "Taux EUR/USD actuel" },
51 + { name: "get_commodity_quotes", desc: "Prix des commodités (or, pétrole, etc.)", example: "Prix de l'or aujourd'hui" },
52 + { name: "get_crypto_quote", desc: "Cotations de cryptomonnaies", example: "Prix du Bitcoin maintenant" },
53 + { name: "get_market_hours", desc: "Heures d'ouverture des marchés", example: "Le marché NYSE est-il ouvert?" },
54 + { name: "get_gainers", desc: "Top gainers du jour", example: "Quelles actions ont le plus monté aujourd'hui?" },
55 + { name: "get_losers", desc: "Top losers du jour", example: "Quelles actions ont le plus baissé?" },
56 + { name: "get_actives", desc: "Actions les plus actives", example: "Actions avec le plus de volume aujourd'hui" },
57 + ]
58 + },
59 + {
60 + id: "fundamentals",
61 + icon: FileText,
62 + title: "États Financiers & Fondamentaux",
63 + color: "from-purple-500 to-pink-500",
64 + description: "Bilans, comptes de résultats, métriques et ratios financiers",
65 + tools: [
66 + { name: "get_company_profile", desc: "Profil complet d'une entreprise", example: "Profil complet de Microsoft" },
67 + { name: "get_income_statement", desc: "Compte de résultat (annuel ou trimestriel)", example: "Revenus annuels d'Apple sur 5 ans" },
68 + { name: "get_balance_sheet", desc: "Bilan comptable", example: "Bilan de Google" },
69 + { name: "get_cash_flow", desc: "État des flux de trésorerie", example: "Flux de trésorerie Amazon" },
70 + { name: "get_key_metrics", desc: "Métriques clés (P/E, ROE, etc.)", example: "Métriques de Tesla" },
71 + { name: "get_financial_ratios", desc: "Ratios financiers complets", example: "Ratios de profitabilité Meta" },
72 + { name: "get_financial_growth", desc: "Croissance financière", example: "Croissance des revenus NVIDIA" },
73 + { name: "get_company_outlook", desc: "Perspectives et guidances", example: "Prévisions pour Amazon" },
74 + { name: "search_companies", desc: "Rechercher des entreprises", example: "Entreprises du secteur tech" },
75 + { name: "get_stock_peers", desc: "Entreprises comparables", example: "Concurrents d'Apple" },
76 + ]
77 + },
78 + {
79 + id: "technical",
80 + icon: Activity,
81 + title: "Indicateurs Techniques",
82 + color: "from-green-500 to-emerald-500",
83 + description: "RSI, MACD, moyennes mobiles et oscillateurs",
84 + tools: [
85 + { name: "get_rsi", desc: "Relative Strength Index", example: "RSI de Tesla sur 14 jours" },
86 + { name: "get_macd", desc: "MACD (Moving Average Convergence Divergence)", example: "MACD pour Bitcoin" },
87 + { name: "get_ema", desc: "Moyenne mobile exponentielle", example: "EMA 50 jours de NVDA" },
88 + { name: "get_sma", desc: "Moyenne mobile simple", example: "SMA 200 jours du S&P 500" },
89 + { name: "get_adx", desc: "Average Directional Index", example: "Force de tendance Apple avec ADX" },
90 + { name: "get_williams_r", desc: "Williams %R oscillateur", example: "Williams %R pour Amazon" },
91 + { name: "get_cci", desc: "Commodity Channel Index", example: "CCI de l'or" },
92 + { name: "get_stochastic", desc: "Oscillateur stochastique", example: "Stochastique Meta" },
93 + ]
94 + },
95 + {
96 + id: "quantitative",
97 + icon: Calculator,
98 + title: "Analyses Quantitatives Python",
99 + color: "from-orange-500 to-red-500",
100 + description: "Monte Carlo, GARCH, VaR, optimisation de portefeuille",
101 + tools: [
102 + { name: "run_monte_carlo_simulation", desc: "Simulation Monte Carlo pour projections", example: "Simulation Monte Carlo pour Tesla sur 1 an" },
103 + { name: "calculate_options_price", desc: "Pricing d'options Black-Scholes avec Greeks", example: "Prix d'une option call Apple strike 200" },
104 + { name: "estimate_garch_volatility", desc: "Modèle GARCH pour volatilité", example: "Volatilité GARCH de Bitcoin" },
105 + { name: "calculate_var", desc: "Value-at-Risk (VaR) historique ou paramétrique", example: "VaR à 95% pour mon portefeuille" },
106 + { name: "optimize_portfolio", desc: "Optimisation de portefeuille Markowitz", example: "Optimise un portefeuille AAPL MSFT GOOGL" },
107 + { name: "analyze_risk_metrics", desc: "Métriques de risque (Sharpe, Sortino, etc.)", example: "Analyse de risque pour Tesla" },
108 + { name: "create_plot", desc: "Graphiques Python personnalisés", example: "Crée un graphique de corrélation FAANG" },
109 + { name: "execute_custom_python", desc: "Code Python libre pour analyses", example: "Calcule la matrice de covariance du portefeuille" },
110 + ]
111 + },
112 + {
113 + id: "analyst",
114 + icon: Users,
115 + title: "Analyses d'Analystes & Consensus",
116 + color: "from-indigo-500 to-purple-500",
117 + description: "Recommandations, prix cibles, estimations et surprises",
118 + tools: [
119 + { name: "get_price_target", desc: "Prix cibles des analystes", example: "Prix cible consensuel pour Apple" },
120 + { name: "get_analyst_estimates", desc: "Estimations de revenus et bénéfices", example: "Estimations pour Tesla Q4" },
121 + { name: "get_earnings_surprises", desc: "Surprises de résultats historiques", example: "Surprises de résultats Netflix" },
122 + { name: "get_upgrades_downgrades", desc: "Changements de recommandations", example: "Dernières recommandations sur NVDA" },
123 + { name: "get_earnings_calendar", desc: "Calendrier des résultats", example: "Prochains résultats cette semaine" },
124 + ]
125 + },
126 + {
127 + id: "insider",
128 + icon: Briefcase,
129 + title: "Trading d'Initiés & Institutionnel",
130 + color: "from-red-500 to-rose-500",
131 + description: "Transactions d'initiés, Congrès, positions institutionnelles",
132 + tools: [
133 + { name: "get_insider_trading", desc: "Transactions d'initiés récentes", example: "Insider trading pour Apple" },
134 + { name: "get_congressional_trading", desc: "Trading du Congrès américain", example: "Derniers achats du Congrès" },
135 + { name: "get_senate_trading", desc: "Trading du Sénat", example: "Positions du Sénat" },
136 + { name: "get_institutional_holders", desc: "Détenteurs institutionnels", example: "Qui détient le plus de Tesla?" },
137 + ]
138 + },
139 + {
140 + id: "economic",
141 + icon: Globe,
142 + title: "Données Économiques & Calendriers",
143 + color: "from-teal-500 to-cyan-500",
144 + description: "Indicateurs macroéconomiques, calendrier économique",
145 + tools: [
146 + { name: "get_economic_calendar", desc: "Événements économiques à venir", example: "Prochains rapports économiques cette semaine" },
147 + { name: "get_economic_indicator", desc: "Indicateurs macro (GDP, CPI, etc.)", example: "Inflation CPI récente" },
148 + { name: "get_treasury_rates", desc: "Taux des bons du Trésor", example: "Rendement du Treasury 10 ans" },
149 + { name: "get_ipo_calendar", desc: "Calendrier des IPO", example: "Prochaines introductions en bourse" },
150 + ]
151 + },
152 + {
153 + id: "news",
154 + icon: Newspaper,
155 + title: "Actualités & Sentiment",
156 + color: "from-amber-500 to-yellow-500",
157 + description: "News financières, sentiment, communiqués de presse",
158 + tools: [
159 + { name: "get_financial_news", desc: "Actualités financières récentes", example: "Dernières news sur Apple" },
160 + { name: "get_press_releases", desc: "Communiqués de presse officiels", example: "Communiqués Tesla" },
161 + { name: "get_stock_news_sentiment", desc: "Sentiment des actualités", example: "Sentiment news pour NVDA" },
162 + { name: "get_social_sentiment", desc: "Sentiment des réseaux sociaux", example: "Sentiment Twitter pour Bitcoin" },
163 + ]
164 + },
165 + {
166 + id: "dividends",
167 + icon: DollarSign,
168 + title: "Dividendes & Actions Corporate",
169 + color: "from-emerald-500 to-green-500",
170 + description: "Historique dividendes, splits, rachats d'actions",
171 + tools: [
172 + { name: "get_dividend_history", desc: "Historique des dividendes", example: "Dividendes Microsoft sur 10 ans" },
173 + { name: "get_stock_split_history", desc: "Historique des splits", example: "Splits d'Apple" },
174 + ]
175 + },
176 + {
177 + id: "screening",
178 + icon: Search,
179 + title: "Screening & Recherche",
180 + color: "from-pink-500 to-rose-500",
181 + description: "Filtrage d'actions, recherche par critères",
182 + tools: [
183 + { name: "get_stock_screener", desc: "Filtrer actions par critères", example: "Actions tech avec P/E < 20 et dividende > 2%" },
184 + { name: "search_symbol", desc: "Rechercher par symbole", example: "Trouver le symbole de Microsoft" },
185 + { name: "search_by_cik", desc: "Rechercher par CIK (SEC)", example: "Entreprise avec CIK 0000789019" },
186 + ]
187 + },
188 + {
189 + id: "etf",
190 + icon: PieChart,
191 + title: "ETF & Fonds",
192 + color: "from-violet-500 to-purple-500",
193 + description: "Composition, pondérations sectorielles et géographiques",
194 + tools: [
195 + { name: "get_etf_holdings", desc: "Composition d'un ETF", example: "Holdings du SPY" },
196 + { name: "get_etf_sector_weightings", desc: "Répartition sectorielle", example: "Exposition sectorielle du QQQ" },
197 + { name: "get_etf_country_weightings", desc: "Répartition géographique", example: "Exposition pays du VTI" },
198 + ]
199 + },
200 + {
201 + id: "esg",
202 + icon: Building2,
203 + title: "ESG & Gouvernance",
204 + color: "from-lime-500 to-green-500",
205 + description: "Scores ESG, critères environnementaux et sociaux",
206 + tools: [
207 + { name: "get_esg_score", desc: "Score ESG (Environnement, Social, Gouvernance)", example: "Score ESG de Tesla" },
208 + ]
209 + },
210 + {
211 + id: "export",
212 + icon: FileText,
213 + title: "Export & Visualisation",
214 + color: "from-sky-500 to-blue-500",
215 + description: "Génération de graphiques, slides, PDF et exports",
216 + tools: [
217 + { name: "create_plot", desc: "Graphiques matplotlib/plotly personnalisés", example: "Crée un graphique de performance FAANG" },
218 + { name: "generate_slides", desc: "Slides Beamer LaTeX professionnelles (thème Metropolis)", example: "Génère des slides pour mon analyse" },
219 + { name: "export_pdf", desc: "Export en PDF de l'analyse", example: "Exporte cette analyse en PDF" },
220 + { name: "download_data", desc: "Téléchargement de données CSV/Excel", example: "Télécharge les données historiques en CSV" },
221 + ]
222 + },
223 + {
224 + id: "web-research",
225 + icon: Globe,
226 + title: "Recherche Web & Scraping",
227 + color: "from-cyan-500 to-teal-500",
228 + description: "Recherche web, scraping de sites, extraction de contenu",
229 + tools: [
230 + { name: "exa_search", desc: "Recherche sémantique avancée avec Exa AI", example: "Recherche sur le web : analyse de Tesla" },
231 + { name: "firecrawl_scrape", desc: "Scraping de pages web spécifiques", example: "Scrape la page investor relations d'Apple" },
232 + { name: "firecrawl_crawl", desc: "Crawl complet d'un site web", example: "Crawl le site de Bloomberg" },
233 + { name: "tavily_search", desc: "Recherche et agrégation avec Tavily", example: "Recherche actualités économiques récentes" },
234 + ]
235 + },
236 + ];
237 +
238 + const pythonFeatures = [
239 + { name: "📊 Graphiques Personnalisés", desc: "Créez des visualisations sur mesure avec matplotlib, seaborn ou plotly" },
240 + { name: "📈 Analyse de Séries Temporelles", desc: "ARIMA, Prophet, décomposition saisonnière" },
241 + { name: "🎲 Simulations Stochastiques", desc: "Monte Carlo, bootstrap, backtesting" },
242 + { name: "📉 Modèles de Volatilité", desc: "GARCH, EGARCH, GJR-GARCH" },
243 + { name: "💰 Gestion de Risque", desc: "VaR, CVaR, Expected Shortfall" },
244 + { name: "🎯 Optimisation", desc: "Portefeuille Markowitz, Black-Litterman" },
245 + { name: "🔢 Calculs Personnalisés", desc: "N'importe quel calcul financier avec pandas, numpy, scipy" },
246 + ];
247 +
248 + const useCases = [
249 + {
250 + title: "Analyse Complète d'une Action",
251 + icon: TrendingUp,
252 + example: 'Fais une analyse ultra complète de Tesla : états financiers, métriques, ratios, analyse technique, news récentes, insider trading, estimations d\'analystes',
253 + color: "text-blue-500"
254 + },
255 + {
256 + title: "Analyse de Portefeuille",
257 + icon: PieChart,
258 + example: 'Optimise un portefeuille avec AAPL, MSFT, GOOGL, AMZN et NVDA. Calcule les poids optimaux, le ratio Sharpe, et fais une simulation Monte Carlo sur 1 an',
259 + color: "text-purple-500"
260 + },
261 + {
262 + title: "Analyse de Risque",
263 + icon: AlertCircle,
264 + example: 'Calcule la Value-at-Risk à 95% pour un portefeuille 60% SPY / 40% TLT. Ajoute une analyse de stress test avec un scénario de crise',
265 + color: "text-red-500"
266 + },
267 + {
268 + title: "Analyse de Volatilité",
269 + icon: LineChart,
270 + example: 'Estime un modèle GARCH sur les rendements du Bitcoin. Prévois la volatilité pour les 30 prochains jours',
271 + color: "text-orange-500"
272 + },
273 + {
274 + title: "Valorisation d'Options",
275 + icon: Target,
276 + example: 'Calcule le prix d\'une option call sur Apple avec strike $200, expiration dans 30 jours. Montre tous les Greeks',
277 + color: "text-indigo-500"
278 + },
279 + {
280 + title: "Recherche Sectorielle",
281 + icon: Building2,
282 + example: 'Trouve les 10 entreprises tech avec le meilleur rendement de dividendes. Compare leurs ratios P/E et croissance',
283 + color: "text-emerald-500"
284 + },
285 + {
286 + title: "Analyse Macro",
287 + icon: Globe,
288 + example: 'Comment une surprise hawkish du FOMC affecte différents secteurs ? Analyse l\'impact historique sur tech, finance et utilities',
289 + color: "text-cyan-500"
290 + },
291 + {
292 + title: "Trading d'Initiés",
293 + icon: Users,
294 + example: 'Montre les dernières transactions d\'initiés pour NVDA. Y a-t-il des patterns ou signaux ?',
295 + color: "text-pink-500"
296 + },
297 + ];
298 +
299 + const advancedExamples = [
300 + {
301 + title: "Analyse Multi-Actions avec Python",
302 + code: `Crée un graphique de corrélation entre Apple, Microsoft, Google, Amazon et Meta sur les 2 dernières années. Calcule aussi la matrice de covariance.`,
303 + result: "Graphique matplotlib + matrice de covariance"
304 + },
305 + {
306 + title: "Backtesting de Stratégie",
307 + code: `Backtest une stratégie simple : achète quand RSI < 30, vends quand RSI > 70 sur Tesla depuis 2020. Calcule le rendement annualisé.`,
308 + result: "Performance, Sharpe ratio, drawdown"
309 + },
310 + {
311 + title: "Analyse de Surface de Volatilité",
312 + code: `Génère une surface de volatilité 3D pour les options Apple avec différents strikes et maturités.`,
313 + result: "Graphique 3D de la volatilité implicite"
314 + },
315 + {
316 + title: "Décomposition de Performance",
317 + code: `Décompose la performance du S&P 500 en : rendement du marché, contribution sectorielle, et effet devise.`,
318 + result: "Attribution de performance détaillée"
319 + },
320 + ];
321 +
322 + return (
323 + <div className="min-h-screen bg-background relative overflow-hidden" data-testid="page-documentation">
324 + {/* Subtle Background */}
325 + <div className="fixed inset-0 z-0 pointer-events-none">
326 + <div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,hsl(var(--primary)/0.03),transparent_70%)]" />
327 + </div>
328 +
329 + {/* Header */}
330 + <header className="sticky top-0 z-50 w-full border-b border-border bg-background/95 backdrop-blur-sm">
331 + <div className="container flex h-14 items-center justify-between px-4 md:px-6">
332 + <div className="flex items-center gap-3 md:gap-4">
333 + <Button
334 + variant="ghost"
335 + size="sm"
336 + onClick={() => setLocation('/')}
337 + className="hover:bg-muted transition-colors gap-2"
338 + data-testid="button-back-to-home"
339 + >
340 + <ArrowLeft className="w-4 h-4" />
341 + <span className="hidden sm:inline font-medium">Retour</span>
342 + </Button>
343 + <button
344 + className="cursor-pointer"
345 + onClick={() => setLocation('/')}
346 + >
347 + <div className="flex items-center gap-2.5">
348 + <Sparkles className="w-5 h-5 text-primary" />
349 + <div className="hidden md:flex items-baseline gap-1.5">
350 + <span className="text-base font-bold text-foreground leading-tight">
351 + VQuant
352 + </span>
353 + </div>
354 + </div>
355 + </button>
356 + </div>
357 + <div className="flex items-center gap-2 md:gap-3">
358 + <div className="h-8 w-px bg-border/50" />
359 + <ThemeColorPicker />
360 + <ThemeToggle />
361 + </div>
362 + </div>
363 + </header>
364 +
365 + {/* Main Content */}
366 + <main className="container mx-auto px-4 py-8 md:py-12 lg:py-20 max-w-7xl relative z-10">
367 + {/* Hero Section */}
368 + <motion.div
369 + initial={{ opacity: 0, y: 20 }}
370 + animate={{ opacity: 1, y: 0 }}
371 + transition={{ duration: 0.6 }}
372 + className="text-center mb-12 md:mb-20"
373 + >
374 + <div className="inline-flex items-center justify-center p-3 md:p-4 rounded-3xl bg-hot shadow-hot border border-white/10 mb-6 md:mb-8">
375 + <Rocket className="w-12 h-12 md:w-16 md:h-16 text-white drop-shadow-[0_0_12px_rgba(255,77,28,0.6)]" />
376 + </div>
377 + <h1 className="text-4xl md:text-5xl lg:text-7xl font-black mb-4 md:mb-6 gradient-text tracking-tight leading-tight">
378 + Documentation Complète
379 + </h1>
380 + <p className="text-lg md:text-xl lg:text-2xl text-muted-foreground mb-4 md:mb-6 max-w-3xl mx-auto leading-relaxed">
381 + Découvrez toutes les capacités de VQuant - Plus de 200 outils financiers et analyses quantitatives à votre disposition.
382 + </p>
383 + <div className="flex flex-wrap items-center justify-center gap-2 md:gap-3">
384 + <Badge className="px-3 md:px-4 py-1.5 md:py-2 text-xs md:text-sm bg-hot text-white border-0">
385 + <Sparkles className="w-3 h-3 md:w-4 md:h-4 mr-2" />
386 + Claude Fable 5
387 + </Badge>
388 + <Badge variant="outline" className="px-3 md:px-4 py-1.5 md:py-2 text-xs md:text-sm">
389 + 200+ Outils FMP
390 + </Badge>
391 + <Badge variant="outline" className="px-3 md:px-4 py-1.5 md:py-2 text-xs md:text-sm">
392 + Python Intégré
393 + </Badge>
394 + <Badge variant="outline" className="px-3 md:px-4 py-1.5 md:py-2 text-xs md:text-sm">
395 + Temps Réel
396 + </Badge>
397 + </div>
398 + </motion.div>
399 +
400 + {/* AI Models */}
401 + <motion.div
402 + initial={{ opacity: 0, y: 20 }}
403 + animate={{ opacity: 1, y: 0 }}
404 + transition={{ duration: 0.6, delay: 0.15 }}
405 + className="mb-12 md:mb-20"
406 + >
407 + <h2 className="text-2xl md:text-3xl lg:text-4xl font-black mb-3 md:mb-4 text-center">Modeles IA disponibles</h2>
408 + <p className="text-center text-muted-foreground mb-8 md:mb-12 text-base md:text-lg">
409 + Choisissez le modele Claude adapte a votre besoin. Selecteur dans la barre de recherche et sur la page de demarrage.
410 + </p>
411 + <div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6">
412 + {aiModels.map((m, i) => {
413 + const Icon = m.icon;
414 + return (
415 + <motion.div
416 + key={m.id}
417 + initial={{ opacity: 0, scale: 0.95 }}
418 + animate={{ opacity: 1, scale: 1 }}
419 + transition={{ duration: 0.4, delay: 0.2 + i * 0.05 }}
420 + >
421 + <Card className="h-full border-2 hover:border-primary/50 transition-all duration-300">
422 + <CardHeader className="pb-3">
423 + <div className="flex items-center gap-3 mb-2">
424 + <div className="p-2 rounded-xl bg-primary/10 border border-primary/20">
425 + <Icon className="w-5 h-5 text-primary" />
426 + </div>
427 + <CardTitle className="text-base md:text-lg">{m.name}</CardTitle>
428 + {m.badge && (
429 + <Badge className={`ml-auto text-[10px] border-0 ${m.badge === "Indisponible" ? "bg-amber-500/20 text-amber-600 dark:text-amber-400" : "bg-primary text-primary-foreground"}`}>{m.badge}</Badge>
430 + )}
431 + </div>
432 + <CardDescription className="text-xs md:text-sm">{m.desc}</CardDescription>
433 + </CardHeader>
434 + <CardContent className="flex flex-wrap gap-2">
435 + <Badge variant="outline" className="text-xs">{m.context}</Badge>
436 + <Badge variant="secondary" className="text-xs font-mono">{m.id}</Badge>
437 + </CardContent>
438 + </Card>
439 + </motion.div>
440 + );
441 + })}
442 + </div>
443 + </motion.div>
444 +
445 + {/* Quick Start Examples */}
446 + <motion.div
447 + initial={{ opacity: 0, y: 20 }}
448 + animate={{ opacity: 1, y: 0 }}
449 + transition={{ duration: 0.6, delay: 0.2 }}
450 + className="mb-12 md:mb-20"
451 + >
452 + <h2 className="text-2xl md:text-3xl lg:text-4xl font-black mb-3 md:mb-4 text-center">Exemples d'Utilisation</h2>
453 + <p className="text-center text-muted-foreground mb-8 md:mb-12 text-base md:text-lg">
454 + Demandez simplement ce que vous voulez analyser - l'IA s'occupe du reste
455 + </p>
456 + <div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6">
457 + {useCases.map((useCase, index) => {
458 + const Icon = useCase.icon;
459 + return (
460 + <motion.div
461 + key={index}
462 + initial={{ opacity: 0, scale: 0.95 }}
463 + animate={{ opacity: 1, scale: 1 }}
464 + transition={{ duration: 0.4, delay: 0.3 + index * 0.05 }}
465 + >
466 + <Card className="h-full hover:shadow-xl transition-all duration-300 border-2 hover:border-primary/50 group cursor-pointer">
467 + <CardHeader className="pb-3">
468 + <Icon className={`w-8 h-8 md:w-10 md:h-10 ${useCase.color} mb-3`} />
469 + <CardTitle className="text-base md:text-lg mb-2">{useCase.title}</CardTitle>
470 + </CardHeader>
471 + <CardContent>
472 + <p className="text-xs md:text-sm text-muted-foreground italic leading-relaxed">
473 + "{useCase.example}"
474 + </p>
475 + </CardContent>
476 + </Card>
477 + </motion.div>
478 + );
479 + })}
480 + </div>
481 + </motion.div>
482 +
483 + {/* All Tools by Category - With Accordions */}
484 + <motion.div
485 + initial={{ opacity: 0, y: 20 }}
486 + animate={{ opacity: 1, y: 0 }}
487 + transition={{ duration: 0.6, delay: 0.4 }}
488 + className="mb-12 md:mb-20"
489 + >
490 + <h2 className="text-2xl md:text-3xl lg:text-4xl font-black mb-3 md:mb-4 text-center">Tous les Outils Disponibles</h2>
491 + <p className="text-center text-muted-foreground mb-8 md:mb-12 text-base md:text-lg max-w-3xl mx-auto">
492 + Plus de 200 outils organisés par catégorie. Cliquez pour explorer chaque section.
493 + </p>
494 +
495 + <Accordion type="multiple" className="space-y-4">
496 + {categories.map((category, index) => {
497 + const Icon = category.icon;
498 + return (
499 + <AccordionItem
500 + key={category.id}
501 + value={category.id}
502 + className="border-2 rounded-2xl px-4 md:px-6 bg-card/50 backdrop-blur-sm hover:border-primary/50 transition-all duration-300"
503 + >
504 + <AccordionTrigger className="hover:no-underline py-4 md:py-6">
505 + <div className="flex items-center gap-3 md:gap-4 text-left">
506 + <div className={`p-2 md:p-3 rounded-xl bg-gradient-to-br ${category.color} flex-shrink-0`}>
507 + <Icon className="w-5 h-5 md:w-6 md:h-6 text-white" />
508 + </div>
509 + <div className="flex-1">
510 + <h3 className="text-base md:text-lg lg:text-xl font-bold mb-1">{category.title}</h3>
511 + <p className="text-xs md:text-sm text-muted-foreground">{category.description}</p>
512 + </div>
513 + <Badge variant="secondary" className="ml-auto text-xs md:text-sm">
514 + {category.tools.length} outils
515 + </Badge>
516 + </div>
517 + </AccordionTrigger>
518 + <AccordionContent className="pt-2 pb-6">
519 + <div className="grid sm:grid-cols-2 gap-3 md:gap-4 mt-4">
520 + {category.tools.map((tool, toolIndex) => (
521 + <Card key={toolIndex} className="hover:shadow-lg transition-all duration-200 border hover:border-primary/30">
522 + <CardHeader className="p-3 md:p-4">
523 + <CardTitle className="text-sm md:text-base font-semibold text-primary mb-1">
524 + {tool.name}
525 + </CardTitle>
526 + <CardDescription className="text-xs md:text-sm leading-relaxed">
527 + {tool.desc}
528 + </CardDescription>
529 + </CardHeader>
530 + <CardContent className="p-3 md:p-4 pt-0">
531 + <div className="bg-muted/50 rounded-lg p-2 md:p-3 border border-border/50">
532 + <p className="text-xs md:text-sm italic text-muted-foreground">
533 + 💬 "{tool.example}"
534 + </p>
535 + </div>
536 + </CardContent>
537 + </Card>
538 + ))}
539 + </div>
540 + </AccordionContent>
541 + </AccordionItem>
542 + );
543 + })}
544 + </Accordion>
545 + </motion.div>
546 +
547 + {/* Python Custom Code Section */}
548 + <motion.div
549 + initial={{ opacity: 0, y: 20 }}
550 + animate={{ opacity: 1, y: 0 }}
551 + transition={{ duration: 0.6, delay: 0.6 }}
552 + className="mb-12 md:mb-20"
553 + >
554 + <Card className="border-2 border-primary/30 bg-gradient-to-br from-primary/5 via-background to-[#ff2d78]/5 shadow-2xl">
555 + <CardHeader className="text-center pb-6 md:pb-8">
556 + <div className="inline-flex items-center justify-center w-14 h-14 md:w-16 md:h-16 rounded-2xl bg-hot shadow-hot mx-auto mb-4 md:mb-6">
557 + <Code className="w-7 h-7 md:w-8 md:h-8 text-white" />
558 + </div>
559 + <CardTitle className="text-2xl md:text-3xl lg:text-4xl font-black mb-3 md:mb-4">
560 + Code Python Libre
561 + </CardTitle>
562 + <CardDescription className="text-base md:text-lg max-w-3xl mx-auto">
563 + Exécutez n'importe quel code Python pour des analyses personnalisées.
564 + Utilisez pandas, numpy, scipy, matplotlib, statsmodels et plus encore.
565 + </CardDescription>
566 + </CardHeader>
567 + <CardContent className="space-y-6 md:space-y-8">
568 + <div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-3 md:gap-4">
569 + {pythonFeatures.map((feature, i) => (
570 + <div key={i} className="flex items-start gap-3 p-3 md:p-4 rounded-xl bg-background/80 border-2 border-primary/10 hover:border-primary/30 transition-all duration-200">
571 + <div className="text-xl md:text-2xl">{feature.name.split(' ')[0]}</div>
572 + <div>
573 + <div className="font-semibold text-sm md:text-base mb-1">{feature.name.substring(3)}</div>
574 + <div className="text-xs md:text-sm text-muted-foreground">{feature.desc}</div>
575 + </div>
576 + </div>
577 + ))}
578 + </div>
579 +
580 + <div className="bg-muted/30 rounded-xl p-4 md:p-6 border-2 border-dashed border-primary/20">
581 + <h4 className="font-bold text-base md:text-lg mb-4 flex items-center gap-2">
582 + <Code className="w-5 h-5 text-primary" />
583 + Exemples Avancés
584 + </h4>
585 + <div className="space-y-4">
586 + {advancedExamples.map((ex, i) => (
587 + <div key={i} className="bg-background rounded-lg p-3 md:p-4 border">
588 + <div className="font-semibold text-sm md:text-base mb-2 text-primary">{ex.title}</div>
589 + <div className="bg-muted/50 rounded p-2 md:p-3 mb-2 font-mono text-xs md:text-sm">
590 + {ex.code}
591 + </div>
592 + <div className="text-xs md:text-sm text-muted-foreground flex items-center gap-2">
593 + <Badge variant="secondary" className="text-xs">Résultat</Badge>
594 + {ex.result}
595 + </div>
596 + </div>
597 + ))}
598 + </div>
599 + </div>
600 + </CardContent>
601 + </Card>
602 + </motion.div>
603 +
604 + {/* How It Works */}
605 + <motion.div
606 + initial={{ opacity: 0, y: 20 }}
607 + animate={{ opacity: 1, y: 0 }}
608 + transition={{ duration: 0.6, delay: 0.7 }}
609 + className="mb-12 md:mb-20"
610 + >
611 + <h2 className="text-2xl md:text-3xl lg:text-4xl font-black mb-3 md:mb-4 text-center">Comment ça marche ?</h2>
612 + <p className="text-center text-muted-foreground mb-8 md:mb-12 text-base md:text-lg">
613 + Un processus simple en 3 étapes
614 + </p>
615 + <div className="grid md:grid-cols-3 gap-6 md:gap-8">
616 + {[
617 + {
618 + step: "1",
619 + title: "Posez votre question",
620 + desc: "Décrivez simplement ce que vous voulez analyser en langage naturel",
621 + icon: Sparkles,
622 + color: "from-blue-500 to-cyan-500"
623 + },
624 + {
625 + step: "2",
626 + title: "L'IA travaille pour vous",
627 + desc: "Claude sélectionne et utilise automatiquement les meilleurs outils pour répondre",
628 + icon: BrainCircuit,
629 + color: "from-purple-500 to-pink-500"
630 + },
631 + {
632 + step: "3",
633 + title: "Recevez l'analyse",
634 + desc: "Obtenez une réponse structurée avec graphiques, données et insights",
635 + icon: BarChart3,
636 + color: "from-green-500 to-emerald-500"
637 + }
638 + ].map((step, i) => {
639 + const Icon = step.icon;
640 + return (
641 + <Card key={i} className="text-center border-2 hover:shadow-2xl transition-all duration-300">
642 + <CardHeader>
643 + <div className="mx-auto mb-4">
644 + <div className={`inline-flex items-center justify-center w-16 h-16 md:w-20 md:h-20 rounded-2xl bg-gradient-to-br ${step.color} p-[2px]`}>
645 + <div className="w-full h-full bg-background rounded-[14px] flex items-center justify-center">
646 + <Icon className="w-7 h-7 md:w-9 md:h-9 text-primary" />
647 + </div>
648 + </div>
649 + </div>
650 + <div className={`inline-flex items-center justify-center w-10 h-10 md:w-12 md:h-12 rounded-full bg-gradient-to-br ${step.color} text-white font-black text-lg md:text-xl mb-3 md:mb-4 mx-auto`}>
651 + {step.step}
652 + </div>
653 + <CardTitle className="text-lg md:text-xl mb-2 md:mb-3">{step.title}</CardTitle>
654 + <CardDescription className="text-sm md:text-base leading-relaxed">
655 + {step.desc}
656 + </CardDescription>
657 + </CardHeader>
658 + </Card>
659 + );
660 + })}
661 + </div>
662 + </motion.div>
663 +
664 + {/* API Coverage */}
665 + <motion.div
666 + initial={{ opacity: 0, y: 20 }}
667 + animate={{ opacity: 1, y: 0 }}
668 + transition={{ duration: 0.6, delay: 0.8 }}
669 + className="mb-12 md:mb-20"
670 + >
671 + <Card className="border-2 border-green-500/30 bg-gradient-to-br from-green-500/5 via-background to-emerald-500/5">
672 + <CardHeader className="text-center">
673 + <Database className="w-12 h-12 md:w-16 md:h-16 mx-auto mb-4 md:mb-6 text-green-500" />
674 + <CardTitle className="text-2xl md:text-3xl font-bold mb-3">Couverture API Complète</CardTitle>
675 + <CardDescription className="text-base md:text-lg max-w-3xl mx-auto">
676 + VQuant s'intègre avec les meilleures sources de données financières
677 + </CardDescription>
678 + </CardHeader>
679 + <CardContent>
680 + <div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4 md:gap-6">
681 + {[
682 + { name: "Financial Modeling Prep", endpoints: "200+", icon: Database, color: "text-blue-500" },
683 + { name: "Exa AI Search", endpoints: "Recherche sémantique", icon: Search, color: "text-purple-500" },
684 + { name: "Firecrawl", endpoints: "Web scraping", icon: Globe, color: "text-green-500" },
685 + { name: "Tavily", endpoints: "Agrégation news", icon: Newspaper, color: "text-orange-500" },
686 + ].map((api, i) => {
687 + const Icon = api.icon;
688 + return (
689 + <div key={i} className="text-center p-4 md:p-6 rounded-xl bg-background/80 border-2 hover:border-primary/30 transition-all">
690 + <Icon className={`w-10 h-10 md:w-12 md:h-12 mx-auto mb-3 md:mb-4 ${api.color}`} />
691 + <div className="font-bold text-sm md:text-base mb-1">{api.name}</div>
692 + <div className="text-xs md:text-sm text-muted-foreground">{api.endpoints}</div>
693 + </div>
694 + );
695 + })}
696 + </div>
697 + </CardContent>
698 + </Card>
699 + </motion.div>
700 +
701 + {/* CTA Section */}
702 + <motion.div
703 + initial={{ opacity: 0, y: 20 }}
704 + animate={{ opacity: 1, y: 0 }}
705 + transition={{ duration: 0.6, delay: 0.9 }}
706 + >
707 + <Card className="border-2 border-primary/50 bg-gradient-to-br from-primary/10 via-background to-[#ff2d78]/10 shadow-2xl">
708 + <CardContent className="p-8 md:p-12 text-center">
709 + <h2 className="text-2xl md:text-3xl lg:text-4xl font-black mb-3 md:mb-4 gradient-text">
710 + Prêt à Explorer ?
711 + </h2>
712 + <p className="text-base md:text-lg text-muted-foreground mb-6 md:mb-8 max-w-2xl mx-auto">
713 + Posez simplement votre question financière et laissez l'IA faire le travail.
714 + Analyses complètes, données en temps réel, et insights puissants en quelques secondes.
715 + </p>
716 + <Button
717 + size="lg"
718 + onClick={() => setLocation('/')}
719 + className="h-12 md:h-14 px-6 md:px-8 text-base md:text-lg bg-primary hover:bg-primary/90 text-primary-foreground transition-colors"
720 + >
721 + <Sparkles className="w-4 h-4 md:w-5 md:h-5 mr-2" />
722 + Commencer une Analyse
723 + </Button>
724 + </CardContent>
725 + </Card>
726 + </motion.div>
727 + </main>
728 + </div>
729 + );
730 +}
added client/src/pages/explore.tsx +492 −0
@@ -0,0 +1,492 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/pages/explore.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { useState } from "react";
18 +import { useQuery } from "@tanstack/react-query";
19 +import { useLocation } from "wouter";
20 +import { Sparkles, BookOpen, Search, ArrowLeft, BarChart3, FileText, DollarSign, Users, Award, Newspaper } from "lucide-react";
21 +import { ThemeToggle } from "@/components/theme-toggle";
22 +import { Button } from "@/components/ui/button";
23 +import { Input } from "@/components/ui/input";
24 +import { Card, CardContent } from "@/components/ui/card";
25 +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
26 +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
27 +
28 +// New explore-specific components
29 +import { MarketOverview } from "@/components/explore/market-overview";
30 +import { StockHeader } from "@/components/explore/stock-header";
31 +import { FinancialStatementsView } from "@/components/explore/financial-statements-view";
32 +import { MetricsView } from "@/components/explore/metrics-view";
33 +import { AnalystView } from "@/components/explore/analyst-view";
34 +import { OwnershipView } from "@/components/explore/ownership-view";
35 +import { NewsView } from "@/components/explore/news-view";
36 +import { AdvancedPriceChart } from "@/components/financial/advanced-price-chart";
37 +import { MarketTicker } from "@/components/market-ticker";
38 +
39 +export default function Explore() {
40 + const [, setLocation] = useLocation();
41 + const [searchQuery, setSearchQuery] = useState("");
42 + const [selectedTicker, setSelectedTicker] = useState<string | null>(null);
43 + const [statementPeriod, setStatementPeriod] = useState<"annual" | "quarter">("annual");
44 +
45 + // Fetch market overview data - Major global indices
46 + const { data: marketIndices } = useQuery({
47 + queryKey: ['market-indices'],
48 + queryFn: async () => {
49 + // Major global indices to display
50 + const symbols = [
51 + '^GSPC', // S&P 500
52 + '^DJI', // Dow Jones Industrial Average
53 + '^IXIC', // NASDAQ Composite
54 + '^RUT', // Russell 2000
55 + '^FTSE', // FTSE 100 (UK)
56 + '^GDAXI', // DAX (Germany)
57 + '^FCHI', // CAC 40 (France)
58 + '^N225', // Nikkei 225 (Japan)
59 + '^HSI', // Hang Seng (Hong Kong)
60 + '^STOXX50E', // EURO STOXX 50
61 + '^AXJO', // S&P/ASX 200 (Australia)
62 + '^GSPTSE' // S&P/TSX Composite (Canada)
63 + ];
64 +
65 + const symbolsParam = symbols.join(',');
66 + const res = await fetch(`https://financialmodelingprep.com/stable/quote?symbol=${symbolsParam}&apikey=${import.meta.env.VITE_FMP_API_KEY || 'JeWwQMjWS3H6hBGHaVxKy1WfGyU5ZeFq'}`);
67 +
68 + if (!res.ok) throw new Error('Failed to fetch market indices');
69 + const data = await res.json();
70 +
71 + // Transform data to match expected format with validation
72 + return data
73 + .filter((index: any) => index && index.symbol && index.price != null)
74 + .map((index: any) => ({
75 + symbol: index.symbol,
76 + name: index.name || index.symbol,
77 + price: Number(index.price) || 0,
78 + change: Number(index.change) || 0,
79 + changesPercentage: Number(index.changesPercentage) || 0,
80 + dayLow: Number(index.dayLow) || 0,
81 + dayHigh: Number(index.dayHigh) || 0,
82 + yearHigh: Number(index.yearHigh) || 0,
83 + yearLow: Number(index.yearLow) || 0,
84 + volume: Number(index.volume) || 0,
85 + previousClose: Number(index.previousClose) || 0
86 + }));
87 + },
88 + refetchInterval: 60000,
89 + });
90 +
91 + const { data: marketStatus } = useQuery({
92 + queryKey: ['market-status'],
93 + queryFn: async () => {
94 + const res = await fetch('/api/fmp/market-hours');
95 + if (!res.ok) throw new Error('Failed to fetch market status');
96 + return res.json();
97 + },
98 + });
99 +
100 + const { data: activeStocks } = useQuery({
101 + queryKey: ['active-stocks'],
102 + queryFn: async () => {
103 + const res = await fetch('/api/fmp/actives');
104 + if (!res.ok) throw new Error('Failed to fetch active stocks');
105 + return res.json();
106 + },
107 + });
108 +
109 + const { data: gainers } = useQuery({
110 + queryKey: ['gainers'],
111 + queryFn: async () => {
112 + const res = await fetch('/api/fmp/gainers');
113 + if (!res.ok) throw new Error('Failed to fetch gainers');
114 + return res.json();
115 + },
116 + });
117 +
118 + const { data: losers } = useQuery({
119 + queryKey: ['losers'],
120 + queryFn: async () => {
121 + const res = await fetch('/api/fmp/losers');
122 + if (!res.ok) throw new Error('Failed to fetch losers');
123 + return res.json();
124 + },
125 + });
126 +
127 + // Fetch stock data when ticker is selected
128 + const { data: companyProfile } = useQuery({
129 + queryKey: ['company-profile', selectedTicker],
130 + queryFn: async () => {
131 + if (!selectedTicker) return null;
132 + const res = await fetch(`/api/fmp/company-profile/${selectedTicker}`);
133 + if (!res.ok) throw new Error('Failed to fetch company profile');
134 + return res.json();
135 + },
136 + enabled: !!selectedTicker,
137 + });
138 +
139 + const { data: stockQuote } = useQuery({
140 + queryKey: ['stock-quote', selectedTicker],
141 + queryFn: async () => {
142 + if (!selectedTicker) return null;
143 + const res = await fetch(`/api/fmp/stock-quote/${selectedTicker}`);
144 + if (!res.ok) throw new Error('Failed to fetch stock quote');
145 + return res.json();
146 + },
147 + enabled: !!selectedTicker,
148 + refetchInterval: 5000,
149 + });
150 +
151 + const { data: historicalPrice } = useQuery({
152 + queryKey: ['historical-price', selectedTicker],
153 + queryFn: async () => {
154 + if (!selectedTicker) return null;
155 + const res = await fetch(`/api/fmp/historical-price/${selectedTicker}`);
156 + if (!res.ok) throw new Error('Failed to fetch historical price');
157 + return res.json();
158 + },
159 + enabled: !!selectedTicker,
160 + });
161 +
162 + const { data: incomeStatement } = useQuery({
163 + queryKey: ['income-statement', selectedTicker, statementPeriod],
164 + queryFn: async () => {
165 + if (!selectedTicker) return null;
166 + const res = await fetch(`/api/fmp/income-statement/${selectedTicker}?period=${statementPeriod}&limit=5`);
167 + if (!res.ok) throw new Error('Failed to fetch income statement');
168 + return res.json();
169 + },
170 + enabled: !!selectedTicker,
171 + });
172 +
173 + const { data: balanceSheet } = useQuery({
174 + queryKey: ['balance-sheet', selectedTicker, statementPeriod],
175 + queryFn: async () => {
176 + if (!selectedTicker) return null;
177 + const res = await fetch(`/api/fmp/balance-sheet/${selectedTicker}?period=${statementPeriod}&limit=5`);
178 + if (!res.ok) throw new Error('Failed to fetch balance sheet');
179 + return res.json();
180 + },
181 + enabled: !!selectedTicker,
182 + });
183 +
184 + const { data: cashFlow } = useQuery({
185 + queryKey: ['cash-flow', selectedTicker, statementPeriod],
186 + queryFn: async () => {
187 + if (!selectedTicker) return null;
188 + const res = await fetch(`/api/fmp/cash-flow/${selectedTicker}?period=${statementPeriod}&limit=5`);
189 + if (!res.ok) throw new Error('Failed to fetch cash flow');
190 + return res.json();
191 + },
192 + enabled: !!selectedTicker,
193 + });
194 +
195 + const { data: keyMetrics } = useQuery({
196 + queryKey: ['key-metrics', selectedTicker],
197 + queryFn: async () => {
198 + if (!selectedTicker) return null;
199 + const res = await fetch(`/api/fmp/key-metrics/${selectedTicker}?period=annual&limit=5`);
200 + if (!res.ok) throw new Error('Failed to fetch key metrics');
201 + return res.json();
202 + },
203 + enabled: !!selectedTicker,
204 + });
205 +
206 + const { data: analystEstimates } = useQuery({
207 + queryKey: ['analyst-estimates', selectedTicker],
208 + queryFn: async () => {
209 + if (!selectedTicker) return null;
210 + const res = await fetch(`/api/fmp/analyst-estimates/${selectedTicker}?period=annual`);
211 + if (!res.ok) throw new Error('Failed to fetch analyst estimates');
212 + return res.json();
213 + },
214 + enabled: !!selectedTicker,
215 + });
216 +
217 + const { data: dividends } = useQuery({
218 + queryKey: ['dividends', selectedTicker],
219 + queryFn: async () => {
220 + if (!selectedTicker) return null;
221 + const res = await fetch(`/api/fmp/dividend-history/${selectedTicker}`);
222 + if (!res.ok) throw new Error('Failed to fetch dividends');
223 + return res.json();
224 + },
225 + enabled: !!selectedTicker,
226 + });
227 +
228 + const { data: institutionalHolders } = useQuery({
229 + queryKey: ['institutional-holders', selectedTicker],
230 + queryFn: async () => {
231 + if (!selectedTicker) return null;
232 + const res = await fetch(`/api/fmp/institutional-holders/${selectedTicker}`);
233 + if (!res.ok) throw new Error('Failed to fetch institutional holders');
234 + return res.json();
235 + },
236 + enabled: !!selectedTicker,
237 + });
238 +
239 + const { data: news } = useQuery({
240 + queryKey: ['news', selectedTicker],
241 + queryFn: async () => {
242 + if (!selectedTicker) return null;
243 + const res = await fetch(`/api/fmp/financial-news/${selectedTicker}?limit=20`);
244 + if (!res.ok) throw new Error('Failed to fetch news');
245 + return res.json();
246 + },
247 + enabled: !!selectedTicker,
248 + });
249 +
250 + const handleSearch = (e: React.FormEvent) => {
251 + e.preventDefault();
252 + if (searchQuery.trim()) {
253 + setSelectedTicker(searchQuery.trim().toUpperCase());
254 + }
255 + };
256 +
257 + const handleTickerClick = (ticker: string) => {
258 + setSelectedTicker(ticker);
259 + setSearchQuery(ticker);
260 + window.scrollTo({ top: 0, behavior: 'smooth' });
261 + };
262 +
263 + const handleBack = () => {
264 + setSelectedTicker(null);
265 + setSearchQuery("");
266 + window.scrollTo({ top: 0, behavior: 'smooth' });
267 + };
268 +
269 + return (
270 + <div className="min-h-screen bg-background" data-testid="page-explore">
271 + {/* Header */}
272 + <header className="sticky top-0 z-50 w-full border-b border-border/40 bg-background/95 backdrop-blur-lg">
273 + <div className="container flex h-14 items-center justify-between px-4">
274 + <div className="flex items-center gap-3">
275 + <Button
276 + variant="ghost"
277 + size="sm"
278 + onClick={() => setLocation('/')}
279 + className="hover-elevate active-elevate-2 gap-2"
280 + >
281 + <ArrowLeft className="w-4 h-4" />
282 + <span className="hidden sm:inline">Retour</span>
283 + </Button>
284 + <button
285 + onClick={() => setLocation('/')}
286 + className="flex items-center gap-2 hover:opacity-80 transition-opacity"
287 + >
288 + <Sparkles className="w-5 h-5 text-primary" />
289 + <span className="hidden md:inline text-lg font-semibold bg-gradient-to-r from-primary to-purple-500 bg-clip-text text-transparent leading-tight">
290 + VQuant
291 + </span>
292 + </button>
293 + </div>
294 + <div className="flex items-center gap-2">
295 + <Tooltip>
296 + <TooltipTrigger asChild>
297 + <Button
298 + variant="ghost"
299 + size="icon"
300 + onClick={() => setLocation('/docs')}
301 + className="hover-elevate active-elevate-2"
302 + >
303 + <BookOpen className="h-5 w-5" />
304 + </Button>
305 + </TooltipTrigger>
306 + <TooltipContent>
307 + <p>Documentation</p>
308 + </TooltipContent>
309 + </Tooltip>
310 + <ThemeToggle />
311 + </div>
312 + </div>
313 + </header>
314 +
315 + {/* Market Ticker Banner */}
316 + <MarketTicker />
317 +
318 + {/* Main Content */}
319 + <main className="container mx-auto px-4 py-8 max-w-[1800px]">
320 + {/* Search Section */}
321 + <div className="mb-8 space-y-4">
322 + <div className="flex items-center gap-3">
323 + <div className="p-3 rounded-xl bg-primary/10">
324 + <BarChart3 className="w-6 h-6 text-primary" />
325 + </div>
326 + <div>
327 + <h1 className="text-3xl font-bold">Exploration Boursière</h1>
328 + <p className="text-muted-foreground">
329 + Analysez en profondeur n'importe quel titre sur les marchés
330 + </p>
331 + </div>
332 + </div>
333 +
334 + {/* Search Bar */}
335 + <form onSubmit={handleSearch} className="relative max-w-2xl">
336 + <Input
337 + type="text"
338 + placeholder="Entrez un symbole boursier (ex: AAPL, TSLA, MSFT)..."
339 + value={searchQuery}
340 + onChange={(e) => setSearchQuery(e.target.value.toUpperCase())}
341 + className="pl-4 pr-12 h-14 text-lg rounded-2xl"
342 + />
343 + <button
344 + type="submit"
345 + className="absolute right-3 top-1/2 -translate-y-1/2 p-2 rounded-lg hover:bg-primary/10 transition-colors"
346 + >
347 + <Search className="w-5 h-5 text-primary" />
348 + </button>
349 + </form>
350 + </div>
351 +
352 + {/* Market Overview or Stock Dashboard */}
353 + {!selectedTicker ? (
354 + <MarketOverview
355 + marketIndices={marketIndices}
356 + marketStatus={marketStatus}
357 + gainers={gainers}
358 + losers={losers}
359 + activeStocks={activeStocks}
360 + onTickerClick={handleTickerClick}
361 + />
362 + ) : (
363 + /* Stock Dashboard */
364 + <div className="space-y-8">
365 + {/* Stock Header */}
366 + <StockHeader
367 + ticker={selectedTicker}
368 + companyProfile={companyProfile?.[0]}
369 + stockQuote={stockQuote?.[0]}
370 + onBack={handleBack}
371 + />
372 +
373 + {/* Price Chart */}
374 + {historicalPrice && (
375 + <Card>
376 + <CardContent className="pt-6">
377 + <AdvancedPriceChart data={historicalPrice} />
378 + </CardContent>
379 + </Card>
380 + )}
381 +
382 + {/* Tabs for different sections */}
383 + <Tabs defaultValue="financials" className="w-full">
384 + <TabsList className="grid w-full grid-cols-2 lg:grid-cols-5 h-auto">
385 + <TabsTrigger value="financials" className="gap-2 py-3">
386 + <FileText className="w-4 h-4" />
387 + <span className="hidden sm:inline">États Financiers</span>
388 + <span className="sm:hidden">Financiers</span>
389 + </TabsTrigger>
390 + <TabsTrigger value="metrics" className="gap-2 py-3">
391 + <DollarSign className="w-4 h-4" />
392 + <span className="hidden sm:inline">Métriques Clés</span>
393 + <span className="sm:hidden">Métriques</span>
394 + </TabsTrigger>
395 + <TabsTrigger value="analysts" className="gap-2 py-3">
396 + <Award className="w-4 h-4" />
397 + <span className="hidden sm:inline">Analystes</span>
398 + <span className="sm:hidden">Analystes</span>
399 + </TabsTrigger>
400 + <TabsTrigger value="ownership" className="gap-2 py-3">
401 + <Users className="w-4 h-4" />
402 + <span className="hidden sm:inline">Actionnariat</span>
403 + <span className="sm:hidden">Actions</span>
404 + </TabsTrigger>
405 + <TabsTrigger value="news" className="gap-2 py-3">
406 + <Newspaper className="w-4 h-4" />
407 + <span className="hidden sm:inline">Actualités</span>
408 + <span className="sm:hidden">News</span>
409 + </TabsTrigger>
410 + </TabsList>
411 +
412 + {/* Financial Statements Tab */}
413 + <TabsContent value="financials" className="space-y-6 mt-6">
414 + <div className="flex items-center justify-between mb-4">
415 + <div>
416 + <h3 className="text-2xl font-bold">États Financiers Détaillés</h3>
417 + <p className="text-muted-foreground">
418 + Compte de résultat, bilan et flux de trésorerie
419 + </p>
420 + </div>
421 + <div className="flex gap-2">
422 + <Button
423 + variant={statementPeriod === 'annual' ? 'default' : 'outline'}
424 + size="sm"
425 + onClick={() => setStatementPeriod('annual')}
426 + >
427 + Annuel
428 + </Button>
429 + <Button
430 + variant={statementPeriod === 'quarter' ? 'default' : 'outline'}
431 + size="sm"
432 + onClick={() => setStatementPeriod('quarter')}
433 + >
434 + Trimestriel
435 + </Button>
436 + </div>
437 + </div>
438 + <FinancialStatementsView
439 + incomeStatement={incomeStatement}
440 + balanceSheet={balanceSheet}
441 + cashFlow={cashFlow}
442 + period={statementPeriod}
443 + />
444 + </TabsContent>
445 +
446 + {/* Key Metrics Tab */}
447 + <TabsContent value="metrics" className="mt-6">
448 + <div className="mb-6">
449 + <h3 className="text-2xl font-bold">Métriques et Ratios Financiers</h3>
450 + <p className="text-muted-foreground">
451 + Analyse approfondie des ratios de valorisation, rentabilité et efficacité
452 + </p>
453 + </div>
454 + <MetricsView keyMetrics={keyMetrics} />
455 + </TabsContent>
456 +
457 + {/* Analysts Tab */}
458 + <TabsContent value="analysts" className="mt-6">
459 + <div className="mb-6">
460 + <h3 className="text-2xl font-bold">Estimations et Dividendes</h3>
461 + <p className="text-muted-foreground">
462 + Prévisions des analystes et historique des dividendes
463 + </p>
464 + </div>
465 + <AnalystView
466 + analystEstimates={analystEstimates}
467 + dividends={dividends}
468 + />
469 + </TabsContent>
470 +
471 + {/* Ownership Tab */}
472 + <TabsContent value="ownership" className="mt-6">
473 + <div className="mb-6">
474 + <h3 className="text-2xl font-bold">Structure Actionnariale</h3>
475 + <p className="text-muted-foreground">
476 + Actionnaires institutionnels et changements de position
477 + </p>
478 + </div>
479 + <OwnershipView institutionalHolders={institutionalHolders} />
480 + </TabsContent>
481 +
482 + {/* News Tab */}
483 + <TabsContent value="news" className="mt-6">
484 + <NewsView news={news} />
485 + </TabsContent>
486 + </Tabs>
487 + </div>
488 + )}
489 + </main>
490 + </div>
491 + );
492 +}
added client/src/pages/home.tsx +263 −0
@@ -0,0 +1,263 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/pages/home.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { SearchBar } from "@/components/chat/search-bar";
18 +import { EmptyState } from "@/components/chat/empty-state";
19 +import { StreamingAnswer } from "@/components/streaming-answer";
20 +import { ToolResults } from "@/components/chat/tool-results";
21 +import { AgentSteps } from "@/components/chat/agent-steps";
22 +import { ThinkingWidget } from "@/components/chat/thinking-widget";
23 +import { MonteCarloResults } from "@/components/chat/monte-carlo-results";
24 +import { OptionsPricingCard } from "@/components/financial/options-pricing-card";
25 +import { CustomPythonFigure } from "@/components/chat/custom-python-figure";
26 +import { ResultCard } from "@/components/chat/result-card";
27 +import { SearchingWidget } from "@/components/chat/searching-widget";
28 +import { ErrorState } from "@/components/chat/error-state";
29 +import { ThemeToggle } from "@/components/theme-toggle";
30 +import { ThemeColorPicker } from "@/components/theme-color-picker";
31 +import { ShareButton } from "@/components/chat/share-button";
32 +import { DownloadButtons } from "@/components/chat/download-buttons";
33 +import { ConversationHistory } from "@/components/chat/conversation-history";
34 +import { SessionHistory } from "@/components/chat/session-history";
35 +import { MarketTicker } from "@/components/market-ticker";
36 +import { Sparkles, BookOpen, Plus, Users, User, History, LogOut } from "lucide-react";
37 +import { useLocation } from "wouter";
38 +import { Button } from "@/components/ui/button";
39 +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
40 +import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
41 +import { useChatSession } from "@/hooks/useChatSession";
42 +
43 +export default function Home() {
44 + const [, setLocation] = useLocation();
45 + const {
46 + currentUser,
47 + handleLogout,
48 + currentSessionId,
49 + historyOpen,
50 + setHistoryOpen,
51 + handleLoadSession,
52 + conversationHistory,
53 + currentQuestion,
54 + streamingAnswer,
55 + thinkingContent,
56 + sources,
57 + searchQueries,
58 + toolResults,
59 + agentSteps,
60 + statusMessage,
61 + currentSearchIndex,
62 + pythonCode,
63 + monteCarloResults,
64 + optionsPricingResults,
65 + customPythonFigures,
66 + figureRegistry,
67 + figureUrls,
68 + resultsRef,
69 + chatMutation,
70 + hasSearched,
71 + handleSearch,
72 + handleRetry,
73 + handleNewConversation,
74 + selectedModel,
75 + handleModelChange,
76 + } = useChatSession();
77 +
78 + return (
79 + <div className="flex flex-col h-screen bg-background relative overflow-hidden" data-testid="page-home">
80 + {/* Subtle Background */}
81 + <div className="fixed inset-0 z-0 pointer-events-none">
82 + <div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,hsl(var(--primary)/0.03),transparent_70%)]" />
83 + </div>
84 +
85 + {/* Header */}
86 + <header className="sticky top-0 z-50 w-full border-b border-border bg-background/95 backdrop-blur-sm" data-testid="header-main">
87 + <div className="container flex h-14 items-center justify-between px-6">
88 + <div className="cursor-pointer" data-testid="logo-container">
89 + <div className="flex items-center gap-2.5">
90 + <div className="relative grid place-items-center size-8 rounded-xl bg-hot shadow-hot overflow-hidden" data-testid="logo-icon">
91 + <span className="pointer-events-none absolute inset-0 bg-gradient-to-br from-white/40 to-transparent opacity-60" />
92 + <Sparkles className="relative w-4 h-4 text-white" />
93 + </div>
94 + <div className="hidden md:flex items-baseline gap-0.5">
95 + <span className="text-base font-extrabold leading-tight tracking-tight">
96 + <span className="text-foreground">V</span><span className="gradient-text">Quant</span>
97 + </span>
98 + </div>
99 + </div>
100 + </div>
101 + <div className="flex items-center gap-3">
102 + {(conversationHistory.length > 0 || hasSearched) && (
103 + <Tooltip>
104 + <TooltipTrigger asChild>
105 + <Button variant="ghost" size="icon" onClick={handleNewConversation} data-testid="button-new-conversation" className="h-9 w-9 hover:bg-muted transition-colors">
106 + <Plus className="h-5 w-5" />
107 + </Button>
108 + </TooltipTrigger>
109 + <TooltipContent><p>Nouvelle conversation</p></TooltipContent>
110 + </Tooltip>
111 + )}
112 + <Tooltip>
113 + <TooltipTrigger asChild>
114 + <Button variant="ghost" size="icon" onClick={() => setLocation('/showcase')} data-testid="button-showcase" className="h-9 w-9 hover:bg-muted transition-colors">
115 + <Users className="h-5 w-5" />
116 + </Button>
117 + </TooltipTrigger>
118 + <TooltipContent><p>Showcase Communautaire</p></TooltipContent>
119 + </Tooltip>
120 + <Tooltip>
121 + <TooltipTrigger asChild>
122 + <Button variant="ghost" size="icon" onClick={() => setLocation('/docs')} data-testid="button-docs" className="h-9 w-9 hover:bg-muted transition-colors">
123 + <BookOpen className="h-5 w-5" />
124 + </Button>
125 + </TooltipTrigger>
126 + <TooltipContent><p>Documentation</p></TooltipContent>
127 + </Tooltip>
128 + <div className="h-8 w-px bg-border/50 mx-1" />
129 + {currentUser ? (
130 + <>
131 + <Sheet open={historyOpen} onOpenChange={setHistoryOpen}>
132 + <SheetTrigger asChild>
133 + <Tooltip>
134 + <TooltipTrigger asChild>
135 + <Button variant="ghost" size="icon" className="h-9 w-9 hover:bg-muted transition-colors">
136 + <History className="h-5 w-5" />
137 + </Button>
138 + </TooltipTrigger>
139 + <TooltipContent><p>Mon historique</p></TooltipContent>
140 + </Tooltip>
141 + </SheetTrigger>
142 + <SheetContent side="right" className="w-80 sm:w-96">
143 + <SheetHeader><SheetTitle>Mon historique</SheetTitle></SheetHeader>
144 + <div className="mt-4 h-[calc(100vh-8rem)]">
145 + <SessionHistory onLoadSession={handleLoadSession} currentSessionId={currentSessionId} />
146 + </div>
147 + </SheetContent>
148 + </Sheet>
149 + <div className="hidden md:flex items-center gap-1.5 px-2.5 py-1.5 rounded-xl bg-primary/10 border border-primary/20">
150 + <User className="h-3.5 w-3.5 text-primary" />
151 + <span className="text-xs font-medium text-primary max-w-[80px] truncate">
152 + {currentUser.displayName}
153 + </span>
154 + </div>
155 + <Tooltip>
156 + <TooltipTrigger asChild>
157 + <Button variant="ghost" size="icon" onClick={handleLogout} className="h-9 w-9 hover:bg-muted transition-colors">
158 + <LogOut className="h-5 w-5" />
159 + </Button>
160 + </TooltipTrigger>
161 + <TooltipContent><p>Deconnexion</p></TooltipContent>
162 + </Tooltip>
163 + </>
164 + ) : (
165 + <Tooltip>
166 + <TooltipTrigger asChild>
167 + <Button variant="ghost" size="icon" onClick={() => setLocation('/auth')} className="h-9 w-9 hover:bg-muted transition-colors">
168 + <User className="h-5 w-5" />
169 + </Button>
170 + </TooltipTrigger>
171 + <TooltipContent><p>Mon compte</p></TooltipContent>
172 + </Tooltip>
173 + )}
174 + <div className="h-8 w-px bg-border/50 mx-1" />
175 + <ThemeColorPicker />
176 + <ThemeToggle />
177 + </div>
178 + </div>
179 + </header>
180 +
181 + <MarketTicker />
182 +
183 + {/* Main Content */}
184 + <main className="flex-1 overflow-y-auto relative z-10 pb-32" data-testid="main-content">
185 + <div className="container mx-auto px-4 py-6 max-w-5xl">
186 + {!hasSearched && <EmptyState onQuestionClick={handleSearch} selectedModel={selectedModel} onModelChange={handleModelChange} />}
187 +
188 + {chatMutation.isError && (
189 + <ErrorState error={chatMutation.error?.message || "An error occurred"} onRetry={handleRetry} />
190 + )}
191 +
192 + {chatMutation.isPending && !streamingAnswer && !chatMutation.isError && <SearchingWidget />}
193 +
194 + {conversationHistory.length > 0 && <ConversationHistory history={conversationHistory} />}
195 +
196 + {!chatMutation.isError && (searchQueries.length > 0 || streamingAnswer) && (
197 + <>
198 + {streamingAnswer && !chatMutation.isPending && (
199 + <div className="mb-6 flex justify-end gap-3" data-testid="share-button-container">
200 + <DownloadButtons question={currentQuestion} answer={streamingAnswer} contentRef={resultsRef} figureUrls={figureUrls} />
201 + <ShareButton question={currentQuestion} answer={streamingAnswer} toolResults={toolResults} sources={sources} customPythonFigures={customPythonFigures} />
202 + </div>
203 + )}
204 +
205 + <div className="space-y-6 pb-6" data-testid="results-container" ref={resultsRef}>
206 + <ThinkingWidget content={thinkingContent} isStreaming={chatMutation.isPending} />
207 + <StreamingAnswer
208 + content={streamingAnswer}
209 + sources={sources}
210 + isStreaming={chatMutation.isPending}
211 + searchQueries={searchQueries}
212 + statusMessage={statusMessage}
213 + currentSearchIndex={currentSearchIndex}
214 + figureRegistry={figureRegistry}
215 + figureUrls={figureUrls}
216 + />
217 +
218 + <AgentSteps steps={agentSteps} />
219 + <ToolResults toolResults={toolResults} pythonCode={pythonCode} />
220 +
221 + {monteCarloResults && (
222 + <div data-chart data-chart-title="Monte Carlo Simulation Results">
223 + <MonteCarloResults pythonCode={pythonCode} results={monteCarloResults} />
224 + </div>
225 + )}
226 +
227 + {optionsPricingResults && (
228 + <div className="max-w-5xl mx-auto mb-8" data-chart data-chart-title="Options Pricing Analysis">
229 + <OptionsPricingCard data={optionsPricingResults} />
230 + </div>
231 + )}
232 +
233 + {customPythonFigures.length > 0 && (
234 + <div className="max-w-5xl mx-auto mb-8" data-chart data-chart-title="Custom Python Analysis Gallery">
235 + <CustomPythonFigure figureBatches={customPythonFigures} />
236 + </div>
237 + )}
238 +
239 + {sources.length > 0 && !chatMutation.isPending && (
240 + <div className="max-w-5xl mx-auto" data-testid="web-results-section">
241 + <h2 className="text-2xl font-semibold mb-6" data-testid="text-web-results-heading">Web Results</h2>
242 + <div className="space-y-4" data-testid="web-results-list">
243 + {sources.map((result, index) => (
244 + <ResultCard key={result.url} result={result} index={index} />
245 + ))}
246 + </div>
247 + </div>
248 + )}
249 + </div>
250 + </>
251 + )}
252 + </div>
253 + </main>
254 +
255 + {/* Search Bar - Fixed at bottom */}
256 + <div className="fixed bottom-0 left-0 right-0 z-40 border-t border-border bg-background/95 backdrop-blur-sm">
257 + <div className="container mx-auto px-4 py-3 max-w-5xl">
258 + <SearchBar onSearch={handleSearch} isLoading={chatMutation.isPending} placeholder="Posez votre question sur les marches financiers..." selectedModel={selectedModel} onModelChange={handleModelChange} />
259 + </div>
260 + </div>
261 + </div>
262 + );
263 +}
added client/src/pages/not-found.tsx +37 −0
@@ -0,0 +1,37 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/pages/not-found.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { Card, CardContent } from "@/components/ui/card";
18 +import { AlertCircle } from "lucide-react";
19 +
20 +export default function NotFound() {
21 + return (
22 + <div className="min-h-screen w-full flex items-center justify-center bg-gray-50">
23 + <Card className="w-full max-w-md mx-4">
24 + <CardContent className="pt-6">
25 + <div className="flex mb-4 gap-2">
26 + <AlertCircle className="h-8 w-8 text-red-500" />
27 + <h1 className="text-2xl font-bold text-gray-900">404 Page Not Found</h1>
28 + </div>
29 +
30 + <p className="mt-4 text-sm text-gray-600">
31 + Did you forget to add the page to the router?
32 + </p>
33 + </CardContent>
34 + </Card>
35 + </div>
36 + );
37 +}
added client/src/pages/privacy.tsx +143 −0
@@ -0,0 +1,143 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/pages/privacy.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { Button } from "@/components/ui/button";
18 +import { ArrowLeft } from "lucide-react";
19 +import { useLocation } from "wouter";
20 +
21 +export default function Privacy() {
22 + const [, setLocation] = useLocation();
23 +
24 + return (
25 + <div className="min-h-screen bg-background">
26 + <div className="container mx-auto px-4 py-8 max-w-4xl">
27 + <Button
28 + variant="ghost"
29 + onClick={() => setLocation('/')}
30 + className="mb-6"
31 + >
32 + <ArrowLeft className="w-4 h-4 mr-2" />
33 + Retour
34 + </Button>
35 +
36 + <div className="prose prose-slate dark:prose-invert max-w-none">
37 + <h1>Privacy Policy</h1>
38 + <p className="text-muted-foreground">Dernière mise à jour : 22 janvier 2026</p>
39 +
40 + <h2>1. Introduction</h2>
41 + <p>
42 + Bienvenue sur VQuant ("nous", "notre" ou "nos"). Nous nous engageons à protéger votre vie privée.
43 + Cette politique de confidentialité explique comment nous collectons, utilisons et protégeons vos informations
44 + personnelles lorsque vous utilisez notre plateforme d'analyse financière propulsée par IA sur <strong>www.vquant.ai</strong>.
45 + </p>
46 +
47 + <h2>2. Informations que nous collectons</h2>
48 +
49 + <h3>2.1 Informations fournies par vous</h3>
50 + <ul>
51 + <li><strong>Compte utilisateur</strong> : Nom d'utilisateur, mot de passe hashé, token de récupération</li>
52 + <li><strong>Requêtes de recherche</strong> : Questions et analyses que vous soumettez à la plateforme</li>
53 + <li><strong>Historique de conversation</strong> : Vos sessions d'analyse et leurs résultats</li>
54 + </ul>
55 +
56 + <h3>2.2 Informations automatiques</h3>
57 + <ul>
58 + <li><strong>Données techniques</strong> : Adresse IP, type de navigateur, timestamps</li>
59 + <li><strong>Cookies de session</strong> : Pour maintenir votre connexion (30 jours max)</li>
60 + <li><strong>Utilisation API</strong> : Tokens utilisés, coûts d'API pour statistiques</li>
61 + </ul>
62 +
63 + <h2>3. Comment nous utilisons vos informations</h2>
64 + <ul>
65 + <li>Fournir et améliorer nos services d'analyse financière</li>
66 + <li>Personnaliser votre expérience utilisateur</li>
67 + <li>Sauvegarder votre historique de conversation</li>
68 + <li>Générer des analyses via Claude AI et nos APIs partenaires</li>
69 + <li>Assurer la sécurité et prévenir les abus</li>
70 + </ul>
71 +
72 + <h2>4. Partage des données</h2>
73 +
74 + <h3>4.1 APIs tierces</h3>
75 + <p>Vos requêtes peuvent être envoyées aux services suivants pour traitement :</p>
76 + <ul>
77 + <li><strong>Anthropic (Claude AI)</strong> : Pour générer les analyses</li>
78 + <li><strong>Financial Modeling Prep</strong> : Pour données financières</li>
79 + <li><strong>Firecrawl</strong> : Pour extraction web et PDFs</li>
80 + <li><strong>Tavily</strong> : Pour recherche web intelligente</li>
81 + <li><strong>Exa</strong> : Pour recherche sémantique</li>
82 + <li><strong>SerpAPI</strong> : Pour recherche Google</li>
83 + </ul>
84 + <p>Ces services ont leurs propres politiques de confidentialité que nous vous encourageons à consulter.</p>
85 +
86 + <h3>4.2 Rapports partagés</h3>
87 + <p>
88 + Lorsque vous utilisez la fonction "Partager", votre analyse devient publiquement accessible via un lien unique.
89 + Ne partagez que des analyses que vous êtes à l'aise de rendre publiques.
90 + </p>
91 +
92 + <h2>5. Sécurité des données</h2>
93 + <ul>
94 + <li>Mots de passe hashés avec bcrypt</li>
95 + <li>Connexions HTTPS en production</li>
96 + <li>Cookies sécurisés (httpOnly, secure)</li>
97 + <li>Variables d'environnement pour clés API</li>
98 + <li>Validation des entrées utilisateur</li>
99 + </ul>
100 +
101 + <h2>6. Vos droits</h2>
102 + <p>Vous avez le droit de :</p>
103 + <ul>
104 + <li><strong>Accéder</strong> à vos données personnelles</li>
105 + <li><strong>Corriger</strong> des informations inexactes</li>
106 + <li><strong>Supprimer</strong> votre compte et données</li>
107 + <li><strong>Exporter</strong> votre historique de conversation</li>
108 + <li><strong>Vous opposer</strong> au traitement de vos données</li>
109 + </ul>
110 +
111 + <h2>7. Rétention des données</h2>
112 + <ul>
113 + <li><strong>Sessions de conversation</strong> : Conservées indéfiniment sauf suppression</li>
114 + <li><strong>Cookies de session</strong> : 30 jours</li>
115 + <li><strong>Rapports partagés</strong> : Conservés indéfiniment sauf suppression</li>
116 + </ul>
117 +
118 + <h2>8. Cookies</h2>
119 + <p>
120 + Nous utilisons uniquement des cookies essentiels pour la gestion de session.
121 + Aucun cookie de tracking ou publicité.
122 + </p>
123 +
124 + <h2>9. Modifications de cette politique</h2>
125 + <p>
126 + Nous pouvons mettre à jour cette politique de confidentialité occasionnellement.
127 + La date de "Dernière mise à jour" en haut de cette page indique quand la politique a été modifiée pour la dernière fois.
128 + </p>
129 +
130 + <h2>10. Contact</h2>
131 + <p>
132 + Pour toute question concernant cette politique de confidentialité ou vos données personnelles, contactez-nous à :
133 + </p>
134 + <p className="font-semibold">
135 + <a href="mailto:admin@vquant.ai" className="text-primary hover:underline">
136 + admin@vquant.ai
137 + </a>
138 + </p>
139 + </div>
140 + </div>
141 + </div>
142 + );
143 +}
added client/src/pages/shared-report.tsx +222 −0
@@ -0,0 +1,222 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/pages/shared-report.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { useQuery } from "@tanstack/react-query";
18 +import { useRoute, useLocation } from "wouter";
19 +import { Sparkles, Loader2, ArrowLeft } from "lucide-react";
20 +import { StreamingAnswer } from "@/components/streaming-answer";
21 +import { ToolResults } from "@/components/chat/tool-results";
22 +import { ResultCard } from "@/components/chat/result-card";
23 +import { ThemeToggle } from "@/components/theme-toggle";
24 +import { ThemeColorPicker } from "@/components/theme-color-picker";
25 +import { Button } from "@/components/ui/button";
26 +import { DownloadButtons } from "@/components/chat/download-buttons";
27 +import { CustomPythonFigure } from "@/components/chat/custom-python-figure";
28 +import type { SearchResult } from "@shared/types";
29 +import { useRef, useMemo } from "react";
30 +
31 +interface SharedReportData {
32 + question: string;
33 + answer: string;
34 + toolResults: any[];
35 + sources: SearchResult[];
36 + customPythonFigures?: Array<{ id: string; figures: string[]; output?: string; description?: string }>;
37 + createdAt: string;
38 +}
39 +
40 +export default function SharedReport() {
41 + const [, params] = useRoute("/share/:shareId");
42 + const [, setLocation] = useLocation();
43 + const shareId = params?.shareId;
44 + const contentRef = useRef<HTMLDivElement>(null);
45 +
46 + const { data, isLoading, error } = useQuery<SharedReportData>({
47 + queryKey: ["/api/share", shareId],
48 + enabled: !!shareId,
49 + });
50 +
51 + // Build figure registry for inline references
52 + const figureRegistry = useMemo(() => {
53 + const registry = new Map<string, string>();
54 + if (data?.customPythonFigures) {
55 + data.customPythonFigures.forEach(batch => {
56 + batch.figures.forEach((fig, idx) => {
57 + const figId = `${batch.id}-${idx}`;
58 + registry.set(figId, fig);
59 + });
60 + });
61 + }
62 + return registry;
63 + }, [data?.customPythonFigures]);
64 +
65 + // Extract flat list of figure data URIs for markdown image resolution
66 + // Figures are stored as base64 in the DB — convert to data URIs so they
67 + // survive redeployments (file-based /figures/ URLs are ephemeral on disk).
68 + const figureUrls = useMemo(() => {
69 + if (!data?.customPythonFigures) return [];
70 + return data.customPythonFigures.flatMap(batch =>
71 + batch.figures.map(fig =>
72 + fig.startsWith('/') || fig.startsWith('http') || fig.startsWith('data:')
73 + ? fig
74 + : `data:image/png;base64,${fig}`
75 + )
76 + );
77 + }, [data?.customPythonFigures]);
78 +
79 + if (isLoading) {
80 + return (
81 + <div className="min-h-screen bg-background flex items-center justify-center">
82 + <div className="text-center">
83 + <Loader2 className="w-8 h-8 animate-spin mx-auto mb-4 text-primary" />
84 + <p className="text-muted-foreground">Chargement du rapport...</p>
85 + </div>
86 + </div>
87 + );
88 + }
89 +
90 + if (error || !data) {
91 + return (
92 + <div className="min-h-screen bg-background flex items-center justify-center">
93 + <div className="text-center">
94 + <h2 className="text-2xl font-semibold mb-2">Rapport introuvable</h2>
95 + <p className="text-muted-foreground">Ce lien de partage n'existe pas ou a expiré</p>
96 + </div>
97 + </div>
98 + );
99 + }
100 +
101 + return (
102 + <div className="min-h-screen bg-background relative overflow-hidden" data-testid="page-shared-report">
103 + {/* Subtle Background */}
104 + <div className="fixed inset-0 z-0 pointer-events-none">
105 + <div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,hsl(var(--primary)/0.03),transparent_70%)]" />
106 + </div>
107 +
108 + {/* Header */}
109 + <header className="sticky top-0 z-50 w-full border-b border-border bg-background/95 backdrop-blur-sm" data-testid="header-main">
110 + <div className="container flex h-14 items-center justify-between px-6">
111 + <div className="flex items-center gap-4">
112 + <Button
113 + variant="ghost"
114 + size="sm"
115 + onClick={() => setLocation('/')}
116 + className="hover:bg-muted transition-colors gap-2"
117 + data-testid="button-back-to-home"
118 + >
119 + <ArrowLeft className="w-4 h-4" />
120 + <span className="hidden sm:inline font-medium">Retour</span>
121 + </Button>
122 + <div className="cursor-pointer" data-testid="logo-container" onClick={() => setLocation('/')}>
123 + <div className="flex items-center gap-2.5">
124 + <Sparkles className="w-5 h-5 text-primary" data-testid="logo-icon" />
125 + <div className="hidden md:flex items-baseline gap-1.5">
126 + <span className="text-base font-bold text-foreground leading-tight">
127 + VQuant
128 + </span>
129 + </div>
130 + </div>
131 + </div>
132 + </div>
133 + <div className="flex items-center gap-3">
134 + <div className="h-8 w-px bg-border/50" />
135 + <ThemeColorPicker />
136 + <ThemeToggle />
137 + </div>
138 + </div>
139 + </header>
140 +
141 + {/* Main Content */}
142 + <main className="container mx-auto px-4 py-12 md:py-20 relative z-10" data-testid="main-content">
143 + <div className="max-w-7xl mx-auto">
144 + {/* Branding */}
145 + <div className="mb-10 p-6 rounded-xl border border-border bg-card" data-testid="branding-section">
146 + <div className="flex items-center gap-4">
147 + <div className="p-2.5 rounded-lg bg-primary/10 border border-primary/20">
148 + <Sparkles className="w-6 h-6 text-primary" />
149 + </div>
150 + <div>
151 + <p className="text-sm font-medium text-muted-foreground">Rapport généré par</p>
152 + <p className="text-xl font-bold text-foreground tracking-tight">
153 + VQuant AI
154 + </p>
155 + </div>
156 + </div>
157 + <p className="text-base text-muted-foreground mt-4 font-medium">
158 + {new Date(data.createdAt).toLocaleDateString('fr-FR', {
159 + year: 'numeric',
160 + month: 'long',
161 + day: 'numeric',
162 + hour: '2-digit',
163 + minute: '2-digit'
164 + })}
165 + </p>
166 + </div>
167 +
168 + {/* Question */}
169 + <div className="mb-6 p-4 rounded-lg bg-muted/50" data-testid="question-section">
170 + <p className="text-sm text-muted-foreground mb-1">Question</p>
171 + <p className="text-lg font-medium" data-testid="text-question">{data.question}</p>
172 + </div>
173 +
174 + {/* Download Buttons */}
175 + <div className="flex gap-3 justify-center my-6">
176 + <DownloadButtons
177 + question={data.question}
178 + answer={data.answer}
179 + contentRef={contentRef}
180 + figureUrls={figureUrls}
181 + />
182 + </div>
183 +
184 + {/* Content */}
185 + <div className="space-y-6" data-testid="content-section" ref={contentRef}>
186 + <StreamingAnswer
187 + content={data.answer}
188 + sources={data.sources}
189 + isStreaming={false}
190 + searchQueries={[]}
191 + statusMessage=""
192 + currentSearchIndex={0}
193 + figureRegistry={figureRegistry}
194 + figureUrls={figureUrls}
195 + />
196 +
197 + <ToolResults toolResults={data.toolResults} />
198 +
199 + {data.customPythonFigures && data.customPythonFigures.length > 0 && (
200 + <div className="max-w-5xl mx-auto mb-8">
201 + <CustomPythonFigure figureBatches={data.customPythonFigures} />
202 + </div>
203 + )}
204 +
205 + {data.sources.length > 0 && (
206 + <div className="max-w-5xl mx-auto" data-testid="web-results-section">
207 + <h2 className="text-2xl font-semibold mb-6" data-testid="text-web-results-heading">
208 + Web Results
209 + </h2>
210 + <div className="grid gap-4" data-testid="results-grid">
211 + {data.sources.map((result: SearchResult, index: number) => (
212 + <ResultCard key={index} result={result} index={index} />
213 + ))}
214 + </div>
215 + </div>
216 + )}
217 + </div>
218 + </div>
219 + </main>
220 + </div>
221 + );
222 +}
added client/src/pages/showcase.tsx +373 −0
@@ -0,0 +1,373 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/pages/showcase.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { useState } from "react";
18 +import { useQuery } from "@tanstack/react-query";
19 +import { useLocation } from "wouter";
20 +import { Sparkles, Search, ArrowLeft, Users, TrendingUp, Filter } from "lucide-react";
21 +import { ThemeToggle } from "@/components/theme-toggle";
22 +import { ThemeColorPicker } from "@/components/theme-color-picker";
23 +import { Button } from "@/components/ui/button";
24 +import { Input } from "@/components/ui/input";
25 +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
26 +import { Badge } from "@/components/ui/badge";
27 +import { formatDistanceToNow } from "date-fns";
28 +import { fr } from "date-fns/locale";
29 +
30 +interface SharedReport {
31 + id: number;
32 + shareId: string;
33 + question: string;
34 + answer: string;
35 + toolResults: any[];
36 + createdAt: string;
37 +}
38 +
39 +export default function Showcase() {
40 + const [, setLocation] = useLocation();
41 + const [searchQuery, setSearchQuery] = useState("");
42 + const [toolFilter, setToolFilter] = useState<string | null>(null);
43 +
44 + // Fetch all shared reports
45 + const { data: reports, isLoading } = useQuery<SharedReport[]>({
46 + queryKey: ["/api/shared-reports"],
47 + refetchInterval: 30000, // Refresh every 30 seconds
48 + });
49 +
50 + // Extract unique tools used across all reports
51 + const allTools = new Set<string>();
52 + reports?.forEach(report => {
53 + report.toolResults?.forEach((tr: any) => {
54 + allTools.add(tr.tool);
55 + });
56 + });
57 +
58 + // Filter reports based on search query and tool filter
59 + const filteredReports = reports?.filter(report => {
60 + const matchesSearch = searchQuery === "" ||
61 + report.question.toLowerCase().includes(searchQuery.toLowerCase()) ||
62 + report.answer.toLowerCase().includes(searchQuery.toLowerCase());
63 +
64 + const matchesTool = !toolFilter || report.toolResults?.some((tr: any) => tr.tool === toolFilter);
65 +
66 + return matchesSearch && matchesTool;
67 + });
68 +
69 + // Complete tool metadata mapping for distinct badges
70 + const TOOL_METADATA: Record<string, { name: string; color: string }> = {
71 + // Fundamental Analysis
72 + get_company_profile: { name: 'Company Profile', color: 'bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20' },
73 + get_income_statement: { name: 'Income Statement', color: 'bg-indigo-500/10 text-indigo-600 dark:text-indigo-400 border-indigo-500/20' },
74 + get_balance_sheet: { name: 'Balance Sheet', color: 'bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20' },
75 + get_cash_flow: { name: 'Cash Flow', color: 'bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/20' },
76 + get_key_metrics: { name: 'Key Metrics', color: 'bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20' },
77 + get_financial_ratios: { name: 'Financial Ratios', color: 'bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 border-cyan-500/20' },
78 + get_financial_growth: { name: 'Financial Growth', color: 'bg-teal-500/10 text-teal-600 dark:text-teal-400 border-teal-500/20' },
79 +
80 + // Market Data
81 + get_stock_quote: { name: 'Stock Quote', color: 'bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20' },
82 + get_historical_price: { name: 'Historical Prices', color: 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20' },
83 + get_intraday_price: { name: 'Intraday Prices', color: 'bg-lime-500/10 text-lime-600 dark:text-lime-400 border-lime-500/20' },
84 +
85 + // Technical Indicators
86 + get_rsi: { name: 'RSI', color: 'bg-cyan-600/10 text-cyan-700 dark:text-cyan-300 border-cyan-600/20' },
87 + get_macd: { name: 'MACD', color: 'bg-blue-600/10 text-blue-700 dark:text-blue-300 border-blue-600/20' },
88 + get_ema: { name: 'EMA', color: 'bg-indigo-600/10 text-indigo-700 dark:text-indigo-300 border-indigo-600/20' },
89 + get_sma: { name: 'SMA', color: 'bg-violet-600/10 text-violet-700 dark:text-violet-300 border-violet-600/20' },
90 + get_adx: { name: 'ADX', color: 'bg-purple-600/10 text-purple-700 dark:text-purple-300 border-purple-600/20' },
91 + get_williams_r: { name: 'Williams %R', color: 'bg-fuchsia-500/10 text-fuchsia-600 dark:text-fuchsia-400 border-fuchsia-500/20' },
92 + get_cci: { name: 'CCI', color: 'bg-pink-500/10 text-pink-600 dark:text-pink-400 border-pink-500/20' },
93 + get_stochastic: { name: 'Stochastic', color: 'bg-rose-500/10 text-rose-600 dark:text-rose-400 border-rose-500/20' },
94 +
95 + // News & Events
96 + get_financial_news: { name: 'Financial News', color: 'bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/20' },
97 + get_press_releases: { name: 'Press Releases', color: 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20' },
98 + get_stock_news_sentiment: { name: 'News Sentiment', color: 'bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 border-yellow-500/20' },
99 +
100 + // Calendar & Events
101 + get_economic_calendar: { name: 'Economic Calendar', color: 'bg-slate-500/10 text-slate-600 dark:text-slate-400 border-slate-500/20' },
102 + get_earnings_calendar: { name: 'Earnings Calendar', color: 'bg-gray-500/10 text-gray-600 dark:text-gray-400 border-gray-500/20' },
103 + get_ipo_calendar: { name: 'IPO Calendar', color: 'bg-zinc-500/10 text-zinc-600 dark:text-zinc-400 border-zinc-500/20' },
104 +
105 + // Trading Activity
106 + get_insider_trading: { name: 'Insider Trading', color: 'bg-red-500/10 text-red-600 dark:text-red-400 border-red-500/20' },
107 + get_congressional_trading: { name: 'Congressional Trading', color: 'bg-red-600/10 text-red-700 dark:text-red-300 border-red-600/20' },
108 + get_senate_trading: { name: 'Senate Trading', color: 'bg-rose-600/10 text-rose-700 dark:text-rose-300 border-rose-600/20' },
109 + get_institutional_holders: { name: 'Institutional Holders', color: 'bg-pink-600/10 text-pink-700 dark:text-pink-300 border-pink-600/20' },
110 +
111 + // Forex & Commodities
112 + get_forex_quote: { name: 'Forex Quote', color: 'bg-cyan-500/10 text-cyan-600 dark:text-cyan-400 border-cyan-500/20' },
113 + get_forex_historical: { name: 'Forex Historical', color: 'bg-teal-600/10 text-teal-700 dark:text-teal-300 border-teal-600/20' },
114 + get_commodity_quotes: { name: 'Commodity Prices', color: 'bg-amber-600/10 text-amber-700 dark:text-amber-300 border-amber-600/20' },
115 +
116 + // Macro & Rates
117 + get_treasury_rates: { name: 'Treasury Rates', color: 'bg-indigo-500/10 text-indigo-600 dark:text-indigo-400 border-indigo-500/20' },
118 + get_economic_indicator: { name: 'Economic Indicator', color: 'bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20' },
119 +
120 + // Analyst & Estimates
121 + get_analyst_estimates: { name: 'Analyst Estimates', color: 'bg-purple-500/10 text-purple-600 dark:text-purple-400 border-purple-500/20' },
122 + get_earnings_surprises: { name: 'Earnings Surprises', color: 'bg-violet-500/10 text-violet-600 dark:text-violet-400 border-violet-500/20' },
123 + get_price_target: { name: 'Price Targets', color: 'bg-fuchsia-600/10 text-fuchsia-700 dark:text-fuchsia-300 border-fuchsia-600/20' },
124 + get_price_target_summary: { name: 'Price Target Summary', color: 'bg-pink-500/10 text-pink-600 dark:text-pink-400 border-pink-500/20' },
125 + get_upgrades_downgrades: { name: 'Upgrades/Downgrades', color: 'bg-rose-500/10 text-rose-600 dark:text-rose-400 border-rose-500/20' },
126 +
127 + // Dividends & Splits
128 + get_dividend_history: { name: 'Dividend History', color: 'bg-green-600/10 text-green-700 dark:text-green-300 border-green-600/20' },
129 + get_stock_split_history: { name: 'Stock Splits', color: 'bg-emerald-600/10 text-emerald-700 dark:text-emerald-300 border-emerald-600/20' },
130 +
131 + // SEC & Filings
132 + get_sec_filings: { name: 'SEC Filings', color: 'bg-gray-600/10 text-gray-700 dark:text-gray-300 border-gray-600/20' },
133 + get_cot_report: { name: 'COT Report', color: 'bg-slate-600/10 text-slate-700 dark:text-slate-300 border-slate-600/20' },
134 +
135 + // ESG
136 + get_esg_score: { name: 'ESG Score', color: 'bg-lime-600/10 text-lime-700 dark:text-lime-300 border-lime-600/20' },
137 +
138 + // Screening & Search
139 + get_stock_screener: { name: 'Stock Screener', color: 'bg-orange-600/10 text-orange-700 dark:text-orange-300 border-orange-600/20' },
140 + search_companies: { name: 'Company Search', color: 'bg-teal-500/10 text-teal-600 dark:text-teal-400 border-teal-500/20' },
141 + get_stock_peers: { name: 'Peer Companies', color: 'bg-sky-600/10 text-sky-700 dark:text-sky-300 border-sky-600/20' },
142 +
143 + // ETF
144 + get_etf_holdings: { name: 'ETF Holdings', color: 'bg-cyan-600/10 text-cyan-700 dark:text-cyan-300 border-cyan-600/20' },
145 + get_etf_sector_weightings: { name: 'ETF Sectors', color: 'bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/20' },
146 + get_etf_country_weightings: { name: 'ETF Countries', color: 'bg-indigo-600/10 text-indigo-700 dark:text-indigo-300 border-indigo-600/20' },
147 +
148 + // Crypto
149 + get_crypto_quote: { name: 'Crypto Quote', color: 'bg-yellow-600/10 text-yellow-700 dark:text-yellow-300 border-yellow-600/20' },
150 + get_crypto_list: { name: 'Crypto List', color: 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20' },
151 +
152 + // Advanced Analysis
153 + run_monte_carlo_simulation: { name: 'Monte Carlo Simulation', color: 'bg-purple-600/10 text-purple-700 dark:text-purple-300 border-purple-600/20' },
154 + calculate_options_price: { name: 'Options Pricing', color: 'bg-blue-600/10 text-blue-700 dark:text-blue-300 border-blue-600/20' },
155 + estimate_garch_volatility: { name: 'GARCH Volatility', color: 'bg-amber-600/10 text-amber-700 dark:text-amber-300 border-amber-600/20' },
156 + calculate_var: { name: 'Value at Risk', color: 'bg-red-600/10 text-red-700 dark:text-red-300 border-red-600/20' },
157 + optimize_portfolio: { name: 'Portfolio Optimization', color: 'bg-green-600/10 text-green-700 dark:text-green-300 border-green-600/20' },
158 + analyze_risk_metrics: { name: 'Risk Metrics', color: 'bg-orange-600/10 text-orange-700 dark:text-orange-300 border-orange-600/20' },
159 +
160 + // Research
161 + web_search_exa: { name: 'Web Search', color: 'bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/20' },
162 +
163 + // Market Hours & Misc
164 + get_market_hours: { name: 'Market Hours', color: 'bg-slate-500/10 text-slate-600 dark:text-slate-400 border-slate-500/20' },
165 + get_company_outlook: { name: 'Company Outlook', color: 'bg-violet-600/10 text-violet-700 dark:text-violet-300 border-violet-600/20' },
166 + get_social_sentiment: { name: 'Social Sentiment', color: 'bg-pink-500/10 text-pink-600 dark:text-pink-400 border-pink-500/20' },
167 +
168 + // Data Downloads & Plots
169 + create_plot: { name: 'Custom Plot', color: 'bg-indigo-500/10 text-indigo-600 dark:text-indigo-400 border-indigo-500/20' },
170 + download_fmp_data: { name: 'Data Download', color: 'bg-sky-500/10 text-sky-600 dark:text-sky-400 border-sky-500/20' },
171 + };
172 +
173 + // Get tool metadata with fallback
174 + const getToolMetadata = (toolName: string) => {
175 + return TOOL_METADATA[toolName] || {
176 + name: toolName.replace(/_/g, ' ').replace(/^get /, ''),
177 + color: 'bg-gray-500/10 text-gray-600 dark:text-gray-400 border-gray-500/20'
178 + };
179 + };
180 +
181 + return (
182 + <div className="min-h-screen bg-background relative overflow-hidden" data-testid="page-showcase">
183 + {/* Subtle Background */}
184 + <div className="fixed inset-0 z-0 pointer-events-none">
185 + <div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,hsl(var(--primary)/0.03),transparent_70%)]" />
186 + </div>
187 +
188 + {/* Header */}
189 + <header className="sticky top-0 z-50 w-full border-b border-border bg-background/95 backdrop-blur-sm">
190 + <div className="container flex h-14 items-center justify-between px-6">
191 + <div className="flex items-center gap-4">
192 + <Button
193 + variant="ghost"
194 + size="sm"
195 + onClick={() => setLocation('/')}
196 + className="hover:bg-muted transition-colors gap-2"
197 + >
198 + <ArrowLeft className="w-4 h-4" />
199 + <span className="hidden sm:inline font-medium">Retour</span>
200 + </Button>
201 + <button
202 + onClick={() => setLocation('/')}
203 + className="flex items-center gap-2.5"
204 + >
205 + <Sparkles className="w-5 h-5 text-primary" />
206 + <div className="hidden md:flex items-baseline gap-1.5">
207 + <span className="text-base font-bold text-foreground leading-tight">
208 + VQuant
209 + </span>
210 + </div>
211 + </button>
212 + </div>
213 + <div className="flex items-center gap-3">
214 + <div className="h-8 w-px bg-border/50" />
215 + <ThemeColorPicker />
216 + <ThemeToggle />
217 + </div>
218 + </div>
219 + </header>
220 +
221 + {/* Main Content */}
222 + <main className="container mx-auto px-4 py-8 max-w-7xl relative z-10">
223 + {/* Page Header */}
224 + <div className="mb-10 space-y-6">
225 + <div className="flex items-center gap-4">
226 + <div className="p-3 rounded-lg bg-primary/10 border border-primary/20">
227 + <Users className="w-6 h-6 text-primary" />
228 + </div>
229 + <div>
230 + <h1 className="text-3xl font-bold text-foreground tracking-tight">
231 + Showcase Communautaire
232 + </h1>
233 + <p className="text-muted-foreground text-base mt-1">
234 + Découvrez toutes les analyses générées par la communauté VQuant
235 + </p>
236 + </div>
237 + </div>
238 +
239 + {/* Search and Filter Bar */}
240 + <div className="flex flex-col sm:flex-row gap-4">
241 + <div className="relative flex-1">
242 + <Input
243 + type="text"
244 + placeholder="Rechercher des analyses..."
245 + value={searchQuery}
246 + onChange={(e) => setSearchQuery(e.target.value)}
247 + className="pl-12 pr-4 h-11 border border-border focus:border-primary bg-card text-base transition-colors"
248 + />
249 + <Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
250 + </div>
251 + {toolFilter && (
252 + <Button
253 + variant="outline"
254 + size="lg"
255 + onClick={() => setToolFilter(null)}
256 + className="gap-2 hover:bg-muted transition-colors"
257 + >
258 + <Filter className="w-4 h-4" />
259 + Effacer filtre
260 + </Button>
261 + )}
262 + </div>
263 +
264 + {/* Stats */}
265 + {reports && (
266 + <div className="flex items-center gap-6 text-sm text-muted-foreground">
267 + <div className="flex items-center gap-2">
268 + <TrendingUp className="w-4 h-4" />
269 + <span>{reports.length} analyses publiques</span>
270 + </div>
271 + <div className="flex items-center gap-2">
272 + <Users className="w-4 h-4" />
273 + <span>{allTools.size} outils utilisés</span>
274 + </div>
275 + </div>
276 + )}
277 + </div>
278 +
279 + {/* Loading State */}
280 + {isLoading && (
281 + <div className="text-center py-12">
282 + <div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
283 + <p className="text-muted-foreground mt-4">Chargement des analyses...</p>
284 + </div>
285 + )}
286 +
287 + {/* Reports Grid */}
288 + {filteredReports && filteredReports.length > 0 && (
289 + <div className="grid md:grid-cols-2 gap-8">
290 + {filteredReports.map((report) => (
291 + <Card
292 + key={report.id}
293 + className="group cursor-pointer border border-border hover:border-primary/40 transition-colors bg-card"
294 + onClick={() => setLocation(`/share/${report.shareId}`)}
295 + >
296 + <CardHeader className="pb-4">
297 + <div className="flex items-start justify-between gap-3">
298 + <CardTitle className="text-lg font-bold line-clamp-2 group-hover:text-primary transition-colors">
299 + {report.question}
300 + </CardTitle>
301 + </div>
302 + <div className="flex items-center gap-2 mt-2">
303 + <div className="w-1.5 h-1.5 rounded-full bg-primary" />
304 + <p className="text-xs text-muted-foreground font-medium">
305 + {formatDistanceToNow(new Date(report.createdAt), {
306 + addSuffix: true,
307 + locale: fr
308 + })}
309 + </p>
310 + </div>
311 + </CardHeader>
312 + <CardContent className="space-y-4">
313 + {/* Answer Preview */}
314 + <p className="text-sm text-muted-foreground line-clamp-3 leading-relaxed">
315 + {report.answer}
316 + </p>
317 +
318 + {/* Tools Used */}
319 + {report.toolResults && report.toolResults.length > 0 && (
320 + <div className="flex flex-wrap gap-2">
321 + {Array.from(new Set(report.toolResults.map((tr: any) => tr.tool))).map((tool: any) => {
322 + const toolMeta = getToolMetadata(tool);
323 + return (
324 + <Badge
325 + key={tool}
326 + variant="outline"
327 + className={`text-xs font-medium px-2.5 py-0.5 ${toolMeta.color} cursor-pointer transition-colors`}
328 + onClick={(e) => {
329 + e.stopPropagation();
330 + setToolFilter(tool);
331 + }}
332 + >
333 + {toolMeta.name}
334 + </Badge>
335 + );
336 + })}
337 + </div>
338 + )}
339 + </CardContent>
340 + </Card>
341 + ))}
342 + </div>
343 + )}
344 +
345 + {/* Empty State */}
346 + {filteredReports && filteredReports.length === 0 && !isLoading && (
347 + <Card className="text-center py-12">
348 + <CardContent>
349 + <Search className="w-12 h-12 mx-auto text-muted-foreground mb-4" />
350 + <h3 className="text-lg font-semibold mb-2">Aucune analyse trouvée</h3>
351 + <p className="text-muted-foreground mb-4">
352 + {searchQuery || toolFilter
353 + ? "Essayez de modifier vos critères de recherche"
354 + : "Aucune analyse n'a été partagée pour le moment"}
355 + </p>
356 + {(searchQuery || toolFilter) && (
357 + <Button
358 + variant="outline"
359 + onClick={() => {
360 + setSearchQuery("");
361 + setToolFilter(null);
362 + }}
363 + >
364 + Réinitialiser les filtres
365 + </Button>
366 + )}
367 + </CardContent>
368 + </Card>
369 + )}
370 + </main>
371 + </div>
372 + );
373 +}
added client/src/pages/slides-generator.tsx +320 −0
@@ -0,0 +1,320 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/pages/slides-generator.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { useState, useEffect } from "react";
18 +import { Button } from "@/components/ui/button";
19 +import { Input } from "@/components/ui/input";
20 +import { Label } from "@/components/ui/label";
21 +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
22 +import { ArrowLeft, Presentation, Loader2, FileCheck, Sparkles } from "lucide-react";
23 +import { useLocation } from "wouter";
24 +import { useToast } from "@/hooks/use-toast";
25 +
26 +export default function SlidesGenerator() {
27 + const [, setLocation] = useLocation();
28 + const { toast } = useToast();
29 + const [authorName, setAuthorName] = useState("");
30 + const [companyName, setCompanyName] = useState("VQuant");
31 + const [question, setQuestion] = useState("");
32 + const [answer, setAnswer] = useState("");
33 + const [figureUrls, setFigureUrls] = useState<string[]>([]);
34 + const [isGenerating, setIsGenerating] = useState(false);
35 + const [isGenerated, setIsGenerated] = useState(false);
36 +
37 + useEffect(() => {
38 + // Récupérer le contenu depuis sessionStorage
39 + const storedContent = sessionStorage.getItem('slidesContent');
40 + if (storedContent) {
41 + try {
42 + const { question: q, answer: a, figureUrls: f } = JSON.parse(storedContent);
43 + setQuestion(q);
44 + setAnswer(a);
45 + if (f) setFigureUrls(f);
46 + } catch (error) {
47 + console.error('Error parsing slides content:', error);
48 + toast({
49 + title: "Erreur",
50 + description: "Impossible de charger le contenu. Retournez à la page principale.",
51 + variant: "destructive",
52 + });
53 + }
54 + } else {
55 + toast({
56 + title: "Aucun contenu",
57 + description: "Aucun contenu à convertir. Retournez à la page principale.",
58 + variant: "destructive",
59 + });
60 + }
61 + }, []);
62 +
63 + const handleGenerateSlides = async () => {
64 + if (!authorName.trim()) {
65 + toast({
66 + title: "Nom requis",
67 + description: "Veuillez entrer votre nom en tant qu'auteur",
68 + variant: "destructive",
69 + });
70 + return;
71 + }
72 +
73 + setIsGenerating(true);
74 +
75 + try {
76 + console.log('[SlidesGenerator] Sending request to generate Beamer PDF...');
77 +
78 + const response = await fetch('/api/convert-to-slides', {
79 + method: 'POST',
80 + headers: {
81 + 'Content-Type': 'application/json',
82 + },
83 + body: JSON.stringify({
84 + question,
85 + answer,
86 + author: authorName,
87 + company: companyName,
88 + figureUrls,
89 + }),
90 + });
91 +
92 + console.log('[SlidesGenerator] Response status:', response.status);
93 + console.log('[SlidesGenerator] Response content-type:', response.headers.get('content-type'));
94 +
95 + // Check if response is an error (JSON)
96 + const contentType = response.headers.get('content-type');
97 + if (contentType?.includes('application/json')) {
98 + const errorData = await response.json();
99 + throw new Error(errorData.error || 'Failed to generate slides');
100 + }
101 +
102 + // Check if response is OK
103 + if (!response.ok) {
104 + throw new Error(`Server error: ${response.status} ${response.statusText}`);
105 + }
106 +
107 + // Response should be PDF blob
108 + console.log('[SlidesGenerator] Downloading PDF blob...');
109 + const blob = await response.blob();
110 + console.log('[SlidesGenerator] PDF blob size:', blob.size, 'bytes');
111 +
112 + if (blob.size === 0) {
113 + throw new Error('PDF is empty');
114 + }
115 +
116 + // Download the PDF
117 + const url = URL.createObjectURL(blob);
118 + const a = document.createElement('a');
119 + a.href = url;
120 + a.download = `VQuant-Presentation-${Date.now()}.pdf`;
121 + document.body.appendChild(a);
122 + a.click();
123 + document.body.removeChild(a);
124 + URL.revokeObjectURL(url);
125 +
126 + console.log('[SlidesGenerator] ✓ PDF downloaded successfully');
127 +
128 + toast({
129 + title: "Présentation générée!",
130 + description: "Votre présentation Beamer PDF a été téléchargée avec succès!",
131 + });
132 +
133 + // Mark as generated for UI update
134 + setIsGenerated(true);
135 + } catch (error) {
136 + console.error('[SlidesGenerator] Error generating slides:', error);
137 + toast({
138 + title: "Erreur de génération",
139 + description: error instanceof Error ? error.message : "Une erreur s'est produite lors de la génération des slides. Veuillez réessayer.",
140 + variant: "destructive",
141 + });
142 + } finally {
143 + setIsGenerating(false);
144 + }
145 + };
146 +
147 + const handleGenerateAnother = () => {
148 + setIsGenerated(false);
149 + setAuthorName("");
150 + sessionStorage.removeItem('slidesContent');
151 + };
152 +
153 + return (
154 + <div className="min-h-screen bg-background">
155 + <div className="container mx-auto px-4 py-8 max-w-4xl">
156 + <Button
157 + variant="ghost"
158 + onClick={() => setLocation('/')}
159 + className="mb-6"
160 + >
161 + <ArrowLeft className="w-4 h-4 mr-2" />
162 + Retour
163 + </Button>
164 +
165 + {!isGenerated ? (
166 + <Card>
167 + <CardHeader>
168 + <CardTitle className="flex items-center gap-2">
169 + <Presentation className="w-6 h-6 text-primary" />
170 + Générateur de Présentation
171 + </CardTitle>
172 + <CardDescription>
173 + Convertissez votre analyse en présentation reveal.js professionnelle avec Claude AI
174 + </CardDescription>
175 + </CardHeader>
176 + <CardContent className="space-y-6">
177 + {/* Preview of content */}
178 + <div className="space-y-2">
179 + <Label>Contenu à convertir</Label>
180 + <div className="p-4 bg-muted/50 rounded-lg max-h-48 overflow-y-auto">
181 + <p className="font-semibold text-sm mb-2">Question :</p>
182 + <p className="text-sm mb-4">{question}</p>
183 + <p className="font-semibold text-sm mb-2">Réponse :</p>
184 + <p className="text-sm line-clamp-6">{answer}</p>
185 + </div>
186 + </div>
187 +
188 + {/* Author name input */}
189 + <div className="space-y-2">
190 + <Label htmlFor="author">Nom de l'auteur *</Label>
191 + <Input
192 + id="author"
193 + placeholder="Votre nom"
194 + value={authorName}
195 + onChange={(e) => setAuthorName(e.target.value)}
196 + className="max-w-md"
197 + />
198 + </div>
199 +
200 + {/* Company name input */}
201 + <div className="space-y-2">
202 + <Label htmlFor="company">Société / Organisation</Label>
203 + <Input
204 + id="company"
205 + placeholder="Nom de votre société"
206 + value={companyName}
207 + onChange={(e) => setCompanyName(e.target.value)}
208 + className="max-w-md"
209 + />
210 + </div>
211 +
212 + {/* Generate button */}
213 + <Button
214 + onClick={handleGenerateSlides}
215 + disabled={isGenerating || !authorName.trim()}
216 + className="w-full sm:w-auto gap-2"
217 + size="lg"
218 + >
219 + {isGenerating ? (
220 + <>
221 + <Loader2 className="w-4 h-4 animate-spin" />
222 + Génération en cours avec Claude AI...
223 + </>
224 + ) : (
225 + <>
226 + <Presentation className="w-4 h-4" />
227 + Générer la Présentation
228 + </>
229 + )}
230 + </Button>
231 +
232 + {/* Info box */}
233 + <div className="mt-6 p-4 bg-blue-500/10 border border-blue-500/20 rounded-lg">
234 + <p className="text-sm text-muted-foreground">
235 + <strong className="text-foreground">🎯 Comment ça marche :</strong>
236 + <br />
237 + 1. Claude Opus 4.8 analyse votre contenu et extrait les points clés
238 + <br />
239 + 2. Il structure l'analyse en slides professionnelles
240 + <br />
241 + 3. LaTeX Beamer compile le tout en PDF de haute qualité
242 + <br />
243 + 4. Le PDF est téléchargé automatiquement (30-90 secondes)
244 + <br />
245 + <br />
246 + <strong className="text-foreground">✨ Résultat :</strong> Présentation PDF de qualité académique/institutionnelle!
247 + </p>
248 + </div>
249 + </CardContent>
250 + </Card>
251 + ) : (
252 + <Card className="border-2 border-green-500/20">
253 + <CardHeader>
254 + <CardTitle className="flex items-center gap-2 text-green-600 dark:text-green-500">
255 + <FileCheck className="w-6 h-6" />
256 + Présentation PDF Générée avec Succès!
257 + </CardTitle>
258 + <CardDescription>
259 + Votre présentation Beamer PDF professionnelle a été téléchargée
260 + </CardDescription>
261 + </CardHeader>
262 + <CardContent className="space-y-4">
263 + <div className="p-6 bg-gradient-to-br from-green-500/10 to-emerald-500/10 border-2 border-green-500/20 rounded-xl text-center">
264 + <Sparkles className="w-12 h-12 text-green-500 mx-auto mb-4" />
265 + <p className="text-lg font-semibold mb-2">Le PDF est dans vos téléchargements!</p>
266 + <p className="text-sm text-muted-foreground">
267 + Ouvrez-le avec n'importe quel lecteur PDF (Adobe, Preview, etc.)
268 + </p>
269 + </div>
270 +
271 + <div className="p-4 bg-blue-500/10 border border-blue-500/20 rounded-lg">
272 + <p className="text-sm">
273 + <strong className="text-foreground">✨ Votre présentation contient :</strong>
274 + </p>
275 + <ul className="text-sm space-y-1 mt-2 ml-4">
276 + <li>• Slide de titre avec vos informations</li>
277 + <li>• Sommaire automatique</li>
278 + <li>• Slides de contenu structurées</li>
279 + <li>• Métriques clés mises en évidence</li>
280 + <li>• Slide de conclusion</li>
281 + <li>• Design professionnel LaTeX Beamer</li>
282 + </ul>
283 + </div>
284 +
285 + <div className="p-4 bg-purple-500/10 border border-purple-500/20 rounded-lg">
286 + <p className="text-sm">
287 + <strong className="text-foreground">🎯 Technologie utilisée :</strong>
288 + </p>
289 + <ul className="text-sm space-y-1 mt-2 ml-4">
290 + <li>• <strong>Claude Opus 4.8</strong> - Structure intelligente du contenu</li>
291 + <li>• <strong>LaTeX Beamer</strong> - Rendu professionnel de qualité académique</li>
292 + <li>• <strong>Theme Madrid</strong> - Design moderne et épuré</li>
293 + </ul>
294 + </div>
295 +
296 + <Button
297 + variant="default"
298 + size="lg"
299 + onClick={handleGenerateAnother}
300 + className="w-full gap-2"
301 + >
302 + <Presentation className="w-4 h-4" />
303 + Générer une Nouvelle Présentation
304 + </Button>
305 +
306 + <Button
307 + variant="outline"
308 + onClick={() => setLocation('/')}
309 + className="w-full"
310 + >
311 + <ArrowLeft className="w-4 h-4 mr-2" />
312 + Retour à l'accueil
313 + </Button>
314 + </CardContent>
315 + </Card>
316 + )}
317 + </div>
318 + </div>
319 + );
320 +}
added client/src/pages/terms.tsx +197 −0
@@ -0,0 +1,197 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/pages/terms.tsx
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import { Button } from "@/components/ui/button";
18 +import { ArrowLeft } from "lucide-react";
19 +import { useLocation } from "wouter";
20 +
21 +export default function Terms() {
22 + const [, setLocation] = useLocation();
23 +
24 + return (
25 + <div className="min-h-screen bg-background">
26 + <div className="container mx-auto px-4 py-8 max-w-4xl">
27 + <Button
28 + variant="ghost"
29 + onClick={() => setLocation('/')}
30 + className="mb-6"
31 + >
32 + <ArrowLeft className="w-4 h-4 mr-2" />
33 + Retour
34 + </Button>
35 +
36 + <div className="prose prose-slate dark:prose-invert max-w-none">
37 + <h1>Terms of Service</h1>
38 + <p className="text-muted-foreground">Dernière mise à jour : 22 janvier 2026</p>
39 +
40 + <h2>1. Acceptation des conditions</h2>
41 + <p>
42 + En accédant et en utilisant VQuant ("le Service"), disponible sur <strong>www.vquant.ai</strong>,
43 + vous acceptez d'être lié par ces Conditions d'utilisation. Si vous n'acceptez pas ces conditions,
44 + veuillez ne pas utiliser le Service.
45 + </p>
46 +
47 + <h2>2. Description du service</h2>
48 + <p>
49 + VQuant est une plateforme d'analyse financière propulsée par intelligence artificielle qui fournit :
50 + </p>
51 + <ul>
52 + <li>Analyses financières via Claude AI de Anthropic</li>
53 + <li>Accès à 265+ endpoints de données financières et de marché</li>
54 + <li>Outils d'analyse quantitative (Monte Carlo, VaR, optimisation de portefeuille)</li>
55 + <li>Extraction de contenu web et PDFs</li>
56 + <li>Recherche web multi-sources</li>
57 + <li>Exécution de code Python personnalisé pour analyses avancées</li>
58 + </ul>
59 +
60 + <h2>3. Utilisation acceptable</h2>
61 +
62 + <h3>3.1 Vous acceptez de NE PAS :</h3>
63 + <ul>
64 + <li>Utiliser le Service à des fins illégales ou non autorisées</li>
65 + <li>Tenter d'accéder à des systèmes ou données non autorisés</li>
66 + <li>Abuser des APIs ou générer une charge excessive</li>
67 + <li>Revendre ou redistribuer les données obtenues via le Service</li>
68 + <li>Utiliser le Service pour spammer ou harceler</li>
69 + <li>Tenter de contourner les limitations de sécurité</li>
70 + <li>Extraire automatiquement (scraper) le Service</li>
71 + </ul>
72 +
73 + <h3>3.2 Vous acceptez de :</h3>
74 + <ul>
75 + <li>Fournir des informations exactes lors de la création de compte</li>
76 + <li>Maintenir la confidentialité de vos identifiants</li>
77 + <li>Respecter les Conditions d'utilisation des APIs tierces</li>
78 + <li>Utiliser les analyses à des fins d'information uniquement</li>
79 + </ul>
80 +
81 + <h2>4. Comptes utilisateurs</h2>
82 + <ul>
83 + <li>Vous êtes responsable de la sécurité de votre compte</li>
84 + <li>Un compte par personne</li>
85 + <li>Nous nous réservons le droit de suspendre ou supprimer des comptes en cas de violation</li>
86 + <li>Utilisez le token de récupération fourni lors de la création pour récupérer votre compte</li>
87 + </ul>
88 +
89 + <h2>5. Propriété intellectuelle</h2>
90 + <ul>
91 + <li>Le Service et son contenu original sont propriété de VQuant</li>
92 + <li>Les données financières sont fournies par nos partenaires API (FMP, etc.)</li>
93 + <li>Les analyses générées par Claude AI appartiennent à Anthropic</li>
94 + <li>Vos requêtes et analyses vous appartiennent</li>
95 + </ul>
96 +
97 + <h2>6. Disclaimer financier</h2>
98 + <p className="font-semibold text-amber-600 dark:text-amber-500">
99 + ⚠️ IMPORTANT : Ce service fournit des informations à but éducatif uniquement.
100 + </p>
101 + <ul>
102 + <li><strong>PAS de conseil financier</strong> : Les analyses ne constituent pas des recommandations d'investissement</li>
103 + <li><strong>Aucune garantie</strong> : Les données et prédictions peuvent être inexactes</li>
104 + <li><strong>Risques</strong> : L'investissement comporte des risques de perte en capital</li>
105 + <li><strong>Vérification</strong> : Toujours vérifier les informations avec des sources officielles</li>
106 + <li><strong>Conseiller professionnel</strong> : Consultez un conseiller financier agréé avant toute décision d'investissement</li>
107 + </ul>
108 +
109 + <h2>7. Limitations de responsabilité</h2>
110 + <p>
111 + LE SERVICE EST FOURNI "TEL QUEL" SANS GARANTIE D'AUCUNE SORTE.
112 + NOUS NE SOMMES PAS RESPONSABLES DE :
113 + </p>
114 + <ul>
115 + <li>Pertes financières résultant de l'utilisation du Service</li>
116 + <li>Interruptions ou indisponibilités du Service</li>
117 + <li>Inexactitudes dans les données fournies par les APIs tierces</li>
118 + <li>Erreurs dans les analyses générées par l'IA</li>
119 + <li>Bugs ou dysfonctionnements du code Python personnalisé</li>
120 + </ul>
121 +
122 + <h2>8. Utilisation des APIs tierces</h2>
123 + <p>Le Service utilise plusieurs APIs tierces. Vous reconnaissez que :</p>
124 + <ul>
125 + <li>Ces services ont leurs propres conditions d'utilisation</li>
126 + <li>Nous ne sommes pas responsables de leur disponibilité</li>
127 + <li>Les coûts d'API sont supportés par VQuant dans les limites raisonnables</li>
128 + <li>Un usage excessif peut être limité ou facturé</li>
129 + </ul>
130 +
131 + <h2>9. Code Python personnalisé</h2>
132 + <p>
133 + L'outil "execute_custom_python_analysis" permet l'exécution de code Python arbitraire.
134 + En l'utilisant, vous reconnaissez que :
135 + </p>
136 + <ul>
137 + <li>Le code s'exécute dans un environnement semi-contrôlé</li>
138 + <li>Il existe un timeout de 90 secondes</li>
139 + <li>L'accès système est restreint pour sécurité</li>
140 + <li>Vous êtes responsable du code que vous générez</li>
141 + </ul>
142 +
143 + <h2>10. Partage de contenu</h2>
144 + <p>
145 + Lorsque vous partagez une analyse :
146 + </p>
147 + <ul>
148 + <li>Elle devient publiquement accessible via un lien unique</li>
149 + <li>Nous ne sommes pas responsables du contenu partagé</li>
150 + <li>Nous nous réservons le droit de supprimer du contenu inapproprié</li>
151 + <li>Vous accordez une licence pour afficher votre contenu partagé</li>
152 + </ul>
153 +
154 + <h2>11. Modifications du service</h2>
155 + <p>
156 + Nous nous réservons le droit de :
157 + </p>
158 + <ul>
159 + <li>Modifier ou interrompre le Service à tout moment</li>
160 + <li>Changer ces Conditions d'utilisation</li>
161 + <li>Ajouter ou retirer des fonctionnalités</li>
162 + <li>Limiter l'accès à certaines fonctionnalités</li>
163 + </ul>
164 +
165 + <h2>12. Résiliation</h2>
166 + <ul>
167 + <li>Vous pouvez supprimer votre compte à tout moment</li>
168 + <li>Nous pouvons suspendre ou supprimer votre compte en cas de violation</li>
169 + <li>Les données peuvent être conservées pour raisons légales ou de sécurité</li>
170 + </ul>
171 +
172 + <h2>13. Loi applicable</h2>
173 + <p>
174 + Ces conditions sont régies par les lois applicables de votre juridiction.
175 + Tout litige sera soumis aux tribunaux compétents.
176 + </p>
177 +
178 + <h2>14. Contact</h2>
179 + <p>
180 + Pour toute question concernant ces Conditions d'utilisation :
181 + </p>
182 + <p className="font-semibold text-xl">
183 + <a href="mailto:admin@vquant.ai" className="text-primary hover:underline">
184 + admin@vquant.ai
185 + </a>
186 + </p>
187 +
188 + <hr className="my-8" />
189 +
190 + <p className="text-sm text-muted-foreground text-center">
191 + En utilisant VQuant, vous acceptez ces Conditions d'utilisation et notre Privacy Policy.
192 + </p>
193 + </div>
194 + </div>
195 + </div>
196 + );
197 +}
added client/src/test/setup.ts +17 −0
@@ -0,0 +1,17 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/test/setup.ts
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import "@testing-library/jest-dom/vitest";
added client/src/utils/chartCapture.ts +93 −0
@@ -0,0 +1,93 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/utils/chartCapture.ts
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import html2canvas from 'html2canvas';
18 +
19 +/**
20 + * Capture un élément spécifique (graphique, tableau, etc.) en PNG
21 + */
22 +export async function captureChartToPNG(element: HTMLElement): Promise<string | null> {
23 + try {
24 + console.log('Capturing chart:', element.className);
25 +
26 + const canvas = await html2canvas(element, {
27 + scale: 2,
28 + backgroundColor: '#ffffff',
29 + logging: false,
30 + useCORS: true,
31 + allowTaint: true,
32 + imageTimeout: 15000,
33 + });
34 +
35 + const dataUrl = canvas.toDataURL('image/png', 0.95);
36 + console.log('Chart captured successfully, size:', canvas.width, 'x', canvas.height);
37 +
38 + return dataUrl;
39 + } catch (error) {
40 + console.error('Error capturing chart:', error);
41 + return null;
42 + }
43 +}
44 +
45 +/**
46 + * Trouve et capture tous les graphiques dans le contenu
47 + */
48 +export async function captureAllCharts(contentElement: HTMLElement): Promise<{ element: HTMLElement; imageData: string }[]> {
49 + const results: { element: HTMLElement; imageData: string }[] = [];
50 +
51 + // Sélecteurs pour les différents types de graphiques
52 + const chartSelectors = [
53 + '.recharts-wrapper', // Graphiques Recharts
54 + '[data-chart]', // Éléments marqués avec data-chart
55 + '.financial-chart', // Charts financiers custom
56 + 'canvas', // Canvas directs
57 + 'svg.recharts-surface', // SVG Recharts
58 + ];
59 +
60 + for (const selector of chartSelectors) {
61 + const charts = contentElement.querySelectorAll(selector);
62 + console.log(`Found ${charts.length} charts for selector: ${selector}`);
63 +
64 + for (const chart of charts) {
65 + const chartElement = chart as HTMLElement;
66 +
67 + // Skip si déjà capturé (pour éviter les doublons)
68 + if (results.some(r => r.element === chartElement)) {
69 + continue;
70 + }
71 +
72 + // Capturer le parent si c'est juste un SVG ou Canvas
73 + let elementToCapture = chartElement;
74 + if (chart.tagName.toLowerCase() === 'svg' || chart.tagName.toLowerCase() === 'canvas') {
75 + const parent = chartElement.parentElement;
76 + if (parent && parent.classList.contains('recharts-wrapper')) {
77 + elementToCapture = parent;
78 + }
79 + }
80 +
81 + const imageData = await captureChartToPNG(elementToCapture);
82 + if (imageData) {
83 + results.push({
84 + element: elementToCapture,
85 + imageData
86 + });
87 + }
88 + }
89 + }
90 +
91 + console.log(`Total charts captured: ${results.length}`);
92 + return results;
93 +}
added client/src/utils/pdfGenerator.ts +655 −0
@@ -0,0 +1,655 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: client/src/utils/pdfGenerator.ts
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +import jsPDF from 'jspdf';
18 +import html2canvas from 'html2canvas';
19 +
20 +interface PDFOptions {
21 + title: string;
22 + content: string;
23 + includeCharts?: boolean;
24 +}
25 +
26 +interface AdvancedPDFOptions {
27 + title: string;
28 + contentElement: HTMLElement;
29 + includeAllVisuals?: boolean;
30 +}
31 +
32 +// Helper function to draw the VQuant logo
33 +function drawLogo(doc: jsPDF, x: number, y: number, size: number): void {
34 + // Draw gradient circle background
35 + doc.setFillColor(139, 92, 246); // Purple
36 + doc.circle(x, y, size, 'F');
37 +
38 + // Add lighter circle for depth
39 + doc.setFillColor(168, 85, 247);
40 + doc.circle(x - size * 0.15, y - size * 0.15, size * 0.6, 'F');
41 +
42 + // Draw "VQ" text
43 + doc.setTextColor(255, 255, 255);
44 + doc.setFontSize(size * 1.2);
45 + doc.setFont('helvetica', 'bold');
46 + const vqText = 'VQ';
47 + const textWidth = doc.getTextWidth(vqText);
48 + doc.text(vqText, x - textWidth / 2, y + size * 0.25);
49 +
50 + // Draw small chart lines for visual effect
51 + doc.setDrawColor(255, 255, 255);
52 + doc.setLineWidth(0.5);
53 + const chartY = y + size * 0.7;
54 + const chartPoints = [
55 + { x: x - size * 0.6, y: chartY },
56 + { x: x - size * 0.3, y: chartY - size * 0.2 },
57 + { x: x, y: chartY - size * 0.1 },
58 + { x: x + size * 0.3, y: chartY - size * 0.3 },
59 + { x: x + size * 0.6, y: chartY - size * 0.15 }
60 + ];
61 +
62 + for (let i = 0; i < chartPoints.length - 1; i++) {
63 + doc.line(
64 + chartPoints[i].x,
65 + chartPoints[i].y,
66 + chartPoints[i + 1].x,
67 + chartPoints[i + 1].y
68 + );
69 + doc.circle(chartPoints[i].x, chartPoints[i].y, 0.5, 'F');
70 + }
71 + doc.circle(chartPoints[chartPoints.length - 1].x, chartPoints[chartPoints.length - 1].y, 0.5, 'F');
72 +}
73 +
74 +// Helper function to add a professional title page
75 +function addTitlePage(doc: jsPDF, title: string): void {
76 + const pageWidth = doc.internal.pageSize.getWidth();
77 + const pageHeight = doc.internal.pageSize.getHeight();
78 + const margin = 20;
79 + const maxWidth = pageWidth - (margin * 2);
80 +
81 + // Background gradient effect - plus subtil
82 + doc.setFillColor(250, 251, 255); // Ultra light indigo
83 + doc.rect(0, 0, pageWidth, pageHeight, 'F');
84 +
85 + // Top decorative bar avec dégradé simulé
86 + doc.setFillColor(99, 102, 241);
87 + doc.rect(0, 0, pageWidth, 10, 'F');
88 + doc.setFillColor(79, 82, 221, 0.8);
89 + doc.rect(0, 8, pageWidth, 2, 'F');
90 +
91 + // Draw large logo in center - position améliorée
92 + const logoSize = 28;
93 + const logoX = pageWidth / 2;
94 + const logoY = 75;
95 + drawLogo(doc, logoX, logoY, logoSize);
96 +
97 + // Main title avec meilleur espacement
98 + doc.setTextColor(55, 48, 163); // Indigo-700
99 + doc.setFontSize(42);
100 + doc.setFont('helvetica', 'bold');
101 + const mainTitle = 'VQuant';
102 + const titleWidth = doc.getTextWidth(mainTitle);
103 + doc.text(mainTitle, (pageWidth - titleWidth) / 2, 125);
104 +
105 + // Subtitle avec meilleure taille
106 + doc.setFontSize(13);
107 + doc.setFont('helvetica', 'normal');
108 + doc.setTextColor(99, 102, 241);
109 + const subtitle = 'Analyse Financière par Intelligence Artificielle';
110 + const subtitleWidth = doc.getTextWidth(subtitle);
111 + doc.text(subtitle, (pageWidth - subtitleWidth) / 2, 137);
112 +
113 + // Decorative line plus élégante
114 + doc.setDrawColor(139, 92, 246);
115 + doc.setLineWidth(0.8);
116 + const lineWidth = 70;
117 + doc.line((pageWidth - lineWidth) / 2, 148, (pageWidth + lineWidth) / 2, 148);
118 +
119 + // Report title
120 + doc.setFontSize(11);
121 + doc.setFont('helvetica', 'bold');
122 + doc.setTextColor(71, 85, 105);
123 + doc.text('RAPPORT D\'ANALYSE', pageWidth / 2, 165, { align: 'center' });
124 +
125 + // Title box avec shadow effect
126 + doc.setFontSize(12);
127 + doc.setFont('helvetica', 'normal');
128 + doc.setTextColor(15, 23, 42);
129 + const titleLines = doc.splitTextToSize(title, maxWidth - 30);
130 + const boxHeight = titleLines.length * 8 + 16;
131 + const boxY = 175;
132 +
133 + // Shadow
134 + doc.setFillColor(226, 232, 240);
135 + doc.roundedRect((pageWidth - maxWidth + 28) / 2 + 1, boxY + 1, maxWidth - 26, boxHeight, 4, 4, 'F');
136 +
137 + // Box principal
138 + doc.setFillColor(255, 255, 255);
139 + doc.setDrawColor(203, 213, 225);
140 + doc.setLineWidth(0.5);
141 + doc.roundedRect((pageWidth - maxWidth + 28) / 2, boxY, maxWidth - 26, boxHeight, 4, 4, 'FD');
142 +
143 + let textY = boxY + 10;
144 + titleLines.forEach((line: string) => {
145 + doc.text(line, pageWidth / 2, textY, { align: 'center' });
146 + textY += 8;
147 + });
148 +
149 + // Date and metadata avec meilleur style
150 + doc.setFontSize(10);
151 + doc.setFont('helvetica', 'italic');
152 + doc.setTextColor(100, 116, 139);
153 + const dateStr = new Date().toLocaleDateString('fr-FR', {
154 + weekday: 'long',
155 + year: 'numeric',
156 + month: 'long',
157 + day: 'numeric'
158 + });
159 + doc.text(`Généré le ${dateStr}`, pageWidth / 2, pageHeight - 45, { align: 'center' });
160 +
161 + // Footer decorative line
162 + doc.setDrawColor(203, 213, 225);
163 + doc.setLineWidth(0.3);
164 + doc.line(40, pageHeight - 30, pageWidth - 40, pageHeight - 30);
165 +
166 + // Footer decorative elements - plus élégants
167 + doc.setFillColor(139, 92, 246);
168 + doc.circle(35, pageHeight - 20, 1.5, 'F');
169 + doc.circle(pageWidth - 35, pageHeight - 20, 1.5, 'F');
170 +
171 + doc.setFontSize(9);
172 + doc.setFont('helvetica', 'normal');
173 + doc.setTextColor(148, 163, 184);
174 + doc.text('www.vibequant.com', pageWidth / 2, pageHeight - 18, { align: 'center' });
175 +
176 + // Watermark subtil
177 + doc.setFontSize(8);
178 + doc.setTextColor(203, 213, 225);
179 + doc.text('Confidentiel', pageWidth / 2, pageHeight - 10, { align: 'center' });
180 +
181 + // Add new page for content
182 + doc.addPage();
183 +}
184 +
185 +// Helper to capture element as image
186 +async function captureElementAsImage(element: HTMLElement): Promise<string | null> {
187 + try {
188 + console.log('captureElementAsImage: Starting capture');
189 + console.log('Element:', element);
190 + console.log('Element dimensions:', {
191 + width: element.scrollWidth,
192 + height: element.scrollHeight,
193 + offsetWidth: element.offsetWidth,
194 + offsetHeight: element.offsetHeight
195 + });
196 + console.log('Element innerHTML length:', element.innerHTML.length);
197 +
198 + // Clone the element to avoid modifying the original
199 + const clone = element.cloneNode(true) as HTMLElement;
200 +
201 + // Remove any animations or transitions that might interfere
202 + clone.style.animation = 'none';
203 + clone.style.transition = 'none';
204 +
205 + // Temporarily add to DOM for measurement
206 + clone.style.position = 'absolute';
207 + clone.style.left = '-9999px';
208 + clone.style.visibility = 'hidden';
209 + clone.style.pointerEvents = 'none';
210 + clone.style.width = element.scrollWidth + 'px';
211 + document.body.appendChild(clone);
212 +
213 + // Wait for any images or charts to load
214 + await new Promise(resolve => setTimeout(resolve, 500));
215 +
216 + const canvas = await html2canvas(clone, {
217 + scale: 2, // Réduit à 2 pour équilibrer qualité et vitesse
218 + backgroundColor: '#ffffff',
219 + logging: true, // Activé pour déboguer
220 + useCORS: true,
221 + allowTaint: true,
222 + foreignObjectRendering: true,
223 + removeContainer: false,
224 + imageTimeout: 15000,
225 + // Amélioration de la qualité de rendu
226 + width: clone.scrollWidth,
227 + height: clone.scrollHeight,
228 + windowWidth: clone.scrollWidth,
229 + windowHeight: clone.scrollHeight,
230 + onclone: (clonedDoc) => {
231 + const style = clonedDoc.createElement('style');
232 + style.textContent = `
233 + * {
234 + animation: none !important;
235 + transition: none !important;
236 + -webkit-font-smoothing: antialiased;
237 + -moz-osx-font-smoothing: grayscale;
238 + }
239 + /* Améliorer le rendu des polices */
240 + body {
241 + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
242 + }
243 + /* Ensure charts and canvases are visible */
244 + canvas, svg {
245 + display: block !important;
246 + visibility: visible !important;
247 + }
248 + /* Fix potential display issues */
249 + .recharts-wrapper, .recharts-surface {
250 + display: block !important;
251 + }
252 + `;
253 + clonedDoc.head.appendChild(style);
254 +
255 + // Force render all canvas elements
256 + const canvases = clonedDoc.querySelectorAll('canvas');
257 + canvases.forEach((canvas) => {
258 + canvas.style.display = 'block';
259 + canvas.style.visibility = 'visible';
260 + });
261 + }
262 + });
263 +
264 + // Clean up
265 + document.body.removeChild(clone);
266 +
267 + console.log('Canvas created:', {
268 + width: canvas.width,
269 + height: canvas.height
270 + });
271 +
272 + const dataUrl = canvas.toDataURL('image/png', 1.0);
273 + console.log('DataURL created, length:', dataUrl.length);
274 +
275 + return dataUrl;
276 + } catch (error) {
277 + console.error('Error capturing element:', error);
278 + console.error('Error stack:', error);
279 + return null;
280 + }
281 +}
282 +
283 +// New function to generate PDF from full HTML content
284 +export async function generateAdvancedPDF(options: AdvancedPDFOptions): Promise<void> {
285 + const { title, contentElement, includeAllVisuals = true } = options;
286 +
287 + console.log('generateAdvancedPDF: Starting PDF generation');
288 + console.log('Title:', title);
289 + console.log('Content element:', contentElement);
290 + console.log('Include visuals:', includeAllVisuals);
291 +
292 + // Create new PDF document
293 + const pdf = new jsPDF({
294 + orientation: 'portrait',
295 + unit: 'mm',
296 + format: 'a4',
297 + });
298 +
299 + const pageWidth = pdf.internal.pageSize.getWidth();
300 + const pageHeight = pdf.internal.pageSize.getHeight();
301 + const margin = 15;
302 + const contentWidth = pageWidth - (margin * 2);
303 +
304 + console.log('PDF page dimensions:', { pageWidth, pageHeight, margin, contentWidth });
305 +
306 + // Add title page first
307 + console.log('Adding title page...');
308 + addTitlePage(pdf, title);
309 +
310 + let yPosition = margin;
311 +
312 + // Add VQuant header with logo - amélioré
313 + // Dégradé simulé pour l'en-tête
314 + pdf.setFillColor(139, 92, 246);
315 + pdf.rect(0, 0, pageWidth, 40, 'F');
316 + pdf.setFillColor(119, 72, 226, 0.9);
317 + pdf.rect(0, 38, pageWidth, 2, 'F');
318 +
319 + // Draw small logo in header
320 + drawLogo(pdf, 20, 20, 9);
321 +
322 + pdf.setTextColor(255, 255, 255);
323 + pdf.setFontSize(22);
324 + pdf.setFont('helvetica', 'bold');
325 + pdf.text('VQuant', 34, 18);
326 +
327 + pdf.setFontSize(10);
328 + pdf.setFont('helvetica', 'normal');
329 + pdf.setTextColor(240, 240, 255);
330 + pdf.text('Financial Intelligence Platform', 34, 27);
331 +
332 + // Add date in header - style amélioré
333 + pdf.setFontSize(8);
334 + pdf.setFont('helvetica', 'italic');
335 + pdf.setTextColor(240, 240, 255);
336 + const date = new Date().toLocaleDateString('fr-FR', {
337 + year: 'numeric',
338 + month: 'long',
339 + day: 'numeric',
340 + });
341 + pdf.text(date, pageWidth - margin - 40, 22);
342 +
343 + yPosition = 52;
344 +
345 + // Add title avec meilleur style
346 + pdf.setTextColor(15, 23, 42);
347 + pdf.setFontSize(18);
348 + pdf.setFont('helvetica', 'bold');
349 + const titleLines = pdf.splitTextToSize(title, contentWidth);
350 + pdf.text(titleLines, margin, yPosition);
351 + yPosition += (titleLines.length * 8) + 10;
352 +
353 + // Add horizontal line - plus élégante
354 + pdf.setDrawColor(139, 92, 246);
355 + pdf.setLineWidth(0.8);
356 + pdf.line(margin, yPosition, pageWidth - margin, yPosition);
357 + yPosition += 12;
358 +
359 + try {
360 + // Capture the entire content element directly
361 + console.log('Starting PDF capture...');
362 + const imgData = await captureElementAsImage(contentElement);
363 +
364 + if (!imgData) {
365 + console.error('Failed to capture content - imgData is null');
366 + throw new Error('Failed to capture content');
367 + }
368 +
369 + console.log('Content captured successfully');
370 + const img = new Image();
371 + img.src = imgData;
372 + await new Promise((resolve) => { img.onload = resolve; });
373 +
374 + const imgWidth = contentWidth;
375 + const imgHeight = (img.height * imgWidth) / img.width;
376 +
377 + console.log(`Image dimensions: ${imgWidth}x${imgHeight}`);
378 +
379 + // Split into multiple pages if needed
380 + const pageContentHeight = pageHeight - yPosition - 15;
381 + let currentHeight = 0;
382 +
383 + while (currentHeight < imgHeight) {
384 + const heightToAdd = Math.min(imgHeight - currentHeight, pageContentHeight);
385 +
386 + if (currentHeight > 0) {
387 + pdf.addPage();
388 + yPosition = margin;
389 + }
390 +
391 + pdf.addImage(
392 + imgData,
393 + 'PNG',
394 + margin,
395 + yPosition - currentHeight,
396 + imgWidth,
397 + imgHeight,
398 + undefined,
399 + 'FAST'
400 + );
401 +
402 + currentHeight += pageContentHeight;
403 +
404 + if (currentHeight < imgHeight) {
405 + yPosition = margin;
406 + }
407 + }
408 + } catch (error) {
409 + console.error('Error capturing content:', error);
410 +
411 + // Fallback: Add error message with details
412 + pdf.setFontSize(11);
413 + pdf.setTextColor(255, 0, 0);
414 + pdf.text('Error: Could not capture content for PDF', margin, yPosition);
415 + yPosition += 10;
416 +
417 + pdf.setFontSize(9);
418 + pdf.setTextColor(100, 100, 100);
419 + const errorMsg = error instanceof Error ? error.message : 'Unknown error';
420 + const errorLines = pdf.splitTextToSize(`Details: ${errorMsg}`, contentWidth);
421 + pdf.text(errorLines, margin, yPosition);
422 +
423 + throw error; // Re-throw to be caught by caller
424 + }
425 +
426 + // Add footer on each page - amélioré
427 + const pageCount = pdf.internal.pages.length - 1;
428 + for (let i = 1; i <= pageCount; i++) {
429 + pdf.setPage(i);
430 +
431 + // Footer line avec dégradé
432 + pdf.setDrawColor(203, 213, 225);
433 + pdf.setLineWidth(0.5);
434 + pdf.line(margin, pageHeight - 15, pageWidth - margin, pageHeight - 15);
435 +
436 + // Footer dots décoratifs
437 + pdf.setFillColor(139, 92, 246);
438 + pdf.circle(margin + 2, pageHeight - 15, 0.8, 'F');
439 + pdf.circle(pageWidth - margin - 2, pageHeight - 15, 0.8, 'F');
440 +
441 + // Footer text - meilleur style
442 + pdf.setFontSize(7.5);
443 + pdf.setFont('helvetica', 'normal');
444 + pdf.setTextColor(100, 116, 139);
445 + pdf.text('Généré par VQuant • www.vibequant.com', margin, pageHeight - 9);
446 +
447 + pdf.setFont('helvetica', 'bold');
448 + pdf.setTextColor(99, 102, 241);
449 + pdf.text(`Page ${i} / ${pageCount}`, pageWidth - margin - 18, pageHeight - 9);
450 + }
451 +
452 + // Save PDF
453 + const filename = `VQuant_Report_${new Date().toISOString().split('T')[0]}.pdf`;
454 + pdf.save(filename);
455 +}
456 +
457 +export async function generatePDF(options: PDFOptions): Promise<void> {
458 + const { title, content, includeCharts = true } = options;
459 +
460 + // Create new PDF document
461 + const pdf = new jsPDF({
462 + orientation: 'portrait',
463 + unit: 'mm',
464 + format: 'a4',
465 + });
466 +
467 + const pageWidth = pdf.internal.pageSize.getWidth();
468 + const pageHeight = pdf.internal.pageSize.getHeight();
469 + const margin = 20;
470 + const contentWidth = pageWidth - (margin * 2);
471 +
472 + // Add title page first
473 + addTitlePage(pdf, title);
474 +
475 + let yPosition = margin;
476 +
477 + // Add VQuant header with logo
478 + pdf.setFillColor(59, 130, 246); // Blue
479 + pdf.rect(0, 0, pageWidth, 30, 'F');
480 +
481 + // Draw small logo in header
482 + drawLogo(pdf, 25, 15, 7);
483 +
484 + pdf.setTextColor(255, 255, 255);
485 + pdf.setFontSize(24);
486 + pdf.setFont('helvetica', 'bold');
487 + pdf.text('VQuant', 35, 15);
488 +
489 + pdf.setFontSize(12);
490 + pdf.setFont('helvetica', 'normal');
491 + pdf.text('Financial Intelligence Platform', 35, 23);
492 +
493 + yPosition = 40;
494 +
495 + // Add title
496 + pdf.setTextColor(0, 0, 0);
497 + pdf.setFontSize(18);
498 + pdf.setFont('helvetica', 'bold');
499 + const titleLines = pdf.splitTextToSize(title, contentWidth);
500 + pdf.text(titleLines, margin, yPosition);
501 + yPosition += (titleLines.length * 8) + 10;
502 +
503 + // Add date
504 + pdf.setFontSize(10);
505 + pdf.setFont('helvetica', 'normal');
506 + pdf.setTextColor(100, 100, 100);
507 + const date = new Date().toLocaleDateString('en-US', {
508 + year: 'numeric',
509 + month: 'long',
510 + day: 'numeric',
511 + });
512 + pdf.text(`Generated on ${date}`, margin, yPosition);
513 + yPosition += 15;
514 +
515 + // Add horizontal line
516 + pdf.setDrawColor(200, 200, 200);
517 + pdf.line(margin, yPosition, pageWidth - margin, yPosition);
518 + yPosition += 10;
519 +
520 + // Process and add content
521 + pdf.setFontSize(11);
522 + pdf.setTextColor(0, 0, 0);
523 + pdf.setFont('helvetica', 'normal');
524 +
525 + // Split content into sections and paragraphs
526 + const sections = content.split(/\n\n+/);
527 +
528 + for (const section of sections) {
529 + if (!section.trim()) continue;
530 +
531 + // Check if section is a heading (starts with #)
532 + if (section.trim().startsWith('#')) {
533 + const heading = section.trim().replace(/^#+\s*/, '');
534 +
535 + // Check if new page needed
536 + if (yPosition > pageHeight - 30) {
537 + pdf.addPage();
538 + yPosition = margin;
539 + }
540 +
541 + pdf.setFontSize(14);
542 + pdf.setFont('helvetica', 'bold');
543 + pdf.setTextColor(59, 130, 246);
544 + const headingLines = pdf.splitTextToSize(heading, contentWidth);
545 + pdf.text(headingLines, margin, yPosition);
546 + yPosition += (headingLines.length * 7) + 5;
547 +
548 + pdf.setFontSize(11);
549 + pdf.setFont('helvetica', 'normal');
550 + pdf.setTextColor(0, 0, 0);
551 + continue;
552 + }
553 +
554 + // Regular paragraph
555 + const lines = pdf.splitTextToSize(section.trim(), contentWidth);
556 +
557 + for (const line of lines) {
558 + // Check if new page needed
559 + if (yPosition > pageHeight - 30) {
560 + pdf.addPage();
561 + yPosition = margin;
562 + }
563 +
564 + pdf.text(line, margin, yPosition);
565 + yPosition += 6;
566 + }
567 +
568 + yPosition += 4; // Add space between paragraphs
569 + }
570 +
571 + // Capture charts if requested
572 + if (includeCharts) {
573 + try {
574 + // Find all chart containers
575 + const chartElements = document.querySelectorAll('[data-chart]');
576 +
577 + for (let i = 0; i < chartElements.length; i++) {
578 + const element = chartElements[i] as HTMLElement;
579 +
580 + // Add new page for chart
581 + pdf.addPage();
582 + yPosition = margin;
583 +
584 + // Add chart title if available
585 + const chartTitle = element.getAttribute('data-chart-title');
586 + if (chartTitle) {
587 + pdf.setFontSize(14);
588 + pdf.setFont('helvetica', 'bold');
589 + pdf.text(chartTitle, margin, yPosition);
590 + yPosition += 10;
591 + }
592 +
593 + // Capture chart as image
594 + const canvas = await html2canvas(element, {
595 + scale: 2,
596 + backgroundColor: '#ffffff',
597 + logging: false,
598 + });
599 +
600 + const imgData = canvas.toDataURL('image/png');
601 + const imgWidth = contentWidth;
602 + const imgHeight = (canvas.height * imgWidth) / canvas.width;
603 +
604 + // Check if image fits on current page
605 + if (yPosition + imgHeight > pageHeight - margin) {
606 + pdf.addPage();
607 + yPosition = margin;
608 + }
609 +
610 + pdf.addImage(imgData, 'PNG', margin, yPosition, imgWidth, imgHeight);
611 + yPosition += imgHeight + 10;
612 + }
613 + } catch (error) {
614 + console.error('Error capturing charts:', error);
615 + }
616 + }
617 +
618 + // Add footer on each page
619 + const pageCount = pdf.internal.pages.length - 1; // Subtract 1 for the internal page array
620 + for (let i = 1; i <= pageCount; i++) {
621 + pdf.setPage(i);
622 +
623 + // Footer line
624 + pdf.setDrawColor(200, 200, 200);
625 + pdf.line(margin, pageHeight - 15, pageWidth - margin, pageHeight - 15);
626 +
627 + // Footer text
628 + pdf.setFontSize(9);
629 + pdf.setTextColor(100, 100, 100);
630 + pdf.setFont('helvetica', 'normal');
631 + pdf.text('Generated by VQuant', margin, pageHeight - 10);
632 + pdf.text(`Page ${i} of ${pageCount}`, pageWidth - margin - 20, pageHeight - 10);
633 + }
634 +
635 + // Save PDF
636 + const filename = `VQuant_Analysis_${new Date().toISOString().split('T')[0]}.pdf`;
637 + pdf.save(filename);
638 +}
639 +
640 +// Helper function to clean markdown formatting for PDF
641 +export function cleanMarkdownForPDF(markdown: string): string {
642 + return markdown
643 + // Remove code blocks
644 + .replace(/```[\s\S]*?```/g, '')
645 + // Remove inline code
646 + .replace(/`([^`]+)`/g, '$1')
647 + // Remove bold/italic
648 + .replace(/\*\*([^*]+)\*\*/g, '$1')
649 + .replace(/\*([^*]+)\*/g, '$1')
650 + // Remove links but keep text
651 + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
652 + // Clean up extra whitespace
653 + .replace(/\n{3,}/g, '\n\n')
654 + .trim();
655 +}
added desktop/create-icon.sh +95 −0
@@ -0,0 +1,95 @@
1 +#!/bin/bash
2 +# =============================================================================
3 +# VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 +# -----------------------------------------------------------------------------
5 +# File: desktop/create-icon.sh
6 +#
7 +# Author: Simon-Pierre Boucher
8 +# Contact: contact@spboucher.ai
9 +# Website: https://www.spboucher.ai
10 +# Demo: https://www.vquant.ai
11 +# License: MIT (see LICENSE)
12 +#
13 +# Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 +# =============================================================================
15 +
16 +# Generate macOS .icns from SVG logo
17 +# Requires: rsvg-convert (librsvg) and iconutil
18 +
19 +set -e
20 +cd "$(dirname "$0")"
21 +
22 +SVG="../public/logo-vibequant.svg"
23 +ICONSET="icons/VQuant.iconset"
24 +
25 +mkdir -p "$ICONSET"
26 +
27 +# If rsvg-convert available, use SVG; otherwise create a simple PNG
28 +if command -v rsvg-convert &>/dev/null; then
29 + for SIZE in 16 32 64 128 256 512 1024; do
30 + rsvg-convert -w $SIZE -h $SIZE "$SVG" > "$ICONSET/icon_${SIZE}x${SIZE}.png"
31 + done
32 + cp "$ICONSET/icon_32x32.png" "$ICONSET/icon_16x16@2x.png"
33 + cp "$ICONSET/icon_64x64.png" "$ICONSET/icon_32x32@2x.png"
34 + cp "$ICONSET/icon_256x256.png" "$ICONSET/icon_128x128@2x.png"
35 + cp "$ICONSET/icon_512x512.png" "$ICONSET/icon_256x256@2x.png"
36 + cp "$ICONSET/icon_1024x1024.png" "$ICONSET/icon_512x512@2x.png"
37 + rm -f "$ICONSET/icon_64x64.png" "$ICONSET/icon_1024x1024.png"
38 +elif command -v sips &>/dev/null; then
39 + # Use sips (macOS built-in) with a placeholder PNG
40 + echo "rsvg-convert not found. Creating placeholder icon with sips..."
41 + # Create a simple colored square as PNG using Python
42 + python3 -c "
43 +import struct, zlib
44 +
45 +def create_png(size, r, g, b):
46 + raw = b''
47 + for y in range(size):
48 + raw += b'\x00'
49 + for x in range(size):
50 + cx, cy = x - size//2, y - size//2
51 + dist = (cx*cx + cy*cy) ** 0.5
52 + if dist < size * 0.4:
53 + raw += bytes([r, g, b, 255])
54 + elif dist < size * 0.45:
55 + alpha = max(0, min(255, int(255 * (1 - (dist - size*0.4) / (size*0.05)))))
56 + raw += bytes([r, g, b, alpha])
57 + else:
58 + raw += bytes([0, 0, 0, 0])
59 +
60 + def chunk(ctype, data):
61 + c = ctype + data
62 + return struct.pack('>I', len(data)) + c + struct.pack('>I', zlib.crc32(c) & 0xFFFFFFFF)
63 +
64 + header = b'\x89PNG\r\n\x1a\n'
65 + ihdr = chunk(b'IHDR', struct.pack('>IIBBBBB', size, size, 8, 6, 0, 0, 0))
66 + idat = chunk(b'IDAT', zlib.compress(raw))
67 + iend = chunk(b'IEND', b'')
68 + return header + ihdr + idat + iend
69 +
70 +with open('$ICONSET/icon_1024x1024.png', 'wb') as f:
71 + f.write(create_png(1024, 0, 113, 227))
72 +"
73 + for SIZE in 16 32 128 256 512; do
74 + sips -z $SIZE $SIZE "$ICONSET/icon_1024x1024.png" --out "$ICONSET/icon_${SIZE}x${SIZE}.png" > /dev/null 2>&1
75 + done
76 + for SIZE in 16 32 128 256 512; do
77 + DOUBLE=$((SIZE * 2))
78 + sips -z $DOUBLE $DOUBLE "$ICONSET/icon_1024x1024.png" --out "$ICONSET/icon_${SIZE}x${SIZE}@2x.png" > /dev/null 2>&1
79 + done
80 + rm -f "$ICONSET/icon_1024x1024.png"
81 +else
82 + echo "Cannot generate icon. Install librsvg: brew install librsvg"
83 + exit 1
84 +fi
85 +
86 +# Generate .icns
87 +if command -v iconutil &>/dev/null; then
88 + iconutil -c icns "$ICONSET" -o icons/icon.icns
89 + echo "Created icons/icon.icns"
90 +else
91 + echo "iconutil not found (macOS only)"
92 +fi
93 +
94 +rm -rf "$ICONSET"
95 +echo "Done!"
added desktop/icons/VQuant.iconset/icon_128x128.png +0 −0

Binary file not shown.

added desktop/icons/VQuant.iconset/icon_128x128@2x.png +0 −0

Binary file not shown.

added desktop/icons/VQuant.iconset/icon_16x16.png +0 −0

Binary file not shown.

added desktop/icons/VQuant.iconset/icon_16x16@2x.png +0 −0

Binary file not shown.

added desktop/icons/VQuant.iconset/icon_256x256.png +0 −0

Binary file not shown.

added desktop/icons/VQuant.iconset/icon_256x256@2x.png +0 −0

Binary file not shown.

added desktop/icons/VQuant.iconset/icon_32x32.png +0 −0

Binary file not shown.

added desktop/icons/VQuant.iconset/icon_32x32@2x.png +0 −0

Binary file not shown.

added desktop/icons/VQuant.iconset/icon_512x512.png +0 −0

Binary file not shown.

added desktop/icons/VQuant.iconset/icon_512x512@2x.png +0 −0

Binary file not shown.

added desktop/icons/icon.icns +0 −0

Binary file not shown.

added desktop/main.js +652 −0
@@ -0,0 +1,652 @@
1 +/*
2 + * =============================================================================
3 + * VibeQuant (vquant) — AI-Powered Financial Intelligence Platform
4 + * -----------------------------------------------------------------------------
5 + * File: desktop/main.js
6 + *
7 + * Author: Simon-Pierre Boucher
8 + * Contact: contact@spboucher.ai
9 + * Website: https://www.spboucher.ai
10 + * Demo: https://www.vquant.ai
11 + * License: MIT (see LICENSE)
12 + *
13 + * Copyright © 2026 Simon-Pierre Boucher. All rights reserved.
14 + * =============================================================================
15 + */
16 +
17 +const { app, BrowserWindow, Menu, ipcMain, dialog, shell, nativeTheme } = require('electron');
18 +const path = require('path');
19 +const { spawn } = require('child_process');
20 +const fs = require('fs');
21 +const Store = require('electron-store');
22 +
23 +const store = new Store({
24 + name: 'vquant-config',
25 + encryptionKey: 'vquant-desktop-secure-key-2024',
26 + schema: {
27 + apiKeys: {
28 + type: 'object',
29 + properties: {
30 + ANTHROPIC_API_KEY: { type: 'string', default: '' },
31 + FMP_API_KEY: { type: 'string', default: '' },
32 + FIRECRAWL_API_KEY: { type: 'string', default: '' },
33 + TAVILY_API_KEY: { type: 'string', default: '' },
34 + EXA_API_KEY: { type: 'string', default: '' },
35 + SERPAPI_API_KEY: { type: 'string', default: '' },
36 + ELEVENLABS_API_KEY: { type: 'string', default: '' },
37 + },
38 + default: {},
39 + },
40 + setupComplete: { type: 'boolean', default: false },
41 + windowBounds: {
42 + type: 'object',
43 + properties: {
44 + width: { type: 'number', default: 1400 },
45 + height: { type: 'number', default: 900 },
46 + x: { type: 'number' },
47 + y: { type: 'number' },
48 + },
49 + default: { width: 1400, height: 900 },
50 + },
51 + },
52 +});
53 +
54 +let mainWindow = null;
55 +let setupWindow = null;
56 +let serverProcess = null;
57 +let serverReady = false;
58 +const SERVER_PORT = 15173;
59 +
60 +function isDev() {
61 + return !app.isPackaged;
62 +}
63 +
64 +function getProjectRoot() {
65 + if (isDev()) {
66 + return path.join(__dirname, '..');
67 + }
68 + return process.resourcesPath;
69 +}
70 +
71 +function loadEnvFile() {
72 + const envPath = path.join(getProjectRoot(), '.env');
73 + const envKeys = {};
74 + try {
75 + const content = fs.readFileSync(envPath, 'utf-8');
76 + for (const line of content.split('\n')) {
77 + const trimmed = line.trim();
78 + if (!trimmed || trimmed.startsWith('#')) continue;
79 + const eqIdx = trimmed.indexOf('=');
80 + if (eqIdx === -1) continue;
81 + const key = trimmed.slice(0, eqIdx).trim();
82 + const val = trimmed.slice(eqIdx + 1).trim();
83 + if (val && !val.includes('your-') && !val.includes('here')) {
84 + envKeys[key] = val;
85 + }
86 + }
87 + } catch (_) {}
88 + return envKeys;
89 +}
90 +
91 +function getEffectiveApiKeys() {
92 + const storeKeys = store.get('apiKeys', {});
93 + const envKeys = loadEnvFile();
94 + return {
95 + ANTHROPIC_API_KEY: storeKeys.ANTHROPIC_API_KEY || envKeys.ANTHROPIC_API_KEY || '',
96 + FMP_API_KEY: storeKeys.FMP_API_KEY || envKeys.FMP_API_KEY || '',
97 + FIRECRAWL_API_KEY: storeKeys.FIRECRAWL_API_KEY || envKeys.FIRECRAWL_API_KEY || '',
98 + TAVILY_API_KEY: storeKeys.TAVILY_API_KEY || envKeys.TAVILY_API_KEY || '',
99 + EXA_API_KEY: storeKeys.EXA_API_KEY || envKeys.EXA_API_KEY || '',
100 + SERPAPI_API_KEY: storeKeys.SERPAPI_API_KEY || envKeys.SERPAPI_API_KEY || '',
101 + ELEVENLABS_API_KEY: storeKeys.ELEVENLABS_API_KEY || envKeys.ELEVENLABS_API_KEY || '',
102 + };
103 +}
104 +
105 +// ─── Server Management ───────────────────────────────────
106 +
107 +function findNode() {
108 + const candidates = [
109 + path.join(process.env.HOME || '', 'local', 'node-v22.15.0-darwin-arm64', 'bin', 'node'),
110 + '/opt/homebrew/bin/node',
111 + '/usr/local/bin/node',
112 + '/usr/bin/node',
113 + ];
114 +
115 + if (process.execPath && !process.execPath.includes('Electron')) {
116 + return process.execPath;
117 + }
118 +
119 + for (const p of candidates) {
120 + if (fs.existsSync(p)) return p;
121 + }
122 +
123 + return 'node';
124 +}
125 +
126 +function findPython() {
127 + const root = getProjectRoot();
128 + const candidates = [
129 + path.join(root, '.venv', 'bin', 'python'),
130 + '/opt/homebrew/bin/python3',
131 + '/usr/local/bin/python3',
132 + '/usr/bin/python3',
133 + ];
134 + for (const p of candidates) {
135 + if (fs.existsSync(p)) return p;
136 + }
137 + return null;
138 +}
139 +
140 +function initDatabase(dbPath) {
141 + return new Promise((resolve) => {
142 + const sql = `
143 +CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, display_name TEXT NOT NULL, token TEXT NOT NULL UNIQUE, created_at INTEGER NOT NULL);
144 +CREATE TABLE IF NOT EXISTS crawled_pages (id TEXT PRIMARY KEY, url TEXT NOT NULL UNIQUE, title TEXT NOT NULL, content TEXT NOT NULL, snippet TEXT, favicon TEXT, crawled_at INTEGER NOT NULL);
145 +CREATE TABLE IF NOT EXISTS embeddings (id TEXT PRIMARY KEY, page_id TEXT NOT NULL UNIQUE, embedding TEXT, token_count INTEGER NOT NULL, created_at INTEGER NOT NULL, FOREIGN KEY (page_id) REFERENCES crawled_pages(id) ON DELETE CASCADE);
146 +CREATE TABLE IF NOT EXISTS messages (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL, content TEXT NOT NULL, sources TEXT, created_at INTEGER NOT NULL);
147 +CREATE TABLE IF NOT EXISTS shared_reports (id TEXT PRIMARY KEY, share_id TEXT NOT NULL UNIQUE, question TEXT NOT NULL, answer TEXT NOT NULL, tool_results TEXT, sources TEXT, custom_python_figures TEXT, created_at INTEGER NOT NULL);
148 +CREATE TABLE IF NOT EXISTS conversation_sessions (id TEXT PRIMARY KEY, session_id TEXT NOT NULL UNIQUE, user_id TEXT, title TEXT NOT NULL, messages TEXT NOT NULL, input_tokens INTEGER DEFAULT 0, output_tokens INTEGER DEFAULT 0, total_cost REAL DEFAULT 0, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE);
149 +CREATE TABLE IF NOT EXISTS active_users (id TEXT PRIMARY KEY, session_id TEXT NOT NULL UNIQUE, user_id TEXT, status TEXT NOT NULL DEFAULT 'idle', current_query TEXT, last_heartbeat INTEGER NOT NULL, user_agent TEXT, ip_address TEXT, created_at INTEGER NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE);
150 +CREATE TABLE IF NOT EXISTS analytics_metrics (id TEXT PRIMARY KEY, timestamp INTEGER NOT NULL, total_requests INTEGER DEFAULT 0, successful_requests INTEGER DEFAULT 0, failed_requests INTEGER DEFAULT 0, active_users INTEGER DEFAULT 0, unique_visitors INTEGER DEFAULT 0, average_response_time REAL DEFAULT 0, peak_response_time REAL DEFAULT 0, tokens_generated INTEGER DEFAULT 0, estimated_cost REAL DEFAULT 0, tool_calls_count INTEGER DEFAULT 0, python_executions INTEGER DEFAULT 0, search_queries INTEGER DEFAULT 0, error_count INTEGER DEFAULT 0, error_rate REAL DEFAULT 0, period_type TEXT NOT NULL DEFAULT 'minute');
151 +CREATE TABLE IF NOT EXISTS request_logs (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, user_id TEXT, query TEXT NOT NULL, response_time REAL, input_tokens INTEGER DEFAULT 0, output_tokens INTEGER DEFAULT 0, total_cost REAL DEFAULT 0, tools_called TEXT, status TEXT NOT NULL, error_message TEXT, timestamp INTEGER NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE);
152 +CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id);
153 +CREATE INDEX IF NOT EXISTS idx_conversation_sessions_user_id ON conversation_sessions(user_id);
154 +CREATE INDEX IF NOT EXISTS idx_conversation_sessions_session_id ON conversation_sessions(session_id);
155 +CREATE INDEX IF NOT EXISTS idx_active_users_session ON active_users(session_id);
156 +CREATE INDEX IF NOT EXISTS idx_active_users_heartbeat ON active_users(last_heartbeat);
157 +CREATE INDEX IF NOT EXISTS idx_analytics_timestamp ON analytics_metrics(timestamp);
158 +CREATE INDEX IF NOT EXISTS idx_request_logs_timestamp ON request_logs(timestamp);
159 +PRAGMA journal_mode=WAL;
160 +`;
161 + const child = spawn('sqlite3', [dbPath], {
162 + stdio: ['pipe', 'pipe', 'pipe'],
163 + });
164 +
165 + child.stdin.write(sql);
166 + child.stdin.end();
167 +
168 + child.stdout.on('data', (d) => console.log('[DB Init]', d.toString().trim()));
169 + child.stderr.on('data', (d) => console.error('[DB Init Err]', d.toString().trim()));
170 + child.on('close', (code) => {
171 + if (code === 0) {
172 + console.log('[VQuant Desktop] Database initialized at:', dbPath);
173 + } else {
174 + console.error('[VQuant Desktop] Database init failed with code:', code);
175 + }
176 + resolve();
177 + });
178 + child.on('error', (err) => {
179 + console.error('[VQuant Desktop] sqlite3 not found:', err.message);
180 + resolve();
181 + });
182 + });
183 +}
184 +
185 +async function startServer() {
186 + const apiKeys = getEffectiveApiKeys();
187 + const root = getProjectRoot();
188 +
189 + const dbDir = app.getPath('userData');
190 + const dbPath = path.join(dbDir, 'vquant.db');
191 +
192 + await initDatabase(dbPath);
193 +
194 + return new Promise((resolve, reject) => {
195 +
196 + const env = {
197 + ...process.env,
198 + NODE_ENV: 'production',
199 + PORT: String(SERVER_PORT),
200 + DATABASE_URL: `sqlite://${dbPath}`,
201 + SESSION_SECRET: 'vquant-desktop-session-secure-' + Math.random().toString(36).slice(2),
202 + ANTHROPIC_API_KEY: apiKeys.ANTHROPIC_API_KEY || '',
203 + FMP_API_KEY: apiKeys.FMP_API_KEY || '',
204 + FIRECRAWL_API_KEY: apiKeys.FIRECRAWL_API_KEY || '',
205 + TAVILY_API_KEY: apiKeys.TAVILY_API_KEY || '',
206 + EXA_API_KEY: apiKeys.EXA_API_KEY || '',
207 + SERPAPI_API_KEY: apiKeys.SERPAPI_API_KEY || '',
208 + ELEVENLABS_API_KEY: apiKeys.ELEVENLABS_API_KEY || '',
209 + };
210 +
211 + const pythonPath = findPython();
212 + if (pythonPath) {
213 + env.PYTHON_PATH = pythonPath;
214 + }
215 +
216 + let serverEntry;
217 + if (isDev()) {
218 + serverEntry = path.join(root, 'dist', 'index.js');
219 + if (!fs.existsSync(serverEntry)) {
220 + serverEntry = null;
221 + }
222 + } else {
223 + serverEntry = path.join(root, 'app-dist', 'index.js');
224 + }
225 +
226 + if (!serverEntry) {
227 + console.log('[VQuant Desktop] Server bundle not found, using tsx dev mode...');
228 + const tsxBin = path.join(root, 'node_modules', '.bin', 'tsx');
229 + const serverTs = path.join(root, 'server', 'index.ts');
230 +
231 + serverProcess = spawn(tsxBin, ['--env-file=.env', serverTs], {
232 + env: { ...env, NODE_ENV: 'development' },
233 + cwd: root,
234 + stdio: ['pipe', 'pipe', 'pipe'],
235 + });
236 + } else {
237 + const nodeBin = findNode();
238 + console.log('[VQuant Desktop] Starting server:', nodeBin, serverEntry);
239 +
240 + serverProcess = spawn(nodeBin, [serverEntry], {
241 + env,
242 + cwd: root,
243 + stdio: ['pipe', 'pipe', 'pipe'],
244 + });
245 + }
246 +
247 + let resolved = false;
248 +
249 + serverProcess.stdout.on('data', (data) => {
250 + const msg = data.toString();
251 + console.log('[Server]', msg.trim());
252 + if (!resolved && (msg.includes('SERVER READY') || msg.includes('Listening on') || msg.includes(`${SERVER_PORT}`))) {
253 + resolved = true;
254 + serverReady = true;
255 + resolve();
256 + }
257 + });
258 +
259 + serverProcess.stderr.on('data', (data) => {
260 + const msg = data.toString().trim();
261 + if (msg) console.error('[Server Err]', msg);
262 + if (!resolved && (msg.includes('Listening on') || msg.includes(`${SERVER_PORT}`))) {
263 + resolved = true;
264 + serverReady = true;
265 + resolve();
266 + }
267 + });
268 +
269 + serverProcess.on('error', (err) => {
270 + console.error('[Server Process Error]', err);
271 + if (!resolved) {
272 + resolved = true;
273 + reject(err);
274 + }
275 + });
276 +
277 + serverProcess.on('exit', (code) => {
278 + console.log('[Server] Exited with code:', code);
279 + serverReady = false;
280 + serverProcess = null;
281 + if (!resolved) {
282 + resolved = true;
283 + reject(new Error(`Server exited with code ${code}`));
284 + }
285 + });
286 +
287 + setTimeout(() => {
288 + if (!resolved) {
289 + resolved = true;
290 + serverReady = true;
291 + resolve();
292 + }
293 + }, 10000);
294 + });
295 +}
296 +
297 +function stopServer() {
298 + if (serverProcess) {
299 + serverProcess.kill('SIGTERM');
300 + setTimeout(() => {
301 + if (serverProcess) {
302 + serverProcess.kill('SIGKILL');
303 + }
304 + }, 3000);
305 + serverProcess = null;
306 + serverReady = false;
307 + }
308 +}
309 +
310 +// ─── Windows ─────────────────────────────────────────────
311 +
312 +function createSetupWindow() {
313 + setupWindow = new BrowserWindow({
314 + width: 680,
315 + height: 780,
316 + resizable: false,
317 + maximizable: false,
318 + titleBarStyle: 'hiddenInset',
319 + vibrancy: 'sidebar',
320 + backgroundColor: nativeTheme.shouldUseDarkColors ? '#1a1a1a' : '#ffffff',
321 + webPreferences: {
322 + preload: path.join(__dirname, 'preload.js'),
323 + contextIsolation: true,
324 + nodeIntegration: false,
325 + },
326 + });
327 +
328 + setupWindow.loadFile(path.join(__dirname, 'setup.html'));
329 +
330 + setupWindow.on('closed', () => {
331 + setupWindow = null;
332 + if (!mainWindow) {
333 + app.quit();
334 + }
335 + });
336 +}
337 +
338 +function createMainWindow() {
339 + const bounds = store.get('windowBounds', { width: 1400, height: 900 });
340 +
341 + mainWindow = new BrowserWindow({
342 + width: bounds.width,
343 + height: bounds.height,
344 + x: bounds.x,
345 + y: bounds.y,
346 + minWidth: 900,
347 + minHeight: 600,
348 + titleBarStyle: 'hiddenInset',
349 + trafficLightPosition: { x: 15, y: 15 },
350 + vibrancy: 'sidebar',
351 + backgroundColor: nativeTheme.shouldUseDarkColors ? '#0a0a0a' : '#ffffff',
352 + webPreferences: {
353 + preload: path.join(__dirname, 'preload.js'),
354 + contextIsolation: true,
355 + nodeIntegration: false,
356 + },
357 + });
358 +
359 + mainWindow.loadURL(`http://localhost:${SERVER_PORT}`);
360 +
361 + mainWindow.webContents.on('did-fail-load', () => {
362 + setTimeout(() => {
363 + if (mainWindow) {
364 + mainWindow.loadURL(`http://localhost:${SERVER_PORT}`);
365 + }
366 + }, 2000);
367 + });
368 +
369 + mainWindow.on('resize', () => {
370 + if (!mainWindow) return;
371 + const [width, height] = mainWindow.getSize();
372 + const [x, y] = mainWindow.getPosition();
373 + store.set('windowBounds', { width, height, x, y });
374 + });
375 +
376 + mainWindow.on('move', () => {
377 + if (!mainWindow) return;
378 + const [x, y] = mainWindow.getPosition();
379 + const bounds = store.get('windowBounds');
380 + store.set('windowBounds', { ...bounds, x, y });
381 + });
382 +
383 + mainWindow.on('closed', () => {
384 + mainWindow = null;
385 + });
386 +}
387 +
388 +// ─── macOS Menu ──────────────────────────────────────────
389 +
390 +function buildMenu() {
391 + const template = [
392 + {
393 + label: 'VQuant',
394 + submenu: [
395 + { label: 'About VQuant', role: 'about' },
396 + { type: 'separator' },
397 + {
398 + label: 'API Keys Settings...',
399 + accelerator: 'Cmd+,',
400 + click: () => {
401 + if (setupWindow) {
402 + setupWindow.focus();
403 + } else {
404 + createSetupWindow();
405 + }
406 + },
407 + },
408 + { type: 'separator' },
409 + {
410 + label: 'Restart Server',
411 + click: async () => {
412 + stopServer();
413 + try {
414 + await startServer();
415 + if (mainWindow) mainWindow.reload();
416 + } catch (err) {
417 + dialog.showErrorBox('VQuant', `Failed to restart: ${err.message}`);
418 + }
419 + },
420 + },
421 + { type: 'separator' },
422 + { label: 'Hide VQuant', role: 'hide' },
423 + { label: 'Hide Others', role: 'hideOthers' },
424 + { label: 'Show All', role: 'unhide' },
425 + { type: 'separator' },
426 + { label: 'Quit VQuant', role: 'quit' },
427 + ],
428 + },
429 + {
430 + label: 'Edit',
431 + submenu: [
432 + { role: 'undo' },
433 + { role: 'redo' },
434 + { type: 'separator' },
435 + { role: 'cut' },
436 + { role: 'copy' },
437 + { role: 'paste' },
438 + { role: 'pasteAndMatchStyle' },
439 + { role: 'selectAll' },
440 + ],
441 + },
442 + {
443 + label: 'View',
444 + submenu: [
445 + { role: 'reload' },
446 + { role: 'forceReload' },
447 + { type: 'separator' },
448 + { role: 'resetZoom' },
449 + { role: 'zoomIn' },
450 + { role: 'zoomOut' },
451 + { type: 'separator' },
452 + { role: 'togglefullscreen' },
453 + ],
454 + },
455 + {
456 + label: 'Window',
457 + submenu: [
458 + { role: 'minimize' },
459 + { role: 'zoom' },
460 + { type: 'separator' },
461 + {
462 + label: 'New Conversation',
463 + accelerator: 'Cmd+N',
464 + click: () => {
465 + if (mainWindow) {
466 + mainWindow.webContents.executeJavaScript('window.location.href = "/"');
467 + }
468 + },
469 + },
470 + { type: 'separator' },
471 + { role: 'front' },
472 + ],
473 + },
474 + {
475 + label: 'Help',
476 + submenu: [
477 + {
478 + label: 'VQuant Documentation',
479 + click: () => {
480 + if (mainWindow) {
481 + mainWindow.webContents.executeJavaScript('window.location.href = "/docs"');
482 + }
483 + },
484 + },
485 + {
486 + label: 'Visit vquant.ai',
487 + click: () => shell.openExternal('https://www.vquant.ai'),
488 + },
489 + { type: 'separator' },
490 + {
491 + label: 'Toggle Developer Tools',
492 + accelerator: 'Alt+Cmd+I',
493 + click: () => {
494 + const win = BrowserWindow.getFocusedWindow();
495 + if (win) win.webContents.toggleDevTools();
496 + },
497 + },
498 + ],
499 + },
500 + ];
501 +
502 + const menu = Menu.buildFromTemplate(template);
503 + Menu.setApplicationMenu(menu);
504 +}
505 +
506 +// ─── IPC Handlers ────────────────────────────────────────
507 +
508 +ipcMain.handle('get-api-keys', () => {
509 + return store.get('apiKeys', {});
510 +});
511 +
512 +ipcMain.handle('save-api-keys', async (_event, keys) => {
513 + store.set('apiKeys', keys);
514 + store.set('setupComplete', true);
515 +
516 + stopServer();
517 +
518 + if (setupWindow) {
519 + setupWindow.webContents.send('setup-status', 'starting-server');
520 + }
521 +
522 + try {
523 + await startServer();
524 +
525 + if (setupWindow) {
526 + setupWindow.close();
527 + }
528 + if (!mainWindow) {
529 + createMainWindow();
530 + } else {
531 + mainWindow.reload();
532 + }
533 +
534 + return { success: true };
535 + } catch (err) {
536 + return { success: false, error: err.message };
537 + }
538 +});
539 +
540 +ipcMain.handle('get-setup-complete', () => {
541 + return store.get('setupComplete', false);
542 +});
543 +
544 +ipcMain.handle('open-external', (_event, url) => {
545 + shell.openExternal(url);
546 +});
547 +
548 +ipcMain.handle('get-app-version', () => {
549 + return app.getVersion();
550 +});
551 +
552 +ipcMain.handle('get-theme', () => {
553 + return nativeTheme.shouldUseDarkColors ? 'dark' : 'light';
554 +});
555 +
556 +// ─── App Lifecycle ───────────────────────────────────────
557 +
558 +app.whenReady().then(async () => {
559 + buildMenu();
560 +
561 + const apiKeys = getEffectiveApiKeys();
562 + const hasRequiredKeys = apiKeys.ANTHROPIC_API_KEY && apiKeys.FMP_API_KEY;
563 +
564 + if (!hasRequiredKeys) {
565 + createSetupWindow();
566 + } else {
567 + const splash = new BrowserWindow({
568 + width: 400,
569 + height: 300,
570 + frame: false,
571 + transparent: true,
572 + resizable: false,
573 + alwaysOnTop: true,
574 + skipTaskbar: true,
575 + webPreferences: { contextIsolation: true },
576 + });
577 +
578 + splash.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(`
579 + <!DOCTYPE html>
580 + <html>
581 + <body style="
582 + margin: 0; display: flex; flex-direction: column;
583 + align-items: center; justify-content: center; height: 100vh;
584 + font-family: -apple-system, BlinkMacSystemFont, sans-serif;
585 + background: ${nativeTheme.shouldUseDarkColors ? '#1a1a1a' : '#ffffff'};
586 + color: ${nativeTheme.shouldUseDarkColors ? '#f5f5f7' : '#1d1d1f'};
587 + border-radius: 16px; -webkit-app-region: drag;
588 + ">
589 + <svg width="48" height="48" viewBox="0 0 24 24" fill="none"
590 + stroke="${nativeTheme.shouldUseDarkColors ? '#2997ff' : '#0071e3'}"
591 + stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
592 + <path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/>
593 + </svg>
594 + <h1 style="font-size: 24px; font-weight: 700; margin: 16px 0 8px;">VQuant</h1>
595 + <p style="font-size: 13px; color: ${nativeTheme.shouldUseDarkColors ? '#86868b' : '#6e6e73'};">
596 + Demarrage du serveur...
597 + </p>
598 + <div style="
599 + margin-top: 20px; width: 32px; height: 32px;
600 + border: 2px solid ${nativeTheme.shouldUseDarkColors ? '#3a3a3a' : '#e8e8ed'};
601 + border-top-color: ${nativeTheme.shouldUseDarkColors ? '#2997ff' : '#0071e3'};
602 + border-radius: 50%; animation: spin 0.8s linear infinite;
603 + "></div>
604 + <style>@keyframes spin { to { transform: rotate(360deg); } }</style>
605 + </body>
606 + </html>
607 + `)}`);
608 +
609 + try {
610 + await startServer();
611 + splash.close();
612 + createMainWindow();
613 + } catch (err) {
614 + splash.close();
615 + console.error('[VQuant] Failed to start server:', err);
616 + const choice = dialog.showMessageBoxSync({
617 + type: 'error',
618 + title: 'VQuant - Erreur',
619 + message: 'Impossible de demarrer le serveur.',
620 + detail: err.message + '\n\nVoulez-vous reconfigurer vos cles API ?',
621 + buttons: ['Reconfigurer', 'Quitter'],
622 + defaultId: 0,
623 + });
624 + if (choice === 0) {
625 + createSetupWindow();
626 + } else {
627 + app.quit();
628 + }
629 + }
630 + }
631 +
632 + app.on('activate', () => {
633 + if (!mainWindow && !setupWindow) {
634 + if (serverReady) {
635 + createMainWindow();
636 + } else {
637 + createSetupWindow();
638 + }
639 + }
640 + });
641 +});
642 +
643 +app.on('window-all-closed', () => {
644 + if (process.platform !== 'darwin') {
645 + stopServer();
646 + app.quit();
647 + }
648 +});
649 +
650 +app.on('before-quit', () => {
651 + stopServer();
652 +});
added desktop/package-lock.json +612 −0
@@ -0,0 +1,5579 @@
1 +{
2 + "name": "vquant-desktop",
3 + "version": "2.0.0",
4 + "lockfileVersion": 3,
5 + "requires": true,
6 + "packages": {
7 + "": {
8 + "name": "vquant-desktop",
9 + "version": "2.0.0",
10 + "license": "MIT",
11 + "dependencies": {
12 + "electron-store": "^8.2.0"
13 + },
14 + "devDependencies": {
15 + "electron": "^33.4.0",
16 + "electron-builder": "^25.1.8"
17 + }
18 + },
19 + "node_modules/@develar/schema-utils": {
20 + "version": "2.6.5",
21 + "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz",
22 + "integrity": "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==",
23 + "dev": true,
24 + "license": "MIT",
25 + "dependencies": {
26 + "ajv": "^6.12.0",
27 + "ajv-keywords": "^3.4.1"
28 + },
29 + "engines": {
30 + "node": ">= 8.9.0"
31 + },
32 + "funding": {
33 + "type": "opencollective",
34 + "url": "https://opencollective.com/webpack"
35 + }
36 + },
37 + "node_modules/@electron/asar": {
38 + "version": "3.4.1",
39 + "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz",
40 + "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==",
41 + "dev": true,
42 + "license": "MIT",
43 + "dependencies": {
44 + "commander": "^5.0.0",
45 + "glob": "^7.1.6",
46 + "minimatch": "^3.0.4"
47 + },
48 + "bin": {
49 + "asar": "bin/asar.js"
50 + },
51 + "engines": {
52 + "node": ">=10.12.0"
53 + }
54 + },
55 + "node_modules/@electron/asar/node_modules/balanced-match": {
56 + "version": "1.0.2",
57 + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
58 + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
59 + "dev": true,
60 + "license": "MIT"
61 + },
62 + "node_modules/@electron/asar/node_modules/brace-expansion": {
63 + "version": "1.1.15",
64 + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
65 + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
66 + "dev": true,
67 + "license": "MIT",
68 + "dependencies": {
69 + "balanced-match": "^1.0.0",
70 + "concat-map": "0.0.1"
71 + }
72 + },
73 + "node_modules/@electron/asar/node_modules/minimatch": {
74 + "version": "3.1.5",
75 + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
76 + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
77 + "dev": true,
78 + "license": "ISC",
79 + "dependencies": {
80 + "brace-expansion": "^1.1.7"
81 + },
82 + "engines": {
83 + "node": "*"
84 + }
85 + },
86 + "node_modules/@electron/get": {
87 + "version": "2.0.3",
88 + "resolved": "https://registry.npmjs.org/@electron/get/-/get-2.0.3.tgz",
89 + "integrity": "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==",
90 + "dev": true,
91 + "license": "MIT",
92 + "dependencies": {
93 + "debug": "^4.1.1",
94 + "env-paths": "^2.2.0",
95 + "fs-extra": "^8.1.0",
96 + "got": "^11.8.5",
97 + "progress": "^2.0.3",
98 + "semver": "^6.2.0",
99 + "sumchecker": "^3.0.1"
100 + },
101 + "engines": {
102 + "node": ">=12"
103 + },
104 + "optionalDependencies": {
105 + "global-agent": "^3.0.0"
106 + }
107 + },
108 + "node_modules/@electron/notarize": {
109 + "version": "2.5.0",
110 + "resolved": "https://registry.npmjs.org/@electron/notarize/-/notarize-2.5.0.tgz",
111 + "integrity": "sha512-jNT8nwH1f9X5GEITXaQ8IF/KdskvIkOFfB2CvwumsveVidzpSc+mvhhTMdAGSYF3O+Nq49lJ7y+ssODRXu06+A==",
112 + "dev": true,
113 + "license": "MIT",
114 + "dependencies": {
115 + "debug": "^4.1.1",
116 + "fs-extra": "^9.0.1",
117 + "promise-retry": "^2.0.1"
118 + },
119 + "engines": {
120 + "node": ">= 10.0.0"
121 + }
122 + },
123 + "node_modules/@electron/notarize/node_modules/fs-extra": {
124 + "version": "9.1.0",
125 + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
126 + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==",
127 + "dev": true,
128 + "license": "MIT",
129 + "dependencies": {
130 + "at-least-node": "^1.0.0",
131 + "graceful-fs": "^4.2.0",
132 + "jsonfile": "^6.0.1",
133 + "universalify": "^2.0.0"
134 + },
135 + "engines": {
136 + "node": ">=10"
137 + }
138 + },
139 + "node_modules/@electron/notarize/node_modules/jsonfile": {
140 + "version": "6.2.1",
141 + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
142 + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
143 + "dev": true,
144 + "license": "MIT",
145 + "dependencies": {
146 + "universalify": "^2.0.0"
147 + },
148 + "optionalDependencies": {
149 + "graceful-fs": "^4.1.6"
150 + }
151 + },
152 + "node_modules/@electron/notarize/node_modules/universalify": {
153 + "version": "2.0.1",
154 + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
155 + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
156 + "dev": true,
157 + "license": "MIT",
158 + "engines": {
159 + "node": ">= 10.0.0"
160 + }
161 + },
162 + "node_modules/@electron/osx-sign": {
163 + "version": "1.3.1",
164 + "resolved": "https://registry.npmjs.org/@electron/osx-sign/-/osx-sign-1.3.1.tgz",
165 + "integrity": "sha512-BAfviURMHpmb1Yb50YbCxnOY0wfwaLXH5KJ4+80zS0gUkzDX3ec23naTlEqKsN+PwYn+a1cCzM7BJ4Wcd3sGzw==",
166 + "dev": true,
167 + "license": "BSD-2-Clause",
168 + "dependencies": {
169 + "compare-version": "^0.1.2",
170 + "debug": "^4.3.4",
171 + "fs-extra": "^10.0.0",
172 + "isbinaryfile": "^4.0.8",
173 + "minimist": "^1.2.6",
174 + "plist": "^3.0.5"
175 + },
176 + "bin": {
177 + "electron-osx-flat": "bin/electron-osx-flat.js",
178 + "electron-osx-sign": "bin/electron-osx-sign.js"
179 + },
180 + "engines": {
181 + "node": ">=12.0.0"
182 + }
183 + },
184 + "node_modules/@electron/osx-sign/node_modules/fs-extra": {
185 + "version": "10.1.0",
186 + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
187 + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
188 + "dev": true,
189 + "license": "MIT",
190 + "dependencies": {
191 + "graceful-fs": "^4.2.0",
192 + "jsonfile": "^6.0.1",
193 + "universalify": "^2.0.0"
194 + },
195 + "engines": {
196 + "node": ">=12"
197 + }
198 + },
199 + "node_modules/@electron/osx-sign/node_modules/isbinaryfile": {
200 + "version": "4.0.10",
201 + "resolved": "https://registry.npmjs.org/isbinaryfile/-/isbinaryfile-4.0.10.tgz",
202 + "integrity": "sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==",
203 + "dev": true,
204 + "license": "MIT",
205 + "engines": {
206 + "node": ">= 8.0.0"
207 + },
208 + "funding": {
209 + "url": "https://github.com/sponsors/gjtorikian/"
210 + }
211 + },
212 + "node_modules/@electron/osx-sign/node_modules/jsonfile": {
213 + "version": "6.2.1",
214 + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
215 + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
216 + "dev": true,
217 + "license": "MIT",
218 + "dependencies": {
219 + "universalify": "^2.0.0"
220 + },
221 + "optionalDependencies": {
222 + "graceful-fs": "^4.1.6"
223 + }
224 + },
225 + "node_modules/@electron/osx-sign/node_modules/universalify": {
226 + "version": "2.0.1",
227 + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
228 + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
229 + "dev": true,
230 + "license": "MIT",
231 + "engines": {
232 + "node": ">= 10.0.0"
233 + }
234 + },
235 + "node_modules/@electron/rebuild": {
236 + "version": "3.6.1",
237 + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-3.6.1.tgz",
238 + "integrity": "sha512-f6596ZHpEq/YskUd8emYvOUne89ij8mQgjYFA5ru25QwbrRO+t1SImofdDv7kKOuWCmVOuU5tvfkbgGxIl3E/w==",
239 + "dev": true,
240 + "license": "MIT",
241 + "dependencies": {
242 + "@malept/cross-spawn-promise": "^2.0.0",
243 + "chalk": "^4.0.0",
244 + "debug": "^4.1.1",
245 + "detect-libc": "^2.0.1",
246 + "fs-extra": "^10.0.0",
247 + "got": "^11.7.0",
248 + "node-abi": "^3.45.0",
249 + "node-api-version": "^0.2.0",
250 + "node-gyp": "^9.0.0",
251 + "ora": "^5.1.0",
252 + "read-binary-file-arch": "^1.0.6",
253 + "semver": "^7.3.5",
254 + "tar": "^6.0.5",
255 + "yargs": "^17.0.1"
256 + },
257 + "bin": {
258 + "electron-rebuild": "lib/cli.js"
259 + },
260 + "engines": {
261 + "node": ">=12.13.0"
262 + }
263 + },
264 + "node_modules/@electron/rebuild/node_modules/fs-extra": {
265 + "version": "10.1.0",
266 + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
267 + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
268 + "dev": true,
269 + "license": "MIT",
270 + "dependencies": {
271 + "graceful-fs": "^4.2.0",
272 + "jsonfile": "^6.0.1",
273 + "universalify": "^2.0.0"
274 + },
275 + "engines": {
276 + "node": ">=12"
277 + }
278 + },
279 + "node_modules/@electron/rebuild/node_modules/jsonfile": {
280 + "version": "6.2.1",
281 + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
282 + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
283 + "dev": true,
284 + "license": "MIT",
285 + "dependencies": {
286 + "universalify": "^2.0.0"
287 + },
288 + "optionalDependencies": {
289 + "graceful-fs": "^4.1.6"
290 + }
291 + },
292 + "node_modules/@electron/rebuild/node_modules/semver": {
293 + "version": "7.8.2",
294 + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz",
295 + "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==",
296 + "dev": true,
297 + "license": "ISC",
298 + "bin": {
299 + "semver": "bin/semver.js"
300 + },
301 + "engines": {
302 + "node": ">=10"
303 + }
304 + },
305 + "node_modules/@electron/rebuild/node_modules/universalify": {
306 + "version": "2.0.1",
307 + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
308 + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
309 + "dev": true,
310 + "license": "MIT",
311 + "engines": {
312 + "node": ">= 10.0.0"
313 + }
314 + },
315 + "node_modules/@electron/universal": {
316 + "version": "2.0.1",
317 + "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.1.tgz",
318 + "integrity": "sha512-fKpv9kg4SPmt+hY7SVBnIYULE9QJl8L3sCfcBsnqbJwwBwAeTLokJ9TRt9y7bK0JAzIW2y78TVVjvnQEms/yyA==",
319 + "dev": true,
320 + "license": "MIT",
321 + "dependencies": {
322 + "@electron/asar": "^3.2.7",
323 + "@malept/cross-spawn-promise": "^2.0.0",
324 + "debug": "^4.3.1",
325 + "dir-compare": "^4.2.0",
326 + "fs-extra": "^11.1.1",
327 + "minimatch": "^9.0.3",
328 + "plist": "^3.1.0"
329 + },
330 + "engines": {
331 + "node": ">=16.4"
332 + }
333 + },
334 + "node_modules/@electron/universal/node_modules/balanced-match": {
335 + "version": "1.0.2",
336 + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
337 + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
338 + "dev": true,
339 + "license": "MIT"
340 + },
341 + "node_modules/@electron/universal/node_modules/brace-expansion": {
342 + "version": "2.1.1",
343 + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz",
344 + "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==",
345 + "dev": true,
346 + "license": "MIT",
347 + "dependencies": {
348 + "balanced-match": "^1.0.0"
349 + }
350 + },
351 + "node_modules/@electron/universal/node_modules/fs-extra": {
352 + "version": "11.3.5",
353 + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz",
354 + "integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==",
355 + "dev": true,
356 + "license": "MIT",
357 + "dependencies": {
358 + "graceful-fs": "^4.2.0",
359 + "jsonfile": "^6.0.1",
360 + "universalify": "^2.0.0"
361 + },
362 + "engines": {
363 + "node": ">=14.14"
364 + }
365 + },
366 + "node_modules/@electron/universal/node_modules/jsonfile": {
367 + "version": "6.2.1",
368 + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
369 + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
370 + "dev": true,
371 + "license": "MIT",
372 + "dependencies": {
373 + "universalify": "^2.0.0"
374 + },
375 + "optionalDependencies": {
376 + "graceful-fs": "^4.1.6"
377 + }
378 + },
379 + "node_modules/@electron/universal/node_modules/minimatch": {
380 + "version": "9.0.9",
381 + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
382 + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
383 + "dev": true,
384 + "license": "ISC",
385 + "dependencies": {
386 + "brace-expansion": "^2.0.2"
387 + },
388 + "engines": {
389 + "node": ">=16 || 14 >=14.17"
390 + },
391 + "funding": {
392 + "url": "https://github.com/sponsors/isaacs"
393 + }
394 + },
395 + "node_modules/@electron/universal/node_modules/universalify": {
396 + "version": "2.0.1",
397 + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
398 + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
399 + "dev": true,
400 + "license": "MIT",
401 + "engines": {
402 + "node": ">= 10.0.0"
403 + }
404 + },
405 + "node_modules/@gar/promisify": {
406 + "version": "1.1.3",
407 + "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz",
408 + "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==",
409 + "dev": true,
410 + "license": "MIT"
411 + },
412 + "node_modules/@isaacs/cliui": {
413 + "version": "8.0.2",
414 + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
415 + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
416 + "dev": true,
417 + "license": "ISC",
418 + "dependencies": {
419 + "string-width": "^5.1.2",
420 + "string-width-cjs": "npm:string-width@^4.2.0",
421 + "strip-ansi": "^7.0.1",
422 + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
423 + "wrap-ansi": "^8.1.0",
424 + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
425 + },
426 + "engines": {
427 + "node": ">=12"
428 + }
429 + },
430 + "node_modules/@isaacs/cliui/node_modules/ansi-regex": {
431 + "version": "6.2.2",
432 + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
433 + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
434 + "dev": true,
435 + "license": "MIT",
436 + "engines": {
437 + "node": ">=12"
438 + },
439 + "funding": {
440 + "url": "https://github.com/chalk/ansi-regex?sponsor=1"
441 + }
442 + },
443 + "node_modules/@isaacs/cliui/node_modules/ansi-styles": {
444 + "version": "6.2.3",
445 + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
446 + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
447 + "dev": true,
448 + "license": "MIT",
449 + "engines": {
450 + "node": ">=12"
451 + },
452 + "funding": {
453 + "url": "https://github.com/chalk/ansi-styles?sponsor=1"
454 + }
455 + },
456 + "node_modules/@isaacs/cliui/node_modules/emoji-regex": {
457 + "version": "9.2.2",
458 + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
459 + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
460 + "dev": true,
461 + "license": "MIT"
462 + },
463 + "node_modules/@isaacs/cliui/node_modules/string-width": {
464 + "version": "5.1.2",
465 + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
466 + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
467 + "dev": true,
468 + "license": "MIT",
469 + "dependencies": {
470 + "eastasianwidth": "^0.2.0",
471 + "emoji-regex": "^9.2.2",
472 + "strip-ansi": "^7.0.1"
473 + },
474 + "engines": {
475 + "node": ">=12"
476 + },
477 + "funding": {
478 + "url": "https://github.com/sponsors/sindresorhus"
479 + }
480 + },
481 + "node_modules/@isaacs/cliui/node_modules/strip-ansi": {
482 + "version": "7.2.0",
483 + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
484 + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
485 + "dev": true,
486 + "license": "MIT",
487 + "dependencies": {
488 + "ansi-regex": "^6.2.2"
489 + },
490 + "engines": {
491 + "node": ">=12"
492 + },
493 + "funding": {
494 + "url": "https://github.com/chalk/strip-ansi?sponsor=1"
495 + }
496 + },
497 + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": {
498 + "version": "8.1.0",
499 + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
500 + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
501 + "dev": true,
502 + "license": "MIT",
503 + "dependencies": {
504 + "ansi-styles": "^6.1.0",
505 + "string-width": "^5.0.1",
506 + "strip-ansi": "^7.0.1"
507 + },
508 + "engines": {
509 + "node": ">=12"
510 + },
511 + "funding": {
512 + "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
513 + }
514 + },
515 + "node_modules/@malept/cross-spawn-promise": {
516 + "version": "2.0.0",
517 + "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz",
518 + "integrity": "sha512-1DpKU0Z5ThltBwjNySMC14g0CkbyhCaz9FkhxqNsZI6uAPJXFS8cMXlBKo26FJ8ZuW6S9GCMcR9IO5k2X5/9Fg==",
519 + "dev": true,
520 + "funding": [
521 + {
522 + "type": "individual",
523 + "url": "https://github.com/sponsors/malept"
524 + },
525 + {
526 + "type": "tidelift",
527 + "url": "https://tidelift.com/subscription/pkg/npm-.malept-cross-spawn-promise?utm_medium=referral&utm_source=npm_fund"
528 + }
529 + ],
530 + "license": "Apache-2.0",
531 + "dependencies": {
532 + "cross-spawn": "^7.0.1"
533 + },
534 + "engines": {
535 + "node": ">= 12.13.0"
536 + }
537 + },
538 + "node_modules/@malept/flatpak-bundler": {
539 + "version": "0.4.0",
540 + "resolved": "https://registry.npmjs.org/@malept/flatpak-bundler/-/flatpak-bundler-0.4.0.tgz",
541 + "integrity": "sha512-9QOtNffcOF/c1seMCDnjckb3R9WHcG34tky+FHpNKKCW0wc/scYLwMtO+ptyGUfMW0/b/n4qRiALlaFHc9Oj7Q==",
542 + "dev": true,
543 + "license": "MIT",
544 + "dependencies": {
545 + "debug": "^4.1.1",
546 + "fs-extra": "^9.0.0",
547 + "lodash": "^4.17.15",
548 + "tmp-promise": "^3.0.2"
549 + },
550 + "engines": {
551 + "node": ">= 10.0.0"
552 + }
553 + },
554 + "node_modules/@malept/flatpak-bundler/node_modules/fs-extra": {
555 + "version": "9.1.0",
556 + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz",
557 + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==",
558 + "dev": true,
559 + "license": "MIT",
560 + "dependencies": {
561 + "at-least-node": "^1.0.0",
562 + "graceful-fs": "^4.2.0",
563 + "jsonfile": "^6.0.1",
564 + "universalify": "^2.0.0"
565 + },
566 + "engines": {
567 + "node": ">=10"
568 + }
569 + },
570 + "node_modules/@malept/flatpak-bundler/node_modules/jsonfile": {
571 + "version": "6.2.1",
572 + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
573 + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
574 + "dev": true,
575 + "license": "MIT",
576 + "dependencies": {
577 + "universalify": "^2.0.0"
578 + },
579 + "optionalDependencies": {
580 + "graceful-fs": "^4.1.6"
581 + }
582 + },
583 + "node_modules/@malept/flatpak-bundler/node_modules/universalify": {
584 + "version": "2.0.1",
585 + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
586 + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
587 + "dev": true,
588 + "license": "MIT",
589 + "engines": {
590 + "node": ">= 10.0.0"
591 + }
592 + },
593 + "node_modules/@npmcli/fs": {
594 + "version": "2.1.2",
595 + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-2.1.2.tgz",
596 + "integrity": "sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ==",
597 + "dev": true,
598 + "license": "ISC",
599 + "dependencies": {
600 + "@gar/promisify": "^1.1.3",
601 + "semver": "^7.3.5"
602 + },
603 + "engines": {
604 + "node": "^12.13.0 || ^14.15.0 || >=16.0.0"
605 + }
606 + },
607 + "node_modules/@npmcli/fs/node_modules/semver": {
608 + "version": "7.8.2",
609 + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.2.tgz",
610 + "integrity": "sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==",
611 + "dev": true,
612 + "license": "ISC",

Diff truncated — file too large.