feat(modal): route-targeted content modals on navigation
Deploy / verify (push) Successful in 1m12s
Deploy / deploy (push) Successful in 2m10s

Add a global modal/popup system driven entirely from the CMS. A new
`modal` config collection self-targets routes via a glob/regex urlPattern
(optional time window), and its `content` array of block references renders
through the existing BlockRenderer — so markdown, HTML and YouTube blocks
work exactly like a page row. A `modals` list collection holds the active
set behind an enable flag (analogous to `campaigns`).

- modal-match.ts: glob (`*`/`**`) + `~regex` pathname matcher, time window
  and modal selection helpers (+ unit tests)
- ContentModal.svelte: immediate/delay/scroll/exit_intent triggers, session
  vs. permanent dismiss (sessionStorage + localStorage, keyed by slug+version)
- +layout.server.ts: load `modals-global` in the bootstrap batch, resolve
  block images server-side
- +layout.svelte: mount on non-embed routes, match against the current path

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-07-06 09:47:49 +02:00
parent 121b593e4e
commit 84c41f80b1
5 changed files with 369 additions and 2 deletions
+198
View File
@@ -0,0 +1,198 @@
<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-sm 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-sm 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}
+108
View File
@@ -0,0 +1,108 @@
import { describe, it, expect } from 'vitest';
import { matchUrlPattern, inTimeWindow, selectModal } from './modal-match';
describe('matchUrlPattern — glob', () => {
it('exact path', () => {
expect(matchUrlPattern('/aktion', '/aktion')).toBe(true);
expect(matchUrlPattern('/aktion', '/aktion/x')).toBe(false);
expect(matchUrlPattern('/aktion', '/aktionismus')).toBe(false);
});
it('trailing slash is irrelevant', () => {
expect(matchUrlPattern('/aktion', '/aktion/')).toBe(true);
expect(matchUrlPattern('/aktion/', '/aktion')).toBe(true);
});
it('root', () => {
expect(matchUrlPattern('/', '/')).toBe(true);
expect(matchUrlPattern('/', '/x')).toBe(false);
});
it('single segment *', () => {
expect(matchUrlPattern('/aktion/*', '/aktion/x')).toBe(true);
expect(matchUrlPattern('/aktion/*', '/aktion/x/y')).toBe(false);
// '/aktion/*' matcht nicht /aktion selbst
expect(matchUrlPattern('/aktion/*', '/aktion')).toBe(false);
});
it('recursive ** includes the prefix itself', () => {
expect(matchUrlPattern('/aktion/**', '/aktion')).toBe(true);
expect(matchUrlPattern('/aktion/**', '/aktion/')).toBe(true);
expect(matchUrlPattern('/aktion/**', '/aktion/x')).toBe(true);
expect(matchUrlPattern('/aktion/**', '/aktion/x/y')).toBe(true);
expect(matchUrlPattern('/aktion/**', '/andere')).toBe(false);
});
it('post section', () => {
expect(matchUrlPattern('/post/**', '/post/mein-beitrag')).toBe(true);
expect(matchUrlPattern('/post/**', '/')).toBe(false);
});
it('global patterns', () => {
expect(matchUrlPattern('**', '/irgendwas/tief')).toBe(true);
expect(matchUrlPattern('**', '/')).toBe(true);
});
it('case-insensitive', () => {
expect(matchUrlPattern('/Aktion', '/aktion')).toBe(true);
});
it('ignores query/hash on pathname', () => {
expect(matchUrlPattern('/aktion', '/aktion?x=1')).toBe(true);
});
});
describe('matchUrlPattern — regex (~ prefix)', () => {
it('applies regex', () => {
expect(matchUrlPattern('~^/download-\\d+$', '/download-42')).toBe(true);
expect(matchUrlPattern('~^/download-\\d+$', '/download-abc')).toBe(false);
});
it('broken regex → no match, no throw', () => {
expect(matchUrlPattern('~(', '/x')).toBe(false);
});
});
describe('inTimeWindow', () => {
const now = Date.parse('2026-07-06T12:00:00Z');
it('open window', () => {
expect(inTimeWindow({}, now)).toBe(true);
});
it('before from', () => {
expect(inTimeWindow({ timeFrom: '2026-07-10T00:00:00Z' }, now)).toBe(false);
});
it('after until', () => {
expect(inTimeWindow({ timeUntil: '2026-07-01T00:00:00Z' }, now)).toBe(false);
});
it('within', () => {
expect(
inTimeWindow(
{ timeFrom: '2026-07-01T00:00:00Z', timeUntil: '2026-07-10T00:00:00Z' },
now,
),
).toBe(true);
});
});
describe('selectModal', () => {
const now = Date.parse('2026-07-06T12:00:00Z');
const never = () => false;
it('picks first matching, in-window, not dismissed', () => {
const modals = [
{ slug: 'a', urlPattern: '/other' },
{ slug: 'b', urlPattern: '/aktion/**' },
{ slug: 'c', urlPattern: '**' },
];
expect(selectModal(modals, '/aktion/x', now, never)?.slug).toBe('b');
});
it('skips dismissed', () => {
const modals = [
{ slug: 'b', urlPattern: '**' },
{ slug: 'c', urlPattern: '**' },
];
const dismissed = (m: { slug?: string }) => m.slug === 'b';
expect(selectModal(modals, '/x', now, dismissed)?.slug).toBe('c');
});
it('returns null when nothing matches', () => {
expect(selectModal([{ slug: 'a', urlPattern: '/x' }], '/y', now, never)).toBe(null);
});
});
Binary file not shown.
+56 -2
View File
@@ -19,7 +19,8 @@ import type {
TopBannerEntry,
} from '$lib/cms';
import type { DeadlineBannerBlockData } from '$lib/block-types';
import { ensureTransformedImage } from '$lib/rusty-image';
import { ensureTransformedImage, resolveContentImages } from '$lib/rusty-image';
import { sanitizeBlocks } from '$lib/sanitize-blocks.server';
import {
DEFAULT_SOCIAL_IMAGE_URL,
ROW_RESOLVE,
@@ -42,6 +43,28 @@ interface SocialLink {
label?: string;
}
/** Ein aufgelöstes Modal (config-Collection `modal`). Content = Block-Liste. */
export interface ModalEntry {
_slug?: string;
slug?: string;
urlPattern?: string;
trigger?: 'immediate' | 'delay' | 'scroll' | 'exit_intent';
delaySeconds?: number;
scrollPercent?: number;
content?: unknown[];
dismissible?: boolean;
version?: string;
size?: 'sm' | 'md' | 'lg';
timeFrom?: string | null;
timeUntil?: string | null;
}
/** Der `modals`-Config-Eintrag: enable-Flag + Liste aufgelöster Modals. */
interface ModalsConfigEntry {
enable?: boolean;
modals?: ModalEntry[];
}
/** Führenden Slash ignorieren (CMS liefert z. B. "slug": "/about"). */
function normalizeSlug(s: string | undefined): string {
return (s ?? '').replace(/^\//, '').trim();
@@ -77,6 +100,7 @@ export const load: LayoutServerLoad = async ({ locals, route }) => {
seoTitleTemplate: null,
seoDescriptionTemplate: null,
analyticsPm86SiteId: null,
modals: [] as ModalEntry[],
};
}
@@ -91,13 +115,14 @@ export const load: LayoutServerLoad = async ({ locals, route }) => {
let bootstrapConfig: PageConfigEntry | null = null;
let bootstrapTopBanner: TopBannerEntry | null = null;
let bootstrapDeadlineBanner: DeadlineBannerBlockData | null = null;
let activeModals: ModalEntry[] = [];
try {
// TTL-Cache über den ganzen Batch — lief vorher ungecacht bei jedem
// SSR-Request. CSV-Key-Head: Webhook-Purge jeder beteiligten Collection
// invalidiert den Eintrag (siehe invalidateCollection in cms.ts).
const batch = await cached(
'navigation',
'navigation,footer,page_config,top_banner,deadline_banner:layout-bootstrap:de',
'navigation,footer,page_config,top_banner,deadline_banner,modals:layout-bootstrap:de',
() => batchFetch([
{
id: 'navHeader',
@@ -141,6 +166,15 @@ export const load: LayoutServerLoad = async ({ locals, route }) => {
locale: 'de',
resolve: 'all',
},
{
// Globale Modal-Liste (config-Collection `modals`, ein Eintrag). Enthält
// die aktiven Modals als aufgelöste Referenzen inkl. content-Blocks.
id: 'modals',
collection: 'modals',
slug: 'modals-global',
locale: 'de',
resolve: 'all',
},
]));
bootstrapNavHeader = batchData<NavigationEntry>(batch.results.navHeader);
bootstrapNavSocial = batchData<NavigationEntry>(batch.results.navSocial);
@@ -148,11 +182,30 @@ export const load: LayoutServerLoad = async ({ locals, route }) => {
bootstrapConfig = batchData<PageConfigEntry>(batch.results.config);
bootstrapTopBanner = batchData<TopBannerEntry>(batch.results.topBanner);
bootstrapDeadlineBanner = batchData<DeadlineBannerBlockData>(batch.results.deadlineBanner);
const modalsConfig = batchData<ModalsConfigEntry>(batch.results.modals);
if (modalsConfig?.enable !== false) {
activeModals = (modalsConfig?.modals ?? []).filter(
(m): m is ModalEntry => typeof m === 'object' && m !== null,
);
}
} catch (err) {
/* CMS nicht erreichbar — alle Felder bleiben null, Page rendert ohne. */
logWarn('layout.bootstrap', err);
}
// Bilder/Markdown in den Modal-Content-Blocks server-seitig auflösen — gleiche
// Pipeline wie Page-Rows (resolveContentImages erwartet rowNContent-Shape,
// darum content als row1Content wrappen; Blocks werden in-place mutiert).
try {
for (const m of activeModals) {
if (!Array.isArray(m.content) || m.content.length === 0) continue;
await resolveContentImages({ row1Content: m.content });
sanitizeBlocks(m);
}
} catch (err) {
logWarn('layout.modals', err);
}
// Fallback für config: Slug "default" probieren (legacy), wenn die kanonische
// Variante fehlt. Selten genug für einen weiteren Single-Call.
if (!bootstrapConfig) {
@@ -416,5 +469,6 @@ export const load: LayoutServerLoad = async ({ locals, route }) => {
(pageConfig as { analyticsPm86SiteId?: string } | null)?.analyticsPm86SiteId ?? null,
announcementBanner: bootstrapTopBanner,
deadlineBanner: bootstrapDeadlineBanner,
modals: activeModals,
};
};
+7
View File
@@ -9,6 +9,7 @@
import AnnouncementBanner from '$lib/components/AnnouncementBanner.svelte';
import DeadlineBannerBlock from '$lib/components/blocks/DeadlineBannerBlock.svelte';
import TranslationProvider from '$lib/components/TranslationProvider.svelte';
import ContentModal from '$lib/components/ContentModal.svelte';
import Breadcrumbs from '$lib/ui/Breadcrumbs.svelte';
import type { LayoutData } from './$types';
import { ensureTransformedImage } from '$lib/rusty-image';
@@ -406,5 +407,11 @@
<Footer footer={data.footerData} translations={data.translations} />
</div>
</div>
<ContentModal
modals={data.modals}
pathname={$page.url.pathname}
translations={data.translations}
/>
{/if}
</TranslationProvider>