Initialise le dépôt Bonpoint avec docs et prototype web.

Structure docs/, web/ (app Node existante) et mobile/ (placeholder Flutter).
This commit is contained in:
jmartin 2026-06-12 11:39:51 +02:00
commit c4ebb5b2d8
40 changed files with 5630 additions and 0 deletions

25
.gitignore vendored Normal file
View 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
View 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
```

View 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 nest PAS
- **PtitsPas** (`app.ptits-pas.fr`) — application pro garde denfants / collectivités (projet séparé, login, SaaS).
- Linstance 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 ; lIAP « sans pub + multi-enfants » doit être le levier principal.
- **Pas de pub** dans lespace parent ni sur les écrans où lenfant 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 dachat |
**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 à lemploi.
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 Ptits 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é dapps « chores / rewards / points ». Angles possibles :
1. **100 % local** — pas dabonnement 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 (23 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 lapp 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 :** 78 semaines à temps partiel.
---
## 13. Critères dacceptation 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 lapp
- [ ] 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 lonboarding.
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
View 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… »).
Lendpoint `/api/voice/dialogflow` reste utile pour **tests** et pour dautres intégrations Dialogflow, mais **ne peut plus être invoqué directement sur une enceinte Nest** via une action conversationnelle.
La voie officielle aujourdhui : **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** (0100 %) = son score de bons points.
Phrases à essayer sur le Nest (en français) :
- « OK Google, **quel est le niveau de batterie dAriana** ? »
- « 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 saffichent 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 dinté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 lapp Google Home.
Options possibles :
- **Auth0** ou **Firebase Auth** (gratuit, adapté à un usage familial)
- OAuth maison (plus technique)
Une fois OAuth configuré, vous liez linté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 à lapp
### 5. Utilisation au quotidien
1. Lier linté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 laction | 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é à lapp (panneau parent)
- Cast TTS vers le Nest après une routine (plus complexe)
- Phrases personnalisées via Home Assistant (si un jour vous ladoptez)

7
mobile/README.md Normal file
View 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
View 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
View File

@ -0,0 +1,4 @@
node_modules/
data/*.db
.env
*.log

25
web/Dockerfile Normal file
View 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
View File

21
web/docker-compose.yml Normal file
View 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

File diff suppressed because it is too large Load Diff

21
web/package.json Normal file
View 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
View 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
View 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
View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

10
web/public/icons/icon.svg Normal file
View 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

View 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
View 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
View 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
View 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
View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

BIN
web/public/photos/helia.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

BIN
web/public/photos/pablo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

250
web/public/reglement.html Normal file
View 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 (&lt; 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 (&lt; 10 €)</td><td class="pts cout">50 pts</td></tr>
</table>
</div>
</section>
<p class="note">
🌐 Voir ses points : bonpoint.ptits-pas.fr &nbsp;|&nbsp;
👨‍👩‍👧 Parents : bonpoint.ptits-pas.fr/parent
</p>
</div>
</body>
</html>

47
web/public/sw.js Normal file
View 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;
}),
);
});

View 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
View 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
View 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
View 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
View 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;

View 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
View 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
View 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
View 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
View 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 };