fodda-mcp 1.3.2 → 1.30.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/LICENSE +31 -0
- package/README.md +192 -20
- package/dist/a2aHandler.d.ts +20 -0
- package/dist/a2aHandler.d.ts.map +1 -0
- package/dist/a2aHandler.js +247 -0
- package/dist/a2aHandler.js.map +1 -0
- package/dist/agents/fodda-researcher/index.d.ts +35 -0
- package/dist/agents/fodda-researcher/index.d.ts.map +1 -0
- package/dist/agents/fodda-researcher/index.js +62 -0
- package/dist/agents/fodda-researcher/index.js.map +1 -0
- package/dist/agents/fodda-researcher/skills.d.ts +16 -0
- package/dist/agents/fodda-researcher/skills.d.ts.map +1 -0
- package/dist/agents/fodda-researcher/skills.js +163 -0
- package/dist/agents/fodda-researcher/skills.js.map +1 -0
- package/dist/brandTemplate.d.ts +14 -0
- package/dist/brandTemplate.d.ts.map +1 -0
- package/dist/brandTemplate.js +742 -0
- package/dist/brandTemplate.js.map +1 -0
- package/dist/catalogCache.d.ts +177 -0
- package/dist/catalogCache.d.ts.map +1 -0
- package/dist/catalogCache.js +708 -0
- package/dist/catalogCache.js.map +1 -0
- package/dist/editorialFill.d.ts +44 -0
- package/dist/editorialFill.d.ts.map +1 -0
- package/dist/editorialFill.js +377 -0
- package/dist/editorialFill.js.map +1 -0
- package/dist/enrichment.d.ts +74 -0
- package/dist/enrichment.d.ts.map +1 -0
- package/dist/enrichment.js +211 -0
- package/dist/enrichment.js.map +1 -0
- package/dist/errorHandling.d.ts +52 -0
- package/dist/errorHandling.d.ts.map +1 -0
- package/dist/errorHandling.js +327 -0
- package/dist/errorHandling.js.map +1 -0
- package/dist/index.d.ts +0 -1
- package/dist/index.js +595 -410
- package/dist/index.js.map +1 -1
- package/dist/pricingCache.d.ts +99 -0
- package/dist/pricingCache.d.ts.map +1 -0
- package/dist/pricingCache.js +414 -0
- package/dist/pricingCache.js.map +1 -0
- package/dist/queryCache.d.ts +25 -0
- package/dist/queryCache.d.ts.map +1 -0
- package/dist/queryCache.js +194 -0
- package/dist/queryCache.js.map +1 -0
- package/dist/searchTemplate.d.ts +16 -0
- package/dist/searchTemplate.d.ts.map +1 -0
- package/dist/searchTemplate.js +318 -0
- package/dist/searchTemplate.js.map +1 -0
- package/dist/sessionTracker.d.ts +39 -0
- package/dist/sessionTracker.d.ts.map +1 -0
- package/dist/sessionTracker.js +187 -0
- package/dist/sessionTracker.js.map +1 -0
- package/dist/skillClient.d.ts +142 -0
- package/dist/skillClient.d.ts.map +1 -0
- package/dist/skillClient.js +256 -0
- package/dist/skillClient.js.map +1 -0
- package/dist/svgVisuals.d.ts +77 -0
- package/dist/svgVisuals.d.ts.map +1 -0
- package/dist/svgVisuals.js +323 -0
- package/dist/svgVisuals.js.map +1 -0
- package/dist/systemPrompt.d.ts +26 -0
- package/dist/systemPrompt.d.ts.map +1 -0
- package/dist/systemPrompt.js +546 -0
- package/dist/systemPrompt.js.map +1 -0
- package/dist/test-waverunner-response.d.ts +2 -0
- package/dist/test-waverunner-response.d.ts.map +1 -0
- package/dist/test-waverunner-response.js +20 -0
- package/dist/test-waverunner-response.js.map +1 -0
- package/dist/test_analyst.d.ts +2 -0
- package/dist/test_analyst.d.ts.map +1 -0
- package/dist/test_analyst.js +39 -0
- package/dist/test_analyst.js.map +1 -0
- package/dist/test_analyst_local.d.ts +2 -0
- package/dist/test_analyst_local.d.ts.map +1 -0
- package/dist/test_analyst_local.js +51 -0
- package/dist/test_analyst_local.js.map +1 -0
- package/dist/test_live_mcp.d.ts +2 -0
- package/dist/test_live_mcp.d.ts.map +1 -0
- package/dist/test_live_mcp.js +49 -0
- package/dist/test_live_mcp.js.map +1 -0
- package/dist/test_skills.d.ts +9 -0
- package/dist/test_skills.d.ts.map +1 -0
- package/dist/test_skills.js +267 -0
- package/dist/test_skills.js.map +1 -0
- package/dist/toolHandlers.d.ts +11 -0
- package/dist/toolHandlers.d.ts.map +1 -0
- package/dist/toolHandlers.js +2829 -0
- package/dist/toolHandlers.js.map +1 -0
- package/dist/tools.d.ts +80 -3
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +89 -186
- package/dist/tools.js.map +1 -1
- package/dist/trialTracker.d.ts +58 -0
- package/dist/trialTracker.d.ts.map +1 -0
- package/dist/trialTracker.js +179 -0
- package/dist/trialTracker.js.map +1 -0
- package/dist/types.d.ts +3 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/verify_hmac.js +1 -1
- package/dist/verify_hmac.js.map +1 -1
- package/dist/verify_tools_endpoint.js +5 -5
- package/dist/verify_tools_endpoint.js.map +1 -1
- package/dist/widgetShell.d.ts +15 -0
- package/dist/widgetShell.d.ts.map +1 -0
- package/dist/widgetShell.js +151 -0
- package/dist/widgetShell.js.map +1 -0
- package/package.json +10 -6
- package/server.json +12 -17
|
@@ -0,0 +1,742 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brand Intelligence Widget Template Engine
|
|
3
|
+
*
|
|
4
|
+
* Server-side slot filling for the Fodda brand intelligence widget.
|
|
5
|
+
* Fills ~90% of slots mechanically. Returns near-complete HTML with
|
|
6
|
+
* only editorial slots open for Claude to fill (~200 tokens).
|
|
7
|
+
*/
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Pressure type color mapping
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
import { fillBrandOneLiner, fillTrendFootprintIntro, fillMarketDataIntro, replaceSlots } from './editorialFill.js';
|
|
12
|
+
import { esc, FODDA_LOGO_URL } from './widgetShell.js';
|
|
13
|
+
import { getDomainGraphIds } from './catalogCache.js';
|
|
14
|
+
const PRESSURE_COLORS = {
|
|
15
|
+
'Direct competitor': { bg: '#FFF0E0', color: '#D97B2B', label: 'Direct competitor' },
|
|
16
|
+
'Adjacent signal': { bg: '#E6F1FB', color: '#2E6BE5', label: 'Adjacent signal' },
|
|
17
|
+
'Crossover mention': { bg: '#EEEDFE', color: '#7C6AB5', label: 'Crossover mention' },
|
|
18
|
+
'Related brand': { bg: '#F5F0FF', color: '#663399', label: 'Related brand' },
|
|
19
|
+
// Legacy labels (backward compat)
|
|
20
|
+
'Heritage challenger': { bg: '#FFF0E0', color: '#D97B2B', label: 'Heritage challenger' },
|
|
21
|
+
'Sibling challenger': { bg: '#FFF0E0', color: '#D97B2B', label: 'Sibling challenger' },
|
|
22
|
+
'Premium challenger': { bg: '#E6F1FB', color: '#2E6BE5', label: 'Premium challenger' },
|
|
23
|
+
'Co-creation partner': { bg: '#EAF3DE', color: '#3A8F5C', label: 'Co-creation partner' },
|
|
24
|
+
'Tech partner': { bg: '#EAF3DE', color: '#3A8F5C', label: 'Tech partner' },
|
|
25
|
+
'Culture collaborator': { bg: '#FBEAF0', color: '#C94F7A', label: 'Culture collaborator' },
|
|
26
|
+
'Crossover threat': { bg: '#EEEDFE', color: '#7C6AB5', label: 'Crossover threat' },
|
|
27
|
+
'Category shadow': { bg: '#EEEDFE', color: '#7C6AB5', label: 'Category shadow' },
|
|
28
|
+
};
|
|
29
|
+
const DEFAULT_PRESSURE = { bg: '#FFF0E0', color: '#D97B2B', label: 'Competitor' };
|
|
30
|
+
// Sector-aware pressure type assignment based on graph overlap
|
|
31
|
+
function guessPressureType(_index, competitor, brandGraphIds, domainGraphIds) {
|
|
32
|
+
const compGraphArr = competitor?.graphIds || [];
|
|
33
|
+
if (compGraphArr.length > 0 && brandGraphIds && brandGraphIds.size > 0) {
|
|
34
|
+
const compGraphs = new Set(compGraphArr);
|
|
35
|
+
const sharedGraphs = [...compGraphs].filter(g => brandGraphIds.has(g));
|
|
36
|
+
if (sharedGraphs.length === 0)
|
|
37
|
+
return 'Crossover mention';
|
|
38
|
+
// Any shared DOMAIN graph = direct competitor (both in retail, both in beauty, etc.)
|
|
39
|
+
const sharedDomain = domainGraphIds ? sharedGraphs.filter(g => domainGraphIds.has(g)) : [];
|
|
40
|
+
if (sharedDomain.length > 0)
|
|
41
|
+
return 'Direct competitor';
|
|
42
|
+
// Shared expert graph only = adjacent signal (co-mentioned in research, not same market)
|
|
43
|
+
return 'Adjacent signal';
|
|
44
|
+
}
|
|
45
|
+
// Fallback: no graph data — conservative label
|
|
46
|
+
return 'Related brand';
|
|
47
|
+
}
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Network node orbit positions (fixed, up to 5 competitors)
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
const ORBIT_POSITIONS = [
|
|
52
|
+
{ cx: 65, cy: 40 }, // top-left
|
|
53
|
+
{ cx: 255, cy: 52 }, // top-right
|
|
54
|
+
{ cx: 268, cy: 155 }, // right
|
|
55
|
+
{ cx: 155, cy: 208 }, // bottom
|
|
56
|
+
{ cx: 30, cy: 162 }, // left
|
|
57
|
+
];
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// SVG brand-name truncation (keeps text inside small circles)
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
function truncSvg(name, maxChars = 12) {
|
|
62
|
+
if (name.length <= maxChars)
|
|
63
|
+
return name;
|
|
64
|
+
return name.slice(0, maxChars - 1).trimEnd() + '…';
|
|
65
|
+
}
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// Sparkline shape derivation
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
function deriveSparklineShape(trend) {
|
|
70
|
+
const now = Date.now();
|
|
71
|
+
const thirtyDaysAgo = now - 30 * 24 * 60 * 60 * 1000;
|
|
72
|
+
const lastSeen = trend.lastSeen || trend.published_at;
|
|
73
|
+
const firstSeen = trend.firstSeen || trend.published_at;
|
|
74
|
+
const evCount = trend.evidence_count || 0;
|
|
75
|
+
if (lastSeen && new Date(lastSeen).getTime() > thirtyDaysAgo && evCount <= 3)
|
|
76
|
+
return 'new-signal';
|
|
77
|
+
if (firstSeen && lastSeen) {
|
|
78
|
+
const span = new Date(lastSeen).getTime() - new Date(firstSeen).getTime();
|
|
79
|
+
const midpoint = new Date(firstSeen).getTime() + span / 2;
|
|
80
|
+
// If most evidence is recent, accelerating
|
|
81
|
+
if (evCount >= 5)
|
|
82
|
+
return 'accelerating';
|
|
83
|
+
if (evCount >= 3)
|
|
84
|
+
return 'emerging';
|
|
85
|
+
}
|
|
86
|
+
if (evCount <= 2)
|
|
87
|
+
return 'emerging';
|
|
88
|
+
return 'steady';
|
|
89
|
+
}
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Lifecycle CSS class
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
function lcClass(lifecycle) {
|
|
94
|
+
switch (lifecycle) {
|
|
95
|
+
case 'building': return 'lb-b';
|
|
96
|
+
case 'emerging': return 'lb-e';
|
|
97
|
+
case 'mature':
|
|
98
|
+
case 'established': return 'lb-m';
|
|
99
|
+
case 'fading':
|
|
100
|
+
case 'plateauing': return 'lb-f';
|
|
101
|
+
default: return 'lb-e';
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function computeLifecycle(trend) {
|
|
105
|
+
const now = Date.now();
|
|
106
|
+
const first = trend.firstSeen || trend.firstSeenDate ? new Date(trend.firstSeen || trend.firstSeenDate).getTime() : 0;
|
|
107
|
+
const last = trend.lastSeen || trend.lastSeenDate ? new Date(trend.lastSeen || trend.lastSeenDate).getTime() : 0;
|
|
108
|
+
// Use freshnessDays as staleness proxy when dates are available
|
|
109
|
+
const freshness = trend.freshnessDays;
|
|
110
|
+
if (first && last) {
|
|
111
|
+
// Date-based lifecycle (most reliable)
|
|
112
|
+
const ageMonths = (now - first) / (1000 * 60 * 60 * 24 * 30);
|
|
113
|
+
const staleDays = (now - last) / (1000 * 60 * 60 * 24);
|
|
114
|
+
const count = trend.evidence_count || trend.evidenceCount || 0;
|
|
115
|
+
if (staleDays > 180)
|
|
116
|
+
return 'fading';
|
|
117
|
+
if (ageMonths < 6 && count < 5)
|
|
118
|
+
return 'emerging';
|
|
119
|
+
if (ageMonths > 12 && count > 10)
|
|
120
|
+
return 'mature';
|
|
121
|
+
return 'building';
|
|
122
|
+
}
|
|
123
|
+
// freshnessDays-based lifecycle (when API provides it but not firstSeen/lastSeen)
|
|
124
|
+
if (freshness !== null && freshness !== undefined) {
|
|
125
|
+
if (freshness > 365)
|
|
126
|
+
return 'fading';
|
|
127
|
+
if (freshness > 180)
|
|
128
|
+
return 'fading';
|
|
129
|
+
if (freshness < 90)
|
|
130
|
+
return 'emerging';
|
|
131
|
+
return 'building';
|
|
132
|
+
}
|
|
133
|
+
// Last resort: signal-score heuristic (least reliable — avoid evidence count dependency)
|
|
134
|
+
const signal = trend.signal_score || 0;
|
|
135
|
+
if (signal >= 70)
|
|
136
|
+
return 'mature';
|
|
137
|
+
if (signal >= 45)
|
|
138
|
+
return 'building';
|
|
139
|
+
if (signal < 20)
|
|
140
|
+
return 'fading';
|
|
141
|
+
return 'emerging';
|
|
142
|
+
}
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// Category CSS class
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
function catClass(category) {
|
|
147
|
+
switch (category) {
|
|
148
|
+
case 'Case Study': return 'cat-cs';
|
|
149
|
+
case 'Signal': return 'cat-si';
|
|
150
|
+
case 'Metric':
|
|
151
|
+
case 'Datapoint':
|
|
152
|
+
case 'Data Point': return 'cat-me';
|
|
153
|
+
case 'Quote':
|
|
154
|
+
case 'Opinion': return 'cat-qu';
|
|
155
|
+
case 'Interpretation':
|
|
156
|
+
case 'Interview': return 'cat-in';
|
|
157
|
+
default: return 'cat-cs';
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// Velocity class
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
function velocityClass(trend) {
|
|
164
|
+
switch (trend) {
|
|
165
|
+
case 'accelerating': return { cls: 'vc-up', label: 'rising ↑' };
|
|
166
|
+
case 'stable': return { cls: 'vc-steady', label: 'steady →' };
|
|
167
|
+
case 'decelerating': return { cls: 'vc-slow', label: 'slowing ↓' };
|
|
168
|
+
default: return { cls: 'vc-build', label: 'building ↗' };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
// HTML escaping — imported from widgetShell.ts (single source of truth)
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// Google Trends SVG helpers
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
function gtY(value) {
|
|
176
|
+
return Math.round(88 - (value / 100 * 76));
|
|
177
|
+
}
|
|
178
|
+
function buildGoogleTrendsSVG(gtData) {
|
|
179
|
+
const defaults = {
|
|
180
|
+
polyline: '0,88 300,88', polygon: '0,88 300,88,300,88,0,88',
|
|
181
|
+
peakX: 150, peakY: 88, peakLabel: '', nowLabel: '',
|
|
182
|
+
annX: 297, annY: 84, annText: '', description: 'Google Trends',
|
|
183
|
+
caption: 'Relative interest (0–100). Not absolute volume. Source: Google Trends.',
|
|
184
|
+
relatedQueriesHtml: '',
|
|
185
|
+
comparisonBarsHtml: '',
|
|
186
|
+
};
|
|
187
|
+
if (!gtData)
|
|
188
|
+
return defaults;
|
|
189
|
+
// API wraps data in a snapshot object
|
|
190
|
+
const gt = gtData.snapshot || gtData;
|
|
191
|
+
if (!gt?.interest_over_time?.length)
|
|
192
|
+
return defaults;
|
|
193
|
+
const points = gt.interest_over_time;
|
|
194
|
+
const n = points.length;
|
|
195
|
+
const step = 300 / Math.max(n - 1, 1);
|
|
196
|
+
let peakVal = 0, peakIdx = 0;
|
|
197
|
+
const coords = [];
|
|
198
|
+
for (let i = 0; i < n; i++) {
|
|
199
|
+
const x = Math.round(i * step);
|
|
200
|
+
const y = gtY(points[i].value || 0);
|
|
201
|
+
coords.push(`${x},${y}`);
|
|
202
|
+
if ((points[i].value || 0) > peakVal) {
|
|
203
|
+
peakVal = points[i].value;
|
|
204
|
+
peakIdx = i;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const polyline = coords.join(' ');
|
|
208
|
+
const polygon = polyline + ',300,88,0,88';
|
|
209
|
+
const lastVal = points[n - 1]?.value || 0;
|
|
210
|
+
const lastY = gtY(lastVal);
|
|
211
|
+
const peakX = Math.round(peakIdx * step);
|
|
212
|
+
const peakY = gtY(peakVal);
|
|
213
|
+
const peakDate = points[peakIdx]?.date || '';
|
|
214
|
+
const trend = lastVal > peakVal * 0.9 ? '↑ near peak' : lastVal > peakVal * 0.6 ? '↑ rising' : '→ moderate';
|
|
215
|
+
const relatedQueriesHtml = (gt.related_queries || [])
|
|
216
|
+
.slice(0, 8)
|
|
217
|
+
.map((q) => `<span class="rqp">${esc(q)}</span>`)
|
|
218
|
+
.join('');
|
|
219
|
+
// Comparison bars (brand vs competitors) — hide if all values are identical (API blending)
|
|
220
|
+
const comparison = gt.comparison || [];
|
|
221
|
+
const compValues = comparison.map((c) => c.latest_value || c.peak_interest || 0);
|
|
222
|
+
const allSame = compValues.length > 1 && compValues.every((v) => v === compValues[0]);
|
|
223
|
+
const compMax = Math.max(...compValues, 1);
|
|
224
|
+
const comparisonBarsHtml = allSame ? '' : comparison.slice(0, 5).map((c) => barRow(c.term || c.keyword || '', c.latest_value || 0, compMax)).join('\n');
|
|
225
|
+
return {
|
|
226
|
+
polyline, polygon, peakX, peakY,
|
|
227
|
+
peakLabel: `${peakDate} peak ${peakVal}`,
|
|
228
|
+
nowLabel: `now ${lastVal}`,
|
|
229
|
+
annX: 297, annY: lastY - 4, annText: trend,
|
|
230
|
+
description: `"${gt.query || 'brand'}", US, past 12 months`,
|
|
231
|
+
caption: 'Relative interest (0–100). Not absolute volume. Source: Google Trends.',
|
|
232
|
+
relatedQueriesHtml,
|
|
233
|
+
comparisonBarsHtml,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
// Bar row sub-template
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
function barRow(label, value, maxValue) {
|
|
240
|
+
const pct = maxValue > 0 ? Math.round((value / maxValue) * 100) : 0;
|
|
241
|
+
return `<div class="br"><div class="brl">${esc(label)}</div><div class="brt"><div class="brf" style="width:${pct}%"></div></div><div class="brc">${value.toLocaleString()}</div></div>`;
|
|
242
|
+
}
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
// Main render function
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
export async function renderBrandWidget(profile) {
|
|
247
|
+
const brand = profile.brand || 'Brand';
|
|
248
|
+
const trends = profile.trend_footprint || [];
|
|
249
|
+
const evidence = profile.evidence_items || [];
|
|
250
|
+
const competitors = profile.competitive_context?.co_occurring_brands || [];
|
|
251
|
+
const crossGraph = profile.cross_graph_presence || [];
|
|
252
|
+
const supplemental = profile.supplemental_signals || {};
|
|
253
|
+
const velocity = velocityClass(profile.summary?.evidence_velocity?.trend || 'stable');
|
|
254
|
+
const timeline = profile.activity_timeline || [];
|
|
255
|
+
// Build set of graphIds the target brand is present in (for sector-aware competitor labels)
|
|
256
|
+
const brandGraphIds = new Set(crossGraph.map((g) => g.graphId).filter(Boolean));
|
|
257
|
+
// Domain graphs from the catalog (retail, beauty, etc.) — used to distinguish real sector overlap
|
|
258
|
+
const domainGIds = getDomainGraphIds();
|
|
259
|
+
// ── Earnings Intelligence section ──
|
|
260
|
+
const earnings = profile.earningsIntelligence || [];
|
|
261
|
+
const earningsHtml = earnings.length > 0
|
|
262
|
+
? earnings.slice(0, 5).map((e) => {
|
|
263
|
+
const topicPills = (e.key_topics || e.keyTopics || []).slice(0, 5)
|
|
264
|
+
.map((t) => `<span class="rqp">${esc(t)}</span>`).join('');
|
|
265
|
+
const quarter = e.quarter || e.period || '';
|
|
266
|
+
const company = e.company || e.ticker || '';
|
|
267
|
+
const title = quarter ? `${company ? company + ' — ' : ''}${quarter}` : (company || 'Earnings Update');
|
|
268
|
+
const provenance = quarter ? `In the ${quarter} earnings report` : (e.source === 'web_supplemental' ? 'Recent reports suggest' : 'Management noted');
|
|
269
|
+
const summary = e.summary || e.description || '';
|
|
270
|
+
return `<div class="ec">
|
|
271
|
+
<div class="et" style="font-size:12px;font-weight:500;">${esc(title)}</div>
|
|
272
|
+
<div class="ex">${esc(provenance)}: ${esc(summary)}</div>
|
|
273
|
+
<div class="rq" style="margin-top:6px;">${topicPills}</div>
|
|
274
|
+
</div>`;
|
|
275
|
+
}).join('\n')
|
|
276
|
+
: '';
|
|
277
|
+
// Enrich trends with lifecycle if missing
|
|
278
|
+
trends.forEach((t) => {
|
|
279
|
+
if (!t.lifecycle || t.lifecycle === 'unknown') {
|
|
280
|
+
t.lifecycle = computeLifecycle(t);
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
// ── Lifecycle bar ──
|
|
284
|
+
const lcDist = profile.summary?.lifecycle_distribution || {};
|
|
285
|
+
const lcTotal = Object.values(lcDist).reduce((a, b) => a + b, 0);
|
|
286
|
+
const lcColors = { building: 'var(--color-text-info)', emerging: 'var(--color-text-success)', mature: 'var(--color-text-secondary)', fading: 'var(--color-text-warning)' };
|
|
287
|
+
const lifecycleBar = Object.entries(lcDist)
|
|
288
|
+
.map(([state, count]) => {
|
|
289
|
+
const pct = lcTotal > 0 ? Math.round((count / lcTotal) * 100) : 0;
|
|
290
|
+
return `<div style="width:${pct}%;background:${lcColors[state] || '#999'};border-radius:4px;"></div>`;
|
|
291
|
+
}).join('');
|
|
292
|
+
const lifecycleLegend = Object.entries(lcDist)
|
|
293
|
+
.map(([state, count]) => `<div class="lci"><div class="lcd" style="background:${lcColors[state] || '#999'}"></div>${state} (${count})</div>`)
|
|
294
|
+
.join('');
|
|
295
|
+
// ── Weak signals (separate from main list) ──
|
|
296
|
+
const weakSignalNames = new Set();
|
|
297
|
+
const weakSignals = trends.filter((t) => (t.evidence_count || 0) < 3 && t.lifecycle === 'emerging');
|
|
298
|
+
weakSignals.forEach((t) => weakSignalNames.add(t.trend_name?.trim()));
|
|
299
|
+
// ── Trend cards (exclude weak signals to avoid duplication) ──
|
|
300
|
+
const mainTrends = trends.filter((t) => !weakSignalNames.has(t.trend_name?.trim()));
|
|
301
|
+
const trendsHtml = mainTrends.map((t, i) => {
|
|
302
|
+
const spark = deriveSparklineShape(t);
|
|
303
|
+
const tName = (t.trend_name || '').trim();
|
|
304
|
+
const badges = [
|
|
305
|
+
`<span class="bdp">${esc(t.graphName)}</span>`,
|
|
306
|
+
`<span class="bd">${t.evidence_count || 0} evidence</span>`,
|
|
307
|
+
t.signal_score ? `<span class="bd">signal ${t.signal_score}</span>` : '',
|
|
308
|
+
t.momentum?.fastMover ? '<span class="bd-fast">fast mover ↗</span>' : '',
|
|
309
|
+
].filter(Boolean).join('\n ');
|
|
310
|
+
return `<div class="card" style="cursor:pointer;" onclick="sendPrompt('Explore ${esc(tName).replace(/'/g, '\\'')} trend in Fodda')">
|
|
311
|
+
<div class="th">
|
|
312
|
+
<div class="tn">${esc(tName)}</div>
|
|
313
|
+
<div class="ta">
|
|
314
|
+
<span class="lb ${lcClass(t.lifecycle)}">${esc(t.lifecycle)}</span>
|
|
315
|
+
</div>
|
|
316
|
+
</div>
|
|
317
|
+
<div class="td">${esc(t.trend_description)}</div>
|
|
318
|
+
<div class="brow">${badges}</div>
|
|
319
|
+
</div>`;
|
|
320
|
+
}).join('\n');
|
|
321
|
+
// Weak signals section (trends already excluded from main list above)
|
|
322
|
+
const weakSignalsHtml = weakSignals.length > 0
|
|
323
|
+
? `<div class="wl"><span class="wd"></span>Weak signal — watch</div>
|
|
324
|
+
` +
|
|
325
|
+
weakSignals.slice(0, 3).map((t) => {
|
|
326
|
+
const tName = (t.trend_name || '').trim();
|
|
327
|
+
return `<div class="card" style="opacity:.75;cursor:pointer;" onclick="sendPrompt('Explore ${esc(tName).replace(/'/g, '\\'')} trend in Fodda')">
|
|
328
|
+
<div class="th">
|
|
329
|
+
<div class="tn">${esc(tName)}</div>
|
|
330
|
+
<span class="lb ${lcClass(t.lifecycle)}">${esc(t.lifecycle)}</span>
|
|
331
|
+
</div>
|
|
332
|
+
<div class="td">${esc(t.trend_description)}</div>
|
|
333
|
+
<div class="brow"><span class="bdp">${esc(t.graphName)}</span><span class="bd">${t.evidence_count || 0} evidence</span></div>
|
|
334
|
+
</div>`;
|
|
335
|
+
}).join('\n')
|
|
336
|
+
: '';
|
|
337
|
+
// ── Evidence cards ──
|
|
338
|
+
const evidenceHtml = evidence.slice(0, 20).map((ev) => {
|
|
339
|
+
const imgTag = ev.image_url ? `<img src="${esc(ev.image_url)}" style="width:100%;border-radius:6px;margin-bottom:8px;" onerror="this.style.display='none'"/>` : '';
|
|
340
|
+
const placeBadge = ev.place ? `<span class="bd">${esc(ev.place)}</span>` : '';
|
|
341
|
+
// Safe date parsing — fallback to empty on Invalid Date
|
|
342
|
+
let date = '';
|
|
343
|
+
if (ev.published_at) {
|
|
344
|
+
const parsed = new Date(ev.published_at);
|
|
345
|
+
date = isNaN(parsed.getTime()) ? '' : parsed.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
|
346
|
+
}
|
|
347
|
+
const titleHtml = ev.source_url
|
|
348
|
+
? `<a href="${esc(ev.source_url)}">${esc(ev.title)}</a>`
|
|
349
|
+
: esc(ev.title);
|
|
350
|
+
const speakerHtml = ev.speaker_name ? `<div style="font-size:11px;color:var(--color-text-secondary);margin-top:4px;font-style:italic;">${esc(ev.speaker_name)}${ev.speaker_title ? ', ' + esc(ev.speaker_title) : ''}</div>` : '';
|
|
351
|
+
return `<div class="ec">
|
|
352
|
+
${imgTag}<div class="et">${titleHtml}</div>
|
|
353
|
+
<div class="ex">${esc(ev.excerpt)}</div>
|
|
354
|
+
${speakerHtml}
|
|
355
|
+
<div class="em">
|
|
356
|
+
<span class="lb ${catClass(ev.category)} bd">${esc(ev.category)}</span>
|
|
357
|
+
<span class="bdp">${esc(ev.graphName)}</span>
|
|
358
|
+
<span class="bd">${date}</span>
|
|
359
|
+
${placeBadge}
|
|
360
|
+
</div>
|
|
361
|
+
</div>`;
|
|
362
|
+
}).join('\n');
|
|
363
|
+
// ── Competitor list cards ──
|
|
364
|
+
const competitorListHtml = competitors.slice(0, 8).map((c, i) => {
|
|
365
|
+
const pType = guessPressureType(i, c, brandGraphIds, domainGIds);
|
|
366
|
+
const colors = PRESSURE_COLORS[pType] || DEFAULT_PRESSURE;
|
|
367
|
+
return `<div class="cc">
|
|
368
|
+
<div>
|
|
369
|
+
<div class="cn">${esc(c.brand)}</div>
|
|
370
|
+
<div class="cd">Co-occurs in ${c.co_occurrences} evidence item${c.co_occurrences !== 1 ? 's' : ''}</div>
|
|
371
|
+
</div>
|
|
372
|
+
<div class="ca">
|
|
373
|
+
<span class="pb" style="background:${colors.bg};color:${colors.color};">${colors.label}</span>
|
|
374
|
+
<button class="cv" onclick="sendPrompt('brand intelligence: ${esc(c.brand)}')">View ↗</button>
|
|
375
|
+
</div>
|
|
376
|
+
</div>`;
|
|
377
|
+
}).join('\n');
|
|
378
|
+
// ── Network SVG ──
|
|
379
|
+
const topComps = competitors.slice(0, 5);
|
|
380
|
+
const networkNodesHtml = topComps.map((c, i) => {
|
|
381
|
+
const pos = ORBIT_POSITIONS[i] || { cx: 150, cy: 113 };
|
|
382
|
+
const pType = guessPressureType(i, c, brandGraphIds, domainGIds);
|
|
383
|
+
const colors = PRESSURE_COLORS[pType] || DEFAULT_PRESSURE;
|
|
384
|
+
return `<line x1="150" y1="113" x2="${pos.cx}" y2="${pos.cy}" stroke="${colors.color}" stroke-width="0.5" stroke-dasharray="3,3"/>
|
|
385
|
+
<circle cx="${pos.cx}" cy="${pos.cy}" r="18" fill="${colors.bg}" stroke="${colors.color}" stroke-width="1"/>
|
|
386
|
+
<text x="${pos.cx}" y="${pos.cy + 3}" class="svgt" font-size="7.5" text-anchor="middle" fill="${colors.color}">${esc(truncSvg(c.brand))}</text>`;
|
|
387
|
+
}).join('\n ');
|
|
388
|
+
// Network legend — bottom-right (avoid overlapping left orbit node)
|
|
389
|
+
const legendTypes = [...new Set(topComps.map((c, i) => guessPressureType(i, c, brandGraphIds, domainGIds)))].slice(0, 5);
|
|
390
|
+
const networkLegendHtml = legendTypes.map((pType, i) => {
|
|
391
|
+
const colors = PRESSURE_COLORS[pType] || DEFAULT_PRESSURE;
|
|
392
|
+
const y = 190 + i * 11;
|
|
393
|
+
return `<rect x="200" y="${y}" width="6" height="6" rx="1" fill="${colors.bg}" stroke="${colors.color}" stroke-width=".5"/>
|
|
394
|
+
<text x="210" y="${y + 5}" class="svgt" font-size="6" fill="var(--color-text-secondary)">${pType}</text>`;
|
|
395
|
+
}).join('\n ');
|
|
396
|
+
// ── Compare buttons ──
|
|
397
|
+
const compareButtonsHtml = competitors.slice(0, 4).map((c) => `<button class="cp" onclick="sendPrompt('brand intelligence: ${esc(c.brand)}')">${esc(c.brand)} ↗</button>`).join('\n ');
|
|
398
|
+
// ── Google Trends ──
|
|
399
|
+
const gt = buildGoogleTrendsSVG(supplemental?.google_trends);
|
|
400
|
+
const hasGoogleTrendsData = gt.polyline !== '0,88 300,88'; // false when API returned empty time-series
|
|
401
|
+
// ── Wikipedia bars ──
|
|
402
|
+
const rawWiki = supplemental?.wikipedia;
|
|
403
|
+
const wikiData = Array.isArray(rawWiki) ? rawWiki : (rawWiki?.snapshot || rawWiki?.articles || rawWiki?.results || []);
|
|
404
|
+
const wikiMax = Math.max(...wikiData.map((w) => w.avg_daily_views || 0), 1);
|
|
405
|
+
const wikiBarsHtml = wikiData.slice(0, 5).map((w) => barRow(w.article || '', w.avg_daily_views || 0, wikiMax)).join('\n');
|
|
406
|
+
const amazonRaw = supplemental?.amazon || {};
|
|
407
|
+
const amazon = amazonRaw?.snapshot || amazonRaw;
|
|
408
|
+
const rawProducts = amazon?.example_products || amazon?.products || [];
|
|
409
|
+
// Deduplicate by title and sort by review count descending
|
|
410
|
+
const seen = new Set();
|
|
411
|
+
const products = rawProducts.filter((p) => {
|
|
412
|
+
const key = (p.name || p.title || '').toLowerCase().trim();
|
|
413
|
+
if (seen.has(key))
|
|
414
|
+
return false;
|
|
415
|
+
seen.add(key);
|
|
416
|
+
return true;
|
|
417
|
+
}).sort((a, b) => (b.reviews || 0) - (a.reviews || 0));
|
|
418
|
+
const amazonProductsHtml = products.slice(0, 4).map((p) => `<div class="ap"><div><div class="an2">${esc(p.name || p.title)}</div><div class="am2">${esc(p.price || '')}</div></div><div><div class="astar">${'★'.repeat(Math.round(p.rating || 0))}${'☆'.repeat(5 - Math.round(p.rating || 0))}</div><div class="am2">${(p.reviews || 0).toLocaleString()} reviews</div></div></div>`).join('\n');
|
|
419
|
+
// ── Geographic bars ──
|
|
420
|
+
const geoDist = profile.geographic_distribution || [];
|
|
421
|
+
const geoMax = Math.max(...geoDist.map((g) => g.count || 0), 1);
|
|
422
|
+
const geoBarsHtml = geoDist.slice(0, 6).map((g) => barRow(g.place || 'Unknown', g.count || 0, geoMax)).join('\n');
|
|
423
|
+
// ── Census Retail data (sector-mapped) ──
|
|
424
|
+
const censusRaw = supplemental?.census_retail;
|
|
425
|
+
const censusSnap = censusRaw?.snapshot || censusRaw || {};
|
|
426
|
+
const censusSubs = censusSnap?.subcategories || [];
|
|
427
|
+
const graphIds = crossGraph.map((g) => (g.graphId || '').toLowerCase());
|
|
428
|
+
const trendNames = trends.map((t) => (t.trendName || t.name || '').toLowerCase());
|
|
429
|
+
const brandLower = brand.toLowerCase();
|
|
430
|
+
const sectorContext = [brandLower, ...graphIds, ...trendNames].join(' ');
|
|
431
|
+
const foodSignals = ['food', 'restaurant', 'dining', 'menu', 'mcdonald', 'burger', 'pizza', 'starbucks', 'coffee', 'cafe', 'bakery', 'grocery'];
|
|
432
|
+
const isFoodBrand = foodSignals.some(kw => sectorContext.includes(kw));
|
|
433
|
+
let matchedSector = null;
|
|
434
|
+
if (isFoodBrand) {
|
|
435
|
+
matchedSector = censusSubs.find((c) => (c.name || '').toLowerCase().includes('food service'));
|
|
436
|
+
if (!matchedSector)
|
|
437
|
+
matchedSector = censusSubs.find((c) => (c.name || '').toLowerCase().includes('food and beverage'));
|
|
438
|
+
}
|
|
439
|
+
if (!matchedSector && (sectorContext.includes('fashion') || sectorContext.includes('sports') || sectorContext.includes('apparel') || sectorContext.includes('beauty') || sectorContext.includes('shoe') || sectorContext.includes('sneaker'))) {
|
|
440
|
+
matchedSector = censusSubs.find((c) => (c.name || '').toLowerCase().includes('clothing'));
|
|
441
|
+
}
|
|
442
|
+
if (!matchedSector) {
|
|
443
|
+
matchedSector = censusSubs.find((c) => (c.name || '').toLowerCase().includes('non-store') || (c.name || '').toLowerCase().includes('e-commerce'));
|
|
444
|
+
}
|
|
445
|
+
const totalRetail = censusSnap?.total_retail_sales_millions;
|
|
446
|
+
const totalMom = censusSnap?.month_over_month_change_pct;
|
|
447
|
+
const beaLabel = matchedSector ? esc(matchedSector.name).replace(/ Stores$/, '') : 'Retail sector';
|
|
448
|
+
const beaValue = matchedSector?.sales_millions ? `$${(matchedSector.sales_millions / 1000).toFixed(1)}B` : '—';
|
|
449
|
+
const beaSub = matchedSector?.mom_change_pct != null ? `${matchedSector.mom_change_pct > 0 ? '+' : ''}${matchedSector.mom_change_pct.toFixed(1)}% MoM` : '';
|
|
450
|
+
const beaChangeLabel = 'Total US retail';
|
|
451
|
+
const beaChangeValue = totalRetail ? `$${(totalRetail / 1000).toFixed(0)}B` : '—';
|
|
452
|
+
const beaChangeSub = totalMom != null ? `${totalMom > 0 ? '+' : ''}${totalMom.toFixed(1)}% MoM` : '';
|
|
453
|
+
// ── Source pills ──
|
|
454
|
+
const graphNames = crossGraph.map((g) => g.graphName).filter(Boolean);
|
|
455
|
+
const sourcePills = [
|
|
456
|
+
...graphNames.map((n) => `<span class="gp">${esc(n)}</span>`),
|
|
457
|
+
hasGoogleTrendsData ? '<span class="gp">Google Trends</span>' : '',
|
|
458
|
+
supplemental?.wikipedia ? '<span class="gp">Wikipedia</span>' : '',
|
|
459
|
+
supplemental?.amazon ? '<span class="gp">Amazon</span>' : '',
|
|
460
|
+
supplemental?.census_retail ? '<span class="gp">US Census</span>' : '',
|
|
461
|
+
].filter(Boolean).join('\n ');
|
|
462
|
+
// ── Export slots (data-driven labels) ──
|
|
463
|
+
const exportLabels = [
|
|
464
|
+
{ label: 'Editorial brief', desc: `Strategic analysis of ${brand}'s innovation position`, prompt: `Write editorial brief for ${brand}` },
|
|
465
|
+
{ label: 'Competitor comparison', desc: `Head-to-head with ${competitors[0]?.brand || 'top rival'}`, prompt: `Compare ${brand} vs ${competitors[0]?.brand || 'competitor'}` },
|
|
466
|
+
{ label: 'Weak signal forecast', desc: `Emerging opportunities on ${brand}'s horizon`, prompt: `Weak signal forecast for ${brand}` },
|
|
467
|
+
{ label: 'Steal this idea', desc: `Actionable concepts from ${brand}'s playbook`, prompt: `Steal this idea from ${brand}` },
|
|
468
|
+
];
|
|
469
|
+
const now = new Date();
|
|
470
|
+
const doy = Math.floor((now.getTime() - new Date(now.getFullYear(), 0, 0).getTime()) / 864e5);
|
|
471
|
+
const folioStr = `ISSUE ${doy} \u00b7 ${now.getDate()} ${now.toLocaleString('en-US', { month: 'short' }).toUpperCase()} \u00b7 ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
|
|
472
|
+
const slotFills = {
|
|
473
|
+
'FOLIO': folioStr,
|
|
474
|
+
'BRAND_NAME': esc(brand),
|
|
475
|
+
'VELOCITY_CLASS': velocity.cls,
|
|
476
|
+
'VELOCITY_LABEL': velocity.label,
|
|
477
|
+
'COMPARE_BUTTONS': compareButtonsHtml,
|
|
478
|
+
'LIFECYCLE_BAR': lifecycleBar,
|
|
479
|
+
'LIFECYCLE_LEGEND': lifecycleLegend,
|
|
480
|
+
'TRENDS_HTML': trendsHtml,
|
|
481
|
+
'WEAK_SIGNALS_HTML': weakSignalsHtml,
|
|
482
|
+
'EVIDENCE_HTML': evidenceHtml,
|
|
483
|
+
'COMPETITOR_LIST_HTML': competitorListHtml,
|
|
484
|
+
'GT_SECTION_HTML': hasGoogleTrendsData ? `<div class="sl2">${gt.description}</div>
|
|
485
|
+
<svg viewBox="0 0 300 96" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:96px;margin-bottom:4px;">
|
|
486
|
+
<defs>
|
|
487
|
+
<linearGradient id="gtg" x1="0" y1="0" x2="0" y2="1">
|
|
488
|
+
<stop offset="0%" stop-color="#663399" stop-opacity=".1"/>
|
|
489
|
+
<stop offset="100%" stop-color="#663399" stop-opacity="0"/>
|
|
490
|
+
</linearGradient>
|
|
491
|
+
</defs>
|
|
492
|
+
<polygon points="${gt.polygon}" fill="url(#gtg)"/>
|
|
493
|
+
<polyline points="${gt.polyline}" fill="none" stroke="#663399" stroke-width="1.5" stroke-linejoin="round"/>
|
|
494
|
+
<line x1="0" y1="88" x2="300" y2="88" stroke="var(--color-border-tertiary)" stroke-width=".5"/>
|
|
495
|
+
<text x="2" y="93" font-size="7" fill="var(--color-text-secondary)" font-family="monospace">Apr '25</text>
|
|
496
|
+
<text x="150" y="93" font-size="7" fill="var(--color-text-secondary)" text-anchor="middle" font-family="monospace">${gt.peakLabel}</text>
|
|
497
|
+
<text x="298" y="93" font-size="7" fill="var(--color-text-secondary)" text-anchor="end" font-family="monospace">${gt.nowLabel}</text>
|
|
498
|
+
<circle cx="${gt.peakX}" cy="${gt.peakY}" r="2" fill="#663399"/>
|
|
499
|
+
<circle cx="300" cy="${gt.annY}" r="2.5" fill="#663399"/>
|
|
500
|
+
<text x="${gt.annX}" y="${gt.annY}" font-size="7" fill="#663399" text-anchor="end" font-family="monospace">${gt.annText}</text>
|
|
501
|
+
</svg>
|
|
502
|
+
<p class="note">{{GT_CAPTION}}</p>` : '',
|
|
503
|
+
'GT_COMPARISON_SECTION_HTML': gt.comparisonBarsHtml ? `<div class="sl2">Search interest — brand vs competitors</div>
|
|
504
|
+
${gt.comparisonBarsHtml}` : '',
|
|
505
|
+
'RELATED_QUERIES_SECTION_HTML': gt.relatedQueriesHtml ? `<div class="sl2">Top related queries</div>
|
|
506
|
+
<div class="rq">${gt.relatedQueriesHtml}</div>
|
|
507
|
+
<p class="note">{{RELATED_QUERIES_NOTE}}</p>` : '',
|
|
508
|
+
'WIKI_BARS_HTML': wikiBarsHtml,
|
|
509
|
+
'AMAZON_STAT_1_LABEL': 'Listings',
|
|
510
|
+
'AMAZON_STAT_1_VALUE': (amazon.product_count || 0).toLocaleString(),
|
|
511
|
+
'AMAZON_STAT_1_SUB': 'total listings',
|
|
512
|
+
'AMAZON_STAT_2_LABEL': 'Median price',
|
|
513
|
+
'AMAZON_STAT_2_VALUE': amazon?.price_range?.median || amazon?.median_price || '—',
|
|
514
|
+
'AMAZON_STAT_2_SUB': 'across all listings',
|
|
515
|
+
'AMAZON_STAT_3_LABEL': 'Avg rating',
|
|
516
|
+
'AMAZON_STAT_3_VALUE': String(amazon?.average_rating || '—'),
|
|
517
|
+
'AMAZON_STAT_3_SUB': 'product average',
|
|
518
|
+
'AMAZON_STAT_4_LABEL': 'Top reviewed',
|
|
519
|
+
'AMAZON_STAT_4_VALUE': products[0] ? (products[0].reviews || 0).toLocaleString() : '—',
|
|
520
|
+
'AMAZON_STAT_4_SUB': products[0] ? esc(products[0].name || products[0].title || '') : '',
|
|
521
|
+
'AMAZON_PRODUCTS_HTML': amazonProductsHtml,
|
|
522
|
+
'AMAZON_CAPTION': 'Snapshot only. Source: Amazon.',
|
|
523
|
+
'BEA_STAT_1_LABEL': beaLabel,
|
|
524
|
+
'BEA_STAT_1_VALUE': beaValue,
|
|
525
|
+
'BEA_STAT_1_SUB': beaSub,
|
|
526
|
+
'BEA_STAT_2_LABEL': beaChangeLabel,
|
|
527
|
+
'BEA_STAT_2_VALUE': beaChangeValue,
|
|
528
|
+
'BEA_STAT_2_SUB': beaChangeSub,
|
|
529
|
+
'GEO_SECTION_HTML': geoBarsHtml ? `<div class="sl2">Geographic spread</div>
|
|
530
|
+
${geoBarsHtml}` : '',
|
|
531
|
+
'EARNINGS_SECTION_HTML': earningsHtml ? `<div class="sl2">Earnings intelligence</div>
|
|
532
|
+
${earningsHtml}
|
|
533
|
+
<p class="note">Source: Fodda Earnings Intelligence.</p>` : '',
|
|
534
|
+
'SOURCE_PILLS_HTML': sourcePills,
|
|
535
|
+
'EXPORT_1_LABEL': exportLabels[0].label,
|
|
536
|
+
'EXPORT_1_DESC': exportLabels[0].desc,
|
|
537
|
+
'EXPORT_1_PROMPT': exportLabels[0].prompt,
|
|
538
|
+
'EXPORT_2_LABEL': exportLabels[1].label,
|
|
539
|
+
'EXPORT_2_DESC': exportLabels[1].desc,
|
|
540
|
+
'EXPORT_2_PROMPT': exportLabels[1].prompt,
|
|
541
|
+
'EXPORT_3_LABEL': exportLabels[2].label,
|
|
542
|
+
'EXPORT_3_DESC': exportLabels[2].desc,
|
|
543
|
+
'EXPORT_3_PROMPT': exportLabels[2].prompt,
|
|
544
|
+
'EXPORT_4_LABEL': exportLabels[3].label,
|
|
545
|
+
'EXPORT_4_DESC': exportLabels[3].desc,
|
|
546
|
+
'EXPORT_4_PROMPT': exportLabels[3].prompt
|
|
547
|
+
};
|
|
548
|
+
let html = TEMPLATE;
|
|
549
|
+
const fills = { ...slotFills };
|
|
550
|
+
// ── Parallel Gemini fills: one-liner + section intros ──
|
|
551
|
+
const [oneLiner, trendIntro, marketIntro] = await Promise.allSettled([
|
|
552
|
+
fillBrandOneLiner(brand, trends, velocity.label, competitors.slice(0, 3).map((c) => c.brand)),
|
|
553
|
+
fillTrendFootprintIntro(brand, trends, profile.summary?.lifecycle_distribution || {}),
|
|
554
|
+
fillMarketDataIntro(brand, supplemental),
|
|
555
|
+
]);
|
|
556
|
+
fills['ONE_LINER'] = oneLiner.status === 'fulfilled' ? oneLiner.value : `${brand} is ${velocity.label} across ${trends.length} trend${trends.length !== 1 ? 's' : ''}.`;
|
|
557
|
+
const trendIntroText = trendIntro.status === 'fulfilled' ? trendIntro.value : '';
|
|
558
|
+
fills['TREND_FOOTPRINT_INTRO'] = trendIntroText ? `<p class="si">${esc(trendIntroText)}</p>` : '';
|
|
559
|
+
const marketIntroText = marketIntro.status === 'fulfilled' ? marketIntro.value : '';
|
|
560
|
+
fills['MARKET_DATA_INTRO'] = marketIntroText ? `<p class="si">${esc(marketIntroText)}</p>` : '';
|
|
561
|
+
trends.slice(0, 10).forEach((t, i) => {
|
|
562
|
+
const tName = t.trend_name || t.trendName || 'this trend';
|
|
563
|
+
fills[`EXPLORE_PROMPT_${i + 1}`] = `Tell me more about "${tName}" and how ${brand} is positioned`;
|
|
564
|
+
});
|
|
565
|
+
fills['GT_CAPTION'] = supplemental?.google_trends ? `12-month search interest for "${brand}". Peak: ${supplemental.google_trends.peak_interest || '—'}, current: ${supplemental.google_trends.latest_value || '—'}. Source: Google Trends.` : `Search interest data for "${brand}". Source: Google Trends.`;
|
|
566
|
+
fills['RELATED_QUERIES_NOTE'] = supplemental?.google_trends?.related_queries?.length ? `Top related searches when people look for ${brand}.` : '';
|
|
567
|
+
fills['WIKI_NOTE'] = wikiData.length > 0 ? `Daily Wikipedia pageviews for ${brand}-related articles. Higher = more cultural attention.` : '';
|
|
568
|
+
fills['SUGGESTED_NEXT_HTML'] = `<div style="display:flex;flex-wrap:wrap;gap:6px;">
|
|
569
|
+
<button class="btn-out" onclick="sendPrompt('What are ${brand.replace(/'/g, "\\'")}'s competitors doing differently?')">Competitive landscape</button>
|
|
570
|
+
<button class="btn-out" onclick="sendPrompt('Show me the evidence behind ${brand.replace(/'/g, "\\'")}'s strongest trend')">Deep dive</button>
|
|
571
|
+
<button class="btn-out" onclick="sendPrompt('How does ${brand.replace(/'/g, "\\'")}' compare to ${competitors[0]?.brand.replace(/'/g, "\\'") || 'its top competitor'}?')">Head-to-head</button>
|
|
572
|
+
</div>`;
|
|
573
|
+
html = replaceSlots(html, fills);
|
|
574
|
+
// ── Final cleanup: strip any remaining unfilled {{...}} placeholders ──
|
|
575
|
+
html = html.replace(/\{\{[A-Z_0-9]+\}\}/g, '');
|
|
576
|
+
const editorialContext = { brand, total_trends: trends.length };
|
|
577
|
+
return { widget_html: html, editorial_context: editorialContext, open_slots: [] };
|
|
578
|
+
}
|
|
579
|
+
// const TEMPLATE = `
|
|
580
|
+
export const TEMPLATE = `
|
|
581
|
+
<style>
|
|
582
|
+
:root{--p:#663399;--pl:#F5F0FF;--pm:#9B72CC;--pl-on:#663399;}
|
|
583
|
+
@media(prefers-color-scheme:dark){:root{--p:#9B72CC;--pl:rgba(155,114,204,0.14);--pm:#663399;--pl-on:#C4A7E8;}}
|
|
584
|
+
.w{border:1px solid var(--p);border-top:3px solid var(--p);border-radius:6px;padding:1.25rem;font-family:var(--font-mono);}
|
|
585
|
+
.hd{display:flex;align-items:center;justify-content:space-between;margin-bottom:1.25rem;padding-bottom:1rem;border-bottom:.5px solid var(--color-border-tertiary);}
|
|
586
|
+
.logo{display:flex;align-items:center;gap:10px;}
|
|
587
|
+
.logo img{height:24px;width:auto;display:block;}
|
|
588
|
+
.lt{font-size:13px;font-weight:500;}.ls{font-size:10px;color:var(--color-text-secondary);}
|
|
589
|
+
.bfolio{font-family:var(--font-mono);font-size:10px;letter-spacing:0.18em;text-transform:uppercase;color:var(--color-text-secondary);}
|
|
590
|
+
.bh{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:.75rem;}
|
|
591
|
+
.bn{font-family:var(--font-serif);font-style:italic;font-weight:400;font-size:22px;letter-spacing:-0.01em;}
|
|
592
|
+
.vc{font-size:9px;font-weight:700;letter-spacing:0.18em;text-transform:uppercase;padding:3px 8px;border:1px solid currentColor;border-radius:2px;font-family:var(--font-mono);}
|
|
593
|
+
.vc-up{color:var(--color-text-success);}
|
|
594
|
+
.vc-build{color:var(--color-text-info);}
|
|
595
|
+
.vc-steady{color:var(--color-text-secondary);}
|
|
596
|
+
.vc-slow{color:var(--color-text-warning);}
|
|
597
|
+
.pv{font-size:14px;font-family:var(--font-serif);font-style:italic;font-weight:400;line-height:1.65;padding:14px 16px;background:var(--pl);border:1px solid var(--p);border-left-width:3px;border-radius:4px;margin-bottom:1.25rem;color:var(--color-text-primary);}
|
|
598
|
+
.lcb{display:flex;height:8px;border-radius:4px;overflow:hidden;margin-bottom:.75rem;gap:2px;}
|
|
599
|
+
.lcl{display:flex;gap:12px;margin-bottom:1.25rem;flex-wrap:wrap;}
|
|
600
|
+
.lci{display:flex;align-items:center;gap:5px;font-size:10px;color:var(--color-text-secondary);}
|
|
601
|
+
.lcd{width:8px;height:8px;border-radius:2px;}
|
|
602
|
+
.card{background:var(--color-background-primary);border:1px solid var(--color-border-tertiary);border-radius:4px;padding:1rem 1.25rem;margin-bottom:8px;transition:border-color .15s,background .15s;}
|
|
603
|
+
.card:hover{border-color:var(--p);background:var(--pl);}
|
|
604
|
+
.th{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:6px;gap:8px;}
|
|
605
|
+
.tn{font-size:13px;font-weight:500;}
|
|
606
|
+
.ta{display:flex;align-items:center;gap:4px;flex-shrink:0;}
|
|
607
|
+
.td{font-size:12px;color:var(--color-text-secondary);margin-bottom:5px;line-height:1.5;}
|
|
608
|
+
.lb{font-size:9px;font-weight:700;letter-spacing:0.18em;text-transform:uppercase;padding:3px 8px;border:1px solid currentColor;border-radius:2px;white-space:nowrap;font-family:var(--font-mono);background:transparent;}
|
|
609
|
+
.lb-b{color:var(--color-text-info);}
|
|
610
|
+
.lb-e{color:var(--color-text-success);}
|
|
611
|
+
.lb-m{color:var(--color-text-secondary);}
|
|
612
|
+
.lb-f{color:var(--color-text-warning);}
|
|
613
|
+
.bd{font-size:9px;font-weight:700;letter-spacing:0.18em;text-transform:uppercase;padding:3px 8px;border:1px solid currentColor;border-radius:2px;background:transparent;color:var(--color-text-secondary);font-family:var(--font-mono);}
|
|
614
|
+
.bd-fast{font-size:9px;font-weight:700;letter-spacing:0.18em;text-transform:uppercase;padding:3px 8px;border:1px solid currentColor;border-radius:2px;background:transparent;color:#7A4000;font-family:var(--font-mono);}
|
|
615
|
+
.bdp{font-size:10px;padding:2px 8px;border-radius:20px;background:var(--pl);color:var(--pl-on);font-family:var(--font-mono);}
|
|
616
|
+
.brow{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px;}
|
|
617
|
+
.wl{font-size:11px;color:var(--color-text-secondary);margin:1.25rem 0 8px;display:flex;align-items:center;gap:6px;}
|
|
618
|
+
.wd{width:6px;height:6px;border-radius:50%;background:var(--color-text-warning);display:inline-block;}
|
|
619
|
+
.ec{border:1px dashed var(--color-border-tertiary);border-radius:4px;margin-bottom:8px;padding:1rem 1.25rem;}
|
|
620
|
+
.et a{color:var(--color-text-info);text-decoration:none;font-size:13px;font-weight:500;}.et a:hover{text-decoration:underline;}
|
|
621
|
+
.ex{font-size:12px;color:var(--color-text-secondary);line-height:1.5;margin:6px 0 8px;}
|
|
622
|
+
.em{display:flex;gap:6px;flex-wrap:wrap;}
|
|
623
|
+
.cit{font-size:10px;color:var(--color-text-secondary);margin-top:8px;padding-top:8px;border-top:.5px solid var(--color-border-tertiary);}
|
|
624
|
+
.cit a{color:var(--color-text-info);text-decoration:none;}
|
|
625
|
+
.cat-cs{background:var(--color-background-info);color:var(--color-text-info);}
|
|
626
|
+
.cat-si{background:var(--pl);color:var(--p);}
|
|
627
|
+
.cat-me{background:var(--color-background-success);color:var(--color-text-success);}
|
|
628
|
+
.cat-qu{background:var(--color-background-warning);color:var(--color-text-warning);}
|
|
629
|
+
.cat-in{background:var(--color-background-secondary);color:var(--color-text-secondary);}
|
|
630
|
+
.cc{background:var(--color-background-primary);border:1px solid var(--color-border-tertiary);border-radius:4px;padding:.875rem 1.25rem;margin-bottom:6px;display:flex;justify-content:space-between;align-items:center;}
|
|
631
|
+
.cn{font-size:13px;font-weight:500;margin-bottom:3px;}
|
|
632
|
+
.cd{font-size:11px;color:var(--color-text-secondary);}
|
|
633
|
+
.ca{display:flex;flex-direction:column;align-items:flex-end;gap:6px;}
|
|
634
|
+
.pb{font-size:9px;font-weight:700;letter-spacing:0.18em;text-transform:uppercase;padding:3px 8px;border:1px solid currentColor;border-radius:2px;font-family:var(--font-mono);background:transparent;}
|
|
635
|
+
.sl2, .sec{font-size:13px;font-weight:500;color:var(--color-text-primary);margin:1.25rem 0 8px;}.sl2:first-child, .sec:first-child{margin-top:0;}
|
|
636
|
+
.br{display:flex;align-items:center;gap:8px;margin-bottom:5px;font-size:12px;}
|
|
637
|
+
.brl{width:120px;color:var(--color-text-secondary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex-shrink:0;}
|
|
638
|
+
.brt{flex:1;height:4px;background:var(--color-background-secondary);border-radius:3px;overflow:hidden;}
|
|
639
|
+
.brf{height:100%;background:var(--p);border-radius:3px;}
|
|
640
|
+
.brc{min-width:44px;text-align:right;color:var(--color-text-secondary);font-size:11px;}
|
|
641
|
+
.sg{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin-bottom:1rem;}
|
|
642
|
+
.sk{background:var(--color-background-secondary);border-radius:8px;padding:10px 12px;}
|
|
643
|
+
.skl{font-size:10px;color:var(--color-text-secondary);margin-bottom:3px;}
|
|
644
|
+
.skv{font-family:var(--font-serif);font-style:italic;font-size:22px;font-weight:400;}.sks{font-size:10px;color:var(--color-text-secondary);margin-top:2px;}
|
|
645
|
+
.rq{display:flex;gap:5px;flex-wrap:wrap;margin-bottom:.75rem;}
|
|
646
|
+
.rqp{font-size:10px;padding:3px 9px;border-radius:20px;background:var(--color-background-secondary);color:var(--color-text-secondary);border:.5px solid var(--color-border-tertiary);}
|
|
647
|
+
.xb{display:flex;align-items:center;gap:10px;padding:12px 16px;background:var(--color-background-secondary);border:.5px solid var(--color-border-tertiary);border-radius:8px;cursor:pointer;text-align:left;width:100%;margin-bottom:8px;font-family:var(--font-mono);transition:all .15s;}
|
|
648
|
+
.xb:hover{background:var(--pl);border-color:var(--pm);}
|
|
649
|
+
.xi{font-size:11px;width:28px;font-weight:500;flex-shrink:0;color:var(--color-text-secondary);}
|
|
650
|
+
.xd{font-size:11px;color:var(--color-text-secondary);margin-top:2px;}
|
|
651
|
+
.xbl{font-weight:500;font-size:12px;}
|
|
652
|
+
.gf{display:flex;gap:5px;flex-wrap:wrap;margin-top:1.5rem;padding-top:1rem;border-top:.5px solid var(--color-border-tertiary);align-items:center;}
|
|
653
|
+
.gfl{font-size:10px;color:var(--color-text-secondary);margin-right:2px;}
|
|
654
|
+
.gp{font-size:10px;padding:2px 9px;border-radius:20px;background:var(--pl);color:var(--pl-on);border:.5px solid var(--pm);}
|
|
655
|
+
.ap{background:var(--color-background-secondary);border-radius:8px;margin-bottom:6px;padding:.75rem 1rem;display:flex;justify-content:space-between;align-items:center;}
|
|
656
|
+
.an2{font-size:12px;font-weight:500;}
|
|
657
|
+
.am2{font-size:11px;color:var(--color-text-secondary);margin-top:2px;}
|
|
658
|
+
.astar{font-size:11px;color:var(--p);}
|
|
659
|
+
.note{font-size:10px;color:var(--color-text-secondary);margin-bottom:1.25rem;}
|
|
660
|
+
.btn-out{font-size:9px;padding:2px 7px;cursor:pointer;border:.5px solid var(--pm);border-radius:20px;background:var(--pl);color:var(--pl-on);font-family:var(--font-mono);}
|
|
661
|
+
.btn-out:hover{background:var(--p);color:#fff;border-color:var(--p);}
|
|
662
|
+
.cp{font-size:11px;background:var(--pl);color:var(--pl-on);border:.5px solid var(--pm);border-radius:20px;padding:3px 10px;cursor:pointer;font-family:var(--font-mono);transition:all .15s;}
|
|
663
|
+
.cp:hover{background:var(--p);color:#fff;}
|
|
664
|
+
.cv{font-size:10px;background:var(--pl);color:var(--pl-on);border:.5px solid var(--pm);border-radius:20px;padding:2px 8px;cursor:pointer;font-family:var(--font-mono);transition:all .15s;}
|
|
665
|
+
.cv:hover{background:var(--p);color:#fff;}
|
|
666
|
+
.an{font-size:13px;line-height:1.7;color:var(--color-text-primary);}
|
|
667
|
+
.an p{margin:0 0 .75rem;}
|
|
668
|
+
.an strong{font-weight:500;}
|
|
669
|
+
.si{font-size:12px;line-height:1.65;color:var(--color-text-secondary);font-style:italic;margin:0 0 1rem;}
|
|
670
|
+
</style>
|
|
671
|
+
|
|
672
|
+
<div class="w">
|
|
673
|
+
<div class="hd">
|
|
674
|
+
<div class="logo">
|
|
675
|
+
<img src="${FODDA_LOGO_URL}" alt="Fodda" style="height:24px;width:24px;"/>
|
|
676
|
+
<div><div class="lt">Fodda</div><div class="ls">Brand Intelligence</div></div>
|
|
677
|
+
</div>
|
|
678
|
+
<div class="bfolio">{{FOLIO}}</div>
|
|
679
|
+
</div>
|
|
680
|
+
|
|
681
|
+
<div class="bh">
|
|
682
|
+
<div class="bn">{{BRAND_NAME}}</div>
|
|
683
|
+
<span class="vc {{VELOCITY_CLASS}}">{{VELOCITY_LABEL}}</span>
|
|
684
|
+
</div>
|
|
685
|
+
|
|
686
|
+
<div class="pv">{{ONE_LINER}}</div>
|
|
687
|
+
|
|
688
|
+
<div class="sec">Case Studies</div>
|
|
689
|
+
{{EVIDENCE_HTML}}
|
|
690
|
+
|
|
691
|
+
{{EARNINGS_SECTION_HTML}}
|
|
692
|
+
|
|
693
|
+
<div class="sec">Relevant trends</div>
|
|
694
|
+
{{TREND_FOOTPRINT_INTRO}}
|
|
695
|
+
<div class="lcb">{{LIFECYCLE_BAR}}</div>
|
|
696
|
+
<div class="lcl">{{LIFECYCLE_LEGEND}}</div>
|
|
697
|
+
{{TRENDS_HTML}}
|
|
698
|
+
{{WEAK_SIGNALS_HTML}}
|
|
699
|
+
|
|
700
|
+
<div class="sec">Competitive</div>
|
|
701
|
+
{{COMPETITOR_LIST_HTML}}
|
|
702
|
+
|
|
703
|
+
<div class="sec">Market data</div>
|
|
704
|
+
{{MARKET_DATA_INTRO}}
|
|
705
|
+
<div class="sl2">US retail sales — Census Bureau</div>
|
|
706
|
+
<div class="sg">
|
|
707
|
+
<div class="sk"><div class="skl">{{BEA_STAT_1_LABEL}}</div><div class="skv">{{BEA_STAT_1_VALUE}}</div><div class="sks">{{BEA_STAT_1_SUB}}</div></div>
|
|
708
|
+
<div class="sk"><div class="skl">{{BEA_STAT_2_LABEL}}</div><div class="skv">{{BEA_STAT_2_VALUE}}</div><div class="sks">{{BEA_STAT_2_SUB}}</div></div>
|
|
709
|
+
</div>
|
|
710
|
+
|
|
711
|
+
{{GT_SECTION_HTML}}
|
|
712
|
+
{{GT_COMPARISON_SECTION_HTML}}
|
|
713
|
+
{{RELATED_QUERIES_SECTION_HTML}}
|
|
714
|
+
|
|
715
|
+
<div class="sl2">Wikipedia — avg daily pageviews</div>
|
|
716
|
+
{{WIKI_BARS_HTML}}
|
|
717
|
+
<p class="note">{{WIKI_NOTE}}</p>
|
|
718
|
+
|
|
719
|
+
<div class="sl2">Amazon footprint</div>
|
|
720
|
+
<div class="sg">
|
|
721
|
+
<div class="sk"><div class="skl">{{AMAZON_STAT_1_LABEL}}</div><div class="skv">{{AMAZON_STAT_1_VALUE}}</div><div class="sks">{{AMAZON_STAT_1_SUB}}</div></div>
|
|
722
|
+
<div class="sk"><div class="skl">{{AMAZON_STAT_2_LABEL}}</div><div class="skv">{{AMAZON_STAT_2_VALUE}}</div><div class="sks">{{AMAZON_STAT_2_SUB}}</div></div>
|
|
723
|
+
<div class="sk"><div class="skl">{{AMAZON_STAT_3_LABEL}}</div><div class="skv">{{AMAZON_STAT_3_VALUE}}</div><div class="sks">{{AMAZON_STAT_3_SUB}}</div></div>
|
|
724
|
+
<div class="sk"><div class="skl">{{AMAZON_STAT_4_LABEL}}</div><div class="skv">{{AMAZON_STAT_4_VALUE}}</div><div class="sks">{{AMAZON_STAT_4_SUB}}</div></div>
|
|
725
|
+
</div>
|
|
726
|
+
{{AMAZON_PRODUCTS_HTML}}
|
|
727
|
+
<p class="note">{{AMAZON_CAPTION}}</p>
|
|
728
|
+
|
|
729
|
+
{{GEO_SECTION_HTML}}
|
|
730
|
+
|
|
731
|
+
<div class="sec">Explore further</div>
|
|
732
|
+
{{SUGGESTED_NEXT_HTML}}
|
|
733
|
+
<button class="xb" onclick="sendPrompt('{{EXPORT_1_PROMPT}}')"><span class="xi">↗</span><div><div class="xbl">{{EXPORT_1_LABEL}}</div><div class="xd">{{EXPORT_1_DESC}}</div></div></button>
|
|
734
|
+
<button class="xb" onclick="sendPrompt('{{EXPORT_2_PROMPT}}')"><span class="xi">↗</span><div><div class="xbl">{{EXPORT_2_LABEL}}</div><div class="xd">{{EXPORT_2_DESC}}</div></div></button>
|
|
735
|
+
<button class="xb" onclick="sendPrompt('{{EXPORT_3_PROMPT}}')"><span class="xi">↗</span><div><div class="xbl">{{EXPORT_3_LABEL}}</div><div class="xd">{{EXPORT_3_DESC}}</div></div></button>
|
|
736
|
+
<button class="xb" onclick="sendPrompt('{{EXPORT_4_PROMPT}}')"><span class="xi">↗</span><div><div class="xbl">{{EXPORT_4_LABEL}}</div><div class="xd">{{EXPORT_4_DESC}}</div></div></button>
|
|
737
|
+
|
|
738
|
+
<div class="gf">
|
|
739
|
+
<span class="gfl">Sources:</span>
|
|
740
|
+
{{SOURCE_PILLS_HTML}}
|
|
741
|
+
</div>`;
|
|
742
|
+
//# sourceMappingURL=brandTemplate.js.map
|