From c95eb0ecb66dafc3eff19b7ca09927bd4a5914d7 Mon Sep 17 00:00:00 2001 From: Michael SCHAL Date: Fri, 27 Feb 2026 10:02:23 +0100 Subject: [PATCH] feat: Add new settings page with Apple-style UI, OpenRouter AI integration, and Mary v4 analysis. --- src/app/(dashboard)/settings/page.tsx | 36 +++-- src/app/api/ai/analyze/route.ts | 9 ++ src/app/globals.css | 123 ++++++++++++++++++ src/components/shared/confirm-modal.tsx | 3 +- .../ai-copilot/data/open-router-client.ts | 55 +++++--- .../grid/components/bulk-ai-analyzer.tsx | 30 +++-- task.md | 13 +- 7 files changed, 221 insertions(+), 48 deletions(-) diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index 1677677..2bcd123 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -214,7 +214,7 @@ export default function SettingsPage() {
-
@@ -263,7 +263,7 @@ export default function SettingsPage() { @@ -406,16 +406,28 @@ export default function SettingsPage() { - {/* Save */} -
+ {/* Save Button — Floating/Sticky style at bottom */} +
diff --git a/src/app/api/ai/analyze/route.ts b/src/app/api/ai/analyze/route.ts index d2f1507..cb1557d 100644 --- a/src/app/api/ai/analyze/route.ts +++ b/src/app/api/ai/analyze/route.ts @@ -6,6 +6,10 @@ import { db } from "@/db"; import { aiSupplierContext } from "@/db/schema"; import { eq } from "drizzle-orm"; +// Limite max acceptable pour la route (Node.js self-hosted). +// Evite que le process tourne indéfiniment en cas de deadlock. +export const maxDuration = 55; + export async function POST(req: NextRequest) { const config = await getSavedDatabaseConfig(); const apiKey = process.env.OPENROUTER_API_KEY || config?.openRouterKey; @@ -42,6 +46,11 @@ export async function POST(req: NextRequest) { if (err instanceof Error && err.message === "rate_limited") { return NextResponse.json({ error: "rate_limited", retryAfter: 30 }, { status: 429 }); } + // Timeout explicite déclenché par l'AbortController du client + if (err instanceof Error && err.message === "timeout") { + console.warn(`[AI] Timeout for product analysis — OpenRouter too slow.`); + return NextResponse.json({ error: "timeout", retryAfter: 5 }, { status: 504 }); + } const msg = err instanceof Error ? err.message : "Unknown error"; return NextResponse.json({ error: msg }, { status: 500 }); } diff --git a/src/app/globals.css b/src/app/globals.css index a265178..fe8d23b 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -274,6 +274,129 @@ } /* ── Apple-style Action Buttons ── */ +/* ── Apple-style Action Buttons (Glossy & Premium) ── */ +.apple-btn-primary, +.apple-btn-secondary { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; + height: 40px; + padding: 0 20px; + border-radius: 12px; + font-size: 13px; + font-weight: 600; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + cursor: pointer; + position: relative; + overflow: hidden; +} + +.apple-btn-primary { + background: linear-gradient(180deg, var(--brand-solid) 0%, var(--brand-hover) 100%); + color: #ffffff; + border: 1px solid rgba(255, 255, 255, 0.1); + box-shadow: + 0 4px 12px rgba(16, 185, 129, 0.25), + inset 0 1px 1px rgba(255, 255, 255, 0.2); + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); +} + +.apple-btn-primary:hover { + filter: brightness(1.1); + transform: translateY(-1px); + box-shadow: + 0 6px 16px rgba(16, 185, 129, 0.35), + inset 0 1px 1px rgba(255, 255, 255, 0.3); +} + +.apple-btn-primary:active { + transform: scale(0.97) translateY(0); + filter: brightness(0.95); +} + +.apple-btn-secondary { + background: var(--bg-surface); + color: var(--text-primary); + border: 1px solid var(--border-strong); + box-shadow: + 0 2px 4px rgba(0, 0, 0, 0.1), + inset 0 1px 0 rgba(255, 255, 255, 0.05); +} + +.apple-btn-secondary:hover { + background: var(--bg-elevated); + border-color: var(--text-muted); + transform: translateY(-1px); +} + +.apple-btn-secondary:active { + transform: scale(0.97) translateY(0); +} + +.apple-btn-danger { + background: linear-gradient(180deg, var(--accent-error) 0%, #be123c 100%); + color: #ffffff; + border: 1px solid rgba(255, 255, 255, 0.1); + box-shadow: + 0 4px 12px rgba(244, 63, 94, 0.25), + inset 0 1px 1px rgba(255, 255, 255, 0.2); +} + +.apple-btn-danger:hover { + filter: brightness(1.1); + transform: translateY(-1px); + box-shadow: + 0 6px 16px rgba(244, 63, 94, 0.35); +} + +.apple-btn-ai { + background: linear-gradient(135deg, #6366f1 0%, #a855f7 50%, #ec4899 100%); + color: #ffffff; + border: 1px solid rgba(255, 255, 255, 0.2); + box-shadow: + 0 4px 15px rgba(168, 85, 247, 0.3), + inset 0 1px 1px rgba(255, 255, 255, 0.3); +} + +.apple-btn-ai:hover { + filter: brightness(1.1); + transform: translateY(-1px) scale(1.02); + box-shadow: 0 6px 20px rgba(168, 85, 247, 0.4); +} + +/* Glass effect overlay for loading/success */ +.btn-glass-overlay { + position: absolute; + inset: 0; + background: rgba(255, 255, 255, 0.1); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; +} + +@keyframes apple-pulse { + 0% { + transform: scale(1); + box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4); + } + + 70% { + transform: scale(1.02); + box-shadow: 0 0 0 10px rgba(16, 185, 129, 0); + } + + 100% { + transform: scale(1); + box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); + } +} + +.animate-apple-save { + animation: apple-pulse 2s infinite ease-in-out; +} + .btn-action { display: inline-flex; align-items: center; diff --git a/src/components/shared/confirm-modal.tsx b/src/components/shared/confirm-modal.tsx index c5d54b0..7fbafd8 100644 --- a/src/components/shared/confirm-modal.tsx +++ b/src/components/shared/confirm-modal.tsx @@ -115,8 +115,7 @@ export function ConfirmModal({ diff --git a/src/features/ai-copilot/data/open-router-client.ts b/src/features/ai-copilot/data/open-router-client.ts index c1919ae..1db6e2d 100644 --- a/src/features/ai-copilot/data/open-router-client.ts +++ b/src/features/ai-copilot/data/open-router-client.ts @@ -12,25 +12,42 @@ export class OpenRouterClient { constructor(private config: OpenRouterConfig) { } async analyzeProduct(p: ProductAnalysisInput): Promise { - const response = await fetch(OPENROUTER_URL, { - method: "POST", - headers: { - Authorization: `Bearer ${this.config.apiKey}`, - "Content-Type": "application/json", - "HTTP-Referer": "https://collectflow.app", - "X-Title": "CollectFlow AI Copilot", - }, - body: JSON.stringify({ - model: this.config.model, - messages: [ - { role: "system", content: AnalysisEngine.generateSystemPrompt() }, - { role: "user", content: AnalysisEngine.generateUserMessage(p) }, - ], - response_format: { type: "json_object" }, - max_tokens: 200, - temperature: 0.1, - }), - }); + // Timeout de 25 secondes : évite le blocage indéfini si OpenRouter est lent. + // On préfère un échec propre avec message d'erreur plutôt qu'un timeout navigateur. + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 25_000); + + let response: Response; + try { + response = await fetch(OPENROUTER_URL, { + method: "POST", + signal: controller.signal, + headers: { + Authorization: `Bearer ${this.config.apiKey}`, + "Content-Type": "application/json", + "HTTP-Referer": "https://collectflow.app", + "X-Title": "CollectFlow AI Copilot", + }, + body: JSON.stringify({ + model: this.config.model, + messages: [ + { role: "system", content: AnalysisEngine.generateSystemPrompt() }, + { role: "user", content: AnalysisEngine.generateUserMessage(p) }, + ], + response_format: { type: "json_object" }, + max_tokens: 200, + temperature: 0.1, + }), + }); + } catch (err: unknown) { + if (err instanceof Error && err.name === "AbortError") { + throw new Error("timeout"); // Géré proprement plus haut + } + throw err; + } finally { + clearTimeout(timeoutId); + } + if (response.status === 429) { throw new Error("rate_limited"); diff --git a/src/features/grid/components/bulk-ai-analyzer.tsx b/src/features/grid/components/bulk-ai-analyzer.tsx index 8bb702d..c14dffe 100644 --- a/src/features/grid/components/bulk-ai-analyzer.tsx +++ b/src/features/grid/components/bulk-ai-analyzer.tsx @@ -192,7 +192,8 @@ export function BulkAiAnalyzer() { try { let res: Response | null = null; - for (let i = 0; i < 3; i++) { + // Jusqu'à 3 tentatives par produit + for (let attempt = 0; attempt < 3; attempt++) { if (isCancelledRef.current) break; try { res = await fetch("/api/ai/analyze", { @@ -200,17 +201,30 @@ export function BulkAiAnalyzer() { headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); - if (res.ok) break; + if (res.ok) break; // Succès → sortie de boucle + if (res.status === 429) { - await new Promise((resolve) => setTimeout(resolve, 2000 * (i + 1))); + // Rate-limit : on attend avant de réessayer + const retryData = await res.json().catch(() => ({})); + const wait = (retryData.retryAfter ?? 30) * 1000; + console.warn(`[BulkAI] Rate limited, retry in ${wait / 1000}s`); + await new Promise((r) => setTimeout(r, Math.min(wait, 15_000))); + } else if (res.status === 504) { + // Timeout serveur : retry rapide (le modèle était peut-être surchargé) + console.warn(`[BulkAI] Timeout (504) for ${payload.codein}, retry #${attempt + 1}`); + await new Promise((r) => setTimeout(r, 2_000)); + } else { + // Autre erreur (500, etc.) : ne pas réessayer inutilement + break; } - } catch (e) { - console.error(`Retry ${i} failed for ${payload.codein}`, e); + } catch (fetchErr) { + console.error(`[BulkAI] Fetch error (attempt ${attempt + 1}) for ${payload.codein}`, fetchErr); + await new Promise((r) => setTimeout(r, 1_000)); // Petite pause avant retry réseau } } if (!res || !res.ok) { - setError(payload.codein, "Erreur API"); + setError(payload.codein, res?.status === 504 ? "Timeout IA" : "Erreur API"); errorsCount++; } else { const data = await res.json(); @@ -220,7 +234,7 @@ export function BulkAiAnalyzer() { } } } catch (err) { - console.error(`Error processing ${payload.codein}`, err); + console.error(`[BulkAI] Error processing ${payload.codein}`, err); setError(payload.codein, "Erreur"); errorsCount++; } finally { @@ -284,7 +298,7 @@ export function BulkAiAnalyzer() { ) : (