Files

688 lines
37 KiB
HTML

{% extends "base.html" %}
{% block extra_head %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<link rel="stylesheet" href="/static/css/herman-dashboard.css?v=13" />
{% endblock %}
{% block content %}
<div x-data="marketingHub()" x-init="init()">
<header class="page-header">
<div>
<h1>Marketing Command <span class="live-badge">Live</span></h1>
<p class="subtitle">RSS trends · halal markt · weer · actie · CRM koppeling</p>
</div>
<div class="retail-actions">
<button class="btn btn-sm btn-pulse" @click="refreshAll()" :disabled="busy">↻ Alles verversen</button>
<a class="btn btn-sm btn-pulse-green" href="/retail">Retail 360 →</a>
<a class="btn btn-sm" href="/projects">Projecten →</a>
</div>
</header>
<div id="viz-toolbar" class="viz-toolbar"></div>
<div class="hm-dash-two-col" style="margin-bottom:1rem">
<div class="hm-chart-panel page-viz-panel viz-mode-chart">
<div class="hm-chart-head"><h4>Marketing data</h4><small>RSS · trends · kansen</small></div>
<div class="page-viz-body">
<div class="hm-chart-wrap page-viz-canvas"><canvas id="chart-marketing"></canvas></div>
<div id="viz-marketing-alt" class="viz-alt"></div>
</div>
</div>
<div class="hm-chart-panel page-viz-panel viz-mode-chart">
<div class="hm-chart-head"><h4>Publish jobs</h4><small>status verdeling</small></div>
<div class="page-viz-body">
<div class="hm-chart-wrap page-viz-canvas"><canvas id="chart-marketing-publish"></canvas></div>
<div id="viz-marketing-publish-alt" class="viz-alt"></div>
</div>
</div>
</div>
<div class="live-feed-bar" x-show="dashboard.rss?.length">
<div class="ticker-track">
<template x-for="r in dashboard.rss" :key="r.id">
<a class="ticker-link" :href="r.link" target="_blank" rel="noopener" x-text="'📰 '+r.feed_name+': '+r.title"></a>
</template>
</div>
</div>
<div class="kpi-row">
<div class="kpi-card kpi-pulse"><span>RSS items</span><strong x-text="dashboard.rss?.length||0"></strong></div>
<div class="kpi-card"><span>Markt trends</span><strong x-text="dashboard.trends?.length||0"></strong></div>
<div class="kpi-card kpi-green"><span>Top kansen</span><strong x-text="dashboard.top_opportunities?.length||0"></strong></div>
<div class="kpi-card"><span>Mentions</span><strong>{{ analytics.mention_count }}</strong></div>
</div>
<div class="vtabs-layout" style="--vt-color:#ff6b9d" x-ref="vtabsWrap">
<nav class="vtabs-nav" x-ref="vtabsNav" :style="'--vt-indicator-top:'+tabIndicatorTop+'px;--vt-indicator-h:'+tabIndicatorH+'px'">
<div class="vtabs-nav-header"><strong>Marketing</strong></div>
<button type="button" class="vtab-btn" :class="tab==='live'?'active':''" @click="setTab('live', $event.currentTarget)" data-vtab="live"><span class="vtab-icon">📡</span><span>Live Feed</span></button>
<button type="button" class="vtab-btn" :class="tab==='reclame'?'active':''" @click="setTab('reclame', $event.currentTarget)" data-vtab="reclame"><span class="vtab-icon">📁</span><span>Reclame folders</span></button>
<button type="button" class="vtab-btn" :class="tab==='publish'?'active':''" @click="setTab('publish', $event.currentTarget)" data-vtab="publish"><span class="vtab-icon">🚀</span><span>Automatisering</span></button>
<button type="button" class="vtab-btn" :class="tab==='actie'?'active':''" @click="setTab('actie', $event.currentTarget)" data-vtab="actie"><span class="vtab-icon"></span><span>Actie</span></button>
<button type="button" class="vtab-btn" :class="tab==='intel'?'active':''" @click="setTab('intel', $event.currentTarget)" data-vtab="intel"><span class="vtab-icon">🎯</span><span>Marktscan</span></button>
<button type="button" class="vtab-btn" :class="tab==='regelgeving'?'active':''" @click="setTab('regelgeving', $event.currentTarget)" data-vtab="regelgeving"><span class="vtab-icon">⚖️</span><span>Regelgeving</span></button>
<button type="button" class="vtab-btn" :class="tab==='bookmarks'?'active':''" @click="setTab('bookmarks', $event.currentTarget)" data-vtab="bookmarks"><span class="vtab-icon"></span><span>Opgeslagen RSS</span></button>
<button type="button" class="vtab-btn" :class="tab==='social'?'active':''" @click="setTab('social', $event.currentTarget)" data-vtab="social"><span class="vtab-icon">💬</span><span>Social</span></button>
<button type="button" class="vtab-btn" :class="tab==='studio'?'active':''" @click="setTab('studio', $event.currentTarget)" data-vtab="studio"><span class="vtab-icon">🎨</span><span>Studio</span></button>
</nav>
<div class="vtabs-content">
<section x-show="tab==='live'" class="panel">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
<div>
<h2>Kant-en-klaar & supermarkt RSS <span class="live-badge">Gefilterd</span></h2>
<p class="hint">Alleen kant-en-klaar maaltijden, retail en supermarkten — geen algemeen nieuws.</p>
<button class="btn btn-sm btn-pulse" @click="refreshRss()" :disabled="busy">RSS ophalen</button>
<template x-for="r in dashboard.rss" :key="r.id">
<div class="feed-card">
<div class="source" x-text="r.feed_name+' · '+r.category"></div>
<a :href="r.link" target="_blank" rel="noopener"><strong x-text="r.title"></strong></a>
<p x-text="(r.summary||'').substring(0,140)+'…'"></p>
<small x-text="r.published_at||r.fetched_at"></small>
<div class="feed-actions">
<a class="btn-source" :href="r.link" target="_blank" rel="noopener">Artikel lezen →</a>
<a class="btn-source" :href="r.feed_url" target="_blank" rel="noopener">Bron feed →</a>
<button type="button" class="btn-source btn-bookmark" :class="isBookmarked(r.id)?'saved':''" @click="toggleBookmark(r)" x-text="isBookmarked(r.id)?'★ Opgeslagen':'☆ Bewaren'"></button>
</div>
</div>
</template>
<p x-show="!dashboard.rss?.length" class="muted">Geen items — klik RSS ophalen.</p>
</div>
<div>
<h2>Markt trends <span class="live-badge">Live</span></h2>
<template x-for="t in dashboard.trends" :key="t.id">
<div class="feed-card">
<strong x-text="t.trend_name"></strong>
<p x-text="t.description||t.trend_category"></p>
<span class="ticker-item" x-text="'Kans: '+Math.round((t.opportunity_score||0)*100)+'%'"></span>
</div>
</template>
<h3 style="margin-top:1.5rem">Top halal kansen</h3>
<template x-for="o in dashboard.top_opportunities" :key="o.name">
<div class="feed-card clickable-row" @click="window.location='/retail'">
<strong x-text="o.chain+' · '+o.name"></strong>
<p x-text="o.city+' · score '+Math.round(o.halal_opportunity_score||0)"></p>
</div>
</template>
</div>
</div>
</section>
<!-- ACTIE -->
<section x-show="tab==='actie'" class="panel">
<h2>Snelle acties <span class="live-badge">Pulse</span></h2>
<div style="display:flex;flex-wrap:wrap;gap:0.75rem;margin-bottom:1.5rem">
<a class="btn btn-pulse" href="/retail">Open Retail 360</a>
<a class="btn btn-pulse-green" href="/clients">CRM Clients</a>
<a class="btn btn-pulse-purple" href="/deals">CRM Deals</a>
<button class="btn btn-pulse" @click="refreshAll()">Trends verversen</button>
</div>
<h3>Herman Recommendations</h3>
<div id="reco-list"><p class="muted">Laden…</p></div>
<h3 style="margin-top:1.5rem">Geplande posts</h3>
<table class="data-table"><thead><tr><th>Account</th><th>Content</th><th>Tijd</th><th>Status</th><th></th></tr></thead>
<tbody>{% for p in scheduled_posts %}<tr>
<td>{{ p.platform }} @{{ p.username }}</td><td>{{ p.content[:60] }}</td><td>{{ p.scheduled_time }}</td><td>{{ p.status }}</td>
<td><button class="btn btn-sm btn-approve" @click="postStatus({{ p.id }}, 'approved')"></button>
<button class="btn btn-sm btn-primary" @click="postStatus({{ p.id }}, 'posted')">Post</button></td></tr>{% endfor %}</tbody></table>
</section>
<!-- RECLAME FOLDERS -->
<section x-show="tab==='reclame'" class="panel">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
<div>
<h2>Supermarkt reclame folders <span class="live-badge">Acties</span></h2>
<p class="hint">Live folders van <a href="https://www.reclamefolder.nl/categorieen/supermarkt/" target="_blank" rel="noopener">Reclamefolder.nl</a> — klik om de folder te openen.</p>
</div>
<div style="display:flex;gap:0.5rem;flex-wrap:wrap">
<button class="btn btn-sm btn-pulse-green" @click="refreshReclamefolders()" :disabled="busy">↻ Ophalen van Reclamefolder.nl</button>
<a class="btn btn-sm" href="https://www.reclamefolder.nl/categorieen/supermarkt/" target="_blank" rel="noopener">Bron site →</a>
</div>
</div>
<div class="reclame-kpi-row kpi-row" style="margin-bottom:1rem">
<div class="kpi-card"><span>Ketens</span><strong x-text="reclameStats.chains||0"></strong></div>
<div class="kpi-card kpi-green"><span>Folders</span><strong x-text="filteredPromos.length"></strong></div>
<div class="kpi-card"><span>Totaal geladen</span><strong x-text="promos.length"></strong></div>
</div>
<div class="form-grid reclame-filters" style="margin-bottom:1rem">
<select class="form-input" x-model="reclameFilter.chain" @change="loadPromos()">
<option value="">Alle ketens</option>
<template x-for="c in reclameChains" :key="c">
<option :value="c" x-text="c"></option>
</template>
</select>
<select class="form-input" x-model="reclameFilter.folder_type" @change="loadPromos()">
<option value="">Alle types</option>
<option value="folder">Folder</option>
<option value="actie">Actie</option>
<option value="bonus">Bonus</option>
</select>
<select class="form-input" x-model="reclameFilter.valid_days" @change="loadPromos()">
<option value="">Alle geldigheid</option>
<option value="7">Nog 7 dagen geldig</option>
<option value="14">Nog 14 dagen geldig</option>
<option value="30">Nog 30 dagen geldig</option>
</select>
<input class="form-input" x-model="reclameFilter.q" @input.debounce.400ms="loadPromos()" placeholder="Zoek op titel, keten…" />
</div>
<div class="promo-grid">
<template x-for="p in filteredPromos" :key="p.id || p.folder_path">
<a class="feed-card promo-card promo-card-link" :href="promoUrl(p)" target="_blank" rel="noopener">
<img x-show="p.image_url" class="promo-cover" :src="p.image_url" :alt="p.title" loading="lazy" />
<span class="ticker-item" x-text="p.chain||'Retail'"></span>
<strong x-text="p.title"></strong>
<p x-text="p.description||p.folder_label||''"></p>
<small x-text="(p.valid_from||'?')+' → '+(p.valid_to||'?')"></small>
<div class="feed-actions">
<span class="btn-source">📁 Folder openen op Reclamefolder.nl →</span>
</div>
</a>
</template>
</div>
<p x-show="!filteredPromos.length && !busy" class="muted">Geen folders — pas filters aan of klik «Ophalen van Reclamefolder.nl».</p>
<h3 style="margin-top:1.5rem">Nieuwe folder registreren</h3>
<form class="form-grid" @submit.prevent="addPromo">
<input class="form-input" x-model="promoForm.chain" placeholder="Keten (AH, Jumbo…)" />
<input class="form-input" x-model="promoForm.title" placeholder="Titel actie" required />
<input class="form-input" x-model="promoForm.folder_path" placeholder="Pad /nas/marketing/reclame/…" />
<input class="form-input" x-model="promoForm.description" placeholder="Beschrijving / context" />
<button class="btn btn-primary btn-pulse" type="submit">Folder toevoegen</button>
</form>
</section>
<!-- AUTOMATISERING / SOCIAL PUBLISH -->
<section x-show="tab==='publish'" class="panel">
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:1rem;flex-wrap:wrap;margin-bottom:1rem">
<div>
<h2>Social automatisering <span class="live-badge">202 Async</span></h2>
<p class="hint">Publiceer naar alle kanalen tegelijk. Zonder API-keys worden kanalen overgeslagen.</p>
</div>
<a class="btn btn-sm btn-pulse-purple" href="/settings?tab=social">⚙️ Social API's instellen</a>
</div>
<div class="publish-channels" style="display:flex;flex-wrap:wrap;gap:0.5rem;margin-bottom:1rem">
<template x-for="ch in publishChannels" :key="ch.platform">
<label class="ticker-item publish-channel" :class="ch.configured ? 'channel-ready' : 'channel-skip'">
<input type="checkbox" :value="ch.platform" x-model="publishForm.channels" />
<span x-text="ch.label + (ch.configured ? ' ✓' : ' (skip)')"></span>
</label>
</template>
</div>
<div class="emoji-bar" style="display:flex;flex-wrap:wrap;gap:0.35rem;margin-bottom:0.5rem">
<template x-for="em in emojis" :key="em">
<button type="button" class="btn btn-sm" @click="insertEmoji(em)" x-text="em"></button>
</template>
</div>
<textarea class="form-input" rows="5" x-model="publishForm.text" placeholder="Post tekst — UTF-8 emoji ondersteund 🥘✨" style="width:100%;margin-bottom:0.75rem"></textarea>
<div style="display:flex;gap:1rem;flex-wrap:wrap;align-items:center;margin-bottom:1rem">
<select class="form-input" x-model="publishForm.project_id" style="max-width:220px">
<option value="">Opslaan in agent-project (auto)</option>
<template x-for="p in cockpitProjects" :key="p.id">
<option :value="p.id" x-text="p.name"></option>
</template>
</select>
<label class="btn btn-secondary btn-sm">
📷 Foto uploaden
<input type="file" accept="image/*" @change="uploadImage($event)" style="display:none" />
</label>
<img x-show="publishForm.image_url" :src="publishForm.image_url" alt="Preview" style="max-height:80px;border-radius:8px" />
<button type="button" class="btn btn-primary btn-pulse" @click="submitPublish()" :disabled="publishBusy || !publishForm.text.trim()">
🚀 Publiceren (achtergrond)
</button>
<span x-show="publishJobId" class="muted">Job #<span x-text="publishJobId"></span> · <span x-text="publishJobStatus"></span></span>
</div>
<h3>Publicatie historie</h3>
<table class="data-table">
<thead><tr><th>#</th><th>Tijd</th><th>Kanalen</th><th>Status</th><th>Resultaat</th></tr></thead>
<tbody>
<template x-for="j in publishHistory" :key="j.id">
<tr>
<td x-text="j.id"></td>
<td x-text="(j.created_at||'').substring(0,19).replace('T',' ')"></td>
<td x-text="(j.channels||[]).join(', ')"></td>
<td x-text="j.status"></td>
<td><small x-text="publishResultSummary(j)"></small></td>
</tr>
</template>
</tbody>
</table>
<p x-show="!publishHistory.length" class="muted">Nog geen publicaties.</p>
</section>
<!-- OPGESLAGEN RSS -->
<section x-show="tab==='bookmarks'" class="panel">
<h2>Opgeslagen RSS artikelen <span class="live-badge"></span></h2>
<template x-for="b in bookmarks" :key="b.id">
<div class="feed-card saved-rss">
<div class="source" x-text="(b.feed_name||'RSS')+' · '+((b.created_at||'').substring(0,10))"></div>
<a :href="b.link" target="_blank" rel="noopener"><strong x-text="b.title"></strong></a>
<div class="feed-actions">
<a class="btn-source" :href="b.link" target="_blank">Artikel →</a>
<button type="button" class="btn-source btn-bookmark saved" @click="removeBookmark(b.rss_item_id)">Verwijder ★</button>
</div>
</div>
</template>
<p x-show="!bookmarks.length" class="muted">Nog geen opgeslagen artikelen — klik ☆ Bewaren in Live Feed.</p>
</section>
<!-- REGELGEVING & CBS -->
<section x-show="tab==='regelgeving'" class="panel">
<div class="regs-header" style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
<div>
<h2>Regelgeving · CBS · Food markt <span class="live-badge">Live RSS</span></h2>
<p class="hint">NVWA, EU food law, CBS statistiek en retail M&A — apart van kant-en-klaar nieuws.</p>
</div>
<button class="btn btn-sm btn-pulse" @click="refreshRegulations()" :disabled="busy">↻ Feeds ophalen</button>
</div>
<div class="regs-columns" style="display:grid;grid-template-columns:repeat(3,1fr);gap:1.25rem">
<div>
<h3 style="font-size:0.9rem;color:#a855f7;margin:0 0 0.75rem">⚖️ Regelgeving & NVWA</h3>
<template x-for="r in regRows.regelgeving" :key="'reg'+r.id">
<div class="feed-card">
<span class="ticker-item" style="background:rgba(168,85,247,0.15);color:#a855f7;border-color:rgba(168,85,247,0.3)">REG</span>
<a :href="r.link" target="_blank" rel="noopener"><strong x-text="r.title"></strong></a>
<small x-text="r.feed_name"></small>
<div class="feed-actions">
<a class="btn-source" :href="r.link" target="_blank">Artikel →</a>
<a class="btn-source" :href="r.feed_url" target="_blank">Bron →</a>
</div>
</div>
</template>
<p x-show="!regRows.regelgeving?.length" class="muted">Geen items — klik Feeds ophalen.</p>
</div>
<div>
<h3 style="font-size:0.9rem;color:#38bdf8;margin:0 0 0.75rem">📊 CBS statistiek</h3>
<template x-for="r in regRows.cbs" :key="'cbs'+r.id">
<div class="feed-card">
<span class="ticker-item" style="background:rgba(56,189,248,0.15);color:#38bdf8">CBS</span>
<a :href="r.link" target="_blank" rel="noopener"><strong x-text="r.title"></strong></a>
<small x-text="r.feed_name"></small>
<div class="feed-actions">
<a class="btn-source" :href="r.link" target="_blank">Artikel →</a>
<a class="btn-source" :href="r.feed_url" target="_blank">Bron →</a>
</div>
</div>
</template>
<p x-show="!regRows.cbs?.length" class="muted">Geen CBS feeds — refresh RSS.</p>
</div>
<div>
<h3 style="font-size:0.9rem;color:#ff9f43;margin:0 0 0.75rem">🏪 Food markt & M&A</h3>
<template x-for="r in regRows.markt" :key="'mkt'+r.id">
<div class="feed-card">
<span class="ticker-item" style="background:rgba(255,159,67,0.15);color:#ff9f43">MKT</span>
<a :href="r.link" target="_blank" rel="noopener"><strong x-text="r.title"></strong></a>
<small x-text="r.feed_name"></small>
<div class="feed-actions">
<a class="btn-source" :href="r.link" target="_blank">Artikel →</a>
<a class="btn-source" :href="r.feed_url" target="_blank">Bron →</a>
</div>
</div>
</template>
<p x-show="!regRows.markt?.length" class="muted">Geen markt news.</p>
</div>
</div>
</section>
<!-- INTEL -->
<section x-show="tab==='intel'" class="panel">
<h2>Marketing strategie · halal kant-en-klaar</h2>
<div class="feed-card">
<strong>🎯 Focus: halal-gap filialen</strong>
<p>Filialen zonder halal certificering in buurten met hoge demografische vraag — direct benaderen via CRM koppeling in Retail 360.</p>
<a class="btn btn-sm btn-pulse" href="/retail">Bekijk kansen →</a>
</div>
<div class="feed-card">
<strong>📊 Data bronnen actief</strong>
<p>CBS demografie · PDOK geocoding · Open-Meteo weer · RSS feeds (Foodlog, RetailDetail, VMT, Nu.nl) · OSM groothandels</p>
</div>
<div class="feed-card">
<strong>🌤️ Weer & seizoen</strong>
<p>Gebruik weer voorspelling per filiaal in Retail 360 om promoties te timen (warme dagen = kant-en-klaar salades, kou = stoofgerechten).</p>
</div>
<button class="btn btn-secondary" @click="loadSocialAnalytics()">Social analytics laden</button>
<pre x-text="socialAnalytics" style="margin-top:1rem;font-size:0.8rem"></pre>
</section>
<!-- SOCIAL (bestaande functionaliteit) -->
<section x-show="tab==='social'" class="panel">
<h2>Mentions</h2>
<form class="form-grid" @submit.prevent="addMention">
<select x-model="mentionForm.platform" class="form-input"><option>Instagram</option><option>TikTok</option><option>Facebook</option><option>LinkedIn</option></select>
<input class="form-input" placeholder="Mention text" x-model="mentionForm.text" required />
<button class="btn btn-primary btn-pulse" type="submit">Add mention</button>
</form>
<table class="data-table"><thead><tr><th>Platform</th><th>Text</th><th>Sentiment</th><th></th></tr></thead>
<tbody>{% for m in social_mentions %}
<tr><td>{{ m.platform }}</td><td>{{ m.text[:80] }}</td><td>{{ m.sentiment_score }}</td>
<td><button class="btn btn-reject btn-sm" @click="deleteMention({{ m.id }})">Delete</button></td></tr>{% endfor %}</tbody></table>
<h3 style="margin-top:1.5rem">Accounts</h3>
<form class="form-grid" @submit.prevent="addAccount">
<input class="form-input" placeholder="Platform" x-model="accountForm.platform" />
<input class="form-input" placeholder="Username" x-model="accountForm.username" />
<button class="btn btn-primary" type="submit">Add</button>
</form>
<table class="data-table"><thead><tr><th>Platform</th><th>Username</th><th>Active</th></tr></thead>
<tbody>{% for a in accounts %}<tr><td>{{ a.platform }}</td><td>{{ a.username }}</td><td>{{ a.is_active }}</td></tr>{% endfor %}</tbody></table>
</section>
<!-- STUDIO -->
<section x-show="tab==='studio'" class="panel">
<h2>AI Studio</h2>
<form @submit.prevent="generateAI">
<input class="form-input" x-model="aiForm.topic" placeholder="Topic — bijv. halal kant-en-klaar Jumbo Amsterdam" />
<input class="form-input" x-model="aiForm.platform" placeholder="Platform" />
<button class="btn btn-primary btn-pulse-purple" type="submit">Generate content</button>
</form>
<pre class="ai-output" x-text="aiOutput"></pre>
<h3 style="margin-top:1.5rem">Agent rules</h3>
<form class="form-grid" @submit.prevent="addRule">
<input class="form-input" x-model="ruleForm.name" placeholder="Rule name" />
<select x-model="ruleForm.condition_type" class="form-input"><option value="sentiment_below">sentiment_below</option><option value="mention_spike">mention_spike</option></select>
<button class="btn btn-primary" type="submit">Add rule</button>
</form>
<table class="data-table"><thead><tr><th>Name</th><th>Condition</th><th>Active</th></tr></thead>
<tbody>{% for r in agent_rules %}<tr><td>{{ r.name }}</td><td>{{ r.condition_type }}</td><td>{{ r.is_active }}</td></tr>{% endfor %}</tbody></table>
</section>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/static/js/dashboard-layout.js?v=2"></script>
<script src="/static/js/page-viz.js?v=1"></script>
<script>
function marketingHub() {
return {
tab: new URLSearchParams(location.search).get('tab') || 'live',
tabIndicatorTop: 0, tabIndicatorH: 44,
busy: false, dashboard: {}, socialAnalytics: '',
regRows: { regelgeving: [], cbs: [], markt: [] },
promos: [], bookmarks: [], bookmarkIds: {},
reclameChains: [], reclameFilter: { chain: '', q: '', folder_type: '', valid_days: '' },
reclameStats: { chains: 0 },
publishChannels: [], publishForm: { text: '', image_url: '', media_ids: [], channels: [], project_id: '' },
publishHistory: [], publishJobId: null, publishJobStatus: '', publishBusy: false,
cockpitProjects: [],
emojis: ['🥘','✨','🔥','📢','🛒','🌿','☪️','🇳🇱','👨‍🍳','💚','📁','🎯'],
promoForm: { chain: '', title: '', folder_path: '', description: '' },
_pollStop: null,
mentionForm: { platform: 'Instagram', text: '' },
accountForm: { platform: 'Instagram', username: '' },
ruleForm: { name: '', condition_type: 'sentiment_below', threshold: 2 },
aiForm: { topic: '', platform: 'Instagram', tone: 'vriendelijk' },
aiOutput: '',
get filteredPromos() {
return this.promos;
},
async init() {
await this.loadDashboard();
await this.loadBookmarks();
await this.loadPublishHistory();
fetch('/api/projects?limit=50').then(r=>r.json()).then(d=>{ this.cockpitProjects = d.items || []; }).catch(()=>{});
if (this.tab === 'regelgeving') await this.loadRegulations();
if (this.tab === 'reclame') { await this.loadReclameChains(); await this.loadPromos(); }
if (this.tab === 'publish') { await this.loadPublishChannels(); await this.loadPublishHistory(); }
this._pollStop = CockpitLive.startPolling(() => this.refreshAll(false), 30000);
this.$nextTick(() => this.updateTabIndicator());
if (window.PageViz) {
window.PageViz.init('marketing', (mode) => this.renderViz(mode));
}
fetch('/api/admin/recommendations/pending').then(r=>r.json()).then(d=>{
const el=document.getElementById('reco-list');
if(!el) return;
el.innerHTML=(d.items||[]).map(i=>`<div class="feed-card"><strong>${i.title}</strong><p>${i.description||''}</p>
<button class="btn btn-sm btn-approve" onclick="fetch('/api/admin/recommendations/${i.id}/approve',{method:'POST'}).then(()=>location.reload())">Approve</button>
<button class="btn btn-sm btn-reject" onclick="fetch('/api/admin/recommendations/${i.id}/dismiss',{method:'POST'}).then(()=>location.reload())">Dismiss</button></div>`).join('')||'<p class="muted">Geen open recommendations</p>';
});
},
setTab(name) {
this.tab = name;
const u = new URL(location.href);
if (name === 'live') u.searchParams.delete('tab');
else u.searchParams.set('tab', name);
history.replaceState({}, '', u);
if (name === 'reclame') { this.loadReclameChains(); this.loadPromos(); }
if (name === 'bookmarks') this.loadBookmarks();
if (name === 'regelgeving') this.loadRegulations();
if (name === 'publish') { this.loadPublishChannels(); this.loadPublishHistory(); }
this.$nextTick(() => this.updateTabIndicator());
},
updateTabIndicator() {
const nav = this.$refs.vtabsNav;
if (!nav) return;
const btn = nav.querySelector('[data-vtab="'+this.tab+'"]');
if (!btn) return;
this.tabIndicatorTop = btn.offsetTop - nav.scrollTop;
this.tabIndicatorH = btn.offsetHeight;
},
async loadReclameChains() {
try {
const d = await fetch('/api/retail/reclamefolder/chains').then(r => r.json());
this.reclameChains = d.chains || [];
this.reclameStats.chains = d.count || this.reclameChains.length;
} catch (e) { this.reclameChains = []; }
},
async loadPromos() {
try {
const params = new URLSearchParams({ status: 'active', limit: '200' });
if (this.reclameFilter.chain) params.set('chain', this.reclameFilter.chain);
if (this.reclameFilter.q) params.set('q', this.reclameFilter.q);
if (this.reclameFilter.folder_type) params.set('folder_type', this.reclameFilter.folder_type);
if (this.reclameFilter.valid_days) params.set('valid_days', this.reclameFilter.valid_days);
let items = (await fetch('/api/retail/promo-campaigns?' + params).then(r => r.json())).items || [];
items = items.filter(p => p.source === 'reclamefolder.nl' || (p.folder_path || '').startsWith('http') || (p.metadata && (typeof p.metadata === 'object' ? p.metadata.edition_id : String(p.metadata).includes('edition_id'))));
this.promos = items;
if (!items.length && !this.reclameFilter.q && !this.reclameFilter.chain) await this.refreshReclamefolders(false);
} catch (e) { this.promos = []; }
},
promoUrl(p) {
const path = p.folder_path || '';
if (path.startsWith('http')) return path;
let meta = p.metadata || {};
if (typeof meta === 'string') { try { meta = JSON.parse(meta); } catch (e) { meta = {}; } }
const editionId = meta.edition_id;
if (editionId) return 'https://www.reclamefolder.nl/f/folders/' + editionId + '/';
const slug = meta.retailer_permaname || '';
if (slug) return 'https://www.reclamefolder.nl/winkels/' + slug + '/';
return 'https://www.reclamefolder.nl/categorieen/supermarkt/';
},
async refreshReclamefolders(toast = true) {
this.busy = true;
try {
const d = await fetch('/api/retail/reclamefolder/refresh', { method: 'POST' }).then(r => r.json());
await this.loadPromos();
await this.loadReclameChains();
if (toast) Cockpit.toast((d.synced || 0) + ' folders opgehaald van Reclamefolder.nl', 'success');
} catch (e) {
if (toast) Cockpit.toast('Reclamefolder ophalen mislukt', 'error');
} finally { this.busy = false; }
},
async addPromo() {
await fetch('/api/retail/promo-campaigns', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(this.promoForm) });
this.promoForm = { chain: '', title: '', folder_path: '', description: '' };
await this.loadPromos();
Cockpit.toast('Folder geregistreerd', 'success');
},
async loadBookmarks() {
try {
const d = await fetch('/api/retail/rss/bookmarks?limit=100').then(r => r.json());
this.bookmarks = d.items || [];
this.bookmarkIds = {};
this.bookmarks.forEach(b => { this.bookmarkIds[b.rss_item_id] = true; });
} catch (e) { this.bookmarks = []; }
},
isBookmarked(id) { return !!this.bookmarkIds[id]; },
async toggleBookmark(r) {
if (this.isBookmarked(r.id)) {
await this.removeBookmark(r.id);
} else {
await fetch('/api/retail/rss/bookmarks', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rss_item_id: r.id, title: r.title, link: r.link, feed_name: r.feed_name }),
});
await this.loadBookmarks();
}
},
async removeBookmark(id) {
await fetch('/api/retail/rss/bookmarks/' + id, { method: 'DELETE' });
await this.loadBookmarks();
},
async loadRegulations() {
try {
const d = await fetch('/api/retail/regulations?limit=25').then(r => r.json());
this.regRows = { regelgeving: d.regelgeving || [], cbs: d.cbs || [], markt: d.markt || [] };
} catch (e) { this.regRows = { regelgeving: [], cbs: [], markt: [] }; }
},
async refreshRegulations() {
this.busy = true;
try { await fetch('/api/retail/rss/refresh', { method: 'POST' }); await this.loadRegulations(); }
finally { this.busy = false; }
},
async loadDashboard() {
try { this.dashboard = await fetch('/api/retail/live-dashboard').then(r=>r.json()); } catch(e) { this.dashboard={}; }
},
async refreshRss() { this.busy=true; try{ await fetch('/api/retail/rss/refresh',{method:'POST'}); await this.loadDashboard(); }finally{ this.busy=false; } },
async refreshAll(toast) {
this.busy = true;
try {
await fetch('/api/retail/rss/refresh', { method: 'POST' });
await this.loadDashboard();
this.renderViz(window.__vizMode || 'neo-bars');
if (toast !== false) Cockpit.toast('Marketing data bijgewerkt', 'success');
} finally { this.busy = false; }
},
async addMention() { await Cockpit.api('/mentions',{method:'POST',body:JSON.stringify(this.mentionForm)}); location.reload(); },
async deleteMention(id) { if(!Cockpit.confirmDelete()) return; await Cockpit.api('/mentions/'+id,{method:'DELETE'}); location.reload(); },
async addAccount() { await Cockpit.api('/accounts',{method:'POST',body:JSON.stringify(this.accountForm)}); location.reload(); },
async postStatus(id, status) { await Cockpit.api('/scheduled-posts/'+id+'/status',{method:'PATCH',body:JSON.stringify({status})}); location.reload(); },
async addRule() { await Cockpit.api('/agent-rules',{method:'POST',body:JSON.stringify({...this.ruleForm,threshold:parseFloat(this.ruleForm.threshold),action:'notify',is_active:true})}); location.reload(); },
async generateAI() { const r=await Cockpit.api('/api/ai/generate-content',{method:'POST',body:JSON.stringify(this.aiForm)}); this.aiOutput=r.content; },
async loadSocialAnalytics() { const r=await Cockpit.api('/analytics/social'); this.socialAnalytics=JSON.stringify(r,null,2); },
insertEmoji(em) {
this.publishForm.text = (this.publishForm.text || '') + em;
},
async loadPublishChannels() {
try {
const r = await fetch('/api/marketing/channels').then(res => res.json());
const labels = { twitter: 'X', linkedin: 'LinkedIn', instagram: 'Instagram', facebook: 'Facebook', tiktok: 'TikTok', pinterest: 'Pinterest' };
this.publishChannels = (r.items || []).map(c => ({
...c, label: labels[c.platform] || c.platform,
}));
if (!this.publishForm.channels.length) {
this.publishForm.channels = (r.platforms || []).slice();
}
} catch (e) { this.publishChannels = []; }
},
async loadPublishHistory() {
try {
const r = await fetch('/api/marketing/publish/history?limit=30').then(res => res.json());
this.publishHistory = r.items || [];
if (window.__marketingRenderViz) window.__marketingRenderViz(window.__vizMode || 'neo-bars');
} catch (e) { this.publishHistory = []; }
},
renderViz(mode) {
window.__vizMode = mode;
window.__marketingRenderViz = (m) => this.renderViz(m);
if (!window.VizEngine) return;
const d = this.dashboard || {};
VizEngine.renderFromRows(mode,
document.getElementById('chart-marketing'),
document.getElementById('viz-marketing-alt'),
'marketing-main', 'Marketing KPIs',
[
{ label: 'RSS', value: (d.rss || []).length, raw: (d.rss || []).length },
{ label: 'Trends', value: (d.trends || []).length, raw: (d.trends || []).length },
{ label: 'Kansen', value: (d.top_opportunities || []).length, raw: (d.top_opportunities || []).length },
{ label: 'Promo', value: this.promos.length, raw: this.promos.length },
],
'hm-eq-purple', VizEngine.MODES ? null : null
);
const statusMap = {};
(this.publishHistory || []).forEach(j => {
const s = j.status || 'unknown';
statusMap[s] = (statusMap[s] || 0) + 1;
});
const labels = Object.keys(statusMap);
const values = labels.map(k => statusMap[k]);
if (!labels.length) { labels.push('geen'); values.push(0); }
VizEngine.renderFromRows(mode,
document.getElementById('chart-marketing-publish'),
document.getElementById('viz-marketing-publish-alt'),
'marketing-publish', 'Publish status',
labels.map((l, i) => ({ label: l, value: values[i], raw: values[i] })),
'hm-eq-cyan'
);
},
publishResultSummary(j) {
const res = j.result || {};
if (typeof res === 'string') { try { return JSON.stringify(JSON.parse(res)); } catch (e) { return res; } }
const parts = [];
Object.entries(res).forEach(([k, v]) => {
if (v && typeof v === 'object') parts.push(k + ': ' + (v.status || v.ok || JSON.stringify(v)));
else parts.push(k + ': ' + v);
});
return parts.join(' · ') || '—';
},
async uploadImage(ev) {
const file = ev.target.files && ev.target.files[0];
if (!file) return;
const fd = new FormData();
fd.append('file', file);
try {
const r = await fetch('/api/marketing/upload', { method: 'POST', body: fd }).then(res => res.json());
this.publishForm.image_url = r.url;
if (r.media_id) this.publishForm.media_ids = [r.media_id];
Cockpit.toast('Afbeelding geüpload', 'success');
} catch (e) { Cockpit.toast('Upload mislukt', 'error'); }
ev.target.value = '';
},
async submitPublish() {
this.publishBusy = true;
try {
const body = {
text: this.publishForm.text,
image_url: this.publishForm.image_url || null,
media_ids: this.publishForm.media_ids || [],
channels: this.publishForm.channels,
project_id: this.publishForm.project_id ? parseInt(this.publishForm.project_id, 10) : null,
};
const r = await fetch('/api/marketing/publish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).then(res => res.json());
this.publishJobId = r.job_id;
this.publishJobStatus = r.status || 'queued';
Cockpit.toast('Publicatie gestart (job #' + r.job_id + ')', 'success');
this.pollPublishJob(r.job_id);
await this.loadPublishHistory();
} catch (e) {
Cockpit.toast('Publicatie mislukt', 'error');
} finally { this.publishBusy = false; }
},
pollPublishJob(jobId) {
let attempts = 0;
const tick = async () => {
attempts++;
try {
const r = await fetch('/api/marketing/publish/' + jobId).then(res => res.json());
const job = r.job || {};
this.publishJobStatus = job.status || '';
if (job.status === 'completed' || job.status === 'failed' || attempts > 30) {
await this.loadPublishHistory();
Cockpit.toast('Job #' + jobId + ': ' + job.status, job.status === 'completed' ? 'success' : 'info');
return;
}
} catch (e) { return; }
setTimeout(tick, 2000);
};
setTimeout(tick, 1500);
},
};
}
</script>
{% endblock %}