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
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import type { ReactNode } from "react";
|
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";
|
import { Subtitles } from "@/components/ui/Subtitles";
|
||||||
|
|
||||||
interface SiteLayoutProps {
|
interface SiteLayoutProps {
|
||||||
@@ -16,11 +16,7 @@ export function SiteLayout({ children }: SiteLayoutProps): React.JSX.Element {
|
|||||||
flexDirection: "column",
|
flexDirection: "column",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
backgroundColor: "#87CEEB",
|
...SITE_BACKGROUND_STYLE,
|
||||||
backgroundImage: `url(${SITE_CONFIG.backgroundImage})`,
|
|
||||||
backgroundSize: "cover",
|
|
||||||
backgroundPosition: "center",
|
|
||||||
backgroundRepeat: "no-repeat",
|
|
||||||
fontFamily: "system-ui, -apple-system, sans-serif",
|
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
|
|||||||
@@ -7,13 +7,15 @@ import { SITUATION_CARDS } from "@/data/site/siteConfig";
|
|||||||
* Screen 2: Situation selection
|
* Screen 2: Situation selection
|
||||||
*/
|
*/
|
||||||
export function SiteSituationScreen(): React.JSX.Element {
|
export function SiteSituationScreen(): React.JSX.Element {
|
||||||
const selectedSituation = useSiteStore((state) => state.selectedSituation);
|
const selectedSituationIndex = useSiteStore(
|
||||||
const setSelectedSituation = useSiteStore(
|
(state) => state.selectedSituationIndex,
|
||||||
(state) => state.setSelectedSituation,
|
);
|
||||||
|
const setSelectedSituationIndex = useSiteStore(
|
||||||
|
(state) => state.setSelectedSituationIndex,
|
||||||
);
|
);
|
||||||
const setStep = useSiteStore((state) => state.setStep);
|
const setStep = useSiteStore((state) => state.setStep);
|
||||||
|
|
||||||
const canProceed = selectedSituation !== null;
|
const canProceed = selectedSituationIndex !== null;
|
||||||
|
|
||||||
const handleConfirm = (): void => {
|
const handleConfirm = (): void => {
|
||||||
if (canProceed) {
|
if (canProceed) {
|
||||||
@@ -63,11 +65,11 @@ export function SiteSituationScreen(): React.JSX.Element {
|
|||||||
<SiteCard
|
<SiteCard
|
||||||
key={card.id}
|
key={card.id}
|
||||||
config={card}
|
config={card}
|
||||||
selected={selectedSituation === index}
|
selected={selectedSituationIndex === index}
|
||||||
variant="situation"
|
variant="situation"
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
if (!card.disabled) {
|
if (!card.disabled) {
|
||||||
setSelectedSituation(index);
|
setSelectedSituationIndex(index);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -7,13 +7,15 @@ import { EXPERIENCE_CARDS } from "@/data/site/siteConfig";
|
|||||||
* Screen 1: Welcome
|
* Screen 1: Welcome
|
||||||
*/
|
*/
|
||||||
export function SiteWelcomeScreen(): React.JSX.Element {
|
export function SiteWelcomeScreen(): React.JSX.Element {
|
||||||
const selectedExperience = useSiteStore((state) => state.selectedExperience);
|
const selectedExperienceIndex = useSiteStore(
|
||||||
const setSelectedExperience = useSiteStore(
|
(state) => state.selectedExperienceIndex,
|
||||||
(state) => state.setSelectedExperience,
|
);
|
||||||
|
const setSelectedExperienceIndex = useSiteStore(
|
||||||
|
(state) => state.setSelectedExperienceIndex,
|
||||||
);
|
);
|
||||||
const setStep = useSiteStore((state) => state.setStep);
|
const setStep = useSiteStore((state) => state.setStep);
|
||||||
|
|
||||||
const canProceed = selectedExperience !== null;
|
const canProceed = selectedExperienceIndex !== null;
|
||||||
|
|
||||||
const handleNext = (): void => {
|
const handleNext = (): void => {
|
||||||
if (canProceed) {
|
if (canProceed) {
|
||||||
@@ -104,10 +106,10 @@ export function SiteWelcomeScreen(): React.JSX.Element {
|
|||||||
<SiteCard
|
<SiteCard
|
||||||
key={card.id}
|
key={card.id}
|
||||||
config={card}
|
config={card}
|
||||||
selected={selectedExperience === index}
|
selected={selectedExperienceIndex === index}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
if (!card.disabled) {
|
if (!card.disabled) {
|
||||||
setSelectedExperience(index);
|
setSelectedExperienceIndex(index);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 type AudioCategory = "music" | "sfx" | "dialogue";
|
||||||
|
|
||||||
export const DEFAULT_CATEGORY_VOLUMES: Record<AudioCategory, number> = {
|
export const DEFAULT_CATEGORY_VOLUMES: Record<AudioCategory, number> = {
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -1,8 +1,23 @@
|
|||||||
|
import type { CSSProperties } from "react";
|
||||||
|
|
||||||
|
const BACKGROUND_IMAGE = "/assets/bg-site.png";
|
||||||
|
|
||||||
export const SITE_CONFIG = {
|
export const SITE_CONFIG = {
|
||||||
backgroundImage: "/assets/bg-site.png",
|
backgroundImage: BACKGROUND_IMAGE,
|
||||||
forcedName: "Danyl",
|
presetPlayerName: "Danyl",
|
||||||
} as const;
|
} 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 {
|
export interface SiteCardConfig {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -24,10 +39,10 @@ export const EXPERIENCE_CARDS: readonly SiteCardConfig[] = [
|
|||||||
* Cards for screen 2: "Quelle est votre situation ?"
|
* Cards for screen 2: "Quelle est votre situation ?"
|
||||||
*/
|
*/
|
||||||
export const SITUATION_CARDS: readonly SiteCardConfig[] = [
|
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-refugie-guerre", label: "Réfugié.e de guerre", disabled: true },
|
||||||
{
|
{
|
||||||
id: "sit-sans-domicile",
|
id: "sit-refugie-climat",
|
||||||
label: "Réfugié.e climatique",
|
label: "Réfugié.e climatique",
|
||||||
disabled: false,
|
disabled: false,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,14 +3,14 @@ import type { SiteStep } from "@/types/game";
|
|||||||
|
|
||||||
interface SiteState {
|
interface SiteState {
|
||||||
currentStep: SiteStep;
|
currentStep: SiteStep;
|
||||||
selectedExperience: number | null;
|
selectedExperienceIndex: number | null;
|
||||||
selectedSituation: number | null;
|
selectedSituationIndex: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SiteActions {
|
interface SiteActions {
|
||||||
setStep: (step: SiteStep) => void;
|
setStep: (step: SiteStep) => void;
|
||||||
setSelectedExperience: (index: number) => void;
|
setSelectedExperienceIndex: (index: number) => void;
|
||||||
setSelectedSituation: (index: number) => void;
|
setSelectedSituationIndex: (index: number) => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,14 +18,15 @@ type SiteStore = SiteState & SiteActions;
|
|||||||
|
|
||||||
const initialState: SiteState = {
|
const initialState: SiteState = {
|
||||||
currentStep: "disclaimer",
|
currentStep: "disclaimer",
|
||||||
selectedExperience: null,
|
selectedExperienceIndex: null,
|
||||||
selectedSituation: null,
|
selectedSituationIndex: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useSiteStore = create<SiteStore>()((set) => ({
|
export const useSiteStore = create<SiteStore>()((set) => ({
|
||||||
...initialState,
|
...initialState,
|
||||||
setStep: (step) => set({ currentStep: step }),
|
setStep: (step) => set({ currentStep: step }),
|
||||||
setSelectedExperience: (index) => set({ selectedExperience: index }),
|
setSelectedExperienceIndex: (index) =>
|
||||||
setSelectedSituation: (index) => set({ selectedSituation: index }),
|
set({ selectedExperienceIndex: index }),
|
||||||
|
setSelectedSituationIndex: (index) => set({ selectedSituationIndex: index }),
|
||||||
reset: () => set(initialState),
|
reset: () => set(initialState),
|
||||||
}));
|
}));
|
||||||
|
|||||||
+1
-34
@@ -1,4 +1,3 @@
|
|||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useSiteStore } from "@/managers/stores/useSiteStore";
|
import { useSiteStore } from "@/managers/stores/useSiteStore";
|
||||||
import { SiteDisclaimerScreen } from "@/components/site/SiteDisclaimerScreen";
|
import { SiteDisclaimerScreen } from "@/components/site/SiteDisclaimerScreen";
|
||||||
import { SiteWelcomeScreen } from "@/components/site/SiteWelcomeScreen";
|
import { SiteWelcomeScreen } from "@/components/site/SiteWelcomeScreen";
|
||||||
@@ -7,39 +6,7 @@ import { SiteNamingScreen } from "@/components/site/SiteNamingScreen";
|
|||||||
import { SiteTransitionOverlay } from "@/components/site/SiteTransitionOverlay";
|
import { SiteTransitionOverlay } from "@/components/site/SiteTransitionOverlay";
|
||||||
import { SiteMobileBlocker } from "@/components/site/SiteMobileBlocker";
|
import { SiteMobileBlocker } from "@/components/site/SiteMobileBlocker";
|
||||||
import { SiteLayout } from "@/components/site/SiteLayout";
|
import { SiteLayout } from "@/components/site/SiteLayout";
|
||||||
|
import { useIsMobile } from "@/hooks/ui/useIsMobile";
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SitePage(): React.JSX.Element {
|
export function SitePage(): React.JSX.Element {
|
||||||
const currentStep = useSiteStore((state) => state.currentStep);
|
const currentStep = useSiteStore((state) => state.currentStep);
|
||||||
|
|||||||
Reference in New Issue
Block a user