2026-06-09 00:41:27 +00:00
{% extends "base.html" %}
2026-06-09 10:41:13 +00:00
{% 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 %}
2026-06-09 00:41:27 +00:00
{% 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 >
2026-06-09 10:41:13 +00:00
< a class = "btn btn-sm" href = "/projects" > Projecten →</ a >
2026-06-09 00:41:27 +00:00
</ div >
</ header >
2026-06-09 10:41:13 +00:00
< 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 >
2026-06-09 00:41:27 +00:00
< 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'" >
< button type = "button" class = "vtab-btn" :class = "tab==='live'?'active':''" @ click = "setTab('live')" data-vtab = "live" >< span class = "vtab-icon" > 📡</ span > Live Feed</ button >
< button type = "button" class = "vtab-btn" :class = "tab==='reclame'?'active':''" @ click = "setTab('reclame')" data-vtab = "reclame" >< span class = "vtab-icon" > 📁</ span > Reclame folders</ button >
< button type = "button" class = "vtab-btn" :class = "tab==='publish'?'active':''" @ click = "setTab('publish')" data-vtab = "publish" >< span class = "vtab-icon" > 🚀</ span > Automatisering</ button >
< button type = "button" class = "vtab-btn" :class = "tab==='actie'?'active':''" @ click = "setTab('actie')" data-vtab = "actie" >< span class = "vtab-icon" > ⚡</ span > Actie</ button >
< button type = "button" class = "vtab-btn" :class = "tab==='intel'?'active':''" @ click = "setTab('intel')" data-vtab = "intel" >< span class = "vtab-icon" > 🎯</ span > Intel</ button >
< button type = "button" class = "vtab-btn" :class = "tab==='regelgeving'?'active':''" @ click = "setTab('regelgeving')" data-vtab = "regelgeving" >< span class = "vtab-icon" > ⚖️</ span > Regelgeving</ button >
< button type = "button" class = "vtab-btn" :class = "tab==='bookmarks'?'active':''" @ click = "setTab('bookmarks')" data-vtab = "bookmarks" >< span class = "vtab-icon" > ★</ span > Opgeslagen RSS</ button >
< button type = "button" class = "vtab-btn" :class = "tab==='social'?'active':''" @ click = "setTab('social')" data-vtab = "social" >< span class = "vtab-icon" > 💬</ span > Social</ button >
< button type = "button" class = "vtab-btn" :class = "tab==='studio'?'active':''" @ click = "setTab('studio')" data-vtab = "studio" >< span class = "vtab-icon" > 🎨</ span > Studio</ 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" >
2026-06-09 10:41:13 +00:00
< 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 >
2026-06-09 00:41:27 +00:00
< 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 %}
2026-06-09 10:41:13 +00:00
< script src = "/static/js/dashboard-layout.js?v=2" ></ script >
< script src = "/static/js/page-viz.js?v=1" ></ script >
2026-06-09 00:41:27 +00:00
< 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 },
2026-06-09 10:41:13 +00:00
publishChannels : [], publishForm : { text : '' , image_url : '' , media_ids : [], channels : [], project_id : '' },
2026-06-09 00:41:27 +00:00
publishHistory : [], publishJobId : null , publishJobStatus : '' , publishBusy : false ,
2026-06-09 10:41:13 +00:00
cockpitProjects : [],
2026-06-09 00:41:27 +00:00
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 ();
2026-06-09 10:41:13 +00:00
await this . loadPublishHistory ();
fetch ( '/api/projects?limit=50' ). then ( r => r . json ()). then ( d =>{ this . cockpitProjects = d . items || []; }). catch (()=>{});
2026-06-09 00:41:27 +00:00
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 ());
2026-06-09 10:41:13 +00:00
if ( window . PageViz ) {
window . PageViz . init ( 'marketing' , ( mode ) => this . renderViz ( mode ));
}
2026-06-09 00:41:27 +00:00
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 ();
2026-06-09 10:41:13 +00:00
this . renderViz ( window . __vizMode || 'neo-bars' );
2026-06-09 00:41:27 +00:00
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 || [];
2026-06-09 10:41:13 +00:00
if ( window . __marketingRenderViz ) window . __marketingRenderViz ( window . __vizMode || 'neo-bars' );
2026-06-09 00:41:27 +00:00
} catch ( e ) { this . publishHistory = []; }
},
2026-06-09 10:41:13 +00:00
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'
);
},
2026-06-09 00:41:27 +00:00
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 ,
2026-06-09 10:41:13 +00:00
project_id : this . publishForm . project_id ? parseInt ( this . publishForm . project_id , 10 ) : null ,
2026-06-09 00:41:27 +00:00
};
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 %}