199 lines
5.8 KiB
Svelte
199 lines
5.8 KiB
Svelte
<script lang="ts">
|
|
import { browser } from "$app/environment";
|
|
import Icon from "@iconify/svelte";
|
|
import "$lib/iconify-offline";
|
|
import BlockRenderer from "./BlockRenderer.svelte";
|
|
import type { Translations } from "$lib/translations";
|
|
import type { ResolvedBlock } from "$lib/block-types";
|
|
import {
|
|
selectModal,
|
|
dismissKey,
|
|
type ModalLike,
|
|
} from "$lib/modal-match";
|
|
|
|
interface ModalEntry extends ModalLike {
|
|
trigger?: "immediate" | "delay" | "scroll" | "exit_intent";
|
|
delaySeconds?: number;
|
|
scrollPercent?: number;
|
|
content?: unknown[];
|
|
dismissible?: boolean;
|
|
size?: "sm" | "md" | "lg";
|
|
}
|
|
|
|
let {
|
|
modals = [],
|
|
pathname,
|
|
translations = {},
|
|
}: {
|
|
modals?: ModalEntry[];
|
|
pathname: string;
|
|
translations?: Translations | null;
|
|
} = $props();
|
|
|
|
let active = $state<ModalEntry | null>(null);
|
|
let open = $state(false);
|
|
let dontShowAgain = $state(false);
|
|
|
|
/** Nur diese Session ausgeblendet (X/Escape/Backdrop). */
|
|
function sessionKey(m: ModalEntry): string {
|
|
return `modal-session:${m.slug ?? m._slug ?? "unknown"}:${m.version ?? ""}`;
|
|
}
|
|
|
|
function isDismissed(m: ModalEntry): boolean {
|
|
try {
|
|
if (localStorage.getItem(dismissKey(m))) return true;
|
|
if (sessionStorage.getItem(sessionKey(m))) return true;
|
|
} catch {
|
|
/* Storage gesperrt (Private Mode) → nicht als dismissed werten. */
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function close(permanent: boolean) {
|
|
const m = active;
|
|
open = false;
|
|
if (!m) return;
|
|
try {
|
|
if (permanent && m.dismissible !== false) {
|
|
localStorage.setItem(dismissKey(m), "1");
|
|
} else {
|
|
sessionStorage.setItem(sessionKey(m), "1");
|
|
}
|
|
} catch {
|
|
/* Storage gesperrt → Modal kommt beim nächsten Load wieder. */
|
|
}
|
|
}
|
|
|
|
function onKeydown(e: KeyboardEvent) {
|
|
if (e.key === "Escape" && open) close(dontShowAgain);
|
|
}
|
|
|
|
const sizeClass = $derived(
|
|
active?.size === "sm"
|
|
? "max-w-md"
|
|
: active?.size === "lg"
|
|
? "max-w-3xl"
|
|
: "max-w-xl",
|
|
);
|
|
|
|
// Kandidat je Route neu wählen und Trigger scharfschalten. Cleanup läuft vor
|
|
// jedem Re-Run (Navigation) und beim Destroy — entfernt Timer/Listener.
|
|
$effect(() => {
|
|
if (!browser) return;
|
|
const path = pathname;
|
|
const list = modals ?? [];
|
|
|
|
open = false;
|
|
dontShowAgain = false;
|
|
const m = selectModal<ModalEntry>(list, path, Date.now(), isDismissed);
|
|
active = m;
|
|
if (!m) return;
|
|
|
|
const show = () => {
|
|
open = true;
|
|
};
|
|
let removeScroll: (() => void) | null = null;
|
|
let removeExit: (() => void) | null = null;
|
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
switch (m.trigger ?? "delay") {
|
|
case "immediate":
|
|
show();
|
|
break;
|
|
case "delay":
|
|
timer = setTimeout(show, Math.max(0, m.delaySeconds ?? 3) * 1000);
|
|
break;
|
|
case "scroll": {
|
|
const pct = Math.min(100, Math.max(1, m.scrollPercent ?? 30));
|
|
const onScroll = () => {
|
|
const h =
|
|
document.documentElement.scrollHeight - window.innerHeight;
|
|
const scrolled = h > 0 ? (window.scrollY / h) * 100 : 100;
|
|
if (scrolled >= pct) {
|
|
show();
|
|
removeScroll?.();
|
|
removeScroll = null;
|
|
}
|
|
};
|
|
window.addEventListener("scroll", onScroll, { passive: true });
|
|
removeScroll = () =>
|
|
window.removeEventListener("scroll", onScroll);
|
|
onScroll(); // Fall: Seite kürzer als Trigger → sofort
|
|
break;
|
|
}
|
|
case "exit_intent": {
|
|
// Nur Desktop (feiner Zeiger) — Touch hat kein Exit-Intent.
|
|
if (window.matchMedia("(pointer: fine)").matches) {
|
|
const onOut = (e: MouseEvent) => {
|
|
if (e.clientY <= 0) show();
|
|
};
|
|
document.addEventListener("mouseout", onOut);
|
|
removeExit = () =>
|
|
document.removeEventListener("mouseout", onOut);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
return () => {
|
|
if (timer) clearTimeout(timer);
|
|
removeScroll?.();
|
|
removeExit?.();
|
|
};
|
|
});
|
|
|
|
const blocks = $derived((active?.content ?? []) as ResolvedBlock[]);
|
|
</script>
|
|
|
|
<svelte:window on:keydown={onKeydown} />
|
|
|
|
{#if open && active}
|
|
<div
|
|
class="fixed inset-0 z-[9998] flex items-center justify-center bg-black/70 p-4 backdrop-blur-sm"
|
|
role="presentation"
|
|
onclick={() => close(dontShowAgain)}
|
|
>
|
|
<div
|
|
class="relative w-full {sizeClass} max-h-[90vh] overflow-y-auto rounded-lg bg-white p-6 shadow-2xl md:p-8"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
onclick={(e) => e.stopPropagation()}
|
|
>
|
|
<button
|
|
type="button"
|
|
onclick={() => close(dontShowAgain)}
|
|
class="absolute top-3 right-3 rounded-full p-2 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-800"
|
|
aria-label="Schließen"
|
|
>
|
|
<Icon icon="lucide:x" class="size-5" aria-hidden="true" />
|
|
</button>
|
|
|
|
<div class="content-modal-body">
|
|
{#each blocks as block (block)}
|
|
<BlockRenderer {block} {translations} />
|
|
{/each}
|
|
</div>
|
|
|
|
{#if active.dismissible !== false}
|
|
<div class="mt-6 flex items-center justify-between gap-4 border-t border-gray-200 pt-4">
|
|
<label class="flex cursor-pointer items-center gap-2 text-sm text-gray-600">
|
|
<input
|
|
type="checkbox"
|
|
class="size-4 accent-wald-700"
|
|
bind:checked={dontShowAgain}
|
|
/>
|
|
<span>Dauerhaft nicht mehr anzeigen</span>
|
|
</label>
|
|
<button
|
|
type="button"
|
|
class="rounded-md bg-wald-700 px-4 py-2 text-sm font-semibold text-white transition-colors hover:bg-wald-800"
|
|
onclick={() => close(dontShowAgain)}
|
|
>
|
|
Schließen
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/if}
|