commit 4d7e5a01d071c46442e5c5348eeaa6cc1effedd5 Author: streaper2 Date: Wed Nov 11 21:18:25 2026 +0100 feat: Banque des Coquillages v1.0 avec Docker et configuration NPM diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3fbed7c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +node_modules +*/node_modules +*/*/node_modules +.git +.gemini +back/data/*.sqlite* +back/uploads/* +front/dist +dist +*.log diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..61dd60f --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# Dépendances Node +node_modules/ +back/node_modules/ +front/node_modules/ +skill/lambda/node_modules/ + +# Builds et artefacts +front/dist/ +dist/ + +# Données locales persistées via Docker volumes +data/ +uploads/ +back/data/*.sqlite* +!back/data/.gitkeep +back/uploads/* +!back/uploads/.gitkeep + +# Fichiers d'environnement +.env +.env.local +.env.*.local + +# Logs +*.log +npm-debug.log* +yarn-debug.log* + +# Fichiers système & IDE +.DS_Store +Thumbs.db +.gemini/ +.idea/ +.vscode/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..fda5ec8 --- /dev/null +++ b/README.md @@ -0,0 +1,124 @@ +# 🐚 La Banque des Coquillages — Hailey, Violette & Andreas + +Application familiale **Physique + Digitale** (Site Web + API + Skill Alexa Echo Show) pour gérer les coquillages de **Hailey**, **Violette** et **Andreas**, leurs activités communes du mois, leur tirelire en euros (max 5 € / mois = 10 coquillages) et leur livret d'épargne bloquée avec intérêts mensuels. + +--- + +## 📁 Structure du Projet + +```text +arrweek/ +├── back/ # API REST Node.js 22 + Express + SQLite (node:sqlite) + Webhook Alexa +│ └── Dockerfile # Image Docker backend de production (Node 22 Alpine) +├── front/ # Application Web React + Vite + Tailwind CSS +│ ├── Dockerfile # Image Docker frontend multi-stage (Node 22 + Nginx Alpine) +│ └── nginx.conf # Configuration Nginx avec reverse proxy (/api/ & /uploads/) +├── skill/ # Skill Alexa officiel +│ ├── interactionModels/ # Modèle d'interaction vocal (fr-FR.json) +│ └── lambda/ # Handler ASK SDK avec écrans APL 1.8 tactiles pour Echo Show +├── docker-compose.yml # Déploiement Docker complet (backend + frontend + volumes) +└── package.json # Monorepo npm workspaces +``` + +--- + +## 🐳 Déploiement Docker & Nginx Proxy Manager (NPM) + +### 1. Démarrer avec Docker Compose +Sur votre serveur Docker : +```bash +# Cloner le dépôt depuis votre git perso +git clone https://git.home.arrondeau.fr//.git +cd + +# Créer les dossiers de persistance +mkdir -p data uploads + +# Lancer la stack en arrière-plan +docker compose up -d --build +``` + +Services démarrés : +- **`coquillages-frontend`** : port `5180` (sert l'application React et relaie automatiquement `/api/` et `/uploads/` vers le backend). +- **`coquillages-backend`** : port `4050` (API Node.js + base SQLite persistée dans `./data` et images dans `./uploads`). + +--- + +### 🌐 2. Configuration dans Nginx Proxy Manager (NPM) + +Dans l'interface web de votre **Nginx Proxy Manager** : + +#### Option A (Recommandée — Domaine unique tout-en-un) : +Créez un nouveau **Proxy Host** : +1. **Domain Names** : `coquillages.home.arrondeau.fr` (ou votre sous-domaine choisi) +2. **Scheme** : `http` +3. **Forward Hostname / IP** : `IP_DE_VOTRE_SERVEUR_DOCKER` (ou le nom du conteneur si NPM est sur le même réseau Docker : `coquillages-frontend`) +4. **Forward Port** : `5180` (ou `80` en interne) +5. **Options recommandées** : + - [x] **Block Common Exploits** + - [x] **Websockets Support** +6. **Onglet SSL** : + - Sélectionnez ou générez un certificat SSL Let's Encrypt + - [x] **Force SSL** + - [x] **HTTP/2 Support** + - [x] **HSTS Enabled** + +> 🎉 **Résultat :** +> - **Application Web** : `https://coquillages.home.arrondeau.fr` +> - **API REST** : `https://coquillages.home.arrondeau.fr/api/...` +> - **Endpoint Alexa Webhook permanent** : `https://coquillages.home.arrondeau.fr/api/alexa/webhook` *(plus besoin de tunnel SSH ou localhost.run !)* + +--- + +## 🎙️ 3. Configuration de l'Endpoint dans la Console Alexa Developer + +Une fois exposé via NPM avec votre certificat HTTPS : +1. Allez sur la [Console Alexa Developer](https://developer.amazon.com/alexa/console/ask). +2. Ouvrez votre Skill **La Banque des Coquillages**. +3. Dans **Endpoint** : + - Sélectionnez **HTTPS**. + - **Default Region** : `https://coquillages.home.arrondeau.fr/api/alexa/webhook` + - Sélectionnez l'option de certificat SSL : *« My development endpoint has a Certificate from a trusted certificate authority »* (Let's Encrypt). + - Cliquez sur **Save Endpoints**. +4. Dans **Interaction Model > JSON Editor** : + - Collez le contenu de [`skill/interactionModels/custom/fr-FR.json`](./skill/interactionModels/custom/fr-FR.json). + - Cliquez sur **Save Model** puis **Build Model**. + +--- + +## 📦 4. Initialisation & Push sur votre Git perso (`git.home.arrondeau.fr`) + +Pour initialiser ce dépôt local et le pousser sur votre instance Git personnelle : + +```bash +# 1. Initialiser le dépôt git localement +git init + +# 2. Ajouter tous les fichiers (le .gitignore protège les node_modules et sqlite) +git add . + +# 3. Premier commit +git commit -m "feat: Banque des Coquillages v1.0 avec Docker et support NPM" + +# 4. Définir la branche principale +git branch -M main + +# 5. Lier votre serveur Git perso +git remote add origin https://git.home.arrondeau.fr//.git + +# 6. Pousser le code +git push -u origin main +``` + +--- + +## 💻 Développement Local (sans Docker) + +```bash +# Lancer Backend + Frontend simultanément +npm run start:all + +# Ou séparément : +npm run dev:back # Backend sur http://localhost:4050 +npm run dev:front # Frontend Vite sur http://localhost:5180 +``` diff --git a/back/Dockerfile b/back/Dockerfile new file mode 100644 index 0000000..5768d1c --- /dev/null +++ b/back/Dockerfile @@ -0,0 +1,27 @@ +FROM node:22-alpine + +WORKDIR /app + +# Copie des définitions de packages du monorepo +COPY package.json package-lock.json ./ +COPY back/package.json ./back/ +COPY skill/lambda/package.json ./skill/lambda/ + +# Installation des dépendances de production +RUN npm install --omit=dev --workspace=back --workspace=skill/lambda + +# Copie des sources du backend et du skill Alexa (utilisé par le webhook Alexa) +COPY back ./back +COPY skill ./skill + +# Création des dossiers de persistance +RUN mkdir -p /app/back/data /app/back/uploads + +WORKDIR /app/back + +ENV NODE_ENV=production +ENV PORT=4050 + +EXPOSE 4050 + +CMD ["node", "src/server.js"] diff --git a/back/data/.gitkeep b/back/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/back/package.json b/back/package.json new file mode 100644 index 0000000..b5be731 --- /dev/null +++ b/back/package.json @@ -0,0 +1,15 @@ +{ + "name": "arrweek-back", + "version": "1.0.0", + "description": "Backend API & Moteur Bancaire pour la Banque des Coquillages (Hailey, Violette & Andreas)", + "type": "module", + "scripts": { + "start": "node src/server.js", + "dev": "node --watch src/server.js" + }, + "dependencies": { + "cors": "^2.8.5", + "express": "^4.21.2" + } +} + diff --git a/back/src/db.js b/back/src/db.js new file mode 100644 index 0000000..6d573a2 --- /dev/null +++ b/back/src/db.js @@ -0,0 +1,270 @@ +import { DatabaseSync } from 'node:sqlite'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const dataDir = path.join(__dirname, '..', 'data'); +if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }); +} + +export const uploadsDir = path.join(__dirname, '..', 'uploads'); +if (!fs.existsSync(uploadsDir)) { + fs.mkdirSync(uploadsDir, { recursive: true }); +} + +const dbPath = path.join(dataDir, 'coquillages.sqlite'); +export const db = new DatabaseSync(dbPath); + +export const DEFAULT_ACTIVITY_IMAGES = { + pizza: 'https://images.unsplash.com/photo-1513104890138-7c749659a591?auto=format&fit=crop&w=1000&q=80', + bowling: 'https://images.unsplash.com/photo-1538511059256-06459b067f41?auto=format&fit=crop&w=1000&q=80', + aquatique: 'https://images.unsplash.com/photo-1582653291997-079a1c04e5a1?auto=format&fit=crop&w=1000&q=80', + accrobranche: 'https://images.unsplash.com/photo-1542273917363-3b1817f69a2d?auto=format&fit=crop&w=1000&q=80', + default: 'https://images.unsplash.com/photo-1507525428034-b723cf961d3e?auto=format&fit=crop&w=1000&q=80' +}; + +export function normalizeName(str = '') { + return str + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .trim(); +} + +export function initDb() { + db.exec(` + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS children ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + slug TEXT NOT NULL UNIQUE, + avatar TEXT NOT NULL, + color TEXT NOT NULL, + available_shells INTEGER NOT NULL DEFAULT 0, + locked_shells INTEGER NOT NULL DEFAULT 0, + euros_balance REAL NOT NULL DEFAULT 0, + shells_converted_month INTEGER NOT NULL DEFAULT 0 + ); + + CREATE TABLE IF NOT EXISTS activities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + emoji TEXT NOT NULL DEFAULT '🎡', + image_url TEXT NOT NULL DEFAULT '', + cost_shells INTEGER NOT NULL, + current_shells INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'ACTIVE', + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS activity_contributions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + activity_id INTEGER NOT NULL, + child_id INTEGER NOT NULL, + shells INTEGER NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (activity_id) REFERENCES activities(id), + FOREIGN KEY (child_id) REFERENCES children(id) + ); + + CREATE TABLE IF NOT EXISTS pending_requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + child_id INTEGER NOT NULL, + type TEXT NOT NULL, + shells INTEGER NOT NULL, + activity_id INTEGER, + source TEXT NOT NULL DEFAULT 'ALEXA', + status TEXT NOT NULL DEFAULT 'PENDING', + note TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + resolved_at TEXT, + FOREIGN KEY (child_id) REFERENCES children(id), + FOREIGN KEY (activity_id) REFERENCES activities(id) + ); + + CREATE TABLE IF NOT EXISTS transactions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + child_id INTEGER, + type TEXT NOT NULL, + shells_delta INTEGER NOT NULL DEFAULT 0, + euros_delta REAL NOT NULL DEFAULT 0, + description TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY (child_id) REFERENCES children(id) + ); + `); + + // Migration douce : ajouter image_url si la table activities existait déjà sans cette colonne + const columns = db.prepare("PRAGMA table_info('activities')").all(); + const hasImageUrl = columns.some((c) => c.name === 'image_url'); + if (!hasImageUrl) { + db.exec("ALTER TABLE activities ADD COLUMN image_url TEXT NOT NULL DEFAULT ''"); + } + + // Migration douce : ajouter alexa_person_id sur la table children pour la reconnaissance vocale Alexa (Voice ID) + const childColumns = db.prepare("PRAGMA table_info('children')").all(); + const hasPersonId = childColumns.some((c) => c.name === 'alexa_person_id'); + if (!hasPersonId) { + db.exec("ALTER TABLE children ADD COLUMN alexa_person_id TEXT DEFAULT NULL"); + } + + // Migration douce : ajouter proposed_title sur la table pending_requests pour les propositions d'activités des enfants + const prColumns = db.prepare("PRAGMA table_info('pending_requests')").all(); + const hasProposedTitle = prColumns.some((c) => c.name === 'proposed_title'); + if (!hasProposedTitle) { + db.exec("ALTER TABLE pending_requests ADD COLUMN proposed_title TEXT DEFAULT NULL"); + } + + // Remplir les images par défaut pour les activités existantes qui n'en ont pas encore + const existingActivities = db.prepare('SELECT id, title, image_url FROM activities').all(); + const updateImg = db.prepare('UPDATE activities SET image_url = ? WHERE id = ?'); + for (const act of existingActivities) { + if (!act.image_url) { + const norm = normalizeName(act.title); + let img = DEFAULT_ACTIVITY_IMAGES.default; + if (norm.includes('pizza') || norm.includes('cinema')) img = DEFAULT_ACTIVITY_IMAGES.pizza; + else if (norm.includes('bowling')) img = DEFAULT_ACTIVITY_IMAGES.bowling; + else if (norm.includes('aquatique') || norm.includes('piscine')) img = DEFAULT_ACTIVITY_IMAGES.aquatique; + else if (norm.includes('accrobranche')) img = DEFAULT_ACTIVITY_IMAGES.accrobranche; + updateImg.run(img, act.id); + } + } + + // Seed default settings if empty + const existingRate = db.prepare('SELECT value FROM settings WHERE key = ?').get('interest_rate_percent'); + if (!existingRate) { + const insertSetting = db.prepare('INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)'); + insertSetting.run('interest_rate_percent', '20'); + insertSetting.run('shells_per_euro', '2'); + insertSetting.run('max_euros_per_month', '5'); + insertSetting.run('max_shells_euro_month', '10'); + insertSetting.run('last_interest_run', 'Jamais'); + } + + // Seed children: Hailey, Violette, Andreas + const childCount = db.prepare('SELECT COUNT(*) as count FROM children').get().count; + if (childCount === 0) { + const insertChild = db.prepare(` + INSERT INTO children (name, slug, avatar, color, available_shells, locked_shells, euros_balance, shells_converted_month) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `); + insertChild.run('Hailey', 'hailey', '🧜‍♀️', 'pink', 12, 10, 1.5, 3); + insertChild.run('Violette', 'violette', '🐬', 'purple', 8, 15, 2.0, 4); + insertChild.run('Andreas', 'andreas', '🦈', 'cyan', 14, 10, 1.0, 2); + } + + // Seed activities if empty + const activityCount = db.prepare('SELECT COUNT(*) as count FROM activities').get().count; + if (activityCount === 0) { + const now = new Date().toISOString(); + const insertActivity = db.prepare(` + INSERT INTO activities (title, description, emoji, image_url, cost_shells, current_shells, status, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `); + + insertActivity.run( + 'Soirée Pizza & Cinéma', + 'Chacun choisit sa pizza et on regarde un grand film en famille avec du pop-corn !', + '🍕', + DEFAULT_ACTIVITY_IMAGES.pizza, + 25, + 12, + 'ACTIVE', + now + ); + insertActivity.run( + 'Bowling en famille', + 'Sortie au bowling avec 2 parties et boissons pour tout le monde.', + '🎳', + DEFAULT_ACTIVITY_IMAGES.bowling, + 45, + 15, + 'ACTIVE', + now + ); + insertActivity.run( + 'Parc Aquatique', + 'Une après-midi complète dans les toboggans géants et la piscine à vagues.', + '🌊', + DEFAULT_ACTIVITY_IMAGES.aquatique, + 70, + 0, + 'ACTIVE', + now + ); + insertActivity.run( + 'Sortie Accrobranche', + 'Parcours dans les arbres et tyroliennes pour Hailey, Violette et Andreas.', + '🧗', + DEFAULT_ACTIVITY_IMAGES.accrobranche, + 60, + 0, + 'ACTIVE', + now + ); + + const insertContribution = db.prepare(` + INSERT INTO activity_contributions (activity_id, child_id, shells, created_at) + VALUES (?, ?, ?, ?) + `); + insertContribution.run(1, 1, 4, now); + insertContribution.run(1, 2, 4, now); + insertContribution.run(1, 3, 4, now); + + insertContribution.run(2, 1, 5, now); + insertContribution.run(2, 2, 5, now); + insertContribution.run(2, 3, 5, now); + + const insertRequest = db.prepare(` + INSERT INTO pending_requests (child_id, type, shells, activity_id, source, status, note, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `); + insertRequest.run( + 1, + 'DEPOSIT_LOCKED', + 6, + null, + 'ALEXA', + 'PENDING', + 'Hailey a demandé sur Alexa de déposer et bloquer 6 coquillages physiques pour gagner des intérêts.', + now + ); + + const insertTx = db.prepare(` + INSERT INTO transactions (child_id, type, shells_delta, euros_delta, description, created_at) + VALUES (?, ?, ?, ?, ?, ?) + `); + insertTx.run(1, 'INIT', 22, 1.5, 'Ouverture du compte coquillages de Hailey', now); + insertTx.run(2, 'INIT', 23, 2.0, 'Ouverture du compte coquillages de Violette', now); + insertTx.run(3, 'INIT', 24, 1.0, 'Ouverture du compte coquillages d\'Andreas', now); + } +} + +export function getSettings() { + const rows = db.prepare('SELECT key, value FROM settings').all(); + const map = {}; + for (const r of rows) { + map[r.key] = r.value; + } + return { + interest_rate_percent: Number(map.interest_rate_percent ?? 20), + shells_per_euro: Number(map.shells_per_euro ?? 2), + max_euros_per_month: Number(map.max_euros_per_month ?? 5), + max_shells_euro_month: Number(map.max_shells_euro_month ?? 10), + last_interest_run: map.last_interest_run ?? 'Jamais' + }; +} + +export function calculateProjectedInterest(lockedShells, ratePercent) { + if (lockedShells <= 0 || ratePercent <= 0) return 0; + return Math.ceil((lockedShells * ratePercent) / 100); +} diff --git a/back/src/server.js b/back/src/server.js new file mode 100644 index 0000000..6949378 --- /dev/null +++ b/back/src/server.js @@ -0,0 +1,1203 @@ +import express from 'express'; +import cors from 'cors'; +import fs from 'node:fs'; +import path from 'node:path'; +import { + db, + initDb, + getSettings, + calculateProjectedInterest, + normalizeName, + uploadsDir, + DEFAULT_ACTIVITY_IMAGES +} from './db.js'; +import { skill as alexaSkill } from '../../skill/lambda/index.js'; + +initDb(); + +const app = express(); +app.set('trust proxy', 1); +app.use(cors()); +app.use(express.json({ limit: '10mb' })); +app.use('/uploads', express.static(uploadsDir)); + +// Upload d'une image depuis le PC/téléphone pour illustrer une activité Alexa +app.post('/api/upload-image', (req, res) => { + const { dataUrl, filename = 'activite.jpg' } = req.body || {}; + if (!dataUrl || typeof dataUrl !== 'string' || !dataUrl.startsWith('data:image/')) { + return res.status(400).json({ error: 'Format d\'image invalide.' }); + } + + const matches = dataUrl.match(/^data:image\/([a-zA-Z0-9+]+);base64,(.+)$/); + if (!matches) { + return res.status(400).json({ error: 'Impossible de décoder l\'image.' }); + } + + const ext = matches[1] === 'jpeg' ? 'jpg' : matches[1]; + const base64Data = matches[2]; + const safeName = normalizeName(filename.replace(/\.[^/.]+$/, '')).replace(/[^a-z0-9]/g, '-') || 'activite'; + const finalFileName = `${safeName}-${Date.now()}.${ext}`; + const filePath = path.join(uploadsDir, finalFileName); + + fs.writeFileSync(filePath, Buffer.from(base64Data, 'base64')); + + const protocol = req.headers['x-forwarded-proto'] || req.protocol || 'http'; + const host = req.get('host') || 'localhost:4050'; + const imageUrl = `${protocol}://${host}/uploads/${finalFileName}`; + + res.status(201).json({ image_url: imageUrl }); +}); + +function getEnrichedChildren() { + const settings = getSettings(); + const children = db.prepare('SELECT * FROM children ORDER BY id ASC').all(); + return children.map((c) => { + const nextInterest = calculateProjectedInterest(c.locked_shells, settings.interest_rate_percent); + const remainingShellsToConvert = Math.max(0, settings.max_shells_euro_month - c.shells_converted_month); + return { + ...c, + projected_interest_shells: nextInterest, + total_shells: c.available_shells + c.locked_shells, + remaining_shells_to_convert: remainingShellsToConvert, + remaining_euros_this_month: +(remainingShellsToConvert / settings.shells_per_euro).toFixed(2) + }; + }); +} + +function findChildByName(rawName) { + if (!rawName) return null; + const norm = normalizeName(rawName); + const children = getEnrichedChildren(); + + const aliases = { + hailey: ['hailey', 'ayley', 'ailey', 'elie', 'hailie', 'hely', 'haley'], + violette: ['violette', 'violet', 'violete'], + andreas: ['andreas', 'andrea', 'handreas'] + }; + + for (const child of children) { + if (child.slug === norm || normalizeName(child.name) === norm) { + return child; + } + const childAliases = aliases[child.slug] || []; + if (childAliases.some((a) => norm.includes(a))) { + return child; + } + } + return null; +} + +function getEnrichedActivities() { + const activities = db.prepare('SELECT * FROM activities ORDER BY status ASC, cost_shells ASC').all(); + const contributions = db + .prepare(` + SELECT ac.activity_id, ac.child_id, c.name as child_name, c.avatar, c.color, SUM(ac.shells) as total_shells + FROM activity_contributions ac + JOIN children c ON c.id = ac.child_id + GROUP BY ac.activity_id, ac.child_id + `) + .all(); + + const pendingActivityReqs = db + .prepare(` + SELECT activity_id, child_id + FROM pending_requests + WHERE status = 'PENDING' AND activity_id IS NOT NULL + `) + .all(); + + return activities.map((act) => { + const actContribs = contributions.filter((c) => c.activity_id === act.id); + const lockedChildIds = [ + ...new Set(pendingActivityReqs.filter((r) => r.activity_id === act.id).map((r) => r.child_id)) + ]; + const remaining = Math.max(0, act.cost_shells - act.current_shells); + const progressPercent = Math.min(100, Math.round((act.current_shells / act.cost_shells) * 100)); + return { + ...act, + image_url: act.image_url || DEFAULT_ACTIVITY_IMAGES.default, + remaining_shells: remaining, + progress_percent: progressPercent, + contributions: actContribs, + locked_child_ids: lockedChildIds + }; + }); +} + +function findActivityByName(rawTitle) { + if (!rawTitle) return null; + const norm = normalizeName(rawTitle); + const activities = getEnrichedActivities().filter((a) => a.status !== 'COMPLETED'); + + // Support du choix par numéro ("activité 1", "la première", "numéro 2") + const numMatch = norm.match(/\b(\d+)\b/); + if (numMatch) { + const idx = Number(numMatch[1]) - 1; + if (activities[idx]) return activities[idx]; + } + if (norm.includes('premier') || norm.includes('premiere')) return activities[0] || null; + if (norm.includes('deuxieme') || norm.includes('second')) return activities[1] || null; + if (norm.includes('troisieme')) return activities[2] || null; + + return ( + activities.find((a) => String(a.id) === norm) || + activities.find((a) => normalizeName(a.title).includes(norm) || norm.includes(normalizeName(a.title))) || + activities.find((a) => { + const words = norm.split(/\s+/).filter((w) => w.length > 2); + return words.some((w) => normalizeName(a.title).includes(w)); + }) || + null + ); +} + +function getPendingRequests() { + return db + .prepare(` + SELECT pr.*, c.name as child_name, c.avatar as child_avatar, c.color as child_color, + a.title as activity_title, a.emoji as activity_emoji, a.image_url as activity_image_url + FROM pending_requests pr + JOIN children c ON c.id = pr.child_id + LEFT JOIN activities a ON a.id = pr.activity_id + WHERE pr.status = 'PENDING' + ORDER BY pr.id DESC + `) + .all(); +} + +function formatActivityCard(act) { + return { + id: act.id, + title: `${act.emoji} ${act.title}`, + subtitle: `${act.current_shells} / ${act.cost_shells} coquillages (${act.progress_percent}%)`, + text: + act.remaining_shells === 0 + ? `🎉 Activité débloquée ! ${act.description}` + : `Coût : ${act.cost_shells} 🐚 • Il manque encore ${act.remaining_shells} 🐚.\n${act.description}`, + imageUrl: act.image_url || DEFAULT_ACTIVITY_IMAGES.default, + progressPercent: act.progress_percent, + currentShells: act.current_shells, + costShells: act.cost_shells, + remainingShells: act.remaining_shells + }; +} + +// 1. GET /api/dashboard +app.get('/api/dashboard', (req, res) => { + const settings = getSettings(); + const children = getEnrichedChildren(); + const activities = getEnrichedActivities(); + const pendingRequests = getPendingRequests(); + const transactions = db + .prepare(` + SELECT t.*, c.name as child_name, c.avatar as child_avatar + FROM transactions t + LEFT JOIN children c ON c.id = t.child_id + ORDER BY t.id DESC + LIMIT 25 + `) + .all(); + + const totalFamilyAvailable = children.reduce((acc, c) => acc + c.available_shells, 0); + const totalFamilyLocked = children.reduce((acc, c) => acc + c.locked_shells, 0); + + res.json({ + settings, + summary: { + total_available_shells: totalFamilyAvailable, + total_locked_shells: totalFamilyLocked, + total_projected_interest: children.reduce((acc, c) => acc + c.projected_interest_shells, 0) + }, + children, + activities, + pending_requests: pendingRequests, + transactions + }); +}); + +// 2. GET /api/children & GET /api/children/:name +app.get('/api/children', (req, res) => { + res.json({ children: getEnrichedChildren(), settings: getSettings() }); +}); + +app.get('/api/children/:name', (req, res) => { + const child = findChildByName(req.params.name); + if (!child) { + return res.status(404).json({ error: `Enfant "${req.params.name}" introuvable.` }); + } + res.json({ child, settings: getSettings() }); +}); + +// 3. GET /api/activities +app.get('/api/activities', (req, res) => { + const budget = req.query.budget ? Number(req.query.budget) : null; + let activities = getEnrichedActivities(); + if (budget !== null && !Number.isNaN(budget)) { + activities = activities.map((a) => ({ + ...a, + affordable_with_budget: a.remaining_shells <= budget + })); + } + res.json({ activities }); +}); + +// 4. POST /api/activities (Create activity with image_url) +app.post('/api/activities', (req, res) => { + const { title, description = '', emoji = '🎡', image_url = '', cost_shells } = req.body; + const cost = Number(cost_shells); + if (!title || !cost || cost <= 0) { + return res.status(400).json({ error: 'Titre et coût en coquillages (> 0) requis.' }); + } + const finalImage = image_url.trim() || DEFAULT_ACTIVITY_IMAGES.default; + const now = new Date().toISOString(); + const result = db + .prepare(` + INSERT INTO activities (title, description, emoji, image_url, cost_shells, current_shells, status, created_at) + VALUES (?, ?, ?, ?, ?, 0, 'ACTIVE', ?) + `) + .run(title.trim(), description.trim(), emoji, finalImage, cost, now); + + res.status(201).json({ id: result.lastInsertRowid, message: 'Activité illustrée créée avec succès.' }); +}); + +// 5. PATCH /api/activities/:id (Update activity including image_url) +app.patch('/api/activities/:id', (req, res) => { + const id = Number(req.params.id); + const existing = db.prepare('SELECT * FROM activities WHERE id = ?').get(id); + if (!existing) return res.status(404).json({ error: 'Activité introuvable.' }); + + const title = req.body.title ?? existing.title; + const description = req.body.description ?? existing.description; + const emoji = req.body.emoji ?? existing.emoji; + const image_url = req.body.image_url !== undefined ? req.body.image_url : existing.image_url; + const cost_shells = req.body.cost_shells !== undefined ? Number(req.body.cost_shells) : existing.cost_shells; + const status = req.body.status ?? existing.status; + + db.prepare(` + UPDATE activities + SET title = ?, description = ?, emoji = ?, image_url = ?, cost_shells = ?, status = ? + WHERE id = ? + `).run(title, description, emoji, image_url, cost_shells, status, id); + + res.json({ message: 'Activité mise à jour.' }); +}); + +// 6. DELETE /api/activities/:id +app.delete('/api/activities/:id', (req, res) => { + const id = Number(req.params.id); + db.prepare('DELETE FROM activity_contributions WHERE activity_id = ?').run(id); + db.prepare('DELETE FROM pending_requests WHERE activity_id = ?').run(id); + db.prepare('DELETE FROM activities WHERE id = ?').run(id); + res.json({ message: 'Activité supprimée.' }); +}); + +// 7. POST /api/requests +app.post('/api/requests', (req, res) => { + const { + child_name, + child_id, + type, + shells, + activity_name, + activity_id, + source = 'ALEXA' + } = req.body; + + const child = child_id + ? getEnrichedChildren().find((c) => c.id === Number(child_id)) + : findChildByName(child_name); + + if (!child) { + return res.status(404).json({ + error: `Je n'ai pas reconnu le prénom. Les enfants inscrits sont Hailey, Violette et Andreas.` + }); + } + + const count = Number(shells); + if (!count || count <= 0) { + return res.status(400).json({ error: 'Le nombre de coquillages doit être supérieur à 0.' }); + } + + const settings = getSettings(); + let targetActivity = null; + + if (type === 'CONTRIBUTE_PHYSICAL' || type === 'CONTRIBUTE_VIRTUAL') { + targetActivity = activity_id + ? getEnrichedActivities().find((a) => a.id === Number(activity_id)) + : findActivityByName(activity_name); + + if (!targetActivity) { + return res.status(404).json({ + error: `Je n'ai pas trouvé l'activité "${activity_name || ''}".` + }); + } + } + + if (type === 'LOCK_VIRTUAL' || type === 'CONTRIBUTE_VIRTUAL' || type === 'CONVERT_EUROS_VIRTUAL') { + if (child.available_shells < count) { + return res.status(400).json({ + error: `${child.name} n'a que ${child.available_shells} coquillages disponibles sur son compte virtuel.` + }); + } + } + + if (type === 'CONVERT_EUROS_PHYSICAL' || type === 'CONVERT_EUROS_VIRTUAL') { + if (count > child.remaining_shells_to_convert) { + return res.status(400).json({ + error: `Limite mensuelle atteinte ! ${child.name} ne peut convertir que ${child.remaining_shells_to_convert} coquillages supplémentaires ce mois-ci (maximum ${settings.max_shells_euro_month} coquillages soit ${settings.max_euros_per_month} euros par mois).` + }); + } + } + + const now = new Date().toISOString(); + let note = ''; + const projectedBonus = calculateProjectedInterest(child.locked_shells + count, settings.interest_rate_percent); + const eurosAmount = +(count / settings.shells_per_euro).toFixed(2); + + switch (type) { + case 'DEPOSIT_LOCKED': + note = `${child.name} veut remettre ${count} coquillages physiques et les BLOQUER sur son livret (+${settings.interest_rate_percent}%/mois).`; + break; + case 'DEPOSIT_AVAILABLE': + note = `${child.name} veut déposer ${count} coquillages physiques sur son compte disponible.`; + break; + case 'LOCK_VIRTUAL': + note = `${child.name} veut bloquer ${count} coquillages de son compte virtuel pour générer des intérêts.`; + break; + case 'CONTRIBUTE_PHYSICAL': + note = `${child.name} veut donner ${count} coquillages physiques pour l'activité "${targetActivity.title}".`; + break; + case 'CONTRIBUTE_VIRTUAL': + note = `${child.name} veut mettre ${count} coquillages de son compte virtuel sur l'activité "${targetActivity.title}".`; + break; + case 'CONVERT_EUROS_PHYSICAL': + note = `${child.name} veut échanger ${count} coquillages physiques contre ${eurosAmount} € dans sa tirelire.`; + break; + case 'CONVERT_EUROS_VIRTUAL': + note = `${child.name} veut convertir ${count} coquillages virtuels contre ${eurosAmount} € dans sa tirelire.`; + break; + default: + return res.status(400).json({ error: `Type d'opération inconnu: ${type}` }); + } + + const result = db + .prepare(` + INSERT INTO pending_requests (child_id, type, shells, activity_id, source, status, note, created_at) + VALUES (?, ?, ?, ?, ?, 'PENDING', ?, ?) + `) + .run(child.id, type, count, targetActivity ? targetActivity.id : null, source, note, now); + + res.status(201).json({ + request_id: result.lastInsertRowid, + child, + activity: targetActivity, + shells: count, + euros_amount: eurosAmount, + projected_bonus: projectedBonus, + interest_rate_percent: settings.interest_rate_percent, + note, + message: note + }); +}); + +// 8. POST /api/requests/:id/approve (Les parents saisissent/confirment le nombre exact de coquillages physiques reçus) +app.post('/api/requests/:id/approve', (req, res) => { + const id = Number(req.params.id); + const request = db.prepare('SELECT * FROM pending_requests WHERE id = ?').get(id); + if (!request || request.status !== 'PENDING') { + return res.status(404).json({ error: 'Demande introuvable ou déjà traitée.' }); + } + + const child = getEnrichedChildren().find((c) => c.id === request.child_id); + const settings = getSettings(); + const now = new Date().toISOString(); + const count = req.body?.shells !== undefined ? Number(req.body.shells) : Number(request.shells); + + if (!count || count <= 0) { + return res.status(400).json({ error: 'Veuillez indiquer un nombre de coquillages supérieur à 0.' }); + } + + db.prepare('UPDATE pending_requests SET shells = ? WHERE id = ?').run(count, id); + + switch (request.type) { + case 'DEPOSIT_AVAILABLE': { + db.prepare('UPDATE children SET available_shells = available_shells + ? WHERE id = ?').run(count, child.id); + db.prepare(` + INSERT INTO transactions (child_id, type, shells_delta, euros_delta, description, created_at) + VALUES (?, 'DEPOSIT_AVAILABLE', ?, 0, ?, ?) + `).run(child.id, count, `Dépôt physique validé : +${count} coquillages disponibles pour ${child.name}`, now); + break; + } + + case 'DEPOSIT_LOCKED': { + db.prepare('UPDATE children SET locked_shells = locked_shells + ? WHERE id = ?').run(count, child.id); + db.prepare(` + INSERT INTO transactions (child_id, type, shells_delta, euros_delta, description, created_at) + VALUES (?, 'DEPOSIT_LOCKED', ?, 0, ?, ?) + `).run(child.id, count, `Placement physique bloqué validé : +${count} coquillages bloqués pour ${child.name}`, now); + break; + } + + case 'LOCK_VIRTUAL': { + if (child.available_shells < count) { + return res.status(400).json({ error: `Solde disponible insuffisant pour ${child.name}.` }); + } + db.prepare(` + UPDATE children + SET available_shells = available_shells - ?, locked_shells = locked_shells + ? + WHERE id = ? + `).run(count, count, child.id); + db.prepare(` + INSERT INTO transactions (child_id, type, shells_delta, euros_delta, description, created_at) + VALUES (?, 'LOCK_VIRTUAL', 0, 0, ?, ?) + `).run(child.id, `${child.name} a transféré ${count} coquillages vers son épargne bloquée`, now); + break; + } + + case 'CONTRIBUTE_PHYSICAL': + case 'CONTRIBUTE_VIRTUAL': { + const activity = db.prepare('SELECT * FROM activities WHERE id = ?').get(request.activity_id); + if (!activity) { + return res.status(404).json({ error: 'Activité associée introuvable.' }); + } + if (request.type === 'CONTRIBUTE_VIRTUAL') { + if (child.available_shells < count) { + return res.status(400).json({ error: `Solde virtuel insuffisant pour ${child.name}.` }); + } + db.prepare('UPDATE children SET available_shells = available_shells - ? WHERE id = ?').run(count, child.id); + } + + const newTotal = activity.current_shells + count; + const newStatus = newTotal >= activity.cost_shells ? 'UNLOCKED' : activity.status; + db.prepare('UPDATE activities SET current_shells = ?, status = ? WHERE id = ?').run(newTotal, newStatus, activity.id); + db.prepare(` + INSERT INTO activity_contributions (activity_id, child_id, shells, created_at) + VALUES (?, ?, ?, ?) + `).run(activity.id, child.id, count, now); + + db.prepare(` + INSERT INTO transactions (child_id, type, shells_delta, euros_delta, description, created_at) + VALUES (?, ?, ?, 0, ?, ?) + `).run( + child.id, + request.type, + request.type === 'CONTRIBUTE_VIRTUAL' ? -count : 0, + `${child.name} a mis ${count} coquillages pour "${activity.title}" (${newTotal}/${activity.cost_shells})`, + now + ); + break; + } + + case 'CONVERT_EUROS_PHYSICAL': + case 'CONVERT_EUROS_VIRTUAL': { + if (child.shells_converted_month + count > settings.max_shells_euro_month) { + return res.status(400).json({ + error: `Dépassement du plafond mensuel de ${settings.max_shells_euro_month} coquillages (${settings.max_euros_per_month}€).` + }); + } + if (request.type === 'CONVERT_EUROS_VIRTUAL' && child.available_shells < count) { + return res.status(400).json({ error: `Solde virtuel insuffisant pour ${child.name}.` }); + } + + const eurosGained = +(count / settings.shells_per_euro).toFixed(2); + if (request.type === 'CONVERT_EUROS_VIRTUAL') { + db.prepare(` + UPDATE children + SET available_shells = available_shells - ?, + euros_balance = euros_balance + ?, + shells_converted_month = shells_converted_month + ? + WHERE id = ? + `).run(count, eurosGained, count, child.id); + } else { + db.prepare(` + UPDATE children + SET euros_balance = euros_balance + ?, + shells_converted_month = shells_converted_month + ? + WHERE id = ? + `).run(eurosGained, count, child.id); + } + + db.prepare(` + INSERT INTO transactions (child_id, type, shells_delta, euros_delta, description, created_at) + VALUES (?, ?, ?, ?, ?, ?) + `).run( + child.id, + request.type, + request.type === 'CONVERT_EUROS_VIRTUAL' ? -count : 0, + eurosGained, + `${child.name} a converti ${count} coquillages en +${eurosGained.toFixed(2)} €`, + now + ); + break; + } + + case 'PROPOSE_ACTIVITY': { + const activityTitle = (req.body?.title || request.proposed_title || 'Nouvelle Activité').trim(); + const cost = req.body?.cost_shells ? Number(req.body.cost_shells) : (count > 0 ? count : 25); + const emoji = req.body?.emoji || '💡'; + const description = (req.body?.description || `Activité proposée par ${child.name} !`).trim(); + const imageUrl = req.body?.image_url?.trim() || DEFAULT_ACTIVITY_IMAGES.default; + + db.prepare(` + INSERT INTO activities (title, description, emoji, image_url, cost_shells, current_shells, status, created_at) + VALUES (?, ?, ?, ?, ?, 0, 'ACTIVE', ?) + `).run(activityTitle, description, emoji, imageUrl, cost, now); + + db.prepare(` + INSERT INTO transactions (child_id, type, shells_delta, euros_delta, description, created_at) + VALUES (?, 'PROPOSE_ACTIVITY', 0, 0, ?, ?) + `).run(child.id, `L'activité "${activityTitle}" proposée par ${child.name} a été validée par les parents (${cost} 🐚)`, now); + break; + } + } + + db.prepare("UPDATE pending_requests SET status = 'APPROVED', resolved_at = ? WHERE id = ?").run(now, id); + res.json({ message: 'Opération validée avec succès !' }); +}); + +// 9. POST /api/requests/:id/reject +app.post('/api/requests/:id/reject', (req, res) => { + const id = Number(req.params.id); + const now = new Date().toISOString(); + db.prepare("UPDATE pending_requests SET status = 'REJECTED', resolved_at = ? WHERE id = ?").run(now, id); + res.json({ message: 'Demande refusée.' }); +}); + +// 10. POST /api/bank/apply-interest +app.post('/api/bank/apply-interest', (req, res) => { + const { unlock_after_interest = false } = req.body || {}; + const settings = getSettings(); + const children = getEnrichedChildren(); + const now = new Date().toISOString(); + const results = []; + + for (const child of children) { + const bonus = calculateProjectedInterest(child.locked_shells, settings.interest_rate_percent); + if (bonus > 0) { + if (unlock_after_interest) { + const totalReturned = child.locked_shells + bonus; + db.prepare(` + UPDATE children + SET available_shells = available_shells + ?, locked_shells = 0 + WHERE id = ? + `).run(totalReturned, child.id); + } else { + db.prepare(` + UPDATE children + SET available_shells = available_shells + ? + WHERE id = ? + `).run(bonus, child.id); + } + + db.prepare(` + INSERT INTO transactions (child_id, type, shells_delta, euros_delta, description, created_at) + VALUES (?, 'MONTHLY_INTEREST', ?, 0, ?, ?) + `).run( + child.id, + bonus, + `Intérêts mensuels (+${settings.interest_rate_percent}% sur ${child.locked_shells} bloqués) : +${bonus} coquillages pour ${child.name}`, + now + ); + + results.push({ child_name: child.name, locked_shells: child.locked_shells, bonus_shells: bonus }); + } + } + + const formattedDate = new Date().toLocaleDateString('fr-FR', { + day: '2-digit', + month: 'long', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + db.prepare("INSERT OR REPLACE INTO settings (key, value) VALUES ('last_interest_run', ?)").run(formattedDate); + + res.json({ + message: `Intérêts mensuels (+${settings.interest_rate_percent}%) appliqués avec succès !`, + results + }); +}); + +// 11. POST /api/bank/new-month +app.post('/api/bank/new-month', (req, res) => { + const { unlock_savings = false } = req.body || {}; + const now = new Date().toISOString(); + + if (unlock_savings) { + db.prepare('UPDATE children SET available_shells = available_shells + locked_shells, locked_shells = 0').run(); + } + db.prepare('UPDATE children SET shells_converted_month = 0').run(); + + db.prepare(` + INSERT INTO transactions (child_id, type, shells_delta, euros_delta, description, created_at) + VALUES (NULL, 'NEW_MONTH', 0, 0, ?, ?) + `).run( + `Nouveau mois démarré (plafond de conversion 5€ remis à zéro${unlock_savings ? ' + épargne débloquée' : ''})`, + now + ); + + res.json({ message: 'Nouveau mois initialisé avec succès !' }); +}); + +// 12. PUT /api/bank/settings +app.put('/api/bank/settings', (req, res) => { + const { interest_rate_percent, shells_per_euro, max_euros_per_month } = req.body; + const upsert = db.prepare('INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)'); + + if (interest_rate_percent !== undefined && Number(interest_rate_percent) >= 0) { + upsert.run('interest_rate_percent', String(Number(interest_rate_percent))); + } + if (shells_per_euro !== undefined && Number(shells_per_euro) > 0) { + upsert.run('shells_per_euro', String(Number(shells_per_euro))); + } + if (max_euros_per_month !== undefined && Number(max_euros_per_month) > 0) { + const sPerEuro = Number(shells_per_euro ?? getSettings().shells_per_euro); + const maxEuros = Number(max_euros_per_month); + upsert.run('max_euros_per_month', String(maxEuros)); + upsert.run('max_shells_euro_month', String(Math.round(maxEuros * sPerEuro))); + } + + res.json({ settings: getSettings(), message: 'Paramètres de la banque mis à jour.' }); +}); + +// 13. POST /api/children/:id/adjust +app.post('/api/children/:id/adjust', (req, res) => { + const id = Number(req.params.id); + const { field, delta, reason = 'Ajustement parental' } = req.body; + const child = db.prepare('SELECT * FROM children WHERE id = ?').get(id); + if (!child) return res.status(404).json({ error: 'Enfant introuvable.' }); + + const numDelta = Number(delta); + const now = new Date().toISOString(); + + if (field === 'available_shells') { + const nextVal = Math.max(0, child.available_shells + numDelta); + db.prepare('UPDATE children SET available_shells = ? WHERE id = ?').run(nextVal, id); + db.prepare(` + INSERT INTO transactions (child_id, type, shells_delta, euros_delta, description, created_at) + VALUES (?, 'ADMIN_ADJUST', ?, 0, ?, ?) + `).run(id, numDelta, `${reason} (${numDelta > 0 ? '+' : ''}${numDelta} coquillages dispo pour ${child.name})`, now); + } else if (field === 'locked_shells') { + const nextVal = Math.max(0, child.locked_shells + numDelta); + db.prepare('UPDATE children SET locked_shells = ? WHERE id = ?').run(nextVal, id); + db.prepare(` + INSERT INTO transactions (child_id, type, shells_delta, euros_delta, description, created_at) + VALUES (?, 'ADMIN_ADJUST', ?, 0, ?, ?) + `).run(id, numDelta, `${reason} (${numDelta > 0 ? '+' : ''}${numDelta} coquillages bloqués pour ${child.name})`, now); + } else if (field === 'unlock_all') { + const unlocked = child.locked_shells; + db.prepare('UPDATE children SET available_shells = available_shells + locked_shells, locked_shells = 0 WHERE id = ?').run(id); + db.prepare(` + INSERT INTO transactions (child_id, type, shells_delta, euros_delta, description, created_at) + VALUES (?, 'UNLOCK', 0, 0, ?, ?) + `).run(id, `Déblocage de ${unlocked} coquillages vers le compte disponible de ${child.name}`, now); + } else if (field === 'reset_euros') { + db.prepare('UPDATE children SET euros_balance = 0 WHERE id = ?').run(id); + db.prepare(` + INSERT INTO transactions (child_id, type, shells_delta, euros_delta, description, created_at) + VALUES (?, 'PAYOUT_EUROS', 0, ?, ?, ?) + `).run(id, -child.euros_balance, `Remise en main propre des ${child.euros_balance.toFixed(2)} € de la tirelire de ${child.name}`, now); + } + + res.json({ message: 'Compte mis à jour.', child: getEnrichedChildren().find((c) => c.id === id) }); +}); + +// 14. POST /api/alexa/voice-intent (Avec support Personalisation Voice ID, cartes illustrées & APL Echo Show) +app.post('/api/alexa/voice-intent', (req, res) => { + const { intent, slots = {}, personId = null, personGivenName = null } = req.body; + const settings = getSettings(); + let children = getEnrichedChildren(); + const activities = getEnrichedActivities(); + const allCards = activities.map(formatActivityCard); + + // Résolution automatique de l'enfant qui parle via Alexa Voice ID (personGivenName ou personId mémorisé) + let recognizedSpeaker = null; + if (personGivenName) { + recognizedSpeaker = findChildByName(personGivenName); + if (recognizedSpeaker && personId && recognizedSpeaker.alexa_person_id !== personId) { + db.prepare('UPDATE children SET alexa_person_id = ? WHERE id = ?').run(personId, recognizedSpeaker.id); + children = getEnrichedChildren(); + } + } + if (!recognizedSpeaker && personId) { + recognizedSpeaker = children.find((c) => c.alexa_person_id === personId) || null; + } + + // Utilise le prénom dit dans la phrase, sinon le profil vocal reconnu par Alexa ! + const resolveTargetChild = () => { + if (slots.ChildName) { + return { child: findChildByName(slots.ChildName), isPersonal: false }; + } + if (recognizedSpeaker) { + return { child: recognizedSpeaker, isPersonal: true }; + } + return { child: null, isPersonal: false }; + }; + + const dynamicActivities = activities + .filter((a) => a.status !== 'COMPLETED') + .map((a) => ({ + id: String(a.id), + title: a.title, + synonyms: a.title + .split(/[\s&,-]+/) + .map((w) => w.trim()) + .filter((w) => w.length > 3) + })); + + const sendAlexaJson = (payload) => + res.json({ + viewMode: 'DASHBOARD', + children, + settings, + activities, + cards: allCards, + card: allCards[0] || null, + ...payload, + dynamicActivities + }); + + switch (intent) { + case 'LaunchRequest': { + const totalAvailable = children.reduce((sum, c) => sum + c.available_shells, 0); + const featuredCard = allCards[0] || { + title: '🐚 La Banque des Coquillages', + subtitle: `${totalAvailable} coquillages disponibles en famille`, + text: `Hailey, Violette & Andreas • Livret bloqué à +${settings.interest_rate_percent}%/mois`, + imageUrl: DEFAULT_ACTIVITY_IMAGES.default + }; + const greeting = recognizedSpeaker + ? `Bonjour ${recognizedSpeaker.name} ! Tu as ${recognizedSpeaker.available_shells} coquillages disponibles et ${recognizedSpeaker.locked_shells} coquillages bloqués.` + : `Bienvenue dans les coquillages de Hailey, Violette et Andreas ! Ensemble, vous avez ${totalAvailable} coquillages disponibles en banque.`; + + return sendAlexaJson({ + viewMode: 'DASHBOARD', + speech: `${greeting} Le taux d'intérêt du livret bloqué est de ${settings.interest_rate_percent} pourcent par mois. Que veux-tu faire ?`, + card: featuredCard, + cards: allCards + }); + } + + case 'IdentifySpeakerIntent': { + const target = findChildByName(slots.ChildName); + if (!target) { + return sendAlexaJson({ + speech: `Dis-moi si tu es Hailey, Violette ou Andreas pour que j'associe ta voix !` + }); + } + if (personId) { + // Retire ce personId d'un autre enfant éventuel et l'associe à cet enfant + db.prepare('UPDATE children SET alexa_person_id = NULL WHERE alexa_person_id = ?').run(personId); + db.prepare('UPDATE children SET alexa_person_id = ? WHERE id = ?').run(personId, target.id); + children = getEnrichedChildren(); + } + const updatedChild = children.find((c) => c.id === target.id) || target; + return sendAlexaJson({ + viewMode: 'CHILD_DETAIL', + selectedChild: updatedChild, + speech: `Super ${updatedChild.name} ! Ta voix est maintenant associée à ton profil. Tu as ${updatedChild.available_shells} coquillages disponibles et ${updatedChild.locked_shells} coquillages bloqués !` + }); + } + + case 'GetActivityDetailIntent': { + const activity = findActivityByName(slots.ActivityName) || activities[0]; + if (!activity) { + return sendAlexaJson({ + speech: `Je n'ai pas trouvé cette activité dans le catalogue.` + }); + } + const contribText = + activity.contributions.length > 0 + ? activity.contributions.map((c) => `${c.child_name} a mis ${c.total_shells} coquillages`).join(', ') + : `Personne n'a encore déposé de coquillages dessus`; + const statusText = + activity.remaining_shells === 0 + ? `Cette activité est débloquée !` + : `Il manque encore ${activity.remaining_shells} coquillages sur ${activity.cost_shells}.`; + + return sendAlexaJson({ + viewMode: 'ACTIVITY_DETAIL', + selectedActivity: activity, + card: formatActivityCard(activity), + speech: `Voici les détails pour ${activity.title} : ${activity.description || ''} Elle coûte ${activity.cost_shells} coquillages. ${contribText}. ${statusText} Tu peux toucher un bouton sur l'écran pour ajouter des coquillages !` + }); + } + + case 'APLTouchEvent': { + const [action, targetId, extraParam] = Array.isArray(slots.arguments) ? slots.arguments : []; + + if (action === 'VIEW_DASHBOARD') { + return sendAlexaJson({ + viewMode: 'DASHBOARD', + speech: `Retour au tableau de bord de Hailey, Violette et Andreas.` + }); + } + + if (action === 'VIEW_CHILD') { + const child = children.find((c) => String(c.id) === String(targetId)) || findChildByName(targetId); + if (child) { + return sendAlexaJson({ + viewMode: 'CHILD_DETAIL', + selectedChild: child, + card: { + title: `${child.avatar} Compte de ${child.name}`, + subtitle: `${child.available_shells} 🐚 dispo • ${child.locked_shells} 🔒 bloqués (+${child.projected_interest_shells} 🐚/mois)`, + text: `Tirelire : ${child.euros_balance.toFixed(2)} € • Touchez un bouton à l'écran pour placer des coquillages.`, + imageUrl: allCards[0]?.imageUrl || DEFAULT_ACTIVITY_IMAGES.default + }, + speech: `Voici le compte de ${child.name} : ${child.available_shells} coquillages disponibles, ${child.locked_shells} coquillages bloqués qui rapportent ${child.projected_interest_shells} coquillages gratuits ce mois-ci, et ${child.euros_balance.toFixed(2).replace('.00', '')} euros dans sa tirelire.` + }); + } + } + + if (action === 'VIEW_ACTIVITY') { + const activity = activities.find((a) => String(a.id) === String(targetId)) || findActivityByName(targetId); + if (activity) { + const contribText = + activity.contributions.length > 0 + ? activity.contributions.map((c) => `${c.child_name} a mis ${c.total_shells} coquillages`).join(', ') + : `Aucun coquillage déposé pour l'instant`; + return sendAlexaJson({ + viewMode: 'ACTIVITY_DETAIL', + selectedActivity: activity, + card: formatActivityCard(activity), + speech: `${activity.title} coûte ${activity.cost_shells} coquillages. ${contribText}. Il reste ${activity.remaining_shells} coquillages à réunir. Touche le prénom d'un enfant sur l'écran pour cotiser !` + }); + } + } + + if (action === 'TOUCH_CONTRIBUTE') { + const activity = activities.find((a) => String(a.id) === String(targetId)); + const child = children.find((c) => String(c.id) === String(extraParam)); + if (activity && child) { + if ((activity.locked_child_ids || []).includes(child.id)) { + return sendAlexaJson({ + viewMode: 'ACTIVITY_DETAIL', + selectedActivity: activity, + card: formatActivityCard(activity), + speech: `${child.name}, ta demande d'ajout de coquillages sur ${activity.title} est déjà validée et verrouillée ! Donne tes coquillages à tes parents pour qu'ils saisissent le nombre sur l'application.` + }); + } + + const now = new Date().toISOString(); + const note = `${child.name} souhaite ajouter des coquillages sur l'activité "${activity.title}". Saisissez le nombre de coquillages physiques remis par ${child.name} :`; + db.prepare(` + INSERT INTO pending_requests (child_id, type, shells, activity_id, source, status, note, created_at) + VALUES (?, 'CONTRIBUTE_PHYSICAL', 0, ?, 'ALEXA', 'PENDING', ?, ?) + `).run(child.id, activity.id, note, now); + + const refreshedActivities = getEnrichedActivities(); + const updatedActivity = refreshedActivities.find((a) => a.id === activity.id) || activity; + + return sendAlexaJson({ + viewMode: 'ACTIVITY_DETAIL', + activities: refreshedActivities, + selectedActivity: updatedActivity, + card: formatActivityCard(updatedActivity), + speech: `C'est validé pour ${child.name} sur ${activity.title} ! Ton bouton est maintenant verrouillé. Donne tes coquillages à tes parents pour qu'ils valident le nombre sur l'application !` + }); + } + } + + if (action === 'TOUCH_LOCK') { + const child = children.find((c) => String(c.id) === String(targetId)); + const shellsToLock = Number(extraParam || 5); + if (child) { + const now = new Date().toISOString(); + const note = `${child.name} veut bloquer ${shellsToLock} coquillages physiques (+${settings.interest_rate_percent}%/mois, demandé depuis l'écran tactile Alexa).`; + db.prepare(` + INSERT INTO pending_requests (child_id, type, shells, activity_id, source, status, note, created_at) + VALUES (?, 'DEPOSIT_LOCKED', ?, NULL, 'ALEXA', 'PENDING', ?, ?) + `).run(child.id, shellsToLock, note, now); + + return sendAlexaJson({ + viewMode: 'CHILD_DETAIL', + selectedChild: child, + speech: `C'est noté ${child.name} ! Donne tes ${shellsToLock} coquillages à tes parents pour les bloquer sur ton livret à ${settings.interest_rate_percent} pourcent d'intérêts !` + }); + } + } + + if (action === 'TOUCH_PROPOSE_ACTIVITY') { + return sendAlexaJson({ + viewMode: 'DASHBOARD', + speech: `Dis-moi à haute voix ce que tu proposes, par exemple : je propose l'activité Trampoline Park !` + }); + } + + return sendAlexaJson({ + viewMode: 'DASHBOARD', + speech: `Que souhaites-tu faire ?` + }); + } + + case 'GetBalanceIntent': { + const { child, isPersonal } = resolveTargetChild(); + if (child) { + const bonusMsg = + child.locked_shells > 0 + ? isPersonal + ? `Grâce au taux de ${settings.interest_rate_percent} pourcent, tes coquillages bloqués te rapporteront ${child.projected_interest_shells} coquillages gratuits à la fin du mois !` + : `Grâce au taux de ${settings.interest_rate_percent} pourcent, ses coquillages bloqués lui rapporteront ${child.projected_interest_shells} coquillages gratuits à la fin du mois !` + : isPersonal + ? `Tu n'as pas encore de coquillages bloqués pour gagner des intérêts.` + : `Il n'y a pas encore de coquillages bloqués pour gagner des intérêts.`; + + const mainSpeech = isPersonal + ? `Bonjour ${child.name} ! Tu as ${child.available_shells} coquillages disponibles et ${child.locked_shells} coquillages bloqués, plus ${child.euros_balance.toFixed(2).replace('.00', '')} euros dans ta tirelire. ${bonusMsg}` + : `${child.name} possède ${child.available_shells} coquillages disponibles et ${child.locked_shells} coquillages bloqués, plus ${child.euros_balance.toFixed(2).replace('.00', '')} euros dans sa tirelire. ${bonusMsg}`; + + return sendAlexaJson({ + viewMode: 'CHILD_DETAIL', + selectedChild: child, + speech: mainSpeech, + card: { + title: `${child.avatar} Compte de ${child.name}`, + subtitle: `${child.available_shells} 🐚 disponibles • ${child.locked_shells} 🔒 bloqués (+${child.projected_interest_shells} 🐚 fin de mois)`, + text: `Tirelire : ${child.euros_balance.toFixed(2)} €\n${bonusMsg}`, + imageUrl: allCards[0]?.imageUrl || DEFAULT_ACTIVITY_IMAGES.default, + progressPercent: Math.min(100, (child.shells_converted_month / settings.max_shells_euro_month) * 100) + } + }); + } + + if (personId) { + return sendAlexaJson({ + speech: `J'entends ta voix, mais je ne sais pas encore lequel de Hailey, Violette ou Andreas tu es ! Dis-moi juste une fois : "Je suis Hailey", "Je suis Violette" ou "Je suis Andreas" pour que je mémorise ta voix !` + }); + } + + const details = children + .map((c) => `${c.name} a ${c.available_shells} disponibles et ${c.locked_shells} bloqués`) + .join(', '); + return sendAlexaJson({ + speech: `Voici le bilan de la famille : ${details}.`, + card: allCards[0] || null, + cards: allCards + }); + } + + case 'ListActivitiesIntent': { + const activeList = activities.filter((a) => a.status !== 'COMPLETED'); + if (activeList.length === 0) { + return sendAlexaJson({ speech: `Il n'y a aucune activité enregistrée pour le moment sur le site.` }); + } + const budget = slots.ShellCount ? Number(slots.ShellCount) : null; + if (budget) { + const affordable = activeList.filter((a) => a.remaining_shells <= budget); + if (affordable.length === 0) { + return sendAlexaJson({ + speech: `Avec ${budget} coquillages, vous ne pouvez pas encore boucler une activité d'un coup, mais vous pouvez cotiser ! Par exemple, il manque ${activeList[0].remaining_shells} coquillages pour ${activeList[0].title}.`, + card: formatActivityCard(activeList[0]), + cards: activeList.map(formatActivityCard) + }); + } + const names = affordable.map((a) => `${a.title} à ${a.remaining_shells} coquillages restants`).join(', ou '); + return sendAlexaJson({ + speech: `Avec ${budget} coquillages, vous pouvez débloquer : ${names} !`, + card: formatActivityCard(affordable[0]), + cards: affordable.map(formatActivityCard) + }); + } + + const desc = activeList + .map((a) => + a.remaining_shells === 0 + ? `${a.title} est déjà débloquée !` + : `${a.title} coûte ${a.cost_shells} coquillages et il en manque encore ${a.remaining_shells}` + ) + .join('. '); + return sendAlexaJson({ + speech: `Voici les activités communes du mois : ${desc}. Regarde l'écran pour voir les photos des activités !`, + card: formatActivityCard(activeList[0]), + cards: activeList.map(formatActivityCard) + }); + } + + case 'DepositShellsIntent': { + const { child } = resolveTargetChild(); + const count = Number(slots.ShellCount || 0); + const isLocked = slots.AccountType !== 'disponible'; + if (!child || count <= 0) { + return sendAlexaJson({ + speech: `Précise bien le prénom entre Hailey, Violette ou Andreas (ou dis "Je suis Hailey" une fois pour associer ta voix), et le nombre de coquillages à placer.` + }); + } + + const reqType = isLocked ? 'DEPOSIT_LOCKED' : 'DEPOSIT_AVAILABLE'; + const now = new Date().toISOString(); + const note = isLocked + ? `${child.name} veut remettre ${count} coquillages physiques et les BLOQUER (+${settings.interest_rate_percent}%/mois).` + : `${child.name} veut déposer ${count} coquillages physiques sur son compte disponible.`; + + db.prepare(` + INSERT INTO pending_requests (child_id, type, shells, activity_id, source, status, note, created_at) + VALUES (?, ?, ?, NULL, 'ALEXA', 'PENDING', ?, ?) + `).run(child.id, reqType, count, note, now); + + const newBonus = calculateProjectedInterest(child.locked_shells + count, settings.interest_rate_percent); + return sendAlexaJson({ + viewMode: 'CHILD_DETAIL', + selectedChild: child, + speech: isLocked + ? `Super idée ${child.name} ! J'ai envoyé ta demande pour bloquer ${count} coquillages. Donne tes ${count} vrais coquillages à tes parents pour qu'ils valident sur le site. Avec les ${settings.interest_rate_percent} pourcent d'intérêts, ton livret te rapportera ${newBonus} coquillages gratuits à la fin du mois !` + : `C'est noté ${child.name} ! Donne tes ${count} coquillages physiques à tes parents pour qu'ils les valident sur ton compte virtuel.`, + card: { + title: `🔒 Dépôt de ${count} coquillages (${child.name})`, + subtitle: isLocked ? `Livret Bloqué à +${settings.interest_rate_percent}% / mois` : 'Compte Disponible', + text: note, + imageUrl: DEFAULT_ACTIVITY_IMAGES.default + } + }); + } + + case 'ContributeActivityIntent': { + const { child } = resolveTargetChild(); + const count = Number(slots.ShellCount || 0); + const activity = findActivityByName(slots.ActivityName); + + if (!child || !activity) { + return sendAlexaJson({ + speech: `Pour participer à une activité, dis par exemple : Hailey veut ajouter des coquillages sur ${activities[0]?.title || 'le Bowling'}, ou touche ton prénom sur l'écran de l'activité !` + }); + } + + if ((activity.locked_child_ids || []).includes(child.id)) { + return sendAlexaJson({ + viewMode: 'ACTIVITY_DETAIL', + selectedActivity: activity, + card: formatActivityCard(activity), + speech: `${child.name}, ta demande d'ajout de coquillages sur ${activity.title} est déjà enregistrée et verrouillée ! Donne tes coquillages à tes parents pour qu'ils valident le nombre sur l'application.` + }); + } + + const now = new Date().toISOString(); + const note = + count > 0 + ? `${child.name} souhaite ajouter des coquillages sur "${activity.title}" (annoncé : ${count} 🐚 — ajustez si besoin) :` + : `${child.name} souhaite ajouter des coquillages sur l'activité "${activity.title}". Saisissez le nombre de coquillages physiques remis par ${child.name} :`; + + db.prepare(` + INSERT INTO pending_requests (child_id, type, shells, activity_id, source, status, note, created_at) + VALUES (?, 'CONTRIBUTE_PHYSICAL', ?, ?, 'ALEXA', 'PENDING', ?, ?) + `).run(child.id, count, activity.id, note, now); + + const refreshedActivities = getEnrichedActivities(); + const updatedActivity = refreshedActivities.find((a) => a.id === activity.id) || activity; + + return sendAlexaJson({ + viewMode: 'ACTIVITY_DETAIL', + activities: refreshedActivities, + selectedActivity: updatedActivity, + speech: `C'est validé ${child.name} ! J'ai verrouillé ta demande d'ajout de coquillages sur ${activity.title}. Donne maintenant tes coquillages à tes parents pour qu'ils saisissent le nombre sur l'application !`, + card: formatActivityCard(updatedActivity) + }); + } + + case 'ConvertEurosIntent': { + const { child } = resolveTargetChild(); + const count = Number(slots.ShellCount || 0); + if (!child || count <= 0) { + return sendAlexaJson({ + speech: `Dis-moi qui entre Hailey, Violette ou Andreas veut échanger ses coquillages, et combien.` + }); + } + if (count > child.remaining_shells_to_convert) { + return sendAlexaJson({ + speech: `Attention ${child.name}, la limite est de ${settings.max_shells_euro_month} coquillages soit ${settings.max_euros_per_month} euros par mois. Il te reste droit à ${child.remaining_shells_to_convert} coquillages convertibles ce mois-ci.` + }); + } + const euros = +(count / settings.shells_per_euro).toFixed(2); + const now = new Date().toISOString(); + const note = `${child.name} veut échanger ${count} coquillages physiques contre ${euros} € dans sa tirelire.`; + db.prepare(` + INSERT INTO pending_requests (child_id, type, shells, activity_id, source, status, note, created_at) + VALUES (?, 'CONVERT_EUROS_PHYSICAL', ?, NULL, 'ALEXA', 'PENDING', ?, ?) + `).run(child.id, count, note, now); + + return sendAlexaJson({ + speech: `Demande enregistrée pour ${child.name} ! En donnant ${count} coquillages à tes parents, tu recevras ${String(euros).replace('.', ' euros ')} euros. Il ne reste plus qu'à valider sur le site !` + }); + } + + case 'SimulateInterestIntent': { + const count = Number(slots.ShellCount || 10); + const bonus = calculateProjectedInterest(count, settings.interest_rate_percent); + return sendAlexaJson({ + speech: `Avec le taux actuel de ${settings.interest_rate_percent} pourcent par mois, si tu bloques ${count} coquillages, tu gagneras ${bonus} coquillages gratuits en plus à la fin du mois, soit ${count + bonus} coquillages au total !` + }); + } + + case 'ProposeActivityIntent': { + const { child } = resolveTargetChild(); + const rawTitle = (slots.ActivityTitle || '').trim(); + + if (!rawTitle) { + return sendAlexaJson({ + speech: `Quelle activité souhaites-tu proposer ? Dis par exemple : je propose l'activité Trampoline Park !` + }); + } + + const cleanTitle = rawTitle.charAt(0).toUpperCase() + rawTitle.slice(1); + const targetChild = child || children[0]; + const now = new Date().toISOString(); + const note = `${targetChild.name} propose une nouvelle activité : "${cleanTitle}". Définissez le nombre de coquillages pour l'activer !`; + + db.prepare(` + INSERT INTO pending_requests (child_id, type, shells, activity_id, proposed_title, source, status, note, created_at) + VALUES (?, 'PROPOSE_ACTIVITY', 0, NULL, ?, 'ALEXA', 'PENDING', ?, ?) + `).run(targetChild.id, cleanTitle, note, now); + + return sendAlexaJson({ + speech: `Super idée ${targetChild.name} ! J'ai bien transmis ta proposition d'activité pour ${cleanTitle}. Papa et Maman l'ont reçue dans leur guichet pour la valider et fixer le nombre de coquillages !`, + card: { + title: `💡 Idée d'activité : ${cleanTitle}`, + subtitle: `Proposée par ${targetChild.name}`, + text: `La proposition a bien été envoyée dans le Guichet des Parents pour validation.`, + imageUrl: DEFAULT_ACTIVITY_IMAGES.default + } + }); + } + + default: + return sendAlexaJson({ + speech: `Je n'ai pas compris la demande. Tu peux me demander le solde de Hailey, Violette ou Andreas, la liste des activités, ou de bloquer des coquillages !` + }); + } +}); + +// 15. GET & POST /api/alexa/webhook (Point de terminaison HTTPS direct pour la Console Alexa Developer) +app.get('/', (req, res) => { + res.json({ + status: 'OK', + service: 'La Banque des Coquillages API', + alexa_webhook: '/api/alexa/webhook' + }); +}); + +app.get('/api/alexa/webhook', (req, res) => { + res.json({ + status: 'OK', + message: '✅ Le Webhook Alexa de La Banque des Coquillages est actif ! Collez cette URL dans la Console Alexa Developer (Endpoint -> HTTPS).' + }); +}); + +app.post('/api/alexa/webhook', async (req, res) => { + try { + const reqType = req.body?.request?.type; + const intentName = req.body?.request?.intent?.name || ''; + const host = req.headers['x-forwarded-host'] || req.get('host') || 'localhost:4050'; + const publicBaseUrl = host.includes('localhost') ? `http://${host}` : `https://${host}`; + req.body._publicBaseUrl = publicBaseUrl; + console.log(`🎙️ [ALEXA WEBHOOK] Reçu: ${reqType} ${intentName} (Host: ${publicBaseUrl})`); + const responseEnvelope = await alexaSkill.invoke(req.body); + res.json(responseEnvelope); + } catch (err) { + console.error('Erreur Webhook Alexa:', err); + res.status(500).json({ error: err.message }); + } +}); + +const PORT = process.env.PORT || 4050; +app.listen(PORT, () => { + console.log(`🐚 Backend API "La Banque des Coquillages" démarré sur http://localhost:${PORT}`); +}); diff --git a/back/uploads/.gitkeep b/back/uploads/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..97fdb0e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,37 @@ +services: + backend: + build: + context: . + dockerfile: back/Dockerfile + container_name: coquillages-backend + restart: unless-stopped + ports: + # Port exposé pour accès direct ou pour Nginx Proxy Manager (NPM) + - "4050:4050" + environment: + - NODE_ENV=production + - PORT=4050 + volumes: + # Persistance de la base SQLite et des images téléversées + - ./data:/app/back/data + - ./uploads:/app/back/uploads + networks: + - coquillages-net + + frontend: + build: + context: . + dockerfile: front/Dockerfile + container_name: coquillages-frontend + restart: unless-stopped + ports: + # Port exposé pour l'accès web ou pour Nginx Proxy Manager (NPM) + - "5180:80" + depends_on: + - backend + networks: + - coquillages-net + +networks: + coquillages-net: + driver: bridge diff --git a/front/Dockerfile b/front/Dockerfile new file mode 100644 index 0000000..9a79c7a --- /dev/null +++ b/front/Dockerfile @@ -0,0 +1,30 @@ +# Étape 1 : Build de l'application React Vite +FROM node:22-alpine AS builder + +WORKDIR /app + +# Copie des configurations de paquets du monorepo +COPY package.json package-lock.json ./ +COPY front/package.json ./front/ + +# Installation des dépendances du frontend +RUN npm install --workspace=front + +# Copie du code source frontend +COPY front ./front + +# Construction du bundle statique de production +RUN npm --workspace=front run build + +# Étape 2 : Image Nginx ultra-légère pour servir les fichiers statiques et relayer l'API +FROM nginx:alpine + +# Copie des fichiers statiques générés +COPY --from=builder /app/front/dist /usr/share/nginx/html + +# Configuration Nginx personnalisée avec reverse proxy vers backend:4050 +COPY front/nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/front/index.html b/front/index.html new file mode 100644 index 0000000..c575fcf --- /dev/null +++ b/front/index.html @@ -0,0 +1,13 @@ + + + + + + 🐚 La Banque des Coquillages — Hailey, Violette & Andreas + + +
+ + + + diff --git a/front/nginx.conf b/front/nginx.conf new file mode 100644 index 0000000..3c74904 --- /dev/null +++ b/front/nginx.conf @@ -0,0 +1,40 @@ +server { + listen 80; + server_name localhost; + + # Compression gzip + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml; + + root /usr/share/nginx/html; + index index.html; + + # Relai vers le backend Express pour toutes les requêtes API + location /api/ { + proxy_pass http://backend:4050/api/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_cache_bypass $http_upgrade; + } + + # Relai vers les images uploadées servies par le backend + location /uploads/ { + proxy_pass http://backend:4050/uploads/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + } + + # Application React (Single Page Application - fallback sur index.html) + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/front/package.json b/front/package.json new file mode 100644 index 0000000..32b6688 --- /dev/null +++ b/front/package.json @@ -0,0 +1,25 @@ +{ + "name": "arrweek-front", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --port 5180", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "lucide-react": "^0.475.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.6", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "tailwindcss": "^4.0.6", + "vite": "^6.1.0" + } +} + diff --git a/front/src/App.jsx b/front/src/App.jsx new file mode 100644 index 0000000..e579088 --- /dev/null +++ b/front/src/App.jsx @@ -0,0 +1,1762 @@ +import React, { useState, useEffect } from 'react'; +import { + Lock, + PiggyBank, + Sparkles, + CheckCircle2, + XCircle, + Plus, + Trash2, + Mic, + Volume2, + ShieldCheck, + TrendingUp, + Gift, + RefreshCw, + History, + Settings, + Coins, + Image as ImageIcon, + Upload, + Tv +} from 'lucide-react'; + +const API_URL = + import.meta.env.VITE_API_URL || + (typeof window !== 'undefined' && window.location.hostname !== 'localhost' + ? '/api' + : 'http://localhost:4050/api'); + +const EMOJI_OPTIONS = ['🍕', '🎳', '🌊', '🧗', '🎢', '🎬', '🍦', '🏕️', '🎮', '🚲', '🎨', '⚽']; + +const PRESET_GALLERY = [ + { + label: 'Pizza & Cinéma', + emoji: '🍕', + url: 'https://images.unsplash.com/photo-1513104890138-7c749659a591?auto=format&fit=crop&w=1000&q=80' + }, + { + label: 'Bowling', + emoji: '🎳', + url: 'https://images.unsplash.com/photo-1538511059256-06459b067f41?auto=format&fit=crop&w=1000&q=80' + }, + { + label: 'Parc Aquatique', + emoji: '🌊', + url: 'https://images.unsplash.com/photo-1582653291997-079a1c04e5a1?auto=format&fit=crop&w=1000&q=80' + }, + { + label: 'Accrobranche', + emoji: '🧗', + url: 'https://images.unsplash.com/photo-1542273917363-3b1817f69a2d?auto=format&fit=crop&w=1000&q=80' + }, + { + label: 'Parc Attractions', + emoji: '🎢', + url: 'https://images.unsplash.com/photo-1513889961551-628c1e5e2ee9?auto=format&fit=crop&w=1000&q=80' + }, + { + label: 'Zoo & Animaux', + emoji: '🦁', + url: 'https://images.unsplash.com/photo-1534567153574-2b12153a87f0?auto=format&fit=crop&w=1000&q=80' + }, + { + label: 'Soirée Glaces', + emoji: '🍦', + url: 'https://images.unsplash.com/photo-1497034825429-c343d7c6a68f?auto=format&fit=crop&w=1000&q=80' + }, + { + label: 'Laser Game / Jeux', + emoji: '🎮', + url: 'https://images.unsplash.com/photo-1511512578047-dfb367046420?auto=format&fit=crop&w=1000&q=80' + } +]; + +export default function App() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [activeTab, setActiveTab] = useState('kids'); // 'kids' | 'parents' | 'alexa' + const [toast, setToast] = useState(null); + + // Quick operation modal for kids/family + const [modalOpen, setModalOpen] = useState(false); + const [modalConfig, setModalConfig] = useState({ + childId: 1, + childName: 'Hailey', + type: 'DEPOSIT_LOCKED', + shells: 4, + activityId: '' + }); + + // New activity form in Parent tab (with image support for Alexa) + const [newActivity, setNewActivity] = useState({ + title: '', + description: '', + emoji: '🎢', + image_url: PRESET_GALLERY[4].url, + cost_shells: 30 + }); + const [uploadingImage, setUploadingImage] = useState(false); + const [editingActivity, setEditingActivity] = useState(null); + + // Bank interest settings form + const [interestInput, setInterestInput] = useState(20); + const [unlockAfterInterest, setUnlockAfterInterest] = useState(false); + const [approvalShells, setApprovalShells] = useState({}); + + // Alexa simulator state (Voice + Echo Show visual card) + const [alexaResponse, setAlexaResponse] = useState( + 'Cliquez sur une commande vocale ci-dessous pour entendre Alexa répondre et voir l\'écran Echo Show !' + ); + const [alexaCard, setAlexaCard] = useState(null); + const [alexaCardsList, setAlexaCardsList] = useState([]); + const [alexaLoading, setAlexaLoading] = useState(false); + const [customAlexa, setCustomAlexa] = useState({ + intent: 'ContributeActivityIntent', + ChildName: 'Hailey', + ShellCount: 6, + AccountType: 'bloqué', + ActivityName: 'Bowling en famille' + }); + + function showToast(msg, isError = false) { + setToast({ msg, isError }); + setTimeout(() => setToast(null), 4500); + } + + async function fetchDashboard() { + try { + const res = await fetch(`${API_URL}/dashboard`); + const json = await res.json(); + setData(json); + if (json.settings) { + setInterestInput(json.settings.interest_rate_percent); + } + if (!alexaCard && json.activities?.length > 0) { + const first = json.activities[0]; + setAlexaCard({ + title: `${first.emoji} ${first.title}`, + subtitle: `${first.current_shells} / ${first.cost_shells} coquillages (${first.progress_percent}%)`, + text: first.description, + imageUrl: first.image_url, + progressPercent: first.progress_percent + }); + } + } catch (err) { + showToast('Impossible de contacter le serveur Backend (port 4050).', true); + } finally { + setLoading(false); + } + } + + useEffect(() => { + fetchDashboard(); + }, []); + + function speakText(text) { + if ('speechSynthesis' in window) { + window.speechSynthesis.cancel(); + const utterance = new SpeechSynthesisUtterance(text); + utterance.lang = 'fr-FR'; + utterance.rate = 1.02; + window.speechSynthesis.speak(utterance); + } + } + + // Upload a local image file from the user's computer/phone + async function handleFileUpload(e, isEditing = false) { + const file = e.target.files?.[0]; + if (!file) return; + setUploadingImage(true); + + const reader = new FileReader(); + reader.onload = async () => { + try { + const res = await fetch(`${API_URL}/upload-image`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + dataUrl: reader.result, + filename: file.name + }) + }); + const json = await res.json(); + if (!res.ok) { + showToast(json.error || 'Erreur upload image', true); + return; + } + if (isEditing && editingActivity) { + setEditingActivity({ ...editingActivity, image_url: json.image_url }); + } else { + setNewActivity((prev) => ({ ...prev, image_url: json.image_url })); + } + showToast('Image téléchargée et prête pour le Skill Alexa ! 🖼️'); + } catch (err) { + showToast('Erreur lors de l\'envoi de l\'image', true); + } finally { + setUploadingImage(false); + } + }; + reader.readAsDataURL(file); + } + + // Create a request (Deposit, Lock, Contribute to Activity, Convert to Euros) + async function handleSubmitRequest(e) { + e.preventDefault(); + try { + const res = await fetch(`${API_URL}/requests`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + child_id: modalConfig.childId, + type: modalConfig.type, + shells: Number(modalConfig.shells), + activity_id: modalConfig.activityId ? Number(modalConfig.activityId) : null, + source: 'WEB' + }) + }); + const json = await res.json(); + if (!res.ok) { + showToast(json.error || 'Erreur lors de la demande', true); + return; + } + showToast(`Demande enregistrée ! Les parents peuvent maintenant valider la réception des coquillages.`); + setModalOpen(false); + fetchDashboard(); + } catch (err) { + showToast(err.message, true); + } + } + + // Approve or Reject a pending physical request (with parent-entered shells count and optional extra metadata) + async function handleResolveRequest(reqId, action, defaultShells = 5, extraBody = {}) { + try { + const shellsCount = Number(approvalShells[reqId] ?? (defaultShells > 0 ? defaultShells : 5)); + const res = await fetch(`${API_URL}/requests/${reqId}/${action}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ shells: shellsCount, ...extraBody }) + }); + const json = await res.json(); + if (!res.ok) { + showToast(json.error || 'Erreur lors de la validation', true); + return; + } + showToast(json.message); + fetchDashboard(); + } catch (err) { + showToast(err.message, true); + } + } + + // Apply monthly interest engine + async function handleApplyMonthlyInterest() { + try { + const res = await fetch(`${API_URL}/bank/apply-interest`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ unlock_after_interest: unlockAfterInterest }) + }); + const json = await res.json(); + showToast(json.message); + fetchDashboard(); + } catch (err) { + showToast(err.message, true); + } + } + + // Start a new month (reset 5€ cap) + async function handleNewMonth(unlockSavings) { + try { + const res = await fetch(`${API_URL}/bank/new-month`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ unlock_savings: unlockSavings }) + }); + const json = await res.json(); + showToast(json.message); + fetchDashboard(); + } catch (err) { + showToast(err.message, true); + } + } + + // Update interest rate setting + async function handleSaveRate(e) { + e.preventDefault(); + try { + const res = await fetch(`${API_URL}/bank/settings`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ interest_rate_percent: Number(interestInput) }) + }); + const json = await res.json(); + showToast(json.message); + fetchDashboard(); + } catch (err) { + showToast(err.message, true); + } + } + + // Create new illustrated activity + async function handleCreateActivity(e) { + e.preventDefault(); + try { + const res = await fetch(`${API_URL}/activities`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(newActivity) + }); + const json = await res.json(); + if (!res.ok) { + showToast(json.error, true); + return; + } + setNewActivity({ + title: '', + description: '', + emoji: '🎢', + image_url: PRESET_GALLERY[4].url, + cost_shells: 30 + }); + showToast('Nouvelle activité illustrée ajoutée pour le site et Alexa !'); + fetchDashboard(); + } catch (err) { + showToast(err.message, true); + } + } + + // Save edited activity (image/title/cost) + async function handleSaveEditedActivity(e) { + e.preventDefault(); + if (!editingActivity) return; + try { + const res = await fetch(`${API_URL}/activities/${editingActivity.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(editingActivity) + }); + if (res.ok) { + showToast('Activité et illustration Alexa mises à jour !'); + setEditingActivity(null); + fetchDashboard(); + } + } catch (err) { + showToast(err.message, true); + } + } + + async function handleDeleteActivity(id) { + await fetch(`${API_URL}/activities/${id}`, { method: 'DELETE' }); + showToast('Activité supprimée.'); + fetchDashboard(); + } + + async function handleAdjustChild(childId, field, delta, reason) { + await fetch(`${API_URL}/children/${childId}/adjust`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ field, delta, reason }) + }); + showToast('Compte mis à jour !'); + fetchDashboard(); + } + + async function triggerAlexaVoice(intent, slots = {}) { + setAlexaLoading(true); + try { + const res = await fetch(`${API_URL}/alexa/voice-intent`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ intent, slots }) + }); + const json = await res.json(); + setAlexaResponse(json.speech); + if (json.card) setAlexaCard(json.card); + if (json.cards) setAlexaCardsList(json.cards); + else setAlexaCardsList([]); + speakText(json.speech); + fetchDashboard(); + } catch (err) { + setAlexaResponse('Erreur de connexion au serveur Backend.'); + } finally { + setAlexaLoading(false); + } + } + + if (loading || !data) { + return ( +
+
+
🐚
+

Chargement de la Banque des Coquillages...

+
+
+ ); + } + + const { settings, summary, children, activities, pending_requests, transactions } = data; + + return ( +
+ {/* Toast Notification */} + {toast && ( +
+ {toast.isError ? : } + {toast.msg} +
+ )} + + {/* Header */} +
+
+
+
+ 🐚 +
+
+

