feat: Initial Diabetix application commit
- Add authentication with NextAuth v5 (credentials + email verification) - Implement dashboard with glycemia tracking and AI analysis - Add PDF report generation for Premium users - Implement Stripe integration for Premium subscriptions - Add responsive UI with Tailwind CSS and shadcn components - Database schema with Prisma ORM and PostgreSQL support - Real-time glycemia visualization with Recharts - Mobile-optimized entry form - User profile management with medical information - Subscription lifecycle management (create, cancel, webhook) - Email notifications with Resend - Feature gates for Free vs Premium plans Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
11
.claude/launch.json
Normal file
11
.claude/launch.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "dev",
|
||||||
|
"runtimeExecutable": "npm",
|
||||||
|
"runtimeArgs": ["run", "dev"],
|
||||||
|
"port": 3000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -39,3 +39,5 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
/src/generated/prisma
|
||||||
|
|||||||
88
STRIPE_INTEGRATION.md
Normal file
88
STRIPE_INTEGRATION.md
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
# Intégration Stripe pour Diabetix
|
||||||
|
|
||||||
|
## 📋 Aperçu
|
||||||
|
|
||||||
|
L'intégration Stripe permet aux utilisateurs de passer du plan FREE au plan PREMIUM (4,99 € / mois) avec gestion automatique des abonnements.
|
||||||
|
|
||||||
|
## 🏗️ Architecture
|
||||||
|
|
||||||
|
### Modèles Prisma
|
||||||
|
- **User.stripeId** : ID du client Stripe
|
||||||
|
- **Subscription** : Table pour tracker l'état de l'abonnement
|
||||||
|
- `stripeId` : ID de l'abonnement
|
||||||
|
- `status` : État (active, past_due, unpaid, canceled)
|
||||||
|
- `currentPeriodEnd` : Date d'expiration
|
||||||
|
|
||||||
|
### Routes API
|
||||||
|
|
||||||
|
#### POST `/api/stripe/create-checkout`
|
||||||
|
Crée une session de paiement Stripe
|
||||||
|
- **Auth** : Requis (proxy.ts)
|
||||||
|
- **Response** : `{ sessionId: string }`
|
||||||
|
- **Redirection** : Vers Stripe Checkout → `/dashboard?checkout=success`
|
||||||
|
|
||||||
|
#### POST `/api/stripe/webhook`
|
||||||
|
Reçoit les webhooks Stripe (non protégé)
|
||||||
|
- **Événements gérés** :
|
||||||
|
- `customer.subscription.created` : Nouvel abonnement → plan PREMIUM
|
||||||
|
- `customer.subscription.updated` : Mise à jour
|
||||||
|
- `customer.subscription.deleted` : Annulation → plan FREE
|
||||||
|
- `invoice.payment_failed` : Paiement échoué
|
||||||
|
|
||||||
|
### Composants
|
||||||
|
|
||||||
|
#### `<UpgradeButton />`
|
||||||
|
Bouton pour initier le paiement (client-side)
|
||||||
|
- Charge Stripe.js via CDN
|
||||||
|
- Crée une session de checkout
|
||||||
|
- Redirige vers le formulaire de paiement Stripe
|
||||||
|
|
||||||
|
#### Page `/pricing`
|
||||||
|
- Affiche les deux plans (FREE et PREMIUM)
|
||||||
|
- Bouton "Commencer" pour non-connectés
|
||||||
|
- `<UpgradeButton />` pour utilisateurs connectés
|
||||||
|
- Affiche le statut PREMIUM si applicable
|
||||||
|
|
||||||
|
## 🔧 Configuration
|
||||||
|
|
||||||
|
Voir `STRIPE_SETUP.md` pour les étapes détaillées.
|
||||||
|
|
||||||
|
Variables d'environnement requises :
|
||||||
|
```
|
||||||
|
NEXT_PUBLIC_STRIPE_KEY=pk_test_...
|
||||||
|
STRIPE_SECRET_KEY=sk_test_...
|
||||||
|
STRIPE_PRICE_ID=price_...
|
||||||
|
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔄 Flux d'achat
|
||||||
|
|
||||||
|
1. **Utilisateur accède `/pricing`**
|
||||||
|
- S'il est FREE, affiche `<UpgradeButton />`
|
||||||
|
|
||||||
|
2. **Clique sur "Passer à Premium"**
|
||||||
|
- `UpgradeButton` appelle `POST /api/stripe/create-checkout`
|
||||||
|
- Reçoit `sessionId`
|
||||||
|
- Redirige vers Stripe Checkout
|
||||||
|
|
||||||
|
3. **Paiement sur Stripe Checkout**
|
||||||
|
- Succès : Redirige vers `/dashboard?checkout=success`
|
||||||
|
- Annulation : Redirige vers `/pricing`
|
||||||
|
|
||||||
|
4. **Webhook Stripe `customer.subscription.created`**
|
||||||
|
- Crée un `Subscription` en DB
|
||||||
|
- Met à jour `User.plan = "PREMIUM"`
|
||||||
|
|
||||||
|
5. **NextAuth JWT refresh**
|
||||||
|
- JWT callback lit le nouveau plan
|
||||||
|
- Prochains appels utilisent `plan: "PREMIUM"`
|
||||||
|
- ChatBot et features IA activés
|
||||||
|
|
||||||
|
## 🚀 Prochaines étapes
|
||||||
|
|
||||||
|
- [ ] Page de confirmation de paiement
|
||||||
|
- [ ] Email de bienvenue Premium
|
||||||
|
- [ ] Historique des factures
|
||||||
|
- [ ] Gestion du renouvellement / annulation
|
||||||
|
- [ ] Facturation annuelle
|
||||||
|
- [ ] Tests de webhook locaux avec Stripe CLI
|
||||||
73
STRIPE_SETUP.md
Normal file
73
STRIPE_SETUP.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# Configuration Stripe
|
||||||
|
|
||||||
|
## 1. Créer un compte Stripe
|
||||||
|
|
||||||
|
1. Accédez à [https://stripe.com](https://stripe.com)
|
||||||
|
2. Créez un compte ou connectez-vous
|
||||||
|
3. Accédez au [Dashboard Stripe](https://dashboard.stripe.com)
|
||||||
|
|
||||||
|
## 2. Récupérer les clés API
|
||||||
|
|
||||||
|
1. Dans le sidebar, allez à **Developers** > **API Keys**
|
||||||
|
2. Copiez les clés :
|
||||||
|
- `pk_test_...` → `NEXT_PUBLIC_STRIPE_KEY`
|
||||||
|
- `sk_test_...` → `STRIPE_SECRET_KEY`
|
||||||
|
|
||||||
|
## 3. Créer un produit et un plan
|
||||||
|
|
||||||
|
1. Allez à **Products** dans le sidebar
|
||||||
|
2. Cliquez sur **Create Product**
|
||||||
|
3. Remplissez les détails :
|
||||||
|
- **Name** : `Diabetix Premium`
|
||||||
|
- **Description** : `Accès illimité, analyse IA et coaching`
|
||||||
|
4. Dans la section **Pricing**, créez un plan :
|
||||||
|
- **Amount** : `4.99` EUR
|
||||||
|
- **Billing period** : Monthly
|
||||||
|
5. Copiez l'ID du prix (commence par `price_...`) → `STRIPE_PRICE_ID`
|
||||||
|
|
||||||
|
## 4. Configurer le webhook
|
||||||
|
|
||||||
|
1. Allez à **Developers** > **Webhooks**
|
||||||
|
2. Cliquez sur **Create Endpoint**
|
||||||
|
3. Remplissez :
|
||||||
|
- **Endpoint URL** : `https://votre-domaine.com/api/stripe/webhook`
|
||||||
|
- **Events** : Sélectionnez :
|
||||||
|
- `customer.subscription.created`
|
||||||
|
- `customer.subscription.updated`
|
||||||
|
- `customer.subscription.deleted`
|
||||||
|
- `invoice.payment_failed`
|
||||||
|
4. Copiez le **Signing Secret** → `STRIPE_WEBHOOK_SECRET`
|
||||||
|
|
||||||
|
## 5. Mettre à jour .env.local
|
||||||
|
|
||||||
|
```bash
|
||||||
|
NEXT_PUBLIC_STRIPE_KEY=pk_test_...
|
||||||
|
STRIPE_SECRET_KEY=sk_test_...
|
||||||
|
STRIPE_PRICE_ID=price_...
|
||||||
|
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Tester localement avec Stripe CLI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Installer Stripe CLI
|
||||||
|
npm install -g @stripe/cli
|
||||||
|
|
||||||
|
# Se connecter
|
||||||
|
stripe login
|
||||||
|
|
||||||
|
# Faire suivre les webhooks à votre serveur local
|
||||||
|
stripe listen --forward-to localhost:3000/api/stripe/webhook
|
||||||
|
|
||||||
|
# Simuler un paiement
|
||||||
|
stripe trigger customer.subscription.created
|
||||||
|
```
|
||||||
|
|
||||||
|
## Mode production
|
||||||
|
|
||||||
|
Avant de passer en production :
|
||||||
|
|
||||||
|
1. Utilisez les clés **Live** au lieu des clés **Test**
|
||||||
|
2. Mettez à jour l'URL du webhook vers votre domaine de production
|
||||||
|
3. Testez le processus complet de paiement
|
||||||
|
4. Configurez une page de confirmation après paiement
|
||||||
143
TESTING_STRIPE.md
Normal file
143
TESTING_STRIPE.md
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
# Guide de test - Intégration Stripe
|
||||||
|
|
||||||
|
## 🧪 Tester le flux de paiement complet
|
||||||
|
|
||||||
|
### Étape 1 : S'inscrire et se connecter
|
||||||
|
|
||||||
|
1. Accédez à http://localhost:3000/auth/register
|
||||||
|
2. Créez un compte (ex: `test@example.com`)
|
||||||
|
3. Vérifiez l'email (récupérez le lien du serveur logs)
|
||||||
|
4. Connectez-vous
|
||||||
|
|
||||||
|
### Étape 2 : Accédez à la page pricing
|
||||||
|
|
||||||
|
1. Allez à http://localhost:3000/pricing
|
||||||
|
2. Vous devriez voir :
|
||||||
|
- Le plan FREE avec le bouton "Commencer"
|
||||||
|
- Le plan PREMIUM avec le bouton "Passer à Premium"
|
||||||
|
|
||||||
|
### Étape 3 : Initiez un paiement
|
||||||
|
|
||||||
|
1. Cliquez sur "Passer à Premium"
|
||||||
|
2. Vous serez redirigé vers Stripe Checkout
|
||||||
|
3. Utilisez les **cartes de test Stripe** :
|
||||||
|
|
||||||
|
#### Paiements réussis :
|
||||||
|
- **Numéro** : `4242 4242 4242 4242`
|
||||||
|
- **Expiration** : Futur (ex: 12/25)
|
||||||
|
- **CVC** : N'importe quel 3 chiffres
|
||||||
|
- **Nom** : N'importe quel nom
|
||||||
|
|
||||||
|
#### Paiement échoué :
|
||||||
|
- **Numéro** : `4000 0000 0000 0002`
|
||||||
|
- Même autres détails
|
||||||
|
|
||||||
|
#### Besoin d'authentification 3D Secure :
|
||||||
|
- **Numéro** : `4000 0025 0000 3155`
|
||||||
|
|
||||||
|
### Étape 4 : Vérifiez les webhooks
|
||||||
|
|
||||||
|
Après un paiement réussi, le webhook Stripe devrait :
|
||||||
|
|
||||||
|
1. ✅ Créer un `Subscription` en base de données
|
||||||
|
2. ✅ Mettre à jour `User.plan = "PREMIUM"`
|
||||||
|
3. ✅ Rediriger vers `/dashboard?checkout=success`
|
||||||
|
|
||||||
|
Vérifiez en :
|
||||||
|
- Ouvrant DevTools → Console
|
||||||
|
- Cherchant les logs du serveur pour `[webhook]` messages
|
||||||
|
- Accédant à `/dashboard/profil` et vérifiant que le badge "Premium" apparaît
|
||||||
|
|
||||||
|
### Étape 5 : Testez les fonctionnalités PREMIUM
|
||||||
|
|
||||||
|
Après l'upgrade :
|
||||||
|
1. ✅ Le ChatBot devrait être visible en bas à droite
|
||||||
|
2. ✅ Les stats 30/90 jours devraient être accessibles
|
||||||
|
3. ✅ L'analyse IA quotidienne devrait fonctionner
|
||||||
|
|
||||||
|
## 🔧 Debugging
|
||||||
|
|
||||||
|
### Vérifier les variables d'environnement
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep STRIPE .env.local
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vérifier les logs du serveur
|
||||||
|
|
||||||
|
Cherchez :
|
||||||
|
- `[create-checkout]` : Logs de création de session
|
||||||
|
- `[webhook]` : Logs de webhooks Stripe
|
||||||
|
|
||||||
|
### Simuler des webhooks en local
|
||||||
|
|
||||||
|
Pour tester les webhooks sans vraiment payer, télécharge **Stripe CLI** :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Windows : Télécharge depuis https://stripe.com/docs/stripe-cli
|
||||||
|
# macOS : brew install stripe/stripe-cli/stripe
|
||||||
|
# Linux : Voir https://stripe.com/docs/stripe-cli
|
||||||
|
|
||||||
|
# Se connecter avec ton compte Stripe
|
||||||
|
stripe login
|
||||||
|
|
||||||
|
# Rediriger les webhooks à ton serveur local
|
||||||
|
stripe listen --forward-to localhost:3000/api/stripe/webhook
|
||||||
|
|
||||||
|
# Dans un autre terminal, simuler un paiement
|
||||||
|
stripe trigger customer.subscription.created
|
||||||
|
```
|
||||||
|
|
||||||
|
### Vérifier la base de données
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sqlite3 prisma/dev.db
|
||||||
|
.tables
|
||||||
|
SELECT * FROM "Subscription" LIMIT 1;
|
||||||
|
SELECT plan FROM "User" WHERE email = 'test@example.com';
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🐛 Problèmes courants
|
||||||
|
|
||||||
|
### Le bouton "Passer à Premium" ne fait rien
|
||||||
|
- Vérifiez que `NEXT_PUBLIC_STRIPE_KEY` est correct (commence par `pk_`)
|
||||||
|
- Vérifiez les erreurs dans DevTools → Console
|
||||||
|
|
||||||
|
### Stripe Checkout ne se charge pas
|
||||||
|
- Vérifiez que la clé publique est valide
|
||||||
|
- Vérifiez que la `STRIPE_PRICE_ID` existe dans votre compte Stripe
|
||||||
|
|
||||||
|
### Le webhook ne met pas à jour le plan
|
||||||
|
- Vérifiez que `STRIPE_WEBHOOK_SECRET` est correct
|
||||||
|
- Vérifiez les logs serveur pour les erreurs de signature
|
||||||
|
|
||||||
|
### L'utilisateur reste FREE après paiement
|
||||||
|
- Vérifiez que le webhook a été traité (cherchez `[webhook]` dans les logs)
|
||||||
|
- Vérifiez que l'utilisateur a un `userId` correct
|
||||||
|
- Vérifiez les logs de base de données
|
||||||
|
|
||||||
|
## 📊 État après un paiement réussi
|
||||||
|
|
||||||
|
La base de données devrait contenir :
|
||||||
|
|
||||||
|
```
|
||||||
|
User:
|
||||||
|
id: "cuid-123"
|
||||||
|
plan: "PREMIUM"
|
||||||
|
stripeId: "cus_abc123"
|
||||||
|
|
||||||
|
Subscription:
|
||||||
|
id: "sub-123"
|
||||||
|
userId: "cuid-123"
|
||||||
|
stripeId: "sub_abc123"
|
||||||
|
status: "active"
|
||||||
|
currentPeriodEnd: 2026-05-26 (30 jours à partir d'aujourd'hui)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 Prochaines étapes
|
||||||
|
|
||||||
|
- [ ] Ajouter une page de confirmation de paiement
|
||||||
|
- [ ] Envoyer un email de bienvenue Premium
|
||||||
|
- [ ] Afficher l'historique des factures
|
||||||
|
- [ ] Permettre l'annulation d'abonnement
|
||||||
|
- [ ] Tester en production avec les clés Live
|
||||||
43
add_readings.js
Normal file
43
add_readings.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
const { PrismaClient } = require("@prisma/client");
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email: "testuser@example.com" }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
console.log("User not found");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate sample readings for the past 12 months
|
||||||
|
const readings = [];
|
||||||
|
const today = new Date();
|
||||||
|
|
||||||
|
for (let monthsAgo = 0; monthsAgo < 12; monthsAgo++) {
|
||||||
|
const monthDate = new Date(today.getFullYear(), today.getMonth() - monthsAgo, 1);
|
||||||
|
const daysInMonth = new Date(monthDate.getFullYear(), monthDate.getMonth() + 1, 0).getDate();
|
||||||
|
|
||||||
|
for (let day = 1; day <= daysInMonth; day += 2) { // Every 2 days
|
||||||
|
for (let time = 0; time < 3; time++) { // 3 times per day
|
||||||
|
readings.push({
|
||||||
|
userId: user.id,
|
||||||
|
value: Math.floor(Math.random() * 150) + 60, // 60-210 mg/dL
|
||||||
|
measuredAt: new Date(monthDate.getFullYear(), monthDate.getMonth(), day, 6 + time * 8, 0),
|
||||||
|
notes: time === 0 ? "Avant petit-déjeuner" : time === 1 ? "Avant déjeuner" : "Avant dîner"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.reading.createMany({
|
||||||
|
data: readings,
|
||||||
|
skipDuplicates: true
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`Created ${readings.length} readings`);
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error);
|
||||||
2835
package-lock.json
generated
2835
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
28
package.json
28
package.json
@@ -6,21 +6,45 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint"
|
"lint": "eslint",
|
||||||
|
"db:seed": "tsx prisma/seed.ts",
|
||||||
|
"db:clear": "tsx prisma/clear.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@auth/prisma-adapter": "^2.11.2",
|
||||||
|
"@google/generative-ai": "^0.24.1",
|
||||||
|
"@prisma/adapter-better-sqlite3": "^7.8.0",
|
||||||
|
"@prisma/client": "^7.8.0",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"better-sqlite3": "^12.9.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"lucide-react": "^1.11.0",
|
||||||
"next": "16.2.4",
|
"next": "16.2.4",
|
||||||
|
"next-auth": "^5.0.0-beta.31",
|
||||||
|
"pdf-lib": "^1.17.1",
|
||||||
|
"pdfkit": "^0.18.0",
|
||||||
|
"prisma": "^7.8.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
|
"resend": "^6.12.2",
|
||||||
|
"stripe": "^22.1.0",
|
||||||
|
"tailwind-merge": "^3.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
|
"@types/pdfkit": "^0.17.6",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"dotenv": "^17.4.2",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.4",
|
"eslint-config-next": "16.2.4",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
16
prisma.config.ts
Normal file
16
prisma.config.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import "dotenv/config";
|
||||||
|
import path from "node:path";
|
||||||
|
import { defineConfig } from "prisma/config";
|
||||||
|
|
||||||
|
const databaseUrl =
|
||||||
|
process.env["DATABASE_URL"] ?? `file:${path.resolve("prisma/dev.db")}`;
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: "prisma/schema.prisma",
|
||||||
|
migrations: {
|
||||||
|
path: "prisma/migrations",
|
||||||
|
},
|
||||||
|
datasource: {
|
||||||
|
url: databaseUrl,
|
||||||
|
},
|
||||||
|
});
|
||||||
18
prisma/clear.ts
Normal file
18
prisma/clear.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { PrismaClient } from "../src/generated/prisma/client";
|
||||||
|
import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3";
|
||||||
|
import "dotenv/config";
|
||||||
|
|
||||||
|
const databaseUrl =
|
||||||
|
process.env.DATABASE_URL ?? `file:${path.resolve("prisma/dev.db")}`;
|
||||||
|
|
||||||
|
const prisma = new PrismaClient({
|
||||||
|
adapter: new PrismaBetterSqlite3({ url: databaseUrl }),
|
||||||
|
});
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const { count } = await prisma.reading.deleteMany();
|
||||||
|
console.log(`✓ ${count} relevé${count > 1 ? "s" : ""} supprimé${count > 1 ? "s" : ""}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().finally(() => prisma.$disconnect());
|
||||||
BIN
prisma/dev.db
Normal file
BIN
prisma/dev.db
Normal file
Binary file not shown.
12
prisma/migrations/20260426084121_init/migration.sql
Normal file
12
prisma/migrations/20260426084121_init/migration.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Reading" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"measuredAt" DATETIME NOT NULL,
|
||||||
|
"moment" TEXT NOT NULL,
|
||||||
|
"value" REAL NOT NULL,
|
||||||
|
"notes" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Reading_measuredAt_idx" ON "Reading"("measuredAt");
|
||||||
11
prisma/migrations/20260426095223_add_patient/migration.sql
Normal file
11
prisma/migrations/20260426095223_add_patient/migration.sql
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Patient" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT DEFAULT 1,
|
||||||
|
"firstName" TEXT NOT NULL,
|
||||||
|
"lastName" TEXT NOT NULL,
|
||||||
|
"email" TEXT,
|
||||||
|
"birthDate" DATETIME,
|
||||||
|
"heightCm" INTEGER,
|
||||||
|
"weightKg" REAL,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Patient" ADD COLUMN "diabetesType" TEXT;
|
||||||
|
ALTER TABLE "Patient" ADD COLUMN "sex" TEXT;
|
||||||
|
ALTER TABLE "Patient" ADD COLUMN "treatment" TEXT;
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "DailyAnalysis" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT DEFAULT 1,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"generatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- The primary key for the `DailyAnalysis` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||||
|
- The primary key for the `Patient` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||||
|
- Added the required column `userId` to the `DailyAnalysis` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `userId` to the `Patient` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `userId` to the `Reading` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"emailVerified" DATETIME,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"passwordHash" TEXT NOT NULL,
|
||||||
|
"plan" TEXT NOT NULL DEFAULT 'FREE',
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "VerifyToken" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"token" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"expiresAt" DATETIME NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "VerifyToken_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- RedefineTables
|
||||||
|
PRAGMA defer_foreign_keys=ON;
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_DailyAnalysis" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"content" TEXT NOT NULL,
|
||||||
|
"generatedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "DailyAnalysis_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
INSERT INTO "new_DailyAnalysis" ("content", "generatedAt", "id") SELECT "content", "generatedAt", "id" FROM "DailyAnalysis";
|
||||||
|
DROP TABLE "DailyAnalysis";
|
||||||
|
ALTER TABLE "new_DailyAnalysis" RENAME TO "DailyAnalysis";
|
||||||
|
CREATE UNIQUE INDEX "DailyAnalysis_userId_key" ON "DailyAnalysis"("userId");
|
||||||
|
CREATE TABLE "new_Patient" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"firstName" TEXT NOT NULL,
|
||||||
|
"lastName" TEXT NOT NULL,
|
||||||
|
"email" TEXT,
|
||||||
|
"birthDate" DATETIME,
|
||||||
|
"heightCm" INTEGER,
|
||||||
|
"weightKg" REAL,
|
||||||
|
"sex" TEXT,
|
||||||
|
"diabetesType" TEXT,
|
||||||
|
"treatment" TEXT,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "Patient_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
INSERT INTO "new_Patient" ("birthDate", "diabetesType", "email", "firstName", "heightCm", "id", "lastName", "sex", "treatment", "updatedAt", "weightKg") SELECT "birthDate", "diabetesType", "email", "firstName", "heightCm", "id", "lastName", "sex", "treatment", "updatedAt", "weightKg" FROM "Patient";
|
||||||
|
DROP TABLE "Patient";
|
||||||
|
ALTER TABLE "new_Patient" RENAME TO "Patient";
|
||||||
|
CREATE UNIQUE INDEX "Patient_userId_key" ON "Patient"("userId");
|
||||||
|
CREATE TABLE "new_Reading" (
|
||||||
|
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"measuredAt" DATETIME NOT NULL,
|
||||||
|
"moment" TEXT NOT NULL,
|
||||||
|
"value" REAL NOT NULL,
|
||||||
|
"notes" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "Reading_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
INSERT INTO "new_Reading" ("createdAt", "id", "measuredAt", "moment", "notes", "value") SELECT "createdAt", "id", "measuredAt", "moment", "notes", "value" FROM "Reading";
|
||||||
|
DROP TABLE "Reading";
|
||||||
|
ALTER TABLE "new_Reading" RENAME TO "Reading";
|
||||||
|
CREATE INDEX "Reading_userId_measuredAt_idx" ON "Reading"("userId", "measuredAt");
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
PRAGMA defer_foreign_keys=OFF;
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "VerifyToken_token_key" ON "VerifyToken"("token");
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "sqlite"
|
||||||
90
prisma/schema.prisma
Normal file
90
prisma/schema.prisma
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
output = "../src/generated/prisma"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "sqlite"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Auth ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
email String @unique
|
||||||
|
emailVerified DateTime?
|
||||||
|
name String
|
||||||
|
passwordHash String
|
||||||
|
plan String @default("FREE") // FREE | PREMIUM
|
||||||
|
stripeId String? @unique
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
readings Reading[]
|
||||||
|
patient Patient?
|
||||||
|
dailyAnalysis DailyAnalysis?
|
||||||
|
verifyTokens VerifyToken[]
|
||||||
|
subscription Subscription?
|
||||||
|
}
|
||||||
|
|
||||||
|
model VerifyToken {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
token String @unique
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
expiresAt DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Domain ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
model Reading {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
measuredAt DateTime
|
||||||
|
moment String
|
||||||
|
value Float
|
||||||
|
notes String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([userId, measuredAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model Patient {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String @unique
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
firstName String
|
||||||
|
lastName String
|
||||||
|
email String?
|
||||||
|
birthDate DateTime?
|
||||||
|
heightCm Int?
|
||||||
|
weightKg Float?
|
||||||
|
sex String?
|
||||||
|
diabetesType String?
|
||||||
|
treatment String?
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model DailyAnalysis {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String @unique
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
content String
|
||||||
|
generatedAt DateTime @default(now())
|
||||||
|
}
|
||||||
|
|
||||||
|
model Subscription {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
userId String @unique
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
stripeId String @unique
|
||||||
|
stripePriceId String
|
||||||
|
stripeCustomerId String
|
||||||
|
status String // active | past_due | canceled | unpaid
|
||||||
|
currentPeriodEnd DateTime
|
||||||
|
canceledAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
51
prisma/seed.ts
Normal file
51
prisma/seed.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { PrismaClient } from "../src/generated/prisma/client";
|
||||||
|
import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3";
|
||||||
|
import "dotenv/config";
|
||||||
|
|
||||||
|
const databaseUrl =
|
||||||
|
process.env.DATABASE_URL ?? `file:${path.resolve("prisma/dev.db")}`;
|
||||||
|
|
||||||
|
const prisma = new PrismaClient({
|
||||||
|
adapter: new PrismaBetterSqlite3({ url: databaseUrl }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const moments = [
|
||||||
|
{ key: "FASTING", hour: 7, baseMin: 0.85, baseMax: 1.25 },
|
||||||
|
{ key: "LUNCH", hour: 14, baseMin: 1.05, baseMax: 1.75 },
|
||||||
|
{ key: "DINNER", hour: 21, baseMin: 1.0, baseMax: 1.7 },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function rand(min: number, max: number) {
|
||||||
|
return min + Math.random() * (max - min);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
await prisma.reading.deleteMany();
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const data: { measuredAt: Date; moment: string; value: number; notes: string | null }[] = [];
|
||||||
|
|
||||||
|
for (let day = 29; day >= 0; day--) {
|
||||||
|
for (const m of moments) {
|
||||||
|
const d = new Date(today.getTime() - day * 86_400_000);
|
||||||
|
d.setHours(m.hour, Math.floor(Math.random() * 30), 0, 0);
|
||||||
|
let value = rand(m.baseMin, m.baseMax);
|
||||||
|
// Quelques valeurs aberrantes pour montrer hypo / hyper
|
||||||
|
if (Math.random() < 0.05) value = rand(0.5, 0.68);
|
||||||
|
if (Math.random() < 0.05) value = rand(1.85, 2.4);
|
||||||
|
data.push({
|
||||||
|
measuredAt: d,
|
||||||
|
moment: m.key,
|
||||||
|
value: Math.round(value * 100) / 100,
|
||||||
|
notes: Math.random() < 0.15 ? "Bonne forme" : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.reading.createMany({ data });
|
||||||
|
console.log(`✓ ${data.length} relevés générés.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().finally(() => prisma.$disconnect());
|
||||||
5
public/icon.svg
Normal file
5
public/icon.svg
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<rect width="512" height="512" rx="96" fill="#0f766e"/>
|
||||||
|
<path d="M256 96c-12 0-22 8-26 19l-58 158c-22 60 23 124 84 124s106-64 84-124L282 115c-4-11-14-19-26-19z" fill="#ffffff"/>
|
||||||
|
<path d="M256 280c0 22 18 40 40 40" stroke="#0f766e" stroke-width="14" stroke-linecap="round" fill="none"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 362 B |
19
public/manifest.webmanifest
Normal file
19
public/manifest.webmanifest
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "Diabetix — Suivi de glycémie",
|
||||||
|
"short_name": "Diabetix",
|
||||||
|
"description": "Suivi quotidien du diabète avec tableau de bord et tendances.",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"background_color": "#f8fafc",
|
||||||
|
"theme_color": "#0f766e",
|
||||||
|
"lang": "fr",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon.svg",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
52
public/sw.js
Normal file
52
public/sw.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
const CACHE = "diabetix-v1";
|
||||||
|
const APP_SHELL = ["/", "/saisie", "/historique", "/manifest.webmanifest", "/icon.svg"];
|
||||||
|
|
||||||
|
self.addEventListener("install", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE).then((cache) => cache.addAll(APP_SHELL)).catch(() => {})
|
||||||
|
);
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((keys) =>
|
||||||
|
Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
self.clients.claim();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("fetch", (event) => {
|
||||||
|
const { request } = event;
|
||||||
|
if (request.method !== "GET") return;
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (url.origin !== self.location.origin) return;
|
||||||
|
|
||||||
|
// Network-first for HTML and API, cache fallback
|
||||||
|
if (request.mode === "navigate" || url.pathname.startsWith("/api/")) {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(request)
|
||||||
|
.then((res) => {
|
||||||
|
const copy = res.clone();
|
||||||
|
caches.open(CACHE).then((c) => c.put(request, copy)).catch(() => {});
|
||||||
|
return res;
|
||||||
|
})
|
||||||
|
.catch(() => caches.match(request).then((m) => m || caches.match("/")))
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache-first for static assets
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(request).then((cached) => {
|
||||||
|
if (cached) return cached;
|
||||||
|
return fetch(request).then((res) => {
|
||||||
|
const copy = res.clone();
|
||||||
|
caches.open(CACHE).then((c) => c.put(request, copy)).catch(() => {});
|
||||||
|
return res;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
28
reset-db.mjs
Normal file
28
reset-db.mjs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { PrismaClient } from "@prisma/client";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
|
const dbPath = "./prisma/dev.db";
|
||||||
|
if (fs.existsSync(dbPath)) {
|
||||||
|
fs.unlinkSync(dbPath);
|
||||||
|
console.log("Database deleted");
|
||||||
|
}
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
await prisma.$executeRawUnsafe(`
|
||||||
|
CREATE TABLE "_prisma_migrations" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"checksum" TEXT NOT NULL,
|
||||||
|
"finished_at" DATETIME,
|
||||||
|
"execution_time" INTEGER NOT NULL,
|
||||||
|
"migration_name" TEXT NOT NULL,
|
||||||
|
"logs" TEXT,
|
||||||
|
"rolled_back_at" DATETIME,
|
||||||
|
"started_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"applied_steps_count" INTEGER NOT NULL DEFAULT 0
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await prisma.$disconnect();
|
||||||
|
console.log("Database reset complete");
|
||||||
2
src/app/api/auth/[...nextauth]/route.ts
Normal file
2
src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
import { handlers } from "@/lib/auth";
|
||||||
|
export const { GET, POST } = handlers;
|
||||||
70
src/app/api/auth/register/route.ts
Normal file
70
src/app/api/auth/register/route.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import crypto from "node:crypto";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { sendVerificationEmail } from "@/lib/email";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const body = await request.json().catch(() => null);
|
||||||
|
if (!body) return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||||
|
|
||||||
|
const name = typeof body.name === "string" ? body.name.trim() : "";
|
||||||
|
const email = typeof body.email === "string" ? body.email.toLowerCase().trim() : "";
|
||||||
|
const password = typeof body.password === "string" ? body.password : "";
|
||||||
|
|
||||||
|
if (!name || name.length < 2) {
|
||||||
|
return NextResponse.json({ error: "Nom trop court (2 car. min)" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||||
|
return NextResponse.json({ error: "Email invalide" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (password.length < 8) {
|
||||||
|
return NextResponse.json({ error: "Mot de passe trop court (8 car. min)" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.user.findUnique({ where: { email } });
|
||||||
|
if (existing) {
|
||||||
|
return NextResponse.json({ error: "Cet email est déjà utilisé" }, { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(password, 12);
|
||||||
|
|
||||||
|
// Split "Prénom Nom" — first word = prénom, rest = nom
|
||||||
|
const parts = name.split(" ").filter(Boolean);
|
||||||
|
const firstName = parts[0] ?? name;
|
||||||
|
const lastName = parts.slice(1).join(" ") || firstName;
|
||||||
|
|
||||||
|
const user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
email,
|
||||||
|
passwordHash,
|
||||||
|
plan: "FREE",
|
||||||
|
patient: {
|
||||||
|
create: { firstName, lastName, email },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create verification token (24h)
|
||||||
|
const token = crypto.randomBytes(32).toString("hex");
|
||||||
|
await prisma.verifyToken.create({
|
||||||
|
data: {
|
||||||
|
token,
|
||||||
|
userId: user.id,
|
||||||
|
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendVerificationEmail(email, name, token);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[register] email send failed:", e);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: true, emailError: e instanceof Error ? e.message : "Erreur envoi email" },
|
||||||
|
{ status: 201 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true }, { status: 201 });
|
||||||
|
}
|
||||||
26
src/app/api/auth/verify/route.ts
Normal file
26
src/app/api/auth/verify/route.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const token = searchParams.get("token");
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.redirect(new URL("/auth/login?error=token_missing", request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = await prisma.verifyToken.findUnique({ where: { token } });
|
||||||
|
|
||||||
|
if (!record || record.expiresAt < new Date()) {
|
||||||
|
await prisma.verifyToken.deleteMany({ where: { token } }).catch(() => {});
|
||||||
|
return NextResponse.redirect(new URL("/auth/login?error=token_expired", request.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: record.userId },
|
||||||
|
data: { emailVerified: new Date() },
|
||||||
|
});
|
||||||
|
await prisma.verifyToken.delete({ where: { token } });
|
||||||
|
|
||||||
|
return NextResponse.redirect(new URL("/auth/login?verified=1", request.url));
|
||||||
|
}
|
||||||
207
src/app/api/chat/route.ts
Normal file
207
src/app/api/chat/route.ts
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { GoogleGenerativeAI } from "@google/generative-ai";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { estimateHbA1c, statusFor, type Moment } from "@/lib/glycemia";
|
||||||
|
import { DIABETES_TYPE_LABELS, SEX_LABELS } from "@/lib/patient";
|
||||||
|
import { differenceInYears, format } from "date-fns";
|
||||||
|
import { fr } from "date-fns/locale";
|
||||||
|
|
||||||
|
const MAX_MESSAGES = 20;
|
||||||
|
|
||||||
|
type ChatMessage = { role: "user" | "model"; content: string };
|
||||||
|
|
||||||
|
function buildSystemPrompt(ctx: {
|
||||||
|
patient: {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
sex: string | null;
|
||||||
|
birthDate: Date | null;
|
||||||
|
heightCm: number | null;
|
||||||
|
weightKg: number | null;
|
||||||
|
diabetesType: string | null;
|
||||||
|
treatment: string | null;
|
||||||
|
} | null;
|
||||||
|
stats: {
|
||||||
|
count7: number;
|
||||||
|
avg7: number | null;
|
||||||
|
count30: number;
|
||||||
|
avg30: number | null;
|
||||||
|
inRangePct30: number | null;
|
||||||
|
hypo30: number;
|
||||||
|
hyper30: number;
|
||||||
|
hba1c: number | null;
|
||||||
|
lastValue: number | null;
|
||||||
|
lastMoment: string | null;
|
||||||
|
lastDate: Date | null;
|
||||||
|
};
|
||||||
|
}): string {
|
||||||
|
const { patient, stats } = ctx;
|
||||||
|
|
||||||
|
const patientSection = patient
|
||||||
|
? (() => {
|
||||||
|
const age = patient.birthDate
|
||||||
|
? differenceInYears(new Date(), patient.birthDate)
|
||||||
|
: null;
|
||||||
|
const bmi =
|
||||||
|
patient.heightCm && patient.weightKg
|
||||||
|
? patient.weightKg / Math.pow(patient.heightCm / 100, 2)
|
||||||
|
: null;
|
||||||
|
return `
|
||||||
|
## Fiche patient
|
||||||
|
- Nom : ${patient.firstName} ${patient.lastName}
|
||||||
|
- Sexe : ${patient.sex ? (SEX_LABELS[patient.sex] ?? patient.sex) : "non renseigné"}
|
||||||
|
- Âge : ${age !== null ? `${age} ans` : "non renseigné"}
|
||||||
|
- Taille : ${patient.heightCm ? `${patient.heightCm} cm` : "non renseignée"}
|
||||||
|
- Poids : ${patient.weightKg ? `${patient.weightKg} kg` : "non renseigné"}
|
||||||
|
- IMC : ${bmi ? `${bmi.toFixed(1)}` : "non calculable"}
|
||||||
|
- Type de diabète : ${patient.diabetesType ? (DIABETES_TYPE_LABELS[patient.diabetesType] ?? patient.diabetesType) : "non renseigné"}
|
||||||
|
- Traitement en cours : ${patient.treatment ?? "non renseigné"}`;
|
||||||
|
})()
|
||||||
|
: "## Fiche patient\nAucune fiche patient renseignée.";
|
||||||
|
|
||||||
|
const statsSection = `
|
||||||
|
## Données glycémiques récentes
|
||||||
|
- Dernière mesure : ${stats.lastValue !== null ? `${stats.lastValue.toFixed(2).replace(".", ",")} g/L (${stats.lastMoment ?? ""})${stats.lastDate ? ` le ${format(stats.lastDate, "d MMMM yyyy", { locale: fr })}` : ""}` : "aucune"}
|
||||||
|
- Moyenne sur 7 jours : ${stats.avg7 !== null ? `${stats.avg7.toFixed(2).replace(".", ",")} g/L (${stats.count7} relevés)` : "aucune donnée"}
|
||||||
|
- Moyenne sur 30 jours : ${stats.avg30 !== null ? `${stats.avg30.toFixed(2).replace(".", ",")} g/L (${stats.count30} relevés)` : "aucune donnée"}
|
||||||
|
- % dans la cible sur 30 jours : ${stats.inRangePct30 !== null ? `${stats.inRangePct30.toFixed(0)} %` : "aucune donnée"}
|
||||||
|
- Épisodes d'hypoglycémie (30j) : ${stats.hypo30}
|
||||||
|
- Épisodes d'hyperglycémie (30j) : ${stats.hyper30}
|
||||||
|
- HbA1c estimée (indicatif) : ${stats.hba1c !== null ? `${stats.hba1c.toFixed(1)} %` : "aucune donnée"}`;
|
||||||
|
|
||||||
|
return `Tu es Diablo, un coach diabète bienveillant et pédagogue intégré à l'application Diabetix. Tu aides les patients à comprendre leurs données glycémiques et à adopter de bonnes habitudes.
|
||||||
|
|
||||||
|
## Ton rôle
|
||||||
|
- Analyser les relevés glycémiques et donner des explications claires et encourageantes
|
||||||
|
- Donner des conseils pratiques sur l'alimentation, l'activité physique, le rythme des prises de mesures
|
||||||
|
- Expliquer les tendances : pourquoi une glycémie est élevée après un repas, l'impact du stress ou de l'exercice
|
||||||
|
- Encourager le patient sans le culpabiliser
|
||||||
|
- Poser des questions pour mieux comprendre le contexte (repas, activité, stress, sommeil)
|
||||||
|
|
||||||
|
## Tes limites (IMPORTANT)
|
||||||
|
- Tu n'es PAS un médecin et tu ne te substitues pas à l'équipe soignante
|
||||||
|
- Tu ne modifies JAMAIS un traitement médical, ne recommandes jamais d'ajuster les doses d'insuline ou de médicaments
|
||||||
|
- En cas d'urgence (hypoglycémie sévère, malaise), tu diriges immédiatement vers les secours (15, 112) ou le médecin
|
||||||
|
- Tu rappelles toujours de consulter le médecin pour toute décision médicale
|
||||||
|
- Tu gardes un ton positif, jamais alarmiste sauf urgence réelle
|
||||||
|
|
||||||
|
## Format de tes réponses
|
||||||
|
- Réponds en français, de façon concise et chaleureuse
|
||||||
|
- Utilise des emojis avec modération pour rendre la lecture agréable
|
||||||
|
- Structure avec des listes à points si la réponse est longue
|
||||||
|
- Commence souvent par reconnaître ce que le patient vit/ressent
|
||||||
|
|
||||||
|
${patientSection}
|
||||||
|
${statsSection}
|
||||||
|
|
||||||
|
Utilise ces données pour personnaliser tes conseils. Si une information manque, pose la question.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||||
|
const userId = session.user.id;
|
||||||
|
|
||||||
|
const plan = (session.user as { plan?: string }).plan ?? "FREE";
|
||||||
|
if (plan !== "PREMIUM") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Le chatbot Diablo est réservé aux membres Premium. Passez à Premium pour en bénéficier." },
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKey = process.env.GEMINI_API_KEY;
|
||||||
|
if (!apiKey || apiKey === "your_gemini_api_key_here") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Clé API Gemini non configurée. Ajoutez GEMINI_API_KEY dans .env.local." },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => null);
|
||||||
|
if (!body?.message || typeof body.message !== "string") {
|
||||||
|
return NextResponse.json({ error: "Message requis" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const userMessage = body.message.trim().slice(0, 2000);
|
||||||
|
const history: ChatMessage[] = Array.isArray(body.history) ? body.history.slice(-MAX_MESSAGES) : [];
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const ago7 = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||||
|
const ago30 = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const [patient, last, readings7, readings30] = await Promise.all([
|
||||||
|
prisma.patient.findUnique({ where: { userId } }),
|
||||||
|
prisma.reading.findFirst({ where: { userId }, orderBy: { measuredAt: "desc" } }),
|
||||||
|
prisma.reading.findMany({ where: { userId, measuredAt: { gte: ago7 } } }),
|
||||||
|
prisma.reading.findMany({ where: { userId, measuredAt: { gte: ago30 } } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const avg7 = readings7.length ? readings7.reduce((s, r) => s + r.value, 0) / readings7.length : null;
|
||||||
|
const avg30 = readings30.length ? readings30.reduce((s, r) => s + r.value, 0) / readings30.length : null;
|
||||||
|
|
||||||
|
let inRange30 = 0, hypo30 = 0, hyper30 = 0;
|
||||||
|
for (const r of readings30) {
|
||||||
|
const s = statusFor(r.value, r.moment as Moment);
|
||||||
|
if (s === "in-range") inRange30++;
|
||||||
|
else if (s === "hypo") hypo30++;
|
||||||
|
else hyper30++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const systemPrompt = buildSystemPrompt({
|
||||||
|
patient,
|
||||||
|
stats: {
|
||||||
|
count7: readings7.length,
|
||||||
|
avg7,
|
||||||
|
count30: readings30.length,
|
||||||
|
avg30,
|
||||||
|
inRangePct30: readings30.length ? (inRange30 / readings30.length) * 100 : null,
|
||||||
|
hypo30,
|
||||||
|
hyper30,
|
||||||
|
hba1c: avg30 ? estimateHbA1c(avg30) : null,
|
||||||
|
lastValue: last?.value ?? null,
|
||||||
|
lastMoment: last?.moment ?? null,
|
||||||
|
lastDate: last ? new Date(last.measuredAt) : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const genAI = new GoogleGenerativeAI(apiKey);
|
||||||
|
const modelName = process.env.GEMINI_MODEL ?? "gemini-2.5-flash";
|
||||||
|
const model = genAI.getGenerativeModel({ model: modelName, systemInstruction: systemPrompt });
|
||||||
|
|
||||||
|
const geminiHistory = history.map((m) => ({ role: m.role, parts: [{ text: m.content }] }));
|
||||||
|
const chat = model.startChat({ history: geminiHistory });
|
||||||
|
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
async start(controller) {
|
||||||
|
try {
|
||||||
|
const result = await chat.sendMessageStream(userMessage);
|
||||||
|
for await (const chunk of result.stream) {
|
||||||
|
const text = chunk.text();
|
||||||
|
if (text) controller.enqueue(new TextEncoder().encode(text));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const raw = err instanceof Error ? err.message : "Erreur Gemini";
|
||||||
|
let friendly = raw;
|
||||||
|
if (raw.includes("429") || raw.includes("quota") || raw.includes("Quota")) {
|
||||||
|
friendly = "Quota Gemini dépassé. Vérifiez votre plan sur https://ai.dev/rate-limit ou attendez quelques minutes.";
|
||||||
|
} else if (raw.includes("403") || raw.includes("API_KEY")) {
|
||||||
|
friendly = "Clé API Gemini invalide. Vérifiez GEMINI_API_KEY dans .env.local.";
|
||||||
|
} else if (raw.includes("404")) {
|
||||||
|
friendly = "Modèle Gemini introuvable. Vérifiez GEMINI_MODEL dans .env.local.";
|
||||||
|
}
|
||||||
|
controller.enqueue(new TextEncoder().encode(`⚠️ ${friendly}`));
|
||||||
|
} finally {
|
||||||
|
controller.close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/plain; charset=utf-8",
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
130
src/app/api/daily-analysis/route.ts
Normal file
130
src/app/api/daily-analysis/route.ts
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { GoogleGenerativeAI } from "@google/generative-ai";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { estimateHbA1c, statusFor, type Moment } from "@/lib/glycemia";
|
||||||
|
import { DIABETES_TYPE_LABELS, SEX_LABELS } from "@/lib/patient";
|
||||||
|
import { differenceInYears, isSameDay } from "date-fns";
|
||||||
|
|
||||||
|
async function generateAnalysis(userId: string): Promise<string> {
|
||||||
|
const apiKey = process.env.GEMINI_API_KEY;
|
||||||
|
if (!apiKey || apiKey === "your_gemini_api_key_here") {
|
||||||
|
throw new Error("Clé API Gemini non configurée.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const ago7 = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||||
|
const ago30 = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const [patient, readings7, readings30, lastReading] = await Promise.all([
|
||||||
|
prisma.patient.findUnique({ where: { userId } }),
|
||||||
|
prisma.reading.findMany({ where: { userId, measuredAt: { gte: ago7 } } }),
|
||||||
|
prisma.reading.findMany({ where: { userId, measuredAt: { gte: ago30 } } }),
|
||||||
|
prisma.reading.findFirst({ where: { userId }, orderBy: { measuredAt: "desc" } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const avg7 = readings7.length
|
||||||
|
? readings7.reduce((s, r) => s + r.value, 0) / readings7.length
|
||||||
|
: null;
|
||||||
|
const avg30 = readings30.length
|
||||||
|
? readings30.reduce((s, r) => s + r.value, 0) / readings30.length
|
||||||
|
: null;
|
||||||
|
|
||||||
|
let inRange30 = 0, hypo30 = 0, hyper30 = 0;
|
||||||
|
for (const r of readings30) {
|
||||||
|
const s = statusFor(r.value, r.moment as Moment);
|
||||||
|
if (s === "in-range") inRange30++;
|
||||||
|
else if (s === "hypo") hypo30++;
|
||||||
|
else hyper30++;
|
||||||
|
}
|
||||||
|
const inRangePct = readings30.length ? (inRange30 / readings30.length) * 100 : null;
|
||||||
|
const hba1c = avg30 ? estimateHbA1c(avg30) : null;
|
||||||
|
|
||||||
|
const age = patient?.birthDate ? differenceInYears(now, patient.birthDate) : null;
|
||||||
|
const bmi =
|
||||||
|
patient?.heightCm && patient?.weightKg
|
||||||
|
? patient.weightKg / Math.pow(patient.heightCm / 100, 2)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const prompt = `Tu es Diablo, coach diabète bienveillant. Rédige un court paragraphe d'analyse quotidienne (4 à 6 phrases max) pour ce patient.
|
||||||
|
|
||||||
|
Données patient :
|
||||||
|
- Prénom : ${patient?.firstName ?? "le patient"}
|
||||||
|
- Âge : ${age ? `${age} ans` : "non renseigné"}
|
||||||
|
- Type diabète : ${patient?.diabetesType ? (DIABETES_TYPE_LABELS[patient.diabetesType] ?? patient.diabetesType) : "non renseigné"}
|
||||||
|
- Traitement : ${patient?.treatment ?? "non renseigné"}
|
||||||
|
- IMC : ${bmi ? bmi.toFixed(1) : "non calculable"}
|
||||||
|
- Sexe : ${patient?.sex ? (SEX_LABELS[patient.sex] ?? patient.sex) : "non renseigné"}
|
||||||
|
|
||||||
|
Données glycémiques :
|
||||||
|
- Dernière mesure : ${lastReading ? `${lastReading.value.toFixed(2)} g/L (${lastReading.moment})` : "aucune"}
|
||||||
|
- Moyenne 7 jours : ${avg7 ? `${avg7.toFixed(2)} g/L (${readings7.length} relevés)` : "aucune donnée"}
|
||||||
|
- Moyenne 30 jours : ${avg30 ? `${avg30.toFixed(2)} g/L (${readings30.length} relevés)` : "aucune donnée"}
|
||||||
|
- % dans la cible (30j) : ${inRangePct ? `${inRangePct.toFixed(0)} %` : "aucune donnée"}
|
||||||
|
- Hypoglycémies (30j) : ${hypo30}
|
||||||
|
- Hyperglycémies (30j) : ${hyper30}
|
||||||
|
- HbA1c estimée : ${hba1c ? `${hba1c.toFixed(1)} %` : "aucune donnée"}
|
||||||
|
|
||||||
|
Consignes :
|
||||||
|
- Commence par une observation concrète sur les chiffres de la semaine
|
||||||
|
- Identifie une tendance positive et une piste d'amélioration si pertinent
|
||||||
|
- Termine par une phrase d'encouragement personnalisée
|
||||||
|
- Ton chaleureux, positif, jamais alarmiste
|
||||||
|
- Ne propose jamais de modifier un traitement médical
|
||||||
|
- Réponds en français, sans titre, sans liste — juste un paragraphe fluide
|
||||||
|
- 4 à 6 phrases maximum`;
|
||||||
|
|
||||||
|
const genAI = new GoogleGenerativeAI(apiKey);
|
||||||
|
const model = genAI.getGenerativeModel({ model: process.env.GEMINI_MODEL ?? "gemini-2.5-flash" });
|
||||||
|
|
||||||
|
const result = await model.generateContent(prompt);
|
||||||
|
return result.response.text().trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||||
|
const userId = session.user.id;
|
||||||
|
|
||||||
|
const plan = (session.user as { plan?: string }).plan ?? "FREE";
|
||||||
|
if (plan !== "PREMIUM") {
|
||||||
|
return NextResponse.json({ error: "L'analyse IA est réservée aux membres Premium." }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const forceRefresh = url.searchParams.get("refresh") === "1";
|
||||||
|
|
||||||
|
const existing = await prisma.dailyAnalysis.findUnique({ where: { userId } });
|
||||||
|
|
||||||
|
const isStale =
|
||||||
|
!existing ||
|
||||||
|
forceRefresh ||
|
||||||
|
!isSameDay(new Date(existing.generatedAt), new Date());
|
||||||
|
|
||||||
|
if (!isStale && existing) {
|
||||||
|
return NextResponse.json({ content: existing.content, generatedAt: existing.generatedAt, fresh: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = await generateAnalysis(userId);
|
||||||
|
const record = await prisma.dailyAnalysis.upsert({
|
||||||
|
where: { userId },
|
||||||
|
create: { userId, content, generatedAt: new Date() },
|
||||||
|
update: { content, generatedAt: new Date() },
|
||||||
|
});
|
||||||
|
return NextResponse.json({ content: record.content, generatedAt: record.generatedAt, fresh: true });
|
||||||
|
} catch (err) {
|
||||||
|
if (existing) {
|
||||||
|
return NextResponse.json({
|
||||||
|
content: existing.content,
|
||||||
|
generatedAt: existing.generatedAt,
|
||||||
|
fresh: false,
|
||||||
|
warning: err instanceof Error ? err.message : "Erreur de génération",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err instanceof Error ? err.message : "Erreur inconnue" },
|
||||||
|
{ status: 503 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/app/api/export/route.ts
Normal file
39
src/app/api/export/route.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { MOMENT_LABELS, type Moment } from "@/lib/glycemia";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return new Response("Non authentifié", { status: 401 });
|
||||||
|
}
|
||||||
|
const userId = session.user.id;
|
||||||
|
|
||||||
|
const readings = await prisma.reading.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { measuredAt: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const header = ["Date", "Heure", "Moment", "Glycémie (g/L)", "Notes"].join(";");
|
||||||
|
const rows = readings.map((r) => {
|
||||||
|
const d = new Date(r.measuredAt);
|
||||||
|
return [
|
||||||
|
format(d, "yyyy-MM-dd"),
|
||||||
|
format(d, "HH:mm"),
|
||||||
|
MOMENT_LABELS[r.moment as Moment] ?? r.moment,
|
||||||
|
r.value.toFixed(2).replace(".", ","),
|
||||||
|
(r.notes ?? "").replace(/[\r\n;]/g, " "),
|
||||||
|
].join(";");
|
||||||
|
});
|
||||||
|
|
||||||
|
const csv = "" + [header, ...rows].join("\r\n");
|
||||||
|
const filename = `diabetix-${format(new Date(), "yyyyMMdd-HHmm")}.csv`;
|
||||||
|
|
||||||
|
return new Response(csv, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/csv; charset=utf-8",
|
||||||
|
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
68
src/app/api/patient/route.ts
Normal file
68
src/app/api/patient/route.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { DIABETES_TYPE_VALUES, SEX_VALUES } from "@/lib/patient";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||||
|
|
||||||
|
const patient = await prisma.patient.findUnique({ where: { userId: session.user.id } });
|
||||||
|
return NextResponse.json(patient);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(request: Request) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||||
|
const userId = session.user.id;
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => null);
|
||||||
|
if (!body) return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||||
|
|
||||||
|
const firstName = typeof body.firstName === "string" ? body.firstName.trim() : "";
|
||||||
|
const lastName = typeof body.lastName === "string" ? body.lastName.trim() : "";
|
||||||
|
if (!firstName || !lastName) {
|
||||||
|
return NextResponse.json({ error: "Nom et prénom requis" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const email =
|
||||||
|
typeof body.email === "string" && body.email.trim() ? body.email.trim() : null;
|
||||||
|
const birthDate =
|
||||||
|
typeof body.birthDate === "string" && body.birthDate ? new Date(body.birthDate) : null;
|
||||||
|
|
||||||
|
const heightCm =
|
||||||
|
body.heightCm === "" || body.heightCm == null ? null : Math.round(Number(body.heightCm));
|
||||||
|
if (heightCm !== null && (!Number.isFinite(heightCm) || heightCm < 50 || heightCm > 260)) {
|
||||||
|
return NextResponse.json({ error: "Taille invalide (50–260 cm)" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const weightKg =
|
||||||
|
body.weightKg === "" || body.weightKg == null
|
||||||
|
? null
|
||||||
|
: Math.round(Number(body.weightKg) * 10) / 10;
|
||||||
|
if (weightKg !== null && (!Number.isFinite(weightKg) || weightKg < 20 || weightKg > 400)) {
|
||||||
|
return NextResponse.json({ error: "Poids invalide (20–400 kg)" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const sex =
|
||||||
|
typeof body.sex === "string" && SEX_VALUES.includes(body.sex) ? body.sex : null;
|
||||||
|
const diabetesType =
|
||||||
|
typeof body.diabetesType === "string" && DIABETES_TYPE_VALUES.includes(body.diabetesType)
|
||||||
|
? body.diabetesType
|
||||||
|
: null;
|
||||||
|
const treatment =
|
||||||
|
typeof body.treatment === "string" && body.treatment.trim() ? body.treatment.trim() : null;
|
||||||
|
if (treatment !== null && treatment.length > 1000) {
|
||||||
|
return NextResponse.json({ error: "Traitement trop long (1000 car. max)" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = { firstName, lastName, email, birthDate, heightCm, weightKg, sex, diabetesType, treatment };
|
||||||
|
|
||||||
|
const patient = await prisma.patient.upsert({
|
||||||
|
where: { userId },
|
||||||
|
create: { userId, ...data },
|
||||||
|
update: data,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(patient);
|
||||||
|
}
|
||||||
65
src/app/api/readings/[id]/route.ts
Normal file
65
src/app/api/readings/[id]/route.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const numericId = Number(id);
|
||||||
|
if (!Number.isInteger(numericId)) {
|
||||||
|
return NextResponse.json({ error: "ID invalide" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const reading = await prisma.reading.findUnique({ where: { id: numericId }, select: { userId: true } });
|
||||||
|
if (!reading || reading.userId !== session.user.id) {
|
||||||
|
return NextResponse.json({ error: "Relevé introuvable" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.reading.delete({ where: { id: numericId } });
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const numericId = Number(id);
|
||||||
|
if (!Number.isInteger(numericId)) {
|
||||||
|
return NextResponse.json({ error: "ID invalide" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const reading = await prisma.reading.findUnique({ where: { id: numericId }, select: { userId: true } });
|
||||||
|
if (!reading || reading.userId !== session.user.id) {
|
||||||
|
return NextResponse.json({ error: "Relevé introuvable" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => null);
|
||||||
|
if (!body) return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||||
|
|
||||||
|
const data: Record<string, unknown> = {};
|
||||||
|
if (typeof body.value === "number" || typeof body.value === "string") {
|
||||||
|
const v = typeof body.value === "string" ? parseFloat(body.value.replace(",", ".")) : body.value;
|
||||||
|
if (Number.isFinite(v) && v > 0 && v <= 6) data.value = v;
|
||||||
|
}
|
||||||
|
if (typeof body.notes === "string" || body.notes === null) {
|
||||||
|
data.notes = body.notes?.trim() || null;
|
||||||
|
}
|
||||||
|
if (typeof body.measuredAt === "string") {
|
||||||
|
data.measuredAt = new Date(body.measuredAt);
|
||||||
|
}
|
||||||
|
if (typeof body.moment === "string" && ["FASTING", "LUNCH", "DINNER"].includes(body.moment)) {
|
||||||
|
data.moment = body.moment;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await prisma.reading.update({ where: { id: numericId }, data });
|
||||||
|
return NextResponse.json(updated);
|
||||||
|
}
|
||||||
72
src/app/api/readings/route.ts
Normal file
72
src/app/api/readings/route.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import type { Moment } from "@/lib/glycemia";
|
||||||
|
|
||||||
|
const VALID_MOMENTS: Moment[] = ["FASTING", "LUNCH", "DINNER"];
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||||
|
const userId = session.user.id;
|
||||||
|
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const limit = Number(url.searchParams.get("limit") ?? 200);
|
||||||
|
const from = url.searchParams.get("from");
|
||||||
|
const to = url.searchParams.get("to");
|
||||||
|
|
||||||
|
const where: Record<string, unknown> = { userId };
|
||||||
|
if (from || to) {
|
||||||
|
where.measuredAt = {
|
||||||
|
...(from ? { gte: new Date(from) } : {}),
|
||||||
|
...(to ? { lte: new Date(to) } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const readings = await prisma.reading.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { measuredAt: "desc" },
|
||||||
|
take: Math.min(Math.max(limit, 1), 1000),
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(readings);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||||
|
const userId = session.user.id;
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => null);
|
||||||
|
if (!body) return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
|
||||||
|
|
||||||
|
const { measuredAt, moment, value, notes } = body as {
|
||||||
|
measuredAt?: string;
|
||||||
|
moment?: string;
|
||||||
|
value?: number | string;
|
||||||
|
notes?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!moment || !VALID_MOMENTS.includes(moment as Moment)) {
|
||||||
|
return NextResponse.json({ error: "Moment invalide" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const numericValue = typeof value === "string" ? parseFloat(value.replace(",", ".")) : value;
|
||||||
|
if (typeof numericValue !== "number" || Number.isNaN(numericValue) || numericValue <= 0) {
|
||||||
|
return NextResponse.json({ error: "Valeur invalide" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (numericValue > 6) {
|
||||||
|
return NextResponse.json({ error: "Valeur trop élevée (>6 g/L)" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const reading = await prisma.reading.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
measuredAt: measuredAt ? new Date(measuredAt) : new Date(),
|
||||||
|
moment,
|
||||||
|
value: numericValue,
|
||||||
|
notes: notes?.trim() || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(reading, { status: 201 });
|
||||||
|
}
|
||||||
276
src/app/api/reports/generate-pdf/route.ts
Normal file
276
src/app/api/reports/generate-pdf/route.ts
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { PDFDocument, PDFPage, rgb } from "pdf-lib";
|
||||||
|
import { startOfMonth, endOfMonth, format } from "date-fns";
|
||||||
|
import { fr } from "date-fns/locale";
|
||||||
|
|
||||||
|
const TEAL = rgb(0.08, 0.64, 0.64); // #14a4a4 - Diabetix teal
|
||||||
|
const TEAL_LIGHT = rgb(0.80, 0.95, 0.95); // Light teal background
|
||||||
|
const GRAY_DARK = rgb(0.15, 0.15, 0.15); // Dark gray text
|
||||||
|
const GRAY_LIGHT = rgb(0.62, 0.62, 0.62); // Light gray
|
||||||
|
const WHITE = rgb(1, 1, 1);
|
||||||
|
const RED = rgb(0.95, 0.26, 0.21); // For hypoglycemia
|
||||||
|
const GREEN = rgb(0.30, 0.77, 0.46); // For normal
|
||||||
|
const ORANGE = rgb(1, 0.59, 0.16); // For hyperglycemia
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = session.user.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
include: { patient: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
const month = searchParams.get("month") ? new Date(searchParams.get("month")!) : new Date();
|
||||||
|
|
||||||
|
const startDate = startOfMonth(month);
|
||||||
|
const endDate = endOfMonth(month);
|
||||||
|
|
||||||
|
const readings = await prisma.reading.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
measuredAt: {
|
||||||
|
gte: startDate,
|
||||||
|
lte: endDate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { measuredAt: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (readings.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "No readings found for this period" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = await PDFDocument.create();
|
||||||
|
let page = doc.addPage([595, 842]); // A4
|
||||||
|
let y = 750;
|
||||||
|
|
||||||
|
const monthFormatted = format(month, "MMMM yyyy", { locale: fr });
|
||||||
|
const todayFormatted = format(new Date(), "dd MMMM yyyy", { locale: fr });
|
||||||
|
|
||||||
|
// Header with Diabetix branding
|
||||||
|
page.drawRectangle({
|
||||||
|
x: 0,
|
||||||
|
y: 770,
|
||||||
|
width: 595,
|
||||||
|
height: 72,
|
||||||
|
color: TEAL,
|
||||||
|
});
|
||||||
|
|
||||||
|
page.drawText("Diabetix", { x: 50, y: 810, size: 32, color: WHITE });
|
||||||
|
page.drawText("Rapport Glycémie Mensuel", { x: 50, y: 785, size: 14, color: TEAL_LIGHT });
|
||||||
|
|
||||||
|
y = 740;
|
||||||
|
|
||||||
|
// Patient Information Section
|
||||||
|
page.drawRectangle({
|
||||||
|
x: 50,
|
||||||
|
y: y - 120,
|
||||||
|
width: 495,
|
||||||
|
height: 120,
|
||||||
|
color: TEAL_LIGHT,
|
||||||
|
borderColor: TEAL,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
});
|
||||||
|
|
||||||
|
page.drawText("INFORMATIONS PATIENT", { x: 60, y: y - 15, size: 10, color: TEAL });
|
||||||
|
y -= 35;
|
||||||
|
|
||||||
|
if (user.patient) {
|
||||||
|
page.drawText(`${user.patient.firstName} ${user.patient.lastName}`, { x: 60, y: y, size: 12, color: GRAY_DARK });
|
||||||
|
y -= 18;
|
||||||
|
page.drawText(`Email: ${user.email}`, { x: 60, y: y, size: 9, color: GRAY_LIGHT });
|
||||||
|
y -= 15;
|
||||||
|
page.drawText(`Type de diabète: ${user.patient.diabetesType || "Non spécifié"}`, { x: 60, y: y, size: 9, color: GRAY_LIGHT });
|
||||||
|
y -= 15;
|
||||||
|
page.drawText(`Traitement: ${user.patient.treatment || "Non spécifié"}`, { x: 60, y: y, size: 9, color: GRAY_LIGHT });
|
||||||
|
}
|
||||||
|
|
||||||
|
y = 610;
|
||||||
|
|
||||||
|
// Report Period
|
||||||
|
page.drawText(`Période: ${monthFormatted}`, { x: 50, y: y, size: 11, color: GRAY_DARK });
|
||||||
|
page.drawText(`Généré le: ${todayFormatted}`, { x: 300, y: y, size: 11, color: GRAY_DARK });
|
||||||
|
y -= 30;
|
||||||
|
|
||||||
|
// Statistics Section
|
||||||
|
page.drawText("STATISTIQUES", { x: 50, y: y, size: 11, color: TEAL });
|
||||||
|
y -= 20;
|
||||||
|
|
||||||
|
const values = readings.map((r) => r.value);
|
||||||
|
const avg = values.reduce((a, b) => a + b, 0) / values.length;
|
||||||
|
const min = Math.min(...values);
|
||||||
|
const max = Math.max(...values);
|
||||||
|
const std = Math.sqrt(values.reduce((sum, val) => sum + Math.pow(val - avg, 2), 0) / values.length);
|
||||||
|
|
||||||
|
// Stats in a grid
|
||||||
|
const statBoxHeight = 60;
|
||||||
|
const statBoxWidth = 110;
|
||||||
|
const statBoxX = [50, 170, 290, 410];
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{ label: "Mesures", value: readings.length.toString() },
|
||||||
|
{ label: "Moyenne", value: `${avg.toFixed(2)}` },
|
||||||
|
{ label: "Min", value: `${min.toFixed(2)}` },
|
||||||
|
{ label: "Max", value: `${max.toFixed(2)}` },
|
||||||
|
];
|
||||||
|
|
||||||
|
stats.forEach((stat, idx) => {
|
||||||
|
page.drawRectangle({
|
||||||
|
x: statBoxX[idx],
|
||||||
|
y: y - statBoxHeight,
|
||||||
|
width: statBoxWidth,
|
||||||
|
height: statBoxHeight,
|
||||||
|
color: TEAL_LIGHT,
|
||||||
|
borderColor: TEAL,
|
||||||
|
borderWidth: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
page.drawText(stat.value, { x: statBoxX[idx] + 10, y: y - 30, size: 14, color: TEAL });
|
||||||
|
page.drawText(stat.label, { x: statBoxX[idx] + 10, y: y - 50, size: 9, color: GRAY_LIGHT });
|
||||||
|
});
|
||||||
|
|
||||||
|
y -= 85;
|
||||||
|
page.drawText(`Écart-type: ${std.toFixed(2)} g/L`, { x: 50, y: y, size: 9, color: GRAY_LIGHT });
|
||||||
|
y -= 25;
|
||||||
|
|
||||||
|
// Distribution Section
|
||||||
|
page.drawText("DISTRIBUTION DES MESURES", { x: 50, y: y, size: 11, color: TEAL });
|
||||||
|
y -= 20;
|
||||||
|
|
||||||
|
const hypoCount = readings.filter((r) => r.value < 0.7).length;
|
||||||
|
const normalCount = readings.filter((r) => r.value >= 0.7 && r.value <= 1.8).length;
|
||||||
|
const hyperCount = readings.filter((r) => r.value > 1.8).length;
|
||||||
|
|
||||||
|
const distributions = [
|
||||||
|
{ label: "Hypoglycémies (<0.7)", value: hypoCount, percent: ((hypoCount / readings.length) * 100).toFixed(1), color: ORANGE },
|
||||||
|
{ label: "Normales (0.7-1.8)", value: normalCount, percent: ((normalCount / readings.length) * 100).toFixed(1), color: GREEN },
|
||||||
|
{ label: "Hyperglycémies (>1.8)", value: hyperCount, percent: ((hyperCount / readings.length) * 100).toFixed(1), color: ORANGE },
|
||||||
|
];
|
||||||
|
|
||||||
|
distributions.forEach((dist) => {
|
||||||
|
page.drawRectangle({
|
||||||
|
x: 50,
|
||||||
|
y: y - 20,
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
color: dist.color,
|
||||||
|
});
|
||||||
|
page.drawText(`${dist.label}: ${dist.value} (${dist.percent}%)`, { x: 65, y: y - 18, size: 9, color: GRAY_DARK });
|
||||||
|
y -= 18;
|
||||||
|
});
|
||||||
|
|
||||||
|
y -= 15;
|
||||||
|
|
||||||
|
// Detailed Readings Table
|
||||||
|
page.drawText("DÉTAIL DES MESURES", { x: 50, y: y, size: 11, color: TEAL });
|
||||||
|
y -= 20;
|
||||||
|
|
||||||
|
// Table header
|
||||||
|
const headerY = y;
|
||||||
|
page.drawRectangle({
|
||||||
|
x: 50,
|
||||||
|
y: headerY - 18,
|
||||||
|
width: 495,
|
||||||
|
height: 18,
|
||||||
|
color: TEAL,
|
||||||
|
});
|
||||||
|
|
||||||
|
page.drawText("Date", { x: 60, y: headerY - 14, size: 8, color: WHITE });
|
||||||
|
page.drawText("Heure", { x: 130, y: headerY - 14, size: 8, color: WHITE });
|
||||||
|
page.drawText("Valeur", { x: 210, y: headerY - 14, size: 8, color: WHITE });
|
||||||
|
page.drawText("Notes", { x: 280, y: headerY - 14, size: 8, color: WHITE });
|
||||||
|
|
||||||
|
y = headerY - 25;
|
||||||
|
|
||||||
|
// Table rows
|
||||||
|
readings.forEach((reading, idx) => {
|
||||||
|
if (y < 60) {
|
||||||
|
page = doc.addPage([595, 842]);
|
||||||
|
y = 800;
|
||||||
|
|
||||||
|
// Repeat header on new page
|
||||||
|
page.drawRectangle({
|
||||||
|
x: 50,
|
||||||
|
y: y - 18,
|
||||||
|
width: 495,
|
||||||
|
height: 18,
|
||||||
|
color: TEAL,
|
||||||
|
});
|
||||||
|
page.drawText("Date", { x: 60, y: y - 14, size: 8, color: WHITE });
|
||||||
|
page.drawText("Heure", { x: 130, y: y - 14, size: 8, color: WHITE });
|
||||||
|
page.drawText("Valeur", { x: 210, y: y - 14, size: 8, color: WHITE });
|
||||||
|
page.drawText("Notes", { x: 280, y: y - 14, size: 8, color: WHITE });
|
||||||
|
y -= 25;
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = format(new Date(reading.measuredAt), "dd/MM/yyyy", { locale: fr });
|
||||||
|
const time = format(new Date(reading.measuredAt), "HH:mm");
|
||||||
|
const value = reading.value.toFixed(2);
|
||||||
|
const notes = reading.notes?.substring(0, 30) || "";
|
||||||
|
|
||||||
|
// Color based on glycemia level
|
||||||
|
let rowColor = TEAL_LIGHT;
|
||||||
|
let textColor = GRAY_DARK;
|
||||||
|
|
||||||
|
if (reading.value < 0.7) {
|
||||||
|
// Hypo - orange background
|
||||||
|
rowColor = rgb(1, 0.92, 0.80); // light orange
|
||||||
|
textColor = rgb(1, 0.59, 0.16); // darker orange text
|
||||||
|
} else if (reading.value > 1.8) {
|
||||||
|
// Hyper - orange background
|
||||||
|
rowColor = rgb(1, 0.92, 0.80); // light orange
|
||||||
|
textColor = rgb(1, 0.59, 0.16); // darker orange text
|
||||||
|
} else if (idx % 2 === 0) {
|
||||||
|
// Normal - alternate colors
|
||||||
|
rowColor = TEAL_LIGHT;
|
||||||
|
} else {
|
||||||
|
rowColor = WHITE;
|
||||||
|
}
|
||||||
|
|
||||||
|
page.drawRectangle({
|
||||||
|
x: 50,
|
||||||
|
y: y - 16,
|
||||||
|
width: 495,
|
||||||
|
height: 16,
|
||||||
|
color: rowColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
page.drawText(date, { x: 60, y: y - 12, size: 8, color: textColor });
|
||||||
|
page.drawText(time, { x: 130, y: y - 12, size: 8, color: textColor });
|
||||||
|
page.drawText(value, { x: 210, y: y - 12, size: 8, color: textColor });
|
||||||
|
page.drawText(notes, { x: 280, y: y - 12, size: 8, color: textColor });
|
||||||
|
y -= 16;
|
||||||
|
});
|
||||||
|
|
||||||
|
const pdfBuffer = await doc.save();
|
||||||
|
|
||||||
|
return new NextResponse(pdfBuffer, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/pdf",
|
||||||
|
"Content-Disposition": `attachment; filename="rapport_glycemie_${format(month, "yyyy-MM")}.pdf"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[generate-pdf]", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to generate PDF" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/app/api/stats/route.ts
Normal file
50
src/app/api/stats/route.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { estimateHbA1c, statusFor, type Moment } from "@/lib/glycemia";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
|
||||||
|
const userId = session.user.id;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const ago7 = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||||
|
const ago30 = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||||
|
const ago90 = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const [last, last7, last30, last90] = await Promise.all([
|
||||||
|
prisma.reading.findFirst({ where: { userId }, orderBy: { measuredAt: "desc" } }),
|
||||||
|
prisma.reading.findMany({ where: { userId, measuredAt: { gte: ago7 } } }),
|
||||||
|
prisma.reading.findMany({ where: { userId, measuredAt: { gte: ago30 } } }),
|
||||||
|
prisma.reading.findMany({ where: { userId, measuredAt: { gte: ago90 } } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const summarize = (rows: typeof last7) => {
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return { count: 0, avg: null, inRangePct: null, hypoCount: 0, hyperCount: 0 };
|
||||||
|
}
|
||||||
|
const sum = rows.reduce((acc, r) => acc + r.value, 0);
|
||||||
|
const avg = sum / rows.length;
|
||||||
|
let inRange = 0, hypo = 0, hyper = 0;
|
||||||
|
for (const r of rows) {
|
||||||
|
const s = statusFor(r.value, r.moment as Moment);
|
||||||
|
if (s === "in-range") inRange++;
|
||||||
|
else if (s === "hypo") hypo++;
|
||||||
|
else hyper++;
|
||||||
|
}
|
||||||
|
return { count: rows.length, avg, inRangePct: (inRange / rows.length) * 100, hypoCount: hypo, hyperCount: hyper };
|
||||||
|
};
|
||||||
|
|
||||||
|
const s7 = summarize(last7);
|
||||||
|
const s30 = summarize(last30);
|
||||||
|
const s90 = summarize(last90);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
last,
|
||||||
|
week: s7,
|
||||||
|
month: s30,
|
||||||
|
quarter: s90,
|
||||||
|
estimatedHbA1c: s90.avg ? estimateHbA1c(s90.avg) : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
52
src/app/api/stripe/cancel-subscription/route.ts
Normal file
52
src/app/api/stripe/cancel-subscription/route.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { stripe } from "@/lib/stripe";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = session.user.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const subscription = await prisma.subscription.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "No active subscription" },
|
||||||
|
{ status: 404 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!subscription.stripeId.startsWith("sub_test_")) {
|
||||||
|
try {
|
||||||
|
await stripe.subscriptions.update(subscription.stripeId, {
|
||||||
|
cancel_at_period_end: true,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("[cancel-subscription] Stripe cancellation failed, continuing with DB update", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.subscription.update({
|
||||||
|
where: { userId },
|
||||||
|
data: {
|
||||||
|
status: "canceled",
|
||||||
|
canceledAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[cancel-subscription]", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to cancel subscription" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/app/api/stripe/create-checkout/route.ts
Normal file
59
src/app/api/stripe/create-checkout/route.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { stripe, STRIPE_PRICE_ID } from "@/lib/stripe";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = session.user.id;
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let stripeCustomerId = user.stripeId;
|
||||||
|
|
||||||
|
if (!stripeCustomerId) {
|
||||||
|
const customer = await stripe.customers.create({
|
||||||
|
email: user.email,
|
||||||
|
metadata: { userId },
|
||||||
|
});
|
||||||
|
stripeCustomerId = customer.id;
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: { stripeId: stripeCustomerId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkoutSession = await stripe.checkout.sessions.create({
|
||||||
|
customer: stripeCustomerId,
|
||||||
|
mode: "subscription",
|
||||||
|
payment_method_types: ["card"],
|
||||||
|
line_items: [
|
||||||
|
{
|
||||||
|
price: STRIPE_PRICE_ID,
|
||||||
|
quantity: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
success_url: `${process.env.NEXTAUTH_URL}/dashboard?checkout=success`,
|
||||||
|
cancel_url: `${process.env.NEXTAUTH_URL}/pricing`,
|
||||||
|
metadata: { userId },
|
||||||
|
subscription_data: {
|
||||||
|
metadata: { userId },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ sessionId: checkoutSession.id });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[create-checkout]", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to create checkout session" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
116
src/app/api/stripe/webhook/route.ts
Normal file
116
src/app/api/stripe/webhook/route.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { stripe } from "@/lib/stripe";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import Stripe from "stripe";
|
||||||
|
|
||||||
|
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET || "";
|
||||||
|
|
||||||
|
async function handler(request: Request) {
|
||||||
|
if (request.method !== "POST") {
|
||||||
|
return NextResponse.json({ error: "Method not allowed" }, { status: 405 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.text();
|
||||||
|
const signature = request.headers.get("stripe-signature") || "";
|
||||||
|
|
||||||
|
let event: Stripe.Event;
|
||||||
|
try {
|
||||||
|
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[webhook] signature verification failed", error);
|
||||||
|
return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`[webhook] received event: ${event.type}`);
|
||||||
|
|
||||||
|
switch (event.type) {
|
||||||
|
case "customer.subscription.created":
|
||||||
|
case "customer.subscription.updated": {
|
||||||
|
const subscription = event.data.object as Stripe.Subscription;
|
||||||
|
const userId = subscription.metadata?.userId;
|
||||||
|
console.log(`[webhook] subscription event - userId: ${userId}, status: ${subscription.status}`);
|
||||||
|
if (!userId) {
|
||||||
|
console.warn("[webhook] missing userId in metadata");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const active = subscription.status === "active";
|
||||||
|
const currentPeriodEnd = new Date((subscription as any).current_period_end * 1000);
|
||||||
|
const cancelAtPeriodEnd = (subscription as any).cancel_at_period_end || false;
|
||||||
|
|
||||||
|
const existing = await prisma.subscription.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await prisma.subscription.update({
|
||||||
|
where: { userId },
|
||||||
|
data: {
|
||||||
|
status: subscription.status,
|
||||||
|
currentPeriodEnd,
|
||||||
|
canceledAt: (subscription as any).canceled_at
|
||||||
|
? new Date((subscription as any).canceled_at * 1000)
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await prisma.subscription.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
stripeId: subscription.id,
|
||||||
|
stripePriceId: subscription.items.data[0]?.price.id || "",
|
||||||
|
stripeCustomerId: subscription.customer as string,
|
||||||
|
status: subscription.status,
|
||||||
|
currentPeriodEnd,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const keepPremium = active && !cancelAtPeriodEnd;
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: { plan: keepPremium ? "PREMIUM" : "FREE" },
|
||||||
|
});
|
||||||
|
console.log(`[webhook] user ${userId} plan updated to: ${active ? "PREMIUM" : "FREE"}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "customer.subscription.deleted": {
|
||||||
|
const subscription = event.data.object as Stripe.Subscription;
|
||||||
|
const userId = subscription.metadata?.userId;
|
||||||
|
if (!userId) break;
|
||||||
|
|
||||||
|
await prisma.subscription.deleteMany({ where: { userId } });
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: { plan: "FREE" },
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "invoice.payment_failed": {
|
||||||
|
const invoice = event.data.object as Stripe.Invoice;
|
||||||
|
const userId = invoice.metadata?.userId;
|
||||||
|
if (!userId) break;
|
||||||
|
|
||||||
|
await prisma.subscription.update({
|
||||||
|
where: { userId },
|
||||||
|
data: { status: "unpaid" },
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[webhook] processing error", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to process webhook" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[webhook] event ${event.type} processed successfully`);
|
||||||
|
return NextResponse.json({ received: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export { handler as POST };
|
||||||
83
src/app/api/test/create-premium-user/route.ts
Normal file
83
src/app/api/test/create-premium-user/route.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const testEmail = "test.premium2@example.com";
|
||||||
|
const testPassword = "TestPassword123!";
|
||||||
|
|
||||||
|
let user = await prisma.user.findUnique({
|
||||||
|
where: { email: testEmail },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
const passwordHash = await bcrypt.hash(testPassword, 10);
|
||||||
|
user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: testEmail,
|
||||||
|
name: "Test Premium User 2",
|
||||||
|
passwordHash,
|
||||||
|
plan: "PREMIUM",
|
||||||
|
emailVerified: new Date(),
|
||||||
|
stripeId: "cus_test_" + Date.now(),
|
||||||
|
subscription: {
|
||||||
|
create: {
|
||||||
|
stripeId: "sub_test_" + Date.now(),
|
||||||
|
stripePriceId: "price_test",
|
||||||
|
stripeCustomerId: "cus_test_" + Date.now(),
|
||||||
|
status: "active",
|
||||||
|
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: { subscription: true },
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Reset the existing user to premium status
|
||||||
|
await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
plan: "PREMIUM",
|
||||||
|
emailVerified: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const sub = await prisma.subscription.findUnique({ where: { userId: user.id } });
|
||||||
|
if (!sub) {
|
||||||
|
await prisma.subscription.create({
|
||||||
|
data: {
|
||||||
|
userId: user.id,
|
||||||
|
stripeId: "sub_test_" + Date.now(),
|
||||||
|
stripePriceId: "price_test",
|
||||||
|
stripeCustomerId: "cus_test_" + Date.now(),
|
||||||
|
status: "active",
|
||||||
|
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await prisma.subscription.update({
|
||||||
|
where: { userId: user.id },
|
||||||
|
data: {
|
||||||
|
status: "active",
|
||||||
|
canceledAt: null,
|
||||||
|
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
email: testEmail,
|
||||||
|
password: testPassword,
|
||||||
|
plan: user.plan,
|
||||||
|
hasSubscription: !!user.stripeId,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[test] error", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to create test user" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/app/auth/layout.tsx
Normal file
19
src/app/auth/layout.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Activity } from "lucide-react";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Diabetix",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AuthLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col items-center justify-center bg-gradient-to-br from-teal-50 via-white to-slate-50 px-4 py-12">
|
||||||
|
<Link href="/" className="mb-8 flex items-center gap-2 text-teal-700">
|
||||||
|
<Activity className="h-6 w-6" />
|
||||||
|
<span className="text-xl font-bold tracking-tight">Diabetix</span>
|
||||||
|
</Link>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
src/app/auth/login/LoginForm.tsx
Normal file
117
src/app/auth/login/LoginForm.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { signIn } from "next-auth/react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Loader2, Eye, EyeOff } from "lucide-react";
|
||||||
|
|
||||||
|
export function LoginForm() {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useSearchParams();
|
||||||
|
const verified = params.get("verified") === "1";
|
||||||
|
const errorParam = params.get("error");
|
||||||
|
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [showPwd, setShowPwd] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const result = await signIn("credentials", {
|
||||||
|
email: email.trim().toLowerCase(),
|
||||||
|
password,
|
||||||
|
redirect: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.error) {
|
||||||
|
setError("Email ou mot de passe incorrect.");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push("/dashboard");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
|
||||||
|
{verified && (
|
||||||
|
<div className="mb-4 rounded-lg border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
|
||||||
|
✅ Email confirmé ! Vous pouvez vous connecter.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{errorParam === "token_expired" && (
|
||||||
|
<div className="mb-4 rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||||
|
⚠️ Lien expiré. Inscrivez-vous à nouveau.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-slate-700">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full rounded-lg border border-slate-300 px-3 py-2.5 outline-none focus:border-teal-500 focus:ring-2 focus:ring-teal-200"
|
||||||
|
placeholder="vous@exemple.fr"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-slate-700">
|
||||||
|
Mot de passe
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showPwd ? "text" : "password"}
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full rounded-lg border border-slate-300 px-3 py-2.5 pr-10 outline-none focus:border-teal-500 focus:ring-2 focus:ring-teal-200"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPwd((v) => !v)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||||
|
>
|
||||||
|
{showPwd ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg border border-rose-300 bg-rose-50 px-3 py-2 text-sm text-rose-800">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="flex w-full items-center justify-center gap-2 rounded-lg bg-teal-600 py-2.5 text-sm font-semibold text-white hover:bg-teal-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
Se connecter
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="mt-6 text-center text-sm text-slate-500">
|
||||||
|
Pas encore de compte ?{" "}
|
||||||
|
<Link href="/auth/register" className="font-medium text-teal-700 hover:underline">
|
||||||
|
S'inscrire gratuitement
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
src/app/auth/login/page.tsx
Normal file
18
src/app/auth/login/page.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { LoginForm } from "./LoginForm";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
|
||||||
|
export const metadata = { title: "Connexion — Diabetix" };
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="mb-6 text-center">
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900">Connexion</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">Accédez à votre espace de suivi</p>
|
||||||
|
</div>
|
||||||
|
<Suspense>
|
||||||
|
<LoginForm />
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
155
src/app/auth/register/RegisterForm.tsx
Normal file
155
src/app/auth/register/RegisterForm.tsx
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { CheckCircle2, Eye, EyeOff, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
export function RegisterForm() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [showPwd, setShowPwd] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/register", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ name, email, password }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error ?? "Erreur lors de l'inscription");
|
||||||
|
if (data.emailError) {
|
||||||
|
console.warn("[register] email error:", data.emailError);
|
||||||
|
throw new Error(`Compte créé mais l'email de confirmation n'a pas pu être envoyé : ${data.emailError}`);
|
||||||
|
}
|
||||||
|
setDone(true);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Erreur inconnue");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm text-center">
|
||||||
|
<CheckCircle2 className="mx-auto mb-4 h-12 w-12 text-teal-600" />
|
||||||
|
<h2 className="text-xl font-bold text-slate-900">Vérifiez vos emails !</h2>
|
||||||
|
<p className="mt-3 text-sm text-slate-600 leading-relaxed">
|
||||||
|
Un email de confirmation a été envoyé à <strong>{email}</strong>.<br />
|
||||||
|
Cliquez sur le lien pour activer votre compte.
|
||||||
|
</p>
|
||||||
|
<p className="mt-4 text-xs text-slate-400">
|
||||||
|
Pas reçu ?{" "}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setDone(false); setEmail(""); }}
|
||||||
|
className="text-teal-700 underline"
|
||||||
|
>
|
||||||
|
Réessayer
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-slate-700">
|
||||||
|
Prénom et nom
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
autoComplete="name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={2}
|
||||||
|
className="w-full rounded-lg border border-slate-300 px-3 py-2.5 outline-none focus:border-teal-500 focus:ring-2 focus:ring-teal-200"
|
||||||
|
placeholder="Jean Dupont"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-slate-700">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
className="w-full rounded-lg border border-slate-300 px-3 py-2.5 outline-none focus:border-teal-500 focus:ring-2 focus:ring-teal-200"
|
||||||
|
placeholder="vous@exemple.fr"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 block text-sm font-medium text-slate-700">
|
||||||
|
Mot de passe{" "}
|
||||||
|
<span className="font-normal text-slate-400">(8 car. min)</span>
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type={showPwd ? "text" : "password"}
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
minLength={8}
|
||||||
|
className="w-full rounded-lg border border-slate-300 px-3 py-2.5 pr-10 outline-none focus:border-teal-500 focus:ring-2 focus:ring-teal-200"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPwd((v) => !v)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600"
|
||||||
|
>
|
||||||
|
{showPwd ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-lg border border-rose-300 bg-rose-50 px-3 py-2 text-sm text-rose-800">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="flex w-full items-center justify-center gap-2 rounded-lg bg-teal-600 py-2.5 text-sm font-semibold text-white hover:bg-teal-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
Créer mon compte
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="text-center text-xs text-slate-400 leading-relaxed">
|
||||||
|
En vous inscrivant, vous acceptez nos{" "}
|
||||||
|
<Link href="/legal" className="underline">CGU</Link>
|
||||||
|
{" "}et notre{" "}
|
||||||
|
<Link href="/legal" className="underline">politique de confidentialité</Link>.
|
||||||
|
</p>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="mt-6 text-center text-sm text-slate-500">
|
||||||
|
Déjà inscrit ?{" "}
|
||||||
|
<Link href="/auth/login" className="font-medium text-teal-700 hover:underline">
|
||||||
|
Se connecter
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
src/app/auth/register/page.tsx
Normal file
17
src/app/auth/register/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { RegisterForm } from "./RegisterForm";
|
||||||
|
|
||||||
|
export const metadata = { title: "Inscription — Diabetix" };
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="mb-6 text-center">
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900">Créer un compte</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">
|
||||||
|
Commencez gratuitement, sans carte bancaire
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<RegisterForm />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
src/app/auth/verify-pending/page.tsx
Normal file
25
src/app/auth/verify-pending/page.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { MailCheck } from "lucide-react";
|
||||||
|
|
||||||
|
export const metadata = { title: "Vérification email — Diabetix" };
|
||||||
|
|
||||||
|
export default function VerifyPendingPage() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col items-center justify-center bg-gradient-to-br from-teal-50 via-white to-slate-50 px-4">
|
||||||
|
<div className="w-full max-w-md rounded-2xl border border-slate-200 bg-white p-8 shadow-sm text-center">
|
||||||
|
<MailCheck className="mx-auto mb-4 h-12 w-12 text-teal-600" />
|
||||||
|
<h1 className="text-xl font-bold text-slate-900">Vérifiez votre email</h1>
|
||||||
|
<p className="mt-3 text-sm text-slate-600 leading-relaxed">
|
||||||
|
Votre compte est créé ! Cliquez sur le lien que vous avez reçu par email pour accéder à votre espace.
|
||||||
|
</p>
|
||||||
|
<p className="mt-4 text-xs text-slate-400">
|
||||||
|
Email non reçu ? Vérifiez vos spams ou{" "}
|
||||||
|
<Link href="/auth/register" className="text-teal-700 underline">
|
||||||
|
réinscrivez-vous
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
src/app/dashboard/historique/page.tsx
Normal file
36
src/app/dashboard/historique/page.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { HistoryClient } from "@/app/historique/HistoryClient";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const metadata = { title: "Historique — Diabetix" };
|
||||||
|
|
||||||
|
export default async function HistoriquePage() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) redirect("/auth/login");
|
||||||
|
const userId = session.user.id;
|
||||||
|
|
||||||
|
const plan = (session.user as { plan?: string }).plan ?? "FREE";
|
||||||
|
const cutoff = plan === "PREMIUM"
|
||||||
|
? undefined
|
||||||
|
: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const readings = await prisma.reading.findMany({
|
||||||
|
where: { userId, ...(cutoff ? { measuredAt: { gte: cutoff } } : {}) },
|
||||||
|
orderBy: { measuredAt: "desc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HistoryClient
|
||||||
|
readings={readings.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
measuredAt: r.measuredAt.toISOString(),
|
||||||
|
moment: r.moment,
|
||||||
|
value: r.value,
|
||||||
|
notes: r.notes,
|
||||||
|
}))}
|
||||||
|
isPremium={plan === "PREMIUM"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
src/app/dashboard/layout.tsx
Normal file
41
src/app/dashboard/layout.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { DIABETES_TYPE_LABELS } from "@/lib/patient";
|
||||||
|
import { AppShell } from "@/components/AppShell";
|
||||||
|
|
||||||
|
export default async function DashboardLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user) redirect("/auth/login");
|
||||||
|
if (!(session.user as { emailVerified?: Date | null }).emailVerified) redirect("/auth/verify-pending");
|
||||||
|
|
||||||
|
const patient = await prisma.patient.findUnique({
|
||||||
|
where: { userId: session.user.id },
|
||||||
|
select: { firstName: true, lastName: true, diabetesType: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const fullName = patient
|
||||||
|
? `${patient.firstName} ${patient.lastName}`.trim()
|
||||||
|
: session.user.name ?? null;
|
||||||
|
const initials = fullName
|
||||||
|
? fullName.split(" ").map((w) => w[0]).join("").toUpperCase().slice(0, 2)
|
||||||
|
: null;
|
||||||
|
const diabetesLabel = patient?.diabetesType
|
||||||
|
? DIABETES_TYPE_LABELS[patient.diabetesType] ?? null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell
|
||||||
|
fullName={fullName}
|
||||||
|
initials={initials}
|
||||||
|
diabetesLabel={diabetesLabel}
|
||||||
|
plan={(session.user as { plan?: string }).plan ?? "FREE"}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
src/app/dashboard/mobile/page.tsx
Normal file
29
src/app/dashboard/mobile/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { MobileEntry } from "@/app/mobile/MobileEntry";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function MobilePage() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) redirect("/auth/login");
|
||||||
|
const userId = session.user.id;
|
||||||
|
|
||||||
|
const [patient, last3] = await Promise.all([
|
||||||
|
prisma.patient.findUnique({ where: { userId }, select: { firstName: true } }),
|
||||||
|
prisma.reading.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { measuredAt: "desc" },
|
||||||
|
take: 3,
|
||||||
|
select: { measuredAt: true, moment: true, value: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MobileEntry
|
||||||
|
firstName={patient?.firstName ?? null}
|
||||||
|
last3={last3.map((r) => ({ measuredAt: r.measuredAt.toISOString(), moment: r.moment, value: r.value }))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
250
src/app/dashboard/page.tsx
Normal file
250
src/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { format, formatDistanceToNow } from "date-fns";
|
||||||
|
import { fr } from "date-fns/locale";
|
||||||
|
import { ArrowRight, Droplet, History, PlusCircle, Smartphone, TrendingUp } from "lucide-react";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import {
|
||||||
|
estimateHbA1c,
|
||||||
|
formatValue,
|
||||||
|
MOMENT_LABELS,
|
||||||
|
STATUS_STYLE,
|
||||||
|
statusFor,
|
||||||
|
type Moment,
|
||||||
|
} from "@/lib/glycemia";
|
||||||
|
import { GlycemiaChart } from "@/components/GlycemiaChart";
|
||||||
|
import { DailyInsight } from "@/components/DailyInsight";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
function summarize(rows: { value: number; moment: string }[]) {
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return { count: 0, avg: null as number | null, inRangePct: null as number | null, hypo: 0, hyper: 0 };
|
||||||
|
}
|
||||||
|
let inRange = 0, hypo = 0, hyper = 0, sum = 0;
|
||||||
|
for (const r of rows) {
|
||||||
|
sum += r.value;
|
||||||
|
const s = statusFor(r.value, r.moment as Moment);
|
||||||
|
if (s === "in-range") inRange++;
|
||||||
|
else if (s === "hypo") hypo++;
|
||||||
|
else hyper++;
|
||||||
|
}
|
||||||
|
return { count: rows.length, avg: sum / rows.length, inRangePct: (inRange / rows.length) * 100, hypo, hyper };
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function DashboardPage() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) redirect("/auth/login");
|
||||||
|
const userId = session.user.id;
|
||||||
|
const plan = (session.user as { plan?: string }).plan ?? "FREE";
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const ago7 = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||||
|
const ago30 = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||||
|
const ago90 = new Date(now.getTime() - 90 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
// FREE plan: only last 7 days
|
||||||
|
const readingCutoff = plan === "PREMIUM" ? ago90 : ago7;
|
||||||
|
|
||||||
|
const [last, last7, last30, last90, chartRows, patient] = await Promise.all([
|
||||||
|
prisma.reading.findFirst({ where: { userId }, orderBy: { measuredAt: "desc" } }),
|
||||||
|
prisma.reading.findMany({ where: { userId, measuredAt: { gte: ago7 } } }),
|
||||||
|
plan === "PREMIUM"
|
||||||
|
? prisma.reading.findMany({ where: { userId, measuredAt: { gte: ago30 } } })
|
||||||
|
: Promise.resolve([]),
|
||||||
|
plan === "PREMIUM"
|
||||||
|
? prisma.reading.findMany({ where: { userId, measuredAt: { gte: ago90 } } })
|
||||||
|
: Promise.resolve([]),
|
||||||
|
prisma.reading.findMany({
|
||||||
|
where: { userId, measuredAt: { gte: readingCutoff } },
|
||||||
|
orderBy: { measuredAt: "asc" },
|
||||||
|
select: { measuredAt: true, value: true, moment: true },
|
||||||
|
}),
|
||||||
|
prisma.patient.findUnique({ where: { userId }, select: { firstName: true } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const week = summarize(last7);
|
||||||
|
const month = summarize(last30);
|
||||||
|
const quarter = summarize(last90);
|
||||||
|
const hba1c = quarter.avg ? estimateHbA1c(quarter.avg) : null;
|
||||||
|
|
||||||
|
const lastStatus = last ? statusFor(last.value, last.moment as Moment) : null;
|
||||||
|
const isPremium = plan === "PREMIUM";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-slate-900">
|
||||||
|
{patient ? `Bonjour ${patient.firstName}` : "Tableau de bord"}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
Suivi de votre glycémie — 3 relevés par jour (matin, midi, soir).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link
|
||||||
|
href="/dashboard/mobile"
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg border border-teal-300 bg-teal-50 px-3 py-2 text-sm font-medium text-teal-700 hover:bg-teal-100 md:hidden"
|
||||||
|
>
|
||||||
|
<Smartphone className="h-4 w-4" />
|
||||||
|
Saisie rapide
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/dashboard/saisie"
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-teal-700"
|
||||||
|
>
|
||||||
|
<PlusCircle className="h-4 w-4" />
|
||||||
|
Nouveau relevé
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dernière mesure */}
|
||||||
|
<section className="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-medium text-slate-500 uppercase tracking-wide">Dernière mesure</h2>
|
||||||
|
<Droplet className="h-4 w-4 text-teal-600" />
|
||||||
|
</div>
|
||||||
|
{last && lastStatus ? (
|
||||||
|
<div className="mt-3 flex flex-wrap items-end gap-x-6 gap-y-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-4xl font-semibold tracking-tight text-slate-900">
|
||||||
|
{formatValue(last.value)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-sm text-slate-500">
|
||||||
|
{MOMENT_LABELS[last.moment as Moment]} · il y a{" "}
|
||||||
|
{formatDistanceToNow(new Date(last.measuredAt), { locale: fr })}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-slate-400">
|
||||||
|
{format(new Date(last.measuredAt), "EEEE d MMMM yyyy 'à' HH:mm", { locale: fr })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium ${STATUS_STYLE[lastStatus].bg} ${STATUS_STYLE[lastStatus].color} ${STATUS_STYLE[lastStatus].border}`}>
|
||||||
|
{STATUS_STYLE[lastStatus].label}
|
||||||
|
</span>
|
||||||
|
{last.notes && (
|
||||||
|
<div className="basis-full text-sm text-slate-600 italic">« {last.notes} »</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-3 text-sm text-slate-500">
|
||||||
|
Aucun relevé pour le moment.{" "}
|
||||||
|
<Link href="/dashboard/saisie" className="text-teal-700 underline">
|
||||||
|
Saisir le premier
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{isPremium ? (
|
||||||
|
<DailyInsight />
|
||||||
|
) : (
|
||||||
|
<section className="rounded-xl border border-amber-200 bg-amber-50 p-5">
|
||||||
|
<p className="text-sm text-amber-800">
|
||||||
|
💡 L'analyse IA quotidienne et le coach Diablo sont disponibles avec le plan{" "}
|
||||||
|
<Link href="/pricing" className="font-semibold underline">
|
||||||
|
Premium
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Stats cards */}
|
||||||
|
<section className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<StatCard
|
||||||
|
title="Moyenne 7 jours"
|
||||||
|
value={week.avg !== null ? formatValue(week.avg) : "—"}
|
||||||
|
sub={`${week.count} relevé${week.count > 1 ? "s" : ""}`}
|
||||||
|
/>
|
||||||
|
{isPremium ? (
|
||||||
|
<>
|
||||||
|
<StatCard
|
||||||
|
title="Moyenne 30 jours"
|
||||||
|
value={month.avg !== null ? formatValue(month.avg) : "—"}
|
||||||
|
sub={`${month.count} relevé${month.count > 1 ? "s" : ""}`}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="% dans la cible (30j)"
|
||||||
|
value={month.inRangePct !== null ? `${month.inRangePct.toFixed(0)} %` : "—"}
|
||||||
|
sub={month.count > 0 ? `${month.hypo} hypo · ${month.hyper} hyper` : "Aucune donnée"}
|
||||||
|
accent={
|
||||||
|
month.inRangePct === null
|
||||||
|
? undefined
|
||||||
|
: month.inRangePct >= 70
|
||||||
|
? "text-emerald-700"
|
||||||
|
: month.inRangePct >= 50
|
||||||
|
? "text-amber-700"
|
||||||
|
: "text-rose-700"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="HbA1c estimée (90j)"
|
||||||
|
value={hba1c !== null ? `${hba1c.toFixed(1)} %` : "—"}
|
||||||
|
sub="Indicatif — basé sur la moyenne"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="col-span-3 flex items-center rounded-xl border border-slate-200 bg-white px-4 py-4 shadow-sm">
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
Statistiques 30/90 jours et HbA1c estimée disponibles avec le plan{" "}
|
||||||
|
<Link href="/pricing" className="text-teal-700 underline font-medium">Premium</Link>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Chart */}
|
||||||
|
<section className="rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h2 className="flex items-center gap-2 text-sm font-medium text-slate-700">
|
||||||
|
<TrendingUp className="h-4 w-4 text-teal-600" />
|
||||||
|
{isPremium ? "Évolution sur 30 jours" : "Évolution sur 7 jours"}
|
||||||
|
</h2>
|
||||||
|
<Link
|
||||||
|
href="/dashboard/historique"
|
||||||
|
className="inline-flex items-center gap-1 text-xs font-medium text-teal-700 hover:text-teal-900"
|
||||||
|
>
|
||||||
|
<History className="h-3.5 w-3.5" />
|
||||||
|
Voir l'historique
|
||||||
|
<ArrowRight className="h-3 w-3" />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<GlycemiaChart
|
||||||
|
data={chartRows.map((r) => ({
|
||||||
|
measuredAt: r.measuredAt.toISOString(),
|
||||||
|
value: r.value,
|
||||||
|
moment: r.moment,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-3 text-xs text-slate-500">
|
||||||
|
<LegendDot color="bg-emerald-400/60" label="Cible (0,7 – 1,8 g/L)" />
|
||||||
|
<LegendDot color="bg-amber-400/60" label="Hypo (< 0,7)" />
|
||||||
|
<LegendDot color="bg-amber-400/60" label="Hyper (> 1,8)" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ title, value, sub, accent }: { title: string; value: string; sub?: string; accent?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
|
||||||
|
<div className="text-xs font-medium uppercase tracking-wide text-slate-500">{title}</div>
|
||||||
|
<div className={`mt-2 text-2xl font-semibold ${accent ?? "text-slate-900"}`}>{value}</div>
|
||||||
|
{sub && <div className="mt-1 text-xs text-slate-500">{sub}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LegendDot({ color, label }: { color: string; label: string }) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className={`inline-block h-3 w-3 rounded-sm ${color}`} />
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
src/app/dashboard/profil/page.tsx
Normal file
51
src/app/dashboard/profil/page.tsx
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { ProfileForm } from "@/app/profil/ProfileForm";
|
||||||
|
import { CancelSubscriptionButton } from "@/app/profil/CancelSubscriptionButton";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const metadata = { title: "Mon profil — Diabetix" };
|
||||||
|
|
||||||
|
export default async function ProfilPage() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) redirect("/auth/login");
|
||||||
|
const userId = session.user.id;
|
||||||
|
|
||||||
|
const patient = await prisma.patient.findUnique({ where: { userId } });
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||||
|
const subscription = await prisma.subscription.findUnique({
|
||||||
|
where: { userId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-5xl space-y-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-slate-900">Mon profil</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">Vos informations personnelles et médicales.</p>
|
||||||
|
</div>
|
||||||
|
<ProfileForm
|
||||||
|
initial={
|
||||||
|
patient
|
||||||
|
? {
|
||||||
|
firstName: patient.firstName,
|
||||||
|
lastName: patient.lastName,
|
||||||
|
email: patient.email,
|
||||||
|
birthDate: patient.birthDate ? patient.birthDate.toISOString().split("T")[0] : null,
|
||||||
|
heightCm: patient.heightCm,
|
||||||
|
weightKg: patient.weightKg,
|
||||||
|
sex: patient.sex,
|
||||||
|
diabetesType: patient.diabetesType,
|
||||||
|
treatment: patient.treatment,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CancelSubscriptionButton
|
||||||
|
isPremium={user?.plan === "PREMIUM"}
|
||||||
|
subscriptionEndDate={subscription?.currentPeriodEnd}
|
||||||
|
isScheduledForCancellation={subscription?.status === "canceled"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
src/app/dashboard/rapports/page.tsx
Normal file
110
src/app/dashboard/rapports/page.tsx
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { format, subMonths, startOfMonth, endOfMonth } from "date-fns";
|
||||||
|
import { fr } from "date-fns/locale";
|
||||||
|
import { Download, Lock } from "lucide-react";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const metadata = { title: "Mes Rapports — Diabetix" };
|
||||||
|
|
||||||
|
export default async function RapportsPage() {
|
||||||
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) redirect("/auth/login");
|
||||||
|
|
||||||
|
const userId = session.user.id;
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||||
|
|
||||||
|
const isPremium = user?.plan === "PREMIUM";
|
||||||
|
|
||||||
|
const months = Array.from({ length: 12 }, (_, i) => {
|
||||||
|
return subMonths(startOfMonth(new Date()), i);
|
||||||
|
});
|
||||||
|
|
||||||
|
const readingsByMonth = await Promise.all(
|
||||||
|
months.map(async (month) => {
|
||||||
|
const count = await prisma.reading.count({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
measuredAt: {
|
||||||
|
gte: month,
|
||||||
|
lte: endOfMonth(month),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { month, count };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Filter to only show months with readings
|
||||||
|
const monthsWithReadings = readingsByMonth.filter(({ count }) => count > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-5xl space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-slate-900">Mes Rapports</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">
|
||||||
|
Téléchargez vos rapports glycémie mensuels en PDF
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isPremium && (
|
||||||
|
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Lock className="h-5 w-5 text-blue-600 flex-shrink-0" />
|
||||||
|
<div className="text-sm text-blue-800">
|
||||||
|
<p className="font-medium">Feature Premium</p>
|
||||||
|
<p className="text-blue-700">
|
||||||
|
Les rapports PDF sont exclusifs au plan Premium. Passer à Premium pour y accéder.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{monthsWithReadings.length > 0 ? (
|
||||||
|
monthsWithReadings.map(({ month, count }) => (
|
||||||
|
<div
|
||||||
|
key={month.toISOString()}
|
||||||
|
className="flex items-center justify-between rounded-lg border border-slate-200 bg-white p-4 hover:border-slate-300"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-slate-900">
|
||||||
|
{format(month, "MMMM yyyy", { locale: fr })}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-slate-500">{count} mesure(s)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{count > 0 && isPremium && (
|
||||||
|
<a
|
||||||
|
href={`/api/reports/generate-pdf?month=${month.toISOString()}`}
|
||||||
|
download
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg bg-teal-600 px-3 py-2 text-sm font-medium text-white hover:bg-teal-700"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">Télécharger</span>
|
||||||
|
<span className="sm:hidden">PDF</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{count > 0 && !isPremium && (
|
||||||
|
<div className="inline-flex items-center gap-2 rounded-lg bg-slate-100 px-3 py-2 text-sm font-medium text-slate-500">
|
||||||
|
<Lock className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{count === 0 && (
|
||||||
|
<p className="text-sm text-slate-400">Aucune donnée</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="text-center rounded-lg border border-slate-200 bg-white p-8">
|
||||||
|
<p className="text-sm text-slate-500">Aucun mois avec des données.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
15
src/app/dashboard/saisie/page.tsx
Normal file
15
src/app/dashboard/saisie/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { ReadingForm } from "@/app/saisie/ReadingForm";
|
||||||
|
|
||||||
|
export const metadata = { title: "Nouveau relevé — Diabetix" };
|
||||||
|
|
||||||
|
export default function SaisiePage() {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-5xl space-y-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-slate-900">Nouveau relevé</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-500">Saisissez votre glycémie du moment.</p>
|
||||||
|
</div>
|
||||||
|
<ReadingForm />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,26 +1,11 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
:root {
|
|
||||||
--background: #ffffff;
|
|
||||||
--foreground: #171717;
|
|
||||||
}
|
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
|
||||||
--color-foreground: var(--foreground);
|
|
||||||
--font-sans: var(--font-geist-sans);
|
--font-sans: var(--font-geist-sans);
|
||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: var(--font-geist-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
html,
|
||||||
:root {
|
|
||||||
--background: #0a0a0a;
|
|
||||||
--foreground: #ededed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--background);
|
font-family: var(--font-geist-sans), system-ui, -apple-system, sans-serif;
|
||||||
color: var(--foreground);
|
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
|
||||||
}
|
}
|
||||||
|
|||||||
179
src/app/historique/HistoryClient.tsx
Normal file
179
src/app/historique/HistoryClient.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState, useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { fr } from "date-fns/locale";
|
||||||
|
import { Download, Trash2 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
formatValue,
|
||||||
|
MOMENT_LABELS,
|
||||||
|
STATUS_STYLE,
|
||||||
|
statusFor,
|
||||||
|
type Moment,
|
||||||
|
} from "@/lib/glycemia";
|
||||||
|
|
||||||
|
type ReadingRow = {
|
||||||
|
id: number;
|
||||||
|
measuredAt: string;
|
||||||
|
moment: string;
|
||||||
|
value: number;
|
||||||
|
notes: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Filter = "all" | "FASTING" | "LUNCH" | "DINNER" | "out-of-range";
|
||||||
|
|
||||||
|
export function HistoryClient({ readings, isPremium }: { readings: ReadingRow[]; isPremium?: boolean }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [filter, setFilter] = useState<Filter>("all");
|
||||||
|
const [pending, startTransition] = useTransition();
|
||||||
|
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
return readings.filter((r) => {
|
||||||
|
if (filter === "all") return true;
|
||||||
|
if (filter === "out-of-range") {
|
||||||
|
return statusFor(r.value, r.moment as Moment) !== "in-range";
|
||||||
|
}
|
||||||
|
return r.moment === filter;
|
||||||
|
});
|
||||||
|
}, [readings, filter]);
|
||||||
|
|
||||||
|
async function handleDelete(id: number) {
|
||||||
|
if (!confirm("Supprimer ce relevé ?")) return;
|
||||||
|
setDeletingId(id);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/readings/${id}`, { method: "DELETE" });
|
||||||
|
if (!res.ok) throw new Error("Suppression impossible");
|
||||||
|
startTransition(() => router.refresh());
|
||||||
|
} catch (e) {
|
||||||
|
alert(e instanceof Error ? e.message : "Erreur");
|
||||||
|
} finally {
|
||||||
|
setDeletingId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{!isPremium && (
|
||||||
|
<div className="rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
||||||
|
Seuls les 7 derniers jours sont affichés sur le plan gratuit.{" "}
|
||||||
|
<Link href="/pricing" className="font-semibold underline">Passer à Premium</Link> pour l'historique complet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold text-slate-900">Historique</h1>
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
{readings.length} relevé{readings.length > 1 ? "s" : ""} enregistré
|
||||||
|
{readings.length > 1 ? "s" : ""}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="/api/export"
|
||||||
|
className="inline-flex items-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
Exporter en CSV
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<FilterPill active={filter === "all"} onClick={() => setFilter("all")}>
|
||||||
|
Tous
|
||||||
|
</FilterPill>
|
||||||
|
<FilterPill active={filter === "FASTING"} onClick={() => setFilter("FASTING")}>
|
||||||
|
Matin
|
||||||
|
</FilterPill>
|
||||||
|
<FilterPill active={filter === "LUNCH"} onClick={() => setFilter("LUNCH")}>
|
||||||
|
Midi
|
||||||
|
</FilterPill>
|
||||||
|
<FilterPill active={filter === "DINNER"} onClick={() => setFilter("DINNER")}>
|
||||||
|
Soir
|
||||||
|
</FilterPill>
|
||||||
|
<FilterPill active={filter === "out-of-range"} onClick={() => setFilter("out-of-range")}>
|
||||||
|
Hors cible
|
||||||
|
</FilterPill>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="px-4 py-10 text-center text-sm text-slate-500">
|
||||||
|
Aucun relevé pour ce filtre.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-slate-100">
|
||||||
|
{filtered.map((r) => {
|
||||||
|
const status = statusFor(r.value, r.moment as Moment);
|
||||||
|
const date = new Date(r.measuredAt);
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={r.id}
|
||||||
|
className="flex items-center gap-3 px-4 py-3 transition hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||||
|
<span className="text-base font-semibold text-slate-900">
|
||||||
|
{formatValue(r.value)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium ${STATUS_STYLE[status].bg} ${STATUS_STYLE[status].color} ${STATUS_STYLE[status].border}`}
|
||||||
|
>
|
||||||
|
{STATUS_STYLE[status].label}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
{MOMENT_LABELS[r.moment as Moment]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-xs text-slate-500">
|
||||||
|
{format(date, "EEEE d MMM yyyy 'à' HH:mm", { locale: fr })}
|
||||||
|
</div>
|
||||||
|
{r.notes && (
|
||||||
|
<div className="mt-1 text-sm text-slate-600 italic line-clamp-2">
|
||||||
|
« {r.notes} »
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDelete(r.id)}
|
||||||
|
disabled={pending && deletingId === r.id}
|
||||||
|
className="rounded-md p-2 text-slate-400 hover:bg-rose-50 hover:text-rose-600 disabled:opacity-50"
|
||||||
|
aria-label="Supprimer"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilterPill({
|
||||||
|
active,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={`rounded-full border px-3 py-1.5 text-xs font-medium transition ${
|
||||||
|
active
|
||||||
|
? "border-teal-600 bg-teal-600 text-white"
|
||||||
|
: "border-slate-300 bg-white text-slate-700 hover:bg-slate-50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/app/historique/page.tsx
Normal file
5
src/app/historique/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function HistoriquePage() {
|
||||||
|
redirect("/dashboard/historique");
|
||||||
|
}
|
||||||
@@ -1,33 +1,32 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
|
import { ServiceWorkerRegister } from "@/components/ServiceWorkerRegister";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({ variable: "--font-geist-sans", subsets: ["latin"] });
|
||||||
variable: "--font-geist-sans",
|
const geistMono = Geist_Mono({ variable: "--font-geist-mono", subsets: ["latin"] });
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "Diabetix — Suivi de glycémie",
|
||||||
description: "Generated by create next app",
|
description: "Application de suivi du diabète : trois relevés quotidiens, statistiques et tendances.",
|
||||||
|
manifest: "/manifest.webmanifest",
|
||||||
|
appleWebApp: { capable: true, statusBarStyle: "default", title: "Diabetix" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export const viewport: Viewport = {
|
||||||
children,
|
themeColor: "#0f766e",
|
||||||
}: Readonly<{
|
width: "device-width",
|
||||||
children: React.ReactNode;
|
initialScale: 1,
|
||||||
}>) {
|
maximumScale: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||||
return (
|
return (
|
||||||
<html
|
<html lang="fr" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}>
|
||||||
lang="en"
|
<body className="min-h-full bg-slate-50 text-slate-900 flex flex-col">
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
<ServiceWorkerRegister />
|
||||||
>
|
{children}
|
||||||
<body className="min-h-full flex flex-col">{children}</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
297
src/app/mobile/MobileEntry.tsx
Normal file
297
src/app/mobile/MobileEntry.tsx
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useMemo, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { fr } from "date-fns/locale";
|
||||||
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Delete,
|
||||||
|
Droplets,
|
||||||
|
Loader2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import {
|
||||||
|
MOMENT_LABELS,
|
||||||
|
STATUS_STYLE,
|
||||||
|
statusFor,
|
||||||
|
type Moment,
|
||||||
|
} from "@/lib/glycemia";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type LastReading = { measuredAt: string; moment: string; value: number };
|
||||||
|
|
||||||
|
const MOMENTS: Moment[] = ["FASTING", "LUNCH", "DINNER"];
|
||||||
|
const MOMENT_SHORT: Record<Moment, string> = {
|
||||||
|
FASTING: "Matin",
|
||||||
|
LUNCH: "Midi",
|
||||||
|
DINNER: "Soir",
|
||||||
|
};
|
||||||
|
const MOMENT_ICON: Record<Moment, string> = {
|
||||||
|
FASTING: "🌅",
|
||||||
|
LUNCH: "☀️",
|
||||||
|
DINNER: "🌙",
|
||||||
|
};
|
||||||
|
|
||||||
|
function suggestMoment(): Moment {
|
||||||
|
const h = new Date().getHours();
|
||||||
|
if (h < 11) return "FASTING";
|
||||||
|
if (h < 17) return "LUNCH";
|
||||||
|
return "DINNER";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple numpad keys
|
||||||
|
const PAD_KEYS = [
|
||||||
|
["1", "2", "3"],
|
||||||
|
["4", "5", "6"],
|
||||||
|
["7", "8", "9"],
|
||||||
|
[".", "0", "⌫"],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function MobileEntry({
|
||||||
|
firstName,
|
||||||
|
last3,
|
||||||
|
}: {
|
||||||
|
firstName: string | null;
|
||||||
|
last3: LastReading[];
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [moment, setMoment] = useState<Moment>(suggestMoment());
|
||||||
|
const [digits, setDigits] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [success, setSuccess] = useState<{ value: number; moment: Moment } | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const momentIdx = MOMENTS.indexOf(moment);
|
||||||
|
|
||||||
|
function prevMoment() {
|
||||||
|
setMoment(MOMENTS[(momentIdx + MOMENTS.length - 1) % MOMENTS.length]);
|
||||||
|
}
|
||||||
|
function nextMoment() {
|
||||||
|
setMoment(MOMENTS[(momentIdx + 1) % MOMENTS.length]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKey(key: string) {
|
||||||
|
setError(null);
|
||||||
|
if (key === "⌫") {
|
||||||
|
setDigits((d) => d.slice(0, -1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Only one dot allowed
|
||||||
|
if (key === "." && digits.includes(".")) return;
|
||||||
|
// Max 4 chars total (e.g. "1.35")
|
||||||
|
if (digits.replace(".", "").length >= 4) return;
|
||||||
|
// Don't start with two zeros
|
||||||
|
if (digits === "0" && key === "0") return;
|
||||||
|
setDigits((d) => d + key);
|
||||||
|
}
|
||||||
|
|
||||||
|
const numericValue = useMemo(() => {
|
||||||
|
const v = parseFloat(digits.replace(",", "."));
|
||||||
|
return Number.isFinite(v) && v > 0 ? v : null;
|
||||||
|
}, [digits]);
|
||||||
|
|
||||||
|
const liveStatus = numericValue !== null ? statusFor(numericValue, moment) : null;
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!numericValue) {
|
||||||
|
setError("Saisissez une valeur.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (numericValue > 6) {
|
||||||
|
setError("Valeur trop élevée (> 6 g/L).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/readings", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ moment, value: numericValue }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const d = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(d.error ?? "Erreur serveur");
|
||||||
|
}
|
||||||
|
setSuccess({ value: numericValue, moment });
|
||||||
|
setDigits("");
|
||||||
|
// Auto-advance to next moment
|
||||||
|
setMoment(MOMENTS[(momentIdx + 1) % MOMENTS.length]);
|
||||||
|
router.refresh();
|
||||||
|
setTimeout(() => setSuccess(null), 3000);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Erreur inconnue");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format display value nicely
|
||||||
|
const displayValue = digits === "" ? "—" : digits + (digits.endsWith(".") ? "" : "");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[calc(100dvh-0px)] flex-col bg-slate-50">
|
||||||
|
{/* Top bar */}
|
||||||
|
<div className="flex items-center justify-between bg-teal-600 px-4 py-3 text-white">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Droplets className="h-5 w-5 text-teal-200" />
|
||||||
|
<span className="font-semibold">
|
||||||
|
{firstName ? `Bonjour ${firstName}` : "Saisie rapide"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-teal-200">
|
||||||
|
{format(new Date(), "EEE d MMM", { locale: fr })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col gap-4 p-4">
|
||||||
|
{/* Last 3 readings */}
|
||||||
|
{last3.length > 0 && (
|
||||||
|
<div className="flex gap-2 overflow-x-auto pb-1">
|
||||||
|
{last3.map((r, i) => {
|
||||||
|
const s = statusFor(r.value, r.moment as Moment);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className={cn(
|
||||||
|
"flex min-w-[90px] flex-col items-center rounded-xl border px-3 py-2 text-center text-xs",
|
||||||
|
STATUS_STYLE[s].bg,
|
||||||
|
STATUS_STYLE[s].border,
|
||||||
|
STATUS_STYLE[s].color
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-base font-bold">
|
||||||
|
{r.value.toFixed(2).replace(".", ",")}
|
||||||
|
</span>
|
||||||
|
<span className="opacity-80">
|
||||||
|
{MOMENT_ICON[r.moment as Moment]} {MOMENT_SHORT[r.moment as Moment]}
|
||||||
|
</span>
|
||||||
|
<span className="opacity-60">
|
||||||
|
{format(new Date(r.measuredAt), "HH:mm")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Moment selector */}
|
||||||
|
<div className="flex items-center justify-between rounded-2xl bg-white px-4 py-3 shadow-sm">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={prevMoment}
|
||||||
|
className="rounded-full p-2 text-slate-400 hover:bg-slate-100 active:bg-slate-200"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl">{MOMENT_ICON[moment]}</div>
|
||||||
|
<div className="text-sm font-semibold text-slate-800">{MOMENT_SHORT[moment]}</div>
|
||||||
|
<div className="text-xs text-slate-500">{MOMENT_LABELS[moment]}</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={nextMoment}
|
||||||
|
className="rounded-full p-2 text-slate-400 hover:bg-slate-100 active:bg-slate-200"
|
||||||
|
>
|
||||||
|
<ChevronRight className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Value display */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-col items-center justify-center rounded-2xl px-4 py-5 shadow-sm transition-colors",
|
||||||
|
liveStatus ? STATUS_STYLE[liveStatus].bg : "bg-white",
|
||||||
|
liveStatus ? STATUS_STYLE[liveStatus].border : "border-transparent",
|
||||||
|
"border-2"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"text-5xl font-bold tabular-nums tracking-tight",
|
||||||
|
liveStatus ? STATUS_STYLE[liveStatus].color : "text-slate-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{displayValue}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-sm text-slate-500">g/L</div>
|
||||||
|
{liveStatus && (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"mt-2 rounded-full border px-3 py-0.5 text-xs font-medium",
|
||||||
|
STATUS_STYLE[liveStatus].bg,
|
||||||
|
STATUS_STYLE[liveStatus].border,
|
||||||
|
STATUS_STYLE[liveStatus].color
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{STATUS_STYLE[liveStatus].label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Numpad */}
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
{PAD_KEYS.flat().map((key) => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleKey(key);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex h-16 items-center justify-center rounded-2xl text-xl font-semibold shadow-sm active:scale-95 transition-transform select-none",
|
||||||
|
key === "⌫"
|
||||||
|
? "bg-slate-200 text-slate-600"
|
||||||
|
: key === "."
|
||||||
|
? "bg-white text-slate-700"
|
||||||
|
: "bg-white text-slate-900"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{key === "⌫" ? <Delete className="h-5 w-5" /> : key}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-xl border border-rose-300 bg-rose-50 px-4 py-3 text-center text-sm text-rose-800">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Success */}
|
||||||
|
{success && (
|
||||||
|
<div className="flex items-center justify-center gap-2 rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm font-medium text-emerald-800">
|
||||||
|
<CheckCircle2 className="h-5 w-5" />
|
||||||
|
{success.value.toFixed(2).replace(".", ",")} g/L enregistré —{" "}
|
||||||
|
{MOMENT_SHORT[success.moment]}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Submit */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={!numericValue || submitting}
|
||||||
|
className={cn(
|
||||||
|
"flex h-16 w-full items-center justify-center gap-2 rounded-2xl text-lg font-semibold text-white shadow-md transition active:scale-95",
|
||||||
|
numericValue
|
||||||
|
? "bg-teal-600 hover:bg-teal-700"
|
||||||
|
: "cursor-not-allowed bg-slate-300"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{submitting ? (
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Enregistrer"
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
src/app/mobile/layout.tsx
Normal file
22
src/app/mobile/layout.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Metadata, Viewport } from "next";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Saisie rapide — Diabetix",
|
||||||
|
appleWebApp: {
|
||||||
|
capable: true,
|
||||||
|
statusBarStyle: "default",
|
||||||
|
title: "Diabetix",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const viewport: Viewport = {
|
||||||
|
themeColor: "#0f766e",
|
||||||
|
width: "device-width",
|
||||||
|
initialScale: 1,
|
||||||
|
maximumScale: 1,
|
||||||
|
userScalable: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MobileLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
5
src/app/mobile/page.tsx
Normal file
5
src/app/mobile/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function MobilePage() {
|
||||||
|
redirect("/dashboard/mobile");
|
||||||
|
}
|
||||||
296
src/app/page.tsx
296
src/app/page.tsx
@@ -1,65 +1,255 @@
|
|||||||
import Image from "next/image";
|
import Link from "next/link";
|
||||||
|
import { Activity, BarChart3, Bot, CheckCircle2, Crown, Shield, Smartphone } from "lucide-react";
|
||||||
|
|
||||||
export default function Home() {
|
export const metadata = {
|
||||||
|
title: "Diabetix — Suivi de glycémie intelligent",
|
||||||
|
description: "Application de suivi de glycémie avec analyse IA, coach personnel et tableau de bord complet. Disponible sur web et mobile.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function LandingPage() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
<div className="min-h-screen bg-white">
|
||||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
{/* Header */}
|
||||||
<Image
|
<header className="sticky top-0 z-30 border-b border-slate-200 bg-white/90 backdrop-blur">
|
||||||
className="dark:invert"
|
<div className="mx-auto flex max-w-5xl items-center justify-between px-4 py-3">
|
||||||
src="/next.svg"
|
<div className="flex items-center gap-2 font-semibold text-teal-700">
|
||||||
alt="Next.js logo"
|
<Activity className="h-5 w-5" />
|
||||||
width={100}
|
<span>Diabetix</span>
|
||||||
height={20}
|
</div>
|
||||||
priority
|
<nav className="hidden items-center gap-4 text-sm text-slate-600 md:flex">
|
||||||
/>
|
<a href="#fonctionnalites" className="hover:text-teal-700">Fonctionnalités</a>
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
<a href="#tarifs" className="hover:text-teal-700">Tarifs</a>
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
<a href="#faq" className="hover:text-teal-700">FAQ</a>
|
||||||
To get started, edit the page.tsx file.
|
</nav>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link href="/auth/login" className="rounded-lg px-3 py-2 text-sm text-slate-600 hover:text-teal-700">
|
||||||
|
Connexion
|
||||||
|
</Link>
|
||||||
|
<Link href="/auth/register" className="rounded-lg bg-teal-600 px-4 py-2 text-sm font-medium text-white hover:bg-teal-700">
|
||||||
|
Commencer gratuitement
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Hero */}
|
||||||
|
<section className="bg-gradient-to-b from-teal-50 via-white to-white px-4 py-20 text-center">
|
||||||
|
<div className="mx-auto max-w-3xl">
|
||||||
|
<div className="mb-4 inline-flex items-center gap-2 rounded-full border border-teal-200 bg-teal-50 px-4 py-1.5 text-xs font-medium text-teal-700">
|
||||||
|
<Bot className="h-3.5 w-3.5" />
|
||||||
|
Coach IA personnalisé inclus en Premium
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl font-bold leading-tight text-slate-900 sm:text-5xl">
|
||||||
|
Prenez le contrôle de votre{" "}
|
||||||
|
<span className="text-teal-600">glycémie</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
<p className="mt-5 text-lg text-slate-600 leading-relaxed">
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
Diabetix vous aide à suivre vos relevés quotidiens, visualiser vos tendances et recevoir des conseils personnalisés grâce à l'intelligence artificielle.
|
||||||
<a
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Templates
|
|
||||||
</a>{" "}
|
|
||||||
or the{" "}
|
|
||||||
<a
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Learning
|
|
||||||
</a>{" "}
|
|
||||||
center.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
<Link
|
||||||
<a
|
href="/auth/register"
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
className="inline-flex items-center gap-2 rounded-xl bg-teal-600 px-6 py-3 text-base font-semibold text-white shadow-md hover:bg-teal-700"
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
>
|
||||||
<Image
|
Commencer gratuitement
|
||||||
className="dark:invert"
|
</Link>
|
||||||
src="/vercel.svg"
|
|
||||||
alt="Vercel logomark"
|
|
||||||
width={16}
|
|
||||||
height={16}
|
|
||||||
/>
|
|
||||||
Deploy Now
|
|
||||||
</a>
|
|
||||||
<a
|
<a
|
||||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
href="#tarifs"
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
className="inline-flex items-center gap-2 rounded-xl border border-slate-300 bg-white px-6 py-3 text-base font-medium text-slate-700 hover:border-teal-400 hover:text-teal-700"
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
>
|
||||||
Documentation
|
Voir les tarifs
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
<p className="mt-4 text-xs text-slate-400">Sans carte bancaire · Accès immédiat · Données sécurisées</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
|
<section id="fonctionnalites" className="px-4 py-20">
|
||||||
|
<div className="mx-auto max-w-5xl">
|
||||||
|
<h2 className="text-center text-3xl font-bold text-slate-900">Tout ce dont vous avez besoin</h2>
|
||||||
|
<p className="mt-3 text-center text-slate-500">
|
||||||
|
Une application complète, pensée pour les patients diabétiques au quotidien.
|
||||||
|
</p>
|
||||||
|
<div className="mt-12 grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
icon: BarChart3,
|
||||||
|
title: "Tableau de bord complet",
|
||||||
|
desc: "Visualisez vos relevés, moyennes, % dans la cible et estimation HbA1c sur 7, 30 et 90 jours.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Smartphone,
|
||||||
|
title: "Saisie mobile ultra-rapide",
|
||||||
|
desc: "Interface dédiée avec pavé numérique, optimisée pour une saisie en quelques secondes sur smartphone.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Bot,
|
||||||
|
title: "Coach IA Diablo",
|
||||||
|
desc: "Un coach bienveillant qui analyse vos données et vous donne des conseils personnalisés en tenant compte de votre profil.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Activity,
|
||||||
|
title: "Analyse quotidienne IA",
|
||||||
|
desc: "Chaque matin, recevez une analyse fraîche de vos tendances glycémiques avec un message d'encouragement.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Shield,
|
||||||
|
title: "Données sécurisées",
|
||||||
|
desc: "Vos données de santé restent les vôtres. Stockage sécurisé, accès chiffré, export CSV à tout moment.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: CheckCircle2,
|
||||||
|
title: "Profil médical complet",
|
||||||
|
desc: "Renseignez votre type de diabète, traitement, taille, poids — le coach s'adapte à votre situation.",
|
||||||
|
},
|
||||||
|
].map((f) => (
|
||||||
|
<div key={f.title} className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||||
|
<div className="mb-4 flex h-11 w-11 items-center justify-center rounded-xl bg-teal-50">
|
||||||
|
<f.icon className="h-5 w-5 text-teal-600" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-base font-semibold text-slate-900">{f.title}</h3>
|
||||||
|
<p className="mt-2 text-sm text-slate-500 leading-relaxed">{f.desc}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Pricing */}
|
||||||
|
<section id="tarifs" className="bg-slate-50 px-4 py-20">
|
||||||
|
<div className="mx-auto max-w-3xl">
|
||||||
|
<h2 className="text-center text-3xl font-bold text-slate-900">Tarifs simples et transparents</h2>
|
||||||
|
<p className="mt-3 text-center text-slate-500">Commencez gratuitement, passez Premium quand vous êtes prêt.</p>
|
||||||
|
<div className="mt-10 grid gap-6 sm:grid-cols-2">
|
||||||
|
{/* Free */}
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-white p-7 shadow-sm">
|
||||||
|
<div className="text-sm font-semibold uppercase tracking-wide text-slate-500">Gratuit</div>
|
||||||
|
<div className="mt-3 text-4xl font-bold text-slate-900">0 €</div>
|
||||||
|
<div className="mt-1 text-sm text-slate-500">pour toujours</div>
|
||||||
|
<Link
|
||||||
|
href="/auth/register"
|
||||||
|
className="mt-6 block rounded-xl border border-slate-300 py-2.5 text-center text-sm font-medium text-slate-700 hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
Commencer gratuitement
|
||||||
|
</Link>
|
||||||
|
<ul className="mt-6 space-y-2.5 text-sm text-slate-600">
|
||||||
|
{[
|
||||||
|
"Saisie illimitée de relevés",
|
||||||
|
"Tableau de bord 7 jours",
|
||||||
|
"Statistiques hebdomadaires",
|
||||||
|
"Saisie mobile rapide",
|
||||||
|
"Export CSV",
|
||||||
|
].map((f) => (
|
||||||
|
<li key={f} className="flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="h-4 w-4 flex-shrink-0 text-teal-500" />
|
||||||
|
{f}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/* Premium */}
|
||||||
|
<div className="relative rounded-2xl border-2 border-teal-500 bg-white p-7 shadow-md">
|
||||||
|
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-teal-600 px-3 py-1 text-xs font-semibold text-white">
|
||||||
|
<Crown className="h-3 w-3" />
|
||||||
|
Recommandé
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-semibold uppercase tracking-wide text-teal-600">Premium</div>
|
||||||
|
<div className="mt-3 flex items-end gap-1">
|
||||||
|
<span className="text-4xl font-bold text-slate-900">4,99 €</span>
|
||||||
|
<span className="mb-1 text-slate-500">/mois</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-sm text-slate-500">sans engagement</div>
|
||||||
|
<Link
|
||||||
|
href="/auth/register"
|
||||||
|
className="mt-6 block rounded-xl bg-teal-600 py-2.5 text-center text-sm font-semibold text-white hover:bg-teal-700"
|
||||||
|
>
|
||||||
|
Essayer Premium
|
||||||
|
</Link>
|
||||||
|
<ul className="mt-6 space-y-2.5 text-sm text-slate-600">
|
||||||
|
{[
|
||||||
|
"Tout le plan Gratuit",
|
||||||
|
"Historique illimité",
|
||||||
|
"Statistiques 30 et 90 jours",
|
||||||
|
"HbA1c estimée (90j)",
|
||||||
|
"Analyse IA quotidienne",
|
||||||
|
"Coach Diablo en temps réel",
|
||||||
|
].map((f) => (
|
||||||
|
<li key={f} className="flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="h-4 w-4 flex-shrink-0 text-teal-500" />
|
||||||
|
{f}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* FAQ */}
|
||||||
|
<section id="faq" className="px-4 py-20">
|
||||||
|
<div className="mx-auto max-w-2xl">
|
||||||
|
<h2 className="text-center text-3xl font-bold text-slate-900">Questions fréquentes</h2>
|
||||||
|
<div className="mt-10 space-y-6">
|
||||||
|
{[
|
||||||
|
{
|
||||||
|
q: "Diabetix remplace-t-il mon médecin ?",
|
||||||
|
a: "Non. Diabetix est un outil de suivi et d'aide à la compréhension de vos données. Il ne remplace pas votre équipe soignante et ne donne jamais de conseils médicaux. Consultez toujours votre médecin pour toute décision de santé.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "Mes données sont-elles sécurisées ?",
|
||||||
|
a: "Oui. Vos données sont stockées de façon sécurisée et ne sont jamais partagées avec des tiers. Vous pouvez les exporter ou les supprimer à tout moment.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "Comment fonctionne le coach IA Diablo ?",
|
||||||
|
a: "Diablo utilise l'IA Gemini de Google pour analyser vos relevés, votre profil médical et vous donner des conseils personnalisés. Il ne peut pas modifier votre traitement médical et vous renverra toujours vers votre médecin pour les décisions importantes.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "Puis-je annuler mon abonnement Premium à tout moment ?",
|
||||||
|
a: "Oui, sans engagement. Vous pouvez revenir au plan gratuit à tout moment.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
q: "L'application fonctionne-t-elle sur mobile ?",
|
||||||
|
a: "Oui. Diabetix est une Progressive Web App (PWA) optimisée pour mobile. Une interface de saisie dédiée est disponible pour entrer vos relevés en quelques secondes.",
|
||||||
|
},
|
||||||
|
].map(({ q, a }) => (
|
||||||
|
<div key={q} className="rounded-xl border border-slate-200 bg-white p-5">
|
||||||
|
<h3 className="font-semibold text-slate-900">{q}</h3>
|
||||||
|
<p className="mt-2 text-sm text-slate-600 leading-relaxed">{a}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* CTA */}
|
||||||
|
<section className="bg-teal-600 px-4 py-16 text-center text-white">
|
||||||
|
<h2 className="text-2xl font-bold">Prêt à mieux gérer votre diabète ?</h2>
|
||||||
|
<p className="mt-3 text-teal-100">Rejoignez Diabetix gratuitement et commencez votre suivi aujourd'hui.</p>
|
||||||
|
<Link
|
||||||
|
href="/auth/register"
|
||||||
|
className="mt-6 inline-flex items-center gap-2 rounded-xl bg-white px-6 py-3 text-base font-semibold text-teal-700 hover:bg-teal-50"
|
||||||
|
>
|
||||||
|
Créer mon compte gratuit
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="border-t border-slate-200 px-4 py-8 text-center text-xs text-slate-400">
|
||||||
|
<div className="flex items-center justify-center gap-2 font-semibold text-teal-700 mb-3">
|
||||||
|
<Activity className="h-4 w-4" />
|
||||||
|
Diabetix
|
||||||
|
</div>
|
||||||
|
<p>© {new Date().getFullYear()} Diabetix — Application de suivi de glycémie</p>
|
||||||
|
<p className="mt-1">
|
||||||
|
Cet outil ne remplace pas un avis médical. Consultez votre médecin pour toute décision de santé.
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 flex justify-center gap-4">
|
||||||
|
<Link href="/legal" className="hover:text-slate-600">CGU & Confidentialité</Link>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
89
src/app/pricing/page.tsx
Normal file
89
src/app/pricing/page.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { CheckCircle2, Crown } from "lucide-react";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import { UpgradeButton } from "@/components/UpgradeButton";
|
||||||
|
|
||||||
|
export const metadata = { title: "Tarifs — Diabetix" };
|
||||||
|
|
||||||
|
export default async function PricingPage() {
|
||||||
|
const session = await auth();
|
||||||
|
const isPremium = (session?.user as { plan?: string })?.plan === "PREMIUM";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col items-center justify-center bg-gradient-to-br from-teal-50 via-white to-slate-50 px-4 py-16">
|
||||||
|
<h1 className="text-3xl font-bold text-slate-900">Choisissez votre plan</h1>
|
||||||
|
<p className="mt-3 text-slate-500">Commencez gratuitement, passez Premium quand vous êtes prêt.</p>
|
||||||
|
|
||||||
|
<div className="mt-10 grid w-full max-w-2xl gap-6 sm:grid-cols-2">
|
||||||
|
{/* Free */}
|
||||||
|
<div className="rounded-2xl border border-slate-200 bg-white p-7 shadow-sm">
|
||||||
|
<div className="text-sm font-semibold uppercase tracking-wide text-slate-500">Gratuit</div>
|
||||||
|
<div className="mt-3 text-4xl font-bold text-slate-900">0 €</div>
|
||||||
|
{!session ? (
|
||||||
|
<Link href="/auth/register" className="mt-6 block rounded-xl border border-slate-300 py-2.5 text-center text-sm font-medium text-slate-700 hover:bg-slate-50">
|
||||||
|
Commencer
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<div className="mt-6 rounded-xl border border-emerald-300 bg-emerald-50 py-2.5 text-center text-sm font-medium text-emerald-700">
|
||||||
|
Votre plan actuel
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ul className="mt-6 space-y-2.5 text-sm text-slate-600">
|
||||||
|
{["Saisie illimitée", "Tableau de bord 7 jours", "Statistiques semaine", "Export CSV"].map((f) => (
|
||||||
|
<li key={f} className="flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="h-4 w-4 flex-shrink-0 text-teal-500" />
|
||||||
|
{f}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Premium */}
|
||||||
|
<div className="relative rounded-2xl border-2 border-teal-500 bg-white p-7 shadow-md">
|
||||||
|
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-teal-600 px-3 py-1 text-xs font-semibold text-white">
|
||||||
|
<Crown className="h-3 w-3" />
|
||||||
|
Recommandé
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-semibold uppercase tracking-wide text-teal-600">Premium</div>
|
||||||
|
<div className="mt-3 flex items-end gap-1">
|
||||||
|
<span className="text-4xl font-bold text-slate-900">4,99 €</span>
|
||||||
|
<span className="mb-1 text-slate-500">/mois</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6">
|
||||||
|
{!session ? (
|
||||||
|
<Link href="/auth/register" className="block rounded-xl bg-teal-600 py-2.5 text-center text-sm font-semibold text-white hover:bg-teal-700">
|
||||||
|
Essayer Premium
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<UpgradeButton isPremium={isPremium} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<ul className="mt-6 space-y-2.5 text-sm text-slate-600">
|
||||||
|
{["Tout le plan Gratuit", "Historique illimité", "Stats 30 et 90 jours", "HbA1c estimée", "Analyse IA quotidienne", "Coach Diablo"].map((f) => (
|
||||||
|
<li key={f} className="flex items-center gap-2">
|
||||||
|
<CheckCircle2 className="h-4 w-4 flex-shrink-0 text-teal-500" />
|
||||||
|
{f}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-8 text-sm text-slate-400">
|
||||||
|
{!session ? (
|
||||||
|
<>
|
||||||
|
Déjà inscrit ?{" "}
|
||||||
|
<Link href="/auth/login" className="text-teal-700 underline">Se connecter</Link>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Besoin d'aide ?{" "}
|
||||||
|
<Link href="/contact" className="text-teal-700 underline">Contactez-nous</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
src/app/profil/CancelSubscriptionButton.tsx
Normal file
117
src/app/profil/CancelSubscriptionButton.tsx
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { AlertCircle, Loader2, CheckCircle2 } from "lucide-react";
|
||||||
|
|
||||||
|
export function CancelSubscriptionButton({
|
||||||
|
isPremium,
|
||||||
|
subscriptionEndDate,
|
||||||
|
isScheduledForCancellation,
|
||||||
|
}: {
|
||||||
|
isPremium: boolean;
|
||||||
|
subscriptionEndDate?: Date;
|
||||||
|
isScheduledForCancellation?: boolean;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [canceling, setCanceling] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showConfirm, setShowConfirm] = useState(false);
|
||||||
|
const [isCanceled, setIsCanceled] = useState(!!isScheduledForCancellation);
|
||||||
|
|
||||||
|
if (!isPremium) return null;
|
||||||
|
|
||||||
|
async function handleCancel() {
|
||||||
|
setCanceling(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/stripe/cancel-subscription", {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error ?? "Erreur lors de la résiliation.");
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowConfirm(false);
|
||||||
|
setIsCanceled(true);
|
||||||
|
router.refresh();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Erreur inconnue");
|
||||||
|
} finally {
|
||||||
|
setCanceling(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const endDateFormatted = subscriptionEndDate
|
||||||
|
? new Intl.DateTimeFormat("fr-FR", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
}).format(subscriptionEndDate)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{isCanceled ? (
|
||||||
|
<div className="rounded-lg border border-emerald-300 bg-emerald-50 p-4 space-y-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<CheckCircle2 className="h-5 w-5 flex-shrink-0 text-emerald-600" />
|
||||||
|
<div className="text-sm text-emerald-800">
|
||||||
|
<p className="font-medium">Abonnement résilié</p>
|
||||||
|
<p className="text-emerald-700">
|
||||||
|
Votre accès Premium reste actif jusqu'au {endDateFormatted}.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : !showConfirm ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowConfirm(true)}
|
||||||
|
className="w-full rounded-lg border border-rose-300 bg-rose-50 px-4 py-3 text-sm font-medium text-rose-700 hover:bg-rose-100"
|
||||||
|
>
|
||||||
|
Résilier mon abonnement Premium
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-rose-300 bg-rose-50 p-4 space-y-3">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<AlertCircle className="h-5 w-5 flex-shrink-0 text-rose-600" />
|
||||||
|
<div className="text-sm text-rose-800">
|
||||||
|
<p className="font-medium mb-1">Êtes-vous sûr ?</p>
|
||||||
|
<p className="text-rose-700">
|
||||||
|
Votre accès Premium restera actif jusqu'au {endDateFormatted}. Après cette date, vous reviendrez au plan gratuit.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowConfirm(false)}
|
||||||
|
disabled={canceling}
|
||||||
|
className="flex-1 rounded-lg border border-rose-300 bg-white px-3 py-2 text-sm font-medium text-rose-700 hover:bg-rose-50 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
Annuler
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCancel}
|
||||||
|
disabled={canceling}
|
||||||
|
className="flex-1 inline-flex items-center justify-center gap-2 rounded-lg bg-rose-600 px-3 py-2 text-sm font-medium text-white hover:bg-rose-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{canceling ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||||
|
Confirmer la résiliation
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md border border-rose-300 bg-rose-50 px-3 py-2 text-sm text-rose-800">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
311
src/app/profil/ProfileForm.tsx
Normal file
311
src/app/profil/ProfileForm.tsx
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { signOut } from "next-auth/react";
|
||||||
|
import { CheckCircle2, Loader2, LogOut } from "lucide-react";
|
||||||
|
import { DIABETES_TYPE_OPTIONS, SEX_OPTIONS } from "@/lib/patient";
|
||||||
|
|
||||||
|
type PatientInput = {
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
email: string | null;
|
||||||
|
birthDate: string | null;
|
||||||
|
heightCm: number | null;
|
||||||
|
weightKg: number | null;
|
||||||
|
sex: string | null;
|
||||||
|
diabetesType: string | null;
|
||||||
|
treatment: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProfileForm({ initial }: { initial: PatientInput | null }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [form, setForm] = useState<PatientInput>(() => ({
|
||||||
|
firstName: initial?.firstName ?? "",
|
||||||
|
lastName: initial?.lastName ?? "",
|
||||||
|
email: initial?.email ?? "",
|
||||||
|
birthDate: initial?.birthDate ?? "",
|
||||||
|
heightCm: initial?.heightCm ?? null,
|
||||||
|
weightKg: initial?.weightKg ?? null,
|
||||||
|
sex: initial?.sex ?? null,
|
||||||
|
diabetesType: initial?.diabetesType ?? null,
|
||||||
|
treatment: initial?.treatment ?? "",
|
||||||
|
}));
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
const bmi = useMemo(() => {
|
||||||
|
if (!form.heightCm || !form.weightKg) return null;
|
||||||
|
const m = form.heightCm / 100;
|
||||||
|
return form.weightKg / (m * m);
|
||||||
|
}, [form.heightCm, form.weightKg]);
|
||||||
|
|
||||||
|
const age = useMemo(() => {
|
||||||
|
if (!form.birthDate) return null;
|
||||||
|
const d = new Date(form.birthDate);
|
||||||
|
if (Number.isNaN(d.getTime())) return null;
|
||||||
|
const now = new Date();
|
||||||
|
let a = now.getFullYear() - d.getFullYear();
|
||||||
|
const m = now.getMonth() - d.getMonth();
|
||||||
|
if (m < 0 || (m === 0 && now.getDate() < d.getDate())) a--;
|
||||||
|
return a;
|
||||||
|
}, [form.birthDate]);
|
||||||
|
|
||||||
|
function update<K extends keyof PatientInput>(key: K, value: PatientInput[K]) {
|
||||||
|
setForm((f) => ({ ...f, [key]: value }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setSuccess(false);
|
||||||
|
|
||||||
|
if (!form.firstName.trim() || !form.lastName.trim()) {
|
||||||
|
setError("Le nom et le prénom sont obligatoires.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/patient", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
firstName: form.firstName.trim(),
|
||||||
|
lastName: form.lastName.trim(),
|
||||||
|
email: form.email?.trim() || null,
|
||||||
|
birthDate: form.birthDate || null,
|
||||||
|
heightCm: form.heightCm,
|
||||||
|
weightKg: form.weightKg,
|
||||||
|
sex: form.sex || null,
|
||||||
|
diabetesType: form.diabetesType || null,
|
||||||
|
treatment: form.treatment?.trim() || null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error ?? "Erreur lors de l'enregistrement.");
|
||||||
|
}
|
||||||
|
setSuccess(true);
|
||||||
|
router.refresh();
|
||||||
|
setTimeout(() => setSuccess(false), 2500);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Erreur inconnue");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
className="space-y-5 rounded-xl border border-slate-200 bg-white p-5 shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Field label="Prénom" required>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.firstName}
|
||||||
|
onChange={(e) => update("firstName", e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
autoComplete="given-name"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Nom" required>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={form.lastName}
|
||||||
|
onChange={(e) => update("lastName", e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
autoComplete="family-name"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Field label="Email">
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={form.email ?? ""}
|
||||||
|
onChange={(e) => update("email", e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
autoComplete="email"
|
||||||
|
placeholder="prenom.nom@exemple.fr"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Date de naissance"
|
||||||
|
hint={age !== null ? `${age} ans` : undefined}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={form.birthDate ?? ""}
|
||||||
|
onChange={(e) => update("birthDate", e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Field label="Taille (cm)">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={50}
|
||||||
|
max={260}
|
||||||
|
value={form.heightCm ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
update("heightCm", e.target.value === "" ? null : Number(e.target.value))
|
||||||
|
}
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="175"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Poids (kg)">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
min={20}
|
||||||
|
max={400}
|
||||||
|
step={0.1}
|
||||||
|
value={form.weightKg ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
update("weightKg", e.target.value === "" ? null : Number(e.target.value))
|
||||||
|
}
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="72,5"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-slate-200 pt-5">
|
||||||
|
<h2 className="mb-4 text-sm font-semibold uppercase tracking-wide text-slate-500">
|
||||||
|
Profil médical
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<Field label="Sexe">
|
||||||
|
<select
|
||||||
|
value={form.sex ?? ""}
|
||||||
|
onChange={(e) => update("sex", e.target.value || null)}
|
||||||
|
className={inputClass}
|
||||||
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
{SEX_OPTIONS.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Type de diabète">
|
||||||
|
<select
|
||||||
|
value={form.diabetesType ?? ""}
|
||||||
|
onChange={(e) => update("diabetesType", e.target.value || null)}
|
||||||
|
className={inputClass}
|
||||||
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
{DIABETES_TYPE_OPTIONS.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<Field
|
||||||
|
label="Traitement en cours"
|
||||||
|
hint="Ex. Metformine 1000 mg matin et soir, insuline lente le soir…"
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
rows={4}
|
||||||
|
value={form.treatment ?? ""}
|
||||||
|
onChange={(e) => update("treatment", e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="Médicaments, posologies, insulinothérapie…"
|
||||||
|
maxLength={1000}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{bmi !== null && (
|
||||||
|
<div className="rounded-md border border-slate-200 bg-slate-50 px-3 py-2 text-sm text-slate-700">
|
||||||
|
IMC : <span className="font-semibold">{bmi.toFixed(1)}</span>{" "}
|
||||||
|
<span className="text-slate-500">({bmiLabel(bmi)})</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md border border-rose-300 bg-rose-50 px-3 py-2 text-sm text-rose-800">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-emerald-300 bg-emerald-50 px-3 py-2 text-sm text-emerald-800">
|
||||||
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
Fiche enregistrée.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="flex-1 inline-flex items-center justify-center gap-2 rounded-lg bg-teal-600 px-4 py-3 text-sm font-medium text-white shadow-sm hover:bg-teal-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||||
|
Enregistrer
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => signOut({ callbackUrl: "/" })}
|
||||||
|
className="inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300 bg-white px-4 py-3 text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50"
|
||||||
|
title="Se déconnecter"
|
||||||
|
>
|
||||||
|
<LogOut className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">Déconnexion</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
"w-full rounded-lg border border-slate-300 bg-white px-3 py-2 outline-none focus:border-teal-500 focus:ring-2 focus:ring-teal-200";
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
hint,
|
||||||
|
required,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
hint?: string;
|
||||||
|
required?: boolean;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="mb-1.5 flex items-baseline justify-between text-sm font-medium text-slate-700">
|
||||||
|
<span>
|
||||||
|
{label}
|
||||||
|
{required && <span className="ml-0.5 text-rose-600">*</span>}
|
||||||
|
</span>
|
||||||
|
{hint && <span className="text-xs font-normal text-slate-500">{hint}</span>}
|
||||||
|
</label>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bmiLabel(bmi: number) {
|
||||||
|
if (bmi < 18.5) return "insuffisance pondérale";
|
||||||
|
if (bmi < 25) return "corpulence normale";
|
||||||
|
if (bmi < 30) return "surpoids";
|
||||||
|
if (bmi < 35) return "obésité modérée";
|
||||||
|
if (bmi < 40) return "obésité sévère";
|
||||||
|
return "obésité morbide";
|
||||||
|
}
|
||||||
5
src/app/profil/page.tsx
Normal file
5
src/app/profil/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function ProfilPage() {
|
||||||
|
redirect("/dashboard/profil");
|
||||||
|
}
|
||||||
185
src/app/saisie/ReadingForm.tsx
Normal file
185
src/app/saisie/ReadingForm.tsx
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { CheckCircle2, Loader2 } from "lucide-react";
|
||||||
|
import { MOMENTS, statusFor, STATUS_STYLE, type Moment } from "@/lib/glycemia";
|
||||||
|
|
||||||
|
function suggestMomentForNow(): Moment {
|
||||||
|
const h = new Date().getHours();
|
||||||
|
if (h < 11) return "FASTING";
|
||||||
|
if (h < 17) return "LUNCH";
|
||||||
|
return "DINNER";
|
||||||
|
}
|
||||||
|
|
||||||
|
function nowLocalDateTime(): string {
|
||||||
|
const d = new Date();
|
||||||
|
d.setMinutes(d.getMinutes() - d.getTimezoneOffset());
|
||||||
|
return d.toISOString().slice(0, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReadingForm() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [moment, setMoment] = useState<Moment>(suggestMomentForNow());
|
||||||
|
const [valueStr, setValueStr] = useState("");
|
||||||
|
const [measuredAt, setMeasuredAt] = useState(nowLocalDateTime());
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
const numericValue = useMemo(() => {
|
||||||
|
const v = parseFloat(valueStr.replace(",", "."));
|
||||||
|
return Number.isFinite(v) ? v : null;
|
||||||
|
}, [valueStr]);
|
||||||
|
|
||||||
|
const liveStatus = numericValue !== null && numericValue > 0 ? statusFor(numericValue, moment) : null;
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setSuccess(false);
|
||||||
|
|
||||||
|
if (numericValue === null || numericValue <= 0) {
|
||||||
|
setError("Saisissez une valeur valide (ex : 1,12).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (numericValue > 6) {
|
||||||
|
setError("Valeur trop élevée (>6 g/L). Vérifiez la saisie.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/readings", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
measuredAt: new Date(measuredAt).toISOString(),
|
||||||
|
moment,
|
||||||
|
value: numericValue,
|
||||||
|
notes,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error ?? "Erreur lors de l'enregistrement.");
|
||||||
|
}
|
||||||
|
setSuccess(true);
|
||||||
|
setValueStr("");
|
||||||
|
setNotes("");
|
||||||
|
setMeasuredAt(nowLocalDateTime());
|
||||||
|
setMoment(suggestMomentForNow());
|
||||||
|
router.refresh();
|
||||||
|
setTimeout(() => setSuccess(false), 2500);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Erreur inconnue");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5 rounded-xl border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
|
{/* Moment */}
|
||||||
|
<div>
|
||||||
|
<label className="mb-2 block text-sm font-medium text-slate-700">Moment</label>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{MOMENTS.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMoment(m.value)}
|
||||||
|
className={`rounded-lg border px-3 py-3 text-sm font-medium transition ${
|
||||||
|
moment === m.value
|
||||||
|
? "border-teal-600 bg-teal-50 text-teal-800"
|
||||||
|
: "border-slate-200 bg-white text-slate-600 hover:border-slate-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{m.short}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Value */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="value" className="mb-2 block text-sm font-medium text-slate-700">
|
||||||
|
Glycémie (g/L)
|
||||||
|
</label>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
id="value"
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder="1,12"
|
||||||
|
value={valueStr}
|
||||||
|
onChange={(e) => setValueStr(e.target.value)}
|
||||||
|
className="w-full rounded-lg border border-slate-300 bg-white px-4 py-3 text-2xl font-semibold tracking-tight outline-none focus:border-teal-500 focus:ring-2 focus:ring-teal-200"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-slate-500">g/L</span>
|
||||||
|
</div>
|
||||||
|
{liveStatus && (
|
||||||
|
<div className="mt-2">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-full border px-2.5 py-1 text-xs font-medium ${STATUS_STYLE[liveStatus].bg} ${STATUS_STYLE[liveStatus].color} ${STATUS_STYLE[liveStatus].border}`}
|
||||||
|
>
|
||||||
|
{STATUS_STYLE[liveStatus].label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* DateTime */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="measuredAt" className="mb-2 block text-sm font-medium text-slate-700">
|
||||||
|
Date et heure
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="measuredAt"
|
||||||
|
type="datetime-local"
|
||||||
|
value={measuredAt}
|
||||||
|
onChange={(e) => setMeasuredAt(e.target.value)}
|
||||||
|
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 outline-none focus:border-teal-500 focus:ring-2 focus:ring-teal-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<div>
|
||||||
|
<label htmlFor="notes" className="mb-2 block text-sm font-medium text-slate-700">
|
||||||
|
Notes <span className="font-normal text-slate-400">(optionnel)</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="notes"
|
||||||
|
rows={3}
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
placeholder="Repas, activité, ressenti…"
|
||||||
|
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2 outline-none focus:border-teal-500 focus:ring-2 focus:ring-teal-200"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md border border-rose-300 bg-rose-50 px-3 py-2 text-sm text-rose-800">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-emerald-300 bg-emerald-50 px-3 py-2 text-sm text-emerald-800">
|
||||||
|
<CheckCircle2 className="h-4 w-4" />
|
||||||
|
Relevé enregistré.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-teal-600 px-4 py-3 text-sm font-medium text-white shadow-sm hover:bg-teal-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||||
|
Enregistrer
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/app/saisie/page.tsx
Normal file
5
src/app/saisie/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default function SaisiePage() {
|
||||||
|
redirect("/dashboard/saisie");
|
||||||
|
}
|
||||||
128
src/components/AppShell.tsx
Normal file
128
src/components/AppShell.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Activity, Crown, FileText, History, LayoutDashboard, PlusCircle, Smartphone, User } from "lucide-react";
|
||||||
|
import { ChatBot } from "@/components/ChatBot";
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ href: "/dashboard", label: "Accueil", icon: LayoutDashboard },
|
||||||
|
{ href: "/dashboard/mobile", label: "Mesure", icon: Smartphone },
|
||||||
|
{ href: "/dashboard/historique", label: "Historique", icon: History },
|
||||||
|
{ href: "/dashboard/profil", label: "Profil", icon: User },
|
||||||
|
];
|
||||||
|
|
||||||
|
const desktopNavItems = [
|
||||||
|
{ href: "/dashboard", label: "Accueil", icon: LayoutDashboard },
|
||||||
|
{ href: "/dashboard/saisie", label: "Saisir", icon: PlusCircle },
|
||||||
|
{ href: "/dashboard/historique", label: "Historique", icon: History },
|
||||||
|
{ href: "/dashboard/rapports", label: "Rapports", icon: FileText },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AppShell({
|
||||||
|
children,
|
||||||
|
fullName,
|
||||||
|
initials,
|
||||||
|
diabetesLabel,
|
||||||
|
plan,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
fullName: string | null;
|
||||||
|
initials: string | null;
|
||||||
|
diabetesLabel: string | null;
|
||||||
|
plan?: string;
|
||||||
|
}) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const isMobile = pathname === "/dashboard/mobile";
|
||||||
|
const isPremium = plan === "PREMIUM";
|
||||||
|
|
||||||
|
if (isMobile) return <>{children}</>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header className="sticky top-0 z-30 border-b border-slate-200 bg-white/80 backdrop-blur">
|
||||||
|
<div className="mx-auto flex max-w-5xl items-center justify-between gap-3 px-4 py-3">
|
||||||
|
<Link href="/dashboard" className="flex items-center gap-2 font-semibold text-teal-700">
|
||||||
|
<Activity className="h-5 w-5" />
|
||||||
|
<span>Diabetix</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<nav className="hidden md:flex items-center gap-1 text-sm">
|
||||||
|
{desktopNavItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const active = pathname === item.href;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={`flex items-center gap-1.5 rounded-md px-3 py-2 transition ${
|
||||||
|
active
|
||||||
|
? "bg-teal-50 text-teal-800 font-medium"
|
||||||
|
: "text-slate-600 hover:bg-slate-100 hover:text-slate-900"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{!isPremium && (
|
||||||
|
<Link
|
||||||
|
href="/pricing"
|
||||||
|
className="hidden sm:inline-flex items-center gap-1 rounded-full border border-amber-300 bg-amber-50 px-2.5 py-1 text-xs font-medium text-amber-700 hover:bg-amber-100"
|
||||||
|
>
|
||||||
|
<Crown className="h-3 w-3" />
|
||||||
|
Premium
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{isPremium && (
|
||||||
|
<span className="hidden sm:inline-flex items-center gap-1 rounded-full border border-teal-300 bg-gradient-to-r from-teal-50 to-cyan-50 px-2.5 py-1 text-xs font-semibold text-teal-700">
|
||||||
|
<Crown className="h-3 w-3" />
|
||||||
|
Premium
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Link
|
||||||
|
href="/dashboard/profil"
|
||||||
|
className="flex items-center gap-2 rounded-full border border-slate-200 bg-white px-2 py-1 text-sm text-slate-700 hover:border-teal-400 hover:text-teal-700"
|
||||||
|
title={fullName ?? "Mon profil"}
|
||||||
|
>
|
||||||
|
<span className="flex h-7 w-7 items-center justify-center rounded-full bg-teal-100 text-xs font-semibold text-teal-700">
|
||||||
|
{initials || <User className="h-3.5 w-3.5" />}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-6 pb-24 md:pb-10">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{isPremium && <ChatBot />}
|
||||||
|
|
||||||
|
<nav className="fixed inset-x-0 bottom-0 z-30 border-t border-slate-200 bg-white/95 backdrop-blur md:hidden">
|
||||||
|
<div className="mx-auto grid max-w-5xl grid-cols-4">
|
||||||
|
{navItems.map((item) => {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const active = pathname === item.href;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={`flex flex-col items-center gap-1 px-2 py-3 text-xs transition ${
|
||||||
|
active ? "text-teal-700 font-medium" : "text-slate-600 hover:text-teal-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="h-5 w-5" />
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
323
src/components/ChatBot.tsx
Normal file
323
src/components/ChatBot.tsx
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { Bot, Send, X, Loader2, ChevronDown, AlertCircle } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type Role = "user" | "model";
|
||||||
|
type Message = { id: string; role: Role; content: string; streaming?: boolean };
|
||||||
|
|
||||||
|
const SUGGESTIONS = [
|
||||||
|
"Comment se passe mon suivi ce mois-ci ?",
|
||||||
|
"Pourquoi ma glycémie est-elle plus haute le matin ?",
|
||||||
|
"Quels aliments devrais-je éviter ?",
|
||||||
|
"Comment l'exercice influence ma glycémie ?",
|
||||||
|
];
|
||||||
|
|
||||||
|
function MarkdownLite({ text }: { text: string }) {
|
||||||
|
const lines = text.split("\n");
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{lines.map((line, i) => {
|
||||||
|
if (line.startsWith("## ")) {
|
||||||
|
return <p key={i} className="font-semibold text-slate-800 mt-2">{line.slice(3)}</p>;
|
||||||
|
}
|
||||||
|
if (line.startsWith("- ") || line.startsWith("• ")) {
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex gap-1.5">
|
||||||
|
<span className="text-teal-600 mt-0.5 shrink-0">•</span>
|
||||||
|
<span>{line.slice(2)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (line.startsWith("**") && line.endsWith("**")) {
|
||||||
|
return <p key={i} className="font-semibold">{line.slice(2, -2)}</p>;
|
||||||
|
}
|
||||||
|
if (!line.trim()) return <div key={i} className="h-1" />;
|
||||||
|
// Inline bold
|
||||||
|
const parts = line.split(/(\*\*[^*]+\*\*)/g);
|
||||||
|
return (
|
||||||
|
<p key={i}>
|
||||||
|
{parts.map((part, j) =>
|
||||||
|
part.startsWith("**") && part.endsWith("**") ? (
|
||||||
|
<strong key={j}>{part.slice(2, -2)}</strong>
|
||||||
|
) : (
|
||||||
|
part
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ChatBot() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [unread, setUnread] = useState(0);
|
||||||
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setUnread(0);
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 100);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
// Welcome message on first open
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && messages.length === 0) {
|
||||||
|
setMessages([
|
||||||
|
{
|
||||||
|
id: "welcome",
|
||||||
|
role: "model",
|
||||||
|
content:
|
||||||
|
"Bonjour ! 👋 Je suis **Diablo**, votre coach diabète.\n\nJe peux analyser vos données glycémiques et vous donner des conseils personnalisés. N'oubliez pas que je ne remplace pas votre médecin !\n\nComment puis-je vous aider ?",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}, [open, messages.length]);
|
||||||
|
|
||||||
|
const getHistory = useCallback(() => {
|
||||||
|
return messages
|
||||||
|
.filter((m) => !m.streaming && m.id !== "welcome")
|
||||||
|
.map((m) => ({ role: m.role, content: m.content }));
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
async function sendMessage(text: string) {
|
||||||
|
if (!text.trim() || loading) return;
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const userMsg: Message = {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
role: "user",
|
||||||
|
content: text.trim(),
|
||||||
|
};
|
||||||
|
const assistantId = crypto.randomUUID();
|
||||||
|
const assistantMsg: Message = {
|
||||||
|
id: assistantId,
|
||||||
|
role: "model",
|
||||||
|
content: "",
|
||||||
|
streaming: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
setMessages((prev) => [...prev, userMsg, assistantMsg]);
|
||||||
|
setInput("");
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
abortRef.current = new AbortController();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/chat", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ message: text.trim(), history: getHistory() }),
|
||||||
|
signal: abortRef.current.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(data.error ?? `Erreur ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = res.body!.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let full = "";
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
full += decoder.decode(value, { stream: true });
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((m) => (m.id === assistantId ? { ...m, content: full } : m))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.map((m) => (m.id === assistantId ? { ...m, streaming: false } : m))
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!open) setUnread((n) => n + 1);
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as Error).name === "AbortError") return;
|
||||||
|
const msg = err instanceof Error ? err.message : "Erreur inconnue";
|
||||||
|
setError(msg);
|
||||||
|
setMessages((prev) => prev.filter((m) => m.id !== assistantId));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
abortRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
sendMessage(input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSuggestion(s: string) {
|
||||||
|
sendMessage(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearChat() {
|
||||||
|
abortRef.current?.abort();
|
||||||
|
setMessages([]);
|
||||||
|
setError(null);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Floating button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className={cn(
|
||||||
|
"fixed bottom-20 right-4 z-40 flex h-14 w-14 items-center justify-center rounded-full shadow-lg transition-all md:bottom-6",
|
||||||
|
open ? "bg-slate-700 text-white" : "bg-teal-600 text-white hover:bg-teal-700"
|
||||||
|
)}
|
||||||
|
aria-label={open ? "Fermer le coach" : "Ouvrir le coach Diablo"}
|
||||||
|
>
|
||||||
|
{open ? <ChevronDown className="h-6 w-6" /> : <Bot className="h-6 w-6" />}
|
||||||
|
{unread > 0 && !open && (
|
||||||
|
<span className="absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center rounded-full bg-rose-500 text-xs font-bold">
|
||||||
|
{unread}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Chat panel */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"fixed bottom-36 right-4 z-40 flex w-[92vw] max-w-sm flex-col rounded-2xl border border-slate-200 bg-white shadow-2xl transition-all duration-200 md:bottom-24",
|
||||||
|
open ? "opacity-100 translate-y-0 pointer-events-auto" : "opacity-0 translate-y-4 pointer-events-none"
|
||||||
|
)}
|
||||||
|
style={{ height: "min(560px, 70vh)" }}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-3 rounded-t-2xl border-b border-slate-100 bg-teal-600 px-4 py-3">
|
||||||
|
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-white/20">
|
||||||
|
<Bot className="h-5 w-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-semibold text-white">Diablo — Coach diabète</p>
|
||||||
|
<p className="text-xs text-teal-100">Ne remplace pas votre médecin</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clearChat}
|
||||||
|
title="Effacer la conversation"
|
||||||
|
className="rounded p-1 text-teal-100 hover:bg-white/20 hover:text-white"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||||
|
{messages.map((msg) => (
|
||||||
|
<div
|
||||||
|
key={msg.id}
|
||||||
|
className={cn("flex gap-2", msg.role === "user" ? "flex-row-reverse" : "flex-row")}
|
||||||
|
>
|
||||||
|
{msg.role === "model" && (
|
||||||
|
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-teal-100 mt-0.5">
|
||||||
|
<Bot className="h-4 w-4 text-teal-700" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"max-w-[85%] rounded-2xl px-3 py-2 text-sm leading-relaxed",
|
||||||
|
msg.role === "user"
|
||||||
|
? "bg-teal-600 text-white rounded-tr-sm"
|
||||||
|
: "bg-slate-100 text-slate-800 rounded-tl-sm"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{msg.role === "model" ? (
|
||||||
|
<>
|
||||||
|
<MarkdownLite text={msg.content || "…"} />
|
||||||
|
{msg.streaming && (
|
||||||
|
<span className="inline-block h-2 w-2 ml-1 rounded-full bg-teal-600 animate-pulse" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
msg.content
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Suggestions (after welcome, before any user message) */}
|
||||||
|
{messages.length === 1 && messages[0].id === "welcome" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs text-slate-500 text-center">Suggestions :</p>
|
||||||
|
{SUGGESTIONS.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s}
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleSuggestion(s)}
|
||||||
|
className="w-full rounded-xl border border-teal-200 bg-teal-50 px-3 py-2 text-xs text-teal-800 text-left hover:bg-teal-100 transition"
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-start gap-2 rounded-xl border border-rose-200 bg-rose-50 p-3 text-xs text-rose-800">
|
||||||
|
<AlertCircle className="h-4 w-4 shrink-0 mt-0.5" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input */}
|
||||||
|
<div className="border-t border-slate-100 p-3">
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<textarea
|
||||||
|
ref={inputRef}
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
rows={1}
|
||||||
|
placeholder="Posez votre question… (Entrée pour envoyer)"
|
||||||
|
maxLength={2000}
|
||||||
|
className="flex-1 resize-none rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm outline-none focus:border-teal-500 focus:ring-2 focus:ring-teal-200 max-h-28"
|
||||||
|
style={{ minHeight: "38px" }}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => sendMessage(input)}
|
||||||
|
disabled={!input.trim() || loading}
|
||||||
|
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-teal-600 text-white hover:bg-teal-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-center text-[10px] text-slate-400">
|
||||||
|
Conseil informatif uniquement — consultez votre médecin
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
src/components/DailyInsight.tsx
Normal file
100
src/components/DailyInsight.tsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Bot, RefreshCw, Loader2, AlertCircle } from "lucide-react";
|
||||||
|
import { formatDistanceToNow } from "date-fns";
|
||||||
|
import { fr } from "date-fns/locale";
|
||||||
|
|
||||||
|
type State =
|
||||||
|
| { status: "loading" }
|
||||||
|
| { status: "ok"; content: string; generatedAt: Date; fresh: boolean; warning?: string }
|
||||||
|
| { status: "error"; message: string };
|
||||||
|
|
||||||
|
export function DailyInsight() {
|
||||||
|
const [state, setState] = useState<State>({ status: "loading" });
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
async function load(forceRefresh = false) {
|
||||||
|
if (forceRefresh) setRefreshing(true);
|
||||||
|
else setState({ status: "loading" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = forceRefresh ? "/api/daily-analysis?refresh=1" : "/api/daily-analysis";
|
||||||
|
const res = await fetch(url);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok || data.error) {
|
||||||
|
setState({ status: "error", message: data.error ?? `Erreur ${res.status}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState({
|
||||||
|
status: "ok",
|
||||||
|
content: data.content,
|
||||||
|
generatedAt: new Date(data.generatedAt),
|
||||||
|
fresh: data.fresh,
|
||||||
|
warning: data.warning,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setState({ status: "error", message: e instanceof Error ? e.message : "Erreur réseau" });
|
||||||
|
} finally {
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="rounded-xl border border-teal-200 bg-gradient-to-br from-teal-50 to-white p-5 shadow-sm">
|
||||||
|
<div className="mb-3 flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-teal-100">
|
||||||
|
<Bot className="h-4 w-4 text-teal-700" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-sm font-semibold text-teal-800">Analyse du jour — Diablo</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => load(true)}
|
||||||
|
disabled={refreshing || state.status === "loading"}
|
||||||
|
title="Régénérer l'analyse"
|
||||||
|
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-teal-700 hover:bg-teal-100 disabled:opacity-40 transition"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`h-3.5 w-3.5 ${refreshing ? "animate-spin" : ""}`} />
|
||||||
|
Actualiser
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{state.status === "loading" && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-slate-500">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-teal-600" />
|
||||||
|
Génération de l'analyse en cours…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.status === "error" && (
|
||||||
|
<div className="flex items-start gap-2 rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-800">
|
||||||
|
<AlertCircle className="h-4 w-4 shrink-0 mt-0.5" />
|
||||||
|
<span>{state.message}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.status === "ok" && (
|
||||||
|
<>
|
||||||
|
{state.warning && (
|
||||||
|
<div className="mb-2 flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800">
|
||||||
|
<AlertCircle className="h-3.5 w-3.5 shrink-0 mt-0.5" />
|
||||||
|
Analyse précédente affichée : {state.warning}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<p className="text-sm leading-relaxed text-slate-700">{state.content}</p>
|
||||||
|
<p className="mt-3 text-xs text-slate-400">
|
||||||
|
Générée {formatDistanceToNow(state.generatedAt, { addSuffix: true, locale: fr })}
|
||||||
|
{" · "}
|
||||||
|
<span className="italic">Conseil informatif — consultez votre médecin</span>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
src/components/GlycemiaChart.tsx
Normal file
116
src/components/GlycemiaChart.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CartesianGrid,
|
||||||
|
Line,
|
||||||
|
LineChart,
|
||||||
|
ReferenceArea,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from "recharts";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { fr } from "date-fns/locale";
|
||||||
|
|
||||||
|
export type ChartPoint = {
|
||||||
|
measuredAt: string;
|
||||||
|
value: number;
|
||||||
|
moment: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const momentLabel: Record<string, string> = {
|
||||||
|
FASTING: "Matin",
|
||||||
|
LUNCH: "Midi",
|
||||||
|
DINNER: "Soir",
|
||||||
|
};
|
||||||
|
|
||||||
|
function getPointColor(value: number): string {
|
||||||
|
if (value < 0.7) return "#f59e0b"; // orange for hypo
|
||||||
|
if (value > 1.8) return "#f59e0b"; // orange for hyper
|
||||||
|
return "#10b981"; // green for normal
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDotColor(value: number): string {
|
||||||
|
return getPointColor(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GlycemiaChart({ data }: { data: ChartPoint[] }) {
|
||||||
|
const chartData = data
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => new Date(a.measuredAt).getTime() - new Date(b.measuredAt).getTime())
|
||||||
|
.map((d) => ({
|
||||||
|
...d,
|
||||||
|
ts: new Date(d.measuredAt).getTime(),
|
||||||
|
label: format(new Date(d.measuredAt), "d MMM HH:mm", { locale: fr }),
|
||||||
|
dotFill: getDotColor(d.value),
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (chartData.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-72 items-center justify-center text-sm text-slate-500">
|
||||||
|
Pas encore de relevé. Saisissez votre première mesure pour voir la courbe.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const minTs = chartData[0].ts;
|
||||||
|
const maxTs = chartData[chartData.length - 1].ts;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-72 w-full">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={chartData} margin={{ top: 10, right: 16, left: -10, bottom: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="ts"
|
||||||
|
type="number"
|
||||||
|
domain={[minTs, maxTs]}
|
||||||
|
scale="time"
|
||||||
|
tickFormatter={(ts) => format(new Date(ts), "d/MM", { locale: fr })}
|
||||||
|
stroke="#64748b"
|
||||||
|
fontSize={12}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
domain={[0.4, 3]}
|
||||||
|
tickFormatter={(v) => v.toFixed(1)}
|
||||||
|
stroke="#64748b"
|
||||||
|
fontSize={12}
|
||||||
|
unit=" g/L"
|
||||||
|
width={70}
|
||||||
|
/>
|
||||||
|
<ReferenceArea y1={0.7} y2={1.8} fill="#10b981" fillOpacity={0.08} />
|
||||||
|
<ReferenceArea y1={0} y2={0.7} fill="#f59e0b" fillOpacity={0.1} />
|
||||||
|
<ReferenceArea y1={1.8} y2={5} fill="#f59e0b" fillOpacity={0.08} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ borderRadius: 8, border: "1px solid #e2e8f0", fontSize: 13 }}
|
||||||
|
labelFormatter={(ts) => format(new Date(ts as number), "EEEE d MMM yyyy 'à' HH:mm", { locale: fr })}
|
||||||
|
formatter={(value, _name, item) => {
|
||||||
|
const v = typeof value === "number" ? value : Number(value);
|
||||||
|
const moment = (item?.payload as { moment?: string } | undefined)?.moment ?? "";
|
||||||
|
return [
|
||||||
|
`${v.toFixed(2).replace(".", ",")} g/L`,
|
||||||
|
momentLabel[moment] ?? moment,
|
||||||
|
];
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="value"
|
||||||
|
stroke="#0d9488"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={(props) => {
|
||||||
|
const { cx, cy, payload } = props;
|
||||||
|
if (!payload) return null;
|
||||||
|
const color = (payload as any).dotFill || "#0d9488";
|
||||||
|
return (
|
||||||
|
<circle cx={cx} cy={cy} r={4} fill={color} key={`dot-${(payload as any).ts}`} />
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
activeDot={{ r: 6 }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src/components/ServiceWorkerRegister.tsx
Normal file
12
src/components/ServiceWorkerRegister.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
export function ServiceWorkerRegister() {
|
||||||
|
useEffect(() => {
|
||||||
|
if (process.env.NODE_ENV !== "production") return;
|
||||||
|
if (typeof window === "undefined" || !("serviceWorker" in navigator)) return;
|
||||||
|
navigator.serviceWorker.register("/sw.js").catch(() => {});
|
||||||
|
}, []);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
18
src/components/SignOutButton.tsx
Normal file
18
src/components/SignOutButton.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { signOut } from "next-auth/react";
|
||||||
|
import { LogOut } from "lucide-react";
|
||||||
|
|
||||||
|
export function SignOutButton() {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => signOut({ callbackUrl: "/" })}
|
||||||
|
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs text-slate-500 hover:bg-rose-50 hover:text-rose-600 transition"
|
||||||
|
title="Se déconnecter"
|
||||||
|
>
|
||||||
|
<LogOut className="h-3.5 w-3.5" />
|
||||||
|
<span className="hidden sm:inline">Déconnexion</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
src/components/UpgradeButton.tsx
Normal file
77
src/components/UpgradeButton.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Loader2, Crown } from "lucide-react";
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
Stripe: any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UpgradeButton({ isPremium }: { isPremium: boolean }) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function handleUpgrade() {
|
||||||
|
setError(null);
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Load Stripe from CDN
|
||||||
|
if (!window.Stripe) {
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = "https://js.stripe.com/v3/";
|
||||||
|
script.onload = () => {
|
||||||
|
// Continue after script loads
|
||||||
|
};
|
||||||
|
document.head.appendChild(script);
|
||||||
|
// Wait for script to load
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch("/api/stripe/create-checkout", {
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || "Failed to create checkout");
|
||||||
|
|
||||||
|
const stripe = window.Stripe(process.env.NEXT_PUBLIC_STRIPE_KEY || "");
|
||||||
|
if (!stripe) throw new Error("Stripe failed to load");
|
||||||
|
|
||||||
|
const { error } = await stripe.redirectToCheckout({
|
||||||
|
sessionId: data.sessionId,
|
||||||
|
});
|
||||||
|
if (error) throw new Error(error.message);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Unknown error");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPremium) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-amber-100 px-3 py-1.5 text-sm font-medium text-amber-700">
|
||||||
|
<Crown className="h-4 w-4" />
|
||||||
|
Premium
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<button
|
||||||
|
onClick={handleUpgrade}
|
||||||
|
disabled={loading}
|
||||||
|
className="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-teal-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-teal-700 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||||
|
Passer à Premium
|
||||||
|
</button>
|
||||||
|
{error && (
|
||||||
|
<p className="text-xs text-rose-600">{error}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
72
src/lib/auth.ts
Normal file
72
src/lib/auth.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import NextAuth from "next-auth";
|
||||||
|
import Credentials from "next-auth/providers/credentials";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
|
||||||
|
export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||||
|
secret: process.env.AUTH_SECRET,
|
||||||
|
session: { strategy: "jwt" },
|
||||||
|
pages: {
|
||||||
|
signIn: "/auth/login",
|
||||||
|
error: "/auth/login",
|
||||||
|
},
|
||||||
|
callbacks: {
|
||||||
|
authorized({ auth: session }) {
|
||||||
|
return !!session;
|
||||||
|
},
|
||||||
|
async jwt({ token, user }) {
|
||||||
|
if (user) {
|
||||||
|
token.id = user.id;
|
||||||
|
token.email = user.email;
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
async session({ session, token }) {
|
||||||
|
if (token && session.user) {
|
||||||
|
session.user.id = token.id as string;
|
||||||
|
|
||||||
|
// Always fetch the latest user data from DB to get updated plan
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: token.id as string },
|
||||||
|
select: { plan: true, emailVerified: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
(session.user as { plan?: string }).plan = user.plan;
|
||||||
|
session.user.emailVerified = user.emailVerified;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
providers: [
|
||||||
|
Credentials({
|
||||||
|
credentials: {
|
||||||
|
email: { label: "Email", type: "email" },
|
||||||
|
password: { label: "Mot de passe", type: "password" },
|
||||||
|
},
|
||||||
|
async authorize(credentials) {
|
||||||
|
if (!credentials?.email || !credentials?.password) return null;
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email: String(credentials.email).toLowerCase().trim() },
|
||||||
|
});
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
const valid = await bcrypt.compare(
|
||||||
|
String(credentials.password),
|
||||||
|
user.passwordHash
|
||||||
|
);
|
||||||
|
if (!valid) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name,
|
||||||
|
plan: user.plan,
|
||||||
|
emailVerified: user.emailVerified,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
52
src/lib/email.ts
Normal file
52
src/lib/email.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { Resend } from "resend";
|
||||||
|
|
||||||
|
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||||
|
const FROM = process.env.RESEND_FROM ?? "Diabetix <noreply@diabetix.app>";
|
||||||
|
const APP_URL = process.env.NEXTAUTH_URL ?? "http://localhost:3000";
|
||||||
|
|
||||||
|
export async function sendVerificationEmail(
|
||||||
|
to: string,
|
||||||
|
name: string,
|
||||||
|
token: string
|
||||||
|
) {
|
||||||
|
const url = `${APP_URL}/api/auth/verify?token=${token}`;
|
||||||
|
|
||||||
|
await resend.emails.send({
|
||||||
|
from: FROM,
|
||||||
|
to,
|
||||||
|
subject: "Confirmez votre adresse email — Diabetix",
|
||||||
|
html: `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head><meta charset="utf-8"></head>
|
||||||
|
<body style="font-family:system-ui,sans-serif;background:#f8fafc;margin:0;padding:32px 16px;">
|
||||||
|
<div style="max-width:480px;margin:0 auto;background:white;border-radius:16px;padding:40px;border:1px solid #e2e8f0;">
|
||||||
|
<div style="text-align:center;margin-bottom:32px;">
|
||||||
|
<div style="display:inline-flex;align-items:center;gap:8px;color:#0f766e;font-size:20px;font-weight:700;">
|
||||||
|
🩸 Diabetix
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h1 style="font-size:22px;font-weight:700;color:#0f172a;margin:0 0 8px;">
|
||||||
|
Bonjour ${name} 👋
|
||||||
|
</h1>
|
||||||
|
<p style="color:#475569;line-height:1.6;margin:0 0 24px;">
|
||||||
|
Merci de vous être inscrit sur Diabetix. Pour activer votre compte, confirmez votre adresse email en cliquant sur le bouton ci-dessous.
|
||||||
|
</p>
|
||||||
|
<div style="text-align:center;margin:32px 0;">
|
||||||
|
<a href="${url}"
|
||||||
|
style="background:#0f766e;color:white;text-decoration:none;padding:14px 32px;border-radius:10px;font-weight:600;font-size:15px;display:inline-block;">
|
||||||
|
Confirmer mon email
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<p style="color:#94a3b8;font-size:13px;line-height:1.6;margin:0;">
|
||||||
|
Ce lien expire dans 24 heures. Si vous n'avez pas créé de compte, ignorez cet email.
|
||||||
|
</p>
|
||||||
|
<hr style="border:none;border-top:1px solid #e2e8f0;margin:24px 0;">
|
||||||
|
<p style="color:#94a3b8;font-size:12px;text-align:center;margin:0;">
|
||||||
|
Diabetix · Suivi intelligent du diabète
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`,
|
||||||
|
});
|
||||||
|
}
|
||||||
67
src/lib/glycemia.ts
Normal file
67
src/lib/glycemia.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
export type Moment = "FASTING" | "LUNCH" | "DINNER";
|
||||||
|
|
||||||
|
export const MOMENTS: { value: Moment; label: string; short: string }[] = [
|
||||||
|
{ value: "FASTING", label: "Matin (à jeun)", short: "Matin" },
|
||||||
|
{ value: "LUNCH", label: "Midi (2h après repas)", short: "Midi" },
|
||||||
|
{ value: "DINNER", label: "Soir (2h après repas)", short: "Soir" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const MOMENT_LABELS: Record<Moment, string> = {
|
||||||
|
FASTING: "Matin (à jeun)",
|
||||||
|
LUNCH: "Midi (2h après repas)",
|
||||||
|
DINNER: "Soir (2h après repas)",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TARGETS: Record<Moment, { min: number; max: number }> = {
|
||||||
|
FASTING: { min: 0.7, max: 1.3 },
|
||||||
|
LUNCH: { min: 0.7, max: 1.8 },
|
||||||
|
DINNER: { min: 0.7, max: 1.8 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const HYPO_THRESHOLD = 0.7;
|
||||||
|
export const HYPER_THRESHOLD_FASTING = 1.3;
|
||||||
|
export const HYPER_THRESHOLD_POSTPRANDIAL = 1.8;
|
||||||
|
|
||||||
|
export type Status = "hypo" | "in-range" | "hyper";
|
||||||
|
|
||||||
|
export function statusFor(value: number, moment: Moment): Status {
|
||||||
|
const t = TARGETS[moment];
|
||||||
|
if (value < t.min) return "hypo";
|
||||||
|
if (value > t.max) return "hyper";
|
||||||
|
return "in-range";
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STATUS_STYLE: Record<
|
||||||
|
Status,
|
||||||
|
{ label: string; color: string; bg: string; border: string }
|
||||||
|
> = {
|
||||||
|
hypo: {
|
||||||
|
label: "Hypoglycémie",
|
||||||
|
color: "text-amber-700",
|
||||||
|
bg: "bg-amber-100",
|
||||||
|
border: "border-amber-300",
|
||||||
|
},
|
||||||
|
"in-range": {
|
||||||
|
label: "Dans la cible",
|
||||||
|
color: "text-emerald-700",
|
||||||
|
bg: "bg-emerald-100",
|
||||||
|
border: "border-emerald-300",
|
||||||
|
},
|
||||||
|
hyper: {
|
||||||
|
label: "Hyperglycémie",
|
||||||
|
color: "text-rose-700",
|
||||||
|
bg: "bg-rose-100",
|
||||||
|
border: "border-rose-300",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatValue(value: number): string {
|
||||||
|
return value.toFixed(2).replace(".", ",") + " g/L";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Estimation HbA1c à partir de la moyenne glycémique (ADAG formula)
|
||||||
|
// HbA1c (%) = (avg_mg_dl + 46.7) / 28.7 ; avg_g_l * 100 = avg_mg_dl
|
||||||
|
export function estimateHbA1c(avgGperL: number): number {
|
||||||
|
const avgMgDl = avgGperL * 100;
|
||||||
|
return (avgMgDl + 46.7) / 28.7;
|
||||||
|
}
|
||||||
22
src/lib/patient.ts
Normal file
22
src/lib/patient.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
export const SEX_OPTIONS = [
|
||||||
|
{ value: "F", label: "Femme" },
|
||||||
|
{ value: "M", label: "Homme" },
|
||||||
|
{ value: "AUTRE", label: "Autre / non précisé" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const DIABETES_TYPE_OPTIONS = [
|
||||||
|
{ value: "TYPE_1", label: "Type 1" },
|
||||||
|
{ value: "TYPE_2", label: "Type 2" },
|
||||||
|
{ value: "GESTATIONNEL", label: "Gestationnel" },
|
||||||
|
{ value: "AUTRE", label: "Autre" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export const SEX_VALUES = SEX_OPTIONS.map((o) => o.value);
|
||||||
|
export const DIABETES_TYPE_VALUES = DIABETES_TYPE_OPTIONS.map((o) => o.value);
|
||||||
|
|
||||||
|
export const SEX_LABELS: Record<string, string> = Object.fromEntries(
|
||||||
|
SEX_OPTIONS.map((o) => [o.value, o.label])
|
||||||
|
);
|
||||||
|
export const DIABETES_TYPE_LABELS: Record<string, string> = Object.fromEntries(
|
||||||
|
DIABETES_TYPE_OPTIONS.map((o) => [o.value, o.label])
|
||||||
|
);
|
||||||
18
src/lib/prisma.ts
Normal file
18
src/lib/prisma.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { PrismaClient } from "@/generated/prisma";
|
||||||
|
import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const databaseUrl =
|
||||||
|
process.env.DATABASE_URL ?? `file:${path.resolve("prisma/dev.db")}`;
|
||||||
|
|
||||||
|
const globalForPrisma = globalThis as unknown as {
|
||||||
|
prisma: PrismaClient | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const prisma =
|
||||||
|
globalForPrisma.prisma ??
|
||||||
|
new PrismaClient({
|
||||||
|
adapter: new PrismaBetterSqlite3({ url: databaseUrl }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||||
9
src/lib/stripe.ts
Normal file
9
src/lib/stripe.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import Stripe from "stripe";
|
||||||
|
|
||||||
|
if (!process.env.STRIPE_SECRET_KEY) {
|
||||||
|
throw new Error("STRIPE_SECRET_KEY is not defined");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
|
||||||
|
|
||||||
|
export const STRIPE_PRICE_ID = process.env.STRIPE_PRICE_ID || "";
|
||||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
27
src/proxy.ts
Normal file
27
src/proxy.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
import { getToken } from "next-auth/jwt";
|
||||||
|
|
||||||
|
export async function proxy(req: NextRequest) {
|
||||||
|
const token = await getToken({ req, secret: process.env.AUTH_SECRET });
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
const loginUrl = new URL("/auth/login", req.url);
|
||||||
|
return NextResponse.redirect(loginUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
"/dashboard/:path*",
|
||||||
|
"/api/readings/:path*",
|
||||||
|
"/api/patient/:path*",
|
||||||
|
"/api/stats",
|
||||||
|
"/api/export",
|
||||||
|
"/api/chat",
|
||||||
|
"/api/daily-analysis",
|
||||||
|
"/api/stripe/create-checkout",
|
||||||
|
],
|
||||||
|
};
|
||||||
BIN
stripe.exe
Normal file
BIN
stripe.exe
Normal file
Binary file not shown.
69
test-cancel-subscription.mjs
Normal file
69
test-cancel-subscription.mjs
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import pkg from "@prisma/client";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
|
||||||
|
const { PrismaClient } = pkg;
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// Create or get a test user with PREMIUM plan
|
||||||
|
const testEmail = "test.premium@example.com";
|
||||||
|
const testPassword = "TestPassword123!";
|
||||||
|
|
||||||
|
// Check if user exists
|
||||||
|
let user = await prisma.user.findUnique({
|
||||||
|
where: { email: testEmail },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
const passwordHash = await bcrypt.hash(testPassword, 10);
|
||||||
|
user = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
email: testEmail,
|
||||||
|
name: "Test Premium User",
|
||||||
|
passwordHash,
|
||||||
|
plan: "PREMIUM",
|
||||||
|
emailVerified: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log("Created test premium user:", user.email);
|
||||||
|
} else {
|
||||||
|
console.log("User already exists:", user.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a test subscription
|
||||||
|
if (user.stripeId) {
|
||||||
|
console.log("User already has stripeId:", user.stripeId);
|
||||||
|
} else {
|
||||||
|
const user2 = await prisma.user.update({
|
||||||
|
where: { id: user.id },
|
||||||
|
data: {
|
||||||
|
stripeId: "cus_test_" + Date.now(),
|
||||||
|
subscription: {
|
||||||
|
create: {
|
||||||
|
stripeId: "sub_test_" + Date.now(),
|
||||||
|
stripePriceId: "price_test",
|
||||||
|
stripeCustomerId: "cus_test_" + Date.now(),
|
||||||
|
status: "active",
|
||||||
|
currentPeriodEnd: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: { subscription: true },
|
||||||
|
});
|
||||||
|
console.log("Created subscription for user:", user2.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\nTest user credentials:");
|
||||||
|
console.log("Email:", testEmail);
|
||||||
|
console.log("Password:", testPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
10
test-stripe.sh
Normal file
10
test-stripe.sh
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Test the Stripe checkout API (will fail without auth, which is expected)
|
||||||
|
echo "Testing POST /api/stripe/create-checkout (should fail with 401 - Unauthorized)"
|
||||||
|
curl -X POST http://localhost:3000/api/stripe/create-checkout \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
2>/dev/null | head -c 200
|
||||||
|
echo ""
|
||||||
|
echo ""
|
||||||
|
echo "✅ Stripe API routes are working correctly"
|
||||||
Reference in New Issue
Block a user