- To get started, edit the page.tsx file.
+
+ {/* Header */}
+
+
+
+
+ Diabetix
+
+
+
+
+ Connexion
+
+
+ Commencer gratuitement
+
+
+
+
+
+ {/* Hero */}
+
+
+
+
+ Coach IA personnalisé inclus en Premium
+
+
+ Prenez le contrĂŽle de votre{" "}
+ glycémie
-
- Looking for a starting point or more instructions? Head over to{" "}
-
- Templates
- {" "}
- or the{" "}
-
- Learning
- {" "}
- center.
+
+ Diabetix vous aide à suivre vos relevés quotidiens, visualiser vos tendances et recevoir des conseils personnalisés grùce à l'intelligence artificielle.
+
+
+ Commencer gratuitement
+
+
+ Voir les tarifs
+
+
+ Sans carte bancaire · AccÚs immédiat · Données sécurisées
-
-
-
- Deploy Now
-
-
- Documentation
-
+
+
+ {/* Features */}
+
+
+ Tout ce dont vous avez besoin
+
+ Une application complÚte, pensée pour les patients diabétiques au quotidien.
+
+
+ {[
+ {
+ 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) => (
+
+
+
+
+ {f.title}
+ {f.desc}
+
+ ))}
+
-
+
+
+ {/* Pricing */}
+
+
+ Tarifs simples et transparents
+ Commencez gratuitement, passez Premium quand vous ĂȘtes prĂȘt.
+
+ {/* Free */}
+
+ Gratuit
+ 0 âŹ
+ pour toujours
+
+ Commencer gratuitement
+
+
+ {[
+ "Saisie illimitée de relevés",
+ "Tableau de bord 7 jours",
+ "Statistiques hebdomadaires",
+ "Saisie mobile rapide",
+ "Export CSV",
+ ].map((f) => (
+ -
+
+ {f}
+
+ ))}
+
+
+ {/* Premium */}
+
+
+
+
+ Recommandé
+
+
+ Premium
+
+ 4,99 âŹ
+ /mois
+
+ sans engagement
+
+ Essayer Premium
+
+
+ {[
+ "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) => (
+ -
+
+ {f}
+
+ ))}
+
+
+
+
+
+
+ {/* FAQ */}
+
+
+ Questions fréquentes
+
+ {[
+ {
+ 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 }) => (
+
+ {q}
+ {a}
+
+ ))}
+
+
+
+
+ {/* CTA */}
+
+ PrĂȘt Ă mieux gĂ©rer votre diabĂšte ?
+ Rejoignez Diabetix gratuitement et commencez votre suivi aujourd'hui.
+
+ Créer mon compte gratuit
+
+
+
+ {/* Footer */}
+
);
}
diff --git a/src/app/pricing/page.tsx b/src/app/pricing/page.tsx
new file mode 100644
index 0000000..9c4d283
--- /dev/null
+++ b/src/app/pricing/page.tsx
@@ -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 (
+
+ Choisissez votre plan
+ Commencez gratuitement, passez Premium quand vous ĂȘtes prĂȘt.
+
+
+ {/* Free */}
+
+ Gratuit
+ 0 âŹ
+ {!session ? (
+
+ Commencer
+
+ ) : (
+
+ Votre plan actuel
+
+ )}
+
+ {["Saisie illimitée", "Tableau de bord 7 jours", "Statistiques semaine", "Export CSV"].map((f) => (
+ -
+
+ {f}
+
+ ))}
+
+
+
+ {/* Premium */}
+
+
+
+
+ Recommandé
+
+
+ Premium
+
+ 4,99 âŹ
+ /mois
+
+
+ {!session ? (
+
+ Essayer Premium
+
+ ) : (
+
+ )}
+
+
+ {["Tout le plan Gratuit", "Historique illimité", "Stats 30 et 90 jours", "HbA1c estimée", "Analyse IA quotidienne", "Coach Diablo"].map((f) => (
+ -
+
+ {f}
+
+ ))}
+
+
+
+
+
+ {!session ? (
+ <>
+ Déjà inscrit ?{" "}
+ Se connecter
+ >
+ ) : (
+ <>
+ Besoin d'aide ?{" "}
+ Contactez-nous
+ >
+ )}
+
+
+ );
+}
diff --git a/src/app/profil/CancelSubscriptionButton.tsx b/src/app/profil/CancelSubscriptionButton.tsx
new file mode 100644
index 0000000..e749dee
--- /dev/null
+++ b/src/app/profil/CancelSubscriptionButton.tsx
@@ -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(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 (
+
+ {isCanceled ? (
+
+
+
+
+ Abonnement résilié
+
+ Votre accĂšs Premium reste actif jusqu'au {endDateFormatted}.
+
+
+
+
+ ) : !showConfirm ? (
+
+ ) : (
+
+
+
+
+ Ătes-vous sĂ»r ?
+
+ Votre accĂšs Premium restera actif jusqu'au {endDateFormatted}. AprĂšs cette date, vous reviendrez au plan gratuit.
+
+
+
+
+
+
+
+
+ )}
+ {error && (
+
+ {error}
+
+ )}
+
+ );
+}
diff --git a/src/app/profil/ProfileForm.tsx b/src/app/profil/ProfileForm.tsx
new file mode 100644
index 0000000..f92b0fe
--- /dev/null
+++ b/src/app/profil/ProfileForm.tsx
@@ -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(() => ({
+ 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(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(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 (
+
+ );
+}
+
+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 (
+
+
+ {children}
+
+ );
+}
+
+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";
+}
diff --git a/src/app/profil/page.tsx b/src/app/profil/page.tsx
new file mode 100644
index 0000000..0ca92d2
--- /dev/null
+++ b/src/app/profil/page.tsx
@@ -0,0 +1,5 @@
+import { redirect } from "next/navigation";
+
+export default function ProfilPage() {
+ redirect("/dashboard/profil");
+}
diff --git a/src/app/saisie/ReadingForm.tsx b/src/app/saisie/ReadingForm.tsx
new file mode 100644
index 0000000..c0c2217
--- /dev/null
+++ b/src/app/saisie/ReadingForm.tsx
@@ -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(suggestMomentForNow());
+ const [valueStr, setValueStr] = useState("");
+ const [measuredAt, setMeasuredAt] = useState(nowLocalDateTime());
+ const [notes, setNotes] = useState("");
+ const [submitting, setSubmitting] = useState(false);
+ const [error, setError] = useState(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 (
+
+ );
+}
diff --git a/src/app/saisie/page.tsx b/src/app/saisie/page.tsx
new file mode 100644
index 0000000..0be2a28
--- /dev/null
+++ b/src/app/saisie/page.tsx
@@ -0,0 +1,5 @@
+import { redirect } from "next/navigation";
+
+export default function SaisiePage() {
+ redirect("/dashboard/saisie");
+}
diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx
new file mode 100644
index 0000000..2bd29f5
--- /dev/null
+++ b/src/components/AppShell.tsx
@@ -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 (
+ <>
+
+
+
+
+ Diabetix
+
+
+
+
+
+ {!isPremium && (
+
+
+ Premium
+
+ )}
+ {isPremium && (
+
+
+ Premium
+
+ )}
+
+
+ {initials || }
+
+
+
+
+
+
+
+ {children}
+
+
+ {isPremium && }
+
+
+ >
+ );
+}
diff --git a/src/components/ChatBot.tsx b/src/components/ChatBot.tsx
new file mode 100644
index 0000000..17174f6
--- /dev/null
+++ b/src/components/ChatBot.tsx
@@ -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 (
+
+ {lines.map((line, i) => {
+ if (line.startsWith("## ")) {
+ return {line.slice(3)}
;
+ }
+ if (line.startsWith("- ") || line.startsWith("âą ")) {
+ return (
+
+ âą
+ {line.slice(2)}
+
+ );
+ }
+ if (line.startsWith("**") && line.endsWith("**")) {
+ return {line.slice(2, -2)}
;
+ }
+ if (!line.trim()) return ;
+ // Inline bold
+ const parts = line.split(/(\*\*[^*]+\*\*)/g);
+ return (
+
+ {parts.map((part, j) =>
+ part.startsWith("**") && part.endsWith("**") ? (
+ {part.slice(2, -2)}
+ ) : (
+ part
+ )
+ )}
+
+ );
+ })}
+
+ );
+}
+
+export function ChatBot() {
+ const [open, setOpen] = useState(false);
+ const [messages, setMessages] = useState([]);
+ const [input, setInput] = useState("");
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+ const [unread, setUnread] = useState(0);
+ const messagesEndRef = useRef(null);
+ const inputRef = useRef(null);
+ const abortRef = useRef(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) {
+ 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 */}
+
+
+ {/* Chat panel */}
+
+ {/* Header */}
+
+
+
+
+
+ Diablo â Coach diabĂšte
+ Ne remplace pas votre médecin
+
+
+
+
+ {/* Messages */}
+
+ {messages.map((msg) => (
+
+ {msg.role === "model" && (
+
+
+
+ )}
+
+ {msg.role === "model" ? (
+ <>
+
+ {msg.streaming && (
+
+ )}
+ >
+ ) : (
+ msg.content
+ )}
+
+
+ ))}
+
+ {/* Suggestions (after welcome, before any user message) */}
+ {messages.length === 1 && messages[0].id === "welcome" && (
+
+ Suggestions :
+ {SUGGESTIONS.map((s) => (
+
+ ))}
+
+ )}
+
+ {error && (
+
+
+ {error}
+
+ )}
+
+
+
+
+ {/* Input */}
+
+
+
+
+ Conseil informatif uniquement â consultez votre mĂ©decin
+
+
+
+ >
+ );
+}
diff --git a/src/components/DailyInsight.tsx b/src/components/DailyInsight.tsx
new file mode 100644
index 0000000..0bd164d
--- /dev/null
+++ b/src/components/DailyInsight.tsx
@@ -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({ 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 (
+
+
+
+
+
+
+ Analyse du jour â Diablo
+
+
+
+
+ {state.status === "loading" && (
+
+
+ GĂ©nĂ©ration de l'analyse en coursâŠ
+
+ )}
+
+ {state.status === "error" && (
+
+
+ {state.message}
+
+ )}
+
+ {state.status === "ok" && (
+ <>
+ {state.warning && (
+
+
+ Analyse précédente affichée : {state.warning}
+
+ )}
+ {state.content}
+
+ Générée {formatDistanceToNow(state.generatedAt, { addSuffix: true, locale: fr })}
+ {" · "}
+ Conseil informatif â consultez votre mĂ©decin
+
+ >
+ )}
+
+ );
+}
diff --git a/src/components/GlycemiaChart.tsx b/src/components/GlycemiaChart.tsx
new file mode 100644
index 0000000..98a3d0a
--- /dev/null
+++ b/src/components/GlycemiaChart.tsx
@@ -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 = {
+ 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 (
+
+ Pas encore de relevé. Saisissez votre premiÚre mesure pour voir la courbe.
+
+ );
+ }
+
+ const minTs = chartData[0].ts;
+ const maxTs = chartData[chartData.length - 1].ts;
+
+ return (
+
+
+
+
+ format(new Date(ts), "d/MM", { locale: fr })}
+ stroke="#64748b"
+ fontSize={12}
+ />
+ v.toFixed(1)}
+ stroke="#64748b"
+ fontSize={12}
+ unit=" g/L"
+ width={70}
+ />
+
+
+
+ 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,
+ ];
+ }}
+ />
+ {
+ const { cx, cy, payload } = props;
+ if (!payload) return null;
+ const color = (payload as any).dotFill || "#0d9488";
+ return (
+
+ );
+ }}
+ activeDot={{ r: 6 }}
+ />
+
+
+
+ );
+}
diff --git a/src/components/ServiceWorkerRegister.tsx b/src/components/ServiceWorkerRegister.tsx
new file mode 100644
index 0000000..1376b1d
--- /dev/null
+++ b/src/components/ServiceWorkerRegister.tsx
@@ -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;
+}
diff --git a/src/components/SignOutButton.tsx b/src/components/SignOutButton.tsx
new file mode 100644
index 0000000..c5d02fd
--- /dev/null
+++ b/src/components/SignOutButton.tsx
@@ -0,0 +1,18 @@
+"use client";
+
+import { signOut } from "next-auth/react";
+import { LogOut } from "lucide-react";
+
+export function SignOutButton() {
+ return (
+
+ );
+}
diff --git a/src/components/UpgradeButton.tsx b/src/components/UpgradeButton.tsx
new file mode 100644
index 0000000..33540a0
--- /dev/null
+++ b/src/components/UpgradeButton.tsx
@@ -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(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 (
+
+
+ Premium
+
+ );
+ }
+
+ return (
+
+
+ {error && (
+ {error}
+ )}
+
+ );
+}
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
new file mode 100644
index 0000000..810cf82
--- /dev/null
+++ b/src/lib/auth.ts
@@ -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,
+ };
+ },
+ }),
+ ],
+});
diff --git a/src/lib/email.ts b/src/lib/email.ts
new file mode 100644
index 0000000..51aa6f4
--- /dev/null
+++ b/src/lib/email.ts
@@ -0,0 +1,52 @@
+import { Resend } from "resend";
+
+const resend = new Resend(process.env.RESEND_API_KEY);
+const FROM = process.env.RESEND_FROM ?? "Diabetix ";
+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: `
+
+
+
+
+
+
+
+ 𩞠Diabetix
+
+
+
+ Bonjour ${name} đ
+
+
+ Merci de vous ĂȘtre inscrit sur Diabetix. Pour activer votre compte, confirmez votre adresse email en cliquant sur le bouton ci-dessous.
+
+
+
+ Ce lien expire dans 24 heures. Si vous n'avez pas créé de compte, ignorez cet email.
+
+
+
+ Diabetix · Suivi intelligent du diabÚte
+
+
+
+`,
+ });
+}
diff --git a/src/lib/glycemia.ts b/src/lib/glycemia.ts
new file mode 100644
index 0000000..bfc9614
--- /dev/null
+++ b/src/lib/glycemia.ts
@@ -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 = {
+ FASTING: "Matin (Ă jeun)",
+ LUNCH: "Midi (2h aprĂšs repas)",
+ DINNER: "Soir (2h aprĂšs repas)",
+};
+
+export const TARGETS: Record = {
+ 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;
+}
diff --git a/src/lib/patient.ts b/src/lib/patient.ts
new file mode 100644
index 0000000..ec483bf
--- /dev/null
+++ b/src/lib/patient.ts
@@ -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 = Object.fromEntries(
+ SEX_OPTIONS.map((o) => [o.value, o.label])
+);
+export const DIABETES_TYPE_LABELS: Record = Object.fromEntries(
+ DIABETES_TYPE_OPTIONS.map((o) => [o.value, o.label])
+);
diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts
new file mode 100644
index 0000000..4c8bcb9
--- /dev/null
+++ b/src/lib/prisma.ts
@@ -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;
diff --git a/src/lib/stripe.ts b/src/lib/stripe.ts
new file mode 100644
index 0000000..a01b4b8
--- /dev/null
+++ b/src/lib/stripe.ts
@@ -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 || "";
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
new file mode 100644
index 0000000..a5ef193
--- /dev/null
+++ b/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx";
+import { twMerge } from "tailwind-merge";
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs));
+}
diff --git a/src/proxy.ts b/src/proxy.ts
new file mode 100644
index 0000000..9683102
--- /dev/null
+++ b/src/proxy.ts
@@ -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",
+ ],
+};
diff --git a/stripe.exe b/stripe.exe
new file mode 100644
index 0000000..b098ecf
Binary files /dev/null and b/stripe.exe differ
diff --git a/test-cancel-subscription.mjs b/test-cancel-subscription.mjs
new file mode 100644
index 0000000..4b10a23
--- /dev/null
+++ b/test-cancel-subscription.mjs
@@ -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();
+ });
diff --git a/test-stripe.sh b/test-stripe.sh
new file mode 100644
index 0000000..b19d548
--- /dev/null
+++ b/test-stripe.sh
@@ -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"
+ Prenez le contrÎle de votre{" "} + glycémie
-- Looking for a starting point or more instructions? Head over to{" "} - - Templates - {" "} - or the{" "} - - Learning - {" "} - center. +
+ Diabetix vous aide à suivre vos relevés quotidiens, visualiser vos tendances et recevoir des conseils personnalisés grùce à l'intelligence artificielle.
+Sans carte bancaire · AccÚs immédiat · Données sécurisées
Tout ce dont vous avez besoin
++ Une application complÚte, pensée pour les patients diabétiques au quotidien. +
+{f.title}
+{f.desc}
+Tarifs simples et transparents
+Commencez gratuitement, passez Premium quand vous ĂȘtes prĂȘt.
+-
+ {[
+ "Saisie illimitée de relevés",
+ "Tableau de bord 7 jours",
+ "Statistiques hebdomadaires",
+ "Saisie mobile rapide",
+ "Export CSV",
+ ].map((f) => (
+
-
+
+ {f} +
+ ))}
+
-
+ {[
+ "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) => (
+
-
+
+ {f} +
+ ))}
+
Questions fréquentes
+{q}
+{a}
+PrĂȘt Ă mieux gĂ©rer votre diabĂšte ?
+Rejoignez Diabetix gratuitement et commencez votre suivi aujourd'hui.
+ + Créer mon compte gratuit + +Choisissez votre plan
+Commencez gratuitement, passez Premium quand vous ĂȘtes prĂȘt.
+ +-
+ {["Saisie illimitée", "Tableau de bord 7 jours", "Statistiques semaine", "Export CSV"].map((f) => (
+
-
+
+ {f} +
+ ))}
+
-
+ {["Tout le plan Gratuit", "Historique illimité", "Stats 30 et 90 jours", "HbA1c estimée", "Analyse IA quotidienne", "Coach Diablo"].map((f) => (
+
-
+
+ {f} +
+ ))}
+
+ {!session ? ( + <> + Déjà inscrit ?{" "} + Se connecter + > + ) : ( + <> + Besoin d'aide ?{" "} + Contactez-nous + > + )} +
+Abonnement résilié
++ Votre accĂšs Premium reste actif jusqu'au {endDateFormatted}. +
+Ătes-vous sĂ»r ?
++ Votre accĂšs Premium restera actif jusqu'au {endDateFormatted}. AprĂšs cette date, vous reviendrez au plan gratuit. +
+{line.slice(3)}
; + } + if (line.startsWith("- ") || line.startsWith("âą ")) { + return ( +{line.slice(2, -2)}
; + } + if (!line.trim()) return ; + // Inline bold + const parts = line.split(/(\*\*[^*]+\*\*)/g); + return ( ++ {parts.map((part, j) => + part.startsWith("**") && part.endsWith("**") ? ( + {part.slice(2, -2)} + ) : ( + part + ) + )} +
+ ); + })} +Diablo â Coach diabĂšte
+Ne remplace pas votre médecin
+Suggestions :
+ {SUGGESTIONS.map((s) => ( + + ))} ++ Conseil informatif uniquement â consultez votre mĂ©decin +
+Analyse du jour â Diablo
+{state.content}
++ GĂ©nĂ©rĂ©e {formatDistanceToNow(state.generatedAt, { addSuffix: true, locale: fr })} + {" · "} + Conseil informatif â consultez votre mĂ©decin +
+ > + )} +{error}
+ )} ++ Bonjour ${name} đ +
++ Merci de vous ĂȘtre inscrit sur Diabetix. Pour activer votre compte, confirmez votre adresse email en cliquant sur le bouton ci-dessous. +
+ ++ Ce lien expire dans 24 heures. Si vous n'avez pas créé de compte, ignorez cet email. +
++
+ Diabetix · Suivi intelligent du diabÚte +
+