TODO: mise a jour 2026-06-04 - cleanup massif, helms ansible generés

This commit is contained in:
Eric FELIXINE
2026-06-04 02:05:32 -04:00
parent b56749182e
commit 8c2251faba
8 changed files with 1237 additions and 115 deletions

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Dependencies
node_modules/
*/node_modules/
# Build outputs
dist/
build/
*.pyc
__pycache__/
# Environment files
.env
.env.local
*.env
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db

185
TODO.md
View File

@@ -1,100 +1,129 @@
# Smart City Digital Twin — TODO List
> Dernière mise à jour : 2026-06-01 22:00 (session continue - smart app + ditto)
> Dernière mise à jour : 2026-06-04 00:30 (finalisation documentation)
## ✅ Complété (session 2026-06-01)
## ✅ Complété (session 2026-06-03 / 06-04)
| ID | Tâche | Détail |
|----|-------|--------|
| jupyterhub-fix | JupyterHub DB path | `sqlite:////srv/jupyterhub/jupyterhub.sqlite` (absolute path, 4 slashes) |
| jupyterhub-rebuild | Rebuild Dockerfile | Supprimé double-nested `/srv/jupyterhub/srv/jupyterhub` |
| jupyterhub-spawner | Spawner config | `SimpleLocalProcessSpawner`, timeout 300s |
| jupyterhub-user | User eric | Créé id=2, admin, authorized |
| jupyterhub-sudo | sudo + eric user in container | Dockerfile modifié, spawn vérifié fonctionnel |
| hermes-dashboard | Dashboard WebUI+TUI | systemd service, localhost:9119, auto-boot |
| or-mbtiles-metadata | Bounds monde + center Martinique | `sqlite3` UPDATE sur metadata |
| or-map-settings | mapsettings.json vérifié | center=[-61,14.5], bounds=Martinique, minZoom=0 |
| or-mbtiles-location | mbtiles actif = /storage/map/ | PAS /opt/map/ (écrasé par volume) |
| trino-fix | node.properties créé | `node.environment=production`, `node.id=trino-lakehouse-01` |
| trino-config | config.properties nettoyé | `plugin.bundles` retiré (incompatible Trino 435) |
| kafka-fix | Kafka KRaft env vars | `KAFKA_CFG_*``KAFKA_*`, `CLUSTER_ID` ajouté, volumes recréés |
| git-push | Commits | Pushé sur Gitea (smart-city-digital-twin-martinique + lakehouse) |
| **smart-app-mvp** | **Smart App City MVP complet** | **Voir détail ci-dessous** |
| honcho-api | Honcho API déployée | `honcho-api-1` — Up sur `honcho.digitribe.fr`, workspace `hermes-agent` |
| honcho-plugin | Plugin mémoire Hermes ↔ Honcho | `~/.hermes/honcho.json` configuré, baseUrl `http://127.0.0.1:8089` |
| honcho-mémoire | Mémoire Honcho fonctionnelle | Stockage messages OK. Dialectic chat → nécessite clé LLM valide |
| cicd-pipeline | Gitea Actions CI/CD | Workflow lint + build + deploy, runner docker-runner-01 |
| ci-cd-secrets | Secrets Gitea Actions | SERVER_HOST, SERVER_USER, SSH_PRIVATE_KEY configurés |
| smart-app-docker | Dockerfile web + Traefik | Multi-stage node + nginx, SPA routing, smartapp.digitribe.fr |
| smart-app-deploy | Script de déploiement | `deploy.sh` — web/docker/api/all |
| localai-fix | LocalAI Bad Gateway | Container n'existe plus, config Traefik supprimée |
| ditto-mongodb-fix | MongoDB connection | `-Dditto.mongodb.uri` dans JAVA_TOOL_OPTIONS |
| ditto-secrets | Nouveaux secrets JWT/devops | Générés aléatoirement, sauvegardés `.env.ditto` |
| ditto-official-images | Gateway custom → latest | `eclipse/ditto-gateway:latest` officiel |
| airflow-deploy | Apache Airflow déployé | `airflow.digitribe.fr` — Python 3.11, LocalExecutor |
| openfn-cleanup | OpenFN supprimé | Race condition Cachex/Ecto non résolue |
| ditto-cleanup | Stack Ditto supprimée | API v2 non fonctionnelle (schema-versions) |
| openremote-cleanup | Stack OpenRemote supprimée | Patches bundle appliqués |
| gravitino-cleanup | Gravitino supprimé | Unhealthy |
| fiware-gis-cleanup | FIWARE GIS Quickstart supprimé | |
| contexus-cleanup | Contexus supprimé | Unhealthy |
| kafka-cleanup | Kafka supprimé | Unhealthy + sera redeployé via Helm |
| flink-cleanup | Flink supprimé | Dépendances kafka |
| bi-cleanup | Superset + Metabase supprimés | Seront redeployés via Helm |
| mindsdb-cleanup | MindsDB supprimé | Autoheal unhealthy |
| odk-cleanup | ODK Central supprimé | Sera redeployé via Helm |
| jupyterhub-cleanup | JupyterHub supprimé | Sera redeployé via Helm |
| zeppelin-cleanup | Zeppelin supprimé | Sera redeployé via Helm |
| gis-cleanup | MapStore + GeoServer + FROST supprimés | Seront redeployés via Helm |
| iot-cleanup | Node-RED + phpIPAM + EMQX + Mosquitto + BunkerM + ChirpStack supprimés | Seront redeployés via Helm |
| monitoring-cleanup | Grafana + Loki + Prometheus + InfluxDB + Telegraf supprimés | Seront redeployés via Helm |
| storage-cleanup | MinIO + PostgreSQL + PostGIS + Redis + Zookeeper supprimés | Seront redeployés via Helm |
| misc-cleanup | AgentGateway + Esperotech + Redpanda Console + Docker exporter + Simulator supprimés | |
| backups | Sauvegardes config | Fichiers sauvegardés dans /home/eric/backups/2026-06-03/ |
| helms-ansible | Fichiers Helm/Ansibles générés | 25+ rôles dans /home/eric/helms/ |
| helms-readme | README déploiement K8s | Architecture, installation, troubleshooting |
| helms-vault | Template vault.yml | Variables chiffrées pour le déploiement |
## 🔴 En cours
| ID | Tâche | Raison | Prochaine action |
|----|-------|--------|------------------|
| or-map-bounds | MapService retourne bounds Pays-Bas | Bug MapResourceImpl.java: mbtiles metadata bounds prioritaire sur mapsettings.json | Générer vrai mbtiles MVT Martinique OU patcher code source OR |
| (aucune) | — | — | — |
## ⏳ En attente
## ⏳ En attente (déploiement Kubernetes via Ansible)
| ID | Tâche |
|----|-------|
| or-mbtiles-martinique | Générer mbtiles MVT PBF pour Martinique (tippecanoe depuis GeoJSON filtré) |
| p1-or-map | Vérifier carte Martinique après fix bounds |
| p1-contexus-60 | Configurer les 60 devices Contexus |
| p3-analyse | GeoMesa + KeplerGL |
| p0-chirpstack | ChirpStack login API gRPC-REST |
| p1-thingsboard | Relancer ThingsBoard (si CPU dispo) |
| smart-app Phase 1 | MVP React Native |
| p2-geoserver | GeoServer + PostGIS couches Martinique |
| k8s-cluster | Créer le cluster Kubernetes (3 nœuds minimum) |
| nfs-server | Configurer le serveur NFS pour le storage |
| traefik-deploy | Déployer Traefik via Helm |
| cert-manager-deploy | Déployer cert-manager pour TLS |
| storage-deploy | Déployer NFS provisioner + StorageClass |
| monitoring-deploy | Déployer Prometheus + Grafana + Loki |
| databases-deploy | Déployer PostgreSQL HA + Redis + MinIO |
| kafka-deploy | Déployer Kafka (Strimzi) |
| flink-deploy | Déployer Apache Flink |
| airflow-deploy | Déployer Apache Airflow |
| iot-deploy | Déployer EMQX + Mosquitto + Node-RED + phpIPAM |
| gitea-deploy | Déployer Gitea |
| jupyterhub-deploy | Déployer JupyterHub |
| bi-deploy | Déployer Superset + Metabase |
| mindsdb-deploy | Déployer MindsDB |
| odk-deploy | Déployer ODK Central |
| gis-deploy | Déployer MapStore + GeoServer + FROST |
| clickhouse-deploy | Déployer ClickHouse (`clickhouse.digitribe.fr`) |
| starrocks-deploy | Déployer StarRocks (`starrocks.digitribe.fr`) |
| trino-deploy | Déployer Trino (`trino.digitribe.fr`) |
| deltalake-deploy | Déployer Delta Lake (`deltalake.digitribe.fr`) |
| streamlit-deploy | Déployer Streamlit (`streamlit.digitribe.fr`) |
| duckdb-deploy | Déployer DuckDB (`duckdb.digitribe.fr`) |
| smartapp-deploy | Déployer Smart App (`smartapp.digitribe.fr`) |
| backup-deploy | Déployer Velero pour les sauvegardes |
## 📝 Notes techniques 2026-06-01
## 📁 Fichiers Helm / Ansible générés
### OpenRemote mbtiles — Points critiques
- Fichier actif : `/storage/map/mapdata.mbtiles` (volume Docker), PAS `/opt/map/`
- OR 1.24.0 ne sert que du **PBF vectoriel** — PNG raster = 404
- Bug : MapService.java donne priorité aux bounds du mbtiles metadata sur mapsettings.json
- Fix : bounds mbtiles metadata = monde (`-180,-85,180,85`), bounds mapsettings = zone désirée
- Pour mettre à jour : `docker cp file.mbtiles openremote-manager:/storage/map/mapdata.mbtiles`
```
helms/
├── README.md # Documentation déploiement
├── deploy.yml # Playbook principal
├── undeploy.yml # Playbook de suppression
├── inventory/
│ └── hosts.yml # Inventory des nœuds K8s
├── group_vars/
│ ├── all.yml # Variables globales
│ └── vault.yml # Variables chiffrées (template)
└── roles/ # 25+ rôles Ansible
├── prerequisites/
├── namespaces/
├── storage/
├── traefik/
├── cert-manager/
├── monitoring/
├── databases/
├── kafka/
├── flink/
├── airflow/
├── iot/
├── gitea/
├── jupyterhub/
├── bi/
├── mindsdb/
├── odk/
├── gis/
├── clickhouse/
├── starrocks/
├── trino/
├── deltalake/
├── streamlit/
├── duckdb/
├── nodered/
├── phpipam/
├── smartapp/
└── backup/
```
### JupyterHub
- Port : 8000 (pas 8080) — accessible via https://jupyter.digitribe.fr
- User eric : id=2, admin, créé via NativeAuthenticator
- Config : `SimpleLocalProcessSpawner`, timeout 300s
- DB : `sqlite:////srv/jupyterhub/jupyterhub.sqlite` (absolute path, 4 slashes)
- `eric` OS user avec sudo NOPASSWD dans le container
- `jupyterhub-singleuser --version` = 5.3.0, `jupyter-lab --version` = 4.5.7
## 📝 Infrastructure actuelle (10 containers Docker)
### Kafka (KRaft)
- `apache/kafka:3.9.0` utilise `KAFKA_*` (pas `KAFKA_CFG_*` qui est Bitnami)
- `CLUSTER_ID=MkU3OEVBNTcwNTJENDM2Qk` requis pour storage formatting
- 2 brokers en mode KRaft (broker+controller), pas de ZooKeeper
### Trino
- Config dans `/home/eric/lakehouse/docker-compose/config/trino/`
- `node.id=trino-lakehouse-01` (pas `_internal_`)
- `plugin.bundles` retiré de config.properties (incompatible Trino 435)
### Infrastructure
- 86+ conteneurs Docker
- Kafka, Trino, JupyterHub = UP ✅ (fixes appliqués cette session)
- Tous les autres services principaux = UP ✅
| Service | Image | Statut |
|---------|-------|--------|
| airflow-scheduler | apache/airflow:2.9.3-python3.11 | ✅ healthy |
| airflow-webserver | apache/airflow:2.9.3-python3.11 | ✅ healthy |
| airflow-init | apache/airflow:2.9.3-python3.11 | 🔄 restarting (one-shot) |
| airflow-postgres | postgres:16 | ✅ healthy |
| smartapp-api | smartapp-api:latest | ✅ Up 38h |
| smartapp-web | nginx:alpine | ✅ Up 38h |
| gitea-runner | gitea/act_runner:latest | ✅ Up 2 days |
| traefik | traefik:v3.1 | ✅ Up 2 days |
| smart-city-kepler | smart-city-kepler:latest | ✅ Up 2 weeks |
| gitea | gitea/gitea:latest | ✅ Up 2 jours |
## Credentials
- **Contexus**: iotevadmin / Digitribe972
- **OpenRemote**: admin / Digitribe972
- **PostgreSQL Contexus**: contexus / Digitribe972
- **Redis Contexus**: Digitribe972
- **Telegraf InfluxDB**: token=my-super-token, org=digitribe, bucket=smartcity
- **Grafana**: admin / Digitribe972
- **Superset**: admin / Digitribe972
- **Metabase**: admin@digitribe.fr / Digitribe972
- **BunkerM MQTT**: bunker / bunker
- **ChirpStack**: admin / Digitribe972
- **ODK Central**: efelixine@digitribe.fr / Digitribe972
- **JupyterHub**: eric / admin (admin) — via NativeAuthenticator
- **MindsDB**: admin@digitribe.fr / Digitribe972
- **Gitea** : eric / (voir config)
- **Airflow** : admin / (changé par Eric)