+ La Banque des Coquillages +

+

+ Hailey, Violette & Andreas • Épargne bloquée à{' '} + +{settings.interest_rate_percent}% / mois • Max{' '} + {settings.max_euros_per_month}€ ({settings.max_shells_euro_month} 🐚) +

+
+
+ + {/* Navigation Tabs */} +
+ + + + + +
+
+
+ +
+ {/* Top Banner if pending requests exist */} + {pending_requests.length > 0 && activeTab !== 'parents' && ( +
+
+ 🧺 +
+

+ {pending_requests.length} remise(s) de coquillages physiques en attente de validation parentale ! +

+

{pending_requests[0].note}

+
+
+ +
+ )} + + {/* ==================== TAB 1: ESPACE ENFANTS & ACTIVITÉS ==================== */} + {activeTab === 'kids' && ( +
+ {/* Children Cards: Hailey, Violette, Andreas */} +
+
+
+

+ 🧜‍♀️🐬🦈 Les Comptes de Hailey, Violette & Andreas +

+

+ Dépose tes vrais coquillages auprès des parents pour les placer, les bloquer à +{settings.interest_rate_percent}% ou choisir une activité ! +

+
+
+ + Dispo famille : {summary.total_available_shells} 🐚 + + • + + Bloqués : {summary.total_locked_shells} 🔒 + + • + + Intérêts fin de mois : +{summary.total_projected_interest} 🐚 + +
+
+ +
+ {children.map((child) => ( +
+
+
+
+
+ {child.avatar} +
+
+

{child.name}

+ + Total : {child.total_shells} coquillages en banque + +
+
+
+ +
+
+
+ + Coquillages Disponibles + + Prêts pour activités ou euros +
+
+ {child.available_shells} + 🐚 +
+
+ +
+
+
+ + Livret Bloqué (+{settings.interest_rate_percent}%/mois) + + + ✨ Rapporte +{child.projected_interest_shells} 🐚 gratuits ce mois-ci ! + +
+
+ {child.locked_shells} + 🔒 +
+
+
+ +
+
+ + Tirelire Euros (2 🐚 = 1 €) + + + {child.euros_balance.toFixed(2)} € + +
+
+
+
+
+ Convertis ce mois : {child.shells_converted_month}/{settings.max_shells_euro_month} 🐚 + Reste : {child.remaining_euros_this_month} € dispo +
+
+
+
+ +
+ + + +
+
+ ))} +
+
+ + {/* Illustrated Common Activities Section */} +
+
+
+

+ Catalogue des Activités Illustrées (Site & Écran Alexa) +

+

+ Chaque activité possède son illustration qui s'affiche aussi sur les écrans Alexa Echo Show et l'application mobile Alexa ! +

+
+
+ +
+ {activities.map((act) => { + const isUnlocked = act.current_shells >= act.cost_shells || act.status === 'UNLOCKED'; + return ( +
+
+ {/* Activity Image Banner */} +
+ {act.title} +
+ +
+ + Illustré sur Alexa + + {isUnlocked && ( + + 🎉 DÉBLOQUÉE ! + + )} +
+ +
+ Coût + {act.cost_shells} 🐚 +
+ +
+
+ {act.emoji} +
+

{act.title}

+

{act.description}

+
+
+
+
+ +
+ {/* Progress Bar */} +
+
+ + Pot commun : {act.current_shells} / {act.cost_shells} 🐚 ({act.progress_percent}%) + + + {act.remaining_shells === 0 + ? 'Objectif atteint !' + : `Encore ${act.remaining_shells} 🐚 à réunir`} + +
+
+
+
+
+ + {/* Who contributed */} +
+ {act.contributions.length === 0 ? ( + + Aucun coquillage déposé sur cette activité pour l'instant. + + ) : ( + act.contributions.map((c) => ( + + {c.avatar} + {c.child_name} : + {c.total_shells} 🐚 + + )) + )} +
+
+
+ + {/* Contribute Buttons for each Child (Locked if pending!) */} +
+ Ajouter des 🐚 : +
+ {children.map((child) => { + const isLocked = Array.isArray(act.locked_child_ids) && act.locked_child_ids.includes(child.id); + if (isLocked) { + return ( + + 🔒 {child.name} (En attente) + + ); + } + return ( + + ); + })} +
+
+
+ ); + })} +
+
+
+ )} + + {/* ==================== TAB 2: GUICHET PARENTS (VALIDATION, IMAGES ALEXA & INTÉRÊTS) ==================== */} + {activeTab === 'parents' && ( +
+ {/* 1. Validation des remises physiques en attente */} +
+
+
+

+ 🧺 Guichet de Validation des Coquillages Physiques ({pending_requests.length}) +

+

+ Quand Hailey, Violette ou Andreas demandent à ajouter des coquillages sur une activité depuis Alexa, leur bouton se verrouille. Saisissez ici le nombre exact de coquillages physiques qu'ils vous donnent et validez ! +

+
+
+ + {pending_requests.length === 0 ? ( +
+ Aucune demande ou remise de coquillages en attente pour le moment. Tout est à jour ! ✨ +
+ ) : ( +
+ {pending_requests.map((req) => { + const isProposal = req.type === 'PROPOSE_ACTIVITY'; + const currentVal = approvalShells[req.id] ?? (req.shells > 0 ? req.shells : (isProposal ? 25 : 5)); + return ( +
+
+
+ {req.child_avatar} +
+
+
+ {req.child_name} + + Via {req.source} + + {isProposal ? ( + + 💡 Proposition d'activité + + ) : req.activity_title ? ( + + {req.activity_emoji} {req.activity_title} (Bouton Alexa verrouillé 🔒) + + ) : null} +
+ {isProposal ? ( +
+ + 🎡 {req.proposed_title || 'Nouvelle Activité'} + +

{req.note}

+
+ ) : ( +

{req.note}

+ )} +
+
+ +
+
+ + {isProposal ? 'Coût en 🐚 fixé :' : 'Nombre de 🐚 reçus :'} + + + setApprovalShells({ ...approvalShells, [req.id]: e.target.value }) + } + className="w-16 bg-slate-950 border border-white/15 rounded-lg px-2 py-1 text-center font-extrabold text-amber-300 text-sm" + /> + {isProposal && 🐚} +
+ + + +
+
+ ); + })} +
+ )} +
+ + {/* 2. Ajouter une Activité Illustrée (avec Aperçu Écran Alexa Echo Show en direct) */} +
+
+

+ Ajouter une Activité Illustrée pour le Skill Alexa +

+

+ Choisissez une photo dans la galerie rapide, importez une image depuis votre PC/téléphone ou collez un lien d'image : elle s'affichera automatiquement sur les écrans Amazon Echo Show et dans l'application Alexa ! +

+
+ +
+ {/* Formulaire de création (7 colonnes) */} +
+
+
+ + setNewActivity({ ...newActivity, title: e.target.value })} + className="w-full bg-slate-950 border border-white/15 rounded-xl px-3.5 py-2.5 text-sm" + /> +
+
+ + setNewActivity({ ...newActivity, cost_shells: e.target.value })} + className="w-full bg-slate-950 border border-white/15 rounded-xl px-3.5 py-2.5 text-sm font-bold text-amber-300" + /> +
+
+ +
+ + setNewActivity({ ...newActivity, description: e.target.value })} + className="w-full bg-slate-950 border border-white/15 rounded-xl px-3.5 py-2 text-sm" + /> +
+ + {/* Choix de l'illustration : Galerie 1-clic + Upload Fichier + URL */} +
+ + + {/* 1. Galerie rapide d'images HD */} +
+ + Option 1 : Choisir une illustration prête à l'emploi en 1 clic + +
+ {PRESET_GALLERY.map((preset) => ( + + ))} +
+
+ + {/* 2. Upload depuis l'ordinateur ou URL personnalisée */} +
+
+ + Option 2 : Envoyer une photo depuis votre PC/téléphone + + +
+ +
+ + Option 3 : Ou coller l'URL d'une image (HTTPS) + + setNewActivity({ ...newActivity, image_url: e.target.value })} + className="w-full bg-slate-900 border border-white/15 rounded-xl px-3 py-2 text-xs text-slate-200" + /> +
+
+
+ + {/* Emoji selector */} +
+ +
+ {EMOJI_OPTIONS.map((em) => ( + + ))} +
+
+ + +
+ + {/* Aperçu en direct de l'écran Alexa Echo Show (5 colonnes) */} +
+
+ Aperçu en direct sur écran Alexa Echo Show : +
+ +
+
+ Aperçu Alexa +
+ +
+ + 🐚 LA BANQUE DES COQUILLAGES + +

+ {newActivity.emoji} {newActivity.title || 'Titre de votre activité'} +

+

+ 0 / {newActivity.cost_shells || 30} coquillages (0%) +

+

+ {newActivity.description || + `Coût : ${newActivity.cost_shells || 30} 🐚 • Dites "Alexa, Hailey met 5 coquillages pour ${ + newActivity.title || 'cette activité' + }"`} +

+
+
+
+ Amazon Echo Show • Rendu APL & StandardCard +
+
+
+
+ + {/* Liste des activités existantes avec possibilité de changer leur photo */} +
+

+ Catalogue actuel (cliquez sur « Modifier l'image » pour changer l'illustration d'une activité existante) : +

+
+ {activities.map((a) => ( +
+
+ {a.title} +
+ + {a.emoji} {a.title} ({a.cost_shells} 🐚) + +
+
+ + +
+
+ ))} +
+
+
+ + {/* 3. Moteur d'Intérêts Mensuels & Réglages Bancaires */} +
+
+
+

+ Moteur d'Intérêts Mensuels (Livret Bloqué) +

+

+ Dernier versement des intérêts : {settings.last_interest_run} +

+ +
+ {children.map((c) => ( +
+
+ {c.avatar} + {c.name} + ({c.locked_shells} 🐚 bloqués) +
+ + +{c.projected_interest_shells} 🐚 d'intérêts + +
+ ))} +
+ + +
+ +
+ + +
+
+ +
+
+

+ Paramètres du Taux & de la Banque +

+

+ Ajustez le pourcentage d'intérêt mensuel qui récompense l'épargne bloquée des enfants. +

+ +
+
+ +
+ setInterestInput(e.target.value)} + className="w-32 bg-slate-950 border border-white/15 rounded-xl px-4 py-2.5 text-lg font-bold text-cyan-300" + /> + + % par mois (Ex: 10 🐚 bloqués → +{Math.ceil((10 * Number(interestInput || 0)) / 100)} 🐚 gratuits) + +
+
+ +
+

+ • Règle Tirelire Euros : {settings.shells_per_euro} coquillages = 1,00 € +

+

+ • Plafond mensuel par enfant :{' '} + + {settings.max_euros_per_month} € maximum ({settings.max_shells_euro_month} coquillages) + +

+
+ + +
+
+
+
+ + {/* 4. Ajustement Rapide des Comptes & Journal */} +
+

⚙️ Ajustement Manuel des Comptes (Parents)

+
+ {children.map((c) => ( +
+
+ + {c.avatar} {c.name} + + + {c.available_shells} dispo • {c.locked_shells} bloqués • {c.euros_balance.toFixed(2)}€ + +
+ +
+ + + + {c.locked_shells > 0 && ( + + )} + {c.euros_balance > 0 && ( + + )} +
+
+ ))} +
+ +

+ Dernières opérations enregistrées +

+
+ {transactions.map((t) => ( +
+ {t.description} + + {new Date(t.created_at).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })} + +
+ ))} +
+
+
+ )} + + {/* ==================== TAB 3: SIMULATEUR VOCAL ALEXA + ÉCRAN ECHO SHOW ==================== */} + {activeTab === 'alexa' && ( +
+
+
+
+ +
+
+

Simulateur Vocal & Écran Echo Show

+

+ Testez les phrases vocales et visualisez la carte illustrée qui s'affiche sur l'écran d'Alexa ! +

+
+
+ + {/* Alexa Echo Bubble */} +
+
+ + + Voix d'Alexa + + +
+

+ « {alexaLoading ? 'Alexa réfléchit...' : alexaResponse} » +

+
+ + {/* Preset Voice Commands */} +
+

+ Phrases rapides à tester en 1 clic : +

+ +
+ + + + + + + + + + + + + +
+
+
+ + {/* Écran Virtuel Amazon Echo Show + Constructeur sur-mesure (5 colonnes) */} +
+ {/* Écran Echo Show */} +
+
+ + Écran Amazon Echo Show + + APL + StandardCard +
+ + {alexaCard && ( +
+
+ {alexaCard.title} +
+ +
+ + 🐚 LA BANQUE DES COQUILLAGES + +

{alexaCard.title}

+ {alexaCard.subtitle && ( +

{alexaCard.subtitle}

+ )} + {alexaCard.text && ( +

{alexaCard.text}

+ )} +
+
+ + {/* Mini-carrousel si plusieurs activités sont retournées */} + {alexaCardsList.length > 1 && ( +
+ + Activités affichées dans le carrousel Alexa (cliquez pour prévisualiser) : + +
+ {alexaCardsList.map((c, idx) => ( + + ))} +
+
+ )} +
+ )} +
+ + {/* Custom Intent Builder */} +
+

🎛️ Tester une commande vocale sur-mesure

+
+
+ + +
+ +
+
+ + +
+ +
+ + setCustomAlexa({ ...customAlexa, ShellCount: e.target.value })} + className="w-full bg-slate-950 border border-white/15 rounded-xl px-3 py-2" + /> +
+
+ + {customAlexa.intent === 'ContributeActivityIntent' && ( +
+ + +
+ )} + + +
+
+
+
+ )} +
+ + {/* Modal for Editing an Existing Activity's Illustration */} + {editingActivity && ( +
+
+
+

+ 🖼️ Modifier l'illustration Alexa : {editingActivity.title} +

+ +
+ +
+
+ {editingActivity.title} +
+ + {editingActivity.emoji} {editingActivity.title} ({editingActivity.cost_shells} 🐚) + +
+
+ +
+ Choisir une image dans la galerie : +
+ {PRESET_GALLERY.map((p) => ( + + ))} +
+
+ +
+ + setEditingActivity({ ...editingActivity, image_url: e.target.value })} + placeholder="URL de l'image" + className="bg-slate-950 border border-white/15 rounded-xl px-3 py-2 text-xs" + /> +
+ + +
+
+
+ )} + + {/* Modal for Child Request (Deposit / Lock / Contribute / Convert Euros) */} + {modalOpen && ( +
+
+
+

Opération pour {modalConfig.childName}

+ +
+ +
+
+ + +
+ + {(modalConfig.type === 'CONTRIBUTE_PHYSICAL' || modalConfig.type === 'CONTRIBUTE_VIRTUAL') && ( +
+ + +
+ )} + +
+ + setModalConfig({ ...modalConfig, shells: e.target.value })} + className="w-full bg-slate-950 border border-white/15 rounded-xl px-4 py-2.5 text-lg font-bold text-amber-300" + /> +
+ +

+ 💡 La demande sera créée dans le Guichet Parents. Dès que les coquillages sont remis aux parents, un clic sur « Valider » créditera le compte ! +

+ + +
+
+
+ )} +
+ ); +} diff --git a/front/src/index.css b/front/src/index.css new file mode 100644 index 0000000..3d552a6 --- /dev/null +++ b/front/src/index.css @@ -0,0 +1,2 @@ +@import "tailwindcss"; + diff --git a/front/src/main.jsx b/front/src/main.jsx new file mode 100644 index 0000000..41b4d85 --- /dev/null +++ b/front/src/main.jsx @@ -0,0 +1,11 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App.jsx'; +import './index.css'; + +ReactDOM.createRoot(document.getElementById('root')).render( + + + +); + diff --git a/front/vite.config.js b/front/vite.config.js new file mode 100644 index 0000000..bb002f9 --- /dev/null +++ b/front/vite.config.js @@ -0,0 +1,21 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; +import tailwindcss from '@tailwindcss/vite'; + +export default defineConfig({ + plugins: [react(), tailwindcss()], + server: { + port: 5180, + proxy: { + '/api': { + target: 'http://localhost:4050', + changeOrigin: true + }, + '/uploads': { + target: 'http://localhost:4050', + changeOrigin: true + } + } + } +}); + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..0ffd3df --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3391 @@ +{ + "name": "arrweek-monorepo", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "arrweek-monorepo", + "workspaces": [ + "back", + "front", + "skill/lambda" + ] + }, + "back": { + "name": "arrweek-back", + "version": "1.0.0", + "dependencies": { + "cors": "^2.8.5", + "express": "^4.21.2" + } + }, + "front": { + "name": "arrweek-front", + "version": "1.0.0", + "dependencies": { + "lucide-react": "^0.475.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.0.6", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.4", + "tailwindcss": "^4.0.6", + "vite": "^6.1.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.12", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.12.tgz", + "integrity": "sha512-2W76mhjrpQFO1lK5e8IvGYB2D0YZ7S2R1Z2BjO2ZUYuW+amcpK/z60U6ZZmzskwoa7sJ6XohP/Wg8H5KxXI/IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.12", + "@babel/types": "^7.29.11", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.11.tgz", + "integrity": "sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz", + "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.12", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.12.tgz", + "integrity": "sha512-WJ7JBp/Q4+z/ImydtI7jS6NVco5++632ExBemSX+Ysb+G1lCwQ5BVo3SY+jGGOcZXI4ZccbZSUSJLNBW6eEpJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.11" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.12", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.12.tgz", + "integrity": "sha512-MscXe/NAZcOkYEsRajHZFt3ynsb22PUi6CTrp5U7+RYycR9By84mn9mFN54YgfITiqK4hFx3Ju3tZSyzfpS5ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.12", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.12", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.11", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.11.tgz", + "integrity": "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.11", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.66.0.tgz", + "integrity": "sha512-7y4XeoUHPecjfUDRmap8U0q6bjfLAPn7uOGT8BKyqy8uNDOjsGS8rlBhOx6sZLVwwjN6hapvGl2mW9/IS6UNww==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.66.0.tgz", + "integrity": "sha512-e8WugYZXpgqrlMXLCudYVRAxAcK7AIY36JxRDwFRD38L9q62G1VsEUW6ijXxzjxYjNB8i1zo3B/ycIQ/qjLC/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.66.0.tgz", + "integrity": "sha512-6vkDCfyDjNLokh+Rt1D2KtNgFFMEPdkWha/mbtP4cSjh9xiN8T/JBS/A7nDYKjmMAXHrvR9J3AzUk2LtFbIBLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.66.0.tgz", + "integrity": "sha512-/mzZAX0+Q6eXxvkR0HB0RZCCrsbmFD8DpsvCQoMYABDfq/DjqkbfytuJ7GvZ4AhR/Mgis2fXbZrGqfbcK2Z5fA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.66.0.tgz", + "integrity": "sha512-JQJx9E8tUjGqed1RevDmoHaS3V30ODdV8uyvGxlwd4K5se6bkPrMeltSybqAtpJ+ltDRfo7FORQaJG2Hd5nGog==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.66.0.tgz", + "integrity": "sha512-GEaUQQjMaG7X1304qMgqLjzCKv6CdDirzJSS+UqUs3xhJ9X2Kjzu0JDbLoUU42FWxwmZqz+SsgU1SlQv9GNRVA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.66.0.tgz", + "integrity": "sha512-DgmAU9N0NhDQDzHgFUocDio7zRO8jR/nY/obDGvCR8zbJ6J77Fg6X49yPuExJUpYPbC82EOaRhG969pWzUSnHQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.66.0.tgz", + "integrity": "sha512-zG09YZ+sk/HoHq+P9gYB0EseQ3M20RbDuKGWey6fbiMDV1LxfXvhgSW9jDaCcvFPMgfLXpimzXY+UYvA8R2mLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.66.0.tgz", + "integrity": "sha512-vlArhgyXbcVc1D4WZPoiYuqJIaMiSHBj6etRXua0KlfjlZeuiQyVAPYi0/N49O4mSp3u1iGUBLEjNa8ec81XZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.66.0.tgz", + "integrity": "sha512-RxJZtk0G87YPotDaRDvRqStV8cbqLSC5TroHvQHHKsqBg0ok64RxKgtXeFsa7FBcy7mb4crBrG3xK8USWIqWNA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.66.0.tgz", + "integrity": "sha512-qlGcw959PrLLkZhiBxF3OZ6SQvj+OKY85mCqGNgSRs4LZlDJ6ICqmz6UdxtINdhcXYwNIbZoapQVBAmZlD2hUQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.66.0.tgz", + "integrity": "sha512-TvTI/7Cy0WvRA1SXgwzu6bwXBnMpmeT/ln5q7WvSpqmXy1VbmEnY8HVIDQNBiaHJvoWqRr0M4FNo7qiqtyMaFw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.66.0.tgz", + "integrity": "sha512-UZEDkSoDRZ50NyLj70nO6mpIK3dvs2cPTTvQb1bA/PBDmr9fvn5KiESuGfh6srd7Zi6Lt9Sv2sVrFr1QoHLvPQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.66.0.tgz", + "integrity": "sha512-VRbxTvagLh3HRcysx1CNq+HHcvJbSebNI6fCbk7jwmmWHkXjrSIV3igXBpRHUTFdg0pagWGu50xoSZXnrZHjVQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.66.0.tgz", + "integrity": "sha512-uWdJpvYqh0LE/i+YBL3sMX0a2kUkICENjmrPmyBmMOFc3jMrs6MSQH/pMD+VIpGEoUcr3XkHGLLfymzIqPSRRg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.66.0.tgz", + "integrity": "sha512-3FUd88pxgWAjK60OgCrika1AcNG+u8zaCdWjg4TlcZTDkt5TGyci8ZU64l3OJ9ax0MEjw3P80B/3PoSQDCLwJQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.66.0.tgz", + "integrity": "sha512-qT2rMmgyej62g5AdwktK+Bt2lBu497pzWZe12TTQ5JmZjOYPnEwcPEAOSLTBxBV+6gIES75rXm219v/QkT397A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.66.0.tgz", + "integrity": "sha512-E46rnZgp7J84Qwe8RMlF+rgimnDo/9fuiGbUArGB0ZkMWf97O7yeSCfKls5x/YJOF0RcuSe9RctuKe1hlxICiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.66.0.tgz", + "integrity": "sha512-Uz1HiZe+qBzqgdPsON+z0wRx3WdATuyzwvGT6cr2VZVrqqko0ZJVfkrQJtwDMuDcAfXYdo7XucaxJ7lTLjEcgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.66.0.tgz", + "integrity": "sha512-Ph7TX3Ex5qdfcb7Tc6eEPugVkRfJ501eTovO4T5029YObfpZ7dzn3RiwpFvGp50nk2QUhvBMZ0PFGlEj6pOYTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.66.0.tgz", + "integrity": "sha512-nZYPEya7ZkeNxUEiF7qmNZbyLTyRN3MK5cO22KqsaoneUzXJuzBIQRw+7BEQy9LGj2O5vrCE9V3EpK06FYo1tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.66.0.tgz", + "integrity": "sha512-u7aTfS3R6C4hRsqXSzaO6qLZYLVuMkHMvcWlux4IM8IJif1VCl2cgPBSo5gSNoFoStQypQP+qwq8W1IyFBf1QQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.66.0.tgz", + "integrity": "sha512-e+i9ueHrH1Bafb0cf0woAIVJbYcoC8vRaD+vz+Xi8w6jz9cYJSV3vyKO6ELGSZ6g4W4MJOm2WPLN4/nQf77h7w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.66.0.tgz", + "integrity": "sha512-KfTCbrtr2zXTqH2E1F+uYlFTrYibxHKqPAHobpd8Kfg49cEHmuHebOidCTDRgqG1suaPFX7oejFQeGE71zWKfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.66.0.tgz", + "integrity": "sha512-xA+neBh1bqkqaS3b48T2UHZnDsTIpb9B0h+TdBzynjF/ymCpYndhfY10RqW0qSKB1HqqQA83mINVhAdwA+mETg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/arrweek-back": { + "resolved": "back", + "link": true + }, + "node_modules/arrweek-front": { + "resolved": "front", + "link": true + }, + "node_modules/ask-sdk-core": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/ask-sdk-core/-/ask-sdk-core-2.14.0.tgz", + "integrity": "sha512-G2yEKbY+XYTFzJXGRpsg7xJHqqgBcgnKEgklXUMRWRFv10gwHgtWDLLlBV6h4IdysTx782rCVJK/UcHcaGbEpA==", + "license": "Apache-2.0", + "dependencies": { + "ask-sdk-runtime": "^2.14.0" + }, + "peerDependencies": { + "ask-sdk-model": "^1.29.0" + } + }, + "node_modules/ask-sdk-model": { + "version": "1.86.0", + "resolved": "https://registry.npmjs.org/ask-sdk-model/-/ask-sdk-model-1.86.0.tgz", + "integrity": "sha512-JmC5mypPBz5Q1Yx1WyeAr2Q/z2Cjm98EjLjTlYAolXF4gokU7fDDjeOAyAD5dkyHjyGLeCEvlC0MJYWFwc84dw==", + "license": "Apache-2.0" + }, + "node_modules/ask-sdk-runtime": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/ask-sdk-runtime/-/ask-sdk-runtime-2.14.0.tgz", + "integrity": "sha512-a96pPs1RU3GgXBHplqAVqh2uxEuSYjTD5+XSbHsf6Fz4KhHpgaxJogOIIybjA6O/d1B9sG+6bjHJGzxBJEcccA==", + "license": "Apache-2.0" + }, + "node_modules/banque-coquillages-skill-lambda": { + "resolved": "skill/lambda", + "link": true + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.34", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.34.tgz", + "integrity": "sha512-Aqr8mA1kIvyJ3UY5YAY7ib8LSo1DmDMbMnVqGPtsKW1LxaFQFwn9Eb3yvq2fUKJsRvaXjnAV7R5osqETapzNiA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.9", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.9.tgz", + "integrity": "sha512-FISIQxV5u91Y9gvuFq3q/tA/qTqLJn3AhRcEvyozyhJTh5OBFFtv827AdHU7Rbyct4r+Ba1/99oFa1Y3yuFsdw==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.16.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/browserslist": { + "version": "4.29.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.5.tgz", + "integrity": "sha512-khTmKu5NGYTotqcYo8Bew2k4RAE9PuXRSFRXG2lQBrCEQHyeQ/vX6gKMxfokwywBsLiXZMnHsmWMG1pnO8J7uQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.33", + "caniuse-lite": "^1.0.30001819", + "electron-to-chromium": "^1.5.464", + "node-releases": "^2.0.60", + "update-browserslist-db": "^1.3.4" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001820", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001820.tgz", + "integrity": "sha512-gfWD6MxqVCqAOwmODfZf7DchWvlrA+SRdNX7ceLfg3xRqe5ssjjpF7EgslC/0tiLiNTajdaNN/q8c/5u/7Gz8w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.468", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.468.tgz", + "integrity": "sha512-8XdEp3sIqO+PzmVPxhzqHq/3+yCxp44m27cm2WY1sRn3ZhwKyKmqSi2hx6gEhmnOXmh8NUuYE00z6Onzn5m6DA==", + "dev": true, + "license": "ISC" + }, + "node_modules/encodeurl": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.1.tgz", + "integrity": "sha512-Ev/hIyePLZtpL98x562lvPtenrUfGr0lfaf44HFZ+hDEwso7ccV9QAZrEVLg0hxNAzygaAFxy2dYaAKKM0G06A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.26.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.26.0.tgz", + "integrity": "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.3", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz", + "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.13", + "proxy-addr": "~2.0.7", + "qs": "~6.16.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.475.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.475.0.tgz", + "integrity": "sha512-NJzvVu1HwFVeZ+Gwq2q00KygM1aBhy/ZrhY9FsAgJtpB+E4R7uxRk9M2iKvHa6/vNxZydIB59htha4c2vvwvVg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-releases": { + "version": "2.0.62", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.62.tgz", + "integrity": "sha512-A0Eq+qVDQI97IbAD6g/HriIuv256el9N8EwF4+xqJ49pwrdRYpdCSmLk0sO4I3fnS6hpYE5v22B73Bk+WArEoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.2.tgz", + "integrity": "sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", + "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/qs": { + "version": "6.16.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.2.tgz", + "integrity": "sha512-VYdnB5S7/0IeXzBacUtwNAaM6d7Y/PgYnNC7bJCI6meqA/OgnsQbZMtVW8Dt2D1PHgbHGtYmhSoEIgvt7xZ2ZA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/react": { + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz", + "integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.1.tgz", + "integrity": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.66.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.66.0.tgz", + "integrity": "sha512-Onq6/qO4ARhxIs+jYiHE0pYehadLyMECAiGEqQ+0/aWND3+2PmifJIIqBLYtuBODqHTlOUEQFtIVPyCy0Lck7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.66.0", + "@rollup/rollup-android-arm64": "4.66.0", + "@rollup/rollup-darwin-arm64": "4.66.0", + "@rollup/rollup-darwin-x64": "4.66.0", + "@rollup/rollup-freebsd-arm64": "4.66.0", + "@rollup/rollup-freebsd-x64": "4.66.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.66.0", + "@rollup/rollup-linux-arm-musleabihf": "4.66.0", + "@rollup/rollup-linux-arm64-gnu": "4.66.0", + "@rollup/rollup-linux-arm64-musl": "4.66.0", + "@rollup/rollup-linux-loong64-gnu": "4.66.0", + "@rollup/rollup-linux-loong64-musl": "4.66.0", + "@rollup/rollup-linux-ppc64-gnu": "4.66.0", + "@rollup/rollup-linux-ppc64-musl": "4.66.0", + "@rollup/rollup-linux-riscv64-gnu": "4.66.0", + "@rollup/rollup-linux-riscv64-musl": "4.66.0", + "@rollup/rollup-linux-s390x-gnu": "4.66.0", + "@rollup/rollup-linux-x64-gnu": "4.66.0", + "@rollup/rollup-linux-x64-musl": "4.66.0", + "@rollup/rollup-openbsd-x64": "4.66.0", + "@rollup/rollup-openharmony-arm64": "4.66.0", + "@rollup/rollup-win32-arm64-msvc": "4.66.0", + "@rollup/rollup-win32-ia32-msvc": "4.66.0", + "@rollup/rollup-win32-x64-gnu": "4.66.0", + "@rollup/rollup-win32-x64-msvc": "4.66.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "6.4.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.5.tgz", + "integrity": "sha512-4enwjFfi4IwtEh0w+jPzpLVOFQiPHmmzPxcHXQOyEur47o6u0jE573DmGpjA+7irXoMLYRZD5ZaFUEneS3abPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "skill/lambda": { + "name": "banque-coquillages-skill-lambda", + "version": "1.0.0", + "dependencies": { + "ask-sdk-core": "^2.14.0", + "ask-sdk-model": "^1.40.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7be534b --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "arrweek-monorepo", + "private": true, + "type": "module", + "workspaces": [ + "back", + "front", + "skill/lambda" + ], + "scripts": { + "start:all": "node start-all.js", + "tunnel": "ssh -o StrictHostKeyChecking=no -R 80:localhost:4050 nokey@localhost.run", + "start:back": "npm --workspace=back start", + "dev:back": "npm --workspace=back run dev", + "dev:front": "npm --workspace=front run dev", + "build:front": "npm --workspace=front run build", + "test:skill": "npm --workspace=skill/lambda test" + } +} + diff --git a/skill/interactionModels/custom/fr-FR.json b/skill/interactionModels/custom/fr-FR.json new file mode 100644 index 0000000..5d5ca7b --- /dev/null +++ b/skill/interactionModels/custom/fr-FR.json @@ -0,0 +1,388 @@ +{ + "interactionModel": { + "languageModel": { + "invocationName": "les coquillages", + "intents": [ + { + "name": "AMAZON.CancelIntent", + "samples": [] + }, + { + "name": "AMAZON.HelpIntent", + "samples": [] + }, + { + "name": "AMAZON.StopIntent", + "samples": [] + }, + { + "name": "AMAZON.NavigateHomeIntent", + "samples": [] + }, + { + "name": "OpenBankIntent", + "slots": [], + "samples": [ + "la banque", + "ouvre la banque", + "la banque des coquillages", + "ouvre la banque des coquillages", + "la boîte", + "ouvre la boîte", + "la boîte à coquillages", + "la boîte des coquillages", + "ouvre la boîte à coquillages", + "ouvre la boîte des coquillages", + "tableau de bord", + "retour à la banque", + "retour à l'accueil" + ] + }, + { + "name": "IdentifySpeakerIntent", + "slots": [ + { + "name": "ChildName", + "type": "CHILD_NAME" + } + ], + "samples": [ + "je suis {ChildName}", + "c'est {ChildName}", + "moi c'est {ChildName}", + "associe ma voix à {ChildName}", + "enregistre ma voix pour {ChildName}" + ] + }, + { + "name": "GetBalanceIntent", + "slots": [ + { + "name": "ChildName", + "type": "CHILD_NAME" + } + ], + "samples": [ + "combien j'ai de coquillages", + "bonjour combien j'ai de coquillages", + "combien ai-je de coquillages", + "j'ai combien de coquillages", + "combien j'ai", + "quel est mon solde", + "où en est mon compte", + "combien j'ai dans ma tirelire", + "combien de coquillages a {ChildName}", + "quel est le solde de {ChildName}", + "combien a {ChildName}", + "combien {ChildName} a de coquillages", + "où en est {ChildName}", + "quel est notre bilan", + "combien on a de coquillages en banque", + "quel est le solde de la famille", + "combien d'argent a {ChildName}" + ] + }, + { + "name": "ListActivitiesIntent", + "slots": [ + { + "name": "ShellCount", + "type": "AMAZON.NUMBER" + } + ], + "samples": [ + "quelles sont les activités", + "quelles sont les activités du mois", + "quelles sont les activités disponibles", + "qu'est-ce qu'on peut faire comme activité", + "que peut-on faire avec {ShellCount} coquillages", + "quelles activités on peut payer avec {ShellCount} coquillages", + "qu'est-ce qu'on peut acheter avec {ShellCount} coquillages", + "liste les activités" + ] + }, + { + "name": "DepositShellsIntent", + "slots": [ + { + "name": "ChildName", + "type": "CHILD_NAME" + }, + { + "name": "ShellCount", + "type": "AMAZON.NUMBER" + }, + { + "name": "AccountType", + "type": "ACCOUNT_TYPE" + } + ], + "samples": [ + "je veux bloquer {ShellCount} coquillages", + "je bloque {ShellCount} coquillages", + "je veux placer {ShellCount} coquillages", + "je dépose {ShellCount} coquillages", + "je place {ShellCount} coquillages sur mon compte {AccountType}", + "{ChildName} veut bloquer {ShellCount} coquillages", + "{ChildName} bloque {ShellCount} coquillages", + "{ChildName} veut placer {ShellCount} coquillages", + "{ChildName} place {ShellCount} coquillages sur son compte {AccountType}", + "{ChildName} veut déposer {ShellCount} coquillages sur son compte {AccountType}", + "{ChildName} dépose {ShellCount} coquillages", + "bloquer {ShellCount} coquillages pour {ChildName}", + "placer {ShellCount} coquillages pour {ChildName}" + ] + }, + { + "name": "ContributeActivityIntent", + "slots": [ + { + "name": "ChildName", + "type": "CHILD_NAME" + }, + { + "name": "ShellCount", + "type": "AMAZON.NUMBER" + }, + { + "name": "ActivityName", + "type": "ACTIVITY_NAME" + } + ], + "samples": [ + "je veux ajouter des coquillages sur {ActivityName}", + "je veux mettre des coquillages pour {ActivityName}", + "j'ajoute des coquillages pour {ActivityName}", + "je participe à {ActivityName}", + "{ChildName} veut ajouter des coquillages sur {ActivityName}", + "{ChildName} veut mettre des coquillages pour {ActivityName}", + "{ChildName} ajoute des coquillages sur {ActivityName}", + "{ChildName} participe à {ActivityName}", + "je mets {ShellCount} coquillages pour {ActivityName}", + "je donne {ShellCount} coquillages pour {ActivityName}", + "je veux mettre {ShellCount} coquillages sur {ActivityName}", + "je cotise {ShellCount} coquillages pour {ActivityName}", + "{ChildName} met {ShellCount} coquillages pour {ActivityName}", + "{ChildName} donne {ShellCount} coquillages pour {ActivityName}", + "{ChildName} veut mettre {ShellCount} coquillages sur {ActivityName}", + "mettre {ShellCount} coquillages de {ChildName} pour {ActivityName}", + "{ChildName} cotise {ShellCount} coquillages pour {ActivityName}" + ] + }, + { + "name": "ConvertEurosIntent", + "slots": [ + { + "name": "ChildName", + "type": "CHILD_NAME" + }, + { + "name": "ShellCount", + "type": "AMAZON.NUMBER" + } + ], + "samples": [ + "je veux convertir {ShellCount} coquillages en euros", + "je veux échanger {ShellCount} coquillages contre des euros", + "j'échange {ShellCount} coquillages en euros", + "je mets {ShellCount} coquillages dans ma tirelire en euros", + "{ChildName} veut convertir {ShellCount} coquillages en euros", + "{ChildName} veut échanger {ShellCount} coquillages contre des euros", + "{ChildName} échange {ShellCount} coquillages en euros", + "convertir {ShellCount} coquillages en euros pour {ChildName}", + "{ChildName} met {ShellCount} coquillages dans sa tirelire en euros" + ] + }, + { + "name": "SimulateInterestIntent", + "slots": [ + { + "name": "ShellCount", + "type": "AMAZON.NUMBER" + } + ], + "samples": [ + "combien rapportent {ShellCount} coquillages bloqués", + "quel est l'intérêt pour {ShellCount} coquillages", + "si je bloque {ShellCount} coquillages je gagne combien", + "calcule les intérêts pour {ShellCount} coquillages" + ] + }, + { + "name": "GetActivityDetailIntent", + "slots": [ + { + "name": "ActivityName", + "type": "ACTIVITY_NAME" + } + ], + "samples": [ + "affiche les détails de {ActivityName}", + "montre les détails de {ActivityName}", + "donne moi les détails de {ActivityName}", + "affiche l'activité {ActivityName}", + "montre moi l'activité {ActivityName}", + "où en est {ActivityName}", + "où en est l'activité {ActivityName}", + "combien il manque pour {ActivityName}", + "détails de {ActivityName}" + ] + }, + { + "name": "ProposeActivityIntent", + "slots": [ + { + "name": "ChildName", + "type": "CHILD_NAME" + }, + { + "name": "ActivityTitle", + "type": "AMAZON.SearchQuery" + } + ], + "samples": [ + "je propose l'activité {ActivityTitle}", + "je propose l'activité de {ActivityTitle}", + "je propose comme activité {ActivityTitle}", + "je propose {ActivityTitle}", + "je veux proposer l'activité {ActivityTitle}", + "je voudrais proposer l'activité {ActivityTitle}", + "je veux proposer comme activité {ActivityTitle}", + "je voudrais proposer {ActivityTitle}", + "je veux proposer {ActivityTitle}", + "propose l'activité {ActivityTitle}", + "propose comme activité {ActivityTitle}", + "proposer l'activité {ActivityTitle}", + "nouvelle activité {ActivityTitle}", + "ajoute l'activité {ActivityTitle}", + "je veux faire l'activité {ActivityTitle}", + "on pourrait faire l'activité {ActivityTitle}", + "on pourrait faire {ActivityTitle}", + "{ChildName} propose l'activité {ActivityTitle}", + "{ChildName} propose comme activité {ActivityTitle}", + "{ChildName} propose {ActivityTitle}", + "{ChildName} veut proposer l'activité {ActivityTitle}", + "{ChildName} veut proposer {ActivityTitle}" + ] + } + ], + "types": [ + { + "name": "CHILD_NAME", + "values": [ + { + "id": "hailey", + "name": { + "value": "Hailey", + "synonyms": [ + "Hailey", + "Haley", + "Ailey", + "Élie", + "Hély" + ] + } + }, + { + "id": "violette", + "name": { + "value": "Violette", + "synonyms": [ + "Violette", + "Violet" + ] + } + }, + { + "id": "andreas", + "name": { + "value": "Andreas", + "synonyms": [ + "Andreas", + "Andréas", + "Andrea", + "Andréa" + ] + } + } + ] + }, + { + "name": "ACCOUNT_TYPE", + "values": [ + { + "id": "bloque", + "name": { + "value": "bloqué", + "synonyms": [ + "bloqué", + "épargne", + "livret", + "intérêts" + ] + } + }, + { + "id": "disponible", + "name": { + "value": "disponible", + "synonyms": [ + "disponible", + "courant", + "normal", + "virtuel" + ] + } + } + ] + }, + { + "name": "ACTIVITY_NAME", + "values": [ + { + "name": { + "value": "Soirée Pizza et Cinéma", + "synonyms": [ + "pizza", + "cinéma", + "soirée pizza", + "film" + ] + } + }, + { + "name": { + "value": "Bowling en famille", + "synonyms": [ + "bowling", + "le bowling" + ] + } + }, + { + "name": { + "value": "Parc Aquatique", + "synonyms": [ + "parc aquatique", + "piscine", + "toboggans" + ] + } + }, + { + "name": { + "value": "Sortie Accrobranche", + "synonyms": [ + "accrobranche", + "l'accrobranche", + "tyrolienne" + ] + } + } + ] + } + ] + } + } +} + diff --git a/skill/lambda/index.js b/skill/lambda/index.js new file mode 100644 index 0000000..c757514 --- /dev/null +++ b/skill/lambda/index.js @@ -0,0 +1,1197 @@ +import Alexa from 'ask-sdk-core'; + +const BACKEND_API_URL = process.env.BACKEND_API_URL || 'http://localhost:4050'; +const FALLBACK_HTTPS_IMAGE = + 'https://images.unsplash.com/photo-1507525428034-b723cf961d3e?auto=format&fit=crop&w=1000&q=80'; + +/** + * Récupère les informations du profil vocal reconnu par Alexa (Skill Personalization / Voice ID) + */ +async function getPersonInfo(handlerInput) { + const system = handlerInput.requestEnvelope?.context?.System; + const person = system?.person; + const personId = person?.personId || null; + let personGivenName = null; + + if (personId && system?.apiEndpoint && system?.apiAccessToken) { + try { + const res = await fetch(`${system.apiEndpoint}/v2/persons/~current/profile/givenName`, { + headers: { + Authorization: `Bearer ${system.apiAccessToken}`, + Accept: 'application/json' + } + }); + if (res.ok) { + const nameData = await res.json(); + if (typeof nameData === 'string') { + personGivenName = nameData; + } + } + } catch { + // Si la permission Given Name n'est pas cochée, on utilise personId mémorisé dans SQLite + } + } + + if (personId) { + console.log(`🗣️ [ALEXA VOICE ID] Locuteur détecté: personId=${personId}, givenName=${personGivenName || 'non fourni'}`); + } + + return { personId, personGivenName }; +} + +/** + * Appelle le moteur de la Banque des Coquillages (dossier back/) + */ +async function callBackendVoiceEngine(intent, slots = {}, handlerInput = null) { + const publicBaseUrl = handlerInput?.requestEnvelope?._publicBaseUrl || ''; + const personInfo = handlerInput ? await getPersonInfo(handlerInput) : { personId: null, personGivenName: null }; + + const response = await fetch(`${BACKEND_API_URL}/api/alexa/voice-intent`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + intent, + slots, + publicBaseUrl, + personId: personInfo.personId, + personGivenName: personInfo.personGivenName + }) + }); + if (!response.ok) { + throw new Error(`Erreur API Backend: ${response.status}`); + } + return response.json(); +} + +function getSlotValue(handlerInput, slotName) { + const slot = Alexa.getSlot(handlerInput.requestEnvelope, slotName); + if (!slot) return null; + const resolution = slot.resolutions?.resolutionsPerAuthority?.[0]; + if (resolution?.status?.code === 'ER_SUCCESS_MATCH') { + return resolution.values[0].value.name; + } + return slot.value || null; +} + +/** + * Nettoie le texte vocal pour éviter que les caractères spéciaux XML (&, <, >) + * ne fassent planter le lecteur SSML (...) d'Amazon Alexa + */ +function sanitizeSpeech(text = '') { + return String(text) + .replace(/&/g, ' et ') + .replace(//g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +function supportsAPL(handlerInput) { + try { + const supportedInterfaces = Alexa.getSupportedInterfaces(handlerInput.requestEnvelope); + return Boolean(supportedInterfaces && supportedInterfaces['Alexa.Presentation.APL']); + } catch { + return false; + } +} + +function ensureHttpsImage(url, publicBaseUrl = '') { + if (typeof url !== 'string' || !url) return FALLBACK_HTTPS_IMAGE; + if (url.startsWith('https://')) return url; + if (publicBaseUrl && url.startsWith('http://localhost:4050')) { + return url.replace('http://localhost:4050', publicBaseUrl); + } + return FALLBACK_HTTPS_IMAGE; +} + +/** + * Construit l'écran APL interactif (100% tactile avec TouchWrapper + SendEvent) : + * - Mode DASHBOARD : Enfants (Hailey, Violette, Andreas) + Banque + Carrousel d'Activités cliquables + * - Mode ACTIVITY_DETAIL : Détails complets d'une activité + qui a cotisé + boutons tactiles [+2 🐚] + * - Mode CHILD_DETAIL : Détails du compte d'un enfant + bouton tactile [Bloquer 5 🐚] + */ +function buildInteractiveAPLDocument(data, publicBaseUrl) { + const children = Array.isArray(data.children) ? data.children : []; + const activities = Array.isArray(data.activities) ? data.activities : []; + const rate = data.settings?.interest_rate_percent ?? 20; + const viewMode = data.viewMode || 'DASHBOARD'; + + // ==================== VUE 1 : DÉTAIL D'UNE ACTIVITÉ (CLIC OU VOIX) ==================== + if (viewMode === 'ACTIVITY_DETAIL' && data.selectedActivity) { + const act = data.selectedActivity; + const actImage = ensureHttpsImage(act.image_url, publicBaseUrl); + const contribs = Array.isArray(act.contributions) ? act.contributions : []; + const contribSummary = + contribs.length > 0 + ? contribs.map((c) => `${c.avatar} ${c.child_name}: ${c.total_shells} 🐚`).join(' • ') + : 'Aucun coquillage déposé pour le moment'; + + return { + type: 'APL', + version: '1.8', + mainTemplate: { + parameters: ['payload'], + items: [ + { + type: 'Frame', + width: '100vw', + height: '100vh', + backgroundColor: '#0B1120', + item: { + type: 'Container', + width: '100vw', + height: '100vh', + paddingLeft: '28dp', + paddingRight: '28dp', + paddingTop: '20dp', + paddingBottom: '20dp', + items: [ + // Barre du haut avec bouton Retour cliquable + { + type: 'Container', + direction: 'row', + justifyContent: 'spaceBetween', + alignItems: 'center', + width: '100%', + paddingBottom: '14dp', + items: [ + { + type: 'TouchWrapper', + onPress: [{ type: 'SendEvent', arguments: ['VIEW_DASHBOARD'] }], + item: { + type: 'Frame', + backgroundColor: '#1E293B', + borderRadius: '12dp', + borderWidth: '1dp', + borderColor: '#38BDF8', + paddingLeft: '16dp', + paddingRight: '16dp', + paddingTop: '8dp', + paddingBottom: '8dp', + item: { + type: 'Text', + text: '⬅ Retour à la Banque', + color: '#38BDF8', + fontSize: '18dp', + fontWeight: '700' + } + } + }, + { + type: 'Text', + text: `🐚 DÉTAIL DE L'ACTIVITÉ • OBJECTIF ${act.cost_shells} COQUILLAGES`, + color: '#FBBF24', + fontSize: '18dp', + fontWeight: '700' + } + ] + }, + + // Zone centrale : Photo à gauche + Infos & Progression à droite + { + type: 'Container', + direction: 'row', + width: '100%', + height: '62%', + items: [ + { + type: 'Image', + source: actImage, + scale: 'best-fill', + width: '44%', + height: '100%', + borderRadius: '18dp' + }, + { + type: 'Container', + width: '56%', + height: '100%', + paddingLeft: '24dp', + justifyContent: 'center', + items: [ + { + type: 'Text', + text: `${act.emoji} ${act.title}`, + color: '#FFFFFF', + fontSize: '32dp', + fontWeight: '800', + maxLines: 2 + }, + { + type: 'Text', + text: act.description || 'Activité commune de la famille', + color: '#CBD5E1', + fontSize: '18dp', + paddingTop: '6dp', + maxLines: 2 + }, + { + type: 'Frame', + backgroundColor: '#1E293B', + borderRadius: '14dp', + paddingLeft: '16dp', + paddingRight: '16dp', + paddingTop: '12dp', + paddingBottom: '12dp', + item: { + type: 'Container', + items: [ + { + type: 'Text', + text: `Pot commun : ${act.current_shells} / ${act.cost_shells} 🐚 (${act.progress_percent}%)`, + color: '#FBBF24', + fontSize: '22dp', + fontWeight: '700' + }, + { + type: 'Text', + text: + act.remaining_shells === 0 + ? '🎉 Activité débloquée !' + : `Il manque encore ${act.remaining_shells} coquillages pour la débloquer`, + color: '#38BDF8', + fontSize: '17dp', + paddingTop: '4dp' + }, + { + type: 'Text', + text: contribSummary, + color: '#E2E8F0', + fontSize: '16dp', + paddingTop: '8dp', + maxLines: 2 + } + ] + } + } + ] + } + ] + }, + + // Zone du bas : Boutons tactiles pour déclarer l'ajout de coquillages (verrouillés après validation !) + { + type: 'Container', + direction: 'row', + justifyContent: 'spaceBetween', + alignItems: 'center', + width: '100%', + paddingTop: '14dp', + items: children.map((child) => { + const isLocked = Array.isArray(act.locked_child_ids) && act.locked_child_ids.includes(child.id); + if (isLocked) { + return { + type: 'Frame', + width: '31%', + backgroundColor: '#1E293B', + borderRadius: '14dp', + borderWidth: '1dp', + borderColor: '#475569', + paddingTop: '12dp', + paddingBottom: '12dp', + paddingLeft: '10dp', + paddingRight: '10dp', + item: { + type: 'Text', + text: `🔒 ${child.name} : En attente`, + color: '#94A3B8', + fontSize: '18dp', + fontWeight: '700', + textAlign: 'center' + } + }; + } + return { + type: 'TouchWrapper', + width: '31%', + onPress: [ + { + type: 'SendEvent', + arguments: ['TOUCH_CONTRIBUTE', String(act.id), String(child.id)] + } + ], + item: { + type: 'Frame', + backgroundColor: '#0284C7', + borderRadius: '14dp', + paddingTop: '12dp', + paddingBottom: '12dp', + paddingLeft: '10dp', + paddingRight: '10dp', + item: { + type: 'Text', + text: `${child.avatar} ${child.name} : Ajouter 🐚`, + color: '#FFFFFF', + fontSize: '18dp', + fontWeight: '700', + textAlign: 'center' + } + } + }; + }) + } + ] + } + } + ] + } + }; + } + + // ==================== VUE 2 : DÉTAIL DU COMPTE D'UN ENFANT ==================== + if (viewMode === 'CHILD_DETAIL' && data.selectedChild) { + const c = data.selectedChild; + return { + type: 'APL', + version: '1.8', + mainTemplate: { + parameters: ['payload'], + items: [ + { + type: 'Frame', + width: '100vw', + height: '100vh', + backgroundColor: '#0B1120', + item: { + type: 'Container', + width: '100vw', + height: '100vh', + paddingLeft: '32dp', + paddingRight: '32dp', + paddingTop: '24dp', + paddingBottom: '24dp', + items: [ + { + type: 'Container', + direction: 'row', + justifyContent: 'spaceBetween', + alignItems: 'center', + width: '100%', + paddingBottom: '20dp', + items: [ + { + type: 'TouchWrapper', + onPress: [{ type: 'SendEvent', arguments: ['VIEW_DASHBOARD'] }], + item: { + type: 'Frame', + backgroundColor: '#1E293B', + borderRadius: '12dp', + borderWidth: '1dp', + borderColor: '#38BDF8', + paddingLeft: '16dp', + paddingRight: '16dp', + paddingTop: '8dp', + paddingBottom: '8dp', + item: { + type: 'Text', + text: '⬅ Retour à la Banque', + color: '#38BDF8', + fontSize: '18dp', + fontWeight: '700' + } + } + }, + { + type: 'Text', + text: `${c.avatar} COMPTE BANCAIRE DE ${c.name.toUpperCase()}`, + color: '#FBBF24', + fontSize: '24dp', + fontWeight: '800' + } + ] + }, + + // 3 grandes cartes pour l'enfant : Disponible, Livret Bloqué (+Intérêts), Tirelire Euros + { + type: 'Container', + direction: 'row', + justifyContent: 'spaceBetween', + width: '100%', + height: '55%', + items: [ + { + type: 'Frame', + width: '31%', + height: '100%', + backgroundColor: '#1E293B', + borderRadius: '18dp', + borderWidth: '2dp', + borderColor: '#FBBF24', + paddingLeft: '18dp', + paddingRight: '18dp', + paddingTop: '18dp', + item: { + type: 'Container', + items: [ + { + type: 'Text', + text: '🐚 DISPONIBLES', + color: '#FBBF24', + fontSize: '18dp', + fontWeight: '700' + }, + { + type: 'Text', + text: `${c.available_shells} 🐚`, + color: '#FFFFFF', + fontSize: '44dp', + fontWeight: '800', + paddingTop: '12dp' + }, + { + type: 'Text', + text: 'Prêts pour les activités ou euros', + color: '#94A3B8', + fontSize: '15dp', + paddingTop: '8dp' + } + ] + } + }, + { + type: 'Frame', + width: '31%', + height: '100%', + backgroundColor: '#1E293B', + borderRadius: '18dp', + borderWidth: '2dp', + borderColor: '#38BDF8', + paddingLeft: '18dp', + paddingRight: '18dp', + paddingTop: '18dp', + item: { + type: 'Container', + items: [ + { + type: 'Text', + text: `🔒 LIVRET BLOQUÉ (+${rate}%)`, + color: '#38BDF8', + fontSize: '18dp', + fontWeight: '700' + }, + { + type: 'Text', + text: `${c.locked_shells} 🔒`, + color: '#FFFFFF', + fontSize: '44dp', + fontWeight: '800', + paddingTop: '12dp' + }, + { + type: 'Text', + text: `✨ +${c.projected_interest_shells} 🐚 gratuits fin de mois !`, + color: '#34D399', + fontSize: '16dp', + fontWeight: '700', + paddingTop: '8dp' + } + ] + } + }, + { + type: 'Frame', + width: '31%', + height: '100%', + backgroundColor: '#1E293B', + borderRadius: '18dp', + borderWidth: '2dp', + borderColor: '#34D399', + paddingLeft: '18dp', + paddingRight: '18dp', + paddingTop: '18dp', + item: { + type: 'Container', + items: [ + { + type: 'Text', + text: '💶 TIRELIRE EUROS', + color: '#34D399', + fontSize: '18dp', + fontWeight: '700' + }, + { + type: 'Text', + text: `${Number(c.euros_balance).toFixed(2)} €`, + color: '#FFFFFF', + fontSize: '44dp', + fontWeight: '800', + paddingTop: '12dp' + }, + { + type: 'Text', + text: `Reste ${c.remaining_shells_to_convert} 🐚 convertibles ce mois`, + color: '#94A3B8', + fontSize: '15dp', + paddingTop: '8dp' + } + ] + } + } + ] + }, + + // Boutons d'action tactiles pour cet enfant + { + type: 'Container', + direction: 'row', + justifyContent: 'spaceBetween', + width: '100%', + paddingTop: '18dp', + items: [ + { + type: 'TouchWrapper', + width: '48%', + onPress: [{ type: 'SendEvent', arguments: ['TOUCH_LOCK', String(c.id), '4'] }], + item: { + type: 'Frame', + backgroundColor: '#0284C7', + borderRadius: '14dp', + paddingTop: '14dp', + paddingBottom: '14dp', + item: { + type: 'Text', + text: `🔒 Demander à bloquer 4 coquillages (+${rate}%/mois)`, + color: '#FFFFFF', + fontSize: '19dp', + fontWeight: '700', + textAlign: 'center' + } + } + }, + { + type: 'TouchWrapper', + width: '48%', + onPress: [{ type: 'SendEvent', arguments: ['TOUCH_LOCK', String(c.id), '8'] }], + item: { + type: 'Frame', + backgroundColor: '#059669', + borderRadius: '14dp', + paddingTop: '14dp', + paddingBottom: '14dp', + item: { + type: 'Text', + text: `🔒 Demander à bloquer 8 coquillages (+${rate}%/mois)`, + color: '#FFFFFF', + fontSize: '19dp', + fontWeight: '700', + textAlign: 'center' + } + } + } + ] + } + ] + } + } + ] + } + }; + } + + // ==================== VUE 3 : DASHBOARD COMPLET (BANQUE + 3 ENFANTS + ACTIVITÉS CLIQUABLES) ==================== + return { + type: 'APL', + version: '1.8', + mainTemplate: { + parameters: ['payload'], + items: [ + { + type: 'Frame', + width: '100vw', + height: '100vh', + backgroundColor: '#0B1120', + item: { + type: 'Container', + width: '100vw', + height: '100vh', + paddingLeft: '24dp', + paddingRight: '24dp', + paddingTop: '16dp', + paddingBottom: '16dp', + items: [ + // 1. En-tête de la Banque + { + type: 'Container', + direction: 'row', + justifyContent: 'spaceBetween', + alignItems: 'center', + width: '100%', + paddingBottom: '10dp', + items: [ + { + type: 'Text', + text: '🐚 LA BANQUE DES COQUILLAGES', + color: '#FBBF24', + fontSize: '22dp', + fontWeight: '800' + }, + { + type: 'Text', + text: `📈 Livret Épargne : +${rate}% / mois • Touchez un enfant ou une activité`, + color: '#38BDF8', + fontSize: '16dp', + fontWeight: '700' + } + ] + }, + + // 2. Les 3 Cartes Enfants Cliquables (Hailey, Violette, Andreas) + { + type: 'Container', + direction: 'row', + justifyContent: 'spaceBetween', + width: '100%', + height: '28%', + items: children.map((c) => ({ + type: 'TouchWrapper', + width: '32%', + height: '100%', + onPress: [{ type: 'SendEvent', arguments: ['VIEW_CHILD', String(c.id)] }], + item: { + type: 'Frame', + width: '100%', + height: '100%', + backgroundColor: '#1E293B', + borderRadius: '14dp', + borderWidth: '1dp', + borderColor: '#334155', + paddingLeft: '14dp', + paddingRight: '14dp', + paddingTop: '10dp', + paddingBottom: '10dp', + item: { + type: 'Container', + justifyContent: 'spaceBetween', + items: [ + { + type: 'Container', + direction: 'row', + justifyContent: 'spaceBetween', + alignItems: 'center', + items: [ + { + type: 'Text', + text: `${c.avatar} ${c.name}`, + color: '#FFFFFF', + fontSize: '21dp', + fontWeight: '800' + }, + { + type: 'Text', + text: `${Number(c.euros_balance).toFixed(2)} €`, + color: '#34D399', + fontSize: '17dp', + fontWeight: '700' + } + ] + }, + { + type: 'Text', + text: `${c.available_shells} 🐚 dispo • ${c.locked_shells} 🔒 bloqués`, + color: '#FBBF24', + fontSize: '16dp', + fontWeight: '700' + }, + { + type: 'Text', + text: `Intérêts fin de mois : +${c.projected_interest_shells} 🐚 gratuits`, + color: '#38BDF8', + fontSize: '13dp' + } + ] + } + } + })) + }, + + // 3. Titre section Activités + { + type: 'Text', + text: '🎡 ACTIVITÉS COMMUNES DU MOIS (Touchez une photo pour voir les détails et cotiser)', + color: '#E2E8F0', + fontSize: '16dp', + fontWeight: '700', + paddingTop: '10dp', + paddingBottom: '8dp' + }, + + // 4. Carrousel Horizontal des Activités Illustrées Cliquables + Carte Proposer une activité + { + type: 'Sequence', + scrollDirection: 'horizontal', + width: '100%', + height: '52%', + items: [ + ...activities.map((act) => ({ + type: 'TouchWrapper', + width: '300dp', + height: '100%', + paddingRight: '16dp', + onPress: [{ type: 'SendEvent', arguments: ['VIEW_ACTIVITY', String(act.id)] }], + item: { + type: 'Frame', + width: '284dp', + height: '100%', + backgroundColor: '#1E293B', + borderRadius: '16dp', + borderWidth: '1dp', + borderColor: '#475569', + item: { + type: 'Container', + width: '100%', + height: '100%', + items: [ + { + type: 'Image', + source: ensureHttpsImage(act.image_url, publicBaseUrl), + scale: 'best-fill', + width: '100%', + height: '58%', + borderRadius: '16dp' + }, + { + type: 'Container', + paddingLeft: '12dp', + paddingRight: '12dp', + paddingTop: '8dp', + items: [ + { + type: 'Text', + text: `${act.emoji} ${act.title}`, + color: '#FFFFFF', + fontSize: '19dp', + fontWeight: '800', + maxLines: 1 + }, + { + type: 'Text', + text: `Pot : ${act.current_shells} / ${act.cost_shells} 🐚 (${act.progress_percent}%)`, + color: '#FBBF24', + fontSize: '16dp', + fontWeight: '700', + paddingTop: '2dp', + maxLines: 1 + }, + { + type: 'Text', + text: + act.remaining_shells === 0 + ? '🎉 Débloquée ! Toucher pour ouvrir' + : `Manque ${act.remaining_shells} 🐚 • Toucher pour détails`, + color: '#38BDF8', + fontSize: '13dp', + paddingTop: '2dp', + maxLines: 1 + } + ] + } + ] + } + } + })), + { + type: 'TouchWrapper', + width: '260dp', + height: '100%', + paddingRight: '16dp', + onPress: [{ type: 'SendEvent', arguments: ['TOUCH_PROPOSE_ACTIVITY'] }], + item: { + type: 'Frame', + width: '244dp', + height: '100%', + backgroundColor: '#1E293B', + borderRadius: '16dp', + borderWidth: '2dp', + borderColor: '#F59E0B', + item: { + type: 'Container', + width: '100%', + height: '100%', + justifyContent: 'center', + alignItems: 'center', + paddingLeft: '16dp', + paddingRight: '16dp', + items: [ + { + type: 'Text', + text: '💡', + fontSize: '44dp' + }, + { + type: 'Text', + text: 'Proposer une activité', + color: '#FBBF24', + fontSize: '18dp', + fontWeight: '800', + paddingTop: '8dp', + textAlign: 'center' + }, + { + type: 'Text', + text: 'Toucher ou dire :\n« Je propose l\'activité... »', + color: '#38BDF8', + fontSize: '13dp', + paddingTop: '6dp', + textAlign: 'center' + } + ] + } + } + } + ] + } + ] + } + } + ] + } + }; +} + +/** + * Construit la réponse vocale + StandardCard + Écran interactif APL + */ +function buildVisualResponse(handlerInput, data, repromptText) { + const publicBaseUrl = handlerInput.requestEnvelope?._publicBaseUrl || ''; + const cleanSpeech = sanitizeSpeech(data.speech); + const builder = handlerInput.responseBuilder.speak(cleanSpeech); + + if (repromptText) { + builder.reprompt(sanitizeSpeech(repromptText)); + } + + const aplSupported = supportsAPL(handlerInput); + + if (data.card) { + const safeImageUrl = ensureHttpsImage(data.card.imageUrl, publicBaseUrl); + const cardTitle = String(data.card.title || 'La Banque des Coquillages'); + const cardSubtitle = String(data.card.subtitle || ''); + const cardText = String(data.card.text || ''); + const cardBody = `${cardSubtitle ? cardSubtitle + '\n' : ''}${cardText}`.trim(); + + builder.withStandardCard(cardTitle, cardBody, safeImageUrl, safeImageUrl); + } + + if (aplSupported) { + const aplDocument = buildInteractiveAPLDocument(data, publicBaseUrl); + builder.addDirective({ + type: 'Alexa.Presentation.APL.RenderDocument', + token: 'banqueCoquillagesVisualToken', + document: aplDocument, + datasources: { + payload: {} + } + }); + } + + return builder.getResponse(); +} + +const LaunchRequestHandler = { + canHandle(handlerInput) { + const reqType = Alexa.getRequestType(handlerInput.requestEnvelope); + if (reqType === 'LaunchRequest') return true; + if (reqType === 'IntentRequest') { + const intentName = Alexa.getIntentName(handlerInput.requestEnvelope); + return intentName === 'OpenBankIntent' || intentName === 'AMAZON.NavigateHomeIntent'; + } + return false; + }, + async handle(handlerInput) { + const data = await callBackendVoiceEngine('LaunchRequest', {}, handlerInput); + return buildVisualResponse( + handlerInput, + data, + 'Tu peux toucher un prénom ou une activité sur l\'écran, ou me demander combien tu as de coquillages.' + ); + } +}; + +const IdentifySpeakerIntentHandler = { + canHandle(handlerInput) { + return ( + Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest' && + Alexa.getIntentName(handlerInput.requestEnvelope) === 'IdentifySpeakerIntent' + ); + }, + async handle(handlerInput) { + const ChildName = getSlotValue(handlerInput, 'ChildName'); + const data = await callBackendVoiceEngine('IdentifySpeakerIntent', { ChildName }, handlerInput); + return buildVisualResponse( + handlerInput, + data, + 'Que veux-tu faire maintenant avec tes coquillages ?' + ); + } +}; + +const GetActivityDetailIntentHandler = { + canHandle(handlerInput) { + return ( + Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest' && + Alexa.getIntentName(handlerInput.requestEnvelope) === 'GetActivityDetailIntent' + ); + }, + async handle(handlerInput) { + const ActivityName = getSlotValue(handlerInput, 'ActivityName'); + const data = await callBackendVoiceEngine('GetActivityDetailIntent', { ActivityName }, handlerInput); + return buildVisualResponse( + handlerInput, + data, + 'Veux-tu mettre des coquillages sur cette activité ou revenir à la banque ?' + ); + } +}; + +/** + * Gère tous les clics / touches sur l'écran Alexa Echo Show (TouchWrapper -> SendEvent) + */ +const APLUserEventHandler = { + canHandle(handlerInput) { + return Alexa.getRequestType(handlerInput.requestEnvelope) === 'Alexa.Presentation.APL.UserEvent'; + }, + async handle(handlerInput) { + const args = handlerInput.requestEnvelope.request.arguments || []; + console.log('👆 [ALEXA TOUCH EVENT] Clic sur l\'écran:', args); + const data = await callBackendVoiceEngine('APLTouchEvent', { arguments: args }, handlerInput); + return buildVisualResponse( + handlerInput, + data, + 'Que souhaites-tu faire maintenant ?' + ); + } +}; + +const GetBalanceIntentHandler = { + canHandle(handlerInput) { + return ( + Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest' && + Alexa.getIntentName(handlerInput.requestEnvelope) === 'GetBalanceIntent' + ); + }, + async handle(handlerInput) { + const ChildName = getSlotValue(handlerInput, 'ChildName'); + const data = await callBackendVoiceEngine('GetBalanceIntent', { ChildName }, handlerInput); + return buildVisualResponse( + handlerInput, + data, + 'Veux-tu consulter les activités ou placer des coquillages ?' + ); + } +}; + +const ListActivitiesIntentHandler = { + canHandle(handlerInput) { + return ( + Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest' && + Alexa.getIntentName(handlerInput.requestEnvelope) === 'ListActivitiesIntent' + ); + }, + async handle(handlerInput) { + const ShellCount = getSlotValue(handlerInput, 'ShellCount'); + const data = await callBackendVoiceEngine('ListActivitiesIntent', { ShellCount }, handlerInput); + return buildVisualResponse( + handlerInput, + data, + 'Touche une activité sur l\'écran ou dis-moi son nom pour voir ses détails !' + ); + } +}; + +const DepositShellsIntentHandler = { + canHandle(handlerInput) { + return ( + Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest' && + Alexa.getIntentName(handlerInput.requestEnvelope) === 'DepositShellsIntent' + ); + }, + async handle(handlerInput) { + const ChildName = getSlotValue(handlerInput, 'ChildName'); + const ShellCount = getSlotValue(handlerInput, 'ShellCount'); + const AccountType = getSlotValue(handlerInput, 'AccountType') || 'bloqué'; + + const data = await callBackendVoiceEngine( + 'DepositShellsIntent', + { + ChildName, + ShellCount, + AccountType + }, + handlerInput + ); + + return buildVisualResponse(handlerInput, data, 'Autre chose ?'); + } +}; + +const ContributeActivityIntentHandler = { + canHandle(handlerInput) { + return ( + Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest' && + Alexa.getIntentName(handlerInput.requestEnvelope) === 'ContributeActivityIntent' + ); + }, + async handle(handlerInput) { + const ChildName = getSlotValue(handlerInput, 'ChildName'); + const ShellCount = getSlotValue(handlerInput, 'ShellCount'); + const ActivityName = getSlotValue(handlerInput, 'ActivityName'); + + const data = await callBackendVoiceEngine( + 'ContributeActivityIntent', + { + ChildName, + ShellCount, + ActivityName + }, + handlerInput + ); + + return buildVisualResponse(handlerInput, data, 'Une autre opération ?'); + } +}; + +const ConvertEurosIntentHandler = { + canHandle(handlerInput) { + return ( + Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest' && + Alexa.getIntentName(handlerInput.requestEnvelope) === 'ConvertEurosIntent' + ); + }, + async handle(handlerInput) { + const ChildName = getSlotValue(handlerInput, 'ChildName'); + const ShellCount = getSlotValue(handlerInput, 'ShellCount'); + + const data = await callBackendVoiceEngine( + 'ConvertEurosIntent', + { + ChildName, + ShellCount + }, + handlerInput + ); + + return buildVisualResponse(handlerInput, data, 'Autre chose ?'); + } +}; + +const SimulateInterestIntentHandler = { + canHandle(handlerInput) { + return ( + Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest' && + Alexa.getIntentName(handlerInput.requestEnvelope) === 'SimulateInterestIntent' + ); + }, + async handle(handlerInput) { + const ShellCount = getSlotValue(handlerInput, 'ShellCount') || '10'; + const data = await callBackendVoiceEngine('SimulateInterestIntent', { ShellCount }, handlerInput); + return buildVisualResponse( + handlerInput, + data, + 'Veux-tu bloquer tes coquillages maintenant ?' + ); + } +}; + +const ProposeActivityIntentHandler = { + canHandle(handlerInput) { + return ( + Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest' && + Alexa.getIntentName(handlerInput.requestEnvelope) === 'ProposeActivityIntent' + ); + }, + async handle(handlerInput) { + const ChildName = getSlotValue(handlerInput, 'ChildName'); + const ActivityTitle = getSlotValue(handlerInput, 'ActivityTitle'); + + const data = await callBackendVoiceEngine( + 'ProposeActivityIntent', + { + ChildName, + ActivityTitle + }, + handlerInput + ); + + return buildVisualResponse( + handlerInput, + data, + 'Veux-tu voir les activités en cours ou poser une autre question ?' + ); + } +}; + +const HelpIntentHandler = { + canHandle(handlerInput) { + return ( + Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest' && + Alexa.getIntentName(handlerInput.requestEnvelope) === 'AMAZON.HelpIntent' + ); + }, + handle(handlerInput) { + const speechText = + 'Dans la banque des coquillages, Hailey, Violette et Andreas peuvent toucher l\'écran ou utiliser la voix pour voir leur solde, afficher les détails d\'une activité, cotiser pour une activité commune, convertir des coquillages en euros, ou les bloquer pour gagner des intérêts !'; + return handlerInput.responseBuilder.speak(speechText).reprompt(speechText).getResponse(); + } +}; + +const CancelAndStopIntentHandler = { + canHandle(handlerInput) { + return ( + Alexa.getRequestType(handlerInput.requestEnvelope) === 'IntentRequest' && + (Alexa.getIntentName(handlerInput.requestEnvelope) === 'AMAZON.CancelIntent' || + Alexa.getIntentName(handlerInput.requestEnvelope) === 'AMAZON.StopIntent') + ); + }, + handle(handlerInput) { + return handlerInput.responseBuilder + .speak('À bientôt dans la banque des coquillages ! Continuez à bien récolter vos coquillages Hailey, Violette et Andreas !') + .getResponse(); + } +}; + +const SessionEndedRequestHandler = { + canHandle(handlerInput) { + return Alexa.getRequestType(handlerInput.requestEnvelope) === 'SessionEndedRequest'; + }, + handle(handlerInput) { + const error = handlerInput.requestEnvelope?.request?.error; + if (error) { + console.error('⚠️ Alexa SessionEnded Error:', JSON.stringify(error)); + } + return handlerInput.responseBuilder.getResponse(); + } +}; + +const ErrorHandler = { + canHandle() { + return true; + }, + handle(handlerInput, error) { + console.error(`Erreur Skill Alexa: ${error.message}`); + return handlerInput.responseBuilder + .speak('Oups, je n\'ai pas réussi à joindre le serveur de la banque des coquillages. Vérifie que le serveur est bien allumé !') + .getResponse(); + } +}; + +const skillBuilder = Alexa.SkillBuilders.custom() + .addRequestHandlers( + LaunchRequestHandler, + IdentifySpeakerIntentHandler, + GetActivityDetailIntentHandler, + APLUserEventHandler, + GetBalanceIntentHandler, + ListActivitiesIntentHandler, + DepositShellsIntentHandler, + ContributeActivityIntentHandler, + ConvertEurosIntentHandler, + SimulateInterestIntentHandler, + ProposeActivityIntentHandler, + HelpIntentHandler, + CancelAndStopIntentHandler, + SessionEndedRequestHandler + ) + .addErrorHandlers(ErrorHandler); + +export const skill = skillBuilder.create(); +export const handler = skillBuilder.lambda(); diff --git a/skill/lambda/local-test.js b/skill/lambda/local-test.js new file mode 100644 index 0000000..037045f --- /dev/null +++ b/skill/lambda/local-test.js @@ -0,0 +1,47 @@ +import { handler } from './index.js'; + +function buildIntentEvent(intentName, slots = {}) { + const formattedSlots = {}; + for (const [k, v] of Object.entries(slots)) { + formattedSlots[k] = { name: k, value: String(v) }; + } + return { + version: '1.0', + session: { new: false, sessionId: 'SessionId.test', application: { applicationId: 'amzn1.ask.skill.test' } }, + context: { System: { application: { applicationId: 'amzn1.ask.skill.test' } } }, + request: { + type: 'IntentRequest', + requestId: 'EdwRequestId.test', + timestamp: new Date().toISOString(), + locale: 'fr-FR', + intent: { + name: intentName, + confirmationStatus: 'NONE', + slots: formattedSlots + } + } + }; +} + +async function runTests() { + console.log('🎙️ Test local du Skill Alexa "La Banque des Coquillages"...\n'); + const tests = [ + { label: 'Solde de Violette', event: buildIntentEvent('GetBalanceIntent', { ChildName: 'Violette' }) }, + { label: 'Activités du mois', event: buildIntentEvent('ListActivitiesIntent', {}) }, + { label: 'Simulation intérêts (10 coquillages)', event: buildIntentEvent('SimulateInterestIntent', { ShellCount: 10 }) } + ]; + + for (const t of tests) { + try { + const res = await new Promise((resolve, reject) => { + handler(t.event, {}, (err, response) => (err ? reject(err) : resolve(response))); + }); + console.log(`✅ [${t.label}] ->`, res.response.outputSpeech.ssml); + } catch (e) { + console.error(`❌ [${t.label}] -> Erreur (assurez-vous que 'back' tourne sur le port 4000):`, e.message); + } + } +} + +runTests(); + diff --git a/skill/lambda/package.json b/skill/lambda/package.json new file mode 100644 index 0000000..3407690 --- /dev/null +++ b/skill/lambda/package.json @@ -0,0 +1,15 @@ +{ + "name": "banque-coquillages-skill-lambda", + "version": "1.0.0", + "description": "AWS Lambda / Alexa Skill Handler pour La Banque des Coquillages (Hailey, Violette et Andreas)", + "type": "module", + "main": "index.js", + "scripts": { + "test": "node local-test.js" + }, + "dependencies": { + "ask-sdk-core": "^2.14.0", + "ask-sdk-model": "^1.40.0" + } +} + diff --git a/skill/skill.json b/skill/skill.json new file mode 100644 index 0000000..423900b --- /dev/null +++ b/skill/skill.json @@ -0,0 +1,43 @@ +{ + "manifest": { + "publishingInformation": { + "locales": { + "fr-FR": { + "summary": "La Banque des Coquillages pour Hailey, Violette et Andreas", + "examplePhrases": [ + "Alexa, ouvre la banque des coquillages", + "Alexa, demande à la banque des coquillages combien de coquillages a Hailey", + "Alexa, demande à la banque des coquillages quelles sont les activités du mois" + ], + "keywords": [ + "coquillages", + "enfants", + "récompenses", + "épargne", + "famille" + ], + "name": "La Banque des Coquillages", + "description": "Skill familial permettant à Hailey, Violette et Andreas de consulter leurs coquillages, de financer des activités communes et de placer leurs coquillages sur un livret bloqué à intérêts." + } + }, + "isAvailableWorldwide": false, + "testingInstructions": "Utiliser avec le backend API familial.", + "category": "EDUCATION_AND_REFERENCE", + "distributionCountries": [ + "FR", + "BE", + "CH", + "CA" + ] + }, + "apis": { + "custom": { + "endpoint": { + "uri": "arn:aws:lambda:eu-west-1:000000000000:function:banque-coquillages-skill" + } + } + }, + "manifestVersion": "1.0" + } +} + diff --git a/start-all.js b/start-all.js new file mode 100644 index 0000000..e181022 --- /dev/null +++ b/start-all.js @@ -0,0 +1,54 @@ +import { spawn } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +function runNode(name, colorCode, args, cwd) { + const child = spawn(process.execPath, args, { + cwd, + stdio: 'pipe' + }); + + child.stdout.on('data', (data) => { + const lines = data.toString().split(/\r?\n/).filter(Boolean); + for (const line of lines) { + console.log(`\x1b[${colorCode}m[${name}]\x1b[0m ${line}`); + } + }); + + child.stderr.on('data', (data) => { + const lines = data.toString().split(/\r?\n/).filter(Boolean); + for (const line of lines) { + console.error(`\x1b[${colorCode}m[${name}]\x1b[0m ${line}`); + } + }); + + return child; +} + +console.log('🐚 Démarrage simultané du Backend (port 4050) et du Frontend (port 5180)...'); + +const back = runNode( + 'BACK ', + '36', + ['--watch', path.join(__dirname, 'back', 'src', 'server.js')], + path.join(__dirname, 'back') +); + +const front = runNode( + 'FRONT', + '33', + [path.join(__dirname, 'node_modules', 'vite', 'bin', 'vite.js')], + path.join(__dirname, 'front') +); + +function shutdown() { + back.kill(); + front.kill(); + process.exit(0); +} + +process.on('SIGINT', shutdown); +process.on('SIGTERM', shutdown);