149 lines
5.4 KiB
TypeScript
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");
|
|
}}
|
|
/>
|
|
);
|
|
}
|