View File

@@ -1,61 +1,183 @@
# Smart App City — Mobile Application
# Smart App City — Application Mobile
> Multi-platform mobile application for Smart City Digital Twin Martinique
> Application mobile multi-platforme pour le Smart City Digital Twin Martinique
## Quick Start
## 📱 Présentation
### Prerequisites
Smart App City est une application mobile (React Native + Expo) qui permet aux citoyens de :
- **Visualiser les données IoT** en temps réel (capteurs de température, humidité, qualité de l'air, bruit, trafic, énergie)
- **Accéder à une marketplace** de services et produits locaux
- **Interagir avec un assistant IA** (météo, transports, énergie, alertes)
- **Gérer ses notifications** et **profil utilisateur**
## 🏗️ Architecture
```
smart-app-city/
├── frontend/ # Application React Native + Expo (TypeScript)
│ ├── src/
│ │ ├── screens/ # 15 écrans (Auth, Dashboard, Map, Marketplace, Chat, Profile, IoT, Notifications)
│ │ ├── components/ # Composants réutilisables (Card, Button, Charts, Maps)
│ │ ├── stores/ # State management (Zustand) — authStore, iotStore, notificationStore, uiStore
│ │ ├── hooks/ # Hooks personnalisés (useSensors, useAlerts, useNotifications, useLocation)
│ │ ├── services/ # Services API (auth, iot, notification, via Axios)
│ │ ├── i18n/ # Internationalisation (FR/EN/ES/DE)
│ │ ├── theme/ # Design system (Blue Ocean palette)
│ │ └── utils/ # Utilitaires (formatters, validators, constants)
│ ├── dist/ # Build web statique (nginx)
│ ├── Dockerfile # Multi-stage build (node + nginx)
│ ├── package.json # Dépendances (Expo 51, React Native 0.74)
│ └── app.json # Configuration Expo
├── backend/ # Backend microservices (Node.js)
├── ai/ # Services IA (RAG, Agent)
├── design/ # Maquettes HTML/CSS (28 screenshots, PDF, Figma JSON, Penpot SVG)
├── docs/ # Documentation
└── docker-compose.yml # Orchestration Docker
```
## 🚀 Développement
### Prérequis
- Node.js 20+
- Expo CLI : `npm install -g expo-cli`
- Docker & Docker Compose
- Docker & Docker Compose (pour le déploiement)
### Development
### Installation
```bash
# Install dependencies
cd frontend
npm install
# Start development server
npx expo start
# Run on device
# Scan QR code with Expo Go app
npm install --legacy-peer-deps
```
### Backend
### Lancement en mode développement
```bash
# Start all services
cd ..
docker-compose up -d
# Mode natif (iOS/Android)
npm start
# Scanner le QR code avec l'app Expo Go
# Start individual service
cd backend/auth-service
npm run start:dev
# Mode web
npm run web
```
### AI Services
### Build web statique
```bash
# Start RAG service
cd ai/rag-service
pip install -r requirements.txt
uvicorn main:app --reload --port 8001
# Sans Docker
cd frontend
npx expo export --platform web --output-dir dist
# Start Agent service
cd ai/agent-service
pip install -r requirements.txt
uvicorn main:app --reload --port 8002
# Avec Docker
docker build -t smartapp-web:latest .
```
## Documentation
### Déploiement
- [Architecture](docs/ARCHITECTURE.md)
- [Beckn Integration](docs/BECKN_INTEGRATION.md)
- [AI Architecture](docs/AI_ARCHITECTURE.md)
- [Internationalization](docs/I18N.md)
```bash
cd smart-app-city
docker compose -f docker-compose.web.yml up -d
```
## License
L'application est accessible sur : **https://smartapp.digitribe.fr**
## 🎨 Design System
### Palette principale
- **Blue Ocean** : `#1565C0` (primaire)
- **Indigo** : `#3949AB` (accent)
- **Cyan** : `#00ACC1` (secondaire)
- **Deep Ocean** : `#0D1B2A` (dark mode)
### Composants
Tous les composants UI sont dans `src/components/` :
- `common/Card.tsx` — Carte réutilisable (3 variants : default, elevated, outlined)
- `common/Button.tsx` — Bouton (5 variants, 3 tailles)
- `common/Header.tsx` — En-tête de section
- `common/LoadingSpinner.tsx` — Indicateur de chargement
- `common/ErrorBoundary.tsx` — Gestion d'erreurs React
- `cards/SensorCard.tsx` — Carte capteur IoT
- `cards/StatsCard.tsx` — Carte statistiques
- `cards/AlertCard.tsx` — Carte alerte
- `cards/ZoneCard.tsx` — Carte zone
- `charts/LineChart.tsx` — Graphique linéaire
- `charts/BarChart.tsx` — Graphique barres
- `charts/GaugeChart.tsx` — Jauge semi-circulaire
- `maps/MapView.tsx` — Vue carte (placeholder pour react-native-maps)
- `maps/MarkerPopup.tsx` — Popup marqueur
## 📊 State Management (Zustand)
### Stores
- **authStore** : Authentification utilisateur (login, register, logout, refresh token)
- **iotStore** : Données IoT (6 capteurs, 3 zones, 2 alertes mock)
- **notificationStore** : Notifications (5 mock notifications)
- **uiStore** : Thème + i18n (FR/EN/ES/DE)
### Hooks
- **useSensors()** : Liste des capteurs, filtrage par type/zone
- **useAlerts()** : Alertes actives/critiques, acknowledge
- **useNotifications()** : CRUD notifications
- **useLocation()** : GPS avec expo-location (défaut : Fort-de-France)
## 🌐 Internationalisation
4 langues supportées : Français (défaut), English, Español, Deutsch.
Fichier de traductions : `src/stores/uiStore.ts` (fonction `t(key, lang)`)
## 🔌 API Backend
L'application communique avec l'API Gateway :
- **Base URL** : `https://api-smartapp.digitribe.fr/api/v1`
- **Authentification** : JWT (Basic Auth pour les routes /admin)
- **Endpoints principaux** :
- `POST /api/auth/login` — Connexion
- `POST /api/auth/register` — Inscription
- `POST /api/auth/refresh` — Refresh token
- `GET /sensors` — Liste des capteurs
- `GET /zones` — Liste des zones
- `GET /alerts` — Liste des alertes
## 📦 Déploiement CI/CD
### Gitea Actions
Fichier : `.gitea/workflows/build-and-deploy.yml`
Pipeline :
1. **Lint** : `tsc --noEmit` (TypeScript check)
2. **Build** : `npx expo export --platform web`
3. **Deploy** : SSH vers le serveur → copie les fichiers ou rebuild Docker
### Secrets Gitea
- `SERVER_HOST` : `localhost`
- `SERVER_USER` : `eric`
- `SSH_PRIVATE_KEY` : Clé SSH pour le déploiement
### Dockerfiles
- `frontend/Dockerfile` : Multi-stage build (node 20 alpine → nginx alpine)
- `docker-compose.web.yml` : Service nginx statique avec Traefik
## 🌍 Services liés
| Service | URL | Description |
|---------|-----|-------------|
| Gitea | https://gitea.digitribe.fr | Git + CI/CD |
| Honcho | https://honcho.digitribe.fr | Mémoire agent IA |
| Grafana | http://127.0.0.1:3088 | Métriques Prometheus |
## 📝 Notes techniques importantes
### WatermelonDB supprimé
Le package `@nozbe/watermelonDB` a été supprimé car la version `^0.27.0` n'existe plus. Pas de remplacement nécessaire — la mémoire locale utilise Zustand + AsyncStorage.
### Build web Expo
- Le build `npx expo export:web` nécessite `@expo/webpack-config@~19.0.1`
- Si le build échoue avec "Lock compromised" : supprimer `package-lock.json` et `node_modules/`, puis `npm install --legacy-peer-deps`
- Le build Docker est plus fiable que le build local (environnement isolé)
### Patch tool
Les fichiers contenant `//`, `=`, `+`, `{}` dans les strings peuvent corriger le tool `patch`. Utiliser `replace_all=true` ou échapper les caractères spéciaux.
## 📄 Licence
Smart City Digital Twin Martinique — Projet public

View File

@@ -0,0 +1,6 @@
FROM node:20-alpine
WORKDIR /app
COPY server.js .
RUN npm init -y && npm install express bcryptjs jsonwebtoken cors
EXPOSE 3001
CMD ["node", "server.js"]

View File

@@ -0,0 +1,175 @@
// Smart App City — Auth Service (Node.js + Express)
const express = require('express');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const cors = require('cors');
const fs = require('fs');
const path = require('path');
const app = express();
const PORT = 3001;
const JWT_SECRET = process.env.JWT_SECRET || 'smart-app-city-jwt-secret-2024';
const DB_PATH = path.join(__dirname, 'users.json');
app.use(cors());
app.use(express.json());
// ── Database helpers ──
function loadUsers() {
if (!fs.existsSync(DB_PATH)) {
fs.writeFileSync(DB_PATH, JSON.stringify({ users: [] }, null, 2));
}
return JSON.parse(fs.readFileSync(DB_PATH, 'utf8'));
}
function saveUsers(db) {
fs.writeFileSync(DB_PATH, JSON.stringify(db, null, 2));
}
// ── Create admin user on startup ──
function createAdminUser() {
const db = loadUsers();
// Admin user
if (!db.users.find(u => u.email === 'admin@digitribe.fr')) {
const hashedPassword = bcrypt.hashSync('Digitribe972', 10);
db.users.push({
id: 'admin-001',
email: 'admin@digitribe.fr',
password: hashedPassword,
firstName: 'Admin',
lastName: 'Digitribe',
roles: ['admin', 'user'],
createdAt: new Date().toISOString()
});
saveUsers(db);
console.log('✅ Admin user created: admin@digitribe.fr / Digitribe972');
} else {
console.log(' Admin user already exists');
}
// Eric user
if (!db.users.find(u => u.email === 'eric@digitribe.fr')) {
const hashedPassword = bcrypt.hashSync('Digitribe972', 10);
db.users.push({
id: 'eric-001',
email: 'eric@digitribe.fr',
password: hashedPassword,
firstName: 'Eric',
lastName: 'Felixine',
roles: ['admin', 'user'],
createdAt: new Date().toISOString()
});
saveUsers(db);
console.log('✅ Eric user created: eric@digitribe.fr / Digitribe972');
} else {
console.log(' Eric user already exists');
}
// Erol user
if (!db.users.find(u => u.email === 'erol@digitribe.fr')) {
const hashedPassword = bcrypt.hashSync('erol', 10);
db.users.push({
id: 'erol-001',
email: 'erol@digitribe.fr',
password: hashedPassword,
firstName: 'Erol',
lastName: 'Digitribe',
roles: ['user'],
createdAt: new Date().toISOString()
});
saveUsers(db);
console.log('✅ Erol user created: erol@digitribe.fr / erol');
} else {
console.log(' Erol user already exists');
}
}
// ── Routes ──
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', service: 'auth-service' });
});
// Register
app.post('/api/auth/register', async (req, res) => {
try {
const { email, password, firstName, lastName } = req.body;
if (!email || !password || !firstName || !lastName) {
return res.status(400).json({ message: 'Tous les champs sont requis' });
}
if (password.length < 8) {
return res.status(400).json({ message: 'Le mot de passe doit contenir au moins 8 caractères' });
}
const db = loadUsers();
if (db.users.find(u => u.email === email)) {
return res.status(409).json({ message: 'Un compte avec cet email existe déjà' });
}
const hashedPassword = await bcrypt.hash(password, 10);
const user = {
id: 'user-' + Date.now(),
email,
password: hashedPassword,
firstName,
lastName,
roles: ['user'],
createdAt: new Date().toISOString()
};
db.users.push(user);
saveUsers(db);
const token = jwt.sign({ id: user.id, email: user.email, roles: user.roles }, JWT_SECRET, { expiresIn: '7d' });
res.json({ accessToken: token, user: { id: user.id, email: user.email, firstName: user.firstName, lastName: user.lastName, roles: user.roles } });
} catch (e) {
res.status(500).json({ message: 'Erreur serveur' });
}
});
// Login
app.post('/api/auth/login', async (req, res) => {
try {
const { email, password } = req.body;
if (!email || !password) {
return res.status(400).json({ message: 'Email et mot de passe requis' });
}
const db = loadUsers();
const user = db.users.find(u => u.email === email);
if (!user) {
return res.status(401).json({ message: 'Identifiants incorrects' });
}
const valid = await bcrypt.compare(password, user.password);
if (!valid) {
return res.status(401).json({ message: 'Identifiants incorrects' });
}
const token = jwt.sign({ id: user.id, email: user.email, roles: user.roles }, JWT_SECRET, { expiresIn: '7d' });
res.json({ accessToken: token, user: { id: user.id, email: user.email, firstName: user.firstName, lastName: user.lastName, roles: user.roles } });
} catch (e) {
res.status(500).json({ message: 'Erreur serveur' });
}
});
// Get current user
app.get('/api/auth/me', (req, res) => {
try {
const token = req.headers.authorization?.split(' ')[1];
if (!token) return res.status(401).json({ message: 'Non authentifié' });
const decoded = jwt.verify(token, JWT_SECRET);
const db = loadUsers();
const user = db.users.find(u => u.id === decoded.id);
if (!user) return res.status(404).json({ message: 'Utilisateur non trouvé' });
res.json({ id: user.id, email: user.email, firstName: user.firstName, lastName: user.lastName, roles: user.roles });
} catch (e) {
res.status(401).json({ message: 'Token invalide' });
}
});
// Logout (client-side, but endpoint for completeness)
app.post('/api/auth/logout', (req, res) => {
res.json({ message: 'Déconnexion réussie' });
});
// Start server
app.listen(PORT, () => {
console.log(`🚀 Auth service running on port ${PORT}`);
createAdminUser();
});

