feat: add site onboarding route
This commit is contained in:
@@ -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 (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
onMouseDown={() => 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}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSelect}
|
||||||
|
disabled={disabled}
|
||||||
|
style={{
|
||||||
|
width: "clamp(120px, 15vw, 160px)",
|
||||||
|
height: "clamp(140px, 18vw, 180px)",
|
||||||
|
border: getBorder(),
|
||||||
|
background: getBackground(),
|
||||||
|
cursor: disabled ? "not-allowed" : "pointer",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
transition: "all 0.15s ease",
|
||||||
|
outline: "none",
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!imagePath && (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: getTextColor(),
|
||||||
|
fontSize: "clamp(10px, 1.5vw, 14px)",
|
||||||
|
fontWeight: 500,
|
||||||
|
textAlign: "center",
|
||||||
|
padding: 8,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
onClick={handleSkip}
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
background: "#000",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: 48,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
color: "#F2F2F2",
|
||||||
|
textAlign: "center",
|
||||||
|
fontFamily: "Inter, system-ui, sans-serif",
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: 400,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
maxWidth: 800,
|
||||||
|
opacity: textOpacity,
|
||||||
|
transition: `opacity ${FADE_OUT_DURATION}ms ease-in-out`,
|
||||||
|
whiteSpace: "pre-wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{DISCLAIMER_TEXT}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
backgroundColor: "#87CEEB",
|
||||||
|
backgroundImage: `url(${SITE_CONFIG.backgroundImage})`,
|
||||||
|
backgroundSize: "cover",
|
||||||
|
backgroundPosition: "center",
|
||||||
|
backgroundRepeat: "no-repeat",
|
||||||
|
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||||
|
color: "#fff",
|
||||||
|
overflow: "hidden",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<Subtitles />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
backgroundColor: "#87CEEB",
|
||||||
|
backgroundImage: `url(${SITE_CONFIG.backgroundImage})`,
|
||||||
|
backgroundSize: "cover",
|
||||||
|
backgroundPosition: "center",
|
||||||
|
backgroundRepeat: "no-repeat",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: 32,
|
||||||
|
gap: 48,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="public/assets/logo/logo.jpg"
|
||||||
|
alt="Logo"
|
||||||
|
style={{
|
||||||
|
width: 120,
|
||||||
|
height: "auto",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
color: "#F2F2F2",
|
||||||
|
textAlign: "center",
|
||||||
|
textShadow: "0 4px 10px rgba(0, 0, 0, 0.4)",
|
||||||
|
fontFamily: "Inter, system-ui, sans-serif",
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 500,
|
||||||
|
lineHeight: 1.6,
|
||||||
|
maxWidth: 320,
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{MOBILE_TEXT}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<HTMLInputElement>(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<HTMLInputElement>): 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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 80,
|
||||||
|
padding: 24,
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 950,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
width: "100%",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 48,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
color: "#F2F2F2",
|
||||||
|
textAlign: "center",
|
||||||
|
textShadow: "0 7px 14.4px rgba(0, 0, 0, 0.25)",
|
||||||
|
fontFamily: "Inter, system-ui, sans-serif",
|
||||||
|
fontSize: "clamp(18px, 3vw, 26px)",
|
||||||
|
fontStyle: "normal",
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: "normal",
|
||||||
|
letterSpacing: "-1.3px",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Quel est votre prénom ?
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={displayValue}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
readOnly
|
||||||
|
placeholder="Écrivez votre prénom ici"
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
padding: "clamp(8px, 1.5vw, 10px)",
|
||||||
|
alignItems: "center",
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 800,
|
||||||
|
minWidth: 280,
|
||||||
|
gap: 10,
|
||||||
|
border: "4px solid #FFF",
|
||||||
|
background: "#D9D9D9",
|
||||||
|
outline: "none",
|
||||||
|
color: "#333",
|
||||||
|
caretColor: "transparent",
|
||||||
|
fontFamily: "Inter, system-ui, sans-serif",
|
||||||
|
fontSize: "clamp(16px, 2.5vw, 20px)",
|
||||||
|
textAlign: "left",
|
||||||
|
boxSizing: "border-box",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SiteButton
|
||||||
|
label="CONFIRMER"
|
||||||
|
disabled={!isComplete}
|
||||||
|
onClick={handleConfirm}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 40,
|
||||||
|
padding: 24,
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 1208,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
color: "#F2F2F2",
|
||||||
|
textAlign: "center",
|
||||||
|
textShadow: "0 7px 14.4px rgba(0, 0, 0, 0.25)",
|
||||||
|
fontFamily: "Inter, system-ui, sans-serif",
|
||||||
|
fontSize: "clamp(20px, 4vw, 32px)",
|
||||||
|
fontStyle: "normal",
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: "normal",
|
||||||
|
letterSpacing: "-1.6px",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Quelle est votre situation ?
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: 16,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{SITUATION_CARDS.map((card, index) => (
|
||||||
|
<SiteCard
|
||||||
|
key={card.id}
|
||||||
|
config={card}
|
||||||
|
selected={selectedSituation === index}
|
||||||
|
onSelect={() => {
|
||||||
|
if (!card.disabled) {
|
||||||
|
setSelectedSituation(index);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SiteButton
|
||||||
|
label="CONFIRMER"
|
||||||
|
disabled={!canProceed}
|
||||||
|
onClick={handleConfirm}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
inset: 0,
|
||||||
|
zIndex: 1000,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
inset: 0,
|
||||||
|
background: "#000",
|
||||||
|
zIndex: 0,
|
||||||
|
opacity: screenOpacity,
|
||||||
|
transition: `opacity ${FADE_DURATION_MS}ms ease-in-out`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
src="/assets/logo/logo.jpg"
|
||||||
|
alt="Logo"
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
zIndex: 1,
|
||||||
|
width: "min(300px, 45vw)",
|
||||||
|
height: "auto",
|
||||||
|
objectFit: "contain",
|
||||||
|
opacity: logoOpacity,
|
||||||
|
transition: `opacity ${FADE_DURATION_MS}ms ease-in-out`,
|
||||||
|
transitionDelay: logoOpacity === 1 ? `${FADE_DURATION_MS}ms` : "0ms",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Subtitles />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 40,
|
||||||
|
padding: 24,
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 1208,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
width: "100%",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
color: "#F2F2F2",
|
||||||
|
textShadow: "0 7px 14.4px rgba(0, 0, 0, 0.25)",
|
||||||
|
fontFamily: '"Nersans One", system-ui, sans-serif',
|
||||||
|
fontSize: "clamp(40px, 8vw, 64px)",
|
||||||
|
fontStyle: "normal",
|
||||||
|
fontWeight: 400,
|
||||||
|
lineHeight: "normal",
|
||||||
|
letterSpacing: "-3px",
|
||||||
|
margin: 0,
|
||||||
|
textAlign: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
BIENVENUE A ALTERA
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
color: "#F2F2F2",
|
||||||
|
textAlign: "center",
|
||||||
|
textShadow: "0 7px 14.4px rgba(0, 0, 0, 0.25)",
|
||||||
|
fontFamily: "Inter, system-ui, sans-serif",
|
||||||
|
fontSize: "clamp(18px, 3vw, 26px)",
|
||||||
|
fontStyle: "normal",
|
||||||
|
fontWeight: 400,
|
||||||
|
lineHeight: "normal",
|
||||||
|
letterSpacing: "-1.3px",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Communauté convivialiste
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2
|
||||||
|
style={{
|
||||||
|
color: "#F2F2F2",
|
||||||
|
textAlign: "center",
|
||||||
|
textShadow: "0 7px 14.4px rgba(0, 0, 0, 0.25)",
|
||||||
|
fontFamily: "Inter, system-ui, sans-serif",
|
||||||
|
fontSize: "clamp(20px, 4vw, 32px)",
|
||||||
|
fontStyle: "normal",
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: "normal",
|
||||||
|
letterSpacing: "-1.6px",
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Choisissez une expérience :
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
gap: 16,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
justifyContent: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{EXPERIENCE_CARDS.map((card, index) => (
|
||||||
|
<SiteCard
|
||||||
|
key={card.id}
|
||||||
|
config={card}
|
||||||
|
selected={selectedExperience === index}
|
||||||
|
onSelect={() => {
|
||||||
|
if (!card.disabled) {
|
||||||
|
setSelectedExperience(index);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SiteButton label="SUIVANT" disabled={!canProceed} onClick={handleNext} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
];
|
||||||
@@ -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<SiteStore>()((set) => ({
|
||||||
|
...initialState,
|
||||||
|
setStep: (step) => set({ currentStep: step }),
|
||||||
|
setSelectedExperience: (index) => set({ selectedExperience: index }),
|
||||||
|
setSelectedSituation: (index) => set({ selectedSituation: index }),
|
||||||
|
reset: () => set(initialState),
|
||||||
|
}));
|
||||||
@@ -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 <SiteMobileBlocker />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentStep === "disclaimer") {
|
||||||
|
return <SiteDisclaimerScreen />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SiteLayout>
|
||||||
|
{currentStep === "welcome" && <SiteWelcomeScreen />}
|
||||||
|
{currentStep === "situation" && <SiteSituationScreen />}
|
||||||
|
{currentStep === "naming" && <SiteNamingScreen />}
|
||||||
|
{currentStep === "transition" && <SiteTransitionOverlay />}
|
||||||
|
</SiteLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
createRouter,
|
createRouter,
|
||||||
} from "@tanstack/react-router";
|
} from "@tanstack/react-router";
|
||||||
import { HomePage } from "@/pages/page";
|
import { HomePage } from "@/pages/page";
|
||||||
|
import { SitePage } from "@/pages/site/page";
|
||||||
import { EditorPage } from "@/pages/editor/page";
|
import { EditorPage } from "@/pages/editor/page";
|
||||||
import { GalleryPage } from "@/pages/gallery/page";
|
import { GalleryPage } from "@/pages/gallery/page";
|
||||||
import { WaypointEditorPage } from "@/pages/waypoint/page";
|
import { WaypointEditorPage } from "@/pages/waypoint/page";
|
||||||
@@ -42,6 +43,12 @@ const indexRoute = createRoute({
|
|||||||
component: HomePage,
|
component: HomePage,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const siteRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: "/site",
|
||||||
|
component: SitePage,
|
||||||
|
});
|
||||||
|
|
||||||
const editorRoute = createRoute({
|
const editorRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: "/editor",
|
path: "/editor",
|
||||||
@@ -102,6 +109,7 @@ const docsChildRoutes = [
|
|||||||
|
|
||||||
const routeTree = rootRoute.addChildren([
|
const routeTree = rootRoute.addChildren([
|
||||||
indexRoute,
|
indexRoute,
|
||||||
|
siteRoute,
|
||||||
editorRoute,
|
editorRoute,
|
||||||
galleryRoute,
|
galleryRoute,
|
||||||
waypointRoute,
|
waypointRoute,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { RepairMissionId } from "@/types/gameplay/repairMission";
|
|||||||
* Steps for the /site onboarding page
|
* Steps for the /site onboarding page
|
||||||
*/
|
*/
|
||||||
export type SiteStep =
|
export type SiteStep =
|
||||||
|
| "disclaimer" // Écran 0: Avertissement (ordi recommandé, bonne connexion)
|
||||||
| "welcome" // Écran 1: Bienvenue à Altera
|
| "welcome" // Écran 1: Bienvenue à Altera
|
||||||
| "situation" // Écran 2: Quelle est votre situation
|
| "situation" // Écran 2: Quelle est votre situation
|
||||||
| "naming" // Écran 3: Quel est votre prénom (Danyl)
|
| "naming" // Écran 3: Quel est votre prénom (Danyl)
|
||||||
|
|||||||
@@ -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=/;`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user