deadhead-space-freight-tycoon/apps/web/src/components/Welcome.tsx

149 lines
5.4 KiB
TypeScript

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<HTMLDivElement>(null);
const touchX = useRef<number | null>(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<HTMLElement>("[data-primary]")?.focus();
}, [index]);
const advance = () => (last ? onClose() : setIndex(index + 1));
// Tab and Shift+Tab stay inside; Escape is Skip.
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key === "Escape") {
e.preventDefault();
onClose();
return;
}
if (e.key !== "Tab" || !dialog.current) return;
const focusable = [...dialog.current.querySelectorAll<HTMLElement>(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(
<div className="fixed inset-0 z-40 flex items-end justify-center bg-black/25 backdrop-blur-sm sm:items-center sm:p-4">
<div
ref={dialog}
role="dialog"
aria-modal="true"
aria-labelledby="welcome-title"
onKeyDown={onKeyDown}
onTouchStart={(e) => {
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"
>
<p aria-live="polite" className="font-display text-xs font-semibold text-ink-300">
{index + 1} of {cards.length}
</p>
<div key={index} data-testid="welcome-card" className={reduced ? "" : "animate-slide"}>
<h2 id="welcome-title" className="mt-2 font-title text-lg leading-snug">
{card.title}
</h2>
<p className="mt-3 text-ink-200">{welcomeBody(card.body)}</p>
</div>
<div aria-hidden="true" className="mt-5 flex justify-center gap-1.5">
{cards.map((_, i) => (
<span
key={i}
className={`h-1.5 rounded-full ${i === index ? "w-5 bg-action" : "w-1.5 bg-white/25"}`}
/>
))}
</div>
<div className="mt-5 flex gap-3">
<Button variant="secondary" onClick={onClose} className="flex-1">
{skip}
</Button>
<Button data-primary onClick={advance} className="flex-1">
{last ? (final === "start" ? start : close) : next}
</Button>
</div>
</div>
</div>,
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<string | null>(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 (
<WelcomeCards
final="start"
onClose={() => {
setClosedFor(state.company.id);
seen.mutate();
void navigate("/contracts");
}}
/>
);
}