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