mango-lollipop 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/html.js ADDED
@@ -0,0 +1,938 @@
1
+ // =============================================================================
2
+ // Mango Lollipop — HTML Generation (Dashboard + Overview)
3
+ // =============================================================================
4
+ // -----------------------------------------------------------------------------
5
+ // Stage display names and colors
6
+ // -----------------------------------------------------------------------------
7
+ const STAGE_META = {
8
+ TX: { label: "Transactional", color: "#666", bg: "#f0f0f0" },
9
+ AQ: { label: "Acquisition", color: "#28a745", bg: "#d4edda" },
10
+ AC: { label: "Activation", color: "#007bff", bg: "#cce5ff" },
11
+ RV: { label: "Revenue", color: "#ffc107", bg: "#fff3cd" },
12
+ RT: { label: "Retention", color: "#fd7e14", bg: "#ffe5cc" },
13
+ RF: { label: "Referral", color: "#6f42c1", bg: "#e8d5f5" },
14
+ };
15
+ // -----------------------------------------------------------------------------
16
+ // Shared helpers
17
+ // -----------------------------------------------------------------------------
18
+ function escapeHtml(str) {
19
+ return str
20
+ .replace(/&/g, "&")
21
+ .replace(/</g, "&lt;")
22
+ .replace(/>/g, "&gt;")
23
+ .replace(/"/g, "&quot;")
24
+ .replace(/'/g, "&#039;");
25
+ }
26
+ function buildStatsBlock(messages) {
27
+ const tx = messages.filter((m) => m.classification === "transactional");
28
+ const lc = messages.filter((m) => m.classification === "lifecycle");
29
+ const byStage = {};
30
+ const byChannel = {};
31
+ const tagCounts = {};
32
+ for (const m of messages) {
33
+ byStage[m.stage] = (byStage[m.stage] ?? 0) + 1;
34
+ // Support singular channel and legacy channels array
35
+ const ch = m.channel ?? (m.channels?.[0]);
36
+ if (ch)
37
+ byChannel[ch] = (byChannel[ch] ?? 0) + 1;
38
+ for (const tag of m.tags) {
39
+ tagCounts[tag] = (tagCounts[tag] ?? 0) + 1;
40
+ }
41
+ }
42
+ return {
43
+ total: messages.length,
44
+ txCount: tx.length,
45
+ lcCount: lc.length,
46
+ byStage,
47
+ byChannel,
48
+ allTags: Object.keys(tagCounts).sort(),
49
+ tagCounts,
50
+ };
51
+ }
52
+ // -----------------------------------------------------------------------------
53
+ // Dashboard HTML
54
+ // -----------------------------------------------------------------------------
55
+ export function generateDashboard(messages, analysis) {
56
+ const stats = buildStatsBlock(messages);
57
+ const dataJson = JSON.stringify(messages);
58
+ const analysisJson = JSON.stringify(analysis);
59
+ return `<!DOCTYPE html>
60
+ <html lang="en">
61
+ <head>
62
+ <meta charset="UTF-8">
63
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
64
+ <title>${escapeHtml(analysis.company.name)} - Lifecycle Messaging Dashboard</title>
65
+ <script src="https://cdn.tailwindcss.com"></script>
66
+ <style>
67
+ .stage-badge { display: inline-block; padding: 2px 8px; border-radius: 9999px; font-size: 0.75rem; font-weight: 600; }
68
+ .tag-pill { display: inline-block; padding: 1px 6px; border-radius: 9999px; font-size: 0.7rem; background: #e5e7eb; color: #374151; cursor: pointer; margin: 1px; }
69
+ .tag-pill.active { background: #3b82f6; color: white; }
70
+ .filter-item { display: flex; justify-content: space-between; align-items: center; padding: 3px 6px; border-radius: 6px; cursor: pointer; font-size: 0.875rem; }
71
+ .filter-item:hover { background: #f3f4f6; }
72
+ .filter-item.active { background: #dbeafe; }
73
+ .collapse-btn { cursor: pointer; user-select: none; display: flex; align-items: center; justify-content: space-between; width: 100%; }
74
+ .collapse-btn .arrow { transition: transform 0.15s; font-size: 0.7rem; color: #9ca3af; }
75
+ .collapse-btn .arrow.open { transform: rotate(90deg); }
76
+ .collapse-body { overflow: hidden; }
77
+ .collapse-body.collapsed { display: none; }
78
+ .msg-row { cursor: pointer; }
79
+ .msg-row:hover { background: #f9fafb; }
80
+ .msg-detail { display: none; }
81
+ .msg-detail.open { display: table-row; }
82
+ .sortable { cursor: pointer; user-select: none; }
83
+ .sortable:hover { color: #3b82f6; }
84
+ .sortable::after { content: ' \\2195'; font-size: 0.7em; opacity: 0.4; }
85
+ </style>
86
+ </head>
87
+ <body class="bg-gray-50 text-gray-900">
88
+
89
+ <!-- Inline data -->
90
+ <script id="msg-data" type="application/json">${dataJson}</script>
91
+ <script id="analysis-data" type="application/json">${analysisJson}</script>
92
+
93
+ <!-- Header -->
94
+ <header class="bg-white border-b px-6 py-4 flex items-center justify-between">
95
+ <div>
96
+ <h1 class="text-xl font-bold">${escapeHtml(analysis.company.name)} &mdash; Lifecycle Messaging Dashboard</h1>
97
+ <p class="text-sm text-gray-500">${escapeHtml(analysis.company.product_type)} &bull; ${stats.total} messages &bull; ${stats.txCount} transactional, ${stats.lcCount} lifecycle</p>
98
+ </div>
99
+ <div class="flex gap-2">
100
+ <button id="btn-all" class="px-3 py-1 text-sm rounded border border-gray-300 bg-white hover:bg-gray-100" onclick="setView('all')">All</button>
101
+ <button id="btn-tx" class="px-3 py-1 text-sm rounded border border-gray-300 bg-white hover:bg-gray-100" onclick="setView('tx')">Transactional</button>
102
+ <button id="btn-lc" class="px-3 py-1 text-sm rounded border border-gray-300 bg-white hover:bg-gray-100" onclick="setView('lc')">Lifecycle</button>
103
+ </div>
104
+ </header>
105
+
106
+ <div class="flex">
107
+ <!-- Sidebar: Filters -->
108
+ <aside class="w-56 bg-white border-r p-4 min-h-screen space-y-1">
109
+
110
+ <!-- Stage filter -->
111
+ <div>
112
+ <button class="collapse-btn" onclick="toggleCollapse('stage-filter')">
113
+ <span class="text-sm font-semibold text-gray-500 uppercase">Stage</span>
114
+ <span class="arrow open" id="arrow-stage-filter">&#9654;</span>
115
+ </button>
116
+ <div id="stage-filter" class="collapse-body mt-1 space-y-0.5">
117
+ ${Object.entries(stats.byStage)
118
+ .map(([stage, count]) => {
119
+ const meta = STAGE_META[stage] ?? {
120
+ label: stage,
121
+ color: "#666",
122
+ bg: "#f0f0f0",
123
+ };
124
+ return ` <div class="filter-item" data-stage="${stage}" onclick="toggleStage(this)"><span class="stage-badge" style="background:${meta.bg};color:${meta.color}">${meta.label}</span><span class="text-xs text-gray-400">${count}</span></div>`;
125
+ })
126
+ .join("\n")}
127
+ </div>
128
+ </div>
129
+
130
+ <!-- Channel filter -->
131
+ <div>
132
+ <button class="collapse-btn" onclick="toggleCollapse('channel-filter')">
133
+ <span class="text-sm font-semibold text-gray-500 uppercase">Channel</span>
134
+ <span class="arrow open" id="arrow-channel-filter">&#9654;</span>
135
+ </button>
136
+ <div id="channel-filter" class="collapse-body mt-1 space-y-0.5">
137
+ ${Object.entries(stats.byChannel)
138
+ .map(([ch, count]) => ` <div class="filter-item" data-channel="${escapeHtml(ch)}" onclick="toggleChannel(this)"><span>${escapeHtml(ch)}</span><span class="text-xs text-gray-400">${count}</span></div>`)
139
+ .join("\n")}
140
+ </div>
141
+ </div>
142
+
143
+ <!-- Tag filter -->
144
+ <div>
145
+ <button class="collapse-btn" onclick="toggleCollapse('tag-filter')">
146
+ <span class="text-sm font-semibold text-gray-500 uppercase">Tags</span>
147
+ <span class="arrow open" id="arrow-tag-filter">&#9654;</span>
148
+ </button>
149
+ <div id="tag-filter" class="collapse-body mt-1">
150
+ ${stats.allTags
151
+ .map((tag) => ` <span class="tag-pill" data-tag="${escapeHtml(tag)}" onclick="toggleTag(this)">${escapeHtml(tag)} <span class="text-gray-400">(${stats.tagCounts[tag]})</span></span>`)
152
+ .join("\n")}
153
+ </div>
154
+ </div>
155
+
156
+ <button class="mt-2 text-xs text-blue-500 hover:underline" onclick="clearAllFilters()">Clear all filters</button>
157
+ </aside>
158
+
159
+ <!-- Main content -->
160
+ <main class="flex-1 p-6 space-y-8">
161
+
162
+ <!-- Matrix Table -->
163
+ <section>
164
+ <h2 class="text-lg font-semibold mb-3">Message Matrix</h2>
165
+ <div class="bg-white rounded-lg border overflow-x-auto">
166
+ <table class="w-full text-sm" id="matrix-table">
167
+ <thead class="bg-gray-50 text-left text-xs text-gray-500 uppercase">
168
+ <tr>
169
+ <th class="px-3 py-2 sortable" data-col="id" onclick="sortTable('id')">ID</th>
170
+ <th class="px-3 py-2 sortable" data-col="stage" onclick="sortTable('stage')">Stage</th>
171
+ <th class="px-3 py-2 sortable" data-col="name" onclick="sortTable('name')">Name</th>
172
+ <th class="px-3 py-2">Trigger</th>
173
+ <th class="px-3 py-2 sortable" data-col="wait" onclick="sortTable('wait')">Wait</th>
174
+ <th class="px-3 py-2">Channel</th>
175
+ <th class="px-3 py-2">CTA</th>
176
+ <th class="px-3 py-2">Tags</th>
177
+ </tr>
178
+ </thead>
179
+ <tbody id="matrix-body">
180
+ </tbody>
181
+ </table>
182
+ </div>
183
+ </section>
184
+
185
+ </main>
186
+ </div>
187
+
188
+ <script>
189
+ const allMessages = JSON.parse(document.getElementById('msg-data').textContent);
190
+ let currentView = 'all';
191
+ let activeStages = new Set();
192
+ let activeChannels = new Set();
193
+ let activeTags = new Set();
194
+ let sortCol = 'id';
195
+ let sortAsc = true;
196
+
197
+ const stageMeta = ${JSON.stringify(STAGE_META)};
198
+
199
+ // --- Collapse ---
200
+ function toggleCollapse(id) {
201
+ const body = document.getElementById(id);
202
+ const arrow = document.getElementById('arrow-' + id);
203
+ body.classList.toggle('collapsed');
204
+ arrow.classList.toggle('open');
205
+ }
206
+
207
+ // --- View toggle ---
208
+ function setView(view) {
209
+ currentView = view;
210
+ document.getElementById('btn-all').classList.toggle('bg-blue-100', view === 'all');
211
+ document.getElementById('btn-tx').classList.toggle('bg-blue-100', view === 'tx');
212
+ document.getElementById('btn-lc').classList.toggle('bg-blue-100', view === 'lc');
213
+ render();
214
+ }
215
+
216
+ // --- Stage filter ---
217
+ function toggleStage(el) {
218
+ const stage = el.dataset.stage;
219
+ if (activeStages.has(stage)) { activeStages.delete(stage); el.classList.remove('active'); }
220
+ else { activeStages.add(stage); el.classList.add('active'); }
221
+ render();
222
+ }
223
+
224
+ // --- Channel filter ---
225
+ function toggleChannel(el) {
226
+ const ch = el.dataset.channel;
227
+ if (activeChannels.has(ch)) { activeChannels.delete(ch); el.classList.remove('active'); }
228
+ else { activeChannels.add(ch); el.classList.add('active'); }
229
+ render();
230
+ }
231
+
232
+ // --- Tag filter ---
233
+ function toggleTag(el) {
234
+ const tag = el.dataset.tag;
235
+ if (activeTags.has(tag)) { activeTags.delete(tag); el.classList.remove('active'); }
236
+ else { activeTags.add(tag); el.classList.add('active'); }
237
+ render();
238
+ }
239
+
240
+ function clearAllFilters() {
241
+ activeStages.clear();
242
+ activeChannels.clear();
243
+ activeTags.clear();
244
+ document.querySelectorAll('.filter-item.active, .tag-pill.active').forEach(el => el.classList.remove('active'));
245
+ currentView = 'all';
246
+ document.getElementById('btn-all').classList.add('bg-blue-100');
247
+ document.getElementById('btn-tx').classList.remove('bg-blue-100');
248
+ document.getElementById('btn-lc').classList.remove('bg-blue-100');
249
+ render();
250
+ }
251
+
252
+ function sortTable(col) {
253
+ if (sortCol === col) sortAsc = !sortAsc;
254
+ else { sortCol = col; sortAsc = true; }
255
+ render();
256
+ }
257
+
258
+ function esc(s) {
259
+ const d = document.createElement('div');
260
+ d.textContent = s;
261
+ return d.innerHTML;
262
+ }
263
+
264
+ function getMsgChannel(m) {
265
+ return m.channel || (m.channels && m.channels[0]) || '';
266
+ }
267
+
268
+ function render() {
269
+ let msgs = allMessages;
270
+
271
+ // View filter
272
+ if (currentView === 'tx') msgs = msgs.filter(m => m.classification === 'transactional');
273
+ if (currentView === 'lc') msgs = msgs.filter(m => m.classification === 'lifecycle');
274
+
275
+ // Stage filter (OR within stages)
276
+ if (activeStages.size > 0) msgs = msgs.filter(m => activeStages.has(m.stage));
277
+
278
+ // Channel filter (OR within channels)
279
+ if (activeChannels.size > 0) msgs = msgs.filter(m => activeChannels.has(getMsgChannel(m)));
280
+
281
+ // Tag filter (OR within tags)
282
+ if (activeTags.size > 0) msgs = msgs.filter(m => m.tags.some(t => activeTags.has(t)));
283
+
284
+ msgs.sort((a, b) => {
285
+ let va = a[sortCol] ?? '';
286
+ let vb = b[sortCol] ?? '';
287
+ if (typeof va === 'string') va = va.toLowerCase();
288
+ if (typeof vb === 'string') vb = vb.toLowerCase();
289
+ if (va < vb) return sortAsc ? -1 : 1;
290
+ if (va > vb) return sortAsc ? 1 : -1;
291
+ return 0;
292
+ });
293
+
294
+ const tbody = document.getElementById('matrix-body');
295
+ tbody.innerHTML = '';
296
+ for (const m of msgs) {
297
+ const meta = stageMeta[m.stage] || { label: m.stage, color: '#666', bg: '#f0f0f0' };
298
+ const ch = getMsgChannel(m);
299
+ const tr = document.createElement('tr');
300
+ tr.className = 'msg-row border-t';
301
+ tr.innerHTML =
302
+ '<td class="px-3 py-2 font-mono text-xs">' + esc(m.id) + '</td>' +
303
+ '<td class="px-3 py-2"><span class="stage-badge" style="background:' + meta.bg + ';color:' + meta.color + '">' + esc(meta.label) + '</span></td>' +
304
+ '<td class="px-3 py-2 font-medium">' + esc(m.name) + '</td>' +
305
+ '<td class="px-3 py-2 text-xs text-gray-600">' + esc(m.trigger.event) + '</td>' +
306
+ '<td class="px-3 py-2 font-mono text-xs">' + esc(m.wait) + '</td>' +
307
+ '<td class="px-3 py-2 text-xs">' + esc(ch) + '</td>' +
308
+ '<td class="px-3 py-2 text-xs">' + esc(m.cta.text) + '</td>' +
309
+ '<td class="px-3 py-2">' + m.tags.map(t => '<span class="tag-pill">' + esc(t) + '</span>').join(' ') + '</td>';
310
+ tr.addEventListener('click', () => toggleDetail(m.id));
311
+ tbody.appendChild(tr);
312
+
313
+ // Detail row
314
+ const detail = document.createElement('tr');
315
+ detail.id = 'detail-' + m.id;
316
+ detail.className = 'msg-detail bg-gray-50';
317
+ detail.innerHTML =
318
+ '<td colspan="8" class="px-6 py-4 text-sm">' +
319
+ '<div class="grid grid-cols-2 gap-4">' +
320
+ (m.subject ? '<div><strong>Subject:</strong> ' + esc(m.subject) + '</div>' : '') +
321
+ '<div><strong>From:</strong> ' + esc(m.from) + '</div>' +
322
+ '<div><strong>Segment:</strong> ' + esc(m.segment) + '</div>' +
323
+ '<div><strong>Goal:</strong> ' + esc(m.goal) + '</div>' +
324
+ '<div><strong>Format:</strong> ' + esc(m.format) + '</div>' +
325
+ '<div><strong>Guards:</strong> ' + (m.guards.length ? m.guards.map(g => esc(g.condition)).join('; ') : '\\u2014') + '</div>' +
326
+ '<div><strong>Suppressions:</strong> ' + (m.suppressions.length ? m.suppressions.map(s => esc(s.condition)).join('; ') : '\\u2014') + '</div>' +
327
+ '<div class="col-span-2"><strong>Comments:</strong> ' + esc(m.comments || '') + '</div>' +
328
+ '</div>' +
329
+ (m.body ? '<div class="mt-3"><strong>Body:</strong><pre class="mt-1 p-3 bg-white border rounded text-xs whitespace-pre-wrap">' + esc(m.body) + '</pre></div>' : '') +
330
+ '<div class="mt-3"><a href="messages.html#' + encodeURIComponent(m.id) + '" style="color:#3b82f6;font-size:0.8rem;text-decoration:none" onmouseover="this.style.textDecoration=\\'underline\\'" onmouseout="this.style.textDecoration=\\'none\\'">Open full preview &rarr;</a></div>' +
331
+ '</td>';
332
+ tbody.appendChild(detail);
333
+ }
334
+ }
335
+
336
+ function toggleDetail(id) {
337
+ const el = document.getElementById('detail-' + id);
338
+ if (el) el.classList.toggle('open');
339
+ }
340
+
341
+ // Initial render
342
+ setView('all');
343
+ </script>
344
+
345
+ <footer style="text-align:center;padding:16px;font-size:0.75rem;color:#9ca3af;border-top:1px solid #e5e7eb">
346
+ <a href="https://github.com/sr-kai/mango-lollipop" style="color:#6b7280;text-decoration:none;font-weight:600">Mango Lollipop</a> &mdash; AI-powered lifecycle messaging for SaaS<br>
347
+ Made by Sasha Kai with probably too much coffee.
348
+ </footer>
349
+ </body>
350
+ </html>`;
351
+ }
352
+ // -----------------------------------------------------------------------------
353
+ // Overview HTML (clean, printable)
354
+ // -----------------------------------------------------------------------------
355
+ export function generateOverview(messages, analysis) {
356
+ const stats = buildStatsBlock(messages);
357
+ const stages = ["TX", "AQ", "AC", "RV", "RT", "RF"];
358
+ // Build implementation order: TX first, then AQ -> RF
359
+ const implOrder = stages
360
+ .filter((s) => (stats.byStage[s] ?? 0) > 0)
361
+ .map((s, i) => {
362
+ const meta = STAGE_META[s];
363
+ const count = stats.byStage[s] ?? 0;
364
+ return `<tr><td class="px-3 py-1">${i + 1}</td><td class="px-3 py-1"><span class="stage-badge" style="background:${meta.bg};color:${meta.color}">${meta.label}</span></td><td class="px-3 py-1">${count} messages</td></tr>`;
365
+ })
366
+ .join("\n");
367
+ // Build condensed matrix rows
368
+ const matrixRows = messages
369
+ .map((m) => {
370
+ const meta = STAGE_META[m.stage] ?? {
371
+ label: m.stage,
372
+ color: "#666",
373
+ bg: "#f0f0f0",
374
+ };
375
+ return `<tr class="border-t">
376
+ <td class="px-3 py-1 font-mono text-xs">${escapeHtml(m.id)}</td>
377
+ <td class="px-3 py-1"><span class="stage-badge" style="background:${meta.bg};color:${meta.color}">${escapeHtml(meta.label)}</span></td>
378
+ <td class="px-3 py-1">${escapeHtml(m.name)}</td>
379
+ <td class="px-3 py-1 text-xs">${escapeHtml(m.trigger.event)}</td>
380
+ <td class="px-3 py-1 font-mono text-xs">${escapeHtml(m.wait)}</td>
381
+ <td class="px-3 py-1 text-xs">${escapeHtml(m.channel ?? (m.channels?.[0] ?? ""))}</td>
382
+ </tr>`;
383
+ })
384
+ .join("\n");
385
+ // Tag summary
386
+ const tagSummary = stats.allTags
387
+ .map((t) => `<span class="tag-pill">${escapeHtml(t)} (${stats.tagCounts[t]})</span>`)
388
+ .join(" ");
389
+ return `<!DOCTYPE html>
390
+ <html lang="en">
391
+ <head>
392
+ <meta charset="UTF-8">
393
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
394
+ <title>${escapeHtml(analysis.company.name)} - Lifecycle Messaging Overview</title>
395
+ <script src="https://cdn.tailwindcss.com"></script>
396
+ <style>
397
+ @media print { body { font-size: 11px; } .page-break { page-break-before: always; } }
398
+ .stage-badge { display: inline-block; padding: 2px 8px; border-radius: 9999px; font-size: 0.75rem; font-weight: 600; }
399
+ .tag-pill { display: inline-block; padding: 1px 6px; border-radius: 9999px; font-size: 0.7rem; background: #e5e7eb; color: #374151; margin: 1px; }
400
+ </style>
401
+ </head>
402
+ <body class="bg-white text-gray-900 max-w-4xl mx-auto p-8">
403
+
404
+ <!-- Inline data -->
405
+ <script id="msg-data" type="application/json">${JSON.stringify(messages)}</script>
406
+ <script id="analysis-data" type="application/json">${JSON.stringify(analysis)}</script>
407
+
408
+ <header class="border-b pb-4 mb-6">
409
+ <h1 class="text-2xl font-bold">${escapeHtml(analysis.company.name)}</h1>
410
+ <p class="text-gray-500">Lifecycle Messaging Overview</p>
411
+ </header>
412
+
413
+ <!-- Company Overview -->
414
+ <section class="mb-8">
415
+ <h2 class="text-lg font-semibold mb-2">Company Overview</h2>
416
+ <div class="grid grid-cols-2 gap-4 text-sm">
417
+ <div><strong>Product Type:</strong> ${escapeHtml(analysis.company.product_type)}</div>
418
+ <div><strong>Target Audience:</strong> ${escapeHtml(analysis.company.target_audience)}</div>
419
+ <div><strong>Value Prop:</strong> ${escapeHtml(analysis.company.key_value_prop)}</div>
420
+ <div><strong>Aha Moment:</strong> ${escapeHtml(analysis.company.aha_moment)}</div>
421
+ <div><strong>Pricing:</strong> ${escapeHtml(analysis.company.pricing_model)}</div>
422
+ <div><strong>Key Features:</strong> ${analysis.company.key_features.map((f) => escapeHtml(f)).join(", ")}</div>
423
+ </div>
424
+ </section>
425
+
426
+ <!-- AARRR Strategy -->
427
+ <section class="mb-8">
428
+ <h2 class="text-lg font-semibold mb-2">AARRR Strategy Summary</h2>
429
+ <div class="text-sm space-y-1">
430
+ <p><strong>Total Messages:</strong> ${stats.total} (${stats.txCount} transactional, ${stats.lcCount} lifecycle)</p>
431
+ <p><strong>Channels:</strong> ${analysis.channels.join(", ")}</p>
432
+ <div class="flex gap-4 mt-2">
433
+ ${stages
434
+ .filter((s) => (stats.byStage[s] ?? 0) > 0)
435
+ .map((s) => {
436
+ const meta = STAGE_META[s];
437
+ return ` <div class="text-center"><span class="stage-badge" style="background:${meta.bg};color:${meta.color}">${meta.label}</span><div class="text-lg font-bold mt-1">${stats.byStage[s]}</div></div>`;
438
+ })
439
+ .join("\n")}
440
+ </div>
441
+ </div>
442
+ </section>
443
+
444
+ <div class="page-break"></div>
445
+
446
+ <!-- Condensed Matrix -->
447
+ <section class="mb-8">
448
+ <h2 class="text-lg font-semibold mb-2">Message Inventory</h2>
449
+ <table class="w-full text-sm border">
450
+ <thead class="bg-gray-50 text-xs text-gray-500 uppercase">
451
+ <tr>
452
+ <th class="px-3 py-2 text-left">ID</th>
453
+ <th class="px-3 py-2 text-left">Stage</th>
454
+ <th class="px-3 py-2 text-left">Name</th>
455
+ <th class="px-3 py-2 text-left">Trigger</th>
456
+ <th class="px-3 py-2 text-left">Wait</th>
457
+ <th class="px-3 py-2 text-left">Channel</th>
458
+ </tr>
459
+ </thead>
460
+ <tbody>
461
+ ${matrixRows}
462
+ </tbody>
463
+ </table>
464
+ </section>
465
+
466
+ <!-- Tag Summary -->
467
+ <section class="mb-8">
468
+ <h2 class="text-lg font-semibold mb-2">Tag Summary</h2>
469
+ <div>${tagSummary || "<span class='text-gray-400'>No tags</span>"}</div>
470
+ </section>
471
+
472
+ <!-- Implementation Order -->
473
+ <section class="mb-8">
474
+ <h2 class="text-lg font-semibold mb-2">Recommended Implementation Order</h2>
475
+ <table class="text-sm">
476
+ <thead class="text-xs text-gray-500 uppercase">
477
+ <tr><th class="px-3 py-1 text-left">Priority</th><th class="px-3 py-1 text-left">Stage</th><th class="px-3 py-1 text-left">Scope</th></tr>
478
+ </thead>
479
+ <tbody>
480
+ ${implOrder}
481
+ </tbody>
482
+ </table>
483
+ </section>
484
+
485
+ <!-- Recommendations -->
486
+ ${analysis.recommendations.length > 0
487
+ ? ` <section class="mb-8">
488
+ <h2 class="text-lg font-semibold mb-2">Recommendations</h2>
489
+ <ul class="list-disc list-inside text-sm space-y-1">
490
+ ${analysis.recommendations.map((r) => ` <li>${escapeHtml(r)}</li>`).join("\n")}
491
+ </ul>
492
+ </section>`
493
+ : ""}
494
+
495
+ <footer style="text-align:center;padding:24px 0 8px;margin-top:2rem;border-top:1px solid #e5e7eb;font-size:0.8rem;color:#9ca3af">
496
+ <a href="https://github.com/sr-kai/mango-lollipop" style="color:#6b7280;text-decoration:none;font-weight:600">Mango Lollipop</a> &mdash; AI-powered lifecycle messaging for SaaS<br>
497
+ Made by Sasha Kai with probably too much coffee.
498
+ </footer>
499
+ </body>
500
+ </html>`;
501
+ }
502
+ // -----------------------------------------------------------------------------
503
+ // Message Viewer HTML (channel-specific previews with hash routing)
504
+ // -----------------------------------------------------------------------------
505
+ export function generateMessageViewer(messages, analysis, messageContent) {
506
+ const stages = ["TX", "AQ", "AC", "RV", "RT", "RF"];
507
+ // Build sidebar HTML server-side (static list)
508
+ const sidebarHtml = stages
509
+ .filter((s) => messages.some((m) => m.stage === s))
510
+ .map((s) => {
511
+ const meta = STAGE_META[s];
512
+ const stageMessages = messages.filter((m) => m.stage === s);
513
+ const items = stageMessages
514
+ .map((m) => {
515
+ const ch = m.channel ?? (m.channels?.[0] ?? "");
516
+ return ` <a href="#${escapeHtml(m.id)}" class="msg-link" id="sb-${escapeHtml(m.id)}" data-id="${escapeHtml(m.id)}">
517
+ <span class="msg-link-id">${escapeHtml(m.id)}</span>
518
+ <span class="msg-link-name">${escapeHtml(m.name)}</span>
519
+ <span class="msg-link-ch">${escapeHtml(ch)}</span>
520
+ </a>`;
521
+ })
522
+ .join("\n");
523
+ return ` <div class="stage-group">
524
+ <div class="stage-group-header">
525
+ <span class="stage-badge" style="background:${meta.bg};color:${meta.color}">${meta.label}</span>
526
+ <span class="text-xs text-gray-400">${stageMessages.length}</span>
527
+ </div>
528
+ ${items}
529
+ </div>`;
530
+ })
531
+ .join("\n");
532
+ const dataJson = JSON.stringify(messages);
533
+ const contentJson = JSON.stringify(messageContent);
534
+ return `<!DOCTYPE html>
535
+ <html lang="en">
536
+ <head>
537
+ <meta charset="UTF-8">
538
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
539
+ <title>${escapeHtml(analysis.company.name)} - Message Previews</title>
540
+ <script src="https://cdn.tailwindcss.com"></script>
541
+ <style>
542
+ /* Layout */
543
+ body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; }
544
+ .viewer-header { display: flex; align-items: center; justify-content: space-between; padding: 12px 24px; background: #fff; border-bottom: 1px solid #e5e7eb; }
545
+ .viewer-header a { color: #3b82f6; text-decoration: none; font-size: 0.875rem; }
546
+ .viewer-header a:hover { text-decoration: underline; }
547
+ .viewer-layout { display: flex; min-height: calc(100vh - 57px); }
548
+
549
+ /* Sidebar */
550
+ .viewer-sidebar { width: 260px; min-width: 260px; background: #fff; border-right: 1px solid #e5e7eb; overflow-y: auto; padding: 12px 8px; }
551
+ .stage-group { margin-bottom: 8px; }
552
+ .stage-group-header { display: flex; align-items: center; justify-content: space-between; padding: 4px 8px; }
553
+ .stage-badge { display: inline-block; padding: 2px 8px; border-radius: 9999px; font-size: 0.7rem; font-weight: 600; }
554
+ .msg-link { display: flex; align-items: center; gap: 6px; padding: 5px 8px; border-radius: 6px; text-decoration: none; color: #374151; font-size: 0.8rem; cursor: pointer; }
555
+ .msg-link:hover { background: #f3f4f6; }
556
+ .msg-link.active { background: #dbeafe; color: #1d4ed8; }
557
+ .msg-link-id { font-family: monospace; font-size: 0.7rem; color: #9ca3af; min-width: 38px; }
558
+ .msg-link.active .msg-link-id { color: #3b82f6; }
559
+ .msg-link-name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
560
+ .msg-link-ch { font-size: 0.65rem; color: #9ca3af; text-transform: uppercase; }
561
+
562
+ /* Preview area */
563
+ .preview-main { flex: 1; background: #f3f4f6; padding: 32px; overflow-y: auto; }
564
+ .preview-empty { display: flex; align-items: center; justify-content: center; height: 60vh; color: #9ca3af; font-size: 1rem; }
565
+ .preview-wrapper { max-width: 740px; margin: 0 auto; }
566
+
567
+ /* Shared preview elements */
568
+ .preview-cta { display: inline-block; padding: 10px 24px; border-radius: 6px; color: #fff; font-weight: 600; font-size: 0.9rem; text-decoration: none; cursor: default; }
569
+ .token { background: #fef3c7; color: #92400e; padding: 1px 4px; border-radius: 3px; font-family: monospace; font-size: 0.85em; }
570
+ .no-content-notice { background: #f9fafb; border: 2px dashed #d1d5db; border-radius: 8px; padding: 24px; text-align: center; color: #6b7280; margin: 20px; }
571
+
572
+ /* --- Email preview --- */
573
+ .email-frame { background: #fff; border-radius: 12px; box-shadow: 0 4px 24px rgba(0,0,0,0.08); overflow: hidden; max-width: 640px; margin: 0 auto; }
574
+ .email-toolbar { background: #f9fafb; padding: 10px 16px; display: flex; align-items: center; gap: 6px; border-bottom: 1px solid #e5e7eb; }
575
+ .email-dot { width: 10px; height: 10px; border-radius: 50%; }
576
+ .email-header { padding: 16px 20px; border-bottom: 1px solid #f3f4f6; font-size: 0.85rem; color: #6b7280; }
577
+ .email-header div { margin-bottom: 4px; }
578
+ .email-header .email-label { font-weight: 600; color: #374151; display: inline-block; width: 70px; }
579
+ .email-subject-line { font-size: 1.1rem; font-weight: 600; color: #111827; }
580
+ .email-preheader { font-size: 0.8rem; color: #9ca3af; padding: 0 20px; margin-top: 4px; }
581
+ .email-body { padding: 24px 20px; font-size: 0.9rem; line-height: 1.7; color: #374151; }
582
+ .email-body p { margin-bottom: 14px; }
583
+ .email-body .preview-cta { background: #3b82f6; margin: 16px 0; }
584
+
585
+ /* --- In-app preview --- */
586
+ .inapp-backdrop { background: rgba(0,0,0,0.3); border-radius: 12px; padding: 60px 20px; display: flex; justify-content: center; align-items: flex-start; min-height: 300px; }
587
+ .inapp-modal { background: #fff; border-radius: 16px; box-shadow: 0 8px 32px rgba(0,0,0,0.12); padding: 28px 24px; max-width: 380px; width: 100%; position: relative; }
588
+ .inapp-close { position: absolute; top: 12px; right: 16px; background: none; border: none; font-size: 1.2rem; color: #9ca3af; cursor: default; }
589
+ .inapp-title { font-size: 1.1rem; font-weight: 700; color: #111827; margin-bottom: 12px; }
590
+ .inapp-body { font-size: 0.9rem; color: #4b5563; line-height: 1.6; margin-bottom: 20px; }
591
+ .inapp-modal .preview-cta { background: #8b5cf6; display: block; text-align: center; border-radius: 10px; }
592
+
593
+ /* --- SMS preview --- */
594
+ .sms-phone { background: #fff; border-radius: 32px; box-shadow: 0 4px 24px rgba(0,0,0,0.10); max-width: 340px; margin: 0 auto; overflow: hidden; border: 6px solid #1a1a1a; }
595
+ .sms-status-bar { background: #1a1a1a; padding: 8px 20px 4px; display: flex; justify-content: space-between; color: #fff; font-size: 0.7rem; font-weight: 600; }
596
+ .sms-header { background: #f2f2f7; padding: 12px 16px; text-align: center; font-weight: 600; font-size: 0.95rem; color: #111; border-bottom: 1px solid #e5e7eb; }
597
+ .sms-body { background: #fff; padding: 20px 16px; min-height: 200px; }
598
+ .sms-bubble { background: #e5e5ea; color: #111; padding: 10px 14px; border-radius: 18px; border-bottom-left-radius: 4px; font-size: 0.9rem; line-height: 1.5; max-width: 85%; display: inline-block; }
599
+ .sms-time { text-align: center; font-size: 0.7rem; color: #8e8e93; margin-top: 8px; }
600
+ .sms-home { height: 4px; width: 120px; background: #1a1a1a; border-radius: 2px; margin: 8px auto; }
601
+
602
+ /* --- Push preview --- */
603
+ .push-phone { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 32px; box-shadow: 0 4px 24px rgba(0,0,0,0.10); max-width: 340px; margin: 0 auto; overflow: hidden; border: 6px solid #1a1a1a; padding: 40px 12px 20px; min-height: 300px; }
604
+ .push-status-bar { display: flex; justify-content: space-between; color: rgba(255,255,255,0.9); font-size: 0.7rem; font-weight: 600; margin-bottom: 24px; padding: 0 8px; }
605
+ .push-card { background: rgba(255,255,255,0.95); backdrop-filter: blur(20px); border-radius: 14px; padding: 12px; display: flex; gap: 10px; align-items: flex-start; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
606
+ .push-icon { width: 36px; height: 36px; border-radius: 8px; background: #f97316; color: #fff; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 0.85rem; flex-shrink: 0; }
607
+ .push-content { flex: 1; min-width: 0; }
608
+ .push-app-name { font-size: 0.7rem; color: #6b7280; text-transform: uppercase; letter-spacing: 0.03em; }
609
+ .push-title { font-size: 0.85rem; font-weight: 600; color: #111827; margin-top: 2px; }
610
+ .push-body-text { font-size: 0.8rem; color: #4b5563; margin-top: 2px; line-height: 1.4; }
611
+
612
+ /* Details card */
613
+ .details-card { background: #fff; border-radius: 12px; box-shadow: 0 2px 12px rgba(0,0,0,0.05); padding: 20px 24px; margin-top: 24px; }
614
+ .details-card h3 { font-size: 0.85rem; font-weight: 700; color: #6b7280; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 12px; }
615
+ .details-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px 24px; font-size: 0.85rem; }
616
+ .details-grid .detail-label { font-weight: 600; color: #374151; }
617
+ .details-grid .detail-value { color: #6b7280; }
618
+ .detail-full { grid-column: span 2; }
619
+
620
+ /* Nav buttons */
621
+ .nav-bar { display: flex; justify-content: space-between; align-items: center; margin-top: 20px; }
622
+ .nav-btn { padding: 6px 14px; border-radius: 6px; border: 1px solid #d1d5db; background: #fff; color: #374151; font-size: 0.8rem; cursor: pointer; text-decoration: none; }
623
+ .nav-btn:hover { background: #f9fafb; }
624
+ .nav-btn.disabled { opacity: 0.4; pointer-events: none; }
625
+
626
+ /* Channel label */
627
+ .channel-label { display: inline-block; padding: 3px 10px; border-radius: 9999px; font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 16px; }
628
+ .channel-label.email { background: #dbeafe; color: #1d4ed8; }
629
+ .channel-label.in-app { background: #ede9fe; color: #6d28d9; }
630
+ .channel-label.sms { background: #d1fae5; color: #065f46; }
631
+ .channel-label.push { background: #ffedd5; color: #c2410c; }
632
+ </style>
633
+ </head>
634
+ <body class="bg-gray-50 text-gray-900">
635
+
636
+ <!-- Embedded data -->
637
+ <script id="msg-data" type="application/json">${dataJson}</script>
638
+ <script id="content-data" type="application/json">${contentJson}</script>
639
+
640
+ <!-- Header -->
641
+ <div class="viewer-header">
642
+ <div style="display:flex;align-items:center;gap:16px">
643
+ <a href="dashboard.html">&larr; Dashboard</a>
644
+ <h1 style="font-size:1rem;font-weight:700;margin:0">${escapeHtml(analysis.company.name)} &mdash; Message Previews</h1>
645
+ </div>
646
+ <span style="font-size:0.8rem;color:#9ca3af">${messages.length} messages</span>
647
+ </div>
648
+
649
+ <div class="viewer-layout">
650
+ <!-- Sidebar -->
651
+ <div class="viewer-sidebar">
652
+ ${sidebarHtml}
653
+ </div>
654
+
655
+ <!-- Preview area -->
656
+ <div class="preview-main" id="preview-main">
657
+ <div class="preview-empty" id="empty-state">
658
+ Select a message from the sidebar to preview
659
+ </div>
660
+ <div class="preview-wrapper" id="preview-wrapper" style="display:none"></div>
661
+ </div>
662
+ </div>
663
+
664
+ <script>
665
+ const messages = JSON.parse(document.getElementById('msg-data').textContent);
666
+ const messageContent = JSON.parse(document.getElementById('content-data').textContent);
667
+ const productName = ${JSON.stringify(escapeHtml(analysis.company.name))};
668
+
669
+ const stageMeta = ${JSON.stringify(STAGE_META)};
670
+
671
+ // ---- Helpers ----
672
+ function esc(s) { const d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }
673
+
674
+ function getMsgChannel(m) { return m.channel || (m.channels && m.channels[0]) || 'email'; }
675
+
676
+ function md(text) {
677
+ if (!text) return '';
678
+ return text
679
+ .replace(/\\*\\*\\[(.+?)\\]\\*\\*/g, function(_, t) { return '<span class="preview-cta" style="background:#3b82f6">' + esc(t) + '</span>'; })
680
+ .replace(/\\*\\*(.+?)\\*\\*/g, '<strong>$1</strong>')
681
+ .replace(/\\*(.+?)\\*/g, '<em>$1</em>')
682
+ .replace(/\\{\\{(\\w+)\\}\\}/g, '<span class="token">{{$1}}</span>')
683
+ .replace(/\\n\\n/g, '</p><p>')
684
+ .replace(/\\n/g, '<br>');
685
+ }
686
+
687
+ // ---- Parse channel content from markdown ----
688
+ function parseContent(rawContent, channel) {
689
+ if (!rawContent) return null;
690
+
691
+ var channelNames = { 'email': 'Email', 'in-app': 'In-App', 'sms': 'SMS', 'push': 'Push Notification' };
692
+ var sectionName = channelNames[channel] || channel;
693
+
694
+ // Split by ## headers
695
+ var sections = rawContent.split(/^## /m);
696
+ var body = rawContent;
697
+
698
+ for (var i = 0; i < sections.length; i++) {
699
+ if (sections[i].indexOf(sectionName) === 0) {
700
+ body = sections[i].substring(sectionName.length).trim();
701
+ break;
702
+ }
703
+ }
704
+
705
+ // Remove trailing --- separators
706
+ body = body.replace(/\\n---\\s*$/, '').trim();
707
+
708
+ var result = { raw: body };
709
+
710
+ // Extract **Subject:**
711
+ var m1 = body.match(/\\*\\*Subject:\\*\\*\\s*(.+)/);
712
+ if (m1) { result.subject = m1[1].trim(); body = body.replace(m1[0], ''); }
713
+
714
+ // Extract **Preheader:**
715
+ var m2 = body.match(/\\*\\*Preheader:\\*\\*\\s*(.+)/);
716
+ if (m2) { result.preheader = m2[1].trim(); body = body.replace(m2[0], ''); }
717
+
718
+ // Extract **Title:**
719
+ var m3 = body.match(/\\*\\*Title:\\*\\*\\s*(.+)/);
720
+ if (m3) { result.title = m3[1].trim(); body = body.replace(m3[0], ''); }
721
+
722
+ // Extract **Body:** (for in-app)
723
+ var m4 = body.match(/\\*\\*Body:\\*\\*\\s*(.+)/);
724
+ if (m4) { result.bodyText = m4[1].trim(); body = body.replace(m4[0], ''); }
725
+
726
+ // Extract **CTA:**
727
+ var m5 = body.match(/\\*\\*CTA:\\*\\*\\s*(.+)/);
728
+ if (m5) { result.cta = m5[1].trim(); body = body.replace(m5[0], ''); }
729
+
730
+ // Extract **[CTA Text]**
731
+ var m6 = body.match(/\\*\\*\\[(.+?)\\]\\*\\*/);
732
+ if (m6) { result.ctaBtn = m6[1].trim(); }
733
+
734
+ result.body = body.trim();
735
+ return result;
736
+ }
737
+
738
+ // ---- Channel renderers ----
739
+ function renderEmailPreview(msg, parsed) {
740
+ var subject = (parsed && parsed.subject) || msg.subject || '(Subject not generated)';
741
+ var preheader = (parsed && parsed.preheader) || msg.preheader || '';
742
+ var bodyHtml = parsed ? '<p>' + md(parsed.body) + '</p>' : '';
743
+
744
+ if (!parsed) {
745
+ bodyHtml = '<div class="no-content-notice">Message copy not yet generated.<br><br><span style="font-size:0.8rem">Run the <strong>generate-messages</strong> skill to create copy.</span></div>';
746
+ if (msg.comments) bodyHtml += '<p style="margin-top:16px;color:#6b7280;font-size:0.85rem"><strong>Notes:</strong> ' + esc(msg.comments) + '</p>';
747
+ }
748
+
749
+ return '<div class="email-frame">' +
750
+ '<div class="email-toolbar"><div class="email-dot" style="background:#ff5f57"></div><div class="email-dot" style="background:#ffbd2e"></div><div class="email-dot" style="background:#28c840"></div></div>' +
751
+ '<div class="email-header">' +
752
+ '<div><span class="email-label">From:</span> ' + esc(msg.from) + '</div>' +
753
+ '<div><span class="email-label">To:</span> {{first_name}} &lt;user@example.com&gt;</div>' +
754
+ '<div><span class="email-label">Subject:</span> <span class="email-subject-line">' + md(subject) + '</span></div>' +
755
+ '</div>' +
756
+ (preheader ? '<div class="email-preheader">' + md(preheader) + '</div>' : '') +
757
+ '<div class="email-body">' + bodyHtml + '</div>' +
758
+ '</div>';
759
+ }
760
+
761
+ function renderInAppPreview(msg, parsed) {
762
+ var title = (parsed && parsed.title) || msg.name || 'Notification';
763
+ var body = (parsed && (parsed.bodyText || parsed.body)) || '';
764
+ var cta = (parsed && parsed.cta) || msg.cta.text || 'OK';
765
+
766
+ var inner;
767
+ if (!parsed) {
768
+ inner = '<div class="no-content-notice" style="margin:0;border:none;background:transparent">Message copy not yet generated.</div>';
769
+ if (msg.comments) inner += '<p style="color:#6b7280;font-size:0.85rem">' + esc(msg.comments) + '</p>';
770
+ } else {
771
+ inner = '<div class="inapp-body">' + md(body) + '</div>';
772
+ }
773
+
774
+ return '<div class="inapp-backdrop">' +
775
+ '<div class="inapp-modal">' +
776
+ '<button class="inapp-close">&times;</button>' +
777
+ '<div class="inapp-title">' + md(title) + '</div>' +
778
+ inner +
779
+ '<span class="preview-cta" style="background:#8b5cf6;display:block;text-align:center;border-radius:10px">' + esc(cta) + '</span>' +
780
+ '</div>' +
781
+ '</div>';
782
+ }
783
+
784
+ function renderSmsPreview(msg, parsed) {
785
+ var body = (parsed && parsed.body) || '';
786
+
787
+ var bubbleContent;
788
+ if (!parsed) {
789
+ bubbleContent = msg.comments ? esc(msg.comments) : '<em style="color:#8e8e93">Copy not yet generated</em>';
790
+ } else {
791
+ bubbleContent = md(body);
792
+ }
793
+
794
+ return '<div class="sms-phone">' +
795
+ '<div class="sms-status-bar"><span>9:41</span><span>\\u2022\\u2022\\u2022\\u2022\\u2022</span></div>' +
796
+ '<div class="sms-header">' + esc(msg.from || productName) + '</div>' +
797
+ '<div class="sms-body">' +
798
+ '<div class="sms-bubble">' + bubbleContent + '</div>' +
799
+ '<div class="sms-time">Just now</div>' +
800
+ '</div>' +
801
+ '<div style="padding:8px 0"><div class="sms-home"></div></div>' +
802
+ '</div>';
803
+ }
804
+
805
+ function renderPushPreview(msg, parsed) {
806
+ var title = (parsed && parsed.title) || msg.name || 'Notification';
807
+ var body = (parsed && (parsed.bodyText || parsed.body)) || '';
808
+ var initial = productName.charAt(0).toUpperCase();
809
+
810
+ var bodyHtml;
811
+ if (!parsed) {
812
+ bodyHtml = msg.comments ? '<div class="push-body-text">' + esc(msg.comments) + '</div>' : '<div class="push-body-text" style="color:#9ca3af"><em>Copy not yet generated</em></div>';
813
+ } else {
814
+ bodyHtml = '<div class="push-body-text">' + md(body).substring(0, 200) + '</div>';
815
+ }
816
+
817
+ return '<div class="push-phone">' +
818
+ '<div class="push-status-bar"><span>9:41</span><span>\\u2022\\u2022\\u2022\\u2022\\u2022</span></div>' +
819
+ '<div class="push-card">' +
820
+ '<div class="push-icon">' + initial + '</div>' +
821
+ '<div class="push-content">' +
822
+ '<div class="push-app-name">' + esc(productName) + ' &middot; now</div>' +
823
+ '<div class="push-title">' + md(title) + '</div>' +
824
+ bodyHtml +
825
+ '</div>' +
826
+ '</div>' +
827
+ '</div>';
828
+ }
829
+
830
+ // ---- Details card ----
831
+ function renderDetails(msg) {
832
+ var guards = msg.guards && msg.guards.length ? msg.guards.map(function(g) { return esc(g.condition); }).join('; ') : '\\u2014';
833
+ var supps = msg.suppressions && msg.suppressions.length ? msg.suppressions.map(function(s) { return esc(s.condition); }).join('; ') : '\\u2014';
834
+ var tags = msg.tags && msg.tags.length ? msg.tags.map(function(t) { return '<span style="display:inline-block;padding:1px 6px;border-radius:9999px;font-size:0.7rem;background:#e5e7eb;color:#374151;margin:1px">' + esc(t) + '</span>'; }).join(' ') : '\\u2014';
835
+
836
+ return '<div class="details-card">' +
837
+ '<h3>Message Details</h3>' +
838
+ '<div class="details-grid">' +
839
+ '<div><span class="detail-label">Trigger:</span></div><div class="detail-value">' + esc(msg.trigger.event) + ' (' + esc(msg.trigger.type) + ')</div>' +
840
+ '<div><span class="detail-label">Wait:</span></div><div class="detail-value">' + esc(msg.wait) + '</div>' +
841
+ '<div><span class="detail-label">Segment:</span></div><div class="detail-value">' + esc(msg.segment) + '</div>' +
842
+ '<div><span class="detail-label">Format:</span></div><div class="detail-value">' + esc(msg.format) + '</div>' +
843
+ '<div><span class="detail-label">Guards:</span></div><div class="detail-value">' + guards + '</div>' +
844
+ '<div><span class="detail-label">Suppressions:</span></div><div class="detail-value">' + supps + '</div>' +
845
+ '<div><span class="detail-label">Goal:</span></div><div class="detail-value detail-full">' + esc(msg.goal) + '</div>' +
846
+ (msg.comments ? '<div><span class="detail-label">Comments:</span></div><div class="detail-value detail-full">' + esc(msg.comments) + '</div>' : '') +
847
+ '<div><span class="detail-label">Tags:</span></div><div class="detail-value">' + tags + '</div>' +
848
+ '</div>' +
849
+ '</div>';
850
+ }
851
+
852
+ // ---- Main render ----
853
+ var currentIdx = -1;
854
+
855
+ function renderMessage(id) {
856
+ var msg = messages.find(function(m) { return m.id === id; });
857
+ if (!msg) return;
858
+
859
+ currentIdx = messages.indexOf(msg);
860
+ var channel = getMsgChannel(msg);
861
+ var raw = messageContent[id] || null;
862
+ var parsed = parseContent(raw, channel);
863
+
864
+ // Channel label
865
+ var chClass = channel.replace(/[^a-z]/g, '-');
866
+ var channelBadge = '<span class="channel-label ' + chClass + '">' + esc(channel) + '</span>';
867
+
868
+ // Stage badge
869
+ var meta = stageMeta[msg.stage] || { label: msg.stage, color: '#666', bg: '#f0f0f0' };
870
+ var stageBadge = '<span class="stage-badge" style="background:' + meta.bg + ';color:' + meta.color + '">' + esc(meta.label) + '</span>';
871
+
872
+ // Message name header
873
+ var header = '<div style="margin-bottom:8px;display:flex;align-items:center;gap:8px">' +
874
+ stageBadge + channelBadge +
875
+ '<span style="font-family:monospace;font-size:0.8rem;color:#9ca3af">' + esc(msg.id) + '</span>' +
876
+ '</div>' +
877
+ '<h2 style="font-size:1.25rem;font-weight:700;margin:0 0 20px">' + esc(msg.name) + '</h2>';
878
+
879
+ // Channel-specific preview
880
+ var preview;
881
+ switch (channel) {
882
+ case 'email': preview = renderEmailPreview(msg, parsed); break;
883
+ case 'in-app': preview = renderInAppPreview(msg, parsed); break;
884
+ case 'sms': preview = renderSmsPreview(msg, parsed); break;
885
+ case 'push': preview = renderPushPreview(msg, parsed); break;
886
+ default: preview = renderEmailPreview(msg, parsed);
887
+ }
888
+
889
+ // Prev / next nav
890
+ var prevId = currentIdx > 0 ? messages[currentIdx - 1].id : null;
891
+ var nextId = currentIdx < messages.length - 1 ? messages[currentIdx + 1].id : null;
892
+ var nav = '<div class="nav-bar">' +
893
+ (prevId ? '<a href="#' + prevId + '" class="nav-btn">&larr; ' + esc(prevId) + '</a>' : '<span></span>') +
894
+ (nextId ? '<a href="#' + nextId + '" class="nav-btn">' + esc(nextId) + ' &rarr;</a>' : '<span></span>') +
895
+ '</div>';
896
+
897
+ var wrapper = document.getElementById('preview-wrapper');
898
+ wrapper.innerHTML = header + preview + renderDetails(msg) + nav;
899
+ wrapper.style.display = 'block';
900
+ document.getElementById('empty-state').style.display = 'none';
901
+
902
+ // Update sidebar active state
903
+ document.querySelectorAll('.msg-link.active').forEach(function(el) { el.classList.remove('active'); });
904
+ var sbItem = document.getElementById('sb-' + id);
905
+ if (sbItem) { sbItem.classList.add('active'); sbItem.scrollIntoView({ block: 'nearest' }); }
906
+ }
907
+
908
+ // ---- Hash routing ----
909
+ function onHashChange() {
910
+ var id = location.hash.slice(1);
911
+ if (id) renderMessage(decodeURIComponent(id));
912
+ }
913
+ window.addEventListener('hashchange', onHashChange);
914
+
915
+ // ---- Keyboard nav ----
916
+ document.addEventListener('keydown', function(e) {
917
+ if (currentIdx < 0) return;
918
+ if (e.key === 'ArrowDown' || e.key === 'j') {
919
+ e.preventDefault();
920
+ if (currentIdx < messages.length - 1) location.hash = '#' + messages[currentIdx + 1].id;
921
+ }
922
+ if (e.key === 'ArrowUp' || e.key === 'k') {
923
+ e.preventDefault();
924
+ if (currentIdx > 0) location.hash = '#' + messages[currentIdx - 1].id;
925
+ }
926
+ });
927
+
928
+ // ---- Initial load ----
929
+ if (location.hash) onHashChange();
930
+ </script>
931
+
932
+ <footer style="text-align:center;padding:24px;font-size:0.8rem;color:#9ca3af;border-top:1px solid #e5e7eb">
933
+ <a href="https://github.com/sr-kai/mango-lollipop" style="color:#6b7280;text-decoration:none;font-weight:600">Mango Lollipop</a> &mdash; AI-powered lifecycle messaging for SaaS<br>
934
+ Made by Sasha Kai with probably too much coffee.
935
+ </footer>
936
+ </body>
937
+ </html>`;
938
+ }