diff --git a/.eslintrc.cjs b/.eslintrc.cjs index d6c9537..416fc0d 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -15,4 +15,18 @@ module.exports = { { allowConstantExport: true }, ], }, + overrides: [ + { + // Composants shadcn vendorés (exportent aussi leurs variants cva) et + // paires provider+hook : la règle ne concerne que la granularité du HMR. + files: [ + 'src/components/ui/**/*.tsx', + 'src/providers/theme-provider.tsx', + 'src/components/language.tsx', + ], + rules: { + 'react-refresh/only-export-components': 'off', + }, + }, + ], } diff --git a/AUDIT.md b/AUDIT.md new file mode 100644 index 0000000..560f3ba --- /dev/null +++ b/AUDIT.md @@ -0,0 +1,189 @@ +# Audit Frontend — Handoff (2026-08-14) + +Audit complet de la landing Python Cameroon (React 18 + Vite + Tailwind + shadcn + framer-motion). +Chaque tâche ci-dessous est **autonome** : elle contient le contexte, les fichiers concernés, la correction attendue et la vérification. +Pour traiter une tâche dans une nouvelle session : « Traite la tâche X de AUDIT.md ». + +**État au moment de l'audit :** le build passe (`npm run build`), `npm run lint` échoue (1 erreur), aucun test, branche `feat/add-pycon-banner`. + +## Ordre de traitement recommandé + +| Priorité | Tâches | Pourquoi | +|---|---|---| +| 🔴 Critique | B1, B2, B3, B4 | Bugs visibles ou trompeurs pour l'utilisateur | +| 🟠 Haute | B5, B6, P1, P2, P3, S1 | Performance mobile + conversions + partage social | +| 🟡 Moyenne | A1–A5, P4–P6, Q1–Q3 | Accessibilité, dette, hygiène | +| 🟢 Basse | Q4, Q5, O1–O3 | Outillage, déploiement, docs | + +Dépendances : B1 avant toute retouche visuelle (le design actuel est partiellement invisible). Q1 (refactor sections) de préférence après P1/P2 pour ne pas refactorer du code qui va être supprimé. + +--- + +## B — Bugs confirmés + +### B1 — Variables CSS HSL avec virgules : toutes les opacités Tailwind sont cassées 🔴 +- **Fichiers :** `src/App.css` lignes 79–83 et 106–109 (`--primary`, `--secondary`, `--muted`, `--accent` en `166, 95%, 29%` avec virgules, dans `:root` ET `.dark`). +- **Problème (vérifié dans le CSS buildé) :** Tailwind génère `hsl(var(--primary) / .1)` pour `bg-primary/10`. Avec les virgules, ça devient `hsl(166, 95%, 29% / .1)` = CSS invalide → propriété silencieusement ignorée. Toutes les classes `bg-primary/xx`, `border-primary/xx`, `from-/via-/to-primary|secondary` (dégradés de titres, halos, badges, bordures — utilisées dans quasiment chaque section) ne s'affichent pas. +- **Correction :** notation espace shadcn : `--primary: 166 95% 29%;` etc. (4 variables × 2 blocs). Vérifier visuellement les 2 thèmes après coup : des couleurs jusqu'ici invisibles vont apparaître, certains effets pourront sembler soudain trop chargés. +- **Vérification :** `npm run build` puis grep `hsl(166, 95%` dans `dist/assets/*.css` → 0 occurrence ; contrôle visuel light + dark. + +### B2 — `--primary-rgb` contient du HSL utilisé dans `rgba(...)` : tous les glows inline sont morts 🔴 +- **Fichiers :** `src/index.css` lignes 1–11 (`--primary-rgb: 166, 95%, 29%`, `--secondary-rgb: 50, 96%, 59%`). Consommé par `rgba(var(--primary-rgb), 0.x)` dans Hero, Team, FAQ, Sponsors, Newsletter, About, Applications, Statistics, HowItWorks, Services. +- **Problème :** `rgba(166, 95%, 29%, 0.4)` est invalide (composantes HSL dans rgba) → tous les box-shadow/glow/gradients inline échouent silencieusement. Cas aggravé : `src/components/Statistics.tsx:117` fait `hsl(var(--primary-rgb))`. +- **Correction :** convertir en vrais RGB : `--primary-rgb: 4, 144, 109;` (hsl 166 95% 29%) et `--secondary-rgb: 250, 217, 55;` (hsl 50 96% 59%) — recalculer précisément. Corriger Statistics.tsx:117 (utiliser `rgba(var(--primary-rgb), 1)` ou `hsl(var(--primary))`). +- **Vérification :** contrôle visuel des glows au hover (cartes Team, FAQ, sponsors). + +### B3 — Formulaire newsletter factice : promet un email qui n'arrive jamais 🔴 +- **Fichier :** `src/containers/Newsletter.tsx` lignes 13–27. +- **Problème :** `handleSubmit` = `setTimeout` 1,5 s + `console.log(email)` + message « Thanks for subscribing! Check your email ». Aucune collecte réelle. Input sans `type="email"` ni `required` ni validation. `e: any` = la seule erreur ESLint du repo (`npm run lint` échoue à cause d'elle). +- **Correction :** brancher un vrai backend (à décider avec l'équipe : Mailchimp/Buttondown/API maison/Google Form). En attendant la décision : a minima typer `React.FormEvent`, `type="email"` + `required`, et retirer le faux message de succès (ou le remplacer par un lien direct vers le canal d'inscription réel). +- **Vérification :** `npm run lint` passe ; une soumission aboutit à une inscription réelle vérifiable. + +### B4 — i18n non fonctionnelle : le bouton FR/EN ne traduit rien 🔴 — ✅ traité (court terme) +- **Fichiers :** `src/i18n.ts` (une seule clé `welcome`, jamais utilisée), `src/components/language.tsx` (le switcher), tous les containers (textes anglais en dur), `index.html` (`lang="en"` figé). `i18next-browser-languagedetector` est installé mais non branché. +- **Problème :** le bouton change l'état et localStorage mais rien ne change à l'écran — fonctionnalité visible qui ment, dans un pays majoritairement francophone. +- **Correction appliquée :** bouton `LanguageSwitcher` retiré de `src/layouts/Navbar.tsx` (mobile + desktop) tant que l'i18n réel n'est pas branché. `src/components/language.tsx` (provider/contexte/switcher) et `src/i18n.ts` restent en place, inutilisés côté UI, pour la reprise du chantier complet plus tard. +- **Reste à faire (gros chantier, découpable) :** extraire tous les textes vers `resources.en/fr`, utiliser `t()` dans chaque composant, brancher le languagedetector, mettre à jour `document.documentElement.lang` au changement, puis réintroduire le switcher. +- **Vérification :** `npm run build` OK ; plus aucun bouton FR/EN dans la navbar (mobile + desktop). + +### B5 — Liens morts et ancre cassée 🟠 +- **`src/containers/Hero.tsx:384`** : CTA principal « Explore Python » pointe `#About` mais la section a `id="about"` (ancres sensibles à la casse) → le bouton ne fait rien. Corriger en `#about`. +- **`src/layouts/Footer.tsx:59`** : lien Youtube = `"#"` → mettre la vraie URL de la chaîne ou retirer. +- **`src/layouts/Footer.tsx:53`** : lien « Community » → `#community`, id inexistant → pointer vers une section réelle ou retirer. +- **`src/containers/Sponsors.tsx:333`** : bouton « Become a Partner » sans onClick ni href → lien mailto/formulaire de contact, ou retirer. +- **Vérification :** cliquer chaque lien du site ; aucune ancre morte. + +### B6 — HTML invalide : `` imbriqué dans ` - - -
- - -
- {["4 Team member", "4 GB Storage", "Upto 6 pages"].map( - (benefit: string) => ( - - {" "} -

{benefit}

-
- ) - )} -
-
- - - {/* Service */} - - -
- -
-
- Light & dark mode - - Lorem ipsum dolor sit amet consect adipisicing elit. Consectetur - natusm. - -
-
-
- - )} - - - ); -}; \ No newline at end of file diff --git a/src/components/HeroNetwork.tsx b/src/components/HeroNetwork.tsx index 4a19d09..676bfbb 100644 --- a/src/components/HeroNetwork.tsx +++ b/src/components/HeroNetwork.tsx @@ -1,7 +1,8 @@ import { useEffect, useRef } from "react"; const COLORS = ["#fcd116", "#ce1126", "#009a44", "#ffffff"]; -const NODE_COUNT = 70; +const NODE_COUNT_DESKTOP = 55; +const NODE_COUNT_MOBILE = 28; const MAX_DIST = 130; interface Node { @@ -27,11 +28,19 @@ export default function HeroNetwork() { const ctx = canvas.getContext("2d"); if (!ctx) return; - let animId: number; + const reducedMotion = window.matchMedia( + "(prefers-reduced-motion: reduce)" + ).matches; + + let animId = 0; + let running = false; + let inView = false; let nodes: Node[] = []; const initNodes = () => { - nodes = Array.from({ length: NODE_COUNT }, () => ({ + const count = + canvas.offsetWidth < 768 ? NODE_COUNT_MOBILE : NODE_COUNT_DESKTOP; + nodes = Array.from({ length: count }, () => ({ x: Math.random() * canvas.width, y: Math.random() * canvas.height, vx: (Math.random() - 0.5) * 0.4, @@ -42,13 +51,7 @@ export default function HeroNetwork() { })); }; - const resize = () => { - canvas.width = canvas.offsetWidth; - canvas.height = canvas.offsetHeight; - initNodes(); - }; - - const draw = () => { + const drawFrame = () => { const { width: W, height: H } = canvas; ctx.clearRect(0, 0, W, H); @@ -82,23 +85,54 @@ export default function HeroNetwork() { ctx.fillStyle = hexAlpha(n.color, n.opacity); ctx.fill(); } + }; - animId = requestAnimationFrame(draw); + const loop = () => { + drawFrame(); + if (running) animId = requestAnimationFrame(loop); }; - const ro = new ResizeObserver(() => { + const stop = () => { + running = false; cancelAnimationFrame(animId); - resize(); - draw(); + }; + + // L'animation ne tourne que si le canvas est visible ET l'onglet actif (AUDIT.md P2) + const sync = () => { + const shouldRun = + inView && document.visibilityState === "visible" && !reducedMotion; + if (shouldRun && !running) { + running = true; + animId = requestAnimationFrame(loop); + } else if (!shouldRun) { + stop(); + } + }; + + const resize = () => { + canvas.width = canvas.offsetWidth; + canvas.height = canvas.offsetHeight; + initNodes(); + if (!running) drawFrame(); + }; + + const io = new IntersectionObserver(([entry]) => { + inView = entry.isIntersecting; + sync(); }); + io.observe(canvas); + + document.addEventListener("visibilitychange", sync); + const ro = new ResizeObserver(resize); ro.observe(canvas); resize(); - draw(); return () => { - cancelAnimationFrame(animId); + stop(); + io.disconnect(); ro.disconnect(); + document.removeEventListener("visibilitychange", sync); }; }, []); @@ -109,4 +143,4 @@ export default function HeroNetwork() { className="absolute inset-0 z-0" /> ); -} \ No newline at end of file +} diff --git a/src/components/Icons.tsx b/src/components/Icons.tsx index 77c0144..b07e867 100644 --- a/src/components/Icons.tsx +++ b/src/components/Icons.tsx @@ -1,16 +1,11 @@ -import { logoLight, logoDark } from "@/assets"; +import { logo } from "@/assets"; export const LogoIcon = () => { return ( - <> -
- Python Cameroon -
- -
- Python Cameroon -
- + + Python Cameroon + Python Cameroon + ); }; diff --git a/src/components/PyConBanner.tsx b/src/components/PyConBanner.tsx new file mode 100644 index 0000000..75f5acb --- /dev/null +++ b/src/components/PyConBanner.tsx @@ -0,0 +1,103 @@ +import { useEffect, useRef, useState } from "react"; +import { PartyPopper } from "lucide-react"; + +const PYCON_URL = "https://cm.pycon.org"; +const EVENT_START = new Date("2026-09-17T00:00:00+01:00"); + +interface TimeLeft { + days: number; + hours: number; + minutes: number; + seconds: number; +} + +const getTimeLeft = (): TimeLeft => { + const diff = Math.max(0, EVENT_START.getTime() - Date.now()); + return { + days: Math.floor(diff / (1000 * 60 * 60 * 24)), + hours: Math.floor((diff / (1000 * 60 * 60)) % 24), + minutes: Math.floor((diff / (1000 * 60)) % 60), + seconds: Math.floor((diff / 1000) % 60), + }; +}; + +const TimeUnit = ({ value, label }: { value: number; label: string }) => ( +
+ + {String(value).padStart(2, "0")} + + + {label} + +
+); + +export const PyConBanner = () => { + const rootRef = useRef(null); + const [timeLeft, setTimeLeft] = useState(getTimeLeft); + + useEffect(() => { + const interval = setInterval(() => setTimeLeft(getTimeLeft()), 1000); + return () => clearInterval(interval); + }, []); + + const visible = Date.now() < EVENT_START.getTime(); + + useEffect(() => { + const el = rootRef.current; + if (!visible || !el) { + document.documentElement.style.setProperty("--pycon-banner-height", "0px"); + return; + } + + const updateHeight = () => + document.documentElement.style.setProperty( + "--pycon-banner-height", + `${el.offsetHeight}px` + ); + + updateHeight(); + const observer = new ResizeObserver(updateHeight); + observer.observe(el); + + return () => { + observer.disconnect(); + document.documentElement.style.setProperty("--pycon-banner-height", "0px"); + }; + }, [visible]); + + if (!visible) return null; + + return ( +
+ + + + PyCon Cameroon 2026 · Sept 17–19 + + +
+ + : + + : + + : + +
+ + + Learn more → + +
+
+ ); +}; diff --git a/src/components/ScrollToTop.tsx b/src/components/ScrollToTop.tsx index 8a21c75..73973e9 100644 --- a/src/components/ScrollToTop.tsx +++ b/src/components/ScrollToTop.tsx @@ -6,13 +6,11 @@ export const ScrollToTop = () => { const [showTopBtn, setShowTopBtn] = useState(false); useEffect(() => { - window.addEventListener("scroll", () => { - if (window.scrollY > 400) { - setShowTopBtn(true); - } else { - setShowTopBtn(false); - } - }); + const handleScroll = () => { + setShowTopBtn(window.scrollY > 400); + }; + window.addEventListener("scroll", handleScroll, { passive: true }); + return () => window.removeEventListener("scroll", handleScroll); }, []); const goToTop = () => { diff --git a/src/components/Statistics.tsx b/src/components/Statistics.tsx index 6c7e2b8..abf0849 100644 --- a/src/components/Statistics.tsx +++ b/src/components/Statistics.tsx @@ -1,5 +1,6 @@ import { useState, useRef, useEffect } from "react"; -import { motion, useInView } from "framer-motion"; +import { m, useInView } from "framer-motion"; +import { GlowBackground } from "@/components/section/GlowBackground"; export const Statistics = () => { const sectionRef = useRef(null); @@ -49,24 +50,24 @@ export const Statistics = () => { if (counterInView) { let startTime: number; const duration = 2000; // 2 seconds - + const animateCount = (timestamp: number) => { if (!startTime) startTime = timestamp; const progress = Math.min((timestamp - startTime) / duration, 1); - + // Easing function for smooth animation const easeOutQuart = 1 - Math.pow(1 - progress, 4); const currentCount = Math.floor(easeOutQuart * targetNumber); - + setCount(currentCount); - + if (progress < 1) { requestAnimationFrame(animateCount); } else { setCount(targetNumber); } }; - + setTimeout(() => { requestAnimationFrame(animateCount); }, delay); @@ -84,52 +85,42 @@ export const Statistics = () => { }; return ( - - {formatNumber(count, suffix)} - - + + {/* Glowing effect behind numbers */} - - + ); }; return ( - { transition={{ duration: 0.8 }} className="relative" > - {/* Background glow effects */} -
- - - -
+
{stats.map(({ description, targetNumber, suffix }: statsProps, index) => ( - {/* Card background with hover effect */} - - - {/* Animated border on hover */} - - - - {description} - + {/* Subtle underline animation on hover */} - - - + + ))}
-
+ ); }; \ No newline at end of file diff --git a/src/components/language.tsx b/src/components/language.tsx index c7c04af..b22cb5a 100644 --- a/src/components/language.tsx +++ b/src/components/language.tsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useState, useMemo, ReactNode, useEffect } from "react"; +import { createContext, useCallback, useContext, useState, useMemo, ReactNode, useEffect } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; @@ -18,15 +18,15 @@ export const LanguageProvider = ({ children }: { children: ReactNode }) => { i18n.changeLanguage(language); }, [language, i18n]); - const toggleLanguage = () => { + const toggleLanguage = useCallback(() => { const newLang = language === "en" ? "fr" : "en"; i18n.changeLanguage(newLang).then(() => { setLanguage(newLang); localStorage.setItem("selectedLanguage", newLang); }); - }; + }, [language, i18n]); - const contextValue = useMemo(() => ({ language, toggleLanguage }), [language]); + const contextValue = useMemo(() => ({ language, toggleLanguage }), [language, toggleLanguage]); return ( diff --git a/src/components/section/GlowBackground.tsx b/src/components/section/GlowBackground.tsx new file mode 100644 index 0000000..6988b37 --- /dev/null +++ b/src/components/section/GlowBackground.tsx @@ -0,0 +1,41 @@ +// Fond décoratif commun aux sections (AUDIT.md Q1) : halos flous statiques +// (rendus statiques lors de P2) + motif de grille optionnel. + +export interface GlowBlob { + /** Position, taille et couleur Tailwind, ex. "top-10 left-1/4 w-72 h-72 bg-primary/10" */ + className: string; + /** Rayon de flou en px (défaut 100) */ + blur?: number; + /** Opacité inline ; omise si la couleur porte déjà son opacité (bg-primary/10) */ + opacity?: number; +} + +const GRID_PATTERN = + "url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAgMCA2MCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjxwYXRoIGZpbGw9IiMyMDIwMjAiIGQ9Ik0wIDBoNjB2NjBIMHoiLz48cGF0aCBkPSJNNjAgMzBjMCAxNi41Ny0xMy40MyAzMC0zMCAzMFMwIDQ2LjU3IDAgMzAgMTMuNDMgMCAzMCAwczMwIDEzLjQzIDMwIDMweiIgc3Ryb2tlPSIjZmZmZmZmMDUiIHN0cm9rZS13aWR0aD0iLjUiLz48cGF0aCBkPSJNMTI5LjUgMTB2MTQwTTEyOSAyOWgtMTI5TTE0My41IDI5aC0xNC41IiBzdHJva2U9IiNmZmZmZmYwNSIgc3Ryb2tlLXdpZHRoPSIuNSIvPjwvZz48L3N2Zz4=')"; + +interface GlowBackgroundProps { + blobs: GlowBlob[]; + /** Affiche le motif de grille à l'opacité donnée (ex. 0.02) */ + gridOpacity?: number; + /** Classes additionnelles du conteneur (ex. "overflow-hidden") */ + className?: string; +} + +export const GlowBackground = ({ blobs, gridOpacity, className = "" }: GlowBackgroundProps) => ( +
+ {blobs.map(({ className: blobClassName, blur = 100, opacity }, i) => ( +
+ ))} + + {gridOpacity !== undefined && ( +
+ )} +
+); diff --git a/src/components/section/SectionHeader.tsx b/src/components/section/SectionHeader.tsx new file mode 100644 index 0000000..c19de93 --- /dev/null +++ b/src/components/section/SectionHeader.tsx @@ -0,0 +1,92 @@ +import { m } from "framer-motion"; +import type { ReactNode } from "react"; + +// En-tête de section commun (AUDIT.md Q1) : badge optionnel, titre h2, +// divider animé et sous-titre. Le dégradé de titre s'obtient en enveloppant +// la partie mise en avant dans . + +/** Partie de titre en dégradé primary→secondary avec halo flou derrière. */ +export const TitleGradient = ({ children }: { children: ReactNode }) => ( + + + {children} + + + +); + +interface SectionBadge { + icon?: ReactNode; + text: string; + iconRight?: ReactNode; +} + +interface SectionHeaderProps { + /** Contrôle l'animation d'entrée (useInView de la section parente) */ + isInView: boolean; + title: ReactNode; + badge?: SectionBadge; + subtitle?: ReactNode; + /** Largeur finale du divider en px (défaut 100) */ + dividerWidth?: number; + titleClassName?: string; + subtitleClassName?: string; +} + +export const SectionHeader = ({ + isInView, + title, + badge, + subtitle, + dividerWidth = 100, + titleClassName = "text-3xl md:text-4xl font-bold relative", + subtitleClassName = "text-lg text-muted-foreground mt-6 max-w-2xl mx-auto", +}: SectionHeaderProps) => ( + + {badge && ( + + {badge.icon} + {badge.text} + {badge.iconRight} + + )} + +

{title}

+ + + + {subtitle && ( + + {subtitle} + + )} + +); diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx deleted file mode 100644 index fda1c3d..0000000 --- a/src/components/ui/avatar.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import * as React from "react"; -import * as AvatarPrimitive from "@radix-ui/react-avatar"; - -import { cn } from "@/lib/utils"; - -const Avatar = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -Avatar.displayName = AvatarPrimitive.Root.displayName; - -const AvatarImage = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -AvatarImage.displayName = AvatarPrimitive.Image.displayName; - -const AvatarFallback = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName; - -export { Avatar, AvatarImage, AvatarFallback }; diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx deleted file mode 100644 index f000e3e..0000000 --- a/src/components/ui/badge.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import * as React from "react" -import { cva, type VariantProps } from "class-variance-authority" - -import { cn } from "@/lib/utils" - -const badgeVariants = cva( - "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", - { - variants: { - variant: { - default: - "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", - secondary: - "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", - destructive: - "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", - outline: "text-foreground", - }, - }, - defaultVariants: { - variant: "default", - }, - } -) - -export interface BadgeProps - extends React.HTMLAttributes, - VariantProps {} - -function Badge({ className, variant, ...props }: BadgeProps) { - return ( -
- ) -} - -export { Badge, badgeVariants } diff --git a/src/containers/About.tsx b/src/containers/About.tsx index 0f9c1cc..c211f57 100644 --- a/src/containers/About.tsx +++ b/src/containers/About.tsx @@ -1,6 +1,7 @@ import { useState, useRef } from "react"; -import { motion, useInView, AnimatePresence } from "framer-motion"; +import { m, useInView } from "framer-motion"; import { Statistics } from "@/components/Statistics"; +import { GlowBackground } from "@/components/section/GlowBackground"; import pilot from "@/assets/pilot.png"; export const About = () => { @@ -8,13 +9,13 @@ export const About = () => { const imageRef = useRef(null); const contentRef = useRef(null); const [isHovered, setIsHovered] = useState(false); - - const isInView = useInView(sectionRef, { once: false, amount: 0.3 }); - const imageInView = useInView(imageRef, { once: false, amount: 0.4 }); - const contentInView = useInView(contentRef, { once: false, amount: 0.3 }); + + const isInView = useInView(sectionRef, { once: true, amount: 0.3 }); + const imageInView = useInView(imageRef, { once: true, amount: 0.4 }); + const contentInView = useInView(contentRef, { once: true, amount: 0.3 }); return ( - { animate={{ opacity: 1 }} exit={{ opacity: 0 }} > - {/* Animated background elements */} -
- - - + - {/* Floating particles */} - - {isInView && ( - <> - {Array.from({ length: 8 }).map((_, i) => ( - - ))} - - )} - -
- - { onMouseEnter={() => setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} > - - {/* Animated border glow */} - { transition={{ duration: 2, repeat: isHovered ? Infinity : 0 }} /> - {/* Moving gradient overlay */} - - +
{/* Enhanced image with 3D effects */} - - - - - - + + {/* Enhanced content section */} - - {/* Enhanced heading with staggered animation */} - - { }} > About{" "} - - - + + + {/* Add content, stats, or text here */} - +
-
-
+ + ); }; diff --git a/src/containers/Applications.tsx b/src/containers/Applications.tsx index 53ca2fa..de12c26 100644 --- a/src/containers/Applications.tsx +++ b/src/containers/Applications.tsx @@ -1,266 +1,53 @@ import { useState, useRef } from "react"; -import { motion, useInView, AnimatePresence } from "framer-motion"; -import { Card, CardHeader, CardTitle } from "@/components/ui/card"; -import { Code2, Database, Brain, Zap, Shield, Gamepad2, Sparkles, ArrowRight, Info } from "lucide-react"; - -interface ApplicationProps { - image: string; - title: string; - description: string; - icon: React.ReactNode; - techStack: string[]; - color: { - primary: string; - secondary: string; - accent: string; - }; -} +import { m, useInView, AnimatePresence } from "framer-motion"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Sparkles, ArrowRight } from "lucide-react"; +import { GlowBackground } from "@/components/section/GlowBackground"; +import { SectionHeader, TitleGradient } from "@/components/section/SectionHeader"; +import { applicationsData } from "@/data/applications"; export const Applications = () => { const [hovered, setHovered] = useState(null); - const [, setActiveCard] = useState(null); const sectionRef = useRef(null); - const isInView = useInView(sectionRef, { once: false, amount: 0.2 }); - - const applications: ApplicationProps[] = [ - { - image: "https://images.unsplash.com/photo-1467232004584-a241de8bcf5d?q=80&w=2069&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", - title: "Web Development", - description: "Python is widely used for building websites with frameworks like Django and Flask. Create powerful, scalable web applications with clean, maintainable code.", - icon: , - techStack: ["Django", "Flask", "FastAPI", "SQLAlchemy"], - color: { - primary: "from-blue-500 to-cyan-500", - secondary: "bg-blue-500/10", - accent: "border-blue-500/30" - } - }, - { - image: "https://plus.unsplash.com/premium_photo-1661878265739-da90bc1af051?q=80&w=1986&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", - title: "Data Science", - description: "Python is the go-to language for data analysis, visualization, and manipulation using Pandas and NumPy. Transform raw data into actionable insights.", - icon: , - techStack: ["Pandas", "NumPy", "Matplotlib", "Jupyter"], - color: { - primary: "from-green-500 to-emerald-500", - secondary: "bg-green-500/10", - accent: "border-green-500/30" - } - }, - { - image: "https://plus.unsplash.com/premium_photo-1681121353159-3278949ff491?q=80&w=1935&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", - title: "Machine Learning & AI", - description: "Python is essential in AI with libraries like TensorFlow and scikit-learn. Build intelligent systems that learn and adapt.", - icon: , - techStack: ["TensorFlow", "PyTorch", "Scikit-learn", "OpenCV"], - color: { - primary: "from-secondary to-pink-500", - secondary: "bg-secondary/10", - accent: "border-secondary/30" - } - }, - { - image: "https://plus.unsplash.com/premium_photo-1676637656198-e2bbf752103a?q=80&w=1887&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", - title: "Automation & Scripting", - description: "Automate repetitive tasks using Python scripts, Selenium, and BeautifulSoup. Increase productivity and eliminate manual work.", - icon: , - techStack: ["Selenium", "BeautifulSoup", "Requests", "Celery"], - color: { - primary: "from-yellow-500 to-orange-500", - secondary: "bg-yellow-500/10", - accent: "border-yellow-500/30" - } - }, - { - image: "https://plus.unsplash.com/premium_photo-1661877737564-3dfd7282efcb?q=80&w=1800&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", - title: "Cybersecurity", - description: "Python is used in ethical hacking, penetration testing, and security analysis. Protect digital assets with powerful security tools.", - icon: , - techStack: ["Scapy", "Nmap", "Metasploit", "Wireshark"], - color: { - primary: "from-red-500 to-rose-500", - secondary: "bg-red-500/10", - accent: "border-red-500/30" - } - }, - { - image: "https://images.unsplash.com/photo-1486572788966-cfd3df1f5b42?q=80&w=2072&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", - title: "Game Development", - description: "Python is used to create games with frameworks like Pygame and Panda3D. Build engaging interactive experiences and simulations.", - icon: , - techStack: ["Pygame", "Panda3D", "Arcade", "Kivy"], - color: { - primary: "from-indigo-500 to-violet-500", - secondary: "bg-indigo-500/10", - accent: "border-indigo-500/30" - } - }, - ]; + const isInView = useInView(sectionRef, { once: true, amount: 0.2 }); return ( - - {/* Animated background elements */} -
- - - - - {/* Grid pattern overlay */} -
- - {/* Floating particles */} - - {isInView && ( - <> - {Array.from({ length: 12 }).map((_, i) => ( - - ))} - - )} - -
- - {/* Section header */} - - - Explore - - Python's Applications - - - {/* Glowing effect behind text */} - - - - - - - Python is used in various fields, from web development to artificial intelligence. -
- - Hover over each section to learn more. - - - {/* Animated underline */} - -
-
+ + + + Explore Python's Applications + + } + subtitle="Python is used in various fields, from web development to artificial intelligence." + dividerWidth={150} + /> {/* Applications grid */} - - {applications.map(({ image, title, description, icon, techStack, color }, index) => ( - ( + { type: "spring", stiffness: 100 }} - onMouseEnter={() => { - setHovered(index); - setActiveCard(index); - }} - onMouseLeave={() => { - setHovered(null); - setActiveCard(null); - }} + onMouseEnter={() => setHovered(index)} + onMouseLeave={() => setHovered(null)} > + {/* Halos hors de la Card : dedans, son overflow-hidden + son contexte + d'empilement les faisaient flotter AU-DESSUS du fond de carte et + rendaient le titre illisible au survol. Ici ils passent derrière + la carte et ne forment qu'un halo autour. */} + + + + - {/* Background glow effect */} - - - {/* Rotating gradient border */} - - {/* Image section with overlay effects */}
- { /> {/* Icon overlay */} - { type: "spring" }} > - { rotate: { duration: 0.5 } }} > - {icon} - - + + + {/* Shine effect */} - { }} /> - {/* Tech stack badges */} - - {hovered === index && ( - - {techStack.slice(0, 3).map((tech, techIndex) => ( - - {tech} - - ))} - - )} -
{/* Content section */} - + - {title} - + - { transition={{ duration: 0.3 }} > - + {/* Sparkle animation */} {hovered === index && ( - - +
)} -
+ - {/* Expandable description overlay */} - - {hovered === index && ( - - +

+ {description} +

+ +
+ {techStack.map((tech) => ( + -

{title}

-

- {description} -

- - {/* All tech stack */} -
- {techStack.map((tech, techIndex) => ( - - {tech} - - ))} -
- - - )} - + {tech} +
+ ))} +
+ -
+ ))} -
+ {/* Bottom call-to-action */} - - - + Ready to start your Python journey? - - + - - Join Python Cameroon - - - Join Python Cameroon + - - - - + + + + ); }; diff --git a/src/containers/FAQ.tsx b/src/containers/FAQ.tsx index 58f1842..17981b8 100644 --- a/src/containers/FAQ.tsx +++ b/src/containers/FAQ.tsx @@ -1,62 +1,24 @@ import { useState, useRef } from "react"; -import { motion, useInView, AnimatePresence } from "framer-motion"; -import { ChevronDown, MessageCircle, HelpCircle, Sparkles } from "lucide-react"; - -interface FAQProps { - question: string; - answer: string; - value: string; -} - -const FAQList: FAQProps[] = [ - { - question: "What is Python Cameroon?", - answer: - "Python Cameroon is a vibrant community dedicated to fostering Python development, networking, and collaboration among developers in Cameroon. We provide mentorship, resources, and events to support Python enthusiasts at all levels.", - value: "item-1", - }, - { - question: "How can I join the Python Cameroon community?", - answer: - "You can join Python Cameroon by connecting with us on our social media channels, participating in our meetups, and engaging in discussions on our online platforms such as Discord and GitHub.", - value: "item-2", - }, - { - question: "Does Python Cameroon offer mentorship programs?", - answer: - "Yes! We have mentorship programs where experienced Python developers guide beginners through learning Python, contributing to open-source projects, and career growth.", - value: "item-3", - }, - { - question: "Are there Python meetups or events in Cameroon?", - answer: - "Absolutely! We organize regular meetups, workshops, and hackathons where developers can network, collaborate on projects, and improve their Python skills.", - value: "item-4", - }, - { - question: "How can I contribute to Python Cameroon?", - answer: - "You can contribute by sharing knowledge, mentoring beginners, participating in projects, and helping organize events. Contributions to our GitHub repositories and volunteering at community events are also highly appreciated!", - value: "item-5", - }, -]; +import { m, useInView } from "framer-motion"; +import { MessageCircle, HelpCircle, Sparkles } from "lucide-react"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { GlowBackground } from "@/components/section/GlowBackground"; +import { SectionHeader, TitleGradient } from "@/components/section/SectionHeader"; +import { faqData } from "@/data/faq"; +import type { FAQItemProps } from "@/types/sections"; export const FAQ = () => { - const [openItems, setOpenItems] = useState([]); const [hoveredItem, setHoveredItem] = useState(null); const sectionRef = useRef(null); - const isInView = useInView(sectionRef, { once: false, amount: 0.2 }); - - const toggleItem = (value: string) => { - setOpenItems(prev => - prev.includes(value) - ? prev.filter(item => item !== value) - : [...prev, value] - ); - }; + const isInView = useInView(sectionRef, { once: true, amount: 0.2 }); return ( - { animate={{ opacity: 1 }} exit={{ opacity: 0 }} > - {/* Animated background elements */} -
- - - - - {/* Floating particles */} - - {isInView && Array.from({ length: 8 }).map((_, i) => ( - - ))} - -
- - {/* Section header */} - - - - Got Questions? - - + - - Frequently Asked{" "} - - Questions - {/* Glowing effect behind text */} - - - - - {/* Animated divider */} - + , + text: "Got Questions?", + iconRight: , + }} + title={ + <> + Frequently Asked Questions + + } + titleClassName="text-3xl md:text-4xl lg:text-5xl font-bold relative" + subtitle="Everything you need to know about joining and contributing to our community" + /> - - Everything you need to know about joining and contributing to our community - - - - {/* FAQ Accordion */} - - {FAQList.map(({ question, answer, value }: FAQProps, index) => { - const isOpen = openItems.includes(value); - const isHovered = hoveredItem === value; - - return ( - setHoveredItem(value)} - onMouseLeave={() => setHoveredItem(null)} - > - {/* Animated border gradient */} - + + {faqData.map(({ question, answer, value }: FAQItemProps, index) => { + const isHovered = hoveredItem === value; - setHoveredItem(value)} + onMouseLeave={() => setHoveredItem(null)} > - {/* Question header */} - toggleItem(value)} - whileTap={{ scale: 0.98 }} + {/* Animated border gradient */} + + + -
- + + + + + + + + {question} + + + + + +
+ {answer} +
+
+ + + {/* Subtle glow effect */} + {isHovered && ( + - - - -
- - - {question} - -
- - - - -
- - {/* Answer content */} - - {isOpen && ( - - -
- {answer} -
-
-
+ /> )} -
- - {/* Subtle glow effect */} - {isHovered && ( - - )} - -
- ); - })} -
+ + + ); + })} + + {/* Contact section */} - - Still have questions? - Contact us - - - -
+ + + + ); }; diff --git a/src/containers/Hero.tsx b/src/containers/Hero.tsx index 3cc34f0..57c5c1f 100644 --- a/src/containers/Hero.tsx +++ b/src/containers/Hero.tsx @@ -1,120 +1,13 @@ import { useState, useEffect, useRef } from "react"; -import { - motion, - useAnimation, - useMotionValue, - useTransform, - AnimatePresence, -} from "framer-motion"; +import { m, useAnimation, useMotionValue, useTransform } from "framer-motion"; import { cameroonFlag } from "@/assets"; import HeroNetwork from "@/components/HeroNetwork"; -// Define interfaces for component props -interface AnimatedCharacterProps { - character: string; - delay?: number; -} - -interface SplitTextAnimationProps { - text: string; - className?: string; - delay?: number; -} - interface EnhancedImageProps { src: string; alt: string; } -// Animated character component for text effects -const AnimatedCharacter = ({ - character, - delay = 0, -}: AnimatedCharacterProps) => { - return ( - - {character} - - ); -}; - -// Split text into animated characters -const SplitTextAnimation = ({ - text, - className = "", - delay = 0, -}: SplitTextAnimationProps) => { - return ( - - {text.split("").map((char: string, index: number) => ( - - ))} - - ); -}; - -// Futuristic bubble background effect -const BubbleBackground = () => { - return ( -
- {[...Array(10)].map((_, i) => ( - - ))} -
- ); -}; - // Enhanced image component with effects const EnhancedImage = ({ src, alt }: EnhancedImageProps) => { const imageRef = useRef(null); @@ -142,7 +35,7 @@ const EnhancedImage = ({ src, alt }: EnhancedImageProps) => { }; return ( - { }} whileHover={{ scale: 1.02 }} > - { transformStyle: "preserve-3d", }} > - { animate={{ scale: 1 }} transition={{ duration: 1.5 }} /> - { ), }} /> - - + + ); }; @@ -215,43 +108,17 @@ export const Hero = () => { }; }, [controls]); - // Text animation sequence - const titleVariants = { - hidden: { opacity: 0 }, - visible: { - opacity: 1, - transition: { - staggerChildren: 0.1, - delayChildren: 0.3, - }, - }, - }; - - const wordVariants = { - hidden: { y: 20, opacity: 0 }, - visible: { - y: 0, - opacity: 1, - transition: { - type: "spring", - damping: 12, - }, - }, - }; - return ( - - - {/* Floating grid lines effect */}
{[...Array(7)].map((_, i) => ( - { /> ))} {[...Array(5)].map((_, i) => ( - {
{/* Text content with animations */} - - -

- - - - {" "} - - is - -

{" "} -

- - - - -

-
+
+ {/* Un seul h1 pour la page — deux lignes en span.block (AUDIT.md A5). + Entrée par révélation de ligne : chaque ligne glisse depuis le bas + derrière un masque overflow-hidden, sans effet lettre par lettre. */} +

+ + + + Python + + {" "} + is + + + + + + Fun! + + + + +

+
- { ease: "easeOut", }} > - + Unleash your creativity with Python. Whether it's building web apps, automating tasks, or exploring AI – Python makes it all possible! - + - {/* Animated underline */} - est invalide dans un

*/} + - + {/* Floating action buttons */} - - Explore Python - - + - - - Newsletter - - - - - + Contact Us + +

+ + + {/* Cameroon Map Display with enhanced effects */}
- - - - + +
- + ); }; diff --git a/src/containers/HowItWorks.tsx b/src/containers/HowItWorks.tsx index bad67bb..936cade 100644 --- a/src/containers/HowItWorks.tsx +++ b/src/containers/HowItWorks.tsx @@ -1,7 +1,9 @@ import { useRef } from "react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { MedalIcon, MapIcon, PlaneIcon, GiftIcon } from "@/components/Icons"; -import { motion, useInView, AnimatePresence } from "framer-motion"; +import { m, useInView, AnimatePresence } from "framer-motion"; +import { GlowBackground } from "@/components/section/GlowBackground"; +import { SectionHeader, TitleGradient } from "@/components/section/SectionHeader"; interface FeatureProps { icon: JSX.Element; @@ -42,14 +44,14 @@ interface AnimatedFeatureCardProps extends FeatureProps { const AnimatedFeatureCard = ({ icon, title, description, index }: AnimatedFeatureCardProps) => { const cardRef = useRef(null); - const isInView = useInView(cardRef, { once: false, amount: 0.2 }); - + const isInView = useInView(cardRef, { once: true, amount: 0.2 }); + return ( - - - + - - - +
{icon} - - - - + + + {title} - + - + - {description} - + - - - - - + ); }; export const HowItWorks = () => { const sectionRef = useRef(null); - const isInView = useInView(sectionRef, { once: false, amount: 0.1 }); - + const isInView = useInView(sectionRef, { once: true, amount: 0.1 }); + return ( - { animate={{ opacity: 1 }} exit={{ opacity: 0 }} > + +
- - -
{Array.from({ length: 7 }).map((_, i) => ( - { /> ))} {Array.from({ length: 5 }).map((_, i) => ( - { ))}
- - - - How It{" "} - - - Works - - - {" "} - Step-by-Step Guide - - - - - - - Explore the power of Python through accessibility, community, scalability, and gamification. - - + How It Works Step-by-Step Guide + + } + subtitle="Explore the power of Python through accessibility, community, scalability, and gamification." + subtitleClassName="md:w-3/4 text-lg text-muted-foreground mt-4 mx-auto" + dividerWidth={80} + /> + + { /> ))} - -
+ + ); }; diff --git a/src/containers/Newsletter.tsx b/src/containers/Newsletter.tsx index 89acccc..b620120 100644 --- a/src/containers/Newsletter.tsx +++ b/src/containers/Newsletter.tsx @@ -1,33 +1,34 @@ -import { useState, useRef } from "react"; +import { useState, useRef, type FormEvent } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { motion, useInView, AnimatePresence } from "framer-motion"; +import { m, useInView, AnimatePresence } from "framer-motion"; +import { GlowBackground } from "@/components/section/GlowBackground"; +import { SectionHeader, TitleGradient } from "@/components/section/SectionHeader"; + +// Pas encore de backend newsletter (voir AUDIT.md B3) : on redirige vers un +// canal d'inscription réel plutôt que de simuler un envoi qui n'aboutit nulle part. +const COMMUNITY_URL = "https://discord.gg/TWVCKCe3Dt"; export const Newsletter = () => { const [email, setEmail] = useState(""); - const [isSubmitting, setIsSubmitting] = useState(false); const [isSuccess, setIsSuccess] = useState(false); const sectionRef = useRef(null); - const isInView = useInView(sectionRef, { once: false, amount: 0.3 }); - - const handleSubmit = (e: any) => { + const isInView = useInView(sectionRef, { once: true, amount: 0.3 }); + + const handleSubmit = (e: FormEvent) => { e.preventDefault(); - setIsSubmitting(true); - + + window.open(COMMUNITY_URL, "_blank", "noopener,noreferrer"); + setEmail(""); + setIsSuccess(true); + setTimeout(() => { - console.log("Subscribed with:", email); - setIsSubmitting(false); - setIsSuccess(true); - setEmail(""); - - setTimeout(() => { - setIsSuccess(false); - }, 3000); - }, 1500); + setIsSuccess(false); + }, 3000); }; return ( - { exit={{ opacity: 0 }} className="relative overflow-hidden" > - { />
-
- - - - - - {isInView && ( - <> - {Array.from({ length: 6 }).map((_, i) => ( - - ))} - - )} - -
+ - - - Join Our Daily{" "} - - - Newsletter - - - - - - - - - - Stay updated with Python Cameroon Community. - - + + Join Our Daily Newsletter + + } + titleClassName="text-4xl md:text-5xl font-bold relative" + subtitle="Stay updated with Python Cameroon Community." + subtitleClassName="text-lg text-muted-foreground mt-6 max-w-2xl mx-auto" + dividerWidth={80} + /> - { }} className="perspective-1000 relative" > - @@ -212,21 +88,9 @@ export const Newsletter = () => { onSubmit={handleSubmit} >
- - { {email && ( - { > - + )}
- - - + -
+ {isSuccess && ( - - Thanks for subscribing! Check your email for updates. - + Opening our Discord in a new tab — join to get updates! + )} - {["Updates", "Events", "Tutorials", "Community"].map((tag, i) => ( - { }} > {tag} - + ))} - +
- -
+ ); }; diff --git a/src/containers/Services.tsx b/src/containers/Services.tsx index 4a75427..9c22a9e 100644 --- a/src/containers/Services.tsx +++ b/src/containers/Services.tsx @@ -2,7 +2,9 @@ import { useRef } from "react"; import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { ChartIcon, WalletIcon, MagnifierIcon } from "@/components/Icons"; import cubeLeg from "../assets/cube-leg.png"; -import { motion, useInView, AnimatePresence } from "framer-motion"; +import { m, useInView, AnimatePresence } from "framer-motion"; +import { GlowBackground } from "@/components/section/GlowBackground"; +import { TitleGradient } from "@/components/section/SectionHeader"; interface ServiceProps { title: string; @@ -15,13 +17,13 @@ const serviceList: ServiceProps[] = [ title: "Code Collaboration", description: "Enhance team productivity with Python-based tools for version control, CI/CD, and collaborative coding.", - icon: , + icon: , }, { title: "Project Management", description: "Streamline workflows with Python-powered task automation, reporting, and data visualization.", - icon: , + icon: , }, { title: "Task Automation", @@ -33,169 +35,117 @@ const serviceList: ServiceProps[] = [ export const Services = () => { const sectionRef = useRef(null); - const isInView = useInView(sectionRef, { once: false, amount: 0.2 }); - + const isInView = useInView(sectionRef, { once: true, amount: 0.2 }); + return ( - -
- - -
- - + + - - - - Client-Centric - - {" "} - Services - +

+ Client-Centric Services +

- Empowering teams with Python-driven efficiency, automation, and collaboration. - est invalide dans un

*/} + - +

{serviceList.map(({ icon, title, description }: ServiceProps, index) => ( - - - + - - - +
{icon} - - - +
+ +
- {title} - - - - + + { {description} - +
- -
-
+ ))}
-
+
- { whileHover={{ scale: 1.03 }} style={{ transformStyle: "preserve-3d" }} > - - - - {[...Array(5)].map((_, i) => ( - - ))} - - - - -
+ + + ); }; diff --git a/src/containers/Sponsors.tsx b/src/containers/Sponsors.tsx index d41a622..2773fca 100644 --- a/src/containers/Sponsors.tsx +++ b/src/containers/Sponsors.tsx @@ -1,32 +1,17 @@ import { useState, useRef } from "react"; -import { motion, useInView, AnimatePresence } from "framer-motion"; - -interface SponsorProps { - icon: string; - name: string; - link: string; -} - -const sponsors: SponsorProps[] = [ - { - icon: "https://avatars.githubusercontent.com/u/142497557?s=200&v=4", - name: "Django Cameroon", - link: "https://github.com/djangocameroon", - }, - { - icon: "https://avatars.githubusercontent.com/u/183505611?s=200&v=4", - name: "Angular Cameroon", - link: "https://github.com/ngcameroon", - }, -]; +import { m, useInView, AnimatePresence } from "framer-motion"; +import { GlowBackground } from "@/components/section/GlowBackground"; +import { SectionHeader, TitleGradient } from "@/components/section/SectionHeader"; +import { sponsorsData } from "@/data/sponsors"; +import type { SponsorProps } from "@/types/sections"; export const Sponsors = () => { const sectionRef = useRef(null); - const isInView = useInView(sectionRef, { once: false, amount: 0.2 }); + const isInView = useInView(sectionRef, { once: true, amount: 0.2 }); const [hoveredIndex, setHoveredIndex] = useState(null); return ( - { animate={{ opacity: 1 }} exit={{ opacity: 0 }} > - {/* Animated background elements */} -
- - - + - {/* Grid pattern overlay */} -
-
- - {/* Section heading */} - - - - Partnering Organisations - - - {/* Glowing effect behind text */} - - - - {/* Animated divider */} - - - {/* Subtitle with animated underline */} - - Our amazing partners who help make the Python Cameroon community thrive and grow. - - - + Partnering Organisations} + titleClassName="text-2xl md:text-3xl font-bold relative" + subtitle="Our amazing partners who help make the Python Cameroon community thrive and grow." + subtitleClassName="text-muted-foreground mt-4 max-w-md mx-auto" + dividerWidth={120} + /> {/* Sponsors gallery */} - {/* Horizontal connecting line */} - { background: "linear-gradient(to right, transparent, rgba(var(--primary-rgb), 0.3), transparent)" }} /> - + - {sponsors.map(({ icon, name, link }: SponsorProps, index) => ( - ( + { onMouseEnter={() => setHoveredIndex(index)} onMouseLeave={() => setHoveredIndex(null)} > - setHoveredIndex(index)} + onBlur={() => setHoveredIndex(null)} > {/* Logo container with effects */} - {/* Rotating background gradient */} - - + {/* Logo image */} - - { rotate: { duration: 0.5, ease: "easeInOut" } }} /> - - - {/* Pulsing glow effect on hover */} + + + {/* Glow effect on hover */} {hoveredIndex === index && ( - )} - + {/* Expanding ring effect on hover */} {hoveredIndex === index && ( - )} - - + + {/* Sponsor name with underline animation */}
- {name} - - + + {/* Animated underline */} -
- + {/* Visit text that appears on hover */} {hoveredIndex === index && ( - { transition={{ duration: 0.2 }} > Visit - - - + + )}
-
+ ))}
-
- + + {/* Call to action button */} - - - - + Become a Partner - + {/* Bottom border animation */} - - + {/* Subtle glow effect */} - - - - + + + {/* Bottom divider line */} - { background: "linear-gradient(to right, transparent, rgba(var(--primary-rgb), 0.3), transparent)" }} /> - + ); }; diff --git a/src/containers/Team.tsx b/src/containers/Team.tsx index 6921be5..2bd9af8 100644 --- a/src/containers/Team.tsx +++ b/src/containers/Team.tsx @@ -1,15 +1,16 @@ import { useState, useRef } from "react"; -import { motion, useInView, AnimatePresence } from "framer-motion"; +import { m, useInView } from "framer-motion"; import { Users, Star, - Sparkles, User, Globe, Linkedin, } from "lucide-react"; import { teamData } from "@/data/team"; import type { TeamProps } from "@/types/sections"; +import { GlowBackground } from "@/components/section/GlowBackground"; +import { SectionHeader, TitleGradient } from "@/components/section/SectionHeader"; // Enhanced image component with fallback system const ProfileImage = ({ @@ -20,7 +21,7 @@ const ProfileImage = ({ isHovered: boolean; }) => { return ( - {/* Profile Image */} {member.image ? ( - ) : ( // Default placeholder when all images fail - - + )} {/* Overlay effect on hover */} - - + ); }; export const Team = () => { const [hoveredMember, setHoveredMember] = useState(null); const sectionRef = useRef(null); - const isInView = useInView(sectionRef, { once: false, amount: 0.2 }); + const isInView = useInView(sectionRef, { once: true, amount: 0.2 }); return ( - { animate={{ opacity: 1 }} exit={{ opacity: 0 }} > - {/* Animated background elements */} -
- - - - - {/* Grid pattern overlay */} -
- - {/* Floating particles */} - - {isInView && - Array.from({ length: 10 }).map((_, i) => ( - - ))} - -
- - {/* Section header */} - - - - - Our Amazing Team - - - - - - - Meet the Python Cameroon Team - {/* Glowing effect behind text */} - - - - - {/* Animated divider */} - + - - Dedicated innovators advancing Python development in Cameroon through - collaboration and expertise. - - + , + text: "Our Amazing Team", + iconRight: , + }} + title={Meet the Python Cameroon Team} + titleClassName="text-3xl md:text-4xl lg:text-5xl font-bold relative" + subtitle="Dedicated innovators advancing Python development in Cameroon through collaboration and expertise." + dividerWidth={120} + /> {/* Team grid */} - { const isHovered = hoveredMember === member.name; return ( - { onMouseLeave={() => setHoveredMember(null)} > {/* Animated border gradient */} - - { > {" "} {/* Profile image container */} - {/* Rotating background gradient */} - { {/* Use the ProfileImage component with fallback system */} - {/* Pulsing ring effect */} - {isHovered && ( - - )} - {" "} + {" "} {/* Member info */} - - { transition={{ duration: 0.3 }} > {member.name} - + - {member.role} - - + + {/* Social links */} - {member.links.linkedIn && ( - { }} > - + )} {member.links.website && ( - { }} > - + )} - + {/* Subtle glow effect on hover */} {isHovered && ( - { transition={{ duration: 0.3 }} /> )} - {/* Sparkle effects on hover */} - - {isHovered && ( - <> - {Array.from({ length: 3 }).map((_, i) => ( - - - - ))} - - )} - - - + + ); })} - - + + ); }; diff --git a/src/data/applications.ts b/src/data/applications.ts new file mode 100644 index 0000000..039832c --- /dev/null +++ b/src/data/applications.ts @@ -0,0 +1,90 @@ +import { Code2, Database, Brain, Zap, Shield, Gamepad2 } from "lucide-react"; +import type { ApplicationProps } from "@/types/sections"; +// Images servies localement en WebP 800px au lieu d'Unsplash ~2000px (AUDIT.md P3) +import webDevelopmentImg from "@/assets/applications/web-development.webp"; +import dataScienceImg from "@/assets/applications/data-science.webp"; +import machineLearningImg from "@/assets/applications/machine-learning.webp"; +import automationImg from "@/assets/applications/automation.webp"; +import cybersecurityImg from "@/assets/applications/cybersecurity.webp"; +import gameDevelopmentImg from "@/assets/applications/game-development.webp"; + +export const applicationsData: ApplicationProps[] = [ + { + image: webDevelopmentImg, + title: "Web Development", + description: + "Python is widely used for building websites with frameworks like Django and Flask. Create powerful, scalable web applications with clean, maintainable code.", + icon: Code2, + techStack: ["Django", "Flask", "FastAPI", "SQLAlchemy"], + color: { + primary: "from-blue-500 to-cyan-500", + secondary: "bg-blue-500/10", + accent: "border-blue-500/30", + }, + }, + { + image: dataScienceImg, + title: "Data Science", + description: + "Python is the go-to language for data analysis, visualization, and manipulation using Pandas and NumPy. Transform raw data into actionable insights.", + icon: Database, + techStack: ["Pandas", "NumPy", "Matplotlib", "Jupyter"], + color: { + primary: "from-green-500 to-emerald-500", + secondary: "bg-green-500/10", + accent: "border-green-500/30", + }, + }, + { + image: machineLearningImg, + title: "Machine Learning & AI", + description: + "Python is essential in AI with libraries like TensorFlow and scikit-learn. Build intelligent systems that learn and adapt.", + icon: Brain, + techStack: ["TensorFlow", "PyTorch", "Scikit-learn", "OpenCV"], + color: { + primary: "from-secondary to-pink-500", + secondary: "bg-secondary/10", + accent: "border-secondary/30", + }, + }, + { + image: automationImg, + title: "Automation & Scripting", + description: + "Automate repetitive tasks using Python scripts, Selenium, and BeautifulSoup. Increase productivity and eliminate manual work.", + icon: Zap, + techStack: ["Selenium", "BeautifulSoup", "Requests", "Celery"], + color: { + primary: "from-yellow-500 to-orange-500", + secondary: "bg-yellow-500/10", + accent: "border-yellow-500/30", + }, + }, + { + image: cybersecurityImg, + title: "Cybersecurity", + description: + "Python is used in ethical hacking, penetration testing, and security analysis. Protect digital assets with powerful security tools.", + icon: Shield, + techStack: ["Scapy", "Nmap", "Metasploit", "Wireshark"], + color: { + primary: "from-red-500 to-rose-500", + secondary: "bg-red-500/10", + accent: "border-red-500/30", + }, + }, + { + image: gameDevelopmentImg, + title: "Game Development", + description: + "Python is used to create games with frameworks like Pygame and Panda3D. Build engaging interactive experiences and simulations.", + icon: Gamepad2, + techStack: ["Pygame", "Panda3D", "Arcade", "Kivy"], + color: { + primary: "from-indigo-500 to-violet-500", + secondary: "bg-indigo-500/10", + accent: "border-indigo-500/30", + }, + }, +]; diff --git a/src/data/faq.ts b/src/data/faq.ts new file mode 100644 index 0000000..e36347f --- /dev/null +++ b/src/data/faq.ts @@ -0,0 +1,34 @@ +import type { FAQItemProps } from "@/types/sections"; + +export const faqData: FAQItemProps[] = [ + { + question: "What is Python Cameroon?", + answer: + "Python Cameroon is a vibrant community dedicated to fostering Python development, networking, and collaboration among developers in Cameroon. We provide mentorship, resources, and events to support Python enthusiasts at all levels.", + value: "item-1", + }, + { + question: "How can I join the Python Cameroon community?", + answer: + "You can join Python Cameroon by connecting with us on our social media channels, participating in our meetups, and engaging in discussions on our online platforms such as Discord and GitHub.", + value: "item-2", + }, + { + question: "Does Python Cameroon offer mentorship programs?", + answer: + "Yes! We have mentorship programs where experienced Python developers guide beginners through learning Python, contributing to open-source projects, and career growth.", + value: "item-3", + }, + { + question: "Are there Python meetups or events in Cameroon?", + answer: + "Absolutely! We organize regular meetups, workshops, and hackathons where developers can network, collaborate on projects, and improve their Python skills.", + value: "item-4", + }, + { + question: "How can I contribute to Python Cameroon?", + answer: + "You can contribute by sharing knowledge, mentoring beginners, participating in projects, and helping organize events. Contributions to our GitHub repositories and volunteering at community events are also highly appreciated!", + value: "item-5", + }, +]; diff --git a/src/data/footer.ts b/src/data/footer.ts new file mode 100644 index 0000000..ebeab3c --- /dev/null +++ b/src/data/footer.ts @@ -0,0 +1,52 @@ +import { Github, Twitter, Linkedin, Youtube, MessageCircle, Phone } from "lucide-react"; +import type { FooterSectionProps } from "@/types/sections"; + +export const footerSections: FooterSectionProps[] = [ + { + title: "Follow US", + links: [ + { + name: "Github", + url: "https://github.com/pythoncameroon", + icon: Github, + }, + { + name: "Twitter(X)", + url: "https://x.com/pythoncameroon", + icon: Twitter, + }, + { + name: "LinkedIn", + url: "https://linkedin.com/company/PythonCameroon", + icon: Linkedin, + }, + { + name: "Youtube", + url: "https://www.youtube.com/@PythonCameroon", + icon: Youtube, + }, + ], + }, + { + title: "About", + links: [ + { name: "FAQ", url: "#faq", icon: null }, + { name: "Team", url: "#team", icon: null }, + ], + }, + { + title: "Community", + links: [ + { + name: "Discord", + url: "https://discord.gg/TWVCKCe3Dt", + icon: MessageCircle, + }, + { + name: "WhatsApp", + url: "https://chat.whatsapp.com/Ckc80ophGEH0NJFmZAzDMr", + icon: Phone, + }, + ], + }, +]; diff --git a/src/data/sponsors.ts b/src/data/sponsors.ts new file mode 100644 index 0000000..3312aeb --- /dev/null +++ b/src/data/sponsors.ts @@ -0,0 +1,14 @@ +import type { SponsorProps } from "@/types/sections"; + +export const sponsorsData: SponsorProps[] = [ + { + icon: "https://avatars.githubusercontent.com/u/142497557?s=200&v=4", + name: "Django Cameroon", + link: "https://github.com/djangocameroon", + }, + { + icon: "https://avatars.githubusercontent.com/u/183505611?s=200&v=4", + name: "Angular Cameroon", + link: "https://github.com/ngcameroon", + }, +]; diff --git a/src/data/team.ts b/src/data/team.ts index 0ee7cb9..1971b2a 100644 --- a/src/data/team.ts +++ b/src/data/team.ts @@ -11,10 +11,11 @@ export const teamData = [{ }, { name: "Edmond Makolle", - role: "Co-Lead, Mentorship / Learning Coordinator", + role: "Co-Lead, Mentorship & Learning Coordinator", image: edmond, links: { linkedIn: "https://www.linkedin.com/in/edmondmakolle", + website: "https://landing.edmondmakolle.com", } }, { diff --git a/src/index.css b/src/index.css index ebe669e..0bde030 100644 --- a/src/index.css +++ b/src/index.css @@ -1,100 +1,78 @@ -:root { - --primary-rgb: 166, 95%, 29%; - --secondary-rgb: 50, 96%, 59%; - /* For green, adjust based on your theme */ -} +@tailwind base; +@tailwind components; +@tailwind utilities; -.dark { - --primary-rgb: 166, 95%, 29%; - --secondary-rgb: 50, 96%, 59%; - /* Adjust for dark mode */ -} +/* *=========== Green theme =========== */ +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 240 10% 3.9%; + --card: 0 0% 100%; + --card-foreground: 240 10% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 240 10% 3.9%; + --primary: 166 95% 29%; + --primary-foreground: 355.7 100% 97.3%; + /* Jaune assombri en thème clair : l'ancien 50 96% 59% ne tenait que + ~1.5:1 sur fond blanc en texte/dégradé de titres (AUDIT.md A5) */ + --secondary: 50 96% 32%; + --secondary-foreground: 240 5.9% 10%; + --muted: 240 4.8% 95.9%; + --muted-foreground: 240 3.8% 46.1%; + --accent: 50 96% 59%; + --accent-foreground: 240 5.9% 10%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 240 5.9% 90%; + --input: 240 5.9% 90%; + --ring: 142.1 76.2% 36.3%; + --radius: 0.5rem; -@keyframes animateGradient { - 0% { - background-position: 0% 50% + /* Fonts */ + --font-dotgothic16: "DotGothic16", sans-serif; + --font-space-mono: "Space Mono", monospace; } - 50% { - background-position: 100% 50% + .dark { + --background: 20 14.3% 4.1%; + --foreground: 0 0% 95%; + --card: 24 9.8% 10%; + --card-foreground: 0 0% 95%; + --popover: 0 0% 9%; + --popover-foreground: 0 0% 95%; + --primary: 166 95% 29%; + --primary-foreground: 144.9 80.4% 10%; + --secondary: 50 96% 59%; + --secondary-foreground: 0 0% 98%; + --muted: 0 0% 15%; + --muted-foreground: 240 5% 64.9%; + --accent: 12 6.5% 15.1%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 85.7% 97.3%; + --border: 240 3.7% 15.9%; + --input: 240 3.7% 15.9%; + --ring: 142.4 71.8% 29.2%; } - - 100% { - background-position: 0% 50% - } -} - -.animate-gradient { - background-size: 200% 200%; - animation: animateGradient 3s ease infinite; -} - -html { - scroll-behavior: smooth; -} - -/* HeroCards background shadow */ -.shadow { - position: absolute; - background: hsl(var(--primary) / 0%); - border-radius: 24px; - rotate: 35deg; - z-index: -1; - width: 260px; - top: 200px; - height: 400px; - filter: blur(150px); - will-change: transform; - animation: shadow-slide infinite 4s linear alternate; } -@keyframes shadow-slide { - from { - background: hsl(var(--primary) / 20%); - right: 460px; +@layer base { + * { + @apply border-border; } - to { - background: hsl(var(--primary) / 80%); - right: 160px; + body { + @apply bg-background text-foreground font-space-mono; + padding-top: var(--pycon-banner-height, 0px); + transition: padding-top 0.2s ease; } -} -@media (max-width: 1024px) { - .shadow { - top: 70px; - } - - @keyframes shadow-slide { - from { - background: hsl(var(--primary) / 20%); - right: 460px; - } - - to { - background: hsl(var(--primary) / 50%); - right: 160px; - } + h1, + h2, + h3, + h4, + h5, + h6 { + @apply font-dotgothic16; } } - -@media (max-width: 768px) { - .shadow { - top: 70px; - width: 100px; - height: 350px; - filter: blur(60px); - } - - @keyframes shadow-slide { - from { - background: hsl(var(--primary) / 20%); - right: 280px; - } - - to { - background: hsl(var(--primary) / 30%); - right: 100px; - } - } -} \ No newline at end of file diff --git a/src/layouts/Footer.tsx b/src/layouts/Footer.tsx index f3b7a21..3c76014 100644 --- a/src/layouts/Footer.tsx +++ b/src/layouts/Footer.tsx @@ -1,78 +1,16 @@ import { useState, useRef } from "react"; -import { motion, useInView, AnimatePresence } from "framer-motion"; -import { - Github, - Twitter, - Linkedin, - Youtube, - MessageCircle, - Phone, - Heart, -} from "lucide-react"; +import { m, useInView, AnimatePresence } from "framer-motion"; +import { Heart } from "lucide-react"; +import { GlowBackground } from "@/components/section/GlowBackground"; +import { footerSections } from "@/data/footer"; export const Footer = () => { const footerRef = useRef(null); - const isInView = useInView(footerRef, { once: false, amount: 0.2 }); + const isInView = useInView(footerRef, { once: true, amount: 0.2 }); const [hoveredSection, setHoveredSection] = useState(null); - const socialIcons = { - Github: Github, - Twitter: Twitter, - Linkedin: Linkedin, - Youtube: Youtube, - Discord: MessageCircle, - WhatsApp: Phone, - }; - - const footerSections = [ - { - title: "Follow US", - links: [ - { - name: "Github", - url: "https://github.com/pythoncameroon", - icon: "Github", - }, - { - name: "Twitter(X)", - url: "https://x.com/pythoncameroon", - icon: "Twitter", - }, - { - name: "LinkedIn", - url: "https://linkedin.com/company/PythonCameroon", - icon: "Linkedin", - }, - ], - }, - { - title: "About", - links: [ - { name: "FAQ", url: "#faq", icon: null }, - { name: "Team", url: "#team", icon: null }, - { name: "Community", url: "#community", icon: null }, - ], - }, - { - title: "Community", - links: [ - { name: "Youtube", url: "#", icon: "Youtube" }, - { - name: "Discord", - url: "https://discord.gg/TWVCKCe3Dt", - icon: "Discord", - }, - { - name: "WhatsApp", - url: "https://chat.whatsapp.com/Ckc80ophGEH0NJFmZAzDMr", - icon: "WhatsApp", - }, - ], - }, - ]; - return ( - { animate={{ opacity: 1 }} exit={{ opacity: 0 }} > - {/* Animated background */} -
- - - - - {/* Grid pattern overlay */} -
-
+ {/* Animated top divider */} - {
{/* Logo section with enhanced animations */} - { } transition={{ duration: 0.8, delay: 0.2 }} > - - - {/* Floating particles around logo */} - - {isInView && ( - <> - {Array.from({ length: 6 }).map((_, i) => ( - - ))} - - )} - - {/* Glow effect */} - - + - Python Cameroon - - + + {/* Description */} - { > Empowering Python developers across Cameroon through community, learning, and innovation. - - + + {/* Footer sections with staggered animations */} {footerSections.map((section, sectionIndex) => ( - { onMouseLeave={() => setHoveredSection(null)} > {/* Section title with glow effect */} - { {section.title} {/* Underline animation */} - { {/* Background glow */} {hoveredSection === section.title && ( - { style={{ filter: "blur(10px)" }} /> )} - + {/* Links with hover animations */} {section.links.map((link, linkIndex) => { - const IconComponent = link.icon - ? socialIcons[link.icon as keyof typeof socialIcons] - : null; + const IconComponent = link.icon; return ( - { duration: 0.4, }} > - { transition={{ duration: 0.2 }} > {IconComponent && ( - @@ -306,73 +169,56 @@ export const Footer = () => { size={16} className="text-primary/70 group-hover:text-primary" /> - + )} {link.name} {/* Underline effect */} - {/* Hover glow effect */} - - - + + ); })} - + ))}
{/* Bottom section with animated copyright */} - - +

© {new Date().getFullYear()}{" "} Python Cameroon . All rights reserved. Built with{" "} - + - {" "} + {" "} for the community. - - +

+
{/* Bottom animated border */} - { "linear-gradient(to right, transparent, rgba(var(--primary-rgb), 0.5), transparent)", }} /> - + ); }; diff --git a/src/layouts/Navbar.tsx b/src/layouts/Navbar.tsx index 87e1c19..c4c1535 100644 --- a/src/layouts/Navbar.tsx +++ b/src/layouts/Navbar.tsx @@ -17,8 +17,9 @@ import { buttonVariants } from "@/components/ui/button"; import { Menu } from "lucide-react"; import { ModeToggle } from "@/components/mode-toggle"; import { LogoIcon } from "@/components/Icons"; -import { LanguageSwitcher } from "@/components/language"; // Import Language Switcher -import { motion, AnimatePresence } from "framer-motion"; +import { PyConBanner } from "@/components/PyConBanner"; +import { GlowBackground } from "@/components/section/GlowBackground"; +import { m, AnimatePresence } from "framer-motion"; interface RouteProps { href: string; @@ -31,8 +32,8 @@ const routeList: RouteProps[] = [ label: "About", }, { - href: "#newsletter", - label: "Newsletter", + href: "#team", + label: "Team", }, { href: "#faq", @@ -40,38 +41,6 @@ const routeList: RouteProps[] = [ }, ]; -// Particle effect for the logo -const ParticleEffect = () => { - return ( -
- {[...Array(5)].map((_, i) => ( - - ))} -
- ); -}; - export const Navbar = () => { const [isOpen, setIsOpen] = useState(false); const [scrolled, setScrolled] = useState(false); @@ -80,37 +49,38 @@ export const Navbar = () => { useEffect(() => { const handleScroll = () => { setScrolled(window.scrollY > 10); - - // Update active section based on scroll - const sections = routeList - .map((route) => { - const id = route.href.substring(1); - const element = document.getElementById(id); - if (element) { - const rect = element.getBoundingClientRect(); - const isInView = - rect.top <= window.innerHeight / 2 && - rect.bottom >= window.innerHeight / 2; - return { id, isInView }; - } - return null; - }) - .filter(Boolean); - - const currentSection = sections.find((section) => section?.isInView)?.id; - if (currentSection) { - setActiveSection(currentSection); - } }; - window.addEventListener("scroll", handleScroll); + window.addEventListener("scroll", handleScroll, { passive: true }); handleScroll(); return () => window.removeEventListener("scroll", handleScroll); }, []); + useEffect(() => { + // Section active = celle qui traverse la ligne médiane du viewport, + // sans getBoundingClientRect à chaque event scroll (AUDIT.md P5) + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + setActiveSection(entry.target.id); + } + }); + }, + { rootMargin: "-50% 0px -50% 0px" } + ); + + routeList.forEach((route) => { + const element = document.getElementById(route.href.substring(1)); + if (element) observer.observe(element); + }); + + return () => observer.disconnect(); + }, []); + return ( - { animate={{ y: 0 }} transition={{ duration: 0.3 }} > - {/* Background glow effect */} -
-
-
-
+ + + - - - {/* Glow effect */} - - - + + {/* Mobile navigation */}
@@ -171,30 +138,16 @@ export const Navbar = () => { - Open menu + - setIsOpen(true)} - > - Menu Icon - +
- + ); }; diff --git a/src/main.tsx b/src/main.tsx index 3952ee2..a119e8a 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,16 +1,31 @@ import React from "react"; import ReactDOM from "react-dom/client"; +import { LazyMotion, domAnimation, MotionConfig } from "framer-motion"; +// index.css (directives Tailwind + thème) doit précéder App et son App.css +// (utilitaires custom) dans le graphe d'imports pour garder l'ordre de cascade +import "./index.css"; import App from "./App.tsx"; import { ThemeProvider } from "@/providers/theme-provider.tsx"; import { LanguageProvider } from "@/components/language.tsx"; -import "./i18n"; -import "./index.css"; +import "./i18n"; +// Fonts auto-hébergées (AUDIT.md P6) — uniquement variantes et sous-ensembles latins utilisés, +// font-display: swap inclus (DotGothic16 complet = ~120 subsets japonais inutiles ici) +import "@fontsource/dotgothic16/latin-400.css"; +import "@fontsource/space-mono/latin-400.css"; +import "@fontsource/space-mono/latin-700.css"; +import "@fontsource/space-mono/latin-ext-400.css"; +import "@fontsource/space-mono/latin-ext-700.css"; ReactDOM.createRoot(document.getElementById("root")!).render( - + {/* strict: seuls les composants `m.` (chargés à la demande) sont autorisés — voir AUDIT.md P1 */} + + + + + diff --git a/src/providers/theme-provider.tsx b/src/providers/theme-provider.tsx index 85163c1..bcf2d3b 100644 --- a/src/providers/theme-provider.tsx +++ b/src/providers/theme-provider.tsx @@ -13,12 +13,7 @@ type ThemeProviderState = { setTheme: (theme: Theme) => void; }; -const initialState: ThemeProviderState = { - theme: "system", - setTheme: () => null, -}; - -const ThemeProviderContext = createContext(initialState); +const ThemeProviderContext = createContext(null); export function ThemeProvider({ children, @@ -33,19 +28,22 @@ export function ThemeProvider({ useEffect(() => { const root = window.document.documentElement; - root.classList.remove("light", "dark"); + const applyTheme = (resolved: "dark" | "light") => { + root.classList.remove("light", "dark"); + root.classList.add(resolved); + }; if (theme === "system") { - const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") - .matches - ? "dark" - : "light"; + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); + applyTheme(mediaQuery.matches ? "dark" : "light"); - root.classList.add(systemTheme); - return; + const handleChange = (e: MediaQueryListEvent) => + applyTheme(e.matches ? "dark" : "light"); + mediaQuery.addEventListener("change", handleChange); + return () => mediaQuery.removeEventListener("change", handleChange); } - root.classList.add(theme); + applyTheme(theme); }, [theme]); const value = { @@ -69,7 +67,7 @@ export function ThemeProvider({ export const useTheme = () => { const context = useContext(ThemeProviderContext); - if (context === undefined) + if (!context) throw new Error("useTheme must be used within a ThemeProvider"); return context; diff --git a/src/types/sections.ts b/src/types/sections.ts index 0164105..5d3932b 100644 --- a/src/types/sections.ts +++ b/src/types/sections.ts @@ -1,3 +1,4 @@ +import type { LucideIcon } from "lucide-react"; type TeamProps = { name: string; @@ -7,6 +8,49 @@ type TeamProps = { linkedIn: string; website?: string; }; -} +}; -export type {TeamProps} \ No newline at end of file +type SponsorProps = { + icon: string; + name: string; + link: string; +}; + +type FAQItemProps = { + question: string; + answer: string; + value: string; +}; + +type ApplicationProps = { + image: string; + title: string; + description: string; + icon: LucideIcon; + techStack: string[]; + color: { + primary: string; + secondary: string; + accent: string; + }; +}; + +type FooterLinkProps = { + name: string; + url: string; + icon: LucideIcon | null; +}; + +type FooterSectionProps = { + title: string; + links: FooterLinkProps[]; +}; + +export type { + TeamProps, + SponsorProps, + FAQItemProps, + ApplicationProps, + FooterLinkProps, + FooterSectionProps, +}; diff --git a/vite.config.ts b/vite.config.ts index 87893b0..dcc3dc5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -5,6 +5,17 @@ import path from "path" // https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], + build: { + rollupOptions: { + output: { + manualChunks: { + react: ["react", "react-dom"], + motion: ["framer-motion"], + i18n: ["i18next", "react-i18next"], + }, + }, + }, + }, resolve: { alias: { "@": path.resolve(__dirname, "./src"),