RegWatch frontend : React/Vite -> nginx + Docker/CI homelab
Build & Deploy / build (push) Successful in 18s

- URL d'API externalisée (VITE_API_URL bakée au build), plus de localhost en dur
- Dockerfile multi-stage pnpm build -> nginx (fallback SPA)
- docker-compose (Watchtower) + .gitea/workflows/build.yml

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-24 18:43:29 +02:00
co-authored by Claude Opus 4.8
commit 2307d9a134
49 changed files with 6678 additions and 0 deletions
+141
View File
@@ -0,0 +1,141 @@
import { Target, Users, Shield, BookOpen } from 'lucide-react';
import styles from '../styles/AboutPage.module.css';
export default function AboutPage() {
return (
<div className={styles.container}>
<div className={styles.maxWidth}>
<div className={styles.header}>
<h1 className={styles.title}>À propos de RegWatch MedLabs</h1>
<p className={styles.subtitle}>Plateforme de veille réglementaire pour les ingrédients cosmétiques</p>
</div>
{/* Mission Section */}
<div className={styles.section}>
<div className={styles.sectionHeader}>
<div className={styles.sectionIcon}>
<Target size={24} />
</div>
<h2 className={styles.sectionTitle}>Notre Mission</h2>
</div>
<p className={styles.sectionText}>
RegWatch MedLabs est une plateforme spécialisée conçue pour aider les experts scientifiques et réglementaires à accéder rapidement aux documents officiels relatifs aux ingrédients cosmétiques.
</p>
<p className={styles.sectionText}>
Notre objectif est de simplifier la veille réglementaire en centralisant les informations provenant des principales autorités sanitaires internationales (SCCS, FDA, EFSA, Commission Européenne, etc.).
</p>
</div>
{/* Pour Qui Section */}
<div className={styles.section}>
<div className={styles.sectionHeader}>
<div className={styles.sectionIcon}>
<Users size={24} />
</div>
<h2 className={styles.sectionTitle}>Pour Qui ?</h2>
</div>
<div className={styles.targetList}>
<div className={styles.targetItem}>
<div className={styles.targetTitle}>
<div className={styles.targetDot}></div>
Toxicologues
</div>
<p className={styles.targetDescription}>
Accès rapide aux évaluations de sécurité et études toxicologiques
</p>
</div>
<div className={styles.targetItem}>
<div className={styles.targetTitle}>
<div className={styles.targetDot}></div>
Consultants Réglementaires
</div>
<p className={styles.targetDescription}>
Suivi des dernières réglementations et mises à jour normatives
</p>
</div>
<div className={styles.targetItem}>
<div className={styles.targetTitle}>
<div className={styles.targetDot}></div>
Responsables Qualité
</div>
<p className={styles.targetDescription}>
Vérification de la conformité des ingrédients cosmétiques
</p>
</div>
<div className={styles.targetItem}>
<div className={styles.targetTitle}>
<div className={styles.targetDot}></div>
Chercheurs
</div>
<p className={styles.targetDescription}>
Accès à la base de données complète pour la recherche scientifique
</p>
</div>
</div>
</div>
{/* Key Features */}
<div className={styles.section}>
<div className={styles.sectionHeader}>
<div className={styles.sectionIcon}>
<Shield size={24} />
</div>
<h2 className={styles.sectionTitle}>Fonctionnalités Clés</h2>
</div>
<div className={styles.featuresGrid}>
<div className={styles.featureCard}>
<h3 className={styles.featureTitle}>Recherche par Ingrédient</h3>
<p className={styles.featureDescription}>
Trouvez rapidement tous les documents relatifs à un ingrédient spécifique
</p>
</div>
<div className={styles.featureCard}>
<h3 className={styles.featureTitle}>Base de Données Complète</h3>
<p className={styles.featureDescription}>
Accès à l'ensemble des documents collectés par notre système de veille
</p>
</div>
<div className={styles.featureCard}>
<h3 className={styles.featureTitle}>Suivi des Mises à Jour</h3>
<p className={styles.featureDescription}>
Notifications des nouveaux documents et révisions réglementaires
</p>
</div>
<div className={styles.featureCard}>
<h3 className={styles.featureTitle}>Filtres Avancés</h3>
<p className={styles.featureDescription}>
Filtrez par source, type de document, date de publication
</p>
</div>
</div>
</div>
{/* About Platform */}
<div className={styles.section}>
<div className={styles.sectionHeader}>
<div className={styles.sectionIcon}>
<BookOpen size={24} />
</div>
<h2 className={styles.sectionTitle}>RegWatch MedLabs</h2>
</div>
<p className={styles.sectionText}>
Plateforme développée par des experts en réglementation cosmétique pour répondre aux besoins spécifiques des professionnels du secteur.
</p>
<div className={styles.infoBox}>
<div className={styles.infoLines}>
<div className={styles.infoLine}>
<span className={styles.infoLabel}>Secteur :</span> Cosmétique & Réglementation
</div>
<div className={styles.infoLine}>
<span className={styles.infoLabel}>Spécialité :</span> Veille réglementaire scientifique
</div>
<div className={styles.infoLine}>
<span className={styles.infoLabel}>Public cible :</span> Experts toxicologues et consultants réglementaires
</div>
</div>
</div>
</div>
</div>
</div>
);
}
+169
View File
@@ -0,0 +1,169 @@
import { useState } from "react";
import styles from "../styles/AuthPage.module.css";
export default function AuthPage({ onAuthSuccess }: { onAuthSuccess: (token: string, user: { id: number; email: string; full_name: string | null }) => void }) {
const [isLogin, setIsLogin] = useState(true);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [fullName, setFullName] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const API_URL = import.meta.env.VITE_API_URL ?? "http://localhost:8000";
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
console.log("Tentative de connexion avec:", email);
const response = await fetch(`${API_URL}/login`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email, password }),
});
console.log("Status:", response.status);
const data = await response.json();
console.log("Response data:", data);
if (!response.ok) {
throw new Error(data.detail || `Erreur ${response.status}`);
}
const userResponse = await fetch(`${API_URL}/me?token=${data.access_token}`);
const userData = await userResponse.json();
onAuthSuccess(data.access_token, userData);
} catch (err) {
console.error("Error:", err);
setError(err instanceof Error ? err.message : "Erreur lors de la connexion");
} finally {
setLoading(false);
}
};
const handleSignup = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
const response = await fetch(`${API_URL}/signup`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email,
password,
full_name: fullName,
}),
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.detail || "Erreur d'inscription");
}
const userData = await response.json();
// Automatiquement connecter après l'inscription
const loginResponse = await fetch(`${API_URL}/login`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email, password }),
});
const loginData = await loginResponse.json();
onAuthSuccess(loginData.access_token, userData);
} catch (err) {
setError(err instanceof Error ? err.message : "Erreur lors de l'inscription");
} finally {
setLoading(false);
}
};
const handleSubmit = isLogin ? handleLogin : handleSignup;
return (
<div className={styles.container}>
<div className={styles.authCard}>
<h1 className={styles.title}>RegWatch</h1>
<p className={styles.subtitle}>Gestion des réglementations alimentaires</p>
<form onSubmit={handleSubmit} className={styles.form}>
{error && <div className={styles.error}>{error}</div>}
<div className={styles.formGroup}>
<label htmlFor="email">Email</label>
<input
type="email"
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
placeholder="votre@email.com"
/>
</div>
{!isLogin && (
<div className={styles.formGroup}>
<label htmlFor="fullName">Nom complet</label>
<input
type="text"
id="fullName"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
placeholder="Votre nom complet"
/>
</div>
)}
<div className={styles.formGroup}>
<label htmlFor="password">Mot de passe</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
placeholder="••••••••"
/>
</div>
<button
type="submit"
className={styles.submitBtn}
disabled={loading}
>
{loading ? "Chargement..." : isLogin ? "Se connecter" : "S'inscrire"}
</button>
</form>
<div className={styles.toggleAuth}>
<p>
{isLogin ? "Pas encore de compte ?" : "Déjà inscrit ?"}
{" "}
<button
type="button"
onClick={() => {
setIsLogin(!isLogin);
setError("");
}}
className={styles.toggleBtn}
>
{isLogin ? "S'inscrire" : "Se connecter"}
</button>
</p>
</div>
</div>
</div>
);
}
+165
View File
@@ -0,0 +1,165 @@
import { FileText, CheckCircle, XCircle, AlertCircle } from 'lucide-react';
import styles from '../styles/CGU.module.css';
export default function CGU() {
return (
<main className={styles.page}>
<div className={styles.content}>
<div className={styles.header}>
<div className={styles.titleRow}>
<FileText size={32} className={styles.titleIcon} />
<h1 className={styles.title}>Conditions Générales d'Utilisation (CGU)</h1>
</div>
<p className={styles.lastUpdate}>Dernière mise à jour : 15 avril 2026</p>
</div>
<div className={styles.sections}>
<section className={styles.card}>
<h2 className={styles.cardTitle}>Préambule</h2>
<div className={styles.prose}>
<p>
Les présentes conditions générales d'utilisation (CGU) régissent l'utilisation de la plateforme RegWatch MedLabs. L'accès et
l'utilisation du site impliquent l'acceptation pleine et entière des présentes CGU par l'utilisateur.
</p>
</div>
</section>
<section className={styles.card}>
<h2 className={styles.cardTitle}>Article 1 - Objet du Service</h2>
<div className={styles.prose}>
<p>
RegWatch MedLabs est une plateforme de veille réglementaire destinée aux professionnels du secteur cosmétique. Le service
permet de :
</p>
<div className={styles.checklist}>
<div className={styles.checkItem}>
<CheckCircle size={16} className={styles.checkIcon} />
<span>Rechercher des documents réglementaires relatifs aux ingrédients cosmétiques</span>
</div>
<div className={styles.checkItem}>
<CheckCircle size={16} className={styles.checkIcon} />
<span>Accéder à une base de données de documents officiels</span>
</div>
<div className={styles.checkItem}>
<CheckCircle size={16} className={styles.checkIcon} />
<span>Suivre les mises à jour réglementaires</span>
</div>
</div>
</div>
</section>
<section className={styles.card}>
<h2 className={styles.cardTitle}>Article 2 - Accès au Service</h2>
<div className={styles.prose}>
<p>
Le service est accessible gratuitement à tout utilisateur disposant d'un accès à internet. Tous les frais supportés par l'utilisateur
pour accéder au service (accès internet notamment) sont à sa charge.
</p>
</div>
</section>
<section className={styles.card}>
<h2 className={styles.cardTitle}>Article 3 - Utilisation du Service</h2>
<div className={styles.prose}>
<p style={{ fontWeight: 600, marginBottom: '8px' }}>L'utilisateur s'engage à :</p>
<div className={styles.checklist}>
<div className={styles.checkItem}>
<CheckCircle size={16} className={styles.checkIcon} />
<span>Utiliser le service de manière loyale et dans le respect des présentes CGU</span>
</div>
<div className={styles.checkItem}>
<CheckCircle size={16} className={styles.checkIcon} />
<span>Ne pas utiliser le service à des fins commerciales sans autorisation</span>
</div>
<div className={styles.checkItem}>
<CheckCircle size={16} className={styles.checkIcon} />
<span>Respecter les droits de propriété intellectuelle</span>
</div>
</div>
<p style={{ fontWeight: 600, marginTop: '16px', marginBottom: '8px' }}>Il est formellement interdit de :</p>
<div className={styles.prohibitList}>
<div className={styles.prohibitItem}>
<XCircle size={16} className={styles.prohibitIcon} />
<span>Extraire ou télécharger massivement des données (scraping)</span>
</div>
<div className={styles.prohibitItem}>
<XCircle size={16} className={styles.prohibitIcon} />
<span>Tenter d'accéder aux parties non publiques du service</span>
</div>
<div className={styles.prohibitItem}>
<XCircle size={16} className={styles.prohibitIcon} />
<span>Perturber ou interrompre le fonctionnement du service</span>
</div>
</div>
</div>
</section>
<section className={styles.card}>
<h2 className={styles.cardTitle}>Article 4 - Responsabilité</h2>
<div className={styles.warningBox}>
<AlertCircle size={20} className={styles.warningIcon} />
<div className={styles.warningContent}>
<div className={styles.warningTitle}>Avertissement Important</div>
<div className={styles.warningText}>
Les documents et informations fournis sur cette plateforme sont à titre informatif uniquement. RegWatch MedLabs ne
peut garantir l'exactitude, l'exhaustivité ou l'actualité des informations.
</div>
</div>
</div>
<div className={styles.prose} style={{ marginTop: '16px' }}>
<p>
L'utilisateur est seul responsable de l'utilisation qu'il fait des informations consultées. RegWatch MedLabs ne saurait être tenu
responsable de toute décision prise sur la base des informations disponibles sur la plateforme.
</p>
<p>
RegWatch MedLabs ne garantit pas que le service soit exempt d'erreurs, de virus ou autres composants nuisibles.
</p>
</div>
</section>
<section className={styles.card}>
<h2 className={styles.cardTitle}>Article 5 - Propriété Intellectuelle</h2>
<div className={styles.prose}>
<p>
Tous les éléments du site (structure, textes, logos, images, etc.) sont protégés par le droit d'auteur. Toute reproduction,
représentation, modification, publication, transmission, dénaturtion, totale ou partielle du site ou de son contenu, par quelque
procédé que ce soit, sans autorisation préalable écrite, est interdite.
</p>
</div>
</section>
<section className={styles.card}>
<h2 className={styles.cardTitle}>Article 6 - Protection des Données</h2>
<div className={styles.prose}>
<p>
Les données personnelles collectées sont traitées conformément au RGPD. Pour plus d'informations, veuillez consulter notre
<span style={{ color: '#1d4ed8' }}> politique de confidentialité</span>.
</p>
</div>
</section>
<section className={styles.card}>
<h2 className={styles.cardTitle}>Article 7 - Modification des CGU</h2>
<div className={styles.prose}>
<p>
RegWatch MedLabs se réserve le droit de modifier les présentes CGU à tout moment. Les nouvelles conditions prendront effet
dès leur publication sur le site. Il est conseillé à l'utilisateur de consulter régulièrement les CGU.
</p>
</div>
</section>
<section className={styles.card}>
<h2 className={styles.cardTitle}>Article 8 - Droit Applicable</h2>
<div className={styles.prose}>
<p>
Les présentes CGU sont régies par le droit français. En cas de litige, et à défaut d'accord amiable, le litige sera porté devant les
tribunaux français compétents.
</p>
</div>
</section>
</div>
</div>
</main>
);
}
+238
View File
@@ -0,0 +1,238 @@
import { useState } from 'react';
import { Mail, Phone, MapPin, Send } from 'lucide-react';
import styles from '../styles/ContactPage.module.css';
export default function ContactPage() {
const [formData, setFormData] = useState({
name: '',
email: '',
subject: '',
message: '',
});
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value,
}));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
console.log('Form submitted:', formData);
setFormData({ name: '', email: '', subject: '', message: '' });
};
return (
<div className={styles.container}>
<div className={styles.maxWidth}>
<div className={styles.header}>
<div className={styles.headerIcon}>
<Mail size={28} />
</div>
<h1 className={styles.title}>Contact</h1>
<p className={styles.subtitle}>Nous sommes à votre écoute pour toute question ou demande d'information</p>
</div>
{/* Main Content */}
<div className={styles.mainContent}>
{/* Contact Info */}
<div className={styles.contactInfo}>
{/* Email */}
<div className={styles.infoSection}>
<h3 className={styles.infoSectionTitle}>
<Mail size={20} className={styles.infoIcon} />
Email
</h3>
<div className={styles.infoContent}>
<a href="mailto:contact@regwatch-medlabs.com" className={styles.infoItemLink}>
<span className={styles.infoItemBold}>contact@regwatch-medlabs.com</span>
</a>
<a href="mailto:support@regwatch-medlabs.com" className={styles.infoItemLink}>
<span className={styles.infoItemBold}>support@regwatch-medlabs.com</span>
</a>
</div>
</div>
{/* Phone */}
<div className={styles.infoSection}>
<h3 className={styles.infoSectionTitle}>
<Phone size={20} className={styles.infoIcon} />
Téléphone
</h3>
<div className={styles.infoContent}>
<div className={styles.infoItem}>
<span className={styles.infoItemBold}>+33 (0)1 XX XX XX XX</span>
</div>
<div className={`${styles.infoItem} ${styles.infoItemSmall}`}>
Du lundi au vendredi, 9h-18h
</div>
</div>
</div>
{/* Address */}
<div className={styles.infoSection}>
<h3 className={styles.infoSectionTitle}>
<MapPin size={20} className={styles.infoIcon} />
Adresse
</h3>
<div className={styles.infoContent}>
<div className={styles.infoItem}>
<span className={styles.infoItemBold}>RegWatch MedLabs</span>
</div>
<div className={styles.infoItem}>[Adresse complète]</div>
<div className={styles.infoItem}>[Code postal] [Ville]</div>
<div className={styles.infoItem}>France</div>
</div>
</div>
{/* Opening Hours */}
<div className={styles.openingHoursBox}>
<h4 className={styles.openingHoursTitle}>Horaires d'ouverture</h4>
<div className={styles.openingHoursList}>
<div>Lundi - Vendredi : 9h00 - 18h00</div>
<div>Samedi - Dimanche : Fermé</div>
</div>
</div>
{/* Technical Support */}
<div className={styles.technicalSupport}>
<h4 className={styles.technicalSupportTitle}>Support Technique</h4>
<p className={styles.technicalSupportText}>
Pour toute assistance technique, merci de préciser :
</p>
<ul className={styles.technicalSupportList}>
<li className={styles.technicalSupportItem}>
<span className={styles.technicalSupportBullet}></span>
<span>Votre navigateur web</span>
</li>
<li className={styles.technicalSupportItem}>
<span className={styles.technicalSupportBullet}></span>
<span>Description du problème</span>
</li>
<li className={styles.technicalSupportItem}>
<span className={styles.technicalSupportBullet}></span>
<span>Captures d'écran si possible</span>
</li>
</ul>
</div>
</div>
{/* Form */}
<div className={styles.formSection}>
<h2 className={styles.formTitle}>Envoyez-nous un message</h2>
<form onSubmit={handleSubmit}>
<div className={styles.formGrid}>
<div className={styles.formGroup}>
<label htmlFor="name" className={`${styles.formLabel} ${styles.formLabelRequired}`}>
Nom complet
</label>
<input
id="name"
type="text"
name="name"
value={formData.name}
onChange={handleChange}
placeholder="Votre nom"
className={styles.formInput}
required
/>
</div>
<div className={styles.formGroup}>
<label htmlFor="email" className={`${styles.formLabel} ${styles.formLabelRequired}`}>
Email
</label>
<input
id="email"
type="email"
name="email"
value={formData.email}
onChange={handleChange}
placeholder="votre.email@exemple.com"
className={styles.formInput}
required
/>
</div>
</div>
<div className={`${styles.formGrid} ${styles.formGridFull}`}>
<div className={styles.formGroup}>
<label htmlFor="subject" className={`${styles.formLabel} ${styles.formLabelRequired}`}>
Sujet
</label>
<select
id="subject"
name="subject"
value={formData.subject}
onChange={handleChange}
className={styles.formSelect}
required
>
<option value="">Sélectionnez un sujet</option>
<option value="general">Question générale</option>
<option value="support">Support technique</option>
<option value="partnership">Partenariat</option>
<option value="feedback">Avis et suggestions</option>
<option value="other">Autre</option>
</select>
</div>
</div>
<div className={`${styles.formGrid} ${styles.formGridFull}`}>
<div className={styles.formGroup}>
<label htmlFor="message" className={`${styles.formLabel} ${styles.formLabelRequired}`}>
Message
</label>
<textarea
id="message"
name="message"
value={formData.message}
onChange={handleChange}
placeholder="Écrivez votre message ici..."
className={styles.formTextarea}
required
></textarea>
</div>
</div>
<p className={styles.disclaimerText}>
* Champs obligatoires. Vos données personnelles sont traitées conformément à notre politique de confidentialité et au RGPD. Elles ne seront utilisées que pour répondre à votre demande.
</p>
<button type="submit" className={styles.submitButton}>
<Send size={18} />
Envoyer le message
</button>
</form>
</div>
</div>
{/* FAQ Section */}
<div className={styles.faqSection}>
<h2 className={styles.faqTitle}>Questions Fréquentes</h2>
<div className={styles.faqItems}>
<div className={styles.faqItem}>
<h3 className={styles.faqQuestion}>Comment rechercher un ingrédient ?</h3>
<p className={styles.faqAnswer}>
Utilisez la barre de recherche sur la page d'accueil ou accédez à la page "Recherche d'Ingrédients" via le menu de navigation.
</p>
</div>
<div className={styles.faqItem}>
<h3 className={styles.faqQuestion}>Les documents sont-ils mis à jour ?</h3>
<p className={styles.faqAnswer}>
Oui, notre système de veille collecte régulièrement les nouveaux documents et mises à jour réglementaires.
</p>
</div>
<div className={styles.faqItem}>
<h3 className={styles.faqQuestion}>Le service est-il gratuit ?</h3>
<p className={styles.faqAnswer}>
Contactez-nous pour connaître nos offres et tarifs adaptés à vos besoins.
</p>
</div>
</div>
</div>
</div>
</div>
);
}
+279
View File
@@ -0,0 +1,279 @@
import { useEffect, useMemo, useState } from "react";
import {
Filter,
ArrowUpDown,
File as FileIcon,
} from "lucide-react";
import styles from "../styles/DocumentsPage.module.css";
import { getDocuments } from "../services/theapi";
type ApiDocument = {
title: string;
ingredient: string;
source: string;
type: string;
date?: string | null;
pdf_url: string;
bold?: boolean;
};
const ITEMS_PER_PAGE = 8;
export default function DocumentsPage() {
const [documents, setDocuments] = useState<ApiDocument[]>([]);
const [loading, setLoading] = useState(true);
const [sourceFilter, setSourceFilter] = useState("All Sources");
const [typeFilter, setTypeFilter] = useState("All Types");
const [dateFilter, setDateFilter] = useState("All Dates");
const [sortBy, setSortBy] = useState("Date (Newest)");
const [currentPage, setCurrentPage] = useState(1);
useEffect(() => {
getDocuments()
.then((data) => {
setDocuments(Array.isArray(data) ? data : []);
})
.catch((err) => {
console.error("API Error:", err);
setDocuments([]);
})
.finally(() => setLoading(false));
}, []);
const allSources = useMemo(
() => ["All Sources", ...new Set(documents.map((d) => d.source))],
[documents]
);
const allTypes = useMemo(
() => ["All Types", ...new Set(documents.map((d) => d.type))],
[documents]
);
const allDates = useMemo(
() => [
"All Dates",
...new Set(
documents.map((d) => String(d.date ?? "").slice(0, 4))
),
],
[documents]
);
const filtered = useMemo(() => {
return documents
.filter((d) => {
const sourceMatch =
sourceFilter === "All Sources" || d.source === sourceFilter;
const typeMatch =
typeFilter === "All Types" || d.type === typeFilter;
const safeDate = String(d.date ?? "");
const dateMatch =
dateFilter === "All Dates" ||
safeDate.startsWith(dateFilter);
return sourceMatch && typeMatch && dateMatch;
})
.sort((a, b) => {
const dateA = String(a.date ?? "");
const dateB = String(b.date ?? "");
const titleA = String(a.title ?? "");
const titleB = String(b.title ?? "");
if (sortBy === "Date (Newest)") return dateB.localeCompare(dateA);
if (sortBy === "Date (Oldest)") return dateA.localeCompare(dateB);
if (sortBy === "Title (A-Z)") return titleA.localeCompare(titleB);
if (sortBy === "Title (Z-A)") return titleB.localeCompare(titleA);
return 0;
});
}, [documents, sourceFilter, typeFilter, dateFilter, sortBy]);
const totalPages = Math.max(
1,
Math.ceil(filtered.length / ITEMS_PER_PAGE)
);
const safePage = Math.min(currentPage, totalPages);
const paginated = filtered.slice(
(safePage - 1) * ITEMS_PER_PAGE,
safePage * ITEMS_PER_PAGE
);
if (loading) {
return (
<div className={styles.container}>
<p>Loading documents...</p>
</div>
);
}
return (
<div className={styles.container}>
<div className={styles.maxWidth}>
{/* HEADER */}
<div className={styles.header}>
<h2 className={styles.title}>Document Database</h2>
<p className={styles.description}>
Complete collection of regulatory monitoring documents
</p>
</div>
{/* FILTERS */}
<div className={styles.filtersBox}>
<div className={styles.filterLabel}>
<Filter size={16} />
<span>Filters:</span>
</div>
<select
value={sourceFilter}
onChange={(e) => setSourceFilter(e.target.value)}
className={styles.select}
>
{allSources.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
<select
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
className={styles.select}
>
{allTypes.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
<select
value={dateFilter}
onChange={(e) => setDateFilter(e.target.value)}
className={styles.select}
>
{allDates.map((d) => (
<option key={d} value={d}>
{d}
</option>
))}
</select>
<div className={styles.sortWrapper}>
<ArrowUpDown size={16} />
<select
value={sortBy}
onChange={(e) => setSortBy(e.target.value)}
className={styles.select}
>
<option value="Date (Newest)">Date (Newest)</option>
<option value="Date (Oldest)">Date (Oldest)</option>
<option value="Title (A-Z)">Title (A-Z)</option>
<option value="Title (Z-A)">Title (Z-A)</option>
</select>
</div>
</div>
{/* STATS */}
<div className={styles.statsRow}>
<p className={styles.statsText}>
Total Documents:
<span className={styles.statsNumber}>
{" "}
{filtered.length}
</span>
</p>
</div>
{/* TABLE */}
<div className={styles.table}>
<div className={styles.tableHeader}>
<span>Title</span>
<span>Ingredient</span>
<span>Source</span>
<span>Type</span>
<span>Date</span>
<span>PDF</span>
</div>
{paginated.map((doc, index) => (
<div
key={`${doc.title}-${doc.source}-${doc.date ?? index}`}
className={styles.tableRow}
>
<div className={styles.titleCell}>
<FileIcon size={16} />
<span>{doc.title}</span>
</div>
<span>{doc.ingredient}</span>
<span>{doc.source}</span>
<span>{doc.type}</span>
<span>{doc.date}</span>
<div>
<a
href={doc.pdf_url}
target="_blank"
rel="noreferrer"
>
<button className={styles.openButton}>
Open
</button>
</a>
</div>
</div>
))}
</div>
{/* PAGINATION */}
{totalPages > 1 && (
<div className={styles.pagination}>
<button
onClick={() =>
setCurrentPage(Math.max(1, safePage - 1))
}
disabled={safePage === 1}
className={styles.paginationButton}
>
Previous
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map(
(page) => (
<button
key={page}
onClick={() => setCurrentPage(page)}
className={
page === safePage
? styles.pageNumberActive
: styles.pageNumberInactive
}
>
{page}
</button>
)
)}
<button
onClick={() =>
setCurrentPage(Math.min(totalPages, safePage + 1))
}
disabled={safePage === totalPages}
className={styles.paginationButton}
>
Next
</button>
</div>
)}
</div>
</div>
);
}
+101
View File
@@ -0,0 +1,101 @@
import { Search, FileText, TrendingUp, Clock } from 'lucide-react';
import styles from '../styles/HomePage.module.css';
import { openPdf, type ApiDocument } from '../services/theapi';
type NavItem =
| 'home'
| 'ingredient-search'
| 'documents'
| 'recent-updates'
| 'about'
| 'contact'
| 'cgu'
| 'mentions-legales';
interface HomePageProps {
handleNavClick: (item: NavItem) => void;
recentDocuments: ApiDocument[];
}
export default function HomePage({ handleNavClick, recentDocuments }: HomePageProps) {
return (
<div className={styles.container}>
<div className={styles.maxWidth}>
{/* Hero Section */}
<div className={styles.heroSection}>
<h1 className={styles.title}>RegWatch MedLabs</h1>
<p className={styles.subtitle}>Cosmetic Regulatory Monitoring Platform</p>
<p className={styles.description}>
Quickly find official regulatory documents related to cosmetic ingredients
</p>
</div>
{/* Feature Cards */}
<div className={styles.cardsGrid}>
<button onClick={() => handleNavClick('ingredient-search')} className={styles.card}>
<div className={styles.cardIconWrapper}>
<Search size={24} className={styles.cardIcon} />
</div>
<h3 className={styles.cardTitle}>Ingredient Search</h3>
<p className={styles.cardDescription}>Search regulatory documents by ingredient</p>
</button>
<button onClick={() => handleNavClick('documents')} className={styles.card}>
<div className={styles.cardIconWrapper}>
<FileText size={24} className={styles.cardIcon} />
</div>
<h3 className={styles.cardTitle}>All Documents</h3>
<p className={styles.cardDescription}>Browse complete document database</p>
</button>
<button onClick={() => handleNavClick('recent-updates')} className={styles.card}>
<div className={styles.cardIconWrapper}>
<TrendingUp size={24} className={styles.cardIcon} />
</div>
<h3 className={styles.cardTitle}>Recent Updates</h3>
<p className={styles.cardDescription}>Latest regulatory changes</p>
</button>
</div>
{/* Recent Documents */}
<div className={styles.recentSection}>
<div className={styles.sectionHeader}>
<Clock size={20} className={styles.sectionIcon} />
<h2 className={styles.sectionTitle}>Recent Regulatory Documents</h2>
</div>
<div className={styles.documentsList}>
{recentDocuments.map((doc, index) => (
<div
key={doc.id}
className={`${styles.documentItem} ${
index < recentDocuments.length - 1 ? styles.documentItemNotLast : ''
}`}
>
<h3 className={styles.documentTitle}>{doc.title}</h3>
<div className={styles.documentMeta}>
<span><span className={styles.metaLabel}>Ingredient:</span> {doc.ingredient}</span>
<span><span className={styles.metaLabel}>Source:</span> {doc.source}</span>
<span><span className={styles.metaLabel}>Date:</span> {doc.date ?? "—"}</span>
{doc.pdf_url ? (
<button
className={styles.documentButton}
onClick={() => openPdf(doc.pdf_url)}
>
Open PDF
</button>
) : (
<button className={styles.documentButton} disabled>No PDF</button>
)}
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}
+161
View File
@@ -0,0 +1,161 @@
import { useEffect, useMemo, useState } from "react";
import { Search, Filter } from "lucide-react";
import styles from "../styles/IngredientSearchPage.module.css";
import { getDocuments, openPdf, type ApiDocument } from "../services/theapi";
export default function IngredientSearchPage() {
const [documents, setDocuments] = useState<ApiDocument[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState("");
const [sourceFilter, setSourceFilter] = useState("All Sources");
const [typeFilter, setTypeFilter] = useState("All Types");
const [dateFilter, setDateFilter] = useState("All Dates");
// FETCH API
useEffect(() => {
getDocuments()
.then((data) => setDocuments(Array.isArray(data) ? data : []))
.catch((err) => {
console.error("API ERROR:", err);
setDocuments([]);
})
.finally(() => setLoading(false));
}, []);
// FILTER OPTIONS
const allSources = useMemo(() => [
"All Sources",
...new Set(documents.map((d) => d.source)),
], [documents]);
const allTypes = useMemo(() => [
"All Types",
...new Set(documents.map((d) => d.type)),
], [documents]);
const allDates = useMemo(() => [
"All Dates",
...new Set(documents.map((d) => String(d.date ?? "").slice(0, 4))).values(),
], [documents]);
// FILTERED RESULTS
const filtered = useMemo(() => {
return documents
.filter((d) => {
const queryMatch =
searchQuery === "" ||
d.ingredient.toLowerCase().includes(searchQuery.toLowerCase()) ||
d.title.toLowerCase().includes(searchQuery.toLowerCase());
const sourceMatch = sourceFilter === "All Sources" || d.source === sourceFilter;
const typeMatch = typeFilter === "All Types" || d.type === typeFilter;
const safeDate = String(d.date ?? "");
const dateMatch = dateFilter === "All Dates" || safeDate.startsWith(dateFilter);
return queryMatch && sourceMatch && typeMatch && dateMatch;
})
.sort((a, b) => String(b.date ?? "").localeCompare(String(a.date ?? "")));
}, [documents, searchQuery, sourceFilter, typeFilter, dateFilter]);
if (loading) {
return <div className={styles.container}><p>Loading...</p></div>;
}
return (
<div className={styles.container}>
<div className={styles.maxWidth}>
{/* HEADER */}
<div className={styles.header}>
<h1 className={styles.title}>Ingredient Search</h1>
<p className={styles.subtitle}>Search cosmetic ingredients and regulatory documents</p>
</div>
{/* SEARCH */}
<div className={styles.searchBarWrapper}>
<div className={styles.searchBar}>
<input
className={styles.searchInput}
type="text"
placeholder="Search ingredient..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<Search size={20} className={styles.searchIcon} />
</div>
</div>
{/* FILTERS */}
<div className={styles.filtersBox}>
<div className={styles.filterHeader}>
<Filter size={16} className={styles.filterIcon} />
<span>Filters</span>
</div>
<div className={styles.filterControls}>
<select className={styles.select} value={sourceFilter} onChange={(e) => setSourceFilter(e.target.value)}>
{allSources.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
<select className={styles.select} value={typeFilter} onChange={(e) => setTypeFilter(e.target.value)}>
{allTypes.map((t) => <option key={t} value={t}>{t}</option>)}
</select>
<select className={styles.select} value={dateFilter} onChange={(e) => setDateFilter(e.target.value)}>
{allDates.map((d) => <option key={d} value={d}>{d || "Unknown"}</option>)}
</select>
</div>
</div>
{/* RESULTS */}
<div className={styles.resultsInfo}>
<span className={styles.resultsCount}>{filtered.length}</span> results found
</div>
{/* DOCUMENTS */}
<div className={styles.documentsList}>
{filtered.length === 0 ? (
<div className={styles.emptyState}>
<p className={styles.emptyMessage}>No documents found.</p>
</div>
) : (
filtered.map((doc) => (
<div key={doc.id} className={styles.documentCard}>
<div className={styles.documentCardFlex}>
<div className={styles.documentContent}>
<h3 className={styles.documentTitle}>{doc.title}</h3>
<div className={styles.documentMeta}>
<div className={styles.metaItem}>
<span className={styles.metaLabel}>Ingredient</span>
<span className={styles.metaValue}>{doc.ingredient}</span>
</div>
<div className={styles.metaItem}>
<span className={styles.metaLabel}>Source</span>
<span className={styles.metaValue}>{doc.source}</span>
</div>
<div className={styles.metaItem}>
<span className={styles.metaLabel}>Type</span>
<span className={styles.metaValue}>{doc.type}</span>
</div>
<div className={styles.metaItem}>
<span className={styles.metaLabel}>Date</span>
<span className={styles.metaValue}>{doc.date ?? "—"}</span>
</div>
</div>
</div>
{doc.pdf_url && (
<button
className={styles.openButton}
onClick={() => openPdf(doc.pdf_url)}
>
Open PDF
</button>
)}
</div>
</div>
))
)}
</div>
</div>
</div>
);
}
+112
View File
@@ -0,0 +1,112 @@
import { Scale, Building2, Globe, Mail } from 'lucide-react';
import styles from '../styles/MentionsLegales.module.css';
export default function MentionsLegales() {
return (
<main className={styles.page}>
<div className={styles.content}>
<div className={styles.header}>
<div className={styles.titleRow}>
<Scale size={32} className={styles.titleIcon} />
<h1 className={styles.title}>Mentions Légales</h1>
</div>
<p className={styles.subtitle}>Informations légales et réglementaires</p>
</div>
<div className={styles.sections}>
<section className={styles.card}>
<div className={styles.cardHeader}>
<Building2 size={20} className={styles.cardIcon} />
<h2 className={styles.cardTitle}>Éditeur du Site</h2>
</div>
<div className={styles.fields}>
<p className={styles.field}><strong>Raison sociale :</strong> RegWatch MedLabs</p>
<p className={styles.field}><strong>Forme juridique :</strong> [À compléter]</p>
<p className={styles.field}><strong>Capital social :</strong> [À compléter]</p>
<p className={styles.field}><strong>Siège social :</strong> [Adresse complète]</p>
<p className={styles.field}><strong>N° SIRET :</strong> [À compléter]</p>
<p className={styles.field}><strong>N° TVA intracommunautaire :</strong> [À compléter]</p>
</div>
</section>
<section className={styles.card}>
<h2 className={styles.cardTitle}>Directeur de la Publication</h2>
<div className={`${styles.fields} ${styles.sectionContent}`}>
<p className={styles.field}><strong>Nom :</strong> [À compléter]</p>
<p className={styles.field}><strong>Fonction :</strong> [À compléter]</p>
</div>
</section>
<section className={styles.card}>
<div className={styles.cardHeader}>
<Globe size={20} className={styles.cardIcon} />
<h2 className={styles.cardTitle}>Hébergement</h2>
</div>
<div className={styles.fields}>
<p className={styles.field}><strong>Hébergeur :</strong> [Nom de l'hébergeur]</p>
<p className={styles.field}><strong>Adresse :</strong> [Adresse complète]</p>
<p className={styles.field}><strong>Téléphone :</strong> [Numéro]</p>
<p className={styles.field}><strong>Site web :</strong> [URL]</p>
</div>
</section>
<section className={styles.card}>
<div className={styles.cardHeader}>
<Mail size={20} className={styles.cardIcon} />
<h2 className={styles.cardTitle}>Contact</h2>
</div>
<div className={styles.fields}>
<p className={styles.field}><strong>Email :</strong> contact@regwatch-medlabs.com</p>
<p className={styles.field}><strong>Téléphone :</strong> [À compléter]</p>
<p className={styles.field}><strong>Adresse postale :</strong> [À compléter]</p>
</div>
</section>
<section className={styles.card}>
<h2 className={styles.cardTitle}>Propriété Intellectuelle</h2>
<div className={`${styles.prose} ${styles.sectionContent}`}>
<p>
L'ensemble de ce site relève de la législation française et internationale sur le droit d'auteur et la propriété intellectuelle. Tous les
droits de reproduction sont réservés, y compris pour les documents téléchargeables et les représentations iconographiques et
photographiques.
</p>
<p className={styles.highlight}>
La reproduction de tout ou partie de ce site sur un support électronique quel qu'il soit est formellement interdite sauf
autorisation expresse du directeur de la publication.
</p>
<p className={styles.highlight}>
Les marques de RegWatch MedLabs ainsi que les logos figurant sur le site sont des marques déposées. Toute reproduction totale
ou partielle de ces marques ou de ces logos effectuée à partir des éléments du site sans l'autorisation expresse de RegWatch
MedLabs est donc prohibée.
</p>
</div>
</section>
<section className={styles.card}>
<h2 className={styles.cardTitle}>Protection des Données Personnelles</h2>
<div className={`${styles.prose} ${styles.sectionContent}`}>
<p>
Conformément à la loi « Informatique et Libertés » du 6 janvier 1978 modifiée et au Règlement Général sur la Protection des
Données (RGPD), vous disposez d'un droit d'accès, de rectification, de suppression et d'opposition aux données personnelles
vous concernant.
</p>
<p>
Pour exercer ces droits, vous pouvez contacter : contact@regwatch-medlabs.com
</p>
</div>
</section>
<section className={styles.card}>
<h2 className={styles.cardTitle}>Cookies</h2>
<div className={`${styles.prose} ${styles.sectionContent}`}>
<p>
Ce site peut utiliser des cookies pour améliorer l'expérience utilisateur. Vous pouvez configurer votre navigateur pour refuser les
cookies, mais certaines fonctionnalités du site pourraient ne pas être disponibles.
</p>
</div>
</section>
</div>
</div>
</main>
);
}
+233
View File
@@ -0,0 +1,233 @@
import { useEffect, useMemo, useState } from "react";
import { TrendingUp, Filter, Eye } from "lucide-react";
import styles from "../styles/RecentUpdatesPage.module.css";
import { getDocuments, openPdf, type ApiDocument } from "../services/theapi";
const ITEMS_PER_PAGE = 8;
export default function RecentUpdatesPage() {
const [updates, setUpdates] = useState<ApiDocument[]>([]);
const [loading, setLoading] = useState(true);
const [sourceFilter, setSourceFilter] = useState("All Sources");
const [typeFilter, setTypeFilter] = useState("All Types");
const [currentPage, setCurrentPage] = useState(1);
useEffect(() => {
getDocuments()
.then((data) => setUpdates(Array.isArray(data) ? data : []))
.catch((err) => {
console.error("API ERROR:", err);
setUpdates([]);
})
.finally(() => setLoading(false));
}, []);
const allSources = useMemo(
() => ["All Sources", ...new Set(updates.map((u) => u.source))],
[updates]
);
const allTypes = useMemo(
() => ["All Types", ...new Set(updates.map((u) => u.type))],
[updates]
);
const filtered = useMemo(() => {
return updates
.filter((u) => {
const sourceMatch =
sourceFilter === "All Sources" || u.source === sourceFilter;
const typeMatch =
typeFilter === "All Types" || u.type === typeFilter;
return sourceMatch && typeMatch;
})
.sort((a, b) =>
String(b.date ?? "").localeCompare(String(a.date ?? ""))
);
}, [updates, sourceFilter, typeFilter]);
const totalPages = Math.max(1, Math.ceil(filtered.length / ITEMS_PER_PAGE));
const safePage = Math.min(currentPage, totalPages);
const paginated = filtered.slice(
(safePage - 1) * ITEMS_PER_PAGE,
safePage * ITEMS_PER_PAGE
);
if (loading) {
return (
<div className={styles.container}>
<p>Loading updates...</p>
</div>
);
}
return (
<div className={styles.container}>
<div className={styles.maxWidth}>
{/* HEADER */}
<div className={styles.header}>
<div className={styles.headerFlex}>
<TrendingUp size={28} className={styles.headerIcon} />
<h1 className={styles.title}>Recent Updates</h1>
</div>
<p className={styles.subtitle}>
Latest cosmetic regulatory updates and monitoring activity
</p>
</div>
{/* STATS */}
<div className={styles.statsGrid}>
<div className={styles.statCard}>
<p className={styles.statLabel}>Total Updates</p>
<h2 className={styles.statValue}>{updates.length}</h2>
<div className={`${styles.statIcon} ${styles.statIconBlue}`}>
<TrendingUp size={18} />
</div>
</div>
<div className={styles.statCard}>
<p className={styles.statLabel}>Sources</p>
<h2 className={styles.statValue}>{allSources.length - 1}</h2>
<div className={`${styles.statIcon} ${styles.statIconGreen}`}>
<Filter size={18} />
</div>
</div>
</div>
{/* FILTERS */}
<div className={styles.filtersBox}>
<div className={styles.filterLabel}>
<Filter size={16} />
<span>Filters</span>
</div>
<select
className={styles.select}
value={sourceFilter}
onChange={(e) => setSourceFilter(e.target.value)}
>
{allSources.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
<select
className={styles.select}
value={typeFilter}
onChange={(e) => setTypeFilter(e.target.value)}
>
{allTypes.map((t) => (
<option key={t} value={t}>
{t}
</option>
))}
</select>
</div>
{/* RESULTS */}
<div className={styles.resultsInfo}>
<span className={styles.resultsCount}>{filtered.length}</span>{" "}
updates found
</div>
{/* TABLE */}
<div className={styles.table}>
<div className={styles.tableHeader}>
<span className={styles.headerCell}>Status</span>
<span className={styles.headerCell}>Title</span>
<span className={styles.headerCell}>Ingredient</span>
<span className={styles.headerCell}>Source</span>
<span className={styles.headerCell}>Type</span>
<span className={styles.headerCell}>Date</span>
<span className={styles.headerCell}>View</span>
</div>
{paginated.map((u, index) => (
<div
key={u.id}
className={`${styles.tableRow} ${
index < paginated.length - 1
? styles.tableRowBorder
: ""
}`}
>
<div>
<span
className={`${styles.statusBadge} ${styles.statusBadgeNew}`}
>
New
</span>
</div>
<div className={`${styles.titleCell} ${styles.cellTruncate}`}>
{u.title}
</div>
<div className={styles.cell}>{u.ingredient}</div>
<div className={styles.cell}>{u.source}</div>
<div className={styles.cell}>{u.type}</div>
<div className={styles.cell}>{u.date ?? "—"}</div>
<div>
{u.pdf_url && (
<button
className={styles.viewButton}
onClick={() => openPdf(u.pdf_url)}
>
<Eye size={14} />
</button>
)}
</div>
</div>
))}
</div>
{/* PAGINATION */}
{totalPages > 1 && (
<div className={styles.pagination}>
<button
className={styles.paginationButton}
disabled={safePage === 1}
onClick={() =>
setCurrentPage((p) => Math.max(1, p - 1))
}
>
Previous
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map(
(page) => (
<button
key={page}
onClick={() => setCurrentPage(page)}
className={
page === safePage
? styles.pageNumberActive
: styles.pageNumberInactive
}
>
{page}
</button>
)
)}
<button
className={styles.paginationButton}
disabled={safePage === totalPages}
onClick={() =>
setCurrentPage((p) => Math.min(totalPages, p + 1))
}
>
Next
</button>
</div>
)}
</div>
</div>
);
}