Initialise le dépôt Bonpoint avec docs et prototype web.
Structure docs/, web/ (app Node existante) et mobile/ (placeholder Flutter).
25
.gitignore
vendored
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# Secrets & données locales
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Node (web)
|
||||||
|
web/node_modules/
|
||||||
|
web/data/*.db
|
||||||
|
web/data/*.db-*
|
||||||
|
|
||||||
|
# Flutter / mobile (futur)
|
||||||
|
mobile/.dart_tool/
|
||||||
|
mobile/.flutter-plugins
|
||||||
|
mobile/.flutter-plugins-dependencies
|
||||||
|
mobile/.packages
|
||||||
|
mobile/build/
|
||||||
|
mobile/.pub-cache/
|
||||||
|
mobile/.pub/
|
||||||
|
mobile/**/ios/Pods/
|
||||||
|
mobile/**/.symlinks/
|
||||||
|
|
||||||
|
# OS / IDE
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
44
README.md
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
# Bonpoint — Bons Points famille
|
||||||
|
|
||||||
|
Dépôt du projet **Bons Points** (gestion de bons points pour enfants).
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
bonpoint/
|
||||||
|
├── docs/ Documentation (cahier des charges, notes Google Home, etc.)
|
||||||
|
├── web/ Application web prototype (Node.js + SQLite + Docker)
|
||||||
|
└── mobile/ Application Android Flutter — à venir
|
||||||
|
```
|
||||||
|
|
||||||
|
## Web (prototype actuel)
|
||||||
|
|
||||||
|
Application familiale déployée sur `bonpoint.ptits-pas.fr` — sert de **laboratoire** (règles, UI, logique métier) pour la future app mobile commerciale.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
cp .env.example .env # éditer PARENT_PIN, JWT_SECRET
|
||||||
|
npm install
|
||||||
|
npm start # http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
Déploiement Docker : voir `web/docker-compose.yml`.
|
||||||
|
|
||||||
|
## Mobile (prévu)
|
||||||
|
|
||||||
|
App Android **100 % locale**, freemium, Play Store — voir `docs/CAHIER_DES_CHARGES_APP_ANDROID.md`.
|
||||||
|
|
||||||
|
Le développement mobile se fera dans un workspace Cursor dédié, branche ou dossier `mobile/`.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
| Fichier | Contenu |
|
||||||
|
|---------|---------|
|
||||||
|
| [CAHIER_DES_CHARGES_APP_ANDROID.md](docs/CAHIER_DES_CHARGES_APP_ANDROID.md) | Spec produit commercial v0.2 |
|
||||||
|
| [GOOGLE_HOME.md](docs/GOOGLE_HOME.md) | Notes intégration Nest (abandonnée) |
|
||||||
|
|
||||||
|
## Dépôt Git
|
||||||
|
|
||||||
|
```
|
||||||
|
ssh://git@git.ptits-pas.fr:2222/jmartin/bonpoint.git
|
||||||
|
```
|
||||||
289
docs/CAHIER_DES_CHARGES_APP_ANDROID.md
Normal file
@ -0,0 +1,289 @@
|
|||||||
|
# Cahier des charges — Bons Points (app Android commerciale)
|
||||||
|
|
||||||
|
**Version :** 0.2
|
||||||
|
**Date :** 9 juin 2026
|
||||||
|
**Statut :** Cadrage — produit public monétisé (pas usage famille seul)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Vision produit
|
||||||
|
|
||||||
|
### Objectif business
|
||||||
|
Publier sur le **Google Play Store** une application de **bons points** pour familles, **100 % locale** (pas de SaaS, pas de compte obligatoire), monétisée pour **générer des revenus** au-delà du coût du compte développeur (~25 €).
|
||||||
|
|
||||||
|
### Proposition de valeur (marketing)
|
||||||
|
> **« Vos bons points restent chez vous — zéro cloud, zéro compte, zéro données enfants sur nos serveurs. »**
|
||||||
|
|
||||||
|
Les données familiales (scores, historique, photos) vivent **uniquement sur le téléphone**. Seul un **mini site de redirection** (téléchargement / QR) peut exister côté serveur — sans accès au contenu familial.
|
||||||
|
|
||||||
|
### Ce n’est PAS
|
||||||
|
- **P’titsPas** (`app.ptits-pas.fr`) — application pro garde d’enfants / collectivités (projet séparé, login, SaaS).
|
||||||
|
- L’instance familiale actuelle (Ariana, Pablo, Hélia) — elle sert de **prototype** et de source de règles par défaut, pas de produit final.
|
||||||
|
|
||||||
|
### Hors périmètre v1
|
||||||
|
- iOS (coût Mac + 99 €/an)
|
||||||
|
- OK Google / Nest / voix
|
||||||
|
- Synchronisation multi-téléphones en temps réel
|
||||||
|
- Comptes utilisateurs cloud (Firebase Auth, etc.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Modèle économique
|
||||||
|
|
||||||
|
### Freemium (recommandé)
|
||||||
|
|
||||||
|
| | **Gratuit** | **Premium** (achat unique) |
|
||||||
|
|--|-------------|----------------------------|
|
||||||
|
| Enfants | **1 enfant** max | **Illimité** (ou plafond raisonnable, ex. 8) |
|
||||||
|
| Règles / récompenses | Pack par défaut + édition limitée | Édition complète, familles custom |
|
||||||
|
| Publicités | **Bandeau AdMob** (bas d’écran) | **Aucune pub** |
|
||||||
|
| Export sauvegarde | ✅ | ✅ |
|
||||||
|
| Historique | 30 derniers jours | Illimité |
|
||||||
|
| Prix cible | 0 € | **2,99 € – 4,99 €** (achat in-app unique) |
|
||||||
|
|
||||||
|
*Chiffres à affiner après étude concurrence.*
|
||||||
|
|
||||||
|
### Publicité (AdMob)
|
||||||
|
- App ciblant **familles et enfants** → politique **Google Play Families** obligatoire.
|
||||||
|
- Pubs **taguées child-directed**, contenu **G** max, SDK Ads certifié Families.
|
||||||
|
- **Revenus pub faibles** sur ce segment ; l’IAP « sans pub + multi-enfants » doit être le levier principal.
|
||||||
|
- **Pas de pub** dans l’espace parent ni sur les écrans où l’enfant dépense ses points (UX + conformité).
|
||||||
|
|
||||||
|
### Coûts récurrents
|
||||||
|
| Poste | Coût |
|
||||||
|
|-------|------|
|
||||||
|
| Compte Google Play | ~25 € une fois |
|
||||||
|
| Hébergement landing `/download` | ~0 € (page statique sur infra existante) |
|
||||||
|
| Backend données familiales | **0 €** |
|
||||||
|
| AdMob / Play Billing | Commission Google sur IAP (~15 %) |
|
||||||
|
|
||||||
|
### Seuil de rentabilité (ordre de grandeur)
|
||||||
|
- Coût fixe initial : ~25 € + temps de dev.
|
||||||
|
- À 3 € IAP net ~2,55 € : **~10 achats** pour couvrir le compte Play (hors temps).
|
||||||
|
- Les pubs seules nécessitent **très nombreuses** impressions (surtout en child-directed).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Utilisateurs cibles
|
||||||
|
|
||||||
|
| Profil | Besoin |
|
||||||
|
|--------|--------|
|
||||||
|
| **Parent** (acheteur décisionnaire) | Gérer points, règles, récompenses, corriger erreurs |
|
||||||
|
| **Enfant** | Voir score, historique, « boutique » |
|
||||||
|
| **Parent soucieux vie privée** | Pas de compte, pas de cloud — argument d’achat |
|
||||||
|
|
||||||
|
**Marché initial :** France, français. Extension EU possible plus tard.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Parcours utilisateur (premier lancement)
|
||||||
|
|
||||||
|
Pas de login. À la première ouverture :
|
||||||
|
|
||||||
|
1. **Écran bienvenue** — promesse (local, privé, simple).
|
||||||
|
2. **Création du 1er enfant** — prénom, couleur, photo (galerie ou avatar).
|
||||||
|
3. **Choix du PIN parent** (obligatoire).
|
||||||
|
4. **Import règles par défaut** — pack « Maison & école » (~30 règles issues du prototype) ou départ vide.
|
||||||
|
5. **Tableau de bord** — prêt à l’emploi.
|
||||||
|
|
||||||
|
Ensuite : ouverture directe sur le tableau de bord (PIN pour espace parent).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Fonctionnalités
|
||||||
|
|
||||||
|
### 5.1 MVP commercial (v1.0 — publication Play Store)
|
||||||
|
|
||||||
|
#### Tableau de bord
|
||||||
|
- Liste enfants (limité à 1 en gratuit ; badge « Passer à Premium » si ajout 2e enfant).
|
||||||
|
- Accès espace parent (PIN).
|
||||||
|
- Menu : réglages, export/import, premium, politique confidentialité.
|
||||||
|
|
||||||
|
#### Enfant
|
||||||
|
- Fiche : photo, score, historique, boutique.
|
||||||
|
- Plancher score à **0**.
|
||||||
|
|
||||||
|
#### Espace parent (PIN)
|
||||||
|
- Multi-sélection enfants (premium si >1 enfant).
|
||||||
|
- Grille règles par familles (édition en premium ; lecture + apply en gratuit).
|
||||||
|
- Historique + **annulation** de mouvement.
|
||||||
|
- Verrouillage auto après sortie de l’écran parent.
|
||||||
|
|
||||||
|
#### Boutique
|
||||||
|
- Récompenses configurables (premium) ou pack défaut (gratuit).
|
||||||
|
|
||||||
|
#### Configuration (premium ou partiel gratuit)
|
||||||
|
- Ajouter / modifier / désactiver règles et récompenses.
|
||||||
|
- Ajouter enfants (premium au-delà du 1er).
|
||||||
|
- Changer PIN, couleurs, photos.
|
||||||
|
- **Export / import** sauvegarde JSON chiffrée (Google Drive, mail, fichier local).
|
||||||
|
|
||||||
|
#### Monétisation intégrée
|
||||||
|
- Bandeau AdMob (écrans autorisés uniquement).
|
||||||
|
- Écran achat Premium (Google Play Billing).
|
||||||
|
- Restauration achat sur nouveau téléphone.
|
||||||
|
|
||||||
|
#### Légal (obligatoire Play Store)
|
||||||
|
- Politique de confidentialité (URL statique).
|
||||||
|
- Mention « pas de collecte données enfants sur serveur ».
|
||||||
|
- Déclaration cible d’âge Play Console (familles).
|
||||||
|
- CGU simplifiées si IAP.
|
||||||
|
|
||||||
|
### 5.2 v1.1+
|
||||||
|
- Widget Android (scores).
|
||||||
|
- Packs de règles thématiques (téléchargement embarqué, pas serveur).
|
||||||
|
- Statistiques locales pour parents (graphique semaine).
|
||||||
|
- Traduction anglais si traction FR.
|
||||||
|
|
||||||
|
### 5.3 Exclu
|
||||||
|
- Voix / assistant.
|
||||||
|
- Sync cloud entre parents.
|
||||||
|
- Réseau social, classements entre familles.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Règles métier (inchangées par rapport au prototype)
|
||||||
|
|
||||||
|
- Score entier, plancher **0**.
|
||||||
|
- Mouvement = trace de chaque action (règle, delta, date).
|
||||||
|
- Annulation = correction inverse (plancher respecté).
|
||||||
|
- Familles de règles : Maison, Routine, Fratrie, Respect, Écrans, Bonus (+ custom en premium).
|
||||||
|
|
||||||
|
*Référence données seed : `apps/bonpoint/src/db.js` (`REGLES_DEFAUT`, `RECOMPENSES_DEFAUT`).*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Architecture technique
|
||||||
|
|
||||||
|
| Couche | Choix |
|
||||||
|
|--------|--------|
|
||||||
|
| Framework | **Flutter** (UI rapide, Cursor-friendly, un codebase) |
|
||||||
|
| Base locale | **drift** ou **sqflite** (SQLite) |
|
||||||
|
| État | Riverpod ou Bloc |
|
||||||
|
| IAP | `in_app_purchase` |
|
||||||
|
| Pub | `google_mobile_ads` (Families-compliant) |
|
||||||
|
| Export | Fichier JSON + checksum ; option chiffrement AES (mot de passe parent) |
|
||||||
|
| PIN | Hash local (bcrypt / argon2 via package) |
|
||||||
|
|
||||||
|
### Réseau (minimal)
|
||||||
|
| Usage | Réseau | Données |
|
||||||
|
|-------|--------|---------|
|
||||||
|
| Usage quotidien app | **Non requis** | — |
|
||||||
|
| AdMob | Oui | Requêtes pub anonymisées (Families) |
|
||||||
|
| Play Billing | Oui | Transaction Google |
|
||||||
|
| Landing `bonpoint.ptits-pas.fr/download` | Oui | Redirection Play Store + compteur scans |
|
||||||
|
| Données familiales vers serveur | **Jamais** | — |
|
||||||
|
|
||||||
|
### Landing marketing (serveur léger)
|
||||||
|
- URL : `https://bonpoint.ptits-pas.fr/download` (ou sous-domaine dédié).
|
||||||
|
- QR code sur cartons → comptage scan → redirect fiche Play Store.
|
||||||
|
- **Aucune** donnée enfant ; analytics agrégés uniquement.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Distribution Play Store
|
||||||
|
|
||||||
|
### Compte développeur
|
||||||
|
- Compte **personnel** ou **organisation** (si structure P’tits Pas / micro-entreprise — *à trancher pour facturation IAP*).
|
||||||
|
|
||||||
|
### Avant publication publique (compte perso récent)
|
||||||
|
- **Test fermé** : minimum **12 testeurs** opt-in, **14 jours consécutifs**.
|
||||||
|
- Questionnaire « production access » rempli par Google.
|
||||||
|
- Prévoir recrutement testeurs (réseau, parents école, etc.) **avant** de viser la prod.
|
||||||
|
|
||||||
|
### Fiche Store
|
||||||
|
- Nom : **Bons Points** (ou **Bonpoint — Bons points famille** si conflit).
|
||||||
|
- Captures : tableau de bord, espace parent, boutique.
|
||||||
|
- Argument : **100 % hors ligne, privé, sans compte**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Stratégie marketing (terrain)
|
||||||
|
|
||||||
|
| Canal | Action |
|
||||||
|
|-------|--------|
|
||||||
|
| **QR cartons** | Carton imprimé → `/download` → Play Store |
|
||||||
|
| **Bouche-à-oreille** | Enfants / parents d’école (récré, WhatsApp parents) |
|
||||||
|
| **Différenciation** | Vie privée vs apps concurrentes cloud + abonnement |
|
||||||
|
| **Pas en v1** | Budget pub Facebook, influenceurs |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Concurrence & positionnement
|
||||||
|
|
||||||
|
Marché saturé d’apps « chores / rewards / points ». Angles possibles :
|
||||||
|
|
||||||
|
1. **100 % local** — pas d’abonnement mensuel.
|
||||||
|
2. **Français natif** — règles et UX pensées France.
|
||||||
|
3. **Prix bas** — IAP unique vs abonnements concurrents.
|
||||||
|
4. **Sans compte** — installation en 2 minutes.
|
||||||
|
|
||||||
|
*Étude concurrentielle à faire (2–3 apps FR/EN) avant finalisation pricing.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Rapport avec le prototype web
|
||||||
|
|
||||||
|
| Élément | Décision |
|
||||||
|
|---------|----------|
|
||||||
|
| `bonpoint.ptits-pas.fr` | Devient **landing + redirect** ; app web famille optionnelle en maintenance |
|
||||||
|
| Code Node actuel | Source de vérité pour **règles par défaut** et **logique métier** ; pas porté tel quel |
|
||||||
|
| Dialogflow / Smart Home / Nest | **Abandonné** |
|
||||||
|
| Photos Ariana/Pablo/Hélia | Remplacées par avatars génériques dans l’app publique |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Planning indicatif
|
||||||
|
|
||||||
|
| Phase | Livrable | Durée estimée |
|
||||||
|
|-------|----------|---------------|
|
||||||
|
| **0** | Validation CDC v0.2 + étude concurrence + pricing | 1 semaine |
|
||||||
|
| **1** | Projet Flutter + BDD + onboarding + 1 enfant | 1 semaine |
|
||||||
|
| **2** | Espace parent, règles, mouvements, annulation | 1 semaine |
|
||||||
|
| **3** | Boutique, export/import, packs règles défaut | 1 semaine |
|
||||||
|
| **4** | AdMob + IAP + écran premium | 1 semaine |
|
||||||
|
| **5** | Légal, polish, tests, test fermé 14 j | 2 semaines |
|
||||||
|
| **6** | Landing QR + cartons + demande prod Play | 1 semaine |
|
||||||
|
|
||||||
|
**Total indicatif :** 7–8 semaines à temps partiel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Critères d’acceptation v1.0
|
||||||
|
|
||||||
|
- [ ] Onboarding sans compte ; 1er enfant créé en < 3 min
|
||||||
|
- [ ] Gratuit : 1 enfant, pub visible, pack règles défaut utilisable
|
||||||
|
- [ ] Premium : achat débloque multi-enfants + sans pub + édition complète
|
||||||
|
- [ ] 100 % fonctionnel en mode avion (hors pub et achat)
|
||||||
|
- [ ] Export puis import sur autre téléphone = données identiques
|
||||||
|
- [ ] Conformité Families (pubs G, pas de tracking enfant)
|
||||||
|
- [ ] Politique confidentialité accessible depuis l’app
|
||||||
|
- [ ] Test fermé 12×14 j validé avant prod
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Points encore à trancher
|
||||||
|
|
||||||
|
| # | Question | Options |
|
||||||
|
|---|----------|---------|
|
||||||
|
| 1 | **Nom marque Play Store** | Bons Points / Bonpoint / autre |
|
||||||
|
| 2 | **Prix IAP** | 2,99 € / 3,99 € / 4,99 € |
|
||||||
|
| 3 | **Limite enfants premium** | Illimité / 6 / 8 |
|
||||||
|
| 4 | **Compte Play** | Perso / société (micro-entreprise) |
|
||||||
|
| 5 | **Édition règles en gratuit** | Lecture seule / 5 règles custom |
|
||||||
|
| 6 | **Historique gratuit** | 30 j / 50 mouvements / illimité |
|
||||||
|
| 7 | **Landing** | Garder `bonpoint.ptits-pas.fr` / nouveau domaine |
|
||||||
|
| 8 | **Quand arrêter le site web actuel** | Dès v1 / après traction app |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Prochaine étape
|
||||||
|
|
||||||
|
1. Valider ce CDC v0.2 (prix IAP, limites gratuit/premium).
|
||||||
|
2. Faire une **mini étude concurrence** (3 apps, leurs prix).
|
||||||
|
3. Créer le repo Flutter `bonpoint-app` et l’onboarding.
|
||||||
|
4. En parallèle : ouvrir compte Play + préparer liste des 12 testeurs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*v0.1 → v0.2 : pivot produit public monétisé (freemium + AdMob + IAP), stack Flutter, landing QR conservée.*
|
||||||
129
docs/GOOGLE_HOME.md
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
# OK Google — Bons Points sur Nest
|
||||||
|
|
||||||
|
## Important : Dialogflow « conversation » ne marche plus sur Nest
|
||||||
|
|
||||||
|
Depuis le **13 juin 2023**, Google a arrêté les **Conversational Actions** (Dialogflow + « Dis à Bons Points… »).
|
||||||
|
|
||||||
|
L’endpoint `/api/voice/dialogflow` reste utile pour **tests** et pour d’autres intégrations Dialogflow, mais **ne peut plus être invoqué directement sur une enceinte Nest** via une action conversationnelle.
|
||||||
|
|
||||||
|
La voie officielle aujourd’hui : **Google Home Smart Home** (capteurs virtuels).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ce qui est déjà prêt côté serveur
|
||||||
|
|
||||||
|
| URL | Rôle |
|
||||||
|
|-----|------|
|
||||||
|
| `GET /api/voice/ariana` | Réponse JSON (test) |
|
||||||
|
| `GET /api/voice?prenom=pablo` | Idem |
|
||||||
|
| `POST /api/voice/dialogflow` | Webhook Dialogflow (tests) |
|
||||||
|
| `POST /api/smarthome` | Fulfillment Google Home (SYNC / QUERY) |
|
||||||
|
|
||||||
|
Test rapide :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -s https://bonpoint.ptits-pas.fr/api/voice/ariana
|
||||||
|
curl -s -X POST https://bonpoint.ptits-pas.fr/api/smarthome \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"requestId":"test","inputs":[{"intent":"action.devices.SYNC"}]}'
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Principe Smart Home (recommandé)
|
||||||
|
|
||||||
|
Chaque enfant apparaît comme un **capteur de batterie** (0–100 %) = son score de bons points.
|
||||||
|
|
||||||
|
Phrases à essayer sur le Nest (en français) :
|
||||||
|
|
||||||
|
- « OK Google, **quel est le niveau de batterie d’Ariana** ? »
|
||||||
|
- « OK Google, **quelle est la batterie de Pablo** ? »
|
||||||
|
- « OK Google, **niveau de batterie Hélia** »
|
||||||
|
|
||||||
|
> Astuce famille : expliquer aux enfants que la « batterie » = leurs bons points.
|
||||||
|
|
||||||
|
Les scores au-dessus de 100 s’affichent comme 100 % (limite Google).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration Google Home Developer (à faire une fois)
|
||||||
|
|
||||||
|
### 1. Créer le projet
|
||||||
|
|
||||||
|
1. Ouvrir [Google Home Developer Console](https://console.home.google.com/)
|
||||||
|
2. **Create project** → nom : `Bons Points`
|
||||||
|
3. Type d’intégration : **Cloud-to-cloud**
|
||||||
|
|
||||||
|
### 2. Fulfillment
|
||||||
|
|
||||||
|
1. Menu **Integrations** → votre intégration Cloud-to-cloud
|
||||||
|
2. **Fulfillment URL** :
|
||||||
|
|
||||||
|
```
|
||||||
|
https://bonpoint.ptits-pas.fr/api/smarthome
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Enregistrer
|
||||||
|
|
||||||
|
### 3. Account linking (obligatoire pour le Nest)
|
||||||
|
|
||||||
|
Google exige un **OAuth 2.0** pour lier le compte dans l’app Google Home.
|
||||||
|
|
||||||
|
Options possibles :
|
||||||
|
|
||||||
|
- **Auth0** ou **Firebase Auth** (gratuit, adapté à un usage familial)
|
||||||
|
- OAuth maison (plus technique)
|
||||||
|
|
||||||
|
Une fois OAuth configuré, vous liez l’intégration dans **Google Home** → **Paramètres** → **Travaille avec Google**.
|
||||||
|
|
||||||
|
### 4. Test avant production
|
||||||
|
|
||||||
|
1. Dans la console : **Test Suite** → lancer SYNC et QUERY
|
||||||
|
2. Vérifier que 3 appareils apparaissent (Ariana, Pablo, Hélia)
|
||||||
|
3. Tester une QUERY sur un appareil → le score doit correspondre à l’app
|
||||||
|
|
||||||
|
### 5. Utilisation au quotidien
|
||||||
|
|
||||||
|
1. Lier l’intégration sur le compte Google du foyer
|
||||||
|
2. Les 3 « capteurs » sont découverts automatiquement
|
||||||
|
3. Les enfants demandent le niveau de batterie / points à voix haute
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dialogflow (optionnel, tests uniquement)
|
||||||
|
|
||||||
|
Si vous voulez quand même un agent Dialogflow pour tester le webhook :
|
||||||
|
|
||||||
|
1. [Dialogflow ES](https://dialogflow.cloud.google.com/) → nouvel agent **fr**
|
||||||
|
2. Intent `score_enfant` avec phrases :
|
||||||
|
- Combien de points a Ariana ?
|
||||||
|
- Quel est le score de Pablo ?
|
||||||
|
- Et Hélia elle a combien ?
|
||||||
|
3. Paramètre `@sys.given-name` ou entité custom `prenom` : Ariana, Pablo, Hélia
|
||||||
|
4. Intent `score_tous` : Combien de points ? / Les scores ?
|
||||||
|
5. **Fulfillment** → activer webhook :
|
||||||
|
|
||||||
|
```
|
||||||
|
https://bonpoint.ptits-pas.fr/api/voice/dialogflow
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Tester dans la console Dialogflow (simulateur) — **pas sur le Nest**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Dépannage
|
||||||
|
|
||||||
|
| Problème | Piste |
|
||||||
|
|----------|--------|
|
||||||
|
| Nest ne connaît pas l’action | Normal si vous utilisez encore Dialogflow conversationnel |
|
||||||
|
| Appareils invisibles | Vérifier account linking + SYNC dans Test Suite |
|
||||||
|
| Mauvais score | Vérifier `GET /api/public/scores` |
|
||||||
|
| Hélia mal reconnue | Dire « Hélia » clairement ; le serveur accepte avec/sans accent |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Évolutions possibles
|
||||||
|
|
||||||
|
- OAuth simplifié intégré à l’app (panneau parent)
|
||||||
|
- Cast TTS vers le Nest après une routine (plus complexe)
|
||||||
|
- Phrases personnalisées via Home Assistant (si un jour vous l’adoptez)
|
||||||
7
mobile/README.md
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
# Application mobile Android
|
||||||
|
|
||||||
|
Emplacement prévu pour l'app **Flutter** (Bons Points — produit commercial Play Store).
|
||||||
|
|
||||||
|
Voir le cahier des charges : [../docs/CAHIER_DES_CHARGES_APP_ANDROID.md](../docs/CAHIER_DES_CHARGES_APP_ANDROID.md).
|
||||||
|
|
||||||
|
**Statut :** non initialisé — à développer dans un workspace Cursor dédié.
|
||||||
11
web/.env.example
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
# PIN parent (4 à 8 chiffres) — à changer avant mise en prod
|
||||||
|
PARENT_PIN=1234
|
||||||
|
|
||||||
|
# Secret JWT — générer avec : openssl rand -hex 32
|
||||||
|
JWT_SECRET=changez-moi-avec-openssl-rand-hex-32
|
||||||
|
|
||||||
|
PORT=3000
|
||||||
|
DATABASE_PATH=/data/bonpoint.db
|
||||||
|
|
||||||
|
# Google Home Smart Home — fulfillment : https://bonpoint.ptits-pas.fr/api/smarthome
|
||||||
|
# Voir docs/GOOGLE_HOME.md pour la configuration OAuth (account linking)
|
||||||
4
web/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
data/*.db
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
25
web/Dockerfile
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
FROM node:22-alpine AS build
|
||||||
|
|
||||||
|
RUN apk add --no-cache python3 make g++
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json ./
|
||||||
|
RUN npm install --omit=dev
|
||||||
|
|
||||||
|
FROM node:22-alpine
|
||||||
|
|
||||||
|
RUN apk add --no-cache libstdc++
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=build /app/node_modules ./node_modules
|
||||||
|
COPY package.json ./
|
||||||
|
COPY src ./src
|
||||||
|
COPY public ./public
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV DATABASE_PATH=/data/bonpoint.db
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "src/server.js"]
|
||||||
0
web/data/.gitkeep
Normal file
21
web/docker-compose.yml
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
services:
|
||||||
|
bonpoint:
|
||||||
|
build: .
|
||||||
|
container_name: bonpoint
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
|
volumes:
|
||||||
|
- ./data:/data
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.docker.network=proxy_network"
|
||||||
|
- "traefik.http.routers.bonpoint.rule=Host(`bonpoint.ptits-pas.fr`)"
|
||||||
|
- "traefik.http.routers.bonpoint.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.bonpoint.tls.certresolver=leresolver"
|
||||||
|
- "traefik.http.services.bonpoint.loadbalancer.server.port=3000"
|
||||||
|
networks:
|
||||||
|
- proxy_network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
proxy_network:
|
||||||
|
external: true
|
||||||
1997
web/package-lock.json
generated
Normal file
21
web/package.json
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "bonpoint",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Application familiale de bons points",
|
||||||
|
"main": "src/server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/server.js",
|
||||||
|
"dev": "node --watch src/server.js",
|
||||||
|
"icons": "node scripts/generate-icons.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"bcryptjs": "^3.0.2",
|
||||||
|
"better-sqlite3": "^11.10.0",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"jsonwebtoken": "^9.0.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"sharp": "^0.35.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
190
web/public/boutique.html
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="theme-color" content="#667eea">
|
||||||
|
<title>Boutique — Bons Points</title>
|
||||||
|
<link rel="manifest" href="/manifest.json">
|
||||||
|
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png">
|
||||||
|
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Bons Points">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<link rel="stylesheet" href="/css/style.css?v=34">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<a id="lien-retour" class="btn-retour">
|
||||||
|
<svg class="btn-retour-icone" width="18" height="18" viewBox="0 0 24 24"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="2.5"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<polyline points="15 18 9 12 15 6"/>
|
||||||
|
</svg>
|
||||||
|
Retour
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div id="boutique-contenu" class="cacher">
|
||||||
|
<div class="fiche-entete">
|
||||||
|
<div id="avatar-boutique" class="avatar-cercle fiche-avatar-centre"></div>
|
||||||
|
<div id="score-bandeau" class="score-bandeau"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="etape-label">Choisis ta récompense</p>
|
||||||
|
<div id="liste-recompenses" class="grille-recompenses"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p id="erreur" class="erreur-page cacher">Enfant introuvable</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="modal-confirm" class="modal">
|
||||||
|
<div class="modal-contenu">
|
||||||
|
<p id="modal-texte"></p>
|
||||||
|
<div class="modal-boutons">
|
||||||
|
<button class="btn btn-principal" id="btn-confirmer">Oui !</button>
|
||||||
|
<button class="btn btn-secondaire" id="btn-annuler">Non</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="feedback" class="feedback"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const prenomUrl = window.location.pathname.split('/').pop();
|
||||||
|
const PHOTOS = {
|
||||||
|
Ariana: '/photos/ariana.png',
|
||||||
|
Pablo: '/photos/pablo.png',
|
||||||
|
'Hélia': '/photos/helia.png',
|
||||||
|
};
|
||||||
|
|
||||||
|
function slug(prenom) {
|
||||||
|
return prenom.toLowerCase()
|
||||||
|
.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
let enfant = null;
|
||||||
|
let recompenses = [];
|
||||||
|
let recompenseChoisie = null;
|
||||||
|
|
||||||
|
const feedback = document.getElementById('feedback');
|
||||||
|
const modal = document.getElementById('modal-confirm');
|
||||||
|
|
||||||
|
function afficherFeedback(msg) {
|
||||||
|
feedback.textContent = msg;
|
||||||
|
feedback.classList.add('visible');
|
||||||
|
setTimeout(() => feedback.classList.remove('visible'), 2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mettreAJourBandeau() {
|
||||||
|
const bandeau = document.getElementById('score-bandeau');
|
||||||
|
bandeau.innerHTML = `
|
||||||
|
<span class="score-bandeau-nom">${enfant.prenom}</span>
|
||||||
|
<span class="score-bandeau-pts" style="color:${enfant.couleur}">
|
||||||
|
${enfant.score} points
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
bandeau.style.borderColor = enfant.couleur;
|
||||||
|
}
|
||||||
|
|
||||||
|
function afficherRecompenses() {
|
||||||
|
mettreAJourBandeau();
|
||||||
|
|
||||||
|
const cont = document.getElementById('liste-recompenses');
|
||||||
|
cont.innerHTML = recompenses.map((r) => {
|
||||||
|
const ok = enfant.score >= r.cout_points;
|
||||||
|
return `
|
||||||
|
<button class="carte-recompense ${ok ? '' : 'indisponible'}"
|
||||||
|
data-id="${r.id}" ${ok ? '' : 'disabled'}>
|
||||||
|
<span class="rec-icone">${r.icone}</span>
|
||||||
|
<span class="rec-libelle">${r.libelle}</span>
|
||||||
|
<span class="rec-cout">${r.cout_points} pts</span>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
cont.querySelectorAll('.carte-recompense:not(.indisponible)').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
recompenseChoisie = recompenses.find(
|
||||||
|
(x) => x.id === parseInt(btn.dataset.id, 10),
|
||||||
|
);
|
||||||
|
document.getElementById('modal-texte').textContent =
|
||||||
|
`${enfant.prenom}, tu veux « ${recompenseChoisie.libelle} » ` +
|
||||||
|
`pour ${recompenseChoisie.cout_points} points ?`;
|
||||||
|
modal.classList.add('ouvert');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fermerModal() {
|
||||||
|
modal.classList.remove('ouvert');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmerAchat() {
|
||||||
|
fermerModal();
|
||||||
|
const res = await fetch('/api/public/acheter', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
enfant_id: enfant.id,
|
||||||
|
recompense_id: recompenseChoisie.id,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
afficherFeedback(data.erreur || 'Erreur');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
enfant = data.enfant;
|
||||||
|
|
||||||
|
afficherFeedback(
|
||||||
|
`Bravo ${data.enfant.prenom} ! ${data.recompense.icone} ` +
|
||||||
|
`${data.recompense.libelle}`,
|
||||||
|
);
|
||||||
|
afficherRecompenses();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function charger() {
|
||||||
|
try {
|
||||||
|
const [resEnfant, resRecompenses] = await Promise.all([
|
||||||
|
fetch(`/api/public/enfant/${prenomUrl}`),
|
||||||
|
fetch('/api/public/recompenses'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!resEnfant.ok) throw new Error('introuvable');
|
||||||
|
|
||||||
|
const data = await resEnfant.json();
|
||||||
|
enfant = data.enfant;
|
||||||
|
recompenses = (await resRecompenses.json()).recompenses;
|
||||||
|
|
||||||
|
const avatar = document.getElementById('avatar-boutique');
|
||||||
|
avatar.style.borderColor = enfant.couleur;
|
||||||
|
avatar.innerHTML =
|
||||||
|
`<img src="${PHOTOS[enfant.prenom]}" alt="${enfant.prenom}">`;
|
||||||
|
|
||||||
|
mettreAJourBandeau();
|
||||||
|
document.getElementById('lien-retour').href =
|
||||||
|
`/enfant/${slug(enfant.prenom)}`;
|
||||||
|
document.title = `Boutique — ${enfant.prenom}`;
|
||||||
|
|
||||||
|
afficherRecompenses();
|
||||||
|
document.getElementById('boutique-contenu').classList.remove('cacher');
|
||||||
|
} catch {
|
||||||
|
document.getElementById('erreur').classList.remove('cacher');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('btn-confirmer').addEventListener('click', confirmerAchat);
|
||||||
|
document.getElementById('btn-annuler').addEventListener('click', fermerModal);
|
||||||
|
modal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === modal) fermerModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
charger();
|
||||||
|
</script>
|
||||||
|
<script src="/js/pwa.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
894
web/public/css/style.css
Normal file
@ -0,0 +1,894 @@
|
|||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.5rem 1rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
text-align: center;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sous-titre {
|
||||||
|
text-align: center;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carte-enfant {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 1.2rem 1.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carte-enfant:hover {
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.carte-lien {
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fleche-fiche {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
color: #ccc;
|
||||||
|
font-weight: 300;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-retour {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
background: #fff;
|
||||||
|
color: #667eea;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 0.55rem 1.1rem 0.55rem 0.9rem;
|
||||||
|
border-radius: 24px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.18);
|
||||||
|
transition: transform 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-retour-icone {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-retour:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 5px 16px rgba(0, 0, 0, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-retour:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fiche-entete {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
margin-bottom: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fiche-entete-compact {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
text-align: left;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 1rem 1.2rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fiche-entete .fiche-avatar-centre.avatar-cercle {
|
||||||
|
width: 50%;
|
||||||
|
height: auto;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
margin: 0 auto 0.75rem;
|
||||||
|
border-width: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fiche-entete .score-bandeau {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-grand {
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
margin: 0 auto 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fiche-prenom {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #fff;
|
||||||
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fiche-prenom-petit {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
color: #333;
|
||||||
|
text-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fiche-score {
|
||||||
|
font-size: 3.5rem;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
color: #fff;
|
||||||
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fiche-section {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 1rem 1.2rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fiche-section h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #4a5568;
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historique-vide {
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historique-libelle {
|
||||||
|
flex: 1;
|
||||||
|
padding-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historique-droite {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 0.1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historique-date {
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fiche-section .historique-item {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.solde-badge-boutique {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #667eea;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.erreur-page {
|
||||||
|
text-align: center;
|
||||||
|
color: #fff;
|
||||||
|
padding: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-cercle {
|
||||||
|
width: 58px;
|
||||||
|
height: 58px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3px solid;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-cercle img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-enfant {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prenom {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score {
|
||||||
|
font-size: 2.2rem;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-parent-accueil {
|
||||||
|
border-color: #667eea;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-parent-titre {
|
||||||
|
width: 2.1rem;
|
||||||
|
height: 2.1rem;
|
||||||
|
border-color: rgba(255, 255, 255, 0.85);
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin-entete .avatar-parent-pin.avatar-cercle {
|
||||||
|
width: 50%;
|
||||||
|
height: auto;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
margin: 0 auto 0.75rem;
|
||||||
|
border-width: 4px;
|
||||||
|
border-color: #667eea;
|
||||||
|
box-shadow: 0 4px 14px rgba(102, 126, 234, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-parent {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 0;
|
||||||
|
padding: 1.2rem 1.5rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.55);
|
||||||
|
border-radius: 20px;
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.12);
|
||||||
|
transition: background 0.2s, transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-parent-texte {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-parent:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-parent:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Zone parent */
|
||||||
|
|
||||||
|
body.parent-page {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parent-container {
|
||||||
|
max-width: 480px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.2rem 1rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parent-barre-haut {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parent-titre {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
color: #fff;
|
||||||
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline-blanc {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
color: #fff;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.6);
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-outline-blanc:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
#ecran-pin .btn-retour {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ecran-pin-carte {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 1.75rem 1.5rem 1.5rem;
|
||||||
|
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin-zone {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0.6rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin-entete {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin-entete h1 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
color: #667eea;
|
||||||
|
text-shadow: none;
|
||||||
|
margin: 0 0 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin-sous-titre {
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#form-pin {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin-input {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
letter-spacing: 0.35rem;
|
||||||
|
padding: 0.8rem 0.75rem;
|
||||||
|
border: 2px solid #ddd;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
direction: ltr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-connexion-pin {
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-self: stretch;
|
||||||
|
padding: 0.8rem 1.25rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin-top: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#ecran-pin .erreur {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin-masque {
|
||||||
|
-webkit-text-security: disc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin-leurre {
|
||||||
|
position: absolute;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.7rem 1.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-principal {
|
||||||
|
background: #667eea;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background: #e53e3e;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
padding: 0.4rem 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.erreur {
|
||||||
|
color: #e53e3e;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section h3 {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
color: #667eea;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-enfant .selecteur-enfant {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-enfant {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0.2rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 0;
|
||||||
|
min-width: 0;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-vignette {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 6.75rem;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3px solid;
|
||||||
|
overflow: visible;
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
transition: transform 0.2s, border-width 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-enfant.actif .chip-vignette {
|
||||||
|
border-width: 5px;
|
||||||
|
transform: scale(1.06);
|
||||||
|
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45), 0 2px 8px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-enfant:not(.actif) .chip-vignette {
|
||||||
|
border-width: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-vignette img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-badge {
|
||||||
|
position: absolute;
|
||||||
|
right: -0.15rem;
|
||||||
|
bottom: -0.1rem;
|
||||||
|
min-width: 1.55rem;
|
||||||
|
height: 1.55rem;
|
||||||
|
padding: 0 0.25rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fff;
|
||||||
|
border: 2px solid currentColor;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-chip {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-label {
|
||||||
|
line-height: 1.1;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-bandeau {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: #f8f9ff;
|
||||||
|
border-radius: 16px;
|
||||||
|
border-left: 4px solid;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-bandeau-nom {
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 1.35rem;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-bandeau-pts {
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grille-regles {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.famille-regles {
|
||||||
|
margin-bottom: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.famille-regles:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.famille-titre {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
background: #667eea;
|
||||||
|
margin-bottom: 0.6rem;
|
||||||
|
padding: 0.35rem 0.6rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-regle {
|
||||||
|
padding: 0.7rem 0.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.3;
|
||||||
|
transition: transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-regle:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-regle.positif {
|
||||||
|
background: #c6f6d5;
|
||||||
|
color: #22543d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-regle.negatif {
|
||||||
|
background: #fed7d7;
|
||||||
|
color: #742a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historique-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.historique-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parent-page .historique-item {
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parent-page .historique-libelle {
|
||||||
|
min-width: 0;
|
||||||
|
line-height: 1.35;
|
||||||
|
padding-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parent-page .historique-droite {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding-top: 0.05rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parent-page .historique-points-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.parent-page .btn-annuler-mouvement {
|
||||||
|
margin-left: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delta-positif {
|
||||||
|
color: #38a169;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delta-negatif {
|
||||||
|
color: #e53e3e;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cacher {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1.5rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: #2d3748;
|
||||||
|
color: #fff;
|
||||||
|
padding: 0.8rem 1.5rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feedback.visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Boutique */
|
||||||
|
|
||||||
|
.btn-boutique {
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
background: #fff;
|
||||||
|
color: #667eea;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 16px;
|
||||||
|
text-decoration: none;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-boutique:hover {
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.etape-label {
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.solde-badge {
|
||||||
|
display: inline-block;
|
||||||
|
background: rgba(255, 255, 255, 0.25);
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-left: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grille-enfants {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carte-cliquable {
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carte-cliquable:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.grille-recompenses {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carte-recompense {
|
||||||
|
background: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 1rem 0.6rem;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.12);
|
||||||
|
transition: transform 0.15s;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carte-recompense:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.carte-recompense.indisponible {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-icone {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-libelle {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rec-cout {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #667eea;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 200;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.ouvert {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-contenu {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
max-width: 320px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-contenu p {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-bottom: 1.2rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-boutons {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondaire {
|
||||||
|
background: #e2e8f0;
|
||||||
|
color: #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-annuler-mouvement {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 1.6rem;
|
||||||
|
height: 1.6rem;
|
||||||
|
background: #fff5f5;
|
||||||
|
border: 1px solid #fc8181;
|
||||||
|
color: #e53e3e;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-left: 0.35rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background 0.15s, transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-annuler-mouvement:hover {
|
||||||
|
background: #fed7d7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-annuler-mouvement:active {
|
||||||
|
transform: scale(0.92);
|
||||||
|
}
|
||||||
126
web/public/enfant.html
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="theme-color" content="#667eea">
|
||||||
|
<title>Bons Points</title>
|
||||||
|
<link rel="manifest" href="/manifest.json">
|
||||||
|
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png">
|
||||||
|
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Bons Points">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<link rel="stylesheet" href="/css/style.css?v=34">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<a href="/" class="btn-retour">
|
||||||
|
<svg class="btn-retour-icone" width="18" height="18" viewBox="0 0 24 24"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="2.5"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<polyline points="15 18 9 12 15 6"/>
|
||||||
|
</svg>
|
||||||
|
Retour
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div id="fiche" class="cacher">
|
||||||
|
<div class="fiche-entete">
|
||||||
|
<div id="avatar-grand" class="avatar-cercle fiche-avatar-centre"></div>
|
||||||
|
<div id="score-bandeau" class="score-bandeau"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fiche-section">
|
||||||
|
<h3>Mon historique</h3>
|
||||||
|
<div id="historique"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a id="lien-boutique" class="btn-boutique">🛍️ Dépenser mes points</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p id="erreur" class="erreur-page cacher">Enfant introuvable</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const PHOTOS = {
|
||||||
|
Ariana: '/photos/ariana.png',
|
||||||
|
Pablo: '/photos/pablo.png',
|
||||||
|
'Hélia': '/photos/helia.png',
|
||||||
|
};
|
||||||
|
|
||||||
|
function slug(prenom) {
|
||||||
|
return prenom.toLowerCase()
|
||||||
|
.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
const prenomUrl = window.location.pathname.split('/').pop();
|
||||||
|
|
||||||
|
function afficherHistorique(mouvements) {
|
||||||
|
const cont = document.getElementById('historique');
|
||||||
|
|
||||||
|
if (!mouvements.length) {
|
||||||
|
cont.innerHTML = '<p class="historique-vide">Aucun mouvement pour l\'instant</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cont.innerHTML = mouvements.map((m) => {
|
||||||
|
const date = new Date(m.cree_le + 'Z').toLocaleString('fr-FR', {
|
||||||
|
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit',
|
||||||
|
});
|
||||||
|
const cls = m.delta >= 0 ? 'delta-positif' : 'delta-negatif';
|
||||||
|
const signe = m.delta > 0 ? '+' : '';
|
||||||
|
const libelle = m.recompense || m.regle || m.note || '?';
|
||||||
|
const icone = m.icone_recompense || m.icone || '';
|
||||||
|
const type = m.recompense_id ? 'achat' : (m.delta >= 0 ? 'gain' : 'perte');
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="historique-item historique-${type}">
|
||||||
|
<span class="historique-libelle">${icone} ${libelle}</span>
|
||||||
|
<span class="historique-droite">
|
||||||
|
<span class="${cls}">${signe}${m.delta}</span>
|
||||||
|
<span class="historique-date">${date}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function charger() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/public/enfant/${prenomUrl}`);
|
||||||
|
if (!res.ok) throw new Error('introuvable');
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const e = data.enfant;
|
||||||
|
|
||||||
|
document.getElementById('avatar-grand').style.borderColor = e.couleur;
|
||||||
|
document.getElementById('avatar-grand').innerHTML =
|
||||||
|
`<img src="${PHOTOS[e.prenom]}" alt="${e.prenom}">`;
|
||||||
|
|
||||||
|
const bandeau = document.getElementById('score-bandeau');
|
||||||
|
bandeau.innerHTML = `
|
||||||
|
<span class="score-bandeau-nom">${e.prenom}</span>
|
||||||
|
<span class="score-bandeau-pts" style="color:${e.couleur}">
|
||||||
|
${e.score} points
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
bandeau.style.borderColor = e.couleur;
|
||||||
|
|
||||||
|
document.getElementById('lien-boutique').href =
|
||||||
|
`/boutique/${slug(e.prenom)}`;
|
||||||
|
|
||||||
|
document.title = `${e.prenom} — Bons Points`;
|
||||||
|
afficherHistorique(data.mouvements);
|
||||||
|
document.getElementById('fiche').classList.remove('cacher');
|
||||||
|
} catch {
|
||||||
|
document.getElementById('erreur').classList.remove('cacher');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
charger();
|
||||||
|
setInterval(charger, 30000);
|
||||||
|
</script>
|
||||||
|
<script src="/js/pwa.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
web/public/icons/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 3.3 KiB |
BIN
web/public/icons/icon-192.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
web/public/icons/icon-512.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
10
web/public/icons/icon.svg
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#667eea"/>
|
||||||
|
<stop offset="100%" stop-color="#764ba2"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="512" height="512" rx="108" fill="url(#bg)"/>
|
||||||
|
<text x="256" y="300" text-anchor="middle" font-size="220" fill="#fff">P</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 414 B |
4
web/public/icons/parent-espace.svg
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M4.5 10.2 12 4.5l7.5 5.7V19a1 1 0 0 1-1 1h-4.5v-5.5h-5V20H5.5a1 1 0 0 1-1-1v-8.8z"/>
|
||||||
|
<path d="M12 10.8S9.5 13 9.5 14.8a2.5 2.5 0 0 0 5 0C14.5 13 12 10.8 12 10.8z" fill="currentColor" stroke="none"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 378 B |
71
web/public/index.html
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="theme-color" content="#667eea">
|
||||||
|
<title>Bons Points</title>
|
||||||
|
<link rel="manifest" href="/manifest.json">
|
||||||
|
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png">
|
||||||
|
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Bons Points">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<link rel="stylesheet" href="/css/style.css?v=39">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>⭐ Bons Points ⭐</h1>
|
||||||
|
<p class="sous-titre">Qui est le plus sage ?</p>
|
||||||
|
<div id="liste-enfants"></div>
|
||||||
|
<a href="/parent" class="btn-parent">
|
||||||
|
<div class="avatar-cercle avatar-parent-accueil" aria-hidden="true">
|
||||||
|
<img src="/photos/parents.png" alt="">
|
||||||
|
</div>
|
||||||
|
<span class="btn-parent-texte">Espace parents</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const PHOTOS = {
|
||||||
|
Ariana: '/photos/ariana.png',
|
||||||
|
Pablo: '/photos/pablo.png',
|
||||||
|
'Hélia': '/photos/helia.png',
|
||||||
|
};
|
||||||
|
|
||||||
|
function slug(prenom) {
|
||||||
|
return prenom.toLowerCase()
|
||||||
|
.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chargerScores() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/public/scores');
|
||||||
|
const data = await res.json();
|
||||||
|
const conteneur = document.getElementById('liste-enfants');
|
||||||
|
|
||||||
|
conteneur.innerHTML = data.enfants.map((e) => `
|
||||||
|
<a class="carte-enfant carte-lien" href="/enfant/${slug(e.prenom)}">
|
||||||
|
<div class="avatar-cercle" style="border-color:${e.couleur}">
|
||||||
|
<img src="${PHOTOS[e.prenom]}" alt="${e.prenom}">
|
||||||
|
</div>
|
||||||
|
<div class="info-enfant">
|
||||||
|
<div class="prenom">${e.prenom}</div>
|
||||||
|
</div>
|
||||||
|
<div class="score" style="color:${e.couleur}">${e.score}</div>
|
||||||
|
<span class="fleche-fiche">›</span>
|
||||||
|
</a>
|
||||||
|
`).join('');
|
||||||
|
} catch {
|
||||||
|
document.getElementById('liste-enfants').innerHTML =
|
||||||
|
'<p style="color:#fff;text-align:center">Chargement impossible</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chargerScores();
|
||||||
|
setInterval(chargerScores, 30000);
|
||||||
|
</script>
|
||||||
|
<script src="/js/pwa.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
5
web/public/js/pwa.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
32
web/public/manifest.json
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "Bons Points",
|
||||||
|
"short_name": "Bons Points",
|
||||||
|
"description": "Suivi des bons points pour Ariana, Pablo et Hélia",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"background_color": "#667eea",
|
||||||
|
"theme_color": "#667eea",
|
||||||
|
"lang": "fr",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
368
web/public/parent.html
Normal file
@ -0,0 +1,368 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="theme-color" content="#667eea">
|
||||||
|
<title>Espace Parents — Bons Points</title>
|
||||||
|
<link rel="manifest" href="/manifest.json">
|
||||||
|
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png">
|
||||||
|
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Bons Points">
|
||||||
|
<meta name="mobile-web-app-capable" content="yes">
|
||||||
|
<link rel="stylesheet" href="/css/style.css?v=40">
|
||||||
|
</head>
|
||||||
|
<body class="parent-page">
|
||||||
|
<div class="parent-container">
|
||||||
|
<div id="ecran-pin">
|
||||||
|
<a href="/" class="btn-retour">
|
||||||
|
<svg class="btn-retour-icone" width="18" height="18" viewBox="0 0 24 24"
|
||||||
|
fill="none" stroke="currentColor" stroke-width="2.5"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<polyline points="15 18 9 12 15 6"/>
|
||||||
|
</svg>
|
||||||
|
Retour
|
||||||
|
</a>
|
||||||
|
<div class="ecran-pin-carte">
|
||||||
|
<div class="pin-entete">
|
||||||
|
<div class="avatar-cercle avatar-parent-pin" aria-hidden="true">
|
||||||
|
<img src="/photos/parents.png" alt="">
|
||||||
|
</div>
|
||||||
|
<h1>Espace Parents</h1>
|
||||||
|
<p class="pin-sous-titre">Entrez votre code PIN</p>
|
||||||
|
</div>
|
||||||
|
<div class="pin-zone">
|
||||||
|
<form id="form-pin" autocomplete="off" onsubmit="return false">
|
||||||
|
<input type="text" name="decoy-user" class="pin-leurre"
|
||||||
|
tabindex="-1" autocomplete="username" aria-hidden="true">
|
||||||
|
<input type="password" name="decoy-pass" class="pin-leurre"
|
||||||
|
tabindex="-1" autocomplete="current-password" aria-hidden="true">
|
||||||
|
<input type="text" id="pin-input" class="pin-input pin-masque"
|
||||||
|
name="bonpoint-code-parent"
|
||||||
|
inputmode="numeric" pattern="[0-9]*" maxlength="8"
|
||||||
|
placeholder="••••"
|
||||||
|
autocomplete="one-time-code"
|
||||||
|
autocorrect="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
spellcheck="false"
|
||||||
|
data-lpignore="true"
|
||||||
|
data-1p-ignore="true"
|
||||||
|
data-form-type="other"
|
||||||
|
readonly>
|
||||||
|
</form>
|
||||||
|
<button class="btn btn-principal btn-connexion-pin" id="btn-connexion">
|
||||||
|
Connexion
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p id="erreur-pin" class="erreur cacher"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="ecran-app" class="cacher">
|
||||||
|
<div class="parent-barre-haut">
|
||||||
|
<h1 class="parent-titre">
|
||||||
|
<div class="avatar-cercle avatar-parent-titre" aria-hidden="true">
|
||||||
|
<img src="/photos/parents.png" alt="">
|
||||||
|
</div>
|
||||||
|
Espace Parents
|
||||||
|
</h1>
|
||||||
|
<button class="btn btn-outline-blanc" id="btn-deconnexion">Déconnexion</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section section-enfant">
|
||||||
|
<h3>Enfant sélectionné</h3>
|
||||||
|
<div class="selecteur-enfant" id="selecteur-enfant"></div>
|
||||||
|
<div id="score-bandeau" class="score-bandeau"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Appliquer une règle</h3>
|
||||||
|
<div id="regles-par-famille"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Derniers mouvements</h3>
|
||||||
|
<div id="historique"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="modal-annuler" class="modal">
|
||||||
|
<div class="modal-contenu">
|
||||||
|
<p id="modal-annuler-texte"></p>
|
||||||
|
<div class="modal-boutons">
|
||||||
|
<button class="btn btn-principal" id="btn-confirmer-annuler">Oui, annuler</button>
|
||||||
|
<button class="btn btn-secondaire" id="btn-fermer-annuler">Non</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="feedback" class="feedback"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const PHOTOS = {
|
||||||
|
Ariana: '/photos/ariana.png',
|
||||||
|
Pablo: '/photos/pablo.png',
|
||||||
|
'Hélia': '/photos/helia.png',
|
||||||
|
};
|
||||||
|
|
||||||
|
let enfants = [];
|
||||||
|
let regles = [];
|
||||||
|
let enfantSelectionne = null;
|
||||||
|
|
||||||
|
const ecranPin = document.getElementById('ecran-pin');
|
||||||
|
const ecranApp = document.getElementById('ecran-app');
|
||||||
|
const pinInput = document.getElementById('pin-input');
|
||||||
|
const erreurPin = document.getElementById('erreur-pin');
|
||||||
|
const feedback = document.getElementById('feedback');
|
||||||
|
const modalAnnuler = document.getElementById('modal-annuler');
|
||||||
|
let mouvementAAnnuler = null;
|
||||||
|
|
||||||
|
function afficherFeedback(msg) {
|
||||||
|
feedback.textContent = msg;
|
||||||
|
feedback.classList.add('visible');
|
||||||
|
setTimeout(() => feedback.classList.remove('visible'), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function verifierSession() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/parent/session');
|
||||||
|
if (res.ok) {
|
||||||
|
ecranPin.classList.add('cacher');
|
||||||
|
ecranApp.classList.remove('cacher');
|
||||||
|
await chargerDonnees();
|
||||||
|
}
|
||||||
|
} catch { /* pas connecté */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connexion() {
|
||||||
|
erreurPin.classList.add('cacher');
|
||||||
|
const pin = pinInput.value;
|
||||||
|
const res = await fetch('/api/parent/connexion', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ pin }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
erreurPin.textContent = data.erreur || 'Erreur';
|
||||||
|
erreurPin.classList.remove('cacher');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ecranPin.classList.add('cacher');
|
||||||
|
ecranApp.classList.remove('cacher');
|
||||||
|
pinInput.value = '';
|
||||||
|
await chargerDonnees();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deconnexion() {
|
||||||
|
await fetch('/api/parent/deconnexion', { method: 'POST' });
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chargerDonnees() {
|
||||||
|
const res = await fetch('/api/parent/donnees');
|
||||||
|
const data = await res.json();
|
||||||
|
enfants = data.enfants;
|
||||||
|
regles = data.regles;
|
||||||
|
|
||||||
|
if (!enfantSelectionne && enfants.length > 0) {
|
||||||
|
enfantSelectionne = enfants[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
afficherSelecteur();
|
||||||
|
afficherRegles();
|
||||||
|
afficherHistorique(data.mouvements);
|
||||||
|
}
|
||||||
|
|
||||||
|
function afficherSelecteur() {
|
||||||
|
const cont = document.getElementById('selecteur-enfant');
|
||||||
|
cont.innerHTML = enfants.map((e) => `
|
||||||
|
<button class="chip-enfant ${e.id === enfantSelectionne ? 'actif' : ''}"
|
||||||
|
data-id="${e.id}">
|
||||||
|
<div class="chip-vignette" style="border-color:${e.couleur}">
|
||||||
|
<img src="${PHOTOS[e.prenom]}" alt="${e.prenom}">
|
||||||
|
<span class="chip-badge" style="color:${e.couleur}">${e.score}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
cont.querySelectorAll('.chip-enfant').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
enfantSelectionne = parseInt(btn.dataset.id, 10);
|
||||||
|
afficherSelecteur();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
mettreAJourBandeau();
|
||||||
|
}
|
||||||
|
|
||||||
|
function mettreAJourBandeau() {
|
||||||
|
const e = enfants.find((x) => x.id === enfantSelectionne);
|
||||||
|
const bandeau = document.getElementById('score-bandeau');
|
||||||
|
if (!e) {
|
||||||
|
bandeau.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bandeau.innerHTML = `
|
||||||
|
<span class="score-bandeau-nom">${e.prenom}</span>
|
||||||
|
<span class="score-bandeau-pts" style="color:${e.couleur}">
|
||||||
|
${e.score} points
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
bandeau.style.borderColor = e.couleur;
|
||||||
|
}
|
||||||
|
|
||||||
|
function afficherRegles() {
|
||||||
|
const cont = document.getElementById('regles-par-famille');
|
||||||
|
const groupes = [];
|
||||||
|
let derniereFamille = null;
|
||||||
|
|
||||||
|
for (const r of regles) {
|
||||||
|
if (r.famille !== derniereFamille) {
|
||||||
|
groupes.push({ famille: r.famille, regles: [] });
|
||||||
|
derniereFamille = r.famille;
|
||||||
|
}
|
||||||
|
groupes[groupes.length - 1].regles.push(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
cont.innerHTML = groupes.map((g) => `
|
||||||
|
<div class="famille-regles">
|
||||||
|
<h4 class="famille-titre">${g.famille}</h4>
|
||||||
|
<div class="grille-regles">
|
||||||
|
${g.regles.map((r) => `
|
||||||
|
<button class="btn-regle ${r.points >= 0 ? 'positif' : 'negatif'}"
|
||||||
|
data-id="${r.id}">
|
||||||
|
${r.icone} ${r.libelle}<br>
|
||||||
|
<strong>${r.points > 0 ? '+' : ''}${r.points}</strong>
|
||||||
|
</button>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
cont.querySelectorAll('.btn-regle').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => appliquerRegle(
|
||||||
|
parseInt(btn.dataset.id, 10),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function appliquerRegle(regleId) {
|
||||||
|
const res = await fetch('/api/parent/appliquer', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
enfant_id: enfantSelectionne,
|
||||||
|
regle_id: regleId,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
afficherFeedback('Erreur');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const idx = enfants.findIndex((e) => e.id === data.enfant.id);
|
||||||
|
if (idx >= 0) enfants[idx] = data.enfant;
|
||||||
|
|
||||||
|
afficherSelecteur();
|
||||||
|
await chargerDonnees();
|
||||||
|
afficherFeedback(`${data.enfant.prenom} : ${data.enfant.score} pts !`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function afficherHistorique(mouvements) {
|
||||||
|
const cont = document.getElementById('historique');
|
||||||
|
if (!mouvements.length) {
|
||||||
|
cont.innerHTML = '<p class="historique-vide">Aucun mouvement</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cont.innerHTML = mouvements.map((m) => {
|
||||||
|
const date = new Date(m.cree_le + 'Z').toLocaleString('fr-FR', {
|
||||||
|
day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit',
|
||||||
|
});
|
||||||
|
const cls = m.delta >= 0 ? 'delta-positif' : 'delta-negatif';
|
||||||
|
const signe = m.delta > 0 ? '+' : '';
|
||||||
|
const libelle = m.recompense || m.regle || m.note || '?';
|
||||||
|
const icone = m.icone_recompense || m.icone || '';
|
||||||
|
return `
|
||||||
|
<div class="historique-item">
|
||||||
|
<span class="historique-libelle">${icone} ${m.enfant} — ${libelle}</span>
|
||||||
|
<span class="historique-droite">
|
||||||
|
<span class="historique-points-col">
|
||||||
|
<span class="${cls}">${signe}${m.delta}</span>
|
||||||
|
<span class="historique-date">${date}</span>
|
||||||
|
</span>
|
||||||
|
<button class="btn-annuler-mouvement" data-id="${m.id}"
|
||||||
|
data-enfant="${m.enfant}" data-libelle="${libelle}"
|
||||||
|
data-delta="${m.delta}" title="Annuler ce mouvement"
|
||||||
|
aria-label="Annuler ce mouvement">✕</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
cont.querySelectorAll('.btn-annuler-mouvement').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => ouvrirModalAnnuler(btn));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ouvrirModalAnnuler(btn) {
|
||||||
|
const delta = parseInt(btn.dataset.delta, 10);
|
||||||
|
const signe = delta > 0 ? '+' : '';
|
||||||
|
mouvementAAnnuler = parseInt(btn.dataset.id, 10);
|
||||||
|
document.getElementById('modal-annuler-texte').textContent =
|
||||||
|
`Annuler « ${btn.dataset.libelle} » pour ${btn.dataset.enfant} (${signe}${delta}) ? Les points seront recalculés.`;
|
||||||
|
modalAnnuler.classList.add('ouvert');
|
||||||
|
}
|
||||||
|
|
||||||
|
function fermerModalAnnuler() {
|
||||||
|
modalAnnuler.classList.remove('ouvert');
|
||||||
|
mouvementAAnnuler = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmerAnnulerMouvement() {
|
||||||
|
if (!mouvementAAnnuler) return;
|
||||||
|
|
||||||
|
const res = await fetch('/api/parent/annuler-mouvement', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ mouvement_id: mouvementAAnnuler }),
|
||||||
|
});
|
||||||
|
|
||||||
|
fermerModalAnnuler();
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
afficherFeedback('Impossible d\'annuler');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await chargerDonnees();
|
||||||
|
afficherFeedback('Mouvement annulé');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('btn-connexion').addEventListener('click', connexion);
|
||||||
|
pinInput.addEventListener('focus', () => pinInput.removeAttribute('readonly'));
|
||||||
|
pinInput.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') connexion();
|
||||||
|
});
|
||||||
|
document.getElementById('btn-deconnexion').addEventListener('click', deconnexion);
|
||||||
|
document.getElementById('btn-confirmer-annuler')
|
||||||
|
.addEventListener('click', confirmerAnnulerMouvement);
|
||||||
|
document.getElementById('btn-fermer-annuler')
|
||||||
|
.addEventListener('click', fermerModalAnnuler);
|
||||||
|
modalAnnuler.addEventListener('click', (e) => {
|
||||||
|
if (e.target === modalAnnuler) fermerModalAnnuler();
|
||||||
|
});
|
||||||
|
|
||||||
|
verifierSession();
|
||||||
|
</script>
|
||||||
|
<script src="/js/pwa.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
web/public/photos/ariana.png
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
web/public/photos/helia.png
Normal file
|
After Width: | Height: | Size: 154 KiB |
BIN
web/public/photos/pablo.png
Normal file
|
After Width: | Height: | Size: 160 KiB |
BIN
web/public/photos/parents.png
Normal file
|
After Width: | Height: | Size: 177 KiB |
250
web/public/reglement.html
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Règlement des Bons Points</title>
|
||||||
|
<style>
|
||||||
|
@page { size: A4 portrait; margin: 10mm; }
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||||
|
font-size: 6.8pt;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: #1a1a2e;
|
||||||
|
background: #fff;
|
||||||
|
width: 210mm;
|
||||||
|
min-height: 297mm;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 6mm 8mm;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 4mm;
|
||||||
|
padding-bottom: 3mm;
|
||||||
|
border-bottom: 2px solid #667eea;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 14pt;
|
||||||
|
color: #667eea;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
.sous-titre {
|
||||||
|
font-size: 8pt;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 1mm;
|
||||||
|
}
|
||||||
|
.enfants {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6mm;
|
||||||
|
margin-top: 2mm;
|
||||||
|
font-size: 8pt;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.enfant-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5mm;
|
||||||
|
padding: 1mm 3mm;
|
||||||
|
border-radius: 20px;
|
||||||
|
color: #333;
|
||||||
|
background: #fff;
|
||||||
|
border: 2px solid;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.enfant-tag img {
|
||||||
|
width: 6mm;
|
||||||
|
height: 6mm;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.grille {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 3mm;
|
||||||
|
}
|
||||||
|
section {
|
||||||
|
break-inside: avoid;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
font-size: 8pt;
|
||||||
|
padding: 1.5mm 2mm;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin-bottom: 1.5mm;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
h2.maison { background: #4a90d9; }
|
||||||
|
h2.routine { background: #9b59b6; }
|
||||||
|
h2.fratrie { background: #e67e22; }
|
||||||
|
h2.respect { background: #e74c3c; }
|
||||||
|
h2.ecrans { background: #34495e; }
|
||||||
|
h2.bonus { background: #f1c40f; color: #333; }
|
||||||
|
h2.boutique { background: #27ae60; }
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-bottom: 2mm;
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
padding: 0.5mm 1mm;
|
||||||
|
vertical-align: top;
|
||||||
|
border-bottom: 0.3px solid #eee;
|
||||||
|
}
|
||||||
|
td.pts {
|
||||||
|
width: 8mm;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.plus { color: #27ae60; }
|
||||||
|
.moins { color: #e74c3c; }
|
||||||
|
.cout { color: #8e44ad; font-weight: 700; }
|
||||||
|
.note {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 6.5pt;
|
||||||
|
color: #888;
|
||||||
|
margin-top: 1mm;
|
||||||
|
padding-top: 2mm;
|
||||||
|
border-top: 1px dashed #ccc;
|
||||||
|
}
|
||||||
|
.boutique-section {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.boutique-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr 1fr;
|
||||||
|
gap: 0 2mm;
|
||||||
|
}
|
||||||
|
.print-hint {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 7pt;
|
||||||
|
color: #667eea;
|
||||||
|
margin-bottom: 3mm;
|
||||||
|
padding: 2mm;
|
||||||
|
background: #f0f4ff;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
@media print {
|
||||||
|
body { padding: 5mm 7mm; }
|
||||||
|
.print-hint { display: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<p class="print-hint">🖨️ Ctrl+P → Format A4, Portrait, marges minimales — pour imprimer ou enregistrer en PDF</p>
|
||||||
|
<header>
|
||||||
|
<h1>⭐ Règlement des Bons Points ⭐</h1>
|
||||||
|
<p class="sous-titre">bonpoint.ptits-pas.fr — Les points ne descendent jamais en dessous de zéro</p>
|
||||||
|
<div class="enfants">
|
||||||
|
<span class="enfant-tag" style="border-color:#E91E8C">
|
||||||
|
<img src="/photos/ariana.png" alt="Ariana"> Ariana
|
||||||
|
</span>
|
||||||
|
<span class="enfant-tag" style="border-color:#2196F3">
|
||||||
|
<img src="/photos/pablo.png" alt="Pablo"> Pablo
|
||||||
|
</span>
|
||||||
|
<span class="enfant-tag" style="border-color:#FF9800">
|
||||||
|
<img src="/photos/helia.png" alt="Hélia"> Hélia
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="grille">
|
||||||
|
<section>
|
||||||
|
<h2 class="maison">🏠 Maison & rangement</h2>
|
||||||
|
<table>
|
||||||
|
<tr><td>🍽️ Je mets la table</td><td class="pts plus">+1</td></tr>
|
||||||
|
<tr><td>🧹 Débarrasser la table</td><td class="pts plus">+1</td></tr>
|
||||||
|
<tr><td>🫧 Ranger le lave-vaisselle</td><td class="pts plus">+1</td></tr>
|
||||||
|
<tr><td>✨ Nettoyer la table du séjour</td><td class="pts plus">+1</td></tr>
|
||||||
|
<tr><td>🎮 Ranger la salle de jeu</td><td class="pts plus">+2</td></tr>
|
||||||
|
<tr><td>🛏️ Ranger sa chambre</td><td class="pts plus">+1</td></tr>
|
||||||
|
<tr><td>👕 Ranger ses habits propres</td><td class="pts plus">+1</td></tr>
|
||||||
|
<tr><td>🏕️ Ranger la cabane</td><td class="pts plus">+1</td></tr>
|
||||||
|
<tr><td>🚲 Ranger les vélos (abri jardin)</td><td class="pts plus">+1</td></tr>
|
||||||
|
<tr><td>👨👩👧 Ranger sur ordre des parents</td><td class="pts plus">+1</td></tr>
|
||||||
|
<tr><td>🗄️ Piquer dans les placards</td><td class="pts moins">−2</td></tr>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 class="routine">⏰ Matin, soir & école</h2>
|
||||||
|
<table>
|
||||||
|
<tr><td>⏰ Habits prêts + s'habiller seul<br><small>Ariana 7h20 / Pablo 8h10</small></td><td class="pts plus">+1</td></tr>
|
||||||
|
<tr><td>🌙 Se préparer pour le lit sans bagarre (< 15 min)</td><td class="pts plus">+1</td></tr>
|
||||||
|
<tr><td>📚 Devoirs faits sans rappel</td><td class="pts plus">+2</td></tr>
|
||||||
|
<tr><td>🎨 Activité calme manuelle 1h</td><td class="pts plus">+2</td></tr>
|
||||||
|
<tr><td>⏰ Retard le matin (Ariana/Pablo)</td><td class="pts moins">−1</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2 class="bonus" style="margin-top:2mm">🌟 Bonus</h2>
|
||||||
|
<table>
|
||||||
|
<tr><td>🌟 Bon point bonus — je suis content !</td><td class="pts plus">+1</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<h2 class="ecrans" style="margin-top:2mm">📱 Écrans</h2>
|
||||||
|
<table>
|
||||||
|
<tr><td>📺 Se lever pour la télé sans autorisation (semaine)</td><td class="pts moins">−3</td></tr>
|
||||||
|
<tr><td>📱 Utiliser un téléphone sans autorisation</td><td class="pts moins">−5</td></tr>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 class="fratrie">👫 Fratrie & entraide</h2>
|
||||||
|
<table>
|
||||||
|
<tr><td>🤝 Aider son frère/sa sœur (sans qu'on demande)</td><td class="pts plus">+2</td></tr>
|
||||||
|
<tr><td>🎁 Partager un jouet sans bagarre</td><td class="pts plus">+1</td></tr>
|
||||||
|
<tr><td>🗣️ Dire « pardon » tout seul</td><td class="pts plus">+1</td></tr>
|
||||||
|
<tr><td>👶 Aider avec Hélia (lire, jouer calmement)</td><td class="pts plus">+1</td></tr>
|
||||||
|
<tr><td>👊 Taper, frapper</td><td class="pts moins">−2</td></tr>
|
||||||
|
<tr><td>😤 Ne pas s'excuser auprès de frère/sœur</td><td class="pts moins">−2</td></tr>
|
||||||
|
<tr><td>😈 Provocation volontaire de frère/sœur</td><td class="pts moins">−2</td></tr>
|
||||||
|
<tr><td>🗣️ Accuser l'autre à tort</td><td class="pts moins">−2</td></tr>
|
||||||
|
<tr><td>💔 Casser / abîmer un jouet (volontairement)</td><td class="pts moins">−3</td></tr>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 class="respect">🙏 Respect & écoute</h2>
|
||||||
|
<table>
|
||||||
|
<tr><td>🤥 Mentir à ses parents</td><td class="pts moins">−2</td></tr>
|
||||||
|
<tr><td>🤬 Dire des gros mots ou insultes</td><td class="pts moins">−1</td></tr>
|
||||||
|
<tr><td>🙄 Répondre mal / faire la tête</td><td class="pts moins">−1</td></tr>
|
||||||
|
<tr><td>🗯️ Crier / hurler</td><td class="pts moins">−1</td></tr>
|
||||||
|
<tr><td>🚪 Faire claquer une porte</td><td class="pts moins">−1</td></tr>
|
||||||
|
<tr><td>👅 Tirer la langue / geste irrespectueux</td><td class="pts moins">−1</td></tr>
|
||||||
|
<tr><td>👂 Ne pas écouter une consigne (plusieurs reprises)</td><td class="pts moins">−2</td></tr>
|
||||||
|
<tr><td>🏃 Partir sans permission (jardin, rue)</td><td class="pts moins">−3</td></tr>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="boutique-section">
|
||||||
|
<h2 class="boutique">🛍️ Boutique — Dépenser ses points</h2>
|
||||||
|
<div class="boutique-grid">
|
||||||
|
<table>
|
||||||
|
<tr><td>🍬 Un bonbon</td><td class="pts cout">1 pt</td></tr>
|
||||||
|
<tr><td>📺 10 min de télé</td><td class="pts cout">1 pt</td></tr>
|
||||||
|
<tr><td>🍪 Un biscuit / cookie</td><td class="pts cout">1 pt</td></tr>
|
||||||
|
<tr><td>🎵 Choisir la musique dans la voiture</td><td class="pts cout">1 pt</td></tr>
|
||||||
|
</table>
|
||||||
|
<table>
|
||||||
|
<tr><td>🍭 Une sucette</td><td class="pts cout">2 pts</td></tr>
|
||||||
|
<tr><td>🍫 Neige</td><td class="pts cout">3 pts</td></tr>
|
||||||
|
<tr><td>🎮 10 min de jeu vidéo</td><td class="pts cout">3 pts</td></tr>
|
||||||
|
<tr><td>🍕 Choisir le menu du repas</td><td class="pts cout">5 pts</td></tr>
|
||||||
|
<tr><td>🍦 Une glace</td><td class="pts cout">5 pts</td></tr>
|
||||||
|
</table>
|
||||||
|
<table>
|
||||||
|
<tr><td>📓 Cahier de dessin neuf</td><td class="pts cout">10 pts</td></tr>
|
||||||
|
<tr><td>🧸 Jouet pas cher (< 10 €)</td><td class="pts cout">50 pts</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p class="note">
|
||||||
|
🌐 Voir ses points : bonpoint.ptits-pas.fr |
|
||||||
|
👨👩👧 Parents : bonpoint.ptits-pas.fr/parent
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
47
web/public/sw.js
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
const CACHE = 'bonpoint-v1';
|
||||||
|
const PRECACHE = [
|
||||||
|
'/',
|
||||||
|
'/css/style.css',
|
||||||
|
'/manifest.json',
|
||||||
|
'/icons/icon-192.png',
|
||||||
|
'/icons/icon-512.png',
|
||||||
|
'/icons/icon.svg',
|
||||||
|
];
|
||||||
|
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE).then((cache) => cache.addAll(PRECACHE)).then(() => self.skipWaiting()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys()
|
||||||
|
.then((keys) => Promise.all(
|
||||||
|
keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)),
|
||||||
|
))
|
||||||
|
.then(() => self.clients.claim()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const { request } = event;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
if (request.method !== 'GET') return;
|
||||||
|
if (url.pathname.startsWith('/api/')) return;
|
||||||
|
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(request).then((cached) => {
|
||||||
|
const fetchPromise = fetch(request).then((response) => {
|
||||||
|
if (response.ok && url.origin === self.location.origin) {
|
||||||
|
const clone = response.clone();
|
||||||
|
caches.open(CACHE).then((cache) => cache.put(request, clone));
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}).catch(() => cached);
|
||||||
|
|
||||||
|
return cached || fetchPromise;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
17
web/scripts/generate-icons.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
const path = require('path');
|
||||||
|
const sharp = require('sharp');
|
||||||
|
|
||||||
|
const svgPath = path.join(__dirname, '..', 'public', 'icons', 'icon.svg');
|
||||||
|
const outDir = path.join(__dirname, '..', 'public', 'icons');
|
||||||
|
|
||||||
|
const sizes = [180, 192, 512];
|
||||||
|
|
||||||
|
Promise.all(sizes.map((size) => sharp(svgPath)
|
||||||
|
.resize(size, size)
|
||||||
|
.png()
|
||||||
|
.toFile(path.join(outDir, size === 180 ? 'apple-touch-icon.png' : `icon-${size}.png`))))
|
||||||
|
.then(() => console.log('Icônes générées'))
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
26
web/src/auth.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
|
||||||
|
const SECRET = process.env.JWT_SECRET || 'dev-secret-changez-moi';
|
||||||
|
const DUREE = '30d';
|
||||||
|
|
||||||
|
function creerToken() {
|
||||||
|
return jwt.sign({ role: 'parent' }, SECRET, { expiresIn: DUREE });
|
||||||
|
}
|
||||||
|
|
||||||
|
function middlewareParent(req, res, next) {
|
||||||
|
const token = req.cookies?.bonpoint_session
|
||||||
|
|| req.headers.authorization?.replace('Bearer ', '');
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return res.status(401).json({ erreur: 'Non authentifié' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
jwt.verify(token, SECRET);
|
||||||
|
next();
|
||||||
|
} catch {
|
||||||
|
return res.status(401).json({ erreur: 'Session expirée' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { creerToken, middlewareParent, SECRET };
|
||||||
492
web/src/db.js
Normal file
@ -0,0 +1,492 @@
|
|||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const dbPath = process.env.DATABASE_PATH
|
||||||
|
|| path.join(__dirname, '..', 'data', 'bonpoint.db');
|
||||||
|
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
db.pragma('foreign_keys = ON');
|
||||||
|
|
||||||
|
function initialiser() {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS enfants (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
prenom TEXT NOT NULL UNIQUE,
|
||||||
|
date_naissance TEXT,
|
||||||
|
score INTEGER NOT NULL DEFAULT 0,
|
||||||
|
couleur TEXT NOT NULL DEFAULT '#6C63FF',
|
||||||
|
ordre INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS regles (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
libelle TEXT NOT NULL,
|
||||||
|
points INTEGER NOT NULL,
|
||||||
|
icone TEXT NOT NULL DEFAULT '⭐',
|
||||||
|
actif INTEGER NOT NULL DEFAULT 1,
|
||||||
|
ordre INTEGER NOT NULL DEFAULT 0,
|
||||||
|
famille TEXT NOT NULL DEFAULT '📋 Autre',
|
||||||
|
famille_ordre INTEGER NOT NULL DEFAULT 99
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS mouvements (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
enfant_id INTEGER NOT NULL,
|
||||||
|
regle_id INTEGER,
|
||||||
|
delta INTEGER NOT NULL,
|
||||||
|
note TEXT,
|
||||||
|
cree_le TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
FOREIGN KEY (enfant_id) REFERENCES enfants(id),
|
||||||
|
FOREIGN KEY (regle_id) REFERENCES regles(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS recompenses (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
libelle TEXT NOT NULL,
|
||||||
|
cout_points INTEGER NOT NULL,
|
||||||
|
icone TEXT NOT NULL DEFAULT '🎁',
|
||||||
|
actif INTEGER NOT NULL DEFAULT 1,
|
||||||
|
ordre INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS config (
|
||||||
|
cle TEXT PRIMARY KEY,
|
||||||
|
valeur TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
migrer();
|
||||||
|
synchroniserRegles();
|
||||||
|
synchroniserRecompenses();
|
||||||
|
|
||||||
|
const nbEnfants = db.prepare('SELECT COUNT(*) AS n FROM enfants').get().n;
|
||||||
|
if (nbEnfants === 0) {
|
||||||
|
insererDonneesInitiales();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const REGLES_VERSION = 'famille-v5';
|
||||||
|
|
||||||
|
const F = {
|
||||||
|
MAISON: '🏠 Maison & rangement',
|
||||||
|
ROUTINE: '⏰ Matin, soir & école',
|
||||||
|
FRATRIE: '👫 Fratrie & entraide',
|
||||||
|
RESPECT: '🙏 Respect & écoute',
|
||||||
|
ECRANS: '📱 Écrans',
|
||||||
|
BONUS: '🌟 Bonus',
|
||||||
|
};
|
||||||
|
|
||||||
|
const REGLES_DEFAUT = [
|
||||||
|
// Maison & rangement
|
||||||
|
{ libelle: 'Je mets la table', points: 1, icone: '🍽️', ordre: 1, famille: F.MAISON, famille_ordre: 1 },
|
||||||
|
{ libelle: 'Débarrasser la table', points: 1, icone: '🧹', ordre: 2, famille: F.MAISON, famille_ordre: 1 },
|
||||||
|
{ libelle: 'Ranger le lave-vaisselle', points: 1, icone: '🫧', ordre: 3, famille: F.MAISON, famille_ordre: 1 },
|
||||||
|
{ libelle: 'Nettoyer la table du séjour', points: 1, icone: '✨', ordre: 4, famille: F.MAISON, famille_ordre: 1 },
|
||||||
|
{ libelle: 'Ranger la salle de jeu', points: 2, icone: '🎮', ordre: 5, famille: F.MAISON, famille_ordre: 1 },
|
||||||
|
{ libelle: 'Ranger sa chambre', points: 1, icone: '🛏️', ordre: 6, famille: F.MAISON, famille_ordre: 1 },
|
||||||
|
{ libelle: 'Ranger ses habits propres', points: 1, icone: '👕', ordre: 7, famille: F.MAISON, famille_ordre: 1 },
|
||||||
|
{ libelle: 'Ranger la cabane', points: 1, icone: '🏕️', ordre: 8, famille: F.MAISON, famille_ordre: 1 },
|
||||||
|
{ libelle: "Ranger les vélos (abri jardin)", points: 1, icone: '🚲', ordre: 9, famille: F.MAISON, famille_ordre: 1 },
|
||||||
|
{ libelle: 'Ranger sur ordre des parents', points: 1, icone: '👨👩👧', ordre: 10, famille: F.MAISON, famille_ordre: 1 },
|
||||||
|
{ libelle: 'Piquer dans les placards', points: -2, icone: '🗄️', ordre: 11, famille: F.MAISON, famille_ordre: 1 },
|
||||||
|
// Matin, soir & école
|
||||||
|
{
|
||||||
|
libelle: "Habits prêts + s'habiller seul (Ariana 7h20 / Pablo 8h10)",
|
||||||
|
points: 1, icone: '⏰', ordre: 1, famille: F.ROUTINE, famille_ordre: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
libelle: 'Se préparer pour le lit sans bagarre (< 15 min)',
|
||||||
|
points: 1, icone: '🌙', ordre: 2, famille: F.ROUTINE, famille_ordre: 2,
|
||||||
|
},
|
||||||
|
{ libelle: 'Devoirs faits sans rappel', points: 2, icone: '📚', ordre: 3, famille: F.ROUTINE, famille_ordre: 2 },
|
||||||
|
{ libelle: 'Activité calme manuelle 1h', points: 2, icone: '🎨', ordre: 4, famille: F.ROUTINE, famille_ordre: 2 },
|
||||||
|
{ libelle: 'Retard le matin (Ariana/Pablo)', points: -1, icone: '⏰', ordre: 5, famille: F.ROUTINE, famille_ordre: 2 },
|
||||||
|
// Fratrie & entraide
|
||||||
|
{
|
||||||
|
libelle: "Aider son frère/sa sœur (sans qu'on demande)",
|
||||||
|
points: 2, icone: '🤝', ordre: 1, famille: F.FRATRIE, famille_ordre: 3,
|
||||||
|
},
|
||||||
|
{ libelle: 'Partager un jouet sans bagarre', points: 1, icone: '🎁', ordre: 2, famille: F.FRATRIE, famille_ordre: 3 },
|
||||||
|
{ libelle: 'Dire « pardon » tout seul', points: 1, icone: '🗣️', ordre: 3, famille: F.FRATRIE, famille_ordre: 3 },
|
||||||
|
{
|
||||||
|
libelle: 'Aider avec Hélia (lire, jouer calmement)',
|
||||||
|
points: 1, icone: '👶', ordre: 4, famille: F.FRATRIE, famille_ordre: 3,
|
||||||
|
},
|
||||||
|
{ libelle: 'Taper, frapper', points: -2, icone: '👊', ordre: 5, famille: F.FRATRIE, famille_ordre: 3 },
|
||||||
|
{
|
||||||
|
libelle: "Ne pas s'excuser auprès de frère/sœur",
|
||||||
|
points: -2, icone: '😤', ordre: 6, famille: F.FRATRIE, famille_ordre: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
libelle: 'Provocation volontaire de frère/sœur',
|
||||||
|
points: -2, icone: '😈', ordre: 7, famille: F.FRATRIE, famille_ordre: 3,
|
||||||
|
},
|
||||||
|
{ libelle: "Accuser l'autre à tort", points: -2, icone: '🗣️', ordre: 8, famille: F.FRATRIE, famille_ordre: 3 },
|
||||||
|
{
|
||||||
|
libelle: 'Casser / abîmer un jouet (volontairement)',
|
||||||
|
points: -3, icone: '💔', ordre: 9, famille: F.FRATRIE, famille_ordre: 3,
|
||||||
|
},
|
||||||
|
// Respect & écoute
|
||||||
|
{ libelle: 'Mentir à ses parents', points: -2, icone: '🤥', ordre: 1, famille: F.RESPECT, famille_ordre: 4 },
|
||||||
|
{ libelle: 'Dire des gros mots ou insultes', points: -1, icone: '🤬', ordre: 2, famille: F.RESPECT, famille_ordre: 4 },
|
||||||
|
{ libelle: 'Répondre mal / faire la tête', points: -1, icone: '🙄', ordre: 3, famille: F.RESPECT, famille_ordre: 4 },
|
||||||
|
{ libelle: 'Crier / hurler', points: -1, icone: '🗯️', ordre: 4, famille: F.RESPECT, famille_ordre: 4 },
|
||||||
|
{ libelle: 'Faire claquer une porte', points: -1, icone: '🚪', ordre: 5, famille: F.RESPECT, famille_ordre: 4 },
|
||||||
|
{
|
||||||
|
libelle: 'Tirer la langue / geste irrespectueux',
|
||||||
|
points: -1, icone: '👅', ordre: 6, famille: F.RESPECT, famille_ordre: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
libelle: 'Ne pas écouter une consigne (plusieurs reprises)',
|
||||||
|
points: -2, icone: '👂', ordre: 7, famille: F.RESPECT, famille_ordre: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
libelle: 'Partir sans permission (jardin, rue)',
|
||||||
|
points: -3, icone: '🏃', ordre: 8, famille: F.RESPECT, famille_ordre: 4,
|
||||||
|
},
|
||||||
|
// Écrans
|
||||||
|
{
|
||||||
|
libelle: 'Se lever pour la télé sans autorisation (semaine)',
|
||||||
|
points: -3, icone: '📺', ordre: 1, famille: F.ECRANS, famille_ordre: 5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
libelle: 'Utiliser un téléphone sans autorisation',
|
||||||
|
points: -5, icone: '📱', ordre: 2, famille: F.ECRANS, famille_ordre: 5,
|
||||||
|
},
|
||||||
|
// Bonus
|
||||||
|
{
|
||||||
|
libelle: 'Bon point bonus — je suis content !',
|
||||||
|
points: 1, icone: '🌟', ordre: 1, famille: F.BONUS, famille_ordre: 6,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function synchroniserRegles() {
|
||||||
|
const version = db.prepare(`
|
||||||
|
SELECT valeur FROM config WHERE cle = 'regles_version'
|
||||||
|
`).get();
|
||||||
|
|
||||||
|
if (version?.valeur === REGLES_VERSION) return;
|
||||||
|
|
||||||
|
db.prepare('UPDATE regles SET actif = 0').run();
|
||||||
|
|
||||||
|
const inserer = db.prepare(`
|
||||||
|
INSERT INTO regles (libelle, points, icone, ordre, famille, famille_ordre)
|
||||||
|
VALUES (@libelle, @points, @icone, @ordre, @famille, @famille_ordre)
|
||||||
|
`);
|
||||||
|
|
||||||
|
for (const r of REGLES_DEFAUT) {
|
||||||
|
inserer.run(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
INSERT OR REPLACE INTO config (cle, valeur)
|
||||||
|
VALUES ('regles_version', @version)
|
||||||
|
`).run({ version: REGLES_VERSION });
|
||||||
|
}
|
||||||
|
|
||||||
|
const RECOMPENSES_VERSION = 'famille-v2';
|
||||||
|
|
||||||
|
const RECOMPENSES_DEFAUT = [
|
||||||
|
{ libelle: 'Un bonbon', cout: 1, icone: '🍬', ordre: 1 },
|
||||||
|
{ libelle: '10 minutes de télé', cout: 1, icone: '📺', ordre: 2 },
|
||||||
|
{ libelle: 'Une sucette', cout: 2, icone: '🍭', ordre: 3 },
|
||||||
|
{ libelle: 'Neige', cout: 3, icone: '🍫', ordre: 4 },
|
||||||
|
{ libelle: '10 minutes de jeu vidéo', cout: 3, icone: '🎮', ordre: 5 },
|
||||||
|
{ libelle: 'Cahier de dessin neuf', cout: 10, icone: '📓', ordre: 6 },
|
||||||
|
{ libelle: 'Jouet pas cher (< 10 €)', cout: 50, icone: '🧸', ordre: 7 },
|
||||||
|
{ libelle: 'Un biscuit / cookie', cout: 1, icone: '🍪', ordre: 8 },
|
||||||
|
{ libelle: 'Choisir la musique dans la voiture', cout: 1, icone: '🎵', ordre: 9 },
|
||||||
|
{ libelle: 'Choisir le menu du repas', cout: 5, icone: '🍕', ordre: 10 },
|
||||||
|
{ libelle: 'Une glace', cout: 5, icone: '🍦', ordre: 11 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function synchroniserRecompenses() {
|
||||||
|
const version = db.prepare(`
|
||||||
|
SELECT valeur FROM config WHERE cle = 'recompenses_version'
|
||||||
|
`).get();
|
||||||
|
|
||||||
|
if (version?.valeur === RECOMPENSES_VERSION) return;
|
||||||
|
|
||||||
|
db.prepare('UPDATE recompenses SET actif = 0').run();
|
||||||
|
|
||||||
|
const inserer = db.prepare(`
|
||||||
|
INSERT INTO recompenses (libelle, cout_points, icone, ordre)
|
||||||
|
VALUES (@libelle, @cout, @icone, @ordre)
|
||||||
|
`);
|
||||||
|
|
||||||
|
for (const r of RECOMPENSES_DEFAUT) {
|
||||||
|
inserer.run(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
INSERT OR REPLACE INTO config (cle, valeur)
|
||||||
|
VALUES ('recompenses_version', @version)
|
||||||
|
`).run({ version: RECOMPENSES_VERSION });
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrer() {
|
||||||
|
const colonnesMvt = db.prepare('PRAGMA table_info(mouvements)').all();
|
||||||
|
const nomsMvt = colonnesMvt.map((c) => c.name);
|
||||||
|
|
||||||
|
if (!nomsMvt.includes('recompense_id')) {
|
||||||
|
db.exec(`
|
||||||
|
ALTER TABLE mouvements
|
||||||
|
ADD COLUMN recompense_id INTEGER REFERENCES recompenses(id)
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
if (!nomsMvt.includes('annule')) {
|
||||||
|
db.exec('ALTER TABLE mouvements ADD COLUMN annule INTEGER NOT NULL DEFAULT 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
const colonnesRegles = db.prepare('PRAGMA table_info(regles)').all();
|
||||||
|
const nomsRegles = colonnesRegles.map((c) => c.name);
|
||||||
|
|
||||||
|
if (!nomsRegles.includes('famille')) {
|
||||||
|
db.exec(`
|
||||||
|
ALTER TABLE regles
|
||||||
|
ADD COLUMN famille TEXT NOT NULL DEFAULT '📋 Autre'
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
if (!nomsRegles.includes('famille_ordre')) {
|
||||||
|
db.exec(`
|
||||||
|
ALTER TABLE regles
|
||||||
|
ADD COLUMN famille_ordre INTEGER NOT NULL DEFAULT 99
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function insererDonneesInitiales() {
|
||||||
|
const enfants = [
|
||||||
|
{ prenom: 'Ariana', date: '2017-11-29', couleur: '#E91E8C', ordre: 1 },
|
||||||
|
{ prenom: 'Pablo', date: '2020-10-23', couleur: '#2196F3', ordre: 2 },
|
||||||
|
{ prenom: 'Hélia', date: '2023-06-22', couleur: '#FF9800', ordre: 3 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const insererEnfant = db.prepare(`
|
||||||
|
INSERT INTO enfants (prenom, date_naissance, score, couleur, ordre)
|
||||||
|
VALUES (@prenom, @date, 0, @couleur, @ordre)
|
||||||
|
`);
|
||||||
|
|
||||||
|
for (const e of enfants) {
|
||||||
|
insererEnfant.run(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pin = process.env.PARENT_PIN || '1234';
|
||||||
|
const hash = bcrypt.hashSync(pin, 10);
|
||||||
|
db.prepare(`
|
||||||
|
INSERT OR REPLACE INTO config (cle, valeur) VALUES ('pin_hash', @hash)
|
||||||
|
`).run({ hash });
|
||||||
|
}
|
||||||
|
|
||||||
|
function insererRecompensesInitiales() {
|
||||||
|
synchroniserRecompenses();
|
||||||
|
}
|
||||||
|
|
||||||
|
function listerEnfants() {
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT id, prenom, date_naissance, score, couleur, ordre
|
||||||
|
FROM enfants ORDER BY ordre
|
||||||
|
`).all();
|
||||||
|
}
|
||||||
|
|
||||||
|
function trouverEnfantParPrenom(prenom) {
|
||||||
|
const normalise = prenom.trim().toLowerCase()
|
||||||
|
.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
||||||
|
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT id, prenom, score, couleur
|
||||||
|
FROM enfants
|
||||||
|
`).all().find((e) => {
|
||||||
|
const p = e.prenom.toLowerCase()
|
||||||
|
.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
||||||
|
return p === normalise;
|
||||||
|
}) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listerRegles() {
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT id, libelle, points, icone, ordre, famille, famille_ordre
|
||||||
|
FROM regles WHERE actif = 1
|
||||||
|
ORDER BY famille_ordre, ordre
|
||||||
|
`).all();
|
||||||
|
}
|
||||||
|
|
||||||
|
function appliquerRegle(enfantId, regleId, note) {
|
||||||
|
const enfant = db.prepare('SELECT * FROM enfants WHERE id = ?').get(enfantId);
|
||||||
|
const regle = db.prepare('SELECT * FROM regles WHERE id = ? AND actif = 1')
|
||||||
|
.get(regleId);
|
||||||
|
|
||||||
|
if (!enfant || !regle) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transaction = db.transaction(() => {
|
||||||
|
const nouveauScore = Math.max(0, enfant.score + regle.points);
|
||||||
|
const deltaReel = nouveauScore - enfant.score;
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO mouvements (enfant_id, regle_id, delta, note)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`).run(enfantId, regleId, deltaReel, note || null);
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE enfants SET score = ? WHERE id = ?
|
||||||
|
`).run(nouveauScore, enfantId);
|
||||||
|
});
|
||||||
|
|
||||||
|
transaction();
|
||||||
|
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT id, prenom, score, couleur FROM enfants WHERE id = ?
|
||||||
|
`).get(enfantId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function listerRecompenses() {
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT id, libelle, cout_points, icone, ordre
|
||||||
|
FROM recompenses WHERE actif = 1 ORDER BY ordre
|
||||||
|
`).all();
|
||||||
|
}
|
||||||
|
|
||||||
|
function listerMouvements(limite = 30) {
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT m.id, m.delta, m.note, m.cree_le, m.recompense_id, m.annule,
|
||||||
|
e.prenom AS enfant,
|
||||||
|
r.libelle AS regle, r.icone,
|
||||||
|
rec.libelle AS recompense, rec.icone AS icone_recompense
|
||||||
|
FROM mouvements m
|
||||||
|
JOIN enfants e ON e.id = m.enfant_id
|
||||||
|
LEFT JOIN regles r ON r.id = m.regle_id
|
||||||
|
LEFT JOIN recompenses rec ON rec.id = m.recompense_id
|
||||||
|
WHERE m.annule = 0
|
||||||
|
ORDER BY m.cree_le DESC
|
||||||
|
LIMIT ?
|
||||||
|
`).all(limite);
|
||||||
|
}
|
||||||
|
|
||||||
|
function listerMouvementsEnfant(enfantId, limite = 30) {
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT m.id, m.delta, m.note, m.cree_le, m.recompense_id,
|
||||||
|
r.libelle AS regle, r.icone,
|
||||||
|
rec.libelle AS recompense, rec.icone AS icone_recompense
|
||||||
|
FROM mouvements m
|
||||||
|
LEFT JOIN regles r ON r.id = m.regle_id
|
||||||
|
LEFT JOIN recompenses rec ON rec.id = m.recompense_id
|
||||||
|
WHERE m.annule = 0 AND m.enfant_id = ?
|
||||||
|
ORDER BY m.cree_le DESC
|
||||||
|
LIMIT ?
|
||||||
|
`).all(enfantId, limite);
|
||||||
|
}
|
||||||
|
|
||||||
|
function acheterRecompense(enfantId, recompenseId) {
|
||||||
|
const enfant = db.prepare('SELECT * FROM enfants WHERE id = ?').get(enfantId);
|
||||||
|
const recompense = db.prepare(`
|
||||||
|
SELECT * FROM recompenses WHERE id = ? AND actif = 1
|
||||||
|
`).get(recompenseId);
|
||||||
|
|
||||||
|
if (!enfant || !recompense) {
|
||||||
|
return { erreur: 'introuvable' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enfant.score < recompense.cout_points) {
|
||||||
|
return { erreur: 'pas_assez_de_points', score: enfant.score,
|
||||||
|
cout: recompense.cout_points };
|
||||||
|
}
|
||||||
|
|
||||||
|
const delta = -recompense.cout_points;
|
||||||
|
const note = `Achat : ${recompense.libelle}`;
|
||||||
|
|
||||||
|
const transaction = db.transaction(() => {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO mouvements (enfant_id, recompense_id, delta, note)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`).run(enfantId, recompenseId, delta, note);
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE enfants SET score = MAX(0, score + ?) WHERE id = ?
|
||||||
|
`).run(delta, enfantId);
|
||||||
|
});
|
||||||
|
|
||||||
|
transaction();
|
||||||
|
|
||||||
|
const enfantMaj = db.prepare(`
|
||||||
|
SELECT id, prenom, score, couleur FROM enfants WHERE id = ?
|
||||||
|
`).get(enfantId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
enfant: enfantMaj,
|
||||||
|
recompense: {
|
||||||
|
libelle: recompense.libelle,
|
||||||
|
icone: recompense.icone,
|
||||||
|
cout: recompense.cout_points,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function annulerMouvement(mouvementId) {
|
||||||
|
const mouvement = db.prepare(`
|
||||||
|
SELECT m.*, e.prenom, e.score AS score_actuel
|
||||||
|
FROM mouvements m
|
||||||
|
JOIN enfants e ON e.id = m.enfant_id
|
||||||
|
WHERE m.id = ? AND m.annule = 0
|
||||||
|
`).get(mouvementId);
|
||||||
|
|
||||||
|
if (!mouvement) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const correction = -mouvement.delta;
|
||||||
|
const nouveauScore = Math.max(0, mouvement.score_actuel + correction);
|
||||||
|
|
||||||
|
const transaction = db.transaction(() => {
|
||||||
|
db.prepare('UPDATE mouvements SET annule = 1 WHERE id = ?')
|
||||||
|
.run(mouvementId);
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE enfants SET score = ? WHERE id = ?
|
||||||
|
`).run(nouveauScore, mouvement.enfant_id);
|
||||||
|
});
|
||||||
|
|
||||||
|
transaction();
|
||||||
|
|
||||||
|
return db.prepare(`
|
||||||
|
SELECT id, prenom, score, couleur FROM enfants WHERE id = ?
|
||||||
|
`).get(mouvement.enfant_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function annulerAchat(mouvementId) {
|
||||||
|
return annulerMouvement(mouvementId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifierPin(pin) {
|
||||||
|
const row = db.prepare(`
|
||||||
|
SELECT valeur FROM config WHERE cle = 'pin_hash'
|
||||||
|
`).get();
|
||||||
|
if (!row) return false;
|
||||||
|
return bcrypt.compareSync(pin, row.valeur);
|
||||||
|
}
|
||||||
|
|
||||||
|
function changerPin(nouveauPin) {
|
||||||
|
const hash = bcrypt.hashSync(nouveauPin, 10);
|
||||||
|
db.prepare(`
|
||||||
|
INSERT OR REPLACE INTO config (cle, valeur) VALUES ('pin_hash', @hash)
|
||||||
|
`).run({ hash });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
db,
|
||||||
|
initialiser,
|
||||||
|
listerEnfants,
|
||||||
|
trouverEnfantParPrenom,
|
||||||
|
listerRegles,
|
||||||
|
listerRecompenses,
|
||||||
|
appliquerRegle,
|
||||||
|
acheterRecompense,
|
||||||
|
annulerAchat,
|
||||||
|
annulerMouvement,
|
||||||
|
listerMouvements,
|
||||||
|
listerMouvementsEnfant,
|
||||||
|
verifierPin,
|
||||||
|
changerPin,
|
||||||
|
};
|
||||||
140
web/src/routes/parent.js
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const {
|
||||||
|
verifierPin, appliquerRegle, annulerMouvement, listerMouvements, changerPin,
|
||||||
|
listerEnfants, listerRegles, listerRecompenses,
|
||||||
|
} = require('../db');
|
||||||
|
const { creerToken, middlewareParent } = require('../auth');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const tentatives = new Map();
|
||||||
|
const MAX_TENTATIVES = 5;
|
||||||
|
const FENETRE_MS = 15 * 60 * 1000;
|
||||||
|
|
||||||
|
function verifierRateLimit(ip) {
|
||||||
|
const maintenant = Date.now();
|
||||||
|
const entree = tentatives.get(ip) || { count: 0, debut: maintenant };
|
||||||
|
|
||||||
|
if (maintenant - entree.debut > FENETRE_MS) {
|
||||||
|
entree.count = 0;
|
||||||
|
entree.debut = maintenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
entree.count += 1;
|
||||||
|
tentatives.set(ip, entree);
|
||||||
|
return entree.count <= MAX_TENTATIVES;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post('/connexion', (req, res) => {
|
||||||
|
const ip = req.ip || 'inconnu';
|
||||||
|
if (!verifierRateLimit(ip)) {
|
||||||
|
return res.status(429).json({
|
||||||
|
erreur: 'Trop de tentatives. Réessayez dans 15 minutes.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pin } = req.body;
|
||||||
|
if (!pin || !/^\d{4,8}$/.test(pin)) {
|
||||||
|
return res.status(400).json({ erreur: 'PIN invalide' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verifierPin(pin)) {
|
||||||
|
return res.status(401).json({ erreur: 'PIN incorrect' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = creerToken();
|
||||||
|
res.cookie('bonpoint_session', token, {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: 30 * 24 * 60 * 60 * 1000,
|
||||||
|
});
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/deconnexion', (_req, res) => {
|
||||||
|
res.clearCookie('bonpoint_session');
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/session', middlewareParent, (_req, res) => {
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/donnees', middlewareParent, (_req, res) => {
|
||||||
|
res.json({
|
||||||
|
enfants: listerEnfants(),
|
||||||
|
regles: listerRegles(),
|
||||||
|
recompenses: listerRecompenses(),
|
||||||
|
mouvements: listerMouvements(20),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/appliquer', middlewareParent, (req, res) => {
|
||||||
|
const { enfant_id, regle_id, note } = req.body;
|
||||||
|
|
||||||
|
if (!enfant_id || !regle_id) {
|
||||||
|
return res.status(400).json({ erreur: 'Paramètres manquants' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultat = appliquerRegle(
|
||||||
|
parseInt(enfant_id, 10),
|
||||||
|
parseInt(regle_id, 10),
|
||||||
|
note,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!resultat) {
|
||||||
|
return res.status(404).json({ erreur: 'Enfant ou règle introuvable' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ enfant: resultat });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/annuler-mouvement', middlewareParent, (req, res) => {
|
||||||
|
const { mouvement_id } = req.body;
|
||||||
|
|
||||||
|
if (!mouvement_id) {
|
||||||
|
return res.status(400).json({ erreur: 'Paramètre manquant' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const enfant = annulerMouvement(parseInt(mouvement_id, 10));
|
||||||
|
|
||||||
|
if (!enfant) {
|
||||||
|
return res.status(404).json({ erreur: 'Mouvement introuvable ou déjà annulé' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ enfant });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/annuler-achat', middlewareParent, (req, res) => {
|
||||||
|
const { mouvement_id } = req.body;
|
||||||
|
|
||||||
|
if (!mouvement_id) {
|
||||||
|
return res.status(400).json({ erreur: 'Paramètre manquant' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const enfant = annulerMouvement(parseInt(mouvement_id, 10));
|
||||||
|
|
||||||
|
if (!enfant) {
|
||||||
|
return res.status(404).json({ erreur: 'Mouvement introuvable ou déjà annulé' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ enfant });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/changer-pin', middlewareParent, (req, res) => {
|
||||||
|
const { ancien_pin, nouveau_pin } = req.body;
|
||||||
|
|
||||||
|
if (!ancien_pin || !nouveau_pin || !/^\d{4,8}$/.test(nouveau_pin)) {
|
||||||
|
return res.status(400).json({ erreur: 'PIN invalide' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verifierPin(ancien_pin)) {
|
||||||
|
return res.status(401).json({ erreur: 'Ancien PIN incorrect' });
|
||||||
|
}
|
||||||
|
|
||||||
|
changerPin(nouveau_pin);
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
63
web/src/routes/public.js
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const {
|
||||||
|
listerEnfants, trouverEnfantParPrenom, listerRegles, listerRecompenses,
|
||||||
|
acheterRecompense, listerMouvementsEnfant,
|
||||||
|
} = require('../db');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get('/scores', (_req, res) => {
|
||||||
|
res.json({ enfants: listerEnfants() });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/regles', (_req, res) => {
|
||||||
|
res.json({ regles: listerRegles() });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/recompenses', (_req, res) => {
|
||||||
|
res.json({ recompenses: listerRecompenses() });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/enfant/:prenom', (req, res) => {
|
||||||
|
const enfant = trouverEnfantParPrenom(req.params.prenom);
|
||||||
|
|
||||||
|
if (!enfant) {
|
||||||
|
return res.status(404).json({ erreur: 'Enfant introuvable' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const limite = Math.min(parseInt(req.query.limite, 10) || 30, 50);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
enfant,
|
||||||
|
mouvements: listerMouvementsEnfant(enfant.id, limite),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/acheter', (req, res) => {
|
||||||
|
const { enfant_id, recompense_id } = req.body;
|
||||||
|
|
||||||
|
if (!enfant_id || !recompense_id) {
|
||||||
|
return res.status(400).json({ erreur: 'Paramètres manquants' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultat = acheterRecompense(
|
||||||
|
parseInt(enfant_id, 10),
|
||||||
|
parseInt(recompense_id, 10),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (resultat.erreur === 'introuvable') {
|
||||||
|
return res.status(404).json({ erreur: 'Enfant ou récompense introuvable' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resultat.erreur === 'pas_assez_de_points') {
|
||||||
|
return res.status(400).json({
|
||||||
|
erreur: 'Pas assez de points',
|
||||||
|
score: resultat.score,
|
||||||
|
cout: resultat.cout,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(resultat);
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
19
web/src/routes/smarthome.js
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { traiterRequeteSmartHome } = require('../smarthome');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.post('/', (req, res) => {
|
||||||
|
try {
|
||||||
|
const reponse = traiterRequeteSmartHome(req.body);
|
||||||
|
res.json(reponse);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Smart Home erreur:', err);
|
||||||
|
res.status(500).json({
|
||||||
|
requestId: req.body?.requestId || 'inconnu',
|
||||||
|
payload: { errorCode: 'transientError' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
65
web/src/routes/voice.js
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { trouverEnfantParPrenom, listerEnfants } = require('../db');
|
||||||
|
const { messageScore, messageTousLesScores } = require('../vocal');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
function reponseVocale(prenom) {
|
||||||
|
if (!prenom) {
|
||||||
|
const enfants = listerEnfants();
|
||||||
|
const speech = messageTousLesScores(enfants);
|
||||||
|
return { speech, enfants };
|
||||||
|
}
|
||||||
|
|
||||||
|
const enfant = trouverEnfantParPrenom(prenom);
|
||||||
|
if (!enfant) {
|
||||||
|
const speech = `Je ne connais pas ${prenom}. Les enfants sont Ariana, Pablo et Hélia.`;
|
||||||
|
return { speech, score: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const speech = messageScore(enfant.prenom, enfant.score);
|
||||||
|
return { speech, score: enfant.score, prenom: enfant.prenom };
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
const prenom = req.query.prenom;
|
||||||
|
const { speech, ...reste } = reponseVocale(prenom);
|
||||||
|
res.json({ speech, ...reste });
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:prenom', (req, res) => {
|
||||||
|
const { speech, ...reste } = reponseVocale(req.params.prenom);
|
||||||
|
res.json({ speech, ...reste });
|
||||||
|
});
|
||||||
|
|
||||||
|
function extrairePrenomDialogflow(body) {
|
||||||
|
const qr = body?.queryResult;
|
||||||
|
if (!qr) return null;
|
||||||
|
|
||||||
|
const params = qr.parameters || {};
|
||||||
|
const depuisParam = params.prenom || params['prenom-enfant'] || params.Prenom
|
||||||
|
|| params.any;
|
||||||
|
if (depuisParam) return depuisParam;
|
||||||
|
|
||||||
|
const intent = (qr.intent?.displayName || '').toLowerCase();
|
||||||
|
if (intent.includes('tous') || intent.includes('all')) return null;
|
||||||
|
|
||||||
|
const texte = (qr.queryText || '').toLowerCase()
|
||||||
|
.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
||||||
|
for (const prenom of ['ariana', 'pablo', 'helia']) {
|
||||||
|
if (texte.includes(prenom)) return prenom;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post('/dialogflow', (req, res) => {
|
||||||
|
const prenom = extrairePrenomDialogflow(req.body);
|
||||||
|
const { speech } = reponseVocale(prenom);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
fulfillmentText: speech,
|
||||||
|
fulfillmentMessages: [{ text: { text: [speech] } }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
49
web/src/server.js
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const cookieParser = require('cookie-parser');
|
||||||
|
const path = require('path');
|
||||||
|
const { initialiser } = require('./db');
|
||||||
|
|
||||||
|
initialiser();
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
app.set('trust proxy', 1);
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(cookieParser());
|
||||||
|
app.get('/sw.js', (_req, res) => {
|
||||||
|
res.set('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||||
|
res.sendFile(path.join(__dirname, '..', 'public', 'sw.js'));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(express.static(path.join(__dirname, '..', 'public')));
|
||||||
|
|
||||||
|
app.use('/api/public', require('./routes/public'));
|
||||||
|
app.use('/api/parent', require('./routes/parent'));
|
||||||
|
app.use('/api/voice', require('./routes/voice'));
|
||||||
|
app.use('/api/smarthome', require('./routes/smarthome'));
|
||||||
|
|
||||||
|
app.get('/parent', (_req, res) => {
|
||||||
|
res.set('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||||||
|
res.sendFile(path.join(__dirname, '..', 'public', 'parent.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/boutique', (_req, res) => {
|
||||||
|
res.redirect('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/boutique/:prenom', (_req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, '..', 'public', 'boutique.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/enfant/:prenom', (_req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, '..', 'public', 'enfant.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/reglement', (_req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, '..', 'public', 'reglement.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, '0.0.0.0', () => {
|
||||||
|
console.log(`Bonpoint démarré sur le port ${PORT}`);
|
||||||
|
});
|
||||||
119
web/src/smarthome.js
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
const { listerEnfants } = require('./db');
|
||||||
|
|
||||||
|
function idEnfant(enfantId) {
|
||||||
|
return `bonpoint-enfant-${enfantId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIdEnfant(deviceId) {
|
||||||
|
const match = /^bonpoint-enfant-(\d+)$/.exec(deviceId);
|
||||||
|
return match ? parseInt(match[1], 10) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function construireAppareils() {
|
||||||
|
return listerEnfants().map((e) => ({
|
||||||
|
id: idEnfant(e.id),
|
||||||
|
type: 'action.devices.types.SENSOR',
|
||||||
|
traits: ['action.devices.traits.EnergyStorage'],
|
||||||
|
name: {
|
||||||
|
name: e.prenom,
|
||||||
|
nicknames: [e.prenom, `bons points ${e.prenom}`],
|
||||||
|
},
|
||||||
|
roomHint: 'Bonpoint',
|
||||||
|
willReportState: true,
|
||||||
|
attributes: {
|
||||||
|
queryOnlyEnergyStorage: true,
|
||||||
|
unit: 'PERCENT',
|
||||||
|
},
|
||||||
|
deviceInfo: {
|
||||||
|
manufacturer: 'Famille',
|
||||||
|
model: 'Bonpoint',
|
||||||
|
hwVersion: '1.0',
|
||||||
|
swVersion: '1.0',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function reponseSync(requestId) {
|
||||||
|
return {
|
||||||
|
requestId,
|
||||||
|
payload: {
|
||||||
|
agentUserId: 'famille-bonpoint',
|
||||||
|
devices: construireAppareils(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function reponseQuery(requestId, deviceIds) {
|
||||||
|
const enfants = listerEnfants();
|
||||||
|
const parId = new Map(enfants.map((e) => [idEnfant(e.id), e]));
|
||||||
|
const devices = {};
|
||||||
|
|
||||||
|
for (const deviceId of deviceIds) {
|
||||||
|
const enfant = parId.get(deviceId);
|
||||||
|
if (!enfant) {
|
||||||
|
devices[deviceId] = {
|
||||||
|
online: false,
|
||||||
|
status: 'ERROR',
|
||||||
|
errorCode: 'deviceNotFound',
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const niveau = Math.min(Math.max(enfant.score, 0), 100);
|
||||||
|
devices[deviceId] = {
|
||||||
|
online: true,
|
||||||
|
status: 'SUCCESS',
|
||||||
|
descriptiveCapacityRemaining: String(niveau),
|
||||||
|
capacityRemaining: [{ rawValue: niveau, unit: 'PERCENT' }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { requestId, payload: { devices } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function reponseExecute(requestId) {
|
||||||
|
return {
|
||||||
|
requestId,
|
||||||
|
payload: {
|
||||||
|
commands: [{
|
||||||
|
ids: [],
|
||||||
|
status: 'ERROR',
|
||||||
|
errorCode: 'notSupported',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function reponseDisconnect(requestId) {
|
||||||
|
return { requestId, payload: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
function traiterRequeteSmartHome(body) {
|
||||||
|
const requestId = body.requestId || 'inconnu';
|
||||||
|
const input = body.inputs?.[0];
|
||||||
|
const intent = input?.intent;
|
||||||
|
|
||||||
|
switch (intent) {
|
||||||
|
case 'action.devices.SYNC':
|
||||||
|
return reponseSync(requestId);
|
||||||
|
case 'action.devices.QUERY': {
|
||||||
|
const deviceIds = (input.payload?.devices || []).map((d) => d.id);
|
||||||
|
return reponseQuery(requestId, deviceIds);
|
||||||
|
}
|
||||||
|
case 'action.devices.EXECUTE':
|
||||||
|
return reponseExecute(requestId);
|
||||||
|
case 'action.devices.DISCONNECT':
|
||||||
|
return reponseDisconnect(requestId);
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
requestId,
|
||||||
|
payload: { errorCode: 'notSupported' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
traiterRequeteSmartHome,
|
||||||
|
idEnfant,
|
||||||
|
parseIdEnfant,
|
||||||
|
};
|
||||||
70
web/src/vocal.js
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
const UNITES = [
|
||||||
|
'zéro', 'un', 'deux', 'trois', 'quatre', 'cinq', 'six', 'sept', 'huit', 'neuf',
|
||||||
|
'dix', 'onze', 'douze', 'treize', 'quatorze', 'quinze', 'seize',
|
||||||
|
];
|
||||||
|
|
||||||
|
const DIZAINES = [
|
||||||
|
'', '', 'vingt', 'trente', 'quarante', 'cinquante', 'soixante',
|
||||||
|
];
|
||||||
|
|
||||||
|
function sousCent(n) {
|
||||||
|
if (n < 17) return UNITES[n];
|
||||||
|
if (n < 20) return 'dix-' + UNITES[n - 10];
|
||||||
|
if (n < 70) {
|
||||||
|
const d = Math.floor(n / 10);
|
||||||
|
const u = n % 10;
|
||||||
|
if (u === 0) return DIZAINES[d];
|
||||||
|
if (u === 1) return DIZAINES[d] + ' et un';
|
||||||
|
return DIZAINES[d] + '-' + UNITES[u];
|
||||||
|
}
|
||||||
|
if (n < 80) {
|
||||||
|
const u = n - 60;
|
||||||
|
if (u === 11) return 'soixante et onze';
|
||||||
|
return 'soixante-' + sousCent(u);
|
||||||
|
}
|
||||||
|
if (n < 100) {
|
||||||
|
const u = n - 80;
|
||||||
|
if (u === 0) return 'quatre-vingts';
|
||||||
|
return 'quatre-vingt-' + sousCent(u);
|
||||||
|
}
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nombreEnFrancais(n) {
|
||||||
|
if (n < 0) return 'moins ' + nombreEnFrancais(-n);
|
||||||
|
if (n < 100) return sousCent(n);
|
||||||
|
if (n < 200) {
|
||||||
|
const reste = n - 100;
|
||||||
|
return reste === 0 ? 'cent' : 'cent ' + sousCent(reste);
|
||||||
|
}
|
||||||
|
return String(n);
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageScore(prenom, score) {
|
||||||
|
const points = nombreEnFrancais(score);
|
||||||
|
const mot = score <= 1 ? 'bon point' : 'bons points';
|
||||||
|
|
||||||
|
if (score === 0) {
|
||||||
|
return `${prenom} n'a pas encore de bons points.`;
|
||||||
|
}
|
||||||
|
if (score < 0) {
|
||||||
|
return `${prenom} a ${points} points. Attention, c'est négatif !`;
|
||||||
|
}
|
||||||
|
return `${prenom} a ${points} ${mot} !`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageTousLesScores(enfants) {
|
||||||
|
if (enfants.length === 0) {
|
||||||
|
return "Je n'ai trouvé aucun enfant.";
|
||||||
|
}
|
||||||
|
|
||||||
|
const parties = enfants.map((e) => {
|
||||||
|
const pts = nombreEnFrancais(e.score);
|
||||||
|
const mot = e.score <= 1 ? 'bon point' : 'bons points';
|
||||||
|
return `${e.prenom} a ${pts} ${mot}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return parties.join('. ') + '.';
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { nombreEnFrancais, messageScore, messageTousLesScores };
|
||||||