View File

@@ -0,0 +1,35 @@
services:
web:
image: nginx:alpine
container_name: smartapp-web
restart: unless-stopped
networks:
- traefik-public
volumes:
- /home/eric/smart-city-digital-twin-martinique/smart-app-city/frontend/dist:/usr/share/nginx/html
labels:
- "traefik.enable=false"
expose:
- "80"
api:
build:
context: ./backend/auth-service
dockerfile: Dockerfile
container_name: smartapp-api
restart: unless-stopped
networks:
- traefik-public
volumes:
- /home/eric/smart-city-digital-twin-martinique/smart-app-city/backend/auth-service/users.json:/app/users.json
expose:
- "3001"
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:3001/health"]
interval: 10s
timeout: 5s
retries: 3
networks:
traefik-public:
external: true

705
smart-app-city/frontend/dist/index.html vendored Normal file
View File

@@ -0,0 +1,705 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
<title>Smart City Martinique</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
:root {
--primary: #1565C0; --primary-dark: #0D47A1; --accent: #00ACC1;
--bg: #0D1B2A; --bg-card: #1E3350; --bg-input: #1B2838;
--text: #E8EAF6; --text-secondary: #9FA8DA; --text-muted: #5C6BC0;
--danger: #D32F2F; --success: #2E7D32; --warning: #F57C00;
--radius: 16px; --radius-sm: 8px; --radius-full: 9999px;
}
html, body { height: 100%; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg); color: var(--text); min-height: 100%;
display: flex; flex-direction: column;
}
/* ── Top Nav ── */
.top-nav {
background: var(--bg-card);
border-bottom: 1px solid rgba(255,255,255,0.05);
display: flex;
justify-content: space-around;
align-items: center;
padding: 6px 0;
flex-shrink: 0;
}
.nav-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 1px;
text-decoration: none;
color: var(--text-muted);
font-size: 10px;
padding: 6px 10px;
border-radius: var(--radius-sm);
transition: color 0.2s;
position: relative;
}
.nav-item.active { color: var(--accent); }
.nav-item-icon { font-size: 20px; }
.nav-badge {
position: absolute;
top: 2px;
right: 4px;
background: var(--danger);
color: white;
border-radius: 8px;
padding: 0 4px;
font-size: 9px;
font-weight: 700;
min-width: 14px;
text-align: center;
}
/* ── Header ── */
.header {
background: linear-gradient(135deg, var(--primary), var(--accent));
padding: 30px 20px 50px;
text-align: center;
flex-shrink: 0;
}
.logo { width: 70px; height: 70px; background: rgba(255,255,255,0.15); border-radius: 18px; margin: 0 auto 12px; display: flex; align-items: center; justify-content: center; font-size: 36px; }
.header h1 { font-size: 24px; font-weight: 700; color: white; margin-bottom: 2px; }
.header p { font-size: 13px; color: rgba(255,255,255,0.8); }
/* ── Form ── */
.form-container { flex: 1; padding: 20px; max-width: 420px; margin: 0 auto; width: 100%; }
.form-title { font-size: 22px; font-weight: 700; margin-bottom: 24px; }
.input-group { margin-bottom: 16px; }
.input-group label { display: block; font-size: 13px; font-weight: 600; color: var(--text-secondary); margin-bottom: 6px; }
.input-wrapper { display: flex; align-items: center; background: var(--bg-input); border-radius: var(--radius-sm); border: 1px solid transparent; transition: border 0.2s; }
.input-wrapper:focus-within { border-color: var(--accent); }
.input-wrapper .icon { padding: 0 12px; font-size: 16px; }
.input-wrapper input { flex: 1; background: none; border: none; outline: none; color: var(--text); font-size: 15px; padding: 14px 12px 14px 0; }
.input-wrapper input::placeholder { color: var(--text-muted); }
.toggle-pw { padding: 0 12px; cursor: pointer; font-size: 16px; }
/* ── Buttons ── */
.btn { display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%; padding: 14px; border: none; border-radius: var(--radius-sm); font-size: 15px; font-weight: 700; cursor: pointer; transition: opacity 0.2s, transform 0.1s; }
.btn:active { transform: scale(0.98); opacity: 0.9; }
.btn-primary { background: var(--primary); color: white; }
.btn-secondary { background: var(--bg-card); color: var(--text); border: 1px solid var(--primary-dark); }
.btn-google { background: #fff; color: #333; }
.btn-apple { background: #000; color: #fff; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn .btn-icon { font-size: 18px; }
/* ── Links ── */
.link { color: var(--accent); text-decoration: none; font-size: 14px; font-weight: 600; }
.link:hover { text-decoration: underline; }
.text-center { text-align: center; }
.mt-12 { margin-top: 12px; }
.mt-24 { margin-top: 24px; }
.mb-8 { margin-bottom: 8px; }
/* ── Divider ── */
.divider { display: flex; align-items: center; gap: 16px; margin: 24px 0; }
.divider::before, .divider::after { content: ''; flex: 1; height: 1px; background: var(--bg-card); }
.divider span { font-size: 12px; color: var(--text-muted); }
/* ── Error/Success ── */
.alert { padding: 12px; border-radius: var(--radius-sm); font-size: 13px; margin-bottom: 16px; display: none; }
.alert.show { display: block; }
.alert-error { background: rgba(211,47,47,0.15); color: #EF5350; border: 1px solid rgba(211,47,47,0.3); }
.alert-success { background: rgba(46,125,50,0.15); color: #66BB6A; border: 1px solid rgba(46,125,50,0.3); }
/* ── Checkbox ── */
.checkbox { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text-secondary); cursor: pointer; }
.checkbox input { width: 18px; height: 18px; accent-color: var(--accent); }
/* ── Loading ── */
.spinner { width: 20px; height: 20px; border: 2px solid rgba(255,255,255,0.3); border-top-color: white; border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Transitions ── */
.screen { display: none; min-height: 100vh; flex-direction: column; }
.screen.active { display: flex; }
/* ── Home Dashboard ── */
.dashboard-header {
background: linear-gradient(135deg, var(--primary), var(--accent));
padding: 20px 16px 50px;
flex-shrink: 0;
}
.dashboard-header-top { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
.greeting { font-size: 13px; color: rgba(255,255,255,0.85); }
.user-name { font-size: 20px; font-weight: 700; color: white; }
.notification-btn { position: relative; background: none; border: none; font-size: 24px; cursor: pointer; }
.notification-badge { position: absolute; top: -4px; right: -4px; background: var(--danger); color: white; border-radius: 10px; padding: 1px 5px; font-size: 10px; font-weight: 700; }
.search-bar { display: flex; align-items: center; background: rgba(255,255,255,0.2); border-radius: var(--radius-full); padding: 10px 16px; gap: 8px; }
.search-bar input { flex: 1; background: none; border: none; outline: none; color: white; font-size: 14px; }
.search-bar input::placeholder { color: rgba(255,255,255,0.5); }
/* ── Metrics Row ── */
.metrics-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; padding: 0 16px; margin-top: -25px; }
.metric-card { background: var(--bg-card); border-radius: var(--radius); padding: 12px 8px; text-align: center; border: 1px solid rgba(255,255,255,0.05); }
.metric-value { font-size: 20px; font-weight: 700; }
.metric-unit { font-size: 10px; color: var(--text-muted); }
.metric-label { font-size: 9px; color: var(--text-muted); margin-top: 2px; }
/* ── Section ── */
.section { padding: 16px; }
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.section-title { font-size: 16px; font-weight: 700; }
.see-all { font-size: 13px; color: var(--accent); text-decoration: none; }
/* ── Cards ── */
.card { background: var(--bg-card); border-radius: var(--radius); padding: 14px; margin-bottom: 10px; border: 1px solid rgba(255,255,255,0.05); }
.card-title { font-size: 14px; font-weight: 700; margin-bottom: 4px; }
.card-subtitle { font-size: 12px; color: var(--text-secondary); }
.card-row { display: flex; justify-content: space-between; align-items: center; margin-top: 8px; }
/* ── Quick Actions ── */
.quick-actions { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
.quick-action { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 12px 8px; background: var(--bg-card); border-radius: var(--radius); text-decoration: none; color: var(--text); }
.quick-action-icon { width: 48px; height: 48px; border-radius: 14px; display: flex; align-items: center; justify-content: center; font-size: 22px; }
.quick-action-label { font-size: 10px; color: var(--text-secondary); text-align: center; }
/* ── Scrollable content ── */
.scrollable { flex: 1; overflow-y: auto; }
/* ── Badge ── */
.badge { display: inline-block; padding: 2px 8px; border-radius: var(--radius-full); font-size: 10px; font-weight: 600; cursor: pointer; }
.badge-green { background: rgba(46,125,50,0.2); color: #66BB6A; }
.badge-orange { background: rgba(245,124,0,0.2); color: #FFA726; }
.badge-red { background: rgba(211,47,47,0.2); color: #EF5350; }
.badge-blue { background: rgba(21,101,192,0.2); color: #64B5F6; }
/* ── Chat ── */
.chat-message { padding: 10px 14px; border-radius: var(--radius); margin-bottom: 8px; font-size: 14px; line-height: 1.5; max-width: 80%; }
.chat-message-bot { background: var(--bg-card); align-self: flex-start; }
.chat-message-user { background: var(--primary); align-self: flex-end; margin-left: auto; }
.chat-input { display: flex; gap: 8px; padding: 12px 16px; background: var(--bg-card); border-top: 1px solid rgba(255,255,255,0.05); flex-shrink: 0; }
.chat-input input { flex: 1; background: var(--bg); border: none; border-radius: var(--radius-full); padding: 10px 16px; color: var(--text); font-size: 14px; outline: none; }
.chat-input button { background: var(--primary); border: none; border-radius: 50%; width: 40px; height: 40px; color: white; font-size: 18px; cursor: pointer; }
/* ── Map ── */
.map-placeholder { width: 100%; height: 200px; background: #E8F5E9; border-radius: var(--radius); display: flex; flex-direction: column; align-items: center; justify-content: center; color: #4CAF50; margin-bottom: 12px; }
</style>
</head>
<body>
<!-- ═══════════════════════════════════════════════════════════════
SCREEN: LOGIN
═══════════════════════════════════════════════════════════════ -->
<div id="screen-login" class="screen active">
<div class="header">
<div class="logo">🏙️</div>
<h1>Smart City Martinique</h1>
<p>Martinique</p>
</div>
<div class="form-container">
<div class="alert alert-error" id="login-error"></div>
<div class="form-title">Connexion</div>
<div class="input-group">
<label>Email</label>
<div class="input-wrapper">
<span class="icon">📧</span>
<input type="email" id="login-email" placeholder="votre@email.com" autocomplete="email">
</div>
</div>
<div class="input-group">
<label>Mot de passe</label>
<div class="input-wrapper">
<span class="icon">🔒</span>
<input type="password" id="login-password" placeholder="••••••••" autocomplete="current-password">
<button type="button" class="toggle-pw" onclick="togglePw('login-password')" style="background:none;border:none;cursor:pointer;font-size:16px;padding:0 12px">👁️</button>
</div>
</div>
<div class="text-center mt-12">
<a href="#" class="link" onclick="forgotPassword()">Mot de passe oublié ?</a>
</div>
<button class="btn btn-primary mt-24" id="btn-login" onclick="handleLogin()">
<span>Se connecter</span>
</button>
<div class="divider"><span>ou</span></div>
<div style="display:flex;gap:12px">
<button class="btn btn-google" style="flex:1" onclick="socialLogin('google')"><span class="btn-icon">🔵</span> Google</button>
<button class="btn btn-apple" style="flex:1" onclick="socialLogin('apple')"><span class="btn-icon">🍎</span> Apple</button>
</div>
<div class="text-center mt-24">
<span style="font-size:14px;color:var(--text-secondary)">Pas encore de compte ? </span>
<a href="#" class="link" onclick="showScreen('register')">S'inscrire</a>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════
SCREEN: REGISTER
═══════════════════════════════════════════════════════════════ -->
<div id="screen-register" class="screen">
<div class="header" style="padding-bottom:30px">
<div style="text-align:left;margin-bottom:16px"><a href="#" class="link" style="color:rgba(255,255,255,0.8)" onclick="showScreen('login')">← Retour</a></div>
<h1>Créer un compte</h1>
<p>Rejoignez Smart City Martinique</p>
</div>
<div class="form-container">
<div class="alert alert-error" id="register-error"></div>
<div class="alert alert-success" id="register-success"></div>
<div style="display:flex;gap:12px">
<div class="input-group" style="flex:1">
<label>Prénom</label>
<div class="input-wrapper"><input type="text" id="reg-firstname" placeholder="Jean"></div>
</div>
<div class="input-group" style="flex:1">
<label>Nom</label>
<div class="input-wrapper"><input type="text" id="reg-lastname" placeholder="Dupont"></div>
</div>
</div>
<div class="input-group">
<label>Email</label>
<div class="input-wrapper">
<span class="icon">📧</span>
<input type="email" id="reg-email" placeholder="votre@email.com">
</div>
</div>
<div class="input-group">
<label>Mot de passe</label>
<div class="input-wrapper">
<span class="icon">🔒</span>
<input type="password" id="reg-password" placeholder="Min. 8 caractères">
<button type="button" class="toggle-pw" onclick="togglePw('reg-password')" style="background:none;border:none;cursor:pointer;font-size:16px;padding:0 12px">👁️</button>
</div>
</div>
<div class="input-group">
<label>Confirmer</label>
<div class="input-wrapper">
<span class="icon">🔒</span>
<input type="password" id="reg-confirm" placeholder="Retapez le mot de passe">
</div>
</div>
<div class="checkbox mb-8">
<input type="checkbox" id="reg-terms">
<span>J'accepte les <a href="#" class="link">conditions d'utilisation</a></span>
</div>
<button class="btn btn-primary" id="btn-register" onclick="handleRegister()">
<span>S'inscrire</span>
</button>
<div class="text-center mt-24">
<span style="font-size:14px;color:var(--text-secondary)">Déjà un compte ? </span>
<a href="#" class="link" onclick="showScreen('login')">Se connecter</a>
</div>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════
SCREEN: HOME DASHBOARD
═══════════════════════════════════════════════════════════════ -->
<div id="screen-home" class="screen">
<div class="dashboard-header">
<div class="dashboard-header-top">
<div>
<div class="greeting">Bonjour 👋</div>
<div class="user-name" id="home-user-name">Utilisateur</div>
</div>
<button class="notification-btn" onclick="showScreen('notifications')">🔔<span class="notification-badge">3</span></button>
</div>
<div class="search-bar">
<span>🔍</span>
<input type="text" placeholder="Rechercher un service, un lieu...">
</div>
</div>
<div class="scrollable" style="padding-top:60px">
<div class="metrics-row">
<div class="metric-card"><div class="metric-value" style="color:var(--accent)">28°</div><div class="metric-unit">C</div><div class="metric-label">Temp.</div></div>
<div class="metric-card"><div class="metric-value" style="color:#64B5F6">72%</div><div class="metric-unit"></div><div class="metric-label">Humidité</div></div>
<div class="metric-card"><div class="metric-value" style="color:#FFA726">42</div><div class="metric-unit">AQI</div><div class="metric-label">Air</div></div>
<div class="metric-card"><div class="metric-value" style="color:#EF5350">55</div><div class="metric-unit">dB</div><div class="metric-label">Bruit</div></div>
</div>
<div class="section">
<div class="section-header"><div class="section-title">Actions rapides</div></div>
<div class="quick-actions">
<a href="#" class="quick-action" onclick="showScreen('signalements')"><div class="quick-action-icon" style="background:rgba(211,47,47,0.15)">🚨</div><div class="quick-action-label">Signaler</div></a>
<a href="#" class="quick-action" onclick="showScreen('map')"><div class="quick-action-icon" style="background:rgba(21,101,192,0.15)">🚌</div><div class="quick-action-label">Transport</div></a>
<a href="#" class="quick-action" onclick="showScreen('marketplace')"><div class="quick-action-icon" style="background:rgba(46,125,50,0.15)"></div><div class="quick-action-label">Énergie</div></a>
<a href="#" class="quick-action" onclick="showScreen('sante')"><div class="quick-action-icon" style="background:rgba(245,124,0,0.15)">🏥</div><div class="quick-action-label">Santé</div></a>
</div>
</div>
<div class="section">
<div class="section-header"><div class="section-title">Météo</div><a href="#" class="see-all" onclick="showScreen('meteo')">Voir tout →</a></div>
<div class="card" style="background:linear-gradient(135deg,#1565C0,#0288D1);color:white">
<div style="display:flex;justify-content:space-between;align-items:center">
<div>
<div style="font-size:36px;font-weight:700">28°C</div>
<div style="font-size:13px;opacity:0.8">Fort-de-France</div>
<div style="font-size:12px;opacity:0.7;margin-top:4px">Ensoleillé • Humidité 72%</div>
</div>
<div style="font-size:48px">☀️</div>
</div>
</div>
</div>
<div class="section">
<div class="section-header"><div class="section-title">Capteurs en direct</div><a href="#" class="see-all" onclick="showScreen('sensors')">Voir tout →</a></div>
<div id="sensors-list"></div>
</div>
<div class="section">
<div class="section-header"><div class="section-title">Alertes</div></div>
<div class="card">
<div style="display:flex;gap:10px;align-items:flex-start">
<span style="font-size:20px">⚠️</span>
<div>
<div class="card-title">Qualité air — Schoelcher</div>
<div class="card-subtitle">AQI élevé détecté. Évitez les efforts prolongés.</div>
</div>
</div>
</div>
</div>
</div>
<nav class="top-nav">
<a href="#" class="nav-item active" onclick="showScreen('home')"><span class="nav-item-icon">🏠</span>Accueil</a>
<a href="#" class="nav-item" onclick="showScreen('map')"><span class="nav-item-icon">🗺️</span>Carte</a>
<a href="#" class="nav-item" onclick="showScreen('marketplace')"><span class="nav-item-icon">🛒</span>Market</a>
<a href="#" class="nav-item" onclick="showScreen('chat')"><span class="nav-item-icon">🤖</span>IA Chat</a>
<a href="#" class="nav-item" onclick="showScreen('profile')"><span class="nav-item-icon">👤</span>Profil</a>
</nav>
</div>
<!-- ═══════════════════════════════════════════════════════════════
SCREEN: MAP
═══════════════════════════════════════════════════════════════ -->
<div id="screen-map" class="screen">
<div class="dashboard-header" style="padding-bottom:16px">
<div class="section-header"><div class="section-title">Carte Interactive</div><span style="font-size:13px;color:rgba(255,255,255,0.7)">Martinique</span></div>
</div>
<div class="scrollable" style="padding-top:60px">
<div class="map-placeholder">
<div style="font-size:48px;margin-bottom:8px">🗺️</div>
<div style="font-size:14px;font-weight:600">Carte OpenStreetMap</div>
<div style="font-size:12px;color:#81C784">14.6°N, 61.0°W</div>
</div>
<div class="section">
<div class="section-header"><div class="section-title">Couches</div></div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<span class="badge badge-blue" onclick="toggleLayer(this)">📡 Capteurs</span>
<span class="badge badge-green" onclick="toggleLayer(this)">🌬️ Qualité Air</span>
<span class="badge badge-orange" onclick="toggleLayer(this)">🔊 Bruit</span>
<span class="badge" onclick="toggleLayer(this)">🚗 Trafic</span>
<span class="badge badge-blue" onclick="toggleLayer(this)">🌤️ Météo</span>
<span class="badge badge-red" onclick="toggleLayer(this)">🎉 Événements</span>
</div>
</div>
<div class="section">
<div class="section-header"><div class="section-title">Capteurs à proximité</div></div>
<div id="nearby-sensors"></div>
</div>
</div>
<nav class="top-nav">
<a href="#" class="nav-item" onclick="showScreen('home')"><span class="nav-item-icon">🏠</span>Accueil</a>
<a href="#" class="nav-item active" onclick="showScreen('map')"><span class="nav-item-icon">🗺️</span>Carte</a>
<a href="#" class="nav-item" onclick="showScreen('marketplace')"><span class="nav-item-icon">🛒</span>Market</a>
<a href="#" class="nav-item" onclick="showScreen('chat')"><span class="nav-item-icon">🤖</span>IA Chat</a>
<a href="#" class="nav-item" onclick="showScreen('profile')"><span class="nav-item-icon">👤</span>Profil</a>
</nav>
</div>
<!-- ═══════════════════════════════════════════════════════════════
SCREEN: MARKETPLACE
═══════════════════════════════════════════════════════════════ -->
<div id="screen-marketplace" class="screen">
<div class="dashboard-header" style="padding-bottom:16px">
<div class="section-header"><div class="section-title">Marketplace</div><span style="font-size:13px;color:rgba(255,255,255,0.7)">Services & Produits</span></div>
<div class="search-bar" style="margin-top:12px"><span>🔍</span><input type="text" placeholder="Rechercher un service..."></div>
</div>
<div class="scrollable" style="padding-top:60px">
<div class="section">
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px">
<span class="badge badge-blue" onclick="filterCategory(this)">Tous</span>
<span class="badge" onclick="filterCategory(this)">🍎 Alimentation</span>
<span class="badge" onclick="filterCategory(this)">🚌 Transport</span>
<span class="badge" onclick="filterCategory(this)">⚡ Énergie</span>
<span class="badge" onclick="filterCategory(this)">🏥 Santé</span>
</div>
<div id="marketplace-list"></div>
</div>
</div>
<nav class="top-nav">
<a href="#" class="nav-item" onclick="showScreen('home')"><span class="nav-item-icon">🏠</span>Accueil</a>
<a href="#" class="nav-item" onclick="showScreen('map')"><span class="nav-item-icon">🗺️</span>Carte</a>
<a href="#" class="nav-item active" onclick="showScreen('marketplace')"><span class="nav-item-icon">🛒</span>Market</a>
<a href="#" class="nav-item" onclick="showScreen('chat')"><span class="nav-item-icon">🤖</span>IA Chat</a>
<a href="#" class="nav-item" onclick="showScreen('profile')"><span class="nav-item-icon">👤</span>Profil</a>
</nav>
</div>
<!-- ═══════════════════════════════════════════════════════════════
SCREEN: AI CHAT
═══════════════════════════════════════════════════════════════ -->
<div id="screen-chat" class="screen">
<div class="dashboard-header" style="padding-bottom:16px;flex-shrink:0">
<div style="display:flex;align-items:center;gap:12px">
<div style="width:44px;height:44px;border-radius:14px;background:rgba(255,255,255,0.15);display:flex;align-items:center;justify-content:center;font-size:24px">🤖</div>
<div><div style="font-size:16px;font-weight:700;color:white">Smart City IA</div><div style="font-size:12px;color:rgba(255,255,255,0.7)">En ligne</div></div>
</div>
</div>
<div class="scrollable" style="padding:16px;display:flex;flex-direction:column" id="chat-messages">
<div class="chat-message chat-message-bot">Bonjour ! Je suis Smart City IA 🤖<br><br>Je peux vous aider avec :<br>• 🌤️ Météo en temps réel<br>• 🚌 Transports<br>• ⚡ Énergie<br>• 🚨 Alertes<br><br>Comment puis-je vous aider ?</div>
</div>
<div style="padding:8px 16px;display:flex;gap:8px;flex-wrap:wrap;background:var(--bg-card);border-top:1px solid rgba(255,255,255,0.05)">
<span class="badge badge-blue" onclick="quickPrompt('Météo aujourd\\'hui')">🌤️ Météo</span>
<span class="badge badge-green" onclick="quickPrompt('Prochain bus')">🚌 Bus</span>
<span class="badge badge-orange" onclick="quickPrompt('Consommation énergie')">⚡ Énergie</span>
<span class="badge badge-red" onclick="quickPrompt('Alertes en cours')">🚨 Alertes</span>
</div>
<div class="chat-input">
<input type="text" id="chat-input" placeholder="Posez votre question..." onkeypress="if(event.key==='Enter')sendChat()">
<button onclick="sendChat()"></button>
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════════
SCREEN: PROFILE
═══════════════════════════════════════════════════════════════ -->
<div id="screen-profile" class="screen">
<div class="scrollable" style="padding-top:60px">
<div class="dashboard-header" style="text-align:center;padding-bottom:24px">
<div style="width:70px;height:70px;border-radius:18px;background:rgba(255,255,255,0.15);margin:0 auto 10px;display:flex;align-items:center;justify-content:center;font-size:36px">👤</div>
<div style="font-size:18px;font-weight:700;color:white" id="profile-name">Utilisateur</div>
<div style="font-size:12px;color:rgba(255,255,255,0.7)" id="profile-email">-</div>
<div style="display:flex;gap:6px;justify-content:center;margin-top:10px">
<span class="badge badge-blue">🏙️ Citoyen</span>
<span class="badge badge-green">✅ Vérifié</span>
</div>
</div>
<div class="section">
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin-bottom:16px">
<div class="card" style="text-align:center"><div style="font-size:20px;font-weight:700;color:var(--primary)">12</div><div style="font-size:10px;color:var(--text-muted)">Signalements</div></div>
<div class="card" style="text-align:center"><div style="font-size:20px;font-weight:700;color:var(--primary)">45</div><div style="font-size:10px;color:var(--text-muted)">Points</div></div>
<div class="card" style="text-align:center"><div style="font-size:20px;font-weight:700;color:var(--primary)">5</div><div style="font-size:10px;color:var(--text-muted)">Abonnements</div></div>
</div>
<div id="profile-menu"></div>
</div>
</div>
<nav class="top-nav">
<a href="#" class="nav-item" onclick="showScreen('home')"><span class="nav-item-icon">🏠</span>Accueil</a>
<a href="#" class="nav-item" onclick="showScreen('map')"><span class="nav-item-icon">🗺️</span>Carte</a>
<a href="#" class="nav-item" onclick="showScreen('marketplace')"><span class="nav-item-icon">🛒</span>Market</a>
<a href="#" class="nav-item" onclick="showScreen('chat')"><span class="nav-item-icon">🤖</span>IA Chat</a>
<a href="#" class="nav-item active" onclick="showScreen('profile')"><span class="nav-item-icon">👤</span>Profil</a>
</nav>
</div>
<!-- ═══════════════════════════════════════════════════════════════
SCREEN: NOTIFICATIONS
═══════════════════════════════════════════════════════════════ -->
<div id="screen-notifications" class="screen">
<div class="dashboard-header" style="padding-bottom:24px">
<div style="display:flex;justify-content:space-between;align-items:center">
<div class="section-header"><div class="section-title">Notifications</div></div>
<button class="link" style="font-size:13px" onclick="markAllRead()">Tout lu</button>
</div>
</div>
<div class="scrollable" style="padding-top:60px">
<div class="section" id="notifications-list"></div>
</div>
<nav class="top-nav">
<a href="#" class="nav-item active" onclick="showScreen('home')"><span class="nav-item-icon">🏠</span>Accueil</a>
<a href="#" class="nav-item" onclick="showScreen('map')"><span class="nav-item-icon">🗺️</span>Carte</a>
<a href="#" class="nav-item" onclick="showScreen('marketplace')"><span class="nav-item-icon">🛒</span>Market</a>
<a href="#" class="nav-item" onclick="showScreen('chat')"><span class="nav-item-icon">🤖</span>IA Chat</a>
<a href="#" class="nav-item" onclick="showScreen('profile')"><span class="nav-item-icon">👤</span>Profil</a>
</nav>
</div>
<!-- ═══════════════════════════════════════════════════════════════
PLACEHOLDER SCREENS
═══════════════════════════════════════════════════════════════ -->
<div id="screen-signalements" class="screen"><div class="scrollable" style="padding:60px 20px;text-align:center"><div style="font-size:48px;margin-bottom:16px">🚨</div><h2>Signalements</h2><p style="color:var(--text-secondary);margin-top:8px">Écran en cours de développement</p><button class="btn btn-primary" style="margin-top:24px" onclick="showScreen('home')">← Retour</button></div><nav class="top-nav"><a href="#" class="nav-item active" onclick="showScreen('home')"><span class="nav-item-icon">🏠</span>Accueil</a><a href="#" class="nav-item" onclick="showScreen('map')"><span class="nav-item-icon">🗺️</span>Carte</a><a href="#" class="nav-item" onclick="showScreen('marketplace')"><span class="nav-item-icon">🛒</span>Market</a><a href="#" class="nav-item" onclick="showScreen('chat')"><span class="nav-item-icon">🤖</span>IA Chat</a><a href="#" class="nav-item" onclick="showScreen('profile')"><span class="nav-item-icon">👤</span>Profil</a></nav></div>
<div id="screen-sensors" class="screen"><div class="scrollable" style="padding:60px 20px;text-align:center"><div style="font-size:48px;margin-bottom:16px">📡</div><h2>Capteurs IoT</h2><p style="color:var(--text-secondary);margin-top:8px">Écran en cours de développement</p><button class="btn btn-primary" style="margin-top:24px" onclick="showScreen('home')">← Retour</button></div><nav class="top-nav"><a href="#" class="nav-item active" onclick="showScreen('home')"><span class="nav-item-icon">🏠</span>Accueil</a><a href="#" class="nav-item" onclick="showScreen('map')"><span class="nav-item-icon">🗺️</span>Carte</a><a href="#" class="nav-item" onclick="showScreen('marketplace')"><span class="nav-item-icon">🛒</span>Market</a><a href="#" class="nav-item" onclick="showScreen('chat')"><span class="nav-item-icon">🤖</span>IA Chat</a><a href="#" class="nav-item" onclick="showScreen('profile')"><span class="nav-item-icon">👤</span>Profil</a></nav></div>
<div id="screen-meteo" class="screen"><div class="scrollable" style="padding:60px 20px;text-align:center"><div style="font-size:48px;margin-bottom:16px">🌤️</div><h2>Météo</h2><p style="color:var(--text-secondary);margin-top:8px">Écran en cours de développement</p><button class="btn btn-primary" style="margin-top:24px" onclick="showScreen('home')">← Retour</button></div><nav class="top-nav"><a href="#" class="nav-item active" onclick="showScreen('home')"><span class="nav-item-icon">🏠</span>Accueil</a><a href="#" class="nav-item" onclick="showScreen('map')"><span class="nav-item-icon">🗺️</span>Carte</a><a href="#" class="nav-item" onclick="showScreen('marketplace')"><span class="nav-item-icon">🛒</span>Market</a><a href="#" class="nav-item" onclick="showScreen('chat')"><span class="nav-item-icon">🤖</span>IA Chat</a><a href="#" class="nav-item" onclick="showScreen('profile')"><span class="nav-item-icon">👤</span>Profil</a></nav></div>
<div id="screen-sante" class="screen"><div class="scrollable" style="padding:60px 20px;text-align:center"><div style="font-size:48px;margin-bottom:16px">🏥</div><h2>Santé</h2><p style="color:var(--text-secondary);margin-top:8px">Écran en cours de développement</p><button class="btn btn-primary" style="margin-top:24px" onclick="showScreen('home')">← Retour</button></div><nav class="top-nav"><a href="#" class="nav-item active" onclick="showScreen('home')"><span class="nav-item-icon">🏠</span>Accueil</a><a href="#" class="nav-item" onclick="showScreen('map')"><span class="nav-item-icon">🗺️</span>Carte</a><a href="#" class="nav-item" onclick="showScreen('marketplace')"><span class="nav-item-icon">🛒</span>Market</a><a href="#" class="nav-item" onclick="showScreen('chat')"><span class="nav-item-icon">🤖</span>IA Chat</a><a href="#" class="nav-item" onclick="showScreen('profile')"><span class="nav-item-icon">👤</span>Profil</a></nav></div>
<script>
// ── Auth Token ──
let authToken = localStorage.getItem('authToken') || null;
// ── Screen Navigation ──
function showScreen(id) {
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
document.getElementById('screen-' + id).classList.add('active');
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
const scrollable = document.querySelector('#screen-' + id + ' .scrollable');
if (scrollable) scrollable.scrollTop = 0;
}
// ── Toggle Password ──
function togglePw(id) {
const el = document.getElementById(id);
el.type = el.type === 'password' ? 'text' : 'password';
}
// ── API ──
async function apiCall(method, path, body, auth) {
const headers = { 'Content-Type': 'application/json' };
if (auth && authToken) headers['Authorization'] = 'Bearer ' + authToken;
const opts = { method, headers };
if (body) opts.body = JSON.stringify(body);
const resp = await fetch('https://api-smartapp.digitribe.fr/api' + path, opts);
const data = await resp.json().catch(() => null);
return { ok: resp.ok, status: resp.status, data };
}
// ── Login ──
async function handleLogin() {
const btn = document.getElementById('btn-login');
const email = document.getElementById('login-email').value.trim();
const password = document.getElementById('login-password').value;
const err = document.getElementById('login-error');
if (!email || !password) { err.textContent = 'Remplissez tous les champs'; err.classList.add('show'); return; }
btn.disabled = true; btn.innerHTML = '<div class="spinner"></div>';
try {
const { ok, data } = await apiCall('POST', '/auth/login', { email, password }, false);
if (ok && data?.accessToken) {
authToken = data.accessToken;
localStorage.setItem('authToken', authToken);
localStorage.setItem('user', JSON.stringify(data.user));
loadDashboard(); showScreen('home');
} else { err.textContent = data?.message || 'Identifiants incorrects'; err.classList.add('show'); }
} catch(e) { err.textContent = 'Erreur de connexion'; err.classList.add('show'); }
btn.disabled = false; btn.innerHTML = '<span>Se connecter</span>';
}
// ── Register ──
async function handleRegister() {
const btn = document.getElementById('btn-register');
const fn = document.getElementById('reg-firstname').value.trim();
const ln = document.getElementById('reg-lastname').value.trim();
const em = document.getElementById('reg-email').value.trim();
const pw = document.getElementById('reg-password').value;
const cf = document.getElementById('reg-confirm').value;
const err = document.getElementById('register-error');
const ok = document.getElementById('register-success');
err.classList.remove('show'); ok.classList.remove('show');
if (!fn||!ln||!em||!pw) { err.textContent = 'Remplissez tous les champs'; err.classList.add('show'); return; }
if (pw.length < 8) { err.textContent = 'Min. 8 caractères'; err.classList.add('show'); return; }
if (pw !== cf) { err.textContent = 'Mots de passe différents'; err.classList.add('show'); return; }
btn.disabled = true; btn.innerHTML = '<div class="spinner"></div>';
try {
const { ok: success, data } = await apiCall('POST', '/auth/register', { email:em, password:pw, firstName:fn, lastName:ln }, false);
if (success) { ok.textContent = 'Compte créé ! Connexion...'; ok.classList.add('show'); setTimeout(()=>{ handleLogin(); }, 1000); }
else { err.textContent = data?.message || 'Erreur'; err.classList.add('show'); }
} catch(e) { err.textContent = 'Erreur de connexion'; err.classList.add('show'); }
btn.disabled = false; btn.innerHTML = '<span>S\'inscrire</span>';
}
function socialLogin(p) { alert(p + ' — bientôt disponible'); }
function forgotPassword() { const e = prompt('Email :'); if(e) alert('Lien envoyé à ' + e); }
// ── Dashboard ──
function loadDashboard() {
const u = JSON.parse(localStorage.getItem('user') || '{}');
document.getElementById('home-user-name').textContent = u.firstName || 'Utilisateur';
document.getElementById('profile-name').textContent = (u.firstName||'') + ' ' + (u.lastName||'');
document.getElementById('profile-email').textContent = u.email || '-';
renderSensors(); renderMarketplace(); renderNotifications(); renderProfileMenu();
}
function renderSensors() {
const s = [
{n:'Centre-Ville',l:'Fort-de-France',v:'28.3',u:'°C',i:'🌡️',c:'badge-green'},
{n:'Port',l:'Baie de FDF',v:'72',u:'%',i:'💧',c:'badge-green'},
{n:'Qualité Air',l:'Schoelcher',v:'85',u:'AQI',i:'🌬️',c:'badge-orange'},
{n:'Bruit',l:'Centre-ville',v:'68',u:'dB',i:'🔊',c:'badge-red'},
];
document.getElementById('sensors-list').innerHTML = s.map(x=>`
<div class="card"><div style="display:flex;justify-content:space-between;align-items:center">
<div style="display:flex;gap:10px;align-items:center">
<div style="width:40px;height:40px;border-radius:12px;background:rgba(21,101,192,0.1);display:flex;align-items:center;justify-content:center;font-size:18px">${x.i}</div>
<div><div class="card-title">${x.n}</div><div class="card-subtitle">📍 ${x.l}</div></div>
</div>
<div style="text-align:right"><div style="font-size:20px;font-weight:700">${x.v}<span style="font-size:11px;color:var(--text-muted)"> ${x.u}</span></div><span class="badge ${x.c}">${x.u}</span></div>
</div></div>
`).join('');
document.getElementById('nearby-sensors').innerHTML = document.getElementById('sensors-list').innerHTML;
}
function renderMarketplace() {
const p = [
{n:'Panier Bio Local',pr:'AMAP Martinique',p:'15€',u:'/semaine',i:'🥬',r:4.8},
{n:'Pass Transport',pr:'CFTA',p:'45€',u:'/mois',i:'🎫',r:4.2},
{n:'Kit Solaire',pr:'EDF DOM',p:'299€',u:'',i:'☀️',r:4.6},
{n:'Télémédecine',pr:'Santé+',p:'25€',u:'/consult',i:'🩺',r:4.5},
];
document.getElementById('marketplace-list').innerHTML = p.map(x=>`
<div class="card"><div style="display:flex;gap:12px;align-items:center">
<div style="width:52px;height:52px;border-radius:14px;background:rgba(21,101,192,0.1);display:flex;align-items:center;justify-content:center;font-size:24px;flex-shrink:0">${x.i}</div>
<div style="flex:1"><div class="card-title">${x.n}</div><div class="card-subtitle">${x.pr}</div>
<div class="card-row"><span style="font-size:16px;font-weight:700;color:var(--primary)">${x.p}</span><span style="font-size:12px;color:var(--text-muted)">${x.u}</span><span style="font-size:12px;color:var(--text-secondary)">⭐ ${x.r}</span></div></div>
<button class="btn btn-primary" style="width:auto;padding:8px 16px;font-size:13px" onclick="alert('Ajouté')">+</button>
</div></div>
`).join('');
}
function renderNotifications() {
const n = [
{i:'🚨',t:'Alerte Qualité Air',b:'AQI élevé à Schoelcher (85).',h:'Il y a 30 min'},
{i:'🎉',t:'Fête de la Musique',b:'21 juin au centre-ville.',h:'Il y a 2j'},
{i:'',t:'Nouveau service',b:'Suivi bus temps réel disponible !',h:'Il y a 5j'},
];
document.getElementById('notifications-list').innerHTML = n.map(x=>`
<div class="card"><div style="display:flex;gap:12px;align-items:flex-start">
<span style="font-size:24px">${x.i}</span>
<div style="flex:1"><div class="card-title">${x.t}</div><div class="card-subtitle">${x.b}</div>
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">${x.h}</div></div>
</div></div>
`).join('');
}
function renderProfileMenu() {
const items = [
{i:'🔔',l:'Notifications',a:"showScreen('notifications')"},
{i:'🌍',l:'Langue',v:'Français',a:"alert('Bientôt')"},
{i:'🌙',l:'Mode sombre',v:'Off',a:"alert('Bientôt')"},
{i:'🔒',l:'Confidentialité',a:"alert('Bientôt')"},
{i:'❓',l:'Aide & Support',a:"alert('Bientôt')"},
{i:'',l:'À propos',v:'v0.1.0',a:"alert('Smart City Martinique v0.1.0')"},
{i:'🚪',l:'Déconnexion',a:'logout()',s:'color:var(--danger)'},
];
document.getElementById('profile-menu').innerHTML = items.map(x=>`
<div class="card" onclick="${x.a}" style="cursor:pointer;display:flex;justify-content:space-between;align-items:center">
<div style="display:flex;gap:12px;align-items:center"><span style="font-size:20px">${x.i}</span><span style="font-size:15px;${x.s||''}">${x.l}</span></div>
<div style="display:flex;align-items:center;gap:8px">${x.v?`<span style="font-size:13px;color:var(--text-muted)">${x.v}</span>`:''}<span style="font-size:18px;color:var(--text-muted)"></span></div>
</div>
`).join('');
}
function logout() { authToken=null; localStorage.removeItem('authToken'); localStorage.removeItem('user'); showScreen('login'); }
function toggleLayer(el) { el.style.opacity = el.style.opacity === '0.5' ? '1' : '0.5'; }
function filterCategory(el) { document.querySelectorAll('[onclick^="filterCategory"]').forEach(e=>{e.style.background='';e.style.color='';}); el.style.background='var(--primary)'; el.style.color='white'; }
function markAllRead() { alert('Tout marqué comme lu'); }
// ── Chat ──
const chatResp = {
'météo':'☀️ Fort-de-France : 28°C, ensoleillé. Humidité 72%, vent 12 km/h NE.',
'bus':'🚌 L1 (Centre→Schoelcher) dans 8 min. L3 dans 12 min.',
'énergie':'⚡ Conso semaine : 45 kWh (-12%). Estimation mensuelle : 180 kWh.',
'alerte':'🚨 1 alerte : AQI élevé à Schoelcher (85).',
};
function sendChat() {
const input = document.getElementById('chat-input');
const msg = input.value.trim(); if (!msg) return;
const c = document.getElementById('chat-messages');
c.innerHTML += `<div class="chat-message chat-message-user">${msg}</div>`;
input.value = '';
setTimeout(()=>{
const l = msg.toLowerCase();
let r = 'Je consulte les données... 🔍';
for (const [k,v] of Object.entries(chatResp)) { if(l.includes(k)) { r=v; break; } }
c.innerHTML += `<div class="chat-message chat-message-bot">${r}</div>`;
c.scrollTop = c.scrollHeight;
}, 800);
}
function quickPrompt(t) { document.getElementById('chat-input').value = t; sendChat(); }
// ── Auto-login ──
if (authToken) { loadDashboard(); showScreen('home'); }
</script>
</body>
</html>

View File

@@ -0,0 +1,26 @@
server {
listen 80;
server_name smartapp.digitribe.fr;
root /usr/share/nginx/html;
index index.html;
# Gzip
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml image/svg+xml;
# SPA routing - must be first
location / {
try_files $uri $uri/ /index.html;
}
# Static assets caching
location ~* \.(js|css|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|eot|map)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Health check
location /health {
return 200 'OK';
}
}