'use strict';
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
    function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
    return new (P || (P = Promise))(function (resolve, reject) {
        function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
        function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
        function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
        step((generator = generator.apply(thisArg, _arguments || [])).next());
    });
};
import { Controller } from '@hotwired/stimulus';
// Registers <el-dialog>, <el-dialog-backdrop>, <el-dialog-panel> custom
// elements that the share modal uses. modal.ts also imports this, but
// importing here makes wishlists.ts self-contained and the JS bundler
// dedupes the side effects.
import '@tailwindplus/elements';
/**
 * Navigate to `url` inside the offer-list Turbo frame so the page doesn't
 * scroll-jump to the top. The frame has `data-turbo-action="advance"`, so
 * Turbo also pushes browser history — URLs stay shareable / cacheable.
 *
 * Inlined here (rather than imported from filter.ts) because Symfony
 * AssetMapper doesn't resolve relative imports between controllers.
 */
function navigateInFrame(url) {
    const a = document.createElement('a');
    a.href = url;
    a.setAttribute('data-turbo-frame', 'cms-offer-list');
    a.setAttribute('data-turbo-action', 'advance');
    a.style.display = 'none';
    document.body.appendChild(a);
    a.click();
    a.remove();
}
const LS_KEY = 'ferienpass.wishlists.v2';
const LS_KEY_V1 = 'ferienpass.wishlists.v1';
const STATE_URL = '/_wishlist';
const SYNC_URL = '/_wishlist/sync';
// Drop tombstones from localStorage after this long — by then every device has
// long since seen the deletion.
const TOMBSTONE_TTL_MS = 90 * 24 * 60 * 60 * 1000;
// Coalesce bursts of local mutations into one account sync.
const SYNC_DEBOUNCE_MS = 800;
function nowIso() {
    return new Date().toISOString();
}
function cardRefs(card) {
    return {
        uuid: card.getAttribute('data-ferienpass--cms-offer-card-offer-uuid-value') || '',
        id: card.getAttribute('data-ferienpass--cms-offer-card-offer-id-value') || '',
    };
}
// The non-empty identifiers a card can be matched by (uuid + legacy id).
function refKeys(refs) {
    return [refs.uuid, refs.id].filter((s) => s !== '');
}
// The reference to persist when adding a card to a list: the base58 UUID when
// available, falling back to the legacy id.
function canonicalRef(refs) {
    return refs.uuid || refs.id;
}
// Is this card on the wishlist? Matches by UUID or by legacy id, so both
// new and pre-migration entries resolve.
function listHasCard(wl, refs) {
    const keys = new Set(refKeys(refs));
    return wl.offerIds.some((ref) => keys.has(String(ref)));
}
function uuidv4() {
    if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
        return crypto.randomUUID();
    }
    return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
        const r = (Math.random() * 16) | 0;
        const v = c === 'x' ? r : (r & 0x3) | 0x8;
        return v.toString(16);
    });
}
// Coerce a (possibly legacy v1 or partial) stored entry into a valid v2
// wishlist, stamping timestamps that are missing.
function normalizeStored(w) {
    if (!w || typeof w.uuid !== 'string' || typeof w.name !== 'string' || !Array.isArray(w.offerIds)) {
        return null;
    }
    const created = typeof w.createdAt === 'string' ? w.createdAt : nowIso();
    return {
        uuid: w.uuid,
        name: w.name,
        offerIds: w.offerIds,
        createdAt: created,
        modifiedAt: typeof w.modifiedAt === 'string' ? w.modifiedAt : created,
        deletedAt: typeof w.deletedAt === 'string' ? w.deletedAt : null,
    };
}
function readAll() {
    let lists = readKey(LS_KEY);
    if (lists === null) {
        // One-time migration from the pre-account-sync schema.
        const legacy = readKey(LS_KEY_V1);
        if (legacy !== null) {
            try {
                window.localStorage.removeItem(LS_KEY_V1);
            }
            catch (_a) {
                /* ignore */
            }
            writeAll(legacy);
            lists = legacy;
        }
        else {
            lists = [];
        }
    }
    // Purge stale tombstones so localStorage doesn't grow unbounded.
    const cutoff = Date.now() - TOMBSTONE_TTL_MS;
    const pruned = lists.filter((w) => {
        if (!w.deletedAt)
            return true;
        const t = Date.parse(w.deletedAt);
        return Number.isNaN(t) || t >= cutoff;
    });
    if (pruned.length !== lists.length) {
        writeAll(pruned);
    }
    return pruned;
}
function readKey(key) {
    try {
        const raw = window.localStorage.getItem(key);
        if (!raw)
            return null;
        const parsed = JSON.parse(raw);
        if (!Array.isArray(parsed))
            return null;
        return parsed.map(normalizeStored).filter((w) => w !== null);
    }
    catch (_a) {
        return null;
    }
}
function writeAll(lists) {
    window.localStorage.setItem(LS_KEY, JSON.stringify(lists));
}
export default class default_1 extends Controller {
    constructor() {
        super(...arguments);
        // `lists` holds ALL entries including tombstones (the sync needs to send
        // deletions); UI/filtering use `active()`.
        this.lists = [];
        this.mode = null;
        this.filteredUuids = new Set();
        // Account-sync state (authenticated users only).
        this.authenticated = false;
        this.csrfToken = null;
        this.syncTimer = null;
        /**
         * After Turbo swaps the offer-list frame, re-apply wishlist filtering
         * (cards are new DOM nodes, our hidden-attribute is gone).
         */
        this.onFrameLoad = (e) => {
            const frame = e.target;
            if ((frame === null || frame === void 0 ? void 0 : frame.id) !== 'cms-offer-list')
                return;
            this.refreshFilteredUuids();
            this.refreshFilterTrigger();
            this.applyWishlistFilter();
            this.renderActiveFilterPill();
        };
        this.onDocumentClick = (e) => {
            if (!this.isOpen())
                return;
            const t = e.target;
            if (this.popoverTarget.contains(t))
                return;
            if (this.hasFilterTriggerTarget && this.filterTriggerTarget.contains(t))
                return;
            // Don't close if a card heart was clicked — its handler decides.
            if (t.closest('[data-action*="ferienpass--cms-offer-card#openWishlistMenu"]'))
                return;
            this.closePopover();
        };
        this.onKeydown = (e) => {
            if (e.key === 'Escape' && this.isOpen())
                this.closePopover();
        };
        this.onStorage = (e) => {
            if (e.key !== LS_KEY)
                return;
            this.lists = readAll();
            this.decorateCards();
            if (this.isOpen())
                this.renderList();
        };
    }
    connect() {
        this.lists = readAll();
        this.importFromUrl();
        this.refreshFilteredUuids();
        this.decorateCards();
        this.refreshFilterTrigger();
        this.applyWishlistFilter();
        document.addEventListener('click', this.onDocumentClick);
        document.addEventListener('keydown', this.onKeydown);
        window.addEventListener('storage', this.onStorage);
        document.addEventListener('turbo:frame-load', this.onFrameLoad);
        // Background: learn auth state and reconcile with the account. Never
        // blocks the (already-rendered) local UI.
        void this.bootstrapSync();
    }
    disconnect() {
        document.removeEventListener('click', this.onDocumentClick);
        document.removeEventListener('keydown', this.onKeydown);
        window.removeEventListener('storage', this.onStorage);
        document.removeEventListener('turbo:frame-load', this.onFrameLoad);
        if (this.syncTimer !== null) {
            window.clearTimeout(this.syncTimer);
            this.syncTimer = null;
        }
    }
    cardTargetConnected(card) {
        this.decorateCard(card);
        this.applyWishlistFilter();
    }
    // ----- Account sync -----
    /**
     * Bootstrap: GET the private state endpoint to learn whether we're
     * authenticated and obtain a CSRF token. If so, push the full local set to
     * the account and adopt the authoritative result.
     */
    bootstrapSync() {
        return __awaiter(this, void 0, void 0, function* () {
            let state;
            try {
                const res = yield fetch(STATE_URL, {
                    headers: { Accept: 'application/json' },
                    credentials: 'same-origin',
                });
                if (!res.ok)
                    return;
                state = (yield res.json());
            }
            catch (_a) {
                return; // offline / network error → localStorage-only, no harm
            }
            if (!state || !state.authenticated || !state.csrfToken) {
                this.authenticated = false;
                return;
            }
            this.authenticated = true;
            this.csrfToken = state.csrfToken;
            // Push local (incl. tombstones) and adopt the reconciled set. The
            // server applies last-write-wins, so this realises both upload-on-login
            // and cross-device convergence in one round-trip.
            yield this.pushSync();
        });
    }
    /**
     * Send the full local set to the account and adopt the authoritative
     * response. No-op (silent) when unauthenticated or on network error.
     */
    pushSync() {
        return __awaiter(this, void 0, void 0, function* () {
            if (!this.authenticated || !this.csrfToken)
                return;
            let result;
            try {
                const res = yield fetch(SYNC_URL, {
                    method: 'POST',
                    credentials: 'same-origin',
                    headers: {
                        'Content-Type': 'application/json',
                        Accept: 'application/json',
                        'X-CSRF-Token': this.csrfToken,
                    },
                    body: JSON.stringify({
                        _token: this.csrfToken,
                        wishlists: this.lists.map((wl) => ({
                            uuid: wl.uuid,
                            name: wl.name,
                            modifiedAt: wl.modifiedAt,
                            deletedAt: wl.deletedAt,
                            offers: wl.offerIds.map((r) => String(r)),
                        })),
                    }),
                });
                if (!res.ok)
                    return;
                result = (yield res.json());
            }
            catch (_a) {
                return;
            }
            this.adoptAuthoritative(result);
        });
    }
    /**
     * Replace local state with the server's authoritative set: active lists
     * become the new `lists`; anything the server reports deleted is dropped.
     */
    adoptAuthoritative(result) {
        if (!result || !Array.isArray(result.wishlists))
            return;
        this.lists = result.wishlists.map((s) => ({
            uuid: s.uuid,
            name: s.name,
            offerIds: Array.isArray(s.offers) ? s.offers : [],
            createdAt: typeof s.createdAt === 'string' ? s.createdAt : nowIso(),
            modifiedAt: typeof s.modifiedAt === 'string' ? s.modifiedAt : nowIso(),
            deletedAt: null,
        }));
        writeAll(this.lists);
        this.refreshFilteredUuids();
        this.decorateCards();
        this.refreshFilterTrigger();
        this.applyWishlistFilter();
        if (this.isOpen())
            this.renderList();
    }
    /** Debounced account push after a local mutation. */
    scheduleSync() {
        if (!this.authenticated)
            return;
        if (this.syncTimer !== null)
            window.clearTimeout(this.syncTimer);
        this.syncTimer = window.setTimeout(() => {
            this.syncTimer = null;
            void this.pushSync();
        }, SYNC_DEBOUNCE_MS);
    }
    /** Active (non-tombstoned) lists — everything the UI shows. */
    active() {
        return this.lists.filter((w) => !w.deletedAt);
    }
    findList(uuid) {
        return this.active().find((w) => w.uuid === uuid);
    }
    touch(wl) {
        wl.modifiedAt = nowIso();
    }
    importFromUrl() {
        const url = new URL(window.location.href);
        // Share links carry the wishlist as `/merkliste/<ids>?liste=<uuid>&liste_name=<name>`.
        // The `liste_name` presence is what distinguishes a share URL from
        // a filter URL — filter URLs use `liste` for selection too but never
        // carry `liste_name`.
        const uuid = url.searchParams.get('liste');
        const name = url.searchParams.get('liste_name');
        if (!uuid || !name)
            return;
        // Guard against filter URLs where `liste` holds a comma-separated list.
        if (uuid.includes(','))
            return;
        // FavoritesController treats the path slug as space-separated offer
        // refs: base58 UUIDs (current) or legacy integer ids (older links).
        const path = window.location.pathname;
        const m = path.match(/\/merkliste\/([^/?]+)/);
        const idsRaw = m ? decodeURIComponent(m[1]) : '';
        const offerIds = idsRaw
            .split(/\s+/)
            .map((s) => s.trim())
            .filter((s) => s !== '')
            .map((s) => {
            if (/^\d+$/.test(s))
                return parseInt(s, 10);
            // base58-encoded UUID (Bitcoin alphabet, 21–22 chars).
            if (/^[1-9A-HJ-NP-Za-km-z]{21,22}$/.test(s))
                return s;
            return null;
        })
            .filter((r) => r !== null);
        if (!offerIds.length)
            return;
        const existing = this.lists.find((w) => w.uuid === uuid);
        if (existing) {
            // Revive if the user had deleted this list and re-opens a share link.
            if (existing.deletedAt) {
                existing.deletedAt = null;
                existing.offerIds = offerIds;
                this.touch(existing);
                writeAll(this.lists);
                this.scheduleSync();
            }
            return;
        }
        const now = nowIso();
        this.lists.push({ uuid, name, offerIds, createdAt: now, modifiedAt: now, deletedAt: null });
        writeAll(this.lists);
        this.scheduleSync();
        this.toast(this.trans('wishlists.imported', { name }));
    }
    trans(key, replacements) {
        const fallbacks = {
            'wishlists.imported': 'Merkliste „{name}“ wurde gespeichert',
            'wishlists.created': 'Merkliste „{name}“ erstellt',
            'wishlists.deleted': 'Merkliste „{name}“ gelöscht',
            'wishlists.linkCopied': 'Link kopiert',
            'wishlists.empty': 'Noch keine Merkliste. Lege unten eine an.',
            'wishlists.filterTitle': 'Filter: nur Angebote auf …',
            'wishlists.addTitle': 'Zu Merkliste hinzufügen',
            'wishlists.defaultName': 'Meine Merkliste',
            'wishlists.unknown': 'Merkliste (nicht hier gespeichert)',
            'wishlists.copyTitle': 'Link zur Merkliste kopieren',
            'wishlists.deleteTitle': 'Merkliste löschen',
            'wishlists.shareTitle': 'Merkliste teilen',
            'wishlists.shareIntro': 'Sende diesen Link an dein anderes Gerät oder an eine andere Person. Sie sieht dann genau deine Wunschangebote.',
            'wishlists.shareCopy': 'Link kopieren',
            'wishlists.shareCopied': 'Kopiert!',
            'wishlists.shareClose': 'Schließen',
        };
        let text = fallbacks[key] || key;
        if (replacements) {
            for (const k in replacements)
                text = text.replace(new RegExp(`\\{${k}\\}`, 'g'), replacements[k]);
        }
        return text;
    }
    // ----- Popover open / close -----
    openFilterMenu(event) {
        var _a;
        event === null || event === void 0 ? void 0 : event.stopPropagation();
        if (((_a = this.mode) === null || _a === void 0 ? void 0 : _a.kind) === 'filter' && this.isOpen()) {
            this.closePopover();
            return;
        }
        this.mode = { kind: 'filter' };
        this.popTitleTarget.textContent = this.trans('wishlists.filterTitle');
        this.openAt(event === null || event === void 0 ? void 0 : event.currentTarget);
    }
    openCardMenu(anchor, refs) {
        var _a;
        if (((_a = this.mode) === null || _a === void 0 ? void 0 : _a.kind) === 'card' && canonicalRef(this.mode.refs) === canonicalRef(refs) && this.isOpen()) {
            this.closePopover();
            return;
        }
        this.mode = { kind: 'card', refs };
        this.popTitleTarget.textContent = this.trans('wishlists.addTitle');
        this.openAt(anchor);
    }
    openAt(anchor) {
        var _a;
        this.renderList();
        if (!anchor)
            anchor = this.hasFilterTriggerTarget ? this.filterTriggerTarget : this.element;
        const rect = anchor.getBoundingClientRect();
        const pop = this.popoverTarget;
        pop.hidden = false;
        pop.style.position = 'absolute';
        const top = window.scrollY + rect.bottom + 6;
        let left = window.scrollX + rect.left;
        const popWidth = 300;
        if (left + popWidth > window.scrollX + window.innerWidth - 12) {
            left = Math.max(8, window.scrollX + rect.right - popWidth);
        }
        pop.style.top = `${top}px`;
        pop.style.left = `${left}px`;
        if (this.hasFilterTriggerTarget && ((_a = this.mode) === null || _a === void 0 ? void 0 : _a.kind) === 'filter') {
            this.filterTriggerTarget.setAttribute('aria-expanded', 'true');
        }
    }
    closePopover() {
        this.popoverTarget.hidden = true;
        this.newFormTarget.hidden = true;
        this.newNameTarget.value = '';
        this.newBtnTarget.disabled = true;
        if (this.hasFilterTriggerTarget)
            this.filterTriggerTarget.setAttribute('aria-expanded', 'false');
        this.mode = null;
    }
    isOpen() {
        return !this.popoverTarget.hidden;
    }
    // ----- New wishlist form -----
    toggleNewForm() {
        this.newFormTarget.hidden = !this.newFormTarget.hidden;
        if (!this.newFormTarget.hidden) {
            window.setTimeout(() => this.newNameTarget.focus(), 0);
        }
    }
    updateNewBtn() {
        this.newBtnTarget.disabled = !this.newNameTarget.value.trim();
    }
    createWishlist(event) {
        var _a;
        event === null || event === void 0 ? void 0 : event.preventDefault();
        const name = this.newNameTarget.value.trim();
        if (!name)
            return;
        const now = nowIso();
        const wl = { uuid: uuidv4(), name, offerIds: [], createdAt: now, modifiedAt: now, deletedAt: null };
        if (((_a = this.mode) === null || _a === void 0 ? void 0 : _a.kind) === 'card')
            wl.offerIds.push(canonicalRef(this.mode.refs));
        this.lists.push(wl);
        writeAll(this.lists);
        this.scheduleSync();
        this.newNameTarget.value = '';
        this.newBtnTarget.disabled = true;
        this.newFormTarget.hidden = true;
        this.renderList();
        this.decorateCards();
        this.toast(this.trans('wishlists.created', { name }));
    }
    // ----- Popover list -----
    renderList() {
        const lists = this.active();
        if (!lists.length) {
            this.popListTarget.innerHTML = `<div class="px-3 py-3 text-sm text-gray-500 dark:text-gray-400">${this.trans('wishlists.empty')}</div>`;
            return;
        }
        const rows = lists
            .map((wl) => {
            var _a, _b;
            const checked = ((_a = this.mode) === null || _a === void 0 ? void 0 : _a.kind) === 'filter'
                ? this.filteredUuids.has(wl.uuid)
                : ((_b = this.mode) === null || _b === void 0 ? void 0 : _b.kind) === 'card'
                    ? listHasCard(wl, this.mode.refs)
                    : false;
            const ct = wl.offerIds.length;
            return `
        <div role="checkbox" aria-checked="${checked}" data-wl-uuid="${wl.uuid}"
             class="group flex items-center gap-2.5 rounded-md px-2.5 py-2 hover:bg-gray-50 dark:hover:bg-white/5 cursor-pointer">
          <span class="grid h-4 w-4 place-items-center rounded border ${checked ? 'border-primary-600 bg-primary-600 text-white' : 'border-gray-300 bg-white text-transparent dark:border-white/20 dark:bg-gray-700'}">
            <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="m5 12 5 5L20 7"/></svg>
          </span>
          <span class="flex-1 truncate text-sm font-medium text-gray-900 dark:text-gray-100">${escapeHtml(wl.name)}</span>
          <span class="text-xs text-gray-400">${ct}</span>
          <button type="button" data-copy-uuid="${wl.uuid}"
                  title="${this.trans('wishlists.copyTitle')}"
                  aria-label="${this.trans('wishlists.copyTitle')}"
                  class="grid h-7 w-7 place-items-center rounded text-gray-400 hover:bg-gray-100 hover:text-gray-700 dark:hover:bg-white/10 dark:hover:text-gray-200">
            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg>
          </button>
          <button type="button" data-delete-uuid="${wl.uuid}"
                  title="${this.trans('wishlists.deleteTitle')}"
                  aria-label="${this.trans('wishlists.deleteTitle')}"
                  class="grid h-7 w-7 place-items-center rounded text-gray-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-400/10 dark:hover:text-red-300">
            <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m2 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>
          </button>
        </div>`;
        })
            .join('');
        this.popListTarget.innerHTML = rows;
        this.popListTarget.querySelectorAll('[data-wl-uuid]').forEach((row) => {
            row.addEventListener('click', (e) => {
                const tgt = e.target;
                if (tgt.closest('[data-copy-uuid]') || tgt.closest('[data-delete-uuid]'))
                    return;
                this.onRowClick(row.getAttribute('data-wl-uuid'));
            });
        });
        this.popListTarget.querySelectorAll('[data-copy-uuid]').forEach((btn) => {
            btn.addEventListener('click', (e) => {
                e.stopPropagation();
                this.copyShareUrl(btn.getAttribute('data-copy-uuid'));
            });
        });
        this.popListTarget.querySelectorAll('[data-delete-uuid]').forEach((btn) => {
            btn.addEventListener('click', (e) => {
                e.stopPropagation();
                this.deleteWishlist(btn.getAttribute('data-delete-uuid'));
            });
        });
    }
    onRowClick(uuid) {
        var _a, _b;
        const wl = this.findList(uuid);
        if (!wl)
            return;
        if (((_a = this.mode) === null || _a === void 0 ? void 0 : _a.kind) === 'filter') {
            this.toggleFilter(wl);
            return;
        }
        if (((_b = this.mode) === null || _b === void 0 ? void 0 : _b.kind) === 'card') {
            const refs = this.mode.refs;
            if (listHasCard(wl, refs)) {
                const keys = new Set(refKeys(refs));
                wl.offerIds = wl.offerIds.filter((ref) => !keys.has(String(ref)));
            }
            else {
                wl.offerIds.push(canonicalRef(refs));
            }
            this.touch(wl);
            writeAll(this.lists);
            this.scheduleSync();
            this.renderList();
            this.decorateCards();
            // If a wishlist filter is active (either an `?liste=` filter on
            // the offer list, or the imported share-link on /merkliste/<ids>),
            // re-run filtering so a card removed from the active list
            // disappears from view instead of lingering as stale UI.
            this.applyWishlistFilter();
        }
    }
    /** Soft-delete (tombstone) a wishlist; propagates to other devices on sync. */
    deleteWishlist(uuid) {
        const wl = this.findList(uuid);
        if (!wl)
            return;
        wl.deletedAt = nowIso();
        this.touch(wl);
        writeAll(this.lists);
        this.scheduleSync();
        this.refreshFilteredUuids();
        this.renderList();
        this.decorateCards();
        this.applyWishlistFilter();
        this.toast(this.trans('wishlists.deleted', { name: wl.name }));
    }
    // ----- Wishlist filter (applied as GET params) -----
    /**
     * Read the currently-active wishlist uuids from the canonical
     * `?liste=<u1>,<u2>` URL form.
     */
    filterUuidsFromUrl() {
        const raw = new URL(window.location.href).searchParams.get('liste');
        if (!raw)
            return [];
        return Array.from(new Set(raw.split(',').map((u) => u.trim()).filter((u) => u !== '')));
    }
    refreshFilteredUuids() {
        this.filteredUuids = new Set(this.filterUuidsFromUrl());
    }
    /**
     * Toggle `wl` in the filter. The URL stays comma-separated and the
     * uuids are sorted alphabetically — same selection in any click order
     * produces the same URL, which helps HTTP caches.
     *
     * Multiple wishlists OR together (an offer matches if it's on any of
     * the selected lists).
     */
    toggleFilter(wl) {
        const current = new Set(this.filterUuidsFromUrl());
        if (current.has(wl.uuid))
            current.delete(wl.uuid);
        else
            current.add(wl.uuid);
        const sorted = [...current].sort();
        const url = new URL(window.location.href);
        url.searchParams.delete('liste');
        // Drop the name-from-share-link so the filter URL stays compact.
        url.searchParams.delete('liste_name');
        if (sorted.length)
            url.searchParams.set('liste', sorted.join(','));
        url.searchParams.delete('page');
        navigateInFrame(url.toString());
    }
    refreshFilterTrigger() {
        if (!this.hasFilterTriggerTarget)
            return;
        const active = this.filteredUuids.size > 0;
        this.filterTriggerTarget.setAttribute('aria-pressed', active ? 'true' : 'false');
        if (this.hasFilterBadgeTarget) {
            this.filterBadgeTarget.hidden = !active;
            this.filterBadgeTarget.textContent = String(this.filteredUuids.size);
        }
    }
    /**
     * Hide cards not in the active wishlist (looked up by uuid in localStorage)
     * and update the result count. The server doesn't filter on `liste`, so
     * this is the only place the wishlist filter takes effect.
     *
     * If a uuid in the URL doesn't match anything in localStorage (e.g.
     * someone followed a `?liste=…` link without the share data), it
     * contributes nothing to the visible set, but its "unknown" pill still
     * appears so the user can clear it.
     */
    applyWishlistFilter() {
        const uuids = this.filterUuidsFromUrl();
        if (uuids.length === 0) {
            this.cardTargets.forEach((c) => showWrapper(c));
            this.togglePagination(true);
            this.relayoutMasonry();
            this.renderActiveFilterPill();
            return;
        }
        // Union of all selected wishlists' offer refs (OR semantics). Refs are
        // compared as strings so base58 UUIDs and legacy ids both match.
        const refSet = new Set();
        uuids.forEach((u) => {
            const wl = this.findList(u);
            wl === null || wl === void 0 ? void 0 : wl.offerIds.forEach((ref) => refSet.add(String(ref)));
        });
        let visible = 0;
        this.cardTargets.forEach((c) => {
            const matches = refKeys(cardRefs(c)).some((k) => refSet.has(k));
            if (matches) {
                showWrapper(c);
                ++visible;
            }
            else {
                hideWrapper(c);
            }
        });
        // Update the count displayed in the filter panel.
        const count = document.querySelector('[data-ferienpass--cms-filter-target="count"]');
        if (count)
            count.textContent = String(visible);
        // Server pagination doesn't reflect the client-side filter.
        this.togglePagination(false);
        this.relayoutMasonry();
        this.renderActiveFilterPill();
    }
    relayoutMasonry() {
        // Defer to next frame so display:none has applied to the DOM before
        // magic-grid measures.
        window.requestAnimationFrame(() => {
            window.dispatchEvent(new Event('resize'));
        });
    }
    togglePagination(show) {
        document
            .querySelectorAll('turbo-frame#cms-offer-list nav[aria-label], turbo-frame#cms-offer-list .pagination')
            .forEach((el) => {
            el.hidden = !show;
        });
    }
    /**
     * The server can't know the wishlist's name (it's in localStorage), so
     * the active-filter pill is rendered/updated client-side once we know
     * which wishlist is active.
     */
    renderActiveFilterPill() {
        const bar = document.querySelector('[data-controller~="ferienpass--cms-active-filters"]');
        if (!bar)
            return;
        // Remove any previous wishlist pills we injected.
        bar.querySelectorAll('[data-wl-pill]').forEach((el) => el.remove());
        const uuids = this.filterUuidsFromUrl();
        if (uuids.length === 0)
            return;
        uuids.forEach((uuid) => {
            const wl = this.findList(uuid);
            const label = wl ? `♥ ${wl.name}` : `♥ ${this.trans('wishlists.unknown')}`;
            // Remove just this uuid from the set; keep the others. Sort the
            // result for the cache-friendly canonical URL.
            const removeUrl = new URL(window.location.href);
            const remaining = uuids.filter((u) => u !== uuid).sort();
            removeUrl.searchParams.delete('liste');
            if (remaining.length)
                removeUrl.searchParams.set('liste', remaining.join(','));
            removeUrl.searchParams.delete('page');
            const pill = document.createElement('a');
            pill.setAttribute('data-wl-pill', '');
            pill.href = removeUrl.toString();
            pill.setAttribute('data-turbo-frame', 'cms-offer-list');
            pill.setAttribute('data-turbo-action', 'advance');
            pill.className =
                'inline-flex items-center gap-1.5 rounded-full bg-gray-900 py-1 pl-3 pr-1 text-sm font-medium text-white hover:bg-gray-800';
            pill.innerHTML = `
              ${escapeHtml(label)}
              <span class="grid h-5 w-5 place-items-center rounded-full bg-white/15 text-white">
                <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg>
              </span>`;
            bar.appendChild(pill);
        });
        bar.hidden = false;
    }
    // ----- Per-card decoration -----
    decorateCards() {
        this.cardTargets.forEach((c) => this.decorateCard(c));
    }
    decorateCard(card) {
        const refs = cardRefs(card);
        if (refKeys(refs).length === 0)
            return;
        const onLists = this.active().filter((wl) => listHasCard(wl, refs));
        // The heart icons' filled state — a card may carry several hearts
        // (e.g. offer details sidebar + application form), sync them all.
        const on = onLists.length > 0;
        card.querySelectorAll('[data-ferienpass--cms-offer-card-target="favBtn"]').forEach((favBtn) => {
            const favIcon = favBtn.querySelector('[data-ferienpass--cms-offer-card-target="favIcon"]');
            if (!favIcon)
                return;
            favBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
            // `!` so it beats the resting `text-gray-700` from the template.
            favBtn.classList.toggle('!text-red-500', on);
            favIcon.querySelectorAll('path').forEach((p) => {
                p.setAttribute('fill', on ? 'currentColor' : 'none');
            });
        });
        // Wishlist pills under title
        const slot = card.querySelector('[data-ferienpass--cms-offer-card-target="wishlists"]');
        if (slot) {
            slot.innerHTML = onLists
                .map((wl) => `
        <span class="inline-flex items-center gap-1 rounded-full bg-red-50 py-0.5 pl-2.5 pr-1 text-[11px] font-semibold text-red-700 dark:bg-red-400/15 dark:text-red-200">
          <svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M12 21s-7-4.5-9.5-9A5.5 5.5 0 0 1 12 6a5.5 5.5 0 0 1 9.5 6c-2.5 4.5-9.5 9-9.5 9z"/></svg>
          ${escapeHtml(wl.name)}
          <button type="button" data-copy-uuid="${wl.uuid}"
                  title="${this.trans('wishlists.copyTitle')}"
                  aria-label="${this.trans('wishlists.copyTitle')}"
                  class="grid h-4 w-4 place-items-center rounded-full hover:bg-black/10">
            <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg>
          </button>
        </span>`)
                .join('');
            slot.querySelectorAll('[data-copy-uuid]').forEach((btn) => {
                btn.addEventListener('click', (e) => {
                    e.stopPropagation();
                    e.preventDefault();
                    this.copyShareUrl(btn.getAttribute('data-copy-uuid'));
                });
            });
        }
    }
    // Public method used by offer-card controller
    requestCardPopover(card, anchor) {
        var _a;
        const refs = cardRefs(card);
        if (refKeys(refs).length === 0)
            return;
        anchor !== null && anchor !== void 0 ? anchor : (anchor = (_a = card.querySelector('[data-ferienpass--cms-offer-card-target="favBtn"]')) !== null && _a !== void 0 ? _a : undefined);
        this.openCardMenu(anchor !== null && anchor !== void 0 ? anchor : card, refs);
    }
    // ----- Sharing -----
    shareUrlFor(wl) {
        const url = new URL('/merkliste/' + wl.offerIds.join(' '), window.location.origin);
        url.searchParams.set('liste', wl.uuid);
        url.searchParams.set('liste_name', wl.name);
        return url.toString();
    }
    /**
     * Open a share modal with the wishlist URL prominently displayed plus
     * an explanation of what to do with it. The URL is auto-copied to the
     * clipboard in the background (best-effort — silent fail on
     * permission-denied since the modal makes the URL manually
     * selectable / copyable too).
     *
     * The previous flow popped a toast that said only "Link kopiert" —
     * users had no idea what the link actually was or what to do with it.
     * The modal hands them the URL plus the why ("send it to your other
     * device or to a friend, they'll see your favourited offers"), so the
     * feature is discoverable without doc-hunting.
     */
    copyShareUrl(uuid) {
        const wl = this.findList(uuid);
        if (!wl)
            return;
        const url = this.shareUrlFor(wl);
        // Best-effort eager copy. The modal itself has its own copy button
        // that re-attempts on click, so this is just a nicety for the
        // common case where clipboard permission is already granted.
        copyToClipboard(url).catch(() => {
            /* swallow — modal still works without it */
        });
        this.openShareModal(url);
    }
    /**
     * Build the share modal on demand and append it to <body>. Uses
     * @tailwindplus/elements <el-dialog>, which is already loaded via
     * modal.ts. Cleans up the DOM node when the dialog closes.
     */
    openShareModal(url) {
        var _a, _b;
        // If a previous share modal is still in flight, remove it first.
        (_b = (_a = document.getElementById('wishlist-share-dialog')) === null || _a === void 0 ? void 0 : _a.closest('el-dialog')) === null || _b === void 0 ? void 0 : _b.remove();
        const wrapper = document.createElement('el-dialog');
        wrapper.innerHTML = `
            <dialog id="wishlist-share-dialog" aria-labelledby="wishlist-share-title"
                    class="fixed inset-0 size-auto max-h-none max-w-none overflow-y-auto bg-transparent backdrop:bg-transparent">
                <el-dialog-backdrop class="fixed inset-0 bg-gray-500/75 transition-opacity data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in dark:bg-gray-900/50"></el-dialog-backdrop>
                <div tabindex="0" class="flex min-h-full items-end justify-center p-4 text-center focus:outline-none sm:items-center sm:p-0">
                    <el-dialog-panel
                            class="relative transform overflow-hidden rounded-lg bg-white px-4 pt-5 pb-4 text-left shadow-xl transition-all data-closed:translate-y-4 data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in sm:my-8 sm:w-full sm:max-w-lg sm:p-6 data-closed:sm:translate-y-0 data-closed:sm:scale-95 dark:bg-gray-800 dark:outline dark:-outline-offset-1 dark:outline-white/10">
                        <div>
                            <div class="mx-auto flex size-12 items-center justify-center rounded-full bg-red-50 dark:bg-red-500/10">
                                <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" class="size-6 text-red-500 dark:text-red-400">
                                    <path d="M12 21s-7-4.5-9.5-9A5.5 5.5 0 0 1 12 6a5.5 5.5 0 0 1 9.5 6c-2.5 4.5-9.5 9-9.5 9z"/>
                                </svg>
                            </div>
                            <div class="mt-3 text-center sm:mt-5">
                                <h3 id="wishlist-share-title" class="text-base font-semibold text-gray-900 dark:text-white">
                                    ${escapeHtml(this.trans('wishlists.shareTitle'))}
                                </h3>
                                <div class="mt-2">
                                    <p class="text-sm text-gray-500 dark:text-gray-400">
                                        ${escapeHtml(this.trans('wishlists.shareIntro'))}
                                    </p>
                                </div>
                            </div>
                        </div>
                        <div class="mt-5 sm:mt-6">
                            <label for="wishlist-share-url" class="sr-only">${escapeHtml(this.trans('wishlists.copyTitle'))}</label>
                            <div class="flex rounded-md shadow-sm ring-1 ring-inset ring-gray-300 focus-within:ring-2 focus-within:ring-primary-600 dark:ring-white/10">
                                <input type="text" readonly id="wishlist-share-url"
                                       value="${escapeHtml(url)}"
                                       data-share-url
                                       class="block w-full min-w-0 flex-1 rounded-l-md border-0 bg-transparent px-3 py-2 text-sm text-gray-900 placeholder:text-gray-400 focus:ring-0 dark:text-white" />
                                <button type="button" data-share-copy
                                        class="inline-flex shrink-0 items-center gap-1 rounded-r-md border-l border-gray-300 bg-gray-50 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 dark:border-white/10 dark:bg-white/5 dark:text-gray-100 dark:hover:bg-white/10">
                                    <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg>
                                    <span data-share-copy-label>${escapeHtml(this.trans('wishlists.shareCopy'))}</span>
                                </button>
                            </div>
                        </div>
                        <div class="mt-5 sm:mt-6">
                            <button type="button" command="close" commandfor="wishlist-share-dialog"
                                    class="inline-flex w-full justify-center rounded-md bg-primary-600 px-3 py-2 text-sm font-semibold text-white shadow-xs hover:bg-primary-500 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 dark:bg-primary-500 dark:hover:bg-primary-400">
                                ${escapeHtml(this.trans('wishlists.shareClose'))}
                            </button>
                        </div>
                    </el-dialog-panel>
                </div>
            </dialog>`;
        document.body.appendChild(wrapper);
        const dialog = wrapper.querySelector('#wishlist-share-dialog');
        const urlField = wrapper.querySelector('[data-share-url]');
        const copyBtn = wrapper.querySelector('[data-share-copy]');
        const copyLabel = wrapper.querySelector('[data-share-copy-label]');
        // Re-copy on demand from inside the modal. We don't rely on the
        // eager-copy from copyShareUrl because the user may have dismissed
        // a permission prompt or copied something else in between.
        copyBtn.addEventListener('click', () => {
            copyToClipboard(url)
                .then(() => {
                const original = copyLabel.textContent;
                copyLabel.textContent = this.trans('wishlists.shareCopied');
                window.setTimeout(() => {
                    copyLabel.textContent = original;
                }, 1500);
            })
                .catch(() => {
                // Fallback: select the field so the user can Cmd/Ctrl+C.
                urlField.focus();
                urlField.select();
            });
        });
        // Auto-select the URL when the dialog opens so keyboard users
        // can Cmd/Ctrl+C immediately without first clicking the button.
        const onOpen = () => {
            urlField.focus();
            urlField.select();
        };
        dialog.addEventListener('toggle', onOpen, { once: true });
        // Clean up the DOM when the dialog closes so we don't leak nodes
        // across repeated copy clicks.
        dialog.addEventListener('close', () => {
            wrapper.remove();
        });
        dialog.showModal();
    }
    // ----- Toast (uses the existing #notifications live region) -----
    /**
     * Prepend a notification-styled card into the global `#notifications`
     * region (rendered once per page by `<twig:Cms:Notifications/>` in
     * fe_page.html.twig). Auto-dismisses after 2.4s; can also be closed.
     *
     * Markup is intentionally a near-clone of `notification.html.twig` so the
     * client-side wishlist toast matches the server-rendered flash style.
     */
    toast(msg) {
        var _a;
        const host = document.getElementById('notifications');
        if (!host)
            return;
        const node = document.createElement('div');
        node.className =
            'pointer-events-auto w-full max-w-sm translate-y-0 transform rounded-lg bg-white dark:bg-gray-900 opacity-100 shadow-lg outline-1 outline-black/5 dark:outline-white/10 dark:-outline-offset-1 transition duration-300 ease-out sm:translate-x-0 starting:translate-y-2 starting:opacity-0 starting:sm:translate-x-2 starting:sm:translate-y-0';
        node.setAttribute('role', 'status');
        node.innerHTML = `
            <div class="p-4">
                <div class="flex items-start">
                    <div class="shrink-0">
                        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true" class="size-6 text-green-400">
                            <path d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" stroke-linecap="round" stroke-linejoin="round" />
                        </svg>
                    </div>
                    <div class="ml-3 w-0 flex-1 pt-0.5">
                        <p class="text-sm font-medium text-gray-900 dark:text-white"></p>
                    </div>
                    <div class="ml-4 flex shrink-0">
                        <button type="button" data-wl-toast-close
                                class="inline-flex rounded-md text-gray-400 hover:text-gray-500 dark:hover:text-white focus:outline-2 focus:outline-offset-2 focus:outline-primary-600 dark:focus:outline-primary-500">
                            <span class="sr-only">Schließen</span>
                            <svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" class="size-5">
                                <path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" />
                            </svg>
                        </button>
                    </div>
                </div>
            </div>`;
        // Set the message via textContent so it can't smuggle HTML.
        const p = node.querySelector('p');
        if (p)
            p.textContent = msg;
        const dismiss = () => {
            // Fade out (the `transition` class on the card handles the timing).
            node.style.opacity = '0';
            node.style.transform = 'translate(0, -4px)';
            window.setTimeout(() => node.remove(), 320);
        };
        (_a = node.querySelector('[data-wl-toast-close]')) === null || _a === void 0 ? void 0 : _a.addEventListener('click', dismiss);
        host.prepend(node);
        window.setTimeout(dismiss, 2400);
    }
}
default_1.targets = [
    'popover',
    'popList',
    'popTitle',
    'newForm',
    'newName',
    'newBtn',
    'filterTrigger',
    'filterBadge',
    'card',
];
/**
 * Cards sit inside a sizing wrapper (`.w-full sm:w-[calc(...)] md:w-[calc(...)]`)
 * that magic-grid positions. Hiding the card itself leaves the wrapper in
 * place and the layout breaks, so we hide the wrapper instead.
 */
function hideWrapper(card) {
    const w = card.parentElement;
    if (w)
        w.style.display = 'none';
}
function showWrapper(card) {
    const w = card.parentElement;
    if (w)
        w.style.display = '';
}
function escapeHtml(s) {
    return s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
function copyToClipboard(text) {
    var _a;
    return __awaiter(this, void 0, void 0, function* () {
        if ((_a = navigator.clipboard) === null || _a === void 0 ? void 0 : _a.writeText) {
            return navigator.clipboard.writeText(text);
        }
        const ta = document.createElement('textarea');
        ta.value = text;
        ta.style.position = 'fixed';
        ta.style.opacity = '0';
        document.body.appendChild(ta);
        ta.select();
        document.execCommand('copy');
        document.body.removeChild(ta);
    });
}
