Subversion Repositories SmartDukaan

Rev

Rev 37508 | Blame | Compare with Previous | Last modification | View Log | RSS feed

$(function () {
    $(document).on('click', '.send-notification', function () {
        loadNotificationPanel('main-content');
    });

    // ─── Mobile Preview modal ────────────────────────────────────────────
    // On mobile the preview column is hidden. The floating "Preview" button
    // moves the .nm-preview-sticky node into the modal body on open, then
    // puts it back on close — so the same DOM (and all the jQuery selectors
    // that update it as the admin types) works in both places.
    $(document).on('click', '#nmPreviewFab', function () {
        var $preview = $('.nm-preview-sticky');
        if ($preview.length) {
            $preview.appendTo('#nmMobilePreviewBody');
        }
        $('#nmMobilePreviewModal').modal('show');
    });
    $('#nmMobilePreviewModal').on('hidden.bs.modal', function () {
        var $preview = $(this).find('.nm-preview-sticky');
        if ($preview.length) {
            $preview.appendTo('.nm-preview-col');
        }
        });

    // ─── Campaign Group picker ───────────────────────────────────────────
    function loadCampaignGroups(preselectId) {
        $.getJSON(context + '/campaignGroups', function (data) {
            var parsed = typeof data === 'string' ? JSON.parse(data) : data;
            var $sel = $('#nmCampaignGroup');
            $sel.find('option.nm-group-opt').remove();
            var $createNew = $sel.find('option[value="__new__"]');
            (parsed || []).forEach(function (g) {
                var opt = $('<option class="nm-group-opt"></option>')
                    .attr('value', g.id).text(g.name);
                $createNew.before(opt);
            });
            if (preselectId) {
                $sel.val(String(preselectId));
            }
        });
    }

    loadCampaignGroups();

    $(document).on('change', '#nmCampaignGroup', function () {
        if ($(this).val() === '__new__') {
            $(this).val('');
            $('#nmGroupName, #nmGroupDesc, #nmGroupStarts, #nmGroupEnds').val('');
            $('#nmGroupModal').modal('show');
        }
    });

    $(document).on('click', '#nmCreateGroupBtn', function () {
        var name = $('#nmGroupName').val().trim();
        if (!name) {
            alert('Group name is required');
            return;
        }
        var body = {
            name: name,
            description: $('#nmGroupDesc').val() || null,
            startsOn: $('#nmGroupStarts').val() || null,
            endsOn: $('#nmGroupEnds').val() || null
        };
        doPostAjaxRequestWithJsonHandler(context + '/campaignGroups', JSON.stringify(body), function (response) {
            var parsed = typeof response === 'string' ? JSON.parse(response) : response;
            if (parsed && parsed.id) {
                $('#nmGroupModal').modal('hide');
                loadCampaignGroups(parsed.id);
            } else {
                alert('Could not create the group. Try again.');
            }
        });
    });

    // ─── Tab strip ───────────────────────────────────────────────────────
    $(document).on('click', '.nm-tab', function () {
        var tab = $(this).data('tab');
        $('.nm-tab').removeClass('active');
        $(this).addClass('active');
        $('.nm-tab-content').removeClass('active');
        $('.nm-tab-content[data-tab-content="' + tab + '"]').addClass('active');

        if (tab === 'calendar') {
            initCalendar();
        }
    });

    // ─── Custom month-grid calendar (replaces FullCalendar 1.5.4) ────────
    var calRef = null;   // { year, month }  — 0-indexed month

    function initCalendar() {
        if (!calRef) {
            var now = new Date();
            calRef = {year: now.getFullYear(), month: now.getMonth()};
        }
        renderCalendarMonth();
        renderScheduledList();
        renderRecentList();
    }

    $(document).on('click', '#ncPrev', function () {
        calRef.month -= 1;
        if (calRef.month < 0) {
            calRef.month = 11;
            calRef.year -= 1;
        }
        renderCalendarMonth();
    });
    $(document).on('click', '#ncNext', function () {
        calRef.month += 1;
        if (calRef.month > 11) {
            calRef.month = 0;
            calRef.year += 1;
        }
        renderCalendarMonth();
    });
    $(document).on('click', '#ncToday', function () {
        var now = new Date();
        calRef = {year: now.getFullYear(), month: now.getMonth()};
        renderCalendarMonth();
    });

    var MONTH_NAMES = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];

    function renderCalendarMonth() {
        var y = calRef.year, m = calRef.month;
        $('#ncTitle').text(MONTH_NAMES[m] + ' ' + y);

        var firstOfMonth = new Date(y, m, 1);
        var startDow = firstOfMonth.getDay();                // 0 = Sun
        var gridStart = new Date(y, m, 1 - startDow);        // first Sunday of the visible grid
        var gridEnd = new Date(y, m, 1 - startDow + 42);     // 6 weeks × 7 days

        var from = fmtDate(gridStart);
        var to = fmtDate(gridEnd);

        // Build the grid skeleton first (so it appears immediately, events fill in on fetch)
        var $body = $('#ncBody').empty();
        var today = fmtDate(new Date());
        var eventsByDate = {};  // filled by AJAX

        for (var w = 0; w < 6; w++) {
            var $tr = $('<tr></tr>');
            for (var d = 0; d < 7; d++) {
                var cell = new Date(gridStart.getFullYear(), gridStart.getMonth(), gridStart.getDate() + w * 7 + d);
                var dateStr = fmtDate(cell);
                var isOtherMonth = cell.getMonth() !== m;
                var isToday = dateStr === today;
                var $td = $('<td></td>')
                    .attr('data-date', dateStr)
                    .append('<div class="nc-day-num">' + cell.getDate() + '</div>')
                    .append('<div class="nc-events"></div>');
                if (isOtherMonth) $td.addClass('nc-other-month');
                if (isToday) $td.addClass('nc-today');
                $tr.append($td);
            }
            $body.append($tr);
        }

        // Fetch events for the visible range and inject into cells
        $.getJSON(context + '/notificationCalendarData?from=' + from + '&to=' + to, function (data) {
            var events = data || [];
            events.forEach(function (e) {
                if (!e.start) return;
                var startDate = new Date(e.start);
                var endDate = e.end ? new Date(e.end) : startDate;
                // Iterate every day the event covers (windows may span days)
                var cursor = new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate());
                var last = new Date(endDate.getFullYear(), endDate.getMonth(), endDate.getDate());
                while (cursor <= last) {
                    var key = fmtDate(cursor);
                    if (!eventsByDate[key]) eventsByDate[key] = [];
                    eventsByDate[key].push(e);
                    cursor.setDate(cursor.getDate() + 1);
                }
            });

            Object.keys(eventsByDate).forEach(function (dateStr) {
                var $cell = $body.find('td[data-date="' + dateStr + '"] .nc-events');
                if ($cell.length === 0) return;
                var dayEvents = eventsByDate[dateStr];
                var shown = dayEvents.slice(0, 4);
                shown.forEach(function (e) {
                    var $ev = $('<div class="nc-event"></div>')
                        .text(labelForEvent(e))
                        .css('background', e.color || '#3a87ad')
                        .data('event', e);
                    $cell.append($ev);
                });
                if (dayEvents.length > shown.length) {
                    $cell.append('<div class="nc-more">+' + (dayEvents.length - shown.length) + ' more</div>');
                }
            });
        });
    }

    function labelForEvent(e) {
        var t = e.start ? formatHM(new Date(e.start)) : '';
        return (t ? t + ' ' : '') + (e.title || '');
    }

    function fmtDate(d) {
        var pad = function (n) {
            return String(n).padStart(2, '0');
        };
        return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate());
    }

    function formatHM(d) {
        var pad = function (n) {
            return String(n).padStart(2, '0');
        };
        return pad(d.getHours()) + ':' + pad(d.getMinutes());
    }

    // ─── Upcoming Scheduled Notifications list ───────────────────────────
    var CATEGORY_COLOR = {
        'ANNOUNCE': '#3a87ad', 'NOTIFICATION': '#4b5563',
        'REMINDER': '#f0ad4e', 'ALERT': '#d9534f'
    };

    function categoryBadge(cat) {
        if (!cat) return '<span style="color:#9ca3af;">—</span>';
        var color = CATEGORY_COLOR[cat] || '#6b7280';
        return '<span style="display:inline-block;padding:2px 8px;border-radius:10px;'
            + 'background:' + color + '20;color:' + color + ';font-size:11px;font-weight:600;">'
            + cat + '</span>';
    }

    function statusBadge(st) {
        var bg = '#eef2ff', fg = '#4338ca';
        if (st === 'DISPATCHED') {
            bg = '#dcfce7';
            fg = '#166534';
        } else if (st === 'CANCELLED') {
            bg = '#f3f4f6';
            fg = '#6b7280';
        } else if (st === 'FAILED') {
            bg = '#fee2e2';
            fg = '#991b1b';
        }
        return '<span style="display:inline-block;padding:2px 8px;border-radius:10px;'
            + 'background:' + bg + ';color:' + fg + ';font-size:11px;font-weight:600;">'
            + st + '</span>';
    }

    function escapeHtml(s) {
        if (s == null) return '';
        return String(s)
            .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
            .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
    }

    function fmtWhen(iso) {
        if (!iso) return '';
        var d = new Date(iso);
        var pad = function (n) {
            return String(n).padStart(2, '0');
        };
        return pad(d.getDate()) + '/' + pad(d.getMonth() + 1) + ' ' + formatHM(d);
    }

    function renderScheduledList() {
        var days = $('#nslDays').val() || '30';
        var $body = $('#nslBody');
        $body.html('<tr><td colspan="7" style="text-align:center;color:#9ca3af;padding:24px;">Loading…</td></tr>');
        $.getJSON(context + '/notificationScheduleList?days=' + encodeURIComponent(days), function (rows) {
            if (!rows || rows.length === 0) {
                $body.html('<tr><td colspan="7" style="text-align:center;color:#9ca3af;padding:24px;">'
                    + 'No scheduled notifications in the next ' + days + ' days.</td></tr>');
                return;
            }
            var html = '';
            rows.forEach(function (r) {
                var titleLine = r.title ? '<div style="font-weight:600;color:#111;">' + escapeHtml(r.title) + '</div>' : '';
                var msgLine = r.message ? '<div style="color:#6b7280;font-size:12px;max-width:420px;'
                    + 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' + escapeHtml(r.message) + '</div>' : '';
                var thumb = r.imageUrl ? '<img src="' + escapeHtml(r.imageUrl) + '" '
                    + 'style="width:36px;height:36px;object-fit:cover;border-radius:4px;margin-right:8px;vertical-align:middle;" />' : '';
                var content = '<div style="display:flex;align-items:center;">' + thumb
                    + '<div>' + titleLine + msgLine + '</div></div>';
                var groupCell = r.groupName
                    ? '<span style="display:inline-block;padding:2px 8px;border-radius:10px;background:#eef2ff;color:#4338ca;font-size:11px;font-weight:600;">'
                    + escapeHtml(r.groupName) + '</span>'
                    : '<span style="color:#9ca3af;">—</span>';
                var audCount = (r.audienceCount != null ? r.audienceCount : 0);
                var audSummary = r.audienceSummary || '';
                var audienceCell = '<div style="font-weight:600;">' + audCount.toLocaleString() + '</div>'
                    + '<div style="font-size:11px;color:#6b7280;">' + escapeHtml(audSummary) + '</div>';
                var canCancel = r.status === 'SCHEDULED';
                var actions = '<button type="button" class="nm-btn nm-btn-outline cdm-view-btn" '
                    + 'data-campaign-id="' + r.campaignId + '" style="margin-right:4px;">View</button>'
                    + '<button type="button" class="nm-btn nm-btn-outline nsl-add-btn" '
                    + 'data-campaign-id="' + r.campaignId + '" style="margin-right:4px;">+ Add date</button>'
                    + '<button type="button" class="nm-btn nm-btn-outline nsl-cancel-btn" '
                    + 'data-schedule-id="' + r.scheduleId + '" '
                    + (canCancel ? '' : 'disabled ')
                    + 'style="color:#dc2626;border-color:#fecaca;">Cancel</button>';
                html += '<tr>'
                    + '<td style="white-space:nowrap;font-weight:600;">' + fmtWhen(r.when) + '</td>'
                    + '<td>' + groupCell + '</td>'
                    + '<td>' + content + '</td>'
                    + '<td>' + categoryBadge(r.category) + '</td>'
                    + '<td style="text-align:right;">' + audienceCell + '</td>'
                    + '<td>' + statusBadge(r.status) + '</td>'
                    + '<td style="text-align:right;white-space:nowrap;">' + actions + '</td>'
                    + '</tr>';
            });
            $body.html(html);
        }).fail(function () {
            $body.html('<tr><td colspan="7" style="text-align:center;color:#dc2626;padding:24px;">'
                + 'Failed to load — check the server log.</td></tr>');
        });
    }

    $(document).on('change', '#nslDays', renderScheduledList);
    $(document).on('click', '#nslRefresh', renderScheduledList);
    $(document).on('click', '.nsl-cancel-btn', function () {
        var scheduleId = $(this).data('schedule-id');
        if (!scheduleId) return;
        if (!confirm('Cancel this scheduled notification?')) return;
        doPostAjaxRequestWithJsonHandler(
            context + '/notificationSchedule/cancel?scheduleId=' + encodeURIComponent(scheduleId),
            '{}',
            function (response) {
                if (response === 'true' || response === true) {
                    renderScheduledList();
                    renderCalendarMonth();
                } else {
                    alert('Could not cancel — the schedule may already be dispatched or cancelled.');
                }
            });
    });
    $(document).on('click', '.nsl-add-btn', function () {
        var campaignId = $(this).data('campaign-id');
        if (!campaignId) return;
        var dateVal = prompt('Add a schedule — enter date (YYYY-MM-DD):');
        if (!dateVal) return;
        var timeVal = prompt('Enter time (HH:MM, 24-hour):');
        if (!timeVal) return;
        var scheduledAt = dateVal + 'T' + timeVal + ':00';
        if (new Date(scheduledAt) <= new Date()) {
            alert('Pick a future date/time.');
            return;
        }
        doPostAjaxRequestWithJsonHandler(
            context + '/notificationSchedule/add?campaignId=' + encodeURIComponent(campaignId)
            + '&scheduledAt=' + encodeURIComponent(scheduledAt),
            '{}',
            function (response) {
                if (response === 'false' || response === false) {
                    alert('Could not add — check date/time and try again.');
                    return;
                }
                renderScheduledList();
                renderCalendarMonth();
                renderRecentList();
            });
    });

    // ─── Recent Notifications list (all formats, all statuses) ───────────
    var FORMAT_COLOR = {
        'PUSH': {bg: '#dbeafe', fg: '#1e40af'},
        'IN_APP_POPUP': {bg: '#fef3c7', fg: '#92400e'},
        'STORY': {bg: '#fce7f3', fg: '#9d174d'}
    };

    function formatBadge(fmt) {
        var meta = FORMAT_COLOR[fmt] || {bg: '#f3f4f6', fg: '#6b7280'};
        var label = fmt === 'IN_APP_POPUP' ? 'POPUP' : (fmt || 'PUSH');
        return '<span style="display:inline-block;padding:2px 8px;border-radius:10px;'
            + 'background:' + meta.bg + ';color:' + meta.fg + ';font-size:11px;font-weight:600;">'
            + label + '</span>';
    }

    function fmtCreated(iso) {
        if (!iso) return '';
        var d = new Date(iso);
        var pad = function (n) {
            return String(n).padStart(2, '0');
        };
        return pad(d.getDate()) + '/' + pad(d.getMonth() + 1) + '/' + d.getFullYear()
            + ' ' + formatHM(d);
    }

    function renderRecentList() {
        var days = $('#nrlDays').val() || '30';
        var $body = $('#nrlBody');
        $body.html('<tr><td colspan="8" style="text-align:center;color:#9ca3af;padding:24px;">Loading…</td></tr>');
        $.getJSON(context + '/notificationCampaignList?days=' + encodeURIComponent(days), function (rows) {
            if (!rows || rows.length === 0) {
                $body.html('<tr><td colspan="8" style="text-align:center;color:#9ca3af;padding:24px;">'
                    + 'No notifications created in the last ' + days + ' days.</td></tr>');
                return;
            }
            var html = '';
            rows.forEach(function (r) {
                var titleLine = r.title ? '<div style="font-weight:600;color:#111;">' + escapeHtml(r.title) + '</div>' : '';
                var msgLine = r.message ? '<div style="color:#6b7280;font-size:12px;max-width:420px;'
                    + 'overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">' + escapeHtml(r.message) + '</div>' : '';
                var thumb = r.imageUrl ? '<img src="' + escapeHtml(r.imageUrl) + '" '
                    + 'style="width:36px;height:36px;object-fit:cover;border-radius:4px;margin-right:8px;vertical-align:middle;" />' : '';
                var content = '<div style="display:flex;align-items:center;">' + thumb
                    + '<div>' + titleLine + msgLine + '</div></div>';
                var groupCell = r.groupName
                    ? '<span style="display:inline-block;padding:2px 8px;border-radius:10px;background:#eef2ff;color:#4338ca;font-size:11px;font-weight:600;">'
                    + escapeHtml(r.groupName) + '</span>'
                    : '<span style="color:#9ca3af;">—</span>';
                var audCount = (r.audienceCount != null ? r.audienceCount : 0);
                var audienceCell = '<div style="font-weight:600;">' + audCount.toLocaleString() + '</div>'
                    + '<div style="font-size:11px;color:#6b7280;">' + escapeHtml(r.audienceSummary || '') + '</div>';
                var viewBtn = '<button type="button" class="nm-btn nm-btn-outline cdm-view-btn" '
                    + 'data-campaign-id="' + r.campaignId + '" style="padding:2px 8px;font-size:11px;">View</button>';
                html += '<tr>'
                    + '<td style="white-space:nowrap;color:#6b7280;">' + fmtCreated(r.createdAt) + '</td>'
                    + '<td>' + formatBadge(r.format) + '</td>'
                    + '<td>' + groupCell + '</td>'
                    + '<td>' + content + '</td>'
                    + '<td>' + categoryBadge(r.category) + '</td>'
                    + '<td style="font-size:12px;color:#374151;">' + escapeHtml(r.delivery || '—') + '</td>'
                    + '<td style="text-align:right;">' + audienceCell + '</td>'
                    + '<td style="text-align:right;">' + viewBtn + '</td>'
                    + '</tr>';
            });
            $body.html(html);
        }).fail(function () {
            $body.html('<tr><td colspan="8" style="text-align:center;color:#dc2626;padding:24px;">'
                + 'Failed to load — check the server log.</td></tr>');
        });
    }

    $(document).on('change', '#nrlDays', renderRecentList);
    $(document).on('click', '#nrlRefresh', renderRecentList);

    // ─── Full campaign detail modal (content + audience list + schedules) ─
    function scheduleStatusBadge(st) {
        var map = {
            'SCHEDULED': {bg: '#eef2ff', fg: '#4338ca'},
            'DISPATCHED': {bg: '#dcfce7', fg: '#166534'},
            'CANCELLED': {bg: '#f3f4f6', fg: '#6b7280'},
            'FAILED': {bg: '#fee2e2', fg: '#991b1b'}
        };
        var meta = map[st] || {bg: '#f3f4f6', fg: '#6b7280'};
        return '<span style="display:inline-block;padding:2px 8px;border-radius:10px;'
            + 'background:' + meta.bg + ';color:' + meta.fg + ';font-size:11px;font-weight:600;">'
            + st + '</span>';
    }

    function contentFieldRow(label, value) {
        if (value == null || value === '') return '';
        return '<tr><td style="padding:4px 8px;color:#6b7280;width:140px;">' + escapeHtml(label) + '</td>'
            + '<td style="padding:4px 8px;color:#111;">' + escapeHtml(value) + '</td></tr>';
    }

    function renderCampaignDetail(campaignId) {
        var $body = $('#cdmBody');
        $('#cdmTitle').text('Campaign detail');
        $body.html('<div style="text-align:center;color:#9ca3af;padding:24px;">Loading…</div>');
        $('#campaignDetailModal').modal('show');

        $.getJSON(context + '/notificationCampaignDetail?campaignId=' + encodeURIComponent(campaignId), function (data) {
            if (!data || !data.campaign) {
                $body.html('<div style="text-align:center;color:#dc2626;padding:24px;">Campaign not found.</div>');
                return;
            }
            var c = data.campaign;
            var content = c.content || {};
            $('#cdmTitle').text(c.name || 'Campaign #' + c.id);

            // ── Section: header info
            var headerHtml = '<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px;">'
                + formatBadge(c.format)
                + categoryBadge(c.category)
                + (c.groupName
                    ? '<span style="display:inline-block;padding:2px 8px;border-radius:10px;background:#eef2ff;color:#4338ca;font-size:11px;font-weight:600;">'
                    + escapeHtml(c.groupName) + '</span>'
                    : '')
                + '<span style="color:#6b7280;font-size:12px;padding:2px 0;">Created: '
                + escapeHtml(fmtCreated(c.createdAt)) + '</span>'
                + '</div>';

            // ── Section: content
            var img = content.imageUrl
                ? '<div style="margin-bottom:8px;"><img src="' + escapeHtml(content.imageUrl)
                + '" style="max-width:200px;max-height:120px;border-radius:6px;border:1px solid #e5e7eb;" /></div>'
                : '';
            var contentTable = '<table style="width:100%;font-size:13px;border-collapse:collapse;">'
                + contentFieldRow('Title', content.title)
                + contentFieldRow('Message', content.message)
                + contentFieldRow('Button Label', content.buttonLabel)
                + contentFieldRow('URL', content.url)
                + contentFieldRow('Show CTA', content.showCta === true || content.showCta === 'true' ? 'Yes' : (content.showCta === false ? 'No' : null))
                + contentFieldRow('Show Close', content.showClose === true || content.showClose === 'true' ? 'Yes' : (content.showClose === false ? 'No' : null))
                + contentFieldRow('Show On Page', content.showOnPage)
                + contentFieldRow('Auto Close (s)', content.autoCloseSeconds)
                + contentFieldRow('Frequency Cap', content.frequencyCap)
                + contentFieldRow('Live From', content.liveFrom ? fmtCreated(content.liveFrom) : null)
                + contentFieldRow('Live Until', content.liveUntil ? fmtCreated(content.liveUntil) : null)
                + '</table>';
            var contentSection = '<div style="margin-bottom:20px;padding:12px;background:#f9fafb;border-radius:6px;">'
                + '<b style="color:#111;font-size:13px;">Content</b><div style="margin-top:8px;">'
                + img + contentTable + '</div></div>';

            // ── Section: audience
            var aud = data.audience || {};
            var users = aud.sampleUsers || [];
            var userRows = '';
            if (users.length === 0) {
                userRows = '<tr><td colspan="4" style="text-align:center;color:#9ca3af;padding:12px;">No users resolved.</td></tr>';
            } else {
                users.forEach(function (u) {
                    userRows += '<tr>'
                        + '<td>' + escapeHtml(u.name || '—') + '</td>'
                        + '<td>' + escapeHtml(u.mobile || '—') + '</td>'
                        + '<td>' + escapeHtml(u.city || '—') + '</td>'
                        + '<td>' + escapeHtml(u.state || '—') + '</td>'
                        + '</tr>';
                });
            }
            var audienceHeader = '<b style="color:#111;font-size:13px;">Audience</b>'
                + ' <span style="color:#111;font-weight:600;">' + (aud.totalCount || 0).toLocaleString() + '</span>'
                + ' <span style="color:#6b7280;font-size:12px;">(' + escapeHtml(aud.summary || '') + ')</span>'
                + (aud.truncated ? ' <span style="color:#f59e0b;font-size:11px;">— showing first ' + users.length + '</span>' : '');
            var audienceSection = '<div style="margin-bottom:20px;padding:12px;background:#f9fafb;border-radius:6px;">'
                + audienceHeader
                + '<div style="margin-top:8px;max-height:300px;overflow-y:auto;">'
                + '<table class="table table-striped" style="margin:0;font-size:12px;">'
                + '<thead><tr><th>Name</th><th>Mobile</th><th>City</th><th>State</th></tr></thead>'
                + '<tbody>' + userRows + '</tbody>'
                + '</table></div></div>';

            // ── Section: schedules
            var scheds = data.schedules || [];
            var schedRows = '';
            if (scheds.length === 0) {
                schedRows = '<tr><td colspan="4" style="text-align:center;color:#9ca3af;padding:12px;">No schedules — sent immediately.</td></tr>';
            } else {
                scheds.forEach(function (s) {
                    schedRows += '<tr>'
                        + '<td>' + escapeHtml(fmtCreated(s.scheduledAt)) + '</td>'
                        + '<td>' + scheduleStatusBadge(s.status) + '</td>'
                        + '<td>' + (s.dispatchedAt ? escapeHtml(fmtCreated(s.dispatchedAt)) : '—') + '</td>'
                        + '<td style="color:#6b7280;">#' + s.id + '</td>'
                        + '</tr>';
                });
            }
            var schedulesSection = '<div style="padding:12px;background:#f9fafb;border-radius:6px;">'
                + '<b style="color:#111;font-size:13px;">Schedules (' + scheds.length + ')</b>'
                + '<div style="margin-top:8px;max-height:250px;overflow-y:auto;">'
                + '<table class="table table-striped" style="margin:0;font-size:12px;">'
                + '<thead><tr><th>Scheduled At</th><th>Status</th><th>Dispatched At</th><th>ID</th></tr></thead>'
                + '<tbody>' + schedRows + '</tbody>'
                + '</table></div></div>';

            $body.html(headerHtml + contentSection + audienceSection + schedulesSection);
        }).fail(function () {
            $body.html('<div style="text-align:center;color:#dc2626;padding:24px;">Failed to load — check the server log.</div>');
        });
    }

    $(document).on('click', '.cdm-view-btn', function () {
        var campaignId = $(this).data('campaign-id');
        if (campaignId) renderCampaignDetail(campaignId);
    });

    // Event click → detail modal (same modal FullCalendar was using)
    $(document).on('click', '.nc-event', function () {
        var event = $(this).data('event');
        if (!event) return;
        var kind = event.kind || 'push-schedule';
        $('#ncDetailCampaign').text(event.title);
        var startD = new Date(event.start);
        var whenStr = fmtDateHuman(startD);
        if (event.end && (kind === 'popup-window' || kind === 'story-window')) {
            whenStr += ' → ' + fmtDateHuman(new Date(event.end));
        }
        $('#ncDetailWhen').text(whenStr);
        $('#ncDetailStatus').text(event.status || event.category || '—');
        $('#ncDetailScheduleId').text(event.id);

        // Reset "add another schedule" section every time the modal opens
        $('#ncAddScheduleBox').hide();
        $('#ncAddErr').hide().text('');
        $('#ncAddDate').val('');
        $('#ncAddTime').val('');
        var $addBtn = $('#ncAddScheduleBtn').off('click').hide();
        // Only PUSH schedules can be duplicated — popup/story are windowed, not repeatable
        if (kind === 'push-schedule' && event.campaignId) {
            $addBtn.show().on('click', function () {
                $('#ncAddScheduleBox').toggle();
                if ($('#ncAddScheduleBox').is(':visible') && !$('#ncAddDate').val()) {
                    var d = new Date();
                    $('#ncAddDate').val(fmtDate(d));
                    $('#ncAddTime').val(formatHM(d));
                }
            });
            $('#ncAddSaveBtn').off('click').on('click', function () {
                var dateVal = $('#ncAddDate').val();
                var timeVal = $('#ncAddTime').val();
                if (!dateVal || !timeVal) {
                    $('#ncAddErr').text('Pick both a date and a time.').show();
                    return;
                }
                // Backend parses LocalDateTime — send "yyyy-MM-ddTHH:mm:00"
                var scheduledAt = dateVal + 'T' + timeVal + ':00';
                if (new Date(scheduledAt) <= new Date()) {
                    $('#ncAddErr').text('Pick a future date/time.').show();
                    return;
                }
                $('#ncAddErr').hide();
                doPostAjaxRequestWithJsonHandler(
                    context + '/notificationSchedule/add?campaignId=' + encodeURIComponent(event.campaignId)
                    + '&scheduledAt=' + encodeURIComponent(scheduledAt),
                    '{}',
                    function (response) {
                        // response is either the new schedule id (number) or 'false'
                        if (response === 'false' || response === false) {
                            $('#ncAddErr').text('Could not add — check date/time and try again.').show();
                                                        return;
                                                }
                        $('#scheduleDetailModal').modal('hide');
                        renderCalendarMonth();
                        renderScheduledList();
                        renderRecentList();
                    });
            });
        }

        var $btn = $('#ncCancelBtn').off('click');
        if (kind === 'push-schedule') {
            if (event.status !== 'SCHEDULED') {
                $btn.prop('disabled', true).text('Not cancellable');
            } else {
                $btn.prop('disabled', false).text('Cancel Schedule').on('click', function () {
                    if (!confirm('Cancel this scheduled notification?')) return;
                    doPostAjaxRequestWithJsonHandler(
                        context + '/notificationSchedule/cancel?scheduleId=' + encodeURIComponent(event.id),
                        '{}',
                        function (response) {
                            $('#scheduleDetailModal').modal('hide');
                            if (response === 'true' || response === true) {
                                renderCalendarMonth();
                                renderScheduledList();
                                renderRecentList();
                            } else {
                                alert('Could not cancel — the schedule may already be dispatched or cancelled.');
                            }
                        });
                });
            }
        } else {
            var alreadyEnded = event.end && new Date(event.end) < new Date();
            if (alreadyEnded) {
                $btn.prop('disabled', true).text('Already ended');
            } else {
                $btn.prop('disabled', false).text('Deactivate now').on('click', function () {
                    var noun = kind === 'popup-window' ? 'pop-up' : 'story';
                    if (!confirm('Deactivate this ' + noun + ' immediately? Partners will stop seeing it.')) return;
                    doPostAjaxRequestWithJsonHandler(
                        context + '/notificationCampaign/deactivate?campaignId=' + encodeURIComponent(event.campaignId),
                        '{}',
                        function (response) {
                            $('#scheduleDetailModal').modal('hide');
                            if (response === 'true' || response === true) {
                                renderCalendarMonth();
                                renderScheduledList();
                                renderRecentList();
                            } else {
                                alert('Could not deactivate — the row may be missing or already ended.');
                            }
                        });
                });
            }
        }
        $('#scheduleDetailModal').modal('show');
    });

    function fmtDateHuman(d) {
        var pad = function (n) {
            return String(n).padStart(2, '0');
        };
        return pad(d.getDate()) + '/' + pad(d.getMonth() + 1) + '/' + d.getFullYear() + ' ' + formatHM(d);
    }

    // ─── Format cards ────────────────────────────────────────────────────
    var FORMAT_META = {
        'PUSH': {cta: 'Send now', detailsTitle: 'Notification details', label: 'Push notification'},
        'IN_APP_POPUP': {cta: 'Publish now', detailsTitle: 'Pop-up details', label: 'In-app pop-up'},
        'STORY': {cta: 'Publish now', detailsTitle: 'Story details', label: 'Story'}
    };

    // Per-format cache of title + message. Push, Popup and Story are meant for
    // different surfaces (tray vs modal vs story rail) with different constraints,
    // so their text should not bleed across formats when the admin switches.
    var contentByFormat = {
        PUSH: {title: '', message: ''},
        IN_APP_POPUP: {title: '', message: ''},
        STORY: {title: '', message: ''}
    };

    $(document).on('click', '.nm-format', function () {
        var fmt = $(this).data('format');
        var prevFmt = activeFormat();
        // Snapshot outgoing format's text before we swap
        if (prevFmt && contentByFormat[prevFmt]) {
            contentByFormat[prevFmt].title = $('#title').val();
            contentByFormat[prevFmt].message = $('#message').val();
        }

        $('.nm-format').removeClass('active');
        $(this).addClass('active');
        $('.nm-panel').removeClass('active').hide();
        $('.nm-panel[data-panel="' + fmt + '"]').addClass('active').show();
        $('.nm-preview').removeClass('active').hide();
        $('.nm-preview[data-preview="' + fmt + '"]').addClass('active').show();

        // Restore incoming format's cached text (empty if the admin never typed
        // anything for this format yet). `trigger('input')` refreshes the char-count
        // and the live preview widgets.
        var cached = contentByFormat[fmt] || {title: '', message: ''};
        $('#title').val(cached.title).trigger('input');
        $('#message').val(cached.message).trigger('input');

        var meta = FORMAT_META[fmt];
        $('#nmPrimaryCtaLabel').text(meta.cta);
        $('#nmDetailsTitle').text(meta.detailsTitle);
        $('#nmSummaryFormat').text(meta.label);
        refreshCategorySummary();
        refreshExpirySummary();
    });
    // Keep the cache in sync so switching mid-typing preserves the current text.
    $(document).on('input', '#title', function () {
        var f = activeFormat();
        if (contentByFormat[f]) {
            contentByFormat[f].title = $(this).val();
        }
    });
    $(document).on('input', '#message', function () {
        var f = activeFormat();
        if (contentByFormat[f]) {
            contentByFormat[f].message = $(this).val();
        }
    });

    function activeFormat() {
        return $('.nm-format.active').data('format') || 'PUSH';
    }

    function getActiveCategorySelect() {
        var fmt = activeFormat();
        if (fmt === 'IN_APP_POPUP') return $('#nmCategoryPopup');
        if (fmt === 'STORY') return $('#nmCategoryStory');
        return $('#nmCategory');
    }

    // ─── Live preview updates ────────────────────────────────────────────
    $(document).on('input', '#title', function () {
        var v = $(this).val();
        $('#titleCount').text(v.length);
        var fallback = 'Restock offer: up to 40% margin this week';
        $('#nmPreviewTitlePush,#nmPreviewTitlePopup,#nmPreviewTitleStory').text(v || fallback);
    });
    $(document).on('input', '#message', function () {
        var v = $(this).val();
        $('#messageCount').text(v.length);
        var fallback = 'Order now and earn extra margin on staples. Free logistics on orders above ₹5,000. Tap to view the catalogue.';
        $('#nmPreviewBodyPush,#nmPreviewBodyPopup,#nmPreviewBodyStory').text(v || fallback);
    });
    $(document).on('input', '#buttonLabelPopup', function () {
        $('#nmPreviewCtaPopup').text($(this).val() || 'View offer');
    });
    $(document).on('input', '#buttonLabelStory', function () {
        $('#nmPreviewCtaStory').text(($(this).val() || 'View offer') + ' ↑');
    });
    $(document).on('change', '#showClose', function () {
        $('#nmPopupPreviewClose').toggle($(this).is(':checked'));
    });
    $(document).on('change', '#showCta', function () {
        $('#nmPreviewCtaPopup').toggle($(this).is(':checked'));
    });
    $(document).on('change', '#expiresAfterPolicy', function () {
        $('#storyExpiryOnDateField').toggle($(this).val() === 'ON_DATE');
        refreshExpirySummary();
    });

    // ─── Summary bindings ───────────────────────────────────────────────
    $(document).on('input', '#campaignName', function () {
        $('#nmSummaryCampaign').text($(this).val() || '—');
    });
    $(document).on('change', '#nmCategory,#nmCategoryPopup,#nmCategoryStory', refreshCategorySummary);
    $(document).on('change', '#expireSat,#popupLiveUntil,#storyExpiryOnDate,#storyLiveFrom,#popupLiveFrom', refreshExpirySummary);
    $(document).on('change', '#selectedUrl', function () {
        var v = $(this).val();
        $('#nmSummaryTap').text((v && v !== 'other') ? v : '—');
    });
    $(document).on('input', '#url', function () {
        if ($('#selectedUrl').val() === 'other') {
            $('#nmSummaryTap').text($(this).val() || '—');
        }
    });
    // "None" tap behaviour hides the Link picker entirely so the admin can ship
    // an info-only notification (no navigation, no CTA).
    $(document).on('change', '#type', function () {
        if ($(this).val() === 'none') {
            $('#nmLinkField').hide();
            $('#selectedUrl').val('');
            $('#url').val('').hide();
            $('#nmSummaryTap').text('None');
        } else {
            $('#nmLinkField').show();
        }
    });
    $(document).on('change', '#fofo-users, #state-users, #internal-users', updateRecipientSummary);

    function refreshCategorySummary() {
        $('#nmSummaryCategory').text(getActiveCategorySelect().find('option:selected').text() || 'Not set');
    }

    function refreshExpirySummary() {
        var v;
        var fmt = activeFormat();
        if (fmt === 'PUSH') {
            if ($('#nmHasExpiry').val() === 'no') {
                v = 'Never (lifetime)';
            } else {
                v = $('#expireSat').val();
            }
        } else if (fmt === 'IN_APP_POPUP') {
            v = $('#popupLiveUntil').val();
        } else if (fmt === 'STORY') {
            v = $('#expiresAfterPolicy').val() === 'ON_DATE'
                ? $('#storyExpiryOnDate').val()
                : '24 h from publish';
        }
        $('#nmSummaryExpires').text(v || '—');
    }

    // Show/hide expiry date input based on the lifetime toggle
    $(document).on('change', '#nmHasExpiry', function () {
        $('#nmExpiryDateField').toggle($(this).val() === 'yes');
        if ($(this).val() === 'no') {
            $('#expireSat').val('');
        }
        refreshExpirySummary();
    });

    function updateRecipientSummary() {
        var partners = ($('#fofo-users').val() || []).length;
        var states = ($('#state-users').val() || []).length;
        var users = ($('#internal-users').val() || []).length;
        var total = partners + states + users;
        if (total === 0) {
            $('#nmSummaryRecipients').text('Not selected');
            return;
        }
        var parts = [];
        if (partners) {
            parts.push(partners + ' partner' + (partners > 1 ? 's' : ''));
        }
        if (states) {
            parts.push(states + ' state' + (states > 1 ? 's' : ''));
        }
        if (users) {
            parts.push(users + ' user' + (users > 1 ? 's' : ''));
        }
        $('#nmSummaryRecipients').text(parts.join(' · '));
    }

    // ─── Image dropzone ─────────────────────────────────────────────────
    $(document).on('click', '.nm-dropzone', function (e) {
        if ($(e.target).is('.nm-file-input')) {
            return;
        }
        $(this).find('.nm-file-input').trigger('click');
    });
    $(document).on('dragover', '.nm-dropzone', function (e) {
        e.preventDefault();
        $(this).addClass('nm-dropzone-hover');
    });
    $(document).on('dragleave dragend drop', '.nm-dropzone', function () {
        $(this).removeClass('nm-dropzone-hover');
    });
    $(document).on('drop', '.nm-dropzone', function (e) {
        e.preventDefault();
        handleFiles(this, e.originalEvent.dataTransfer.files);
    });
    $(document).on('change', '.nm-file-input', function () {
        handleFiles($(this).closest('.nm-dropzone')[0], this.files);
        this.value = '';   // allow re-selecting the same file
    });
    $(document).on('click', '.nm-thumb-x', function (e) {
        e.stopPropagation();
        var $list = $(this).closest('.nm-thumb-list');
        $(this).closest('.nm-thumb').remove();
        if ($list.data('target') === 'popupImage') {
            refreshPopupPreviewImage();
        }
    });

    // Reflect the uploaded/removed popup image in the live preview card.
    function refreshPopupPreviewImage() {
        var $firstImg = $('.nm-thumb-list[data-target="popupImage"] .nm-thumb img').first();
        var $preview = $('#nmPreviewImagePopup');
        if ($firstImg.length && $firstImg.attr('src')) {
            $preview.attr('src', $firstImg.attr('src')).show();
        } else {
            $preview.attr('src', '').hide();
        }
    }

    function handleFiles(dropzone, fileList) {
        if (!fileList || fileList.length === 0) {
            return;
        }
        var $dz = $(dropzone);
        var target = $dz.data('target');
        var multi = $dz.data('multi') === true;
        var $list = $('.nm-thumb-list[data-target="' + target + '"]');
        if (!multi) {
            $list.empty();
        }
        for (var i = 0; i < fileList.length; i++) {
            uploadOne(fileList[i], $list);
            if (!multi) {
                break;
            }
        }
    }

    function uploadOne(file, $list) {
        if (!file.type || file.type.indexOf('image/') !== 0) {
            alert('Only image files are accepted.');
            return;
        }
        if (file.size > 5 * 1024 * 1024) {
            alert('Image is larger than 5 MB — please compress and retry.');
            return;
        }
        var $thumb = $('<li class="nm-thumb"></li>');
        var $spinner = $('<div class="nm-thumb-spinner"><i class="fa fa-spinner fa-spin"></i></div>');
        $thumb.append($spinner);
        $list.append($thumb);

        // Local preview via FileReader
        var reader = new FileReader();
        reader.onload = function (e) {
            $thumb.prepend('<img src="' + e.target.result + '">');
            if ($list.data('target') === 'popupImage') {
                refreshPopupPreviewImage();
            }
        };
        reader.readAsDataURL(file);

        // Upload to server; store the returned URL/id on the thumb.
        doAjaxUploadRequestHandler(context + '/document-upload', 'POST', file, function (response) {
            var docId = response && response.response && response.response.document_id;
            if (!docId) {
                $thumb.remove();
                alert('Upload failed. Try again.');
                return;
            }
            var url = context + '/document/' + docId;
            $thumb.data('doc-id', docId).data('url', url);
            $spinner.remove();
            $thumb.append('<span class="nm-thumb-x">&times;</span>');
        });
    }

    // ─── Linked entities: schemes / offers / products ────────────────────
    // Cache keyed by URL (which encodes recipient filters). Type + same recipients → cache hit.
    var LINKABLE_CACHE = {};

    function buildLinkableUrl(type) {
        var endpoint = type === 'SCHEME' ? 'schemes' : 'offers';
        var fofoIds = $('#fofo-users').val() || [];
        var stateIds = $('#state-users').val() || [];
        var qs = [];
        if (fofoIds.length) {
            qs.push('fofoIds=' + fofoIds.join(','));
        }
        if (stateIds.length) {
            qs.push('stateIds=' + stateIds.join(','));
        }
        return context + '/notificationLinkables/' + endpoint + (qs.length ? '?' + qs.join('&') : '');
    }

    // If recipients change while a scheme/offer picker is open, refetch with the new filter.
    $(document).on('change', '#fofo-users, #state-users', function () {
        LINKABLE_CACHE = {};   // invalidate the whole cache — filters affect every fetch
        var type = $('#linkableType').val();
        if (type === 'SCHEME' || type === 'OFFER') {
            $('#linkableType').trigger('change');   // reload the picker with new filters
        }
    });

    $(document).on('change', '#linkableType', function () {
        var type = $(this).val();
        var $picker = $('#linkablePicker');
        var $searchWrap = $('#linkableSearchWrap');
        if (!type) {
            $picker.hide();
            $searchWrap.hide();
            return;
        }
        if (type === 'PRODUCT') {
            $picker.hide();
            $searchWrap.show();
            initLinkableProductTypeahead();
            $('#linkableSearchInput').val('').focus();
            return;
        }
        // SCHEME / OFFER — build the URL from the current recipient selection.
        $searchWrap.hide();
        $picker.show().html('<option value="">— Loading —</option>');
        var url = buildLinkableUrl(type);
        if (LINKABLE_CACHE[url]) {
            renderLinkableOptions($picker, LINKABLE_CACHE[url]);
        } else {
            $.getJSON(url, function (data) {
                var parsed = typeof data === 'string' ? JSON.parse(data) : data;
                LINKABLE_CACHE[url] = parsed;
                renderLinkableOptions($picker, parsed);
            }).fail(function () {
                $picker.html('<option value="">— Failed to load —</option>');
            });
        }
    });

    function renderLinkableOptions($sel, items) {
        var opts = '<option value="">— Pick one —</option>';
        items.forEach(function (it) {
            opts += '<option value="' + it.id + '" data-label="' + escAttr(it.label) + '" data-type="' + it.type + '">'
                + escHtml(it.label) + '</option>';
        });
        $sel.html(opts);
    }

    $(document).on('change', '#linkablePicker', function () {
        var $opt = $(this).find('option:selected');
        if (!$opt.val()) {
            return;
        }
        addLinkedEntity($opt.data('type'), parseInt($opt.val(), 10), $opt.data('label'));
        $(this).val('');
    });

    // Product typeahead — Bootstrap 3 typeahead widget against Solr-backed /entity.
    // Same widget the rest of the app uses (getItemAheadOptions pattern) so behaviour and
    // dropdown positioning stay consistent.
    function initLinkableProductTypeahead() {
        var $input = $('#linkableSearchInput');
        if (!$input.length || $input.data('nm-typeahead-init')) {
            return;
        }
        $input.data('nm-typeahead-init', true);
        $input.typeahead('destroy').typeahead({
            source: function (q, process) {
                if (q && q.length >= 2) {
                    return $.ajax(context + '/entity', {
                        global: false,
                        data: {query: q, limit: 30, activeOnly: true},
                        success: function (data) {
                            var parsed = typeof data === 'string' ? JSON.parse(data) : data;
                            process(parsed || []);
                        }
                    });
                }
            },
            delay: 250,
            items: 20,
            minLength: 2,
            displayText: function (item) {
                return item.title_s || ('Catalog ' + item.catalogId_i);
            },
            afterSelect: function (item) {
                var label = item.title_s || ('Catalog ' + item.catalogId_i);
                addLinkedEntity('PRODUCT', item.catalogId_i, label);
                $input.val('');
            }
        });
    }

    function addLinkedEntity(type, id, label) {
        var key = type + ':' + id;
        var duplicate = false;
        $('#linkedEntitiesList li').each(function () {
            if ($(this).data('key') === key) {
                duplicate = true;
            }
        });
        if (duplicate) {
            return;
        }
        var $li = $('<li class="nm-chip"></li>')
            .attr('data-key', key)
            .data({type: type, id: id, label: label})
            .text('[' + type.charAt(0) + '] ' + label)
            .append(' <span class="nm-chip-x removeLinkedEntity">&times;</span>');
        $('#linkedEntitiesList').append($li);
    }

    $(document).on('click', '.removeLinkedEntity', function () {
        $(this).closest('li').remove();
    });

    function collectLinkedEntities() {
        var out = [];
        $('#linkedEntitiesList li').each(function () {
            out.push({type: $(this).data('type'), id: $(this).data('id'), label: $(this).data('label')});
        });
        return out;
    }

    function escHtml(s) {
        return String(s || '').replace(/[&<>]/g, function (c) {
            return {'&': '&amp;', '<': '&lt;', '>': '&gt;'}[c];
        });
    }

    function escAttr(s) {
        return String(s || '').replace(/"/g, '&quot;');
    }

    function collectImages(target) {
        var out = [];
        $('.nm-thumb-list[data-target="' + target + '"] .nm-thumb').each(function () {
            var url = $(this).data('url');
            if (url) {
                out.push(url);
            }
        });
        return out;
    }

    // ─── Category → legacy message_type mapping ─────────────────────────
    var CATEGORY_TO_MESSAGE_TYPE = {
        'ANNOUNCE': 'scheme',       // was OFFER — same underlying route
        'NOTIFICATION': 'notification', // was INFO
        'REMINDER': 'reminder',
        'ALERT': 'pricechange'
    };

    // ─── Schedule modal ─────────────────────────────────────────────────
    $(document).on('click', '#nmScheduleBtn', function () {
        $('#nmScheduleModal').modal('show');
        refreshScheduleButtonState();   // set initial disabled state on open
    });

    // "Schedule & save" is only meaningful when at least one date has been added.
    // Grey out until then so the admin can't submit an empty schedule.
    function refreshScheduleButtonState() {
        var hasDates = $('#scheduleDatesList li').length > 0;
        $('#nmSubmitScheduleBtn')
            .prop('disabled', !hasDates)
            .css('opacity', hasDates ? 1 : 0.5)
            .css('cursor', hasDates ? 'pointer' : 'not-allowed');
    }

    $(document).on('click', '#addScheduleDate', function () {
        var raw = $('#scheduleDateInput').val();
        if (!raw) {
            alert('Pick a date and time');
            return;
        }
        var iso = raw.length === 16 ? raw + ':00' : raw;
        var duplicate = false;
        $('#scheduleDatesList li').each(function () {
            if ($(this).data('ts') === iso) {
                duplicate = true;
            }
        });
        if (duplicate) {
            return;
        }
        var $li = $('<li class="nm-chip"></li>').attr('data-ts', iso)
            .text(raw.replace('T', ' '))
            .append(' <span class="nm-chip-x removeScheduleDate">&times;</span>');
        $('#scheduleDatesList').append($li);
        $('#scheduleDateInput').val('');
        refreshScheduleButtonState();
    });
    $(document).on('click', '.removeScheduleDate', function () {
        $(this).closest('li').remove();
        refreshScheduleButtonState();
    });

    $(document).on('click', '#nmSubmitScheduleBtn', function () {
        var scheduledTimestamps = collectScheduledTimestamps();
        if (scheduledTimestamps.length === 0) {
            alert('Add at least one date, or use "Send now" to send immediately.');
            return;
        }
        if (!validateAndConfirm('Schedule for ' + scheduledTimestamps.length + ' date(s)?')) {
            return;
        }
        submitNotification(scheduledTimestamps, function () {
            $('#nmScheduleModal').modal('hide');
            $('#scheduleDatesList').empty();
        });
    });

    function collectScheduledTimestamps() {
        var list = [];
        $('#scheduleDatesList li').each(function () {
            list.push($(this).data('ts'));
        });
        return list;
    }

    // ─── Send / Publish now ─────────────────────────────────────────────
    $(document).on('click', '.create-notification-button', function () {
        var confirmText = activeFormat() === 'PUSH' ? 'Send this notification now?' : 'Publish now?';
        if (!validateAndConfirm(confirmText)) {
            return;
        }
        submitNotification([], function () { /* stay on the form */
        });
    });

    function validateAndConfirm(confirmText) {
        var campaignName = $('#campaignName').val();
        var title = $('#title').val();
        var message = $('#message').val();
        var category = getActiveCategorySelect().val();
        var type = $('#type').val();
        var fmt = activeFormat();

        if (!campaignName) {
            alert('Campaign name is required');
            return false;
        }
        if (!title) {
            alert('Title is required');
            return false;
        }
        if (!message) {
            alert('Message is required');
            return false;
        }
        if (!category) {
            alert('Category is required');
            return false;
        }
        if (!type) {
            alert('Tap action is required');
            return false;
        }

        if (fmt === 'PUSH' && $('#nmHasExpiry').val() === 'yes' && !$('#expireSat').val()) {
            alert('Expiry date is required (or switch life span to "Lifetime")');
            return false;
        }
        if (fmt === 'IN_APP_POPUP' && !$('#showOnPage').val()) {
            alert('"Show on page (route)" is required — e.g. /dashboard');
            return false;
        }
        if (fmt === 'IN_APP_POPUP' && !$('#popupLiveUntil').val()) {
            alert('"Live until" date is required');
            return false;
        }
        if (fmt === 'STORY' && $('#expiresAfterPolicy').val() === 'ON_DATE' && !$('#storyExpiryOnDate').val()) {
            alert('"Expires on" date is required');
            return false;
        }

        if (type === 'url') {
            var linkVal = $('#selectedUrl').val();
            var effectiveUrl = (linkVal === 'other') ? $('#url').val() : linkVal;
            if (!effectiveUrl) {
                alert('Link to open is required');
                return false;
            }
        }
        return confirm(confirmText);
    }

    function submitNotification(scheduledTimestamps, onSuccess) {
        var fmt = activeFormat();
        var typeVal = $('#type').val();
        var linkVal = $('#selectedUrl').val();
        var effectiveUrl = (typeVal === 'none')
            ? ''
            : (linkVal === 'other' ? $('#url').val() : linkVal);
        var category = getActiveCategorySelect().val();

        var selectedGroup = $('#nmCampaignGroup').val();
        var campaignGroupId = (selectedGroup && selectedGroup !== '__new__') ? parseInt(selectedGroup, 10) : null;

        var payload = {
            stateIds: $('#state-users').val(),
            userIds: ($('#fofo-users').val() || []).concat($('#internal-users').val() || []),
            campaignGroupId: campaignGroupId,
            campaignName: $('#campaignName').val(),
            title: $('#title').val(),
            message: $('#message').val(),
            type: $('#type').val(),
            url: effectiveUrl,
            messageType: CATEGORY_TO_MESSAGE_TYPE[category],
            category: category,
            format: fmt,
            linkedEntities: collectLinkedEntities()
        };

        if (fmt === 'PUSH') {
            var pushImgs = collectImages('pushImages');
            payload.images = pushImgs;
            payload.imageUrl = pushImgs[0] || '';   // first as legacy imageUrl for older readers
            payload.showImage = pushImgs.length > 0 ? 'true' : 'false';
            payload.documentId = localStorage.getItem('notifyDocument');
            // Lifetime toggle: only send an expiry when the admin explicitly chose one.
            // null / omit → mobile treats as never-expiring.
            payload.expiresat = $('#nmHasExpiry').val() === 'yes'
                ? getDatesFromPicker('input[name="dateTime"]').startDate
                : null;
        } else if (fmt === 'IN_APP_POPUP') {
            var popupImgs = collectImages('popupImage');
            payload.imageUrl = popupImgs[0] || '';
            payload.buttonLabel = $('#buttonLabelPopup').val();
            payload.showCta = $('#showCta').is(':checked');
            payload.showClose = $('#showClose').is(':checked');
            payload.showOnPage = $('#showOnPage').val() || null;
            payload.autoCloseSeconds = $('#autoCloseSeconds').val() ? parseInt($('#autoCloseSeconds').val(), 10) : null;
            payload.frequencyCap = 'ALWAYS';
            payload.liveFrom = normalizeDate($('#popupLiveFrom').val());
            // liveUntil is a date input; admin intent is "still visible through end of that day",
            // so anchor at 23:59:59 instead of the default midnight start-of-day.
            payload.liveUntil = normalizeDate($('#popupLiveUntil').val(), true);
            payload.expiresat = payload.liveUntil;
        } else if (fmt === 'STORY') {
            payload.buttonLabel = $('#buttonLabelStory').val();
            payload.storyCards = collectImages('storyCards');
            payload.postInCategory = $('#postInCategory').val();
            payload.playDurationSeconds = parseInt($('#playDurationSeconds').val(), 10);
            payload.expiresAfterPolicy = $('#expiresAfterPolicy').val();
            payload.liveFrom = normalizeDate($('#storyLiveFrom').val());
            // Anchor a concrete liveUntil + expiresat so downstream (React app, cron, expiry check)
            // doesn't have to derive it. FIXED_HOURS = liveFrom + 24 h; ON_DATE = whatever the admin picks.
            if (payload.expiresAfterPolicy === 'ON_DATE') {
                payload.expiresat = normalizeDate($('#storyExpiryOnDate').val());
                payload.liveUntil = payload.expiresat;
            } else {
                payload.liveUntil = addHoursIso(payload.liveFrom, 24);
                payload.expiresat = payload.liveUntil;
            }
        }

        if (scheduledTimestamps && scheduledTimestamps.length > 0) {
            payload.scheduledTimestamps = scheduledTimestamps;
        }

        doPostAjaxRequestWithJsonHandler(
            context + '/sendNotification',
            JSON.stringify(payload),
            function (response) {
                if (response === 'true' || response === true) {
                    alert(scheduledTimestamps.length > 0
                        ? 'Scheduled successfully'
                        : (fmt === 'PUSH' ? 'Sent successfully' : 'Published successfully'));
                    localStorage.removeItem('notifyDocument');
                    if (onSuccess) {
                        onSuccess();
                    }
                } else {
                    alert('Something went wrong. Check the campaign and try again.');
                }
            });
    }

    function normalizeDate(v, endOfDay) {
        if (!v) {
            return null;
        }
        if (v.length === 10) {
            return v + (endOfDay ? 'T23:59:59' : 'T00:00:00');
        }
        if (v.length === 16) {
            return v + ':00';
        }
        return v;
    }

    function addHoursIso(isoLocal, hours) {
        if (!isoLocal) {
            return null;
        }
        var d = new Date(isoLocal);
        if (isNaN(d.getTime())) {
            return null;
        }
        d.setHours(d.getHours() + hours);
        // Back to local-ISO without the trailing Z.
        var pad = function (n) {
            return String(n).padStart(2, '0');
        };
        return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
            + 'T' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds());
    }

    // ─── Reset ──────────────────────────────────────────────────────────
    $(document).on('click', '#nmResetBtn', function () {
        if (!confirm('Clear all fields?')) {
            return;
        }
        $('input[type="text"], input[type="date"], input[type="datetime-local"], textarea').val('');
        $('#titleCount, #messageCount').text('0');
        $('#showImage').prop('checked', false).val('false');
        $('#showCta, #showClose').prop('checked', true);
        $('#fofo-users, #state-users, #internal-users').val([]).multiselect('refresh');
        $('#type').val('url');
        $('#selectedUrl').val('').trigger('change');
        $('.nm-select[name="category"]').val('').trigger('change');
        $('.nm-thumb-list').empty();
        $('#scheduleDatesList').empty();
        $('#linkedEntitiesList').empty();
        $('#linkableType').val('');
        $('#linkablePicker').hide();
        $('#linkableSearchWrap').hide();
        $('#nmCampaignGroup').val('');
        // Reset life span toggle to default (lifetime / no expiry)
        $('#nmHasExpiry').val('no').trigger('change');
        // Clear the per-format title/message cache too
        contentByFormat.PUSH = {title: '', message: ''};
        contentByFormat.IN_APP_POPUP = {title: '', message: ''};
        contentByFormat.STORY = {title: '', message: ''};
        // Clear popup preview image
        refreshPopupPreviewImage();
        $('#url').hide();
        $('.nm-format').removeClass('active');
        $('.nm-format[data-format="PUSH"]').addClass('active').trigger('click');
        $('#nmSummaryCampaign, #nmSummaryTap, #nmSummaryExpires').text('—');
        $('#nmSummaryRecipients').text('Not selected');
        $('#nmSummaryCategory').text('Not set');
        $('#nmSummarySchedule').text('Send now');
        localStorage.removeItem('notifyDocument');
    });
});

function loadNotificationPanel(domId) {
    localStorage.removeItem('notifyDocument');
    doGetAjaxRequestHandler(context + '/sendNotificationPanel', function (response) {
                $('#' + domId).html(response);
        });
}

// Attachment document upload (Push format only — separate from image dropzones)
$(document).on('input', '#notifyDocument', function () {
    if (confirm('Document has been selected, do you want to upload?')) {
        var fileSelector = $('#notifyDocument')[0];
        if (fileSelector !== undefined && fileSelector.files[0] !== undefined) {
            var uploadUrl = context + '/document-upload';
            var file = this.files[0];
            doAjaxUploadRequestHandler(uploadUrl, 'POST', file, function (response) {
                var documentId = response.response.document_id;
                localStorage.setItem('notifyDocument', documentId);
            });
        }
    }
});