diff --git a/src/components/site/SiteButton.tsx b/src/components/site/SiteButton.tsx
new file mode 100644
index 0000000..cee1f8a
--- /dev/null
+++ b/src/components/site/SiteButton.tsx
@@ -0,0 +1,52 @@
+import { useState } from "react";
+
+interface SiteButtonProps {
+ label: string;
+ disabled?: boolean;
+ onClick: () => void;
+}
+
+export function SiteButton({
+ label,
+ disabled = false,
+ onClick,
+}: SiteButtonProps): React.JSX.Element {
+ const [isPressed, setIsPressed] = useState(false);
+
+ return (
+ setIsPressed(true)}
+ onMouseUp={() => setIsPressed(false)}
+ onMouseLeave={() => setIsPressed(false)}
+ style={{
+ display: "inline-flex",
+ padding: "12px 20px",
+ justifyContent: "center",
+ alignItems: "center",
+ gap: 10,
+ background: disabled ? "#b0b0b0" : "#FFF",
+ boxShadow: disabled
+ ? "none"
+ : isPressed
+ ? "0 4px 10px 0 rgba(0, 0, 0, 0.35)"
+ : "0 7px 14.4px 0 rgba(0, 0, 0, 0.25)",
+ border: "none",
+ cursor: disabled ? "not-allowed" : "pointer",
+ color: disabled ? "#888888" : "#000",
+ fontFamily: "Inter, system-ui, sans-serif",
+ fontSize: "clamp(18px, 3vw, 26px)",
+ fontStyle: "normal",
+ fontWeight: 700,
+ lineHeight: "normal",
+ letterSpacing: "-1.3px",
+ textTransform: "uppercase",
+ transition: "box-shadow 0.15s ease",
+ }}
+ >
+ {label}
+
+ );
+}
diff --git a/src/components/site/SiteCard.tsx b/src/components/site/SiteCard.tsx
new file mode 100644
index 0000000..05d4eca
--- /dev/null
+++ b/src/components/site/SiteCard.tsx
@@ -0,0 +1,68 @@
+import type { SiteCardConfig } from "@/data/site/siteConfig";
+
+interface SiteCardProps {
+ config: SiteCardConfig;
+ selected: boolean;
+ onSelect: () => void;
+}
+
+export function SiteCard({
+ config,
+ selected,
+ onSelect,
+}: SiteCardProps): React.JSX.Element {
+ const { label, imagePath, disabled } = config;
+
+ const getBackground = (): string => {
+ if (imagePath) return `url(${imagePath}) center/cover`;
+ if (disabled) return "#b8b8b8";
+ if (selected) return "#d9d9d9";
+ return "#e8e8e8";
+ };
+
+ const getBorder = (): string => {
+ if (selected) return "3px solid #a8d5a2";
+ if (disabled) return "none";
+ return "2px solid #ffffff";
+ };
+
+ const getTextColor = (): string => {
+ if (disabled) return "#888888";
+ return "#666666";
+ };
+
+ return (
+
+ {!imagePath && (
+
+ {label}
+
+ )}
+
+ );
+}
diff --git a/src/components/site/SiteDisclaimerScreen.tsx b/src/components/site/SiteDisclaimerScreen.tsx
new file mode 100644
index 0000000..caf02ab
--- /dev/null
+++ b/src/components/site/SiteDisclaimerScreen.tsx
@@ -0,0 +1,83 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useSiteStore } from "@/managers/stores/useSiteStore";
+
+const DISCLAIMER_TEXT =
+ "Ce site a été conçu pour être utilisé sur ordinateur.\nPour une meilleure expérience, assurez-vous d'avoir une bonne connexion internet et une machine performante.";
+
+const TEXT_DISPLAY_DURATION = 5000;
+const FADE_OUT_DURATION = 1000;
+const TRANSITION_DELAY = 250;
+
+/**
+ * Screen 0: Disclaimer
+ */
+export function SiteDisclaimerScreen(): React.JSX.Element {
+ const setStep = useSiteStore((state) => state.setStep);
+ const [textOpacity, setTextOpacity] = useState(0);
+ const hasSkipped = useRef(false);
+
+ const handleSkip = useCallback(() => {
+ if (hasSkipped.current) return;
+ hasSkipped.current = true;
+ setStep("welcome");
+ }, [setStep]);
+
+ useEffect(() => {
+ // Fade in text
+ const fadeInTimeout = window.setTimeout(() => {
+ setTextOpacity(1);
+ }, 100);
+
+ // Start fade out after display duration
+ const fadeOutTimeout = window.setTimeout(() => {
+ setTextOpacity(0);
+ }, TEXT_DISPLAY_DURATION);
+
+ // Transition to welcome after fade out + delay
+ const transitionTimeout = window.setTimeout(
+ () => {
+ handleSkip();
+ },
+ TEXT_DISPLAY_DURATION + FADE_OUT_DURATION + TRANSITION_DELAY,
+ );
+
+ return () => {
+ window.clearTimeout(fadeInTimeout);
+ window.clearTimeout(fadeOutTimeout);
+ window.clearTimeout(transitionTimeout);
+ };
+ }, [handleSkip]);
+
+ return (
+
+
+ {DISCLAIMER_TEXT}
+
+
+ );
+}
diff --git a/src/components/site/SiteLayout.tsx b/src/components/site/SiteLayout.tsx
new file mode 100644
index 0000000..98ecf21
--- /dev/null
+++ b/src/components/site/SiteLayout.tsx
@@ -0,0 +1,33 @@
+import type { ReactNode } from "react";
+import { SITE_CONFIG } from "@/data/site/siteConfig";
+import { Subtitles } from "@/components/ui/Subtitles";
+
+interface SiteLayoutProps {
+ children: ReactNode;
+}
+
+export function SiteLayout({ children }: SiteLayoutProps): React.JSX.Element {
+ return (
+
+ {children}
+
+
+ );
+}
diff --git a/src/components/site/SiteMobileBlocker.tsx b/src/components/site/SiteMobileBlocker.tsx
new file mode 100644
index 0000000..ef47a16
--- /dev/null
+++ b/src/components/site/SiteMobileBlocker.tsx
@@ -0,0 +1,53 @@
+import { SITE_CONFIG } from "@/data/site/siteConfig";
+
+const MOBILE_TEXT =
+ "Ce site a été conçu pour être utilisé sur ordinateur. Veuillez réessayer sur votre ordinateur pour une expérience optimale.";
+
+/**
+ * Mobile blocker screen
+ */
+export function SiteMobileBlocker(): React.JSX.Element {
+ return (
+
+
+
+ {MOBILE_TEXT}
+
+
+ );
+}
diff --git a/src/components/site/SiteNamingScreen.tsx b/src/components/site/SiteNamingScreen.tsx
new file mode 100644
index 0000000..5afe1d6
--- /dev/null
+++ b/src/components/site/SiteNamingScreen.tsx
@@ -0,0 +1,133 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useGameStore } from "@/managers/stores/useGameStore";
+import { useSiteStore } from "@/managers/stores/useSiteStore";
+import { SiteButton } from "@/components/site/SiteButton";
+import { SITE_CONFIG } from "@/data/site/siteConfig";
+import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
+import { playDialogueById } from "@/utils/dialogues/playDialogue";
+
+/**
+ * Screen 3: Name input
+ */
+export function SiteNamingScreen(): React.JSX.Element {
+ const setStep = useSiteStore((state) => state.setStep);
+ const setPlayerName = useGameStore((state) => state.setPlayerName);
+ const [charIndex, setCharIndex] = useState(0);
+ const dialogueStarted = useRef(false);
+ const inputRef = useRef(null);
+
+ const forcedName = SITE_CONFIG.forcedName;
+ const displayValue = forcedName.slice(0, charIndex);
+ const isComplete = charIndex >= forcedName.length;
+
+ // Play dialogue when screen appears (with subtitles)
+ useEffect(() => {
+ if (dialogueStarted.current) return;
+ dialogueStarted.current = true;
+
+ void (async () => {
+ const manifest = await loadDialogueManifest();
+ if (manifest) {
+ await playDialogueById(manifest, "narrateur_intro_prenom");
+ }
+ })();
+ }, []);
+
+ // Focus input on mount
+ useEffect(() => {
+ inputRef.current?.focus();
+ }, []);
+
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent): void => {
+ e.preventDefault();
+
+ // Only process if not complete and it's a letter key
+ if (!isComplete && e.key.length === 1 && /[a-zA-Z]/.test(e.key)) {
+ setCharIndex((prev) => Math.min(prev + 1, forcedName.length));
+ }
+ },
+ [isComplete, forcedName.length],
+ );
+
+ const handleConfirm = (): void => {
+ if (isComplete) {
+ setPlayerName(forcedName);
+ setStep("transition");
+ }
+ };
+
+ return (
+
+
+
+ Quel est votre prénom ?
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/site/SiteSituationScreen.tsx b/src/components/site/SiteSituationScreen.tsx
new file mode 100644
index 0000000..d24589d
--- /dev/null
+++ b/src/components/site/SiteSituationScreen.tsx
@@ -0,0 +1,82 @@
+import { useSiteStore } from "@/managers/stores/useSiteStore";
+import { SiteCard } from "@/components/site/SiteCard";
+import { SiteButton } from "@/components/site/SiteButton";
+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 setStep = useSiteStore((state) => state.setStep);
+
+ const canProceed = selectedSituation !== null;
+
+ const handleConfirm = (): void => {
+ if (canProceed) {
+ setStep("naming");
+ }
+ };
+
+ return (
+
+
+ Quelle est votre situation ?
+
+
+
+ {SITUATION_CARDS.map((card, index) => (
+ {
+ if (!card.disabled) {
+ setSelectedSituation(index);
+ }
+ }}
+ />
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/components/site/SiteTransitionOverlay.tsx b/src/components/site/SiteTransitionOverlay.tsx
new file mode 100644
index 0000000..3c7d150
--- /dev/null
+++ b/src/components/site/SiteTransitionOverlay.tsx
@@ -0,0 +1,109 @@
+import { useEffect, useRef, useState } from "react";
+import { useNavigate } from "@tanstack/react-router";
+import { useSiteStore } from "@/managers/stores/useSiteStore";
+import { Subtitles } from "@/components/ui/Subtitles";
+import { setSiteVisited } from "@/utils/cookies/siteVisitCookie";
+import { loadDialogueManifest } from "@/utils/dialogues/loadDialogueManifest";
+import { playDialogueById } from "@/utils/dialogues/playDialogue";
+
+const FADE_DURATION_MS = 1000;
+
+/**
+ * Transition overlay: black screen (fade in) + logo (fade in/out) + dialogue with subtitles + redirect to /
+ */
+export function SiteTransitionOverlay(): React.JSX.Element {
+ const navigate = useNavigate();
+ const reset = useSiteStore((state) => state.reset);
+ const [screenOpacity, setScreenOpacity] = useState(0);
+ const [logoOpacity, setLogoOpacity] = useState(0);
+ const transitionStarted = useRef(false);
+
+ useEffect(() => {
+ if (transitionStarted.current) return;
+ transitionStarted.current = true;
+
+ // Fade in black screen
+ setScreenOpacity(1);
+
+ // Set cookie
+ setSiteVisited();
+
+ // Fade in logo after the black screen transition delay.
+ setLogoOpacity(1);
+
+ // Play transition dialogue (with subtitles) then fade out logo and redirect
+ void (async () => {
+ const manifest = await loadDialogueManifest();
+ if (manifest) {
+ const dialogueAudio = await playDialogueById(
+ manifest,
+ "narrateur_intro_apresprenom",
+ );
+ if (dialogueAudio) {
+ dialogueAudio.addEventListener(
+ "ended",
+ () => {
+ // Fade out logo
+ setLogoOpacity(0);
+ // Redirect after logo fade out
+ setTimeout(() => {
+ reset();
+ navigate({ to: "/" });
+ }, FADE_DURATION_MS);
+ },
+ { once: true },
+ );
+ return;
+ }
+ }
+ // Fallback: redirect after 3s if dialogue fails
+ setTimeout(() => {
+ setLogoOpacity(0);
+ setTimeout(() => {
+ reset();
+ navigate({ to: "/" });
+ }, FADE_DURATION_MS);
+ }, 3000);
+ })();
+ }, [navigate, reset]);
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/src/components/site/SiteWelcomeScreen.tsx b/src/components/site/SiteWelcomeScreen.tsx
new file mode 100644
index 0000000..9220053
--- /dev/null
+++ b/src/components/site/SiteWelcomeScreen.tsx
@@ -0,0 +1,120 @@
+import { useSiteStore } from "@/managers/stores/useSiteStore";
+import { SiteCard } from "@/components/site/SiteCard";
+import { SiteButton } from "@/components/site/SiteButton";
+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 setStep = useSiteStore((state) => state.setStep);
+
+ const canProceed = selectedExperience !== null;
+
+ const handleNext = (): void => {
+ if (canProceed) {
+ setStep("situation");
+ }
+ };
+
+ return (
+
+
+
+ BIENVENUE A ALTERA
+
+
+ Communauté convivialiste
+
+
+
+
+ Choisissez une expérience :
+
+
+
+ {EXPERIENCE_CARDS.map((card, index) => (
+ {
+ if (!card.disabled) {
+ setSelectedExperience(index);
+ }
+ }}
+ />
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/data/site/siteConfig.ts b/src/data/site/siteConfig.ts
new file mode 100644
index 0000000..59593c2
--- /dev/null
+++ b/src/data/site/siteConfig.ts
@@ -0,0 +1,35 @@
+export const SITE_CONFIG = {
+ backgroundImage: "/assets/bg-site.png",
+ forcedName: "Danyl",
+} as const;
+
+export interface SiteCardConfig {
+ id: string;
+ label: string;
+ imagePath?: string;
+ disabled: boolean;
+}
+
+/**
+ * Cards for screen 1: "Choisissez une expérience"
+ */
+export const EXPERIENCE_CARDS: readonly SiteCardConfig[] = [
+ { id: "exp-fabrik", label: "La Fabrik", disabled: false },
+ { id: "exp-ferme", label: "La Ferme verticale", disabled: true },
+ { id: "exp-energie", label: "La Zone d'énergie", disabled: true },
+ { id: "exp-ecole", label: "L'École", disabled: true },
+];
+
+/**
+ * Cards for screen 2: "Quelle est votre situation ?"
+ */
+export const SITUATION_CARDS: readonly SiteCardConfig[] = [
+ { id: "sit-habitants", label: "Habitants d'Altera", disabled: true },
+ { id: "sit-apprentis", label: "Apprentis-Citoyens", disabled: true },
+ {
+ id: "sit-refugies",
+ label: "Réfugiés Climatiques arrivants",
+ disabled: false,
+ },
+ { id: "sit-seniors", label: "Seniors Hyper-Connectés", disabled: true },
+];
diff --git a/src/managers/stores/useSiteStore.ts b/src/managers/stores/useSiteStore.ts
new file mode 100644
index 0000000..15e3fd1
--- /dev/null
+++ b/src/managers/stores/useSiteStore.ts
@@ -0,0 +1,31 @@
+import { create } from "zustand";
+import type { SiteStep } from "@/types/game";
+
+interface SiteState {
+ currentStep: SiteStep;
+ selectedExperience: number | null;
+ selectedSituation: number | null;
+}
+
+interface SiteActions {
+ setStep: (step: SiteStep) => void;
+ setSelectedExperience: (index: number) => void;
+ setSelectedSituation: (index: number) => void;
+ reset: () => void;
+}
+
+type SiteStore = SiteState & SiteActions;
+
+const initialState: SiteState = {
+ currentStep: "disclaimer",
+ selectedExperience: null,
+ selectedSituation: null,
+};
+
+export const useSiteStore = create()((set) => ({
+ ...initialState,
+ setStep: (step) => set({ currentStep: step }),
+ setSelectedExperience: (index) => set({ selectedExperience: index }),
+ setSelectedSituation: (index) => set({ selectedSituation: index }),
+ reset: () => set(initialState),
+}));
diff --git a/src/pages/site/page.tsx b/src/pages/site/page.tsx
new file mode 100644
index 0000000..ac889c3
--- /dev/null
+++ b/src/pages/site/page.tsx
@@ -0,0 +1,64 @@
+import { useEffect, useState } from "react";
+import { useSiteStore } from "@/managers/stores/useSiteStore";
+import { SiteDisclaimerScreen } from "@/components/site/SiteDisclaimerScreen";
+import { SiteWelcomeScreen } from "@/components/site/SiteWelcomeScreen";
+import { SiteSituationScreen } from "@/components/site/SiteSituationScreen";
+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;
+}
+
+export function SitePage(): React.JSX.Element {
+ const currentStep = useSiteStore((state) => state.currentStep);
+ const isMobile = useIsMobile();
+
+ if (isMobile) {
+ return ;
+ }
+
+ if (currentStep === "disclaimer") {
+ return ;
+ }
+
+ return (
+
+ {currentStep === "welcome" && }
+ {currentStep === "situation" && }
+ {currentStep === "naming" && }
+ {currentStep === "transition" && }
+
+ );
+}
diff --git a/src/router.tsx b/src/router.tsx
index c00c2d0..f9c481e 100644
--- a/src/router.tsx
+++ b/src/router.tsx
@@ -5,6 +5,7 @@ import {
createRouter,
} from "@tanstack/react-router";
import { HomePage } from "@/pages/page";
+import { SitePage } from "@/pages/site/page";
import { EditorPage } from "@/pages/editor/page";
import { GalleryPage } from "@/pages/gallery/page";
import { WaypointEditorPage } from "@/pages/waypoint/page";
@@ -42,6 +43,12 @@ const indexRoute = createRoute({
component: HomePage,
});
+const siteRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: "/site",
+ component: SitePage,
+});
+
const editorRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/editor",
@@ -102,6 +109,7 @@ const docsChildRoutes = [
const routeTree = rootRoute.addChildren([
indexRoute,
+ siteRoute,
editorRoute,
galleryRoute,
waypointRoute,
diff --git a/src/types/game.ts b/src/types/game.ts
index 9d4a9a7..07da6b7 100644
--- a/src/types/game.ts
+++ b/src/types/game.ts
@@ -4,6 +4,7 @@ import type { RepairMissionId } from "@/types/gameplay/repairMission";
* Steps for the /site onboarding page
*/
export type SiteStep =
+ | "disclaimer" // Écran 0: Avertissement (ordi recommandé, bonne connexion)
| "welcome" // Écran 1: Bienvenue à Altera
| "situation" // Écran 2: Quelle est votre situation
| "naming" // Écran 3: Quel est votre prénom (Danyl)
diff --git a/src/utils/cookies/siteVisitCookie.ts b/src/utils/cookies/siteVisitCookie.ts
new file mode 100644
index 0000000..716aa2e
--- /dev/null
+++ b/src/utils/cookies/siteVisitCookie.ts
@@ -0,0 +1,35 @@
+const COOKIE_NAME = "siteVisited";
+const EXPIRY_HOURS = 24;
+
+/**
+ * Check if the site has been visited today (within 24 hours)
+ */
+export function hasSiteBeenVisitedToday(): boolean {
+ const cookies = document.cookie.split(";");
+
+ for (const cookie of cookies) {
+ const [name, value] = cookie.trim().split("=");
+ if (name === COOKIE_NAME && value === "true") {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+/**
+ * Set the site visited cookie with 24-hour expiration
+ */
+export function setSiteVisited(): void {
+ const expiryDate = new Date();
+ expiryDate.setTime(expiryDate.getTime() + EXPIRY_HOURS * 60 * 60 * 1000);
+
+ document.cookie = `${COOKIE_NAME}=true; expires=${expiryDate.toUTCString()}; path=/; SameSite=Strict`;
+}
+
+/**
+ * Clear the site visited cookie (useful for debugging)
+ */
+export function clearSiteVisited(): void {
+ document.cookie = `${COOKIE_NAME}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
+}