RegWatch frontend : React/Vite -> nginx + Docker/CI homelab
Build & Deploy / build (push) Successful in 18s
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user