From 0f6860f1ae8a097e4d1edb514b5937ae27638cc1 Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Sat, 30 May 2026 18:43:35 +0200 Subject: [PATCH] refactor(site): extract shared utilities and centralise dialogue IDs - new src/hooks/ui/useIsMobile.ts (matchMedia + useSyncExternalStore) replacing the resize-handler hook inlined inside pages/site/page.tsx - new src/hooks/ui/usePrefersReducedMotion.ts - new src/data/site/dialogueIds.ts so site and intro components stop carrying hard-coded narrator IDs - siteConfig: add SITE_BACKGROUND_STYLE shared by SiteLayout and SiteMobileBlocker, rename forcedName to presetPlayerName, fix the swapped id/label pairing on situation cards - useSiteStore: rename selectedExperience/Situation to *Index so the stored value (an array index) is obvious in callers - audioConfig: drop dead AUDIO_PATHS placeholders - propagate the renames and SITE_BACKGROUND_STYLE through SiteLayout, SiteWelcomeScreen, SiteSituationScreen and pages/site/page.tsx --- src/components/site/SiteLayout.tsx | 8 ++--- src/components/site/SiteSituationScreen.tsx | 14 +++++---- src/components/site/SiteWelcomeScreen.tsx | 14 +++++---- src/data/audioConfig.ts | 8 ----- src/data/site/dialogueIds.ts | 9 ++++++ src/data/site/siteConfig.ts | 23 +++++++++++--- src/hooks/ui/useIsMobile.ts | 31 ++++++++++++++++++ src/hooks/ui/usePrefersReducedMotion.ts | 29 +++++++++++++++++ src/managers/stores/useSiteStore.ts | 17 +++++----- src/pages/site/page.tsx | 35 +-------------------- 10 files changed, 116 insertions(+), 72 deletions(-) create mode 100644 src/data/site/dialogueIds.ts create mode 100644 src/hooks/ui/useIsMobile.ts create mode 100644 src/hooks/ui/usePrefersReducedMotion.ts diff --git a/src/components/site/SiteLayout.tsx b/src/components/site/SiteLayout.tsx index 98ecf21..7af128d 100644 --- a/src/components/site/SiteLayout.tsx +++ b/src/components/site/SiteLayout.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from "react"; -import { SITE_CONFIG } from "@/data/site/siteConfig"; +import { SITE_BACKGROUND_STYLE } from "@/data/site/siteConfig"; import { Subtitles } from "@/components/ui/Subtitles"; interface SiteLayoutProps { @@ -16,11 +16,7 @@ export function SiteLayout({ children }: SiteLayoutProps): React.JSX.Element { flexDirection: "column", alignItems: "center", justifyContent: "center", - backgroundColor: "#87CEEB", - backgroundImage: `url(${SITE_CONFIG.backgroundImage})`, - backgroundSize: "cover", - backgroundPosition: "center", - backgroundRepeat: "no-repeat", + ...SITE_BACKGROUND_STYLE, fontFamily: "system-ui, -apple-system, sans-serif", color: "#fff", overflow: "hidden", diff --git a/src/components/site/SiteSituationScreen.tsx b/src/components/site/SiteSituationScreen.tsx index 52f21b0..deaba42 100644 --- a/src/components/site/SiteSituationScreen.tsx +++ b/src/components/site/SiteSituationScreen.tsx @@ -7,13 +7,15 @@ import { SITUATION_CARDS } from "@/data/site/siteConfig"; * Screen 2: Situation selection */ export function SiteSituationScreen(): React.JSX.Element { - const selectedSituation = useSiteStore((state) => state.selectedSituation); - const setSelectedSituation = useSiteStore( - (state) => state.setSelectedSituation, + const selectedSituationIndex = useSiteStore( + (state) => state.selectedSituationIndex, + ); + const setSelectedSituationIndex = useSiteStore( + (state) => state.setSelectedSituationIndex, ); const setStep = useSiteStore((state) => state.setStep); - const canProceed = selectedSituation !== null; + const canProceed = selectedSituationIndex !== null; const handleConfirm = (): void => { if (canProceed) { @@ -63,11 +65,11 @@ export function SiteSituationScreen(): React.JSX.Element { { if (!card.disabled) { - setSelectedSituation(index); + setSelectedSituationIndex(index); } }} /> diff --git a/src/components/site/SiteWelcomeScreen.tsx b/src/components/site/SiteWelcomeScreen.tsx index 9220053..d51a3b6 100644 --- a/src/components/site/SiteWelcomeScreen.tsx +++ b/src/components/site/SiteWelcomeScreen.tsx @@ -7,13 +7,15 @@ import { EXPERIENCE_CARDS } from "@/data/site/siteConfig"; * Screen 1: Welcome */ export function SiteWelcomeScreen(): React.JSX.Element { - const selectedExperience = useSiteStore((state) => state.selectedExperience); - const setSelectedExperience = useSiteStore( - (state) => state.setSelectedExperience, + const selectedExperienceIndex = useSiteStore( + (state) => state.selectedExperienceIndex, + ); + const setSelectedExperienceIndex = useSiteStore( + (state) => state.setSelectedExperienceIndex, ); const setStep = useSiteStore((state) => state.setStep); - const canProceed = selectedExperience !== null; + const canProceed = selectedExperienceIndex !== null; const handleNext = (): void => { if (canProceed) { @@ -104,10 +106,10 @@ export function SiteWelcomeScreen(): React.JSX.Element { { if (!card.disabled) { - setSelectedExperience(index); + setSelectedExperienceIndex(index); } }} /> diff --git a/src/data/audioConfig.ts b/src/data/audioConfig.ts index bef58ef..e8a6a3f 100644 --- a/src/data/audioConfig.ts +++ b/src/data/audioConfig.ts @@ -1,11 +1,3 @@ -export const AUDIO_PATHS = { - intro: "/sounds/effect/fa.mp3", - bienvenue: "/sounds/effect/fa.mp3", - alertCentral: "/sounds/effect/fa.mp3", - searching: "/sounds/effect/fa.mp3", - helped: "/sounds/effect/fa.mp3", -} as const; - export type AudioCategory = "music" | "sfx" | "dialogue"; export const DEFAULT_CATEGORY_VOLUMES: Record = { diff --git a/src/data/site/dialogueIds.ts b/src/data/site/dialogueIds.ts new file mode 100644 index 0000000..9855bb7 --- /dev/null +++ b/src/data/site/dialogueIds.ts @@ -0,0 +1,9 @@ +/** + * Dialogue manifest IDs used by the /site flow and the intro sequence. + * Defined once here so components don't hold magic strings. + */ +export const SITE_DIALOGUE_IDS = { + naming: "narrateur_intro_prenom", + transition: "narrateur_intro_apresprenom", + introOrder: "narrateur_ordreebike", +} as const; diff --git a/src/data/site/siteConfig.ts b/src/data/site/siteConfig.ts index b00dc02..b5c42d0 100644 --- a/src/data/site/siteConfig.ts +++ b/src/data/site/siteConfig.ts @@ -1,8 +1,23 @@ +import type { CSSProperties } from "react"; + +const BACKGROUND_IMAGE = "/assets/bg-site.png"; + export const SITE_CONFIG = { - backgroundImage: "/assets/bg-site.png", - forcedName: "Danyl", + backgroundImage: BACKGROUND_IMAGE, + presetPlayerName: "Danyl", } as const; +/** + * Shared background style used by SiteLayout and SiteMobileBlocker. + */ +export const SITE_BACKGROUND_STYLE: CSSProperties = { + backgroundColor: "#87CEEB", + backgroundImage: `url(${BACKGROUND_IMAGE})`, + backgroundSize: "cover", + backgroundPosition: "center", + backgroundRepeat: "no-repeat", +}; + export interface SiteCardConfig { id: string; label: string; @@ -24,10 +39,10 @@ export const EXPERIENCE_CARDS: readonly SiteCardConfig[] = [ * Cards for screen 2: "Quelle est votre situation ?" */ export const SITUATION_CARDS: readonly SiteCardConfig[] = [ - { id: "sit-refugie-climat", label: "Sans domicile fixe", disabled: true }, + { id: "sit-sans-domicile", label: "Sans domicile fixe", disabled: true }, { id: "sit-refugie-guerre", label: "Réfugié.e de guerre", disabled: true }, { - id: "sit-sans-domicile", + id: "sit-refugie-climat", label: "Réfugié.e climatique", disabled: false, }, diff --git a/src/hooks/ui/useIsMobile.ts b/src/hooks/ui/useIsMobile.ts new file mode 100644 index 0000000..590b911 --- /dev/null +++ b/src/hooks/ui/useIsMobile.ts @@ -0,0 +1,31 @@ +import { useSyncExternalStore } from "react"; + +const MOBILE_MEDIA_QUERY = + "(max-width: 767px), (pointer: coarse) and (hover: none)"; + +function subscribeToMobileQuery(callback: () => void): () => void { + const query = window.matchMedia(MOBILE_MEDIA_QUERY); + query.addEventListener("change", callback); + return () => query.removeEventListener("change", callback); +} + +function getMobileSnapshot(): boolean { + return window.matchMedia(MOBILE_MEDIA_QUERY).matches; +} + +function getServerMobileSnapshot(): boolean { + return false; +} + +/** + * True when the device is a phone or a touch-only tablet. + * Uses matchMedia so layout decisions follow CSS conventions + * and avoid resize-handler churn. + */ +export function useIsMobile(): boolean { + return useSyncExternalStore( + subscribeToMobileQuery, + getMobileSnapshot, + getServerMobileSnapshot, + ); +} diff --git a/src/hooks/ui/usePrefersReducedMotion.ts b/src/hooks/ui/usePrefersReducedMotion.ts new file mode 100644 index 0000000..f16464d --- /dev/null +++ b/src/hooks/ui/usePrefersReducedMotion.ts @@ -0,0 +1,29 @@ +import { useSyncExternalStore } from "react"; + +const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)"; + +function subscribeToReducedMotion(callback: () => void): () => void { + const query = window.matchMedia(REDUCED_MOTION_QUERY); + query.addEventListener("change", callback); + return () => query.removeEventListener("change", callback); +} + +function getReducedMotionSnapshot(): boolean { + return window.matchMedia(REDUCED_MOTION_QUERY).matches; +} + +function getServerReducedMotionSnapshot(): boolean { + return false; +} + +/** + * True when the user has requested reduced motion at the OS level. + * UI fades and transitions should collapse to 0ms when this is true. + */ +export function usePrefersReducedMotion(): boolean { + return useSyncExternalStore( + subscribeToReducedMotion, + getReducedMotionSnapshot, + getServerReducedMotionSnapshot, + ); +} diff --git a/src/managers/stores/useSiteStore.ts b/src/managers/stores/useSiteStore.ts index 15e3fd1..85171f8 100644 --- a/src/managers/stores/useSiteStore.ts +++ b/src/managers/stores/useSiteStore.ts @@ -3,14 +3,14 @@ import type { SiteStep } from "@/types/game"; interface SiteState { currentStep: SiteStep; - selectedExperience: number | null; - selectedSituation: number | null; + selectedExperienceIndex: number | null; + selectedSituationIndex: number | null; } interface SiteActions { setStep: (step: SiteStep) => void; - setSelectedExperience: (index: number) => void; - setSelectedSituation: (index: number) => void; + setSelectedExperienceIndex: (index: number) => void; + setSelectedSituationIndex: (index: number) => void; reset: () => void; } @@ -18,14 +18,15 @@ type SiteStore = SiteState & SiteActions; const initialState: SiteState = { currentStep: "disclaimer", - selectedExperience: null, - selectedSituation: null, + selectedExperienceIndex: null, + selectedSituationIndex: null, }; export const useSiteStore = create()((set) => ({ ...initialState, setStep: (step) => set({ currentStep: step }), - setSelectedExperience: (index) => set({ selectedExperience: index }), - setSelectedSituation: (index) => set({ selectedSituation: index }), + setSelectedExperienceIndex: (index) => + set({ selectedExperienceIndex: index }), + setSelectedSituationIndex: (index) => set({ selectedSituationIndex: index }), reset: () => set(initialState), })); diff --git a/src/pages/site/page.tsx b/src/pages/site/page.tsx index ac889c3..722fea6 100644 --- a/src/pages/site/page.tsx +++ b/src/pages/site/page.tsx @@ -1,4 +1,3 @@ -import { useEffect, useState } from "react"; import { useSiteStore } from "@/managers/stores/useSiteStore"; import { SiteDisclaimerScreen } from "@/components/site/SiteDisclaimerScreen"; import { SiteWelcomeScreen } from "@/components/site/SiteWelcomeScreen"; @@ -7,39 +6,7 @@ import { SiteNamingScreen } from "@/components/site/SiteNamingScreen"; import { SiteTransitionOverlay } from "@/components/site/SiteTransitionOverlay"; import { SiteMobileBlocker } from "@/components/site/SiteMobileBlocker"; import { SiteLayout } from "@/components/site/SiteLayout"; - -/** - * Check if user is on mobile device - */ -function useIsMobile(): boolean { - const [isMobile, setIsMobile] = useState(false); - - useEffect(() => { - const checkMobile = (): void => { - const userAgent = navigator.userAgent.toLowerCase(); - const mobileKeywords = [ - "android", - "webos", - "iphone", - "ipad", - "ipod", - "blackberry", - "windows phone", - ]; - const isMobileDevice = mobileKeywords.some((keyword) => - userAgent.includes(keyword), - ); - const isSmallScreen = window.innerWidth < 768; - setIsMobile(isMobileDevice || isSmallScreen); - }; - - checkMobile(); - window.addEventListener("resize", checkMobile); - return () => window.removeEventListener("resize", checkMobile); - }, []); - - return isMobile; -} +import { useIsMobile } from "@/hooks/ui/useIsMobile"; export function SitePage(): React.JSX.Element { const currentStep = useSiteStore((state) => state.currentStep);