Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 2m37s
112 lines
3.8 KiB
TypeScript
112 lines
3.8 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useId, useRef } from "react";
|
||
import { MapPin } from "lucide-react";
|
||
import { useI18n } from "./I18nProvider";
|
||
|
||
const storageKey = "minegeo:intro:v1";
|
||
let seenThisSession = false;
|
||
|
||
/** Home-only, decorative welcome. The underlying page always renders normally. */
|
||
export function FirstVisitIntro() {
|
||
const { t } = useI18n();
|
||
const titleId = useId();
|
||
const dialog = useRef<HTMLDialogElement>(null);
|
||
|
||
useEffect(() => {
|
||
const element = dialog.current;
|
||
if (!element) return;
|
||
const motion = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||
let end: ReturnType<typeof setTimeout> | undefined;
|
||
const finish = () => {
|
||
clearTimeout(end);
|
||
if (element.open) element.close();
|
||
};
|
||
// Defer the browser-only decision; Strict Mode can clean up its first setup.
|
||
const start = setTimeout(() => {
|
||
if (seenThisSession) return;
|
||
try {
|
||
if (localStorage.getItem(storageKey)) return;
|
||
localStorage.setItem(storageKey, "seen");
|
||
} catch {
|
||
try {
|
||
if (sessionStorage.getItem(storageKey)) return;
|
||
sessionStorage.setItem(storageKey, "seen");
|
||
} catch {
|
||
// Both stores denied: keep the fallback for this page session in memory.
|
||
}
|
||
}
|
||
seenThisSession = true;
|
||
// This is decorative delight, so reduced motion goes straight to the site.
|
||
if (motion.matches || !element.isConnected) return;
|
||
// A welcome must never interrupt another modal, such as account notices.
|
||
if (document.querySelector("dialog[open]")) return;
|
||
element.showModal();
|
||
end = setTimeout(finish, 4000);
|
||
}, 0);
|
||
const motionChanged = () => {
|
||
if (motion.matches) finish();
|
||
};
|
||
motion.addEventListener("change", motionChanged);
|
||
element.addEventListener("close", finish);
|
||
return () => {
|
||
clearTimeout(start);
|
||
finish();
|
||
motion.removeEventListener("change", motionChanged);
|
||
element.removeEventListener("close", finish);
|
||
};
|
||
}, []);
|
||
|
||
return (
|
||
<dialog
|
||
ref={dialog}
|
||
className="first-visit-intro"
|
||
aria-labelledby={titleId}
|
||
>
|
||
<div className="intro-cartography" aria-hidden="true" />
|
||
<div className="intro-content">
|
||
<div className="intro-map-stage" aria-hidden="true">
|
||
<span className="intro-coordinate intro-coordinate-left">
|
||
X: −128
|
||
</span>
|
||
<span className="intro-coordinate intro-coordinate-right">
|
||
Z: +256
|
||
</span>
|
||
<div className="intro-map-enter">
|
||
<div className="intro-map-surface">
|
||
<span className="intro-map-water" />
|
||
<span className="intro-map-path" />
|
||
<i className="intro-map-block intro-map-block-one" />
|
||
<i className="intro-map-block intro-map-block-two" />
|
||
<i className="intro-map-block intro-map-block-three" />
|
||
<span className="intro-map-target" />
|
||
</div>
|
||
</div>
|
||
<div className="intro-pin-anchor">
|
||
<MapPin className="intro-pin" strokeWidth={1.5} />
|
||
</div>
|
||
<span className="intro-bearing">N</span>
|
||
</div>
|
||
<div className="intro-title">
|
||
<p className="eyebrow">{t("YOUR NEXT EXPEDITION")}</p>
|
||
<h2 id={titleId} aria-label={t("MineGeo")}>
|
||
<span aria-hidden="true">MINE</span>
|
||
<span aria-hidden="true">GEO</span>
|
||
</h2>
|
||
<p className="intro-subtitle">
|
||
{t("Find your place in a blocky world.")}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
className="intro-skip button"
|
||
autoFocus
|
||
onClick={() => dialog.current?.close()}
|
||
>
|
||
{t("Skip intro")} <span aria-hidden="true">Esc</span>
|
||
</button>
|
||
</dialog>
|
||
);
|
||
}
|