import { content } from "@deadhead/content"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useEffect, useRef, useState, type KeyboardEvent } from "react"; import { createPortal } from "react-dom"; import { useNavigate } from "react-router"; import { useApi } from "../api/context"; import { useLoadedPlayer } from "../state/actions"; import { PLAYER_STATE_KEY } from "../state/player"; import { reducedMotionOn, usePreferences } from "../state/preferences"; import { Button } from "../ui"; const FOCUSABLE = 'button:not([disabled]), [href], [tabindex]:not([tabindex="-1"])'; /** A card's body with the tutorial employer's name filled in. */ export function welcomeBody(body: string): string { return body.split("{company}").join(content.tutorialCompanyName); } /** * The three welcome cards (decisions 0.5.131, 0.5.132): Next through them, * Skip on every card, and a last button that starts the game ("Let's fly") or, * reopened from Settings → Help, just closes ("Close"). Cards slide in, or * change at once with reduced motion; focus stays inside until it closes. */ export function WelcomeCards({ final, onClose, }: { final: "start" | "close"; onClose: () => void; }) { const { cards, next, skip, start, close } = content.welcome; const [index, setIndex] = useState(0); const reduced = usePreferences(reducedMotionOn); const dialog = useRef(null); const touchX = useRef(null); const card = cards[index]!; const last = index === cards.length - 1; // Focus moves into the pop-up, onto the main button of each card. useEffect(() => { dialog.current?.querySelector("[data-primary]")?.focus(); }, [index]); const advance = () => (last ? onClose() : setIndex(index + 1)); // Tab and Shift+Tab stay inside; Escape is Skip. const onKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); onClose(); return; } if (e.key !== "Tab" || !dialog.current) return; const focusable = [...dialog.current.querySelectorAll(FOCUSABLE)]; if (focusable.length === 0) return; const first = focusable[0]!; const end = focusable.at(-1)!; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); end.focus(); } else if (!e.shiftKey && document.activeElement === end) { e.preventDefault(); first.focus(); } }; return createPortal(
{ touchX.current = e.touches[0]?.clientX ?? null; }} onTouchEnd={(e) => { const from = touchX.current; const to = e.changedTouches[0]?.clientX; touchX.current = null; if (from === null || to === undefined) return; // Swipe left for the next card, right for the previous one. if (from - to > 50) advance(); else if (to - from > 50 && index > 0) setIndex(index - 1); }} className="w-full max-w-md overflow-hidden rounded-t-2xl border border-glass-line bg-space-850 p-6 pb-[max(1.5rem,env(safe-area-inset-bottom))] shadow-panel sm:rounded-2xl" >

{index + 1} of {cards.length}

{card.title}

{welcomeBody(card.body)}

, document.body, ); } /** * A new company's welcome (decision 0.5.131): shown until this company has * seen it; closing it records that and goes to Contracts, where the tutorial * intro card follows. */ export function WelcomeGate() { const state = useLoadedPlayer(); const api = useApi(); const queryClient = useQueryClient(); const navigate = useNavigate(); // Closed for this company: a new company (Settings) gets its own welcome. const [closedFor, setClosedFor] = useState(null); const seen = useMutation({ mutationFn: () => api.call("markWelcomeSeen", {}), onSuccess: (next) => queryClient.setQueryData(PLAYER_STATE_KEY, next), }); if (state.welcomeSeen || closedFor === state.company.id) return null; return ( { setClosedFor(state.company.id); seen.mutate(); void navigate("/contracts"); }} /> ); }