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 ( + + ); +} 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 ( + + ); +} 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 ( +
+ Logo +

+ {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 ( +
+
+ Logo + +
+ ); +} 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=/;`; +}