added newsletter

This commit is contained in:
2026-10-16 15:10:11 +02:00
parent 419abac97f
commit cce365225b
8 changed files with 2688 additions and 0 deletions
+998
View File
@@ -0,0 +1,998 @@
<?php
require_once('include/headscript.php');
require_once(__DIR__ . '/class/mailer.php');
require_once(__DIR__ . '/class/NewsletterRenderer.php');
require_once(__DIR__ . '/class/NewsletterWp.php');
/**
* Editor delle newsletter: blocchi + anteprima dal vivo + salvataggio automatico.
* NB: protezione "solo staff" da aggiungere come per le altre pagine admin.
*/
$pdo = DBHandlerSelect::getInstance()->getConnection();
function e($value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
$id = (int) ($_GET['id'] ?? 0);
$st = $pdo->prepare("SELECT iddraft FROM newsletter_drafts WHERE iddraft = ? AND deleted_at IS NULL");
$st->execute([$id]);
if (!$st->fetchColumn()) {
header('Location: newsletter.php');
exit;
}
// Email dell'utente collegato (proposta per l'invio di prova)
$st = $pdo->prepare("SELECT email FROM auth_users WHERE id = ?");
$st->execute([(int) $iduserlogin]);
$myEmail = (string) $st->fetchColumn();
// Valori predefiniti dei blocchi, presi dal PHP (una sola fonte)
$templates = [];
foreach (NewsletterRenderer::BLOCK_TYPES as $t) {
if ($t === 'raw') continue;
$b = NewsletterRenderer::newBlock($t);
unset($b['id']);
$templates[$t] = $b;
}
$jsonFlags = JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE;
?>
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8" />
<title>YogiBook - Editor newsletter</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" href="assets/images/favicon.ico">
<link href="assets/css/bootstrap.min.css" id="bootstrap-style" rel="stylesheet" type="text/css" />
<link href="assets/css/icons.min.css" rel="stylesheet" type="text/css" />
<link href="assets/css/app.min.css" id="app-style" rel="stylesheet" type="text/css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script src="https://cdn.jsdelivr.net/npm/tinymce@6.8.3/tinymce.min.js" referrerpolicy="origin"></script>
<style>
:root { --nl-border: #e6e8ec; --nl-muted: #6b7280; --nl-accent: #1ebf73; }
.nl-top { position: sticky; top: 70px; z-index: 20; background: #fff; border-radius: 14px; box-shadow: 0 4px 20px rgba(0,0,0,.06); padding: 14px 18px; margin-bottom: 16px; }
.nl-top .form-control { border-radius: 8px; }
.nl-status { font-size: 13px; color: var(--nl-muted); white-space: nowrap; }
.nl-status .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 5px; background: #cbd5e1; vertical-align: middle; }
.nl-status.ok .dot { background: var(--nl-accent); }
.nl-status.busy .dot { background: #f0ad4e; }
.nl-status.err .dot { background: #dc3545; }
.nl-counter { font-size: 11px; color: var(--nl-muted); }
.nl-counter.warn { color: #c0392b; font-weight: 600; }
.nl-card { background: #fff; border: 1px solid var(--nl-border); border-radius: 12px; margin-bottom: 10px; overflow: hidden; }
.nl-card.open { border-color: var(--nl-accent); box-shadow: 0 0 0 3px rgba(30,191,115,.12); }
.nl-card-head { display: flex; align-items: center; gap: 10px; padding: 10px 12px; cursor: pointer; user-select: none; }
.nl-card-head .ico { width: 30px; height: 30px; border-radius: 8px; background: #f1f5f9; display: flex; align-items: center; justify-content: center; color: #475569; flex: 0 0 30px; }
.nl-card-head .lbl { font-weight: 600; color: #1f2937; font-size: 14px; }
.nl-card-head .sum { font-size: 12px; color: var(--nl-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.nl-card-head .txt { flex: 1; min-width: 0; }
.nl-card-head .acts { display: flex; gap: 2px; flex: 0 0 auto; }
.nl-card-head .acts button { border: 0; background: transparent; color: #64748b; width: 28px; height: 28px; border-radius: 6px; }
.nl-card-head .acts button:hover { background: #f1f5f9; color: #111827; }
.nl-card-head .acts button.del:hover { background: #fdecec; color: #c0392b; }
.nl-card-body { display: none; padding: 4px 14px 14px; border-top: 1px solid var(--nl-border); }
.nl-card.open .nl-card-body { display: block; }
.nl-field { margin-top: 10px; }
.nl-field label { font-size: 12px; font-weight: 600; color: #374151; margin-bottom: 3px; display: block; }
.nl-field .form-control, .nl-field .form-select { font-size: 14px; border-radius: 8px; }
.nl-color { display: flex; gap: 6px; align-items: center; }
.nl-color input[type=color] { width: 38px; height: 34px; padding: 2px; border: 1px solid var(--nl-border); border-radius: 8px; background: #fff; }
.nl-color .form-control { max-width: 120px; }
.nl-img { display: flex; gap: 10px; align-items: flex-start; }
.nl-img .thumb { width: 84px; height: 64px; border-radius: 8px; border: 1px solid var(--nl-border); background: #f8fafc center/cover no-repeat; flex: 0 0 84px; }
.nl-img .grow { flex: 1; min-width: 0; }
.nl-hint { font-size: 12px; color: var(--nl-muted); margin-top: 6px; }
.nl-row { display: flex; gap: 10px; }
.nl-row > * { flex: 1; }
.nl-add { width: 100%; border: 2px dashed #cbd5e1; background: #fafbfc; color: #475569; border-radius: 12px; padding: 12px; font-weight: 600; }
.nl-add:hover { border-color: var(--nl-accent); color: var(--nl-accent); }
.nl-add-menu { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.nl-add-menu button { border: 1px solid var(--nl-border); background: #fff; border-radius: 10px; padding: 12px 6px; font-size: 13px; color: #374151; }
.nl-add-menu button:hover { border-color: var(--nl-accent); background: #f3fbf7; }
.nl-add-menu i { display: block; font-size: 18px; margin-bottom: 4px; color: #475569; }
.nl-preview-wrap { position: sticky; top: 230px; }
.nl-preview-bar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
.nl-device button { border: 1px solid var(--nl-border); background: #fff; padding: 4px 10px; font-size: 13px; }
.nl-device button.active { background: #1f2937; color: #fff; border-color: #1f2937; }
.nl-device button:first-child { border-radius: 8px 0 0 8px; }
.nl-device button:last-child { border-radius: 0 8px 8px 0; }
.nl-frame-box { background: #e9ecef; border-radius: 14px; padding: 14px; display: flex; justify-content: center; }
#nlFrame { width: 100%; height: calc(100vh - 290px); min-height: 480px; border: 0; background: #fff; border-radius: 6px; transition: width .2s; }
#nlFrame.mobile { width: 390px; }
.nl-kb { font-size: 12px; color: var(--nl-muted); }
.nl-checks { margin-top: 10px; font-size: 13px; }
.nl-checks div { padding: 3px 0; }
.nl-checks .error { color: #b02a37; }
.nl-checks .warning { color: #8a5a1a; }
.nl-banner { border-radius: 10px; padding: 10px 14px; font-size: 14px; display: none; }
.nl-banner.show { display: block; }
.vers-list { max-height: 60vh; overflow-y: auto; }
.vers-item { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 9px 4px; border-bottom: 1px solid #f1f2f4; }
.vers-item small { color: #9ca3af; }
#versFrame { width: 100%; height: 60vh; border: 1px solid var(--nl-border); border-radius: 8px; }
@media (max-width: 991px) {
.nl-preview-wrap { position: static; margin-top: 16px; }
.nl-top { position: static; }
}
</style>
</head>
<body>
<div id="layout-wrapper">
<header id="page-topbar" class="isvertical-topbar">
<div class="navbar-header">
<div class="d-flex">
<?php include('include/logoarea.php'); ?>
<button type="button" class="btn btn-sm px-3 font-size-24 header-item waves-effect vertical-menu-btn">
<i class="bx bx-menu align-middle"></i>
</button>
<div class="page-title-box align-self-center d-none d-md-block">
<h4 class="page-title mb-0">Editor newsletter</h4>
</div>
</div>
<div class="d-flex">
<?php include('include/languageselection.php'); ?>
<?php include('include/profiletopbar.php'); ?>
</div>
</div>
</header>
<?php include('include/sidebar.php'); ?>
<div class="main-content">
<div class="page-content">
<div class="container-fluid">
<!-- Barra in alto: titolo, oggetto, preheader, stato, azioni -->
<div class="nl-top">
<div class="d-flex align-items-center gap-2 flex-wrap mb-2">
<a href="newsletter.php" class="btn btn-sm btn-light" title="Torna all'elenco"><i class="fas fa-arrow-left"></i></a>
<input type="text" class="form-control form-control-sm fw-bold" id="fTitle" style="max-width:280px" placeholder="Nome interno">
<span class="nl-status" id="nlStatus"><span class="dot"></span><span class="t">Caricamento…</span></span>
<span id="nlWp" class="nl-status"></span>
<div class="ms-auto d-flex gap-2 flex-wrap">
<button class="btn btn-sm btn-light" id="btnStyle"><i class="fas fa-palette me-1"></i> Stile</button>
<button class="btn btn-sm btn-light" id="btnVersions"><i class="fas fa-clock-rotate-left me-1"></i> Versioni</button>
<button class="btn btn-sm btn-outline-primary" id="btnTest"><i class="fas fa-paper-plane me-1"></i> Invia prova</button>
<button class="btn btn-sm btn-success" id="btnPublish"><i class="fas fa-upload me-1"></i> Pubblica su The Newsletter</button>
</div>
</div>
<div class="row g-2">
<div class="col-md-6">
<input type="text" class="form-control" id="fSubject" placeholder="Oggetto dell'email" maxlength="255">
<div class="nl-counter" id="cSubject"></div>
</div>
<div class="col-md-6">
<input type="text" class="form-control" id="fPreheader" placeholder="Preheader: la riga di anteprima sotto l'oggetto" maxlength="255">
<div class="nl-counter" id="cPreheader"></div>
</div>
</div>
<!-- Avvisi sempre visibili dentro la barra -->
<div class="nl-banner alert alert-warning mt-2 mb-0" id="bnRestore"></div>
<div class="nl-banner alert alert-danger mt-2 mb-0" id="bnConflict"></div>
</div>
<div class="row">
<div class="col-lg-5">
<div id="nlBlocks"></div>
<button class="nl-add" id="btnAdd"><i class="fas fa-plus me-1"></i> Aggiungi blocco</button>
<p class="nl-hint mt-2">Si salva da solo mentre scrivi. <kbd>Ctrl</kbd>+<kbd>S</kbd> salva subito. Nel testo usa <code>{name}</code> per il nome di chi riceve.</p>
</div>
<div class="col-lg-7">
<div class="nl-preview-wrap">
<div class="nl-preview-bar">
<div class="nl-device">
<button class="active" data-dev="desktop"><i class="fas fa-desktop"></i> Computer</button><button data-dev="mobile"><i class="fas fa-mobile-screen"></i> Telefono</button>
</div>
<span class="nl-kb" id="nlKb"></span>
</div>
<div class="nl-frame-box"><iframe id="nlFrame" title="Anteprima newsletter"></iframe></div>
<div class="nl-checks" id="nlChecks"></div>
</div>
</div>
</div>
</div>
<?php include('include/footer.php'); ?>
</div>
</div>
</div>
<!-- Versioni -->
<div class="modal fade" id="mVersions" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Versioni salvate</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row">
<div class="col-md-5">
<div class="d-flex gap-2 mb-2">
<input type="text" class="form-control form-control-sm" id="versNote" placeholder="Nome della versione (es. prima della revisione)">
<button class="btn btn-sm btn-primary text-nowrap" id="btnSnapshot">Salva versione</button>
</div>
<div class="vers-list" id="versList"></div>
</div>
<div class="col-md-7">
<iframe id="versFrame" title="Anteprima versione"></iframe>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Stile -->
<div class="modal fade" id="mStyle" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Stile della newsletter</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body" id="styleBody"></div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">Fatto</button>
</div>
</div>
</div>
</div>
<script src="assets/libs/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="assets/libs/metismenujs/metismenujs.min.js"></script>
<script src="assets/libs/simplebar/simplebar.min.js"></script>
<script src="assets/libs/eva-icons/eva.min.js"></script>
<script src="assets/js/app.js"></script>
<script>
(function() {
'use strict';
const API = 'newsletter_api.php';
const DRAFT_ID = <?php echo $id; ?>;
const MY_EMAIL = <?php echo json_encode($myEmail, $jsonFlags); ?>;
const TEMPLATES = <?php echo json_encode($templates, $jsonFlags); ?>;
const LS_KEY = 'yb_nl_draft_' + DRAFT_ID;
const TYPES = {
header: { label: 'Logo', icon: 'fa-crown' },
heading: { label: 'Titolo', icon: 'fa-heading' },
text: { label: 'Testo', icon: 'fa-align-left' },
image: { label: 'Immagine', icon: 'fa-image' },
button: { label: 'Pulsante', icon: 'fa-hand-pointer' },
columns: { label: 'Immagine + testo', icon: 'fa-table-columns' },
separator: { label: 'Separatore', icon: 'fa-minus' },
social: { label: 'Social', icon: 'fa-share-nodes' },
footer: { label: 'Piè di pagina', icon: 'fa-shoe-prints' },
raw: { label: 'Blocco WordPress', icon: 'fa-lock' }
};
const PAD = [
{ k: 'pad_top', label: 'Spazio sopra', type: 'number', min: 0, max: 120 },
{ k: 'pad_bottom', label: 'Spazio sotto', type: 'number', min: 0, max: 120 }
];
const FIELDS = {
header: [
{ k: 'logo_url', label: 'Logo', type: 'image' },
{ row: [{ k: 'logo_width', label: 'Larghezza (px)', type: 'number', min: 20, max: 600 }, { k: 'link', label: 'Link al clic', type: 'url' }] },
{ row: PAD }
],
heading: [
{ k: 'text', label: 'Titolo', type: 'textarea', rows: 2 },
{ row: [{ k: 'align', label: 'Allineamento', type: 'select', options: [['left', 'Sinistra'], ['center', 'Centro'], ['right', 'Destra']] }, { k: 'font_size', label: 'Dimensione (vuoto = stile)', type: 'number', min: 14, max: 60 }] },
{ row: [{ k: 'color', label: 'Colore', type: 'color' }, { k: 'background', label: 'Sfondo blocco', type: 'color' }] },
{ row: PAD }
],
text: [
{ k: 'html', type: 'rich' },
{ row: [{ k: 'background', label: 'Sfondo', type: 'color' }, { k: 'background_2', label: 'Sfumatura verso', type: 'color' }] },
{ row: PAD }
],
image: [
{ k: 'url', label: 'Immagine', type: 'image' },
{ k: 'alt', label: 'Testo alternativo (se le immagini sono bloccate)', type: 'text' },
{ row: [{ k: 'link', label: 'Link al clic', type: 'url' }, { k: 'width', label: 'Larghezza (max 600)', type: 'number', min: 50, max: 600 }] },
{ row: PAD }
],
button: [
{ k: 'label', label: 'Testo del pulsante', type: 'text', hint: 'Per andare a capo scrivi <br>' },
{ k: 'url', label: 'Link', type: 'url' },
{ row: [{ k: 'button_bg', label: 'Colore pulsante', type: 'color' }, { k: 'color', label: 'Colore testo', type: 'color' }] },
{ row: [{ k: 'font_size', label: 'Testo (px)', type: 'number', min: 10, max: 60 }, { k: 'width', label: 'Larghezza (px)', type: 'number', min: 80, max: 600 }, { k: 'radius', label: 'Angoli', type: 'number', min: 0, max: 50 }] },
{ row: PAD }
],
columns: [
{ k: 'image_url', label: 'Immagine', type: 'image' },
{ row: [{ k: 'image_alt', label: 'Testo alternativo', type: 'text' }, { k: 'link', label: 'Link immagine', type: 'url' }] },
{ k: 'image_side', label: 'Posizione immagine', type: 'select', options: [['left', 'A sinistra'], ['right', 'A destra']] },
{ k: 'html', type: 'rich' },
{ row: PAD }
],
separator: [
{ row: [{ k: 'color', label: 'Colore', type: 'color' }, { k: 'height', label: 'Spessore', type: 'number', min: 1, max: 10 }] },
{ row: PAD }
],
social: [
{ k: 'icon_size', label: 'Dimensione icone', type: 'number', min: 16, max: 64 },
{ note: 'I link dei social si cambiano da <strong>Stile</strong>.' },
{ row: PAD }
],
footer: [
{ k: 'profile_label', label: 'Testo "gestisci iscrizione"', type: 'text' },
{ k: 'view_label', label: 'Testo "vedi online"', type: 'text' },
{ k: 'unsub_label', label: 'Testo "disiscriviti" (vuoto = non mostrarlo)', type: 'text' },
{ row: PAD }
],
raw: [
{ note: 'Blocco importato da WordPress che qui non si può modificare: viene pubblicato identico. Puoi spostarlo o eliminarlo.' }
]
};
const STYLE_FIELDS = [
{ row: [{ k: 'background', label: 'Sfondo esterno', type: 'color' }, { k: 'block_background', label: 'Sfondo contenuto', type: 'color' }] },
{ row: [{ k: 'font_family', label: 'Carattere', type: 'select', options: [
['Verdana, Geneva, sans-serif', 'Verdana'], ['Helvetica, Arial, sans-serif', 'Helvetica / Arial'],
['Georgia, serif', 'Georgia'], ["'Trebuchet MS', Tahoma, sans-serif", 'Trebuchet'], ["Tahoma, Geneva, sans-serif", 'Tahoma']] },
{ k: 'font_size', label: 'Dimensione testo', type: 'number', min: 12, max: 22 }] },
{ row: [{ k: 'text_color', label: 'Colore testo', type: 'color' }, { k: 'link_color', label: 'Colore link', type: 'color' }] },
{ row: [{ k: 'title_font_size', label: 'Dimensione titoli', type: 'number', min: 16, max: 60 }, { k: 'title_color', label: 'Colore titoli', type: 'color' }] },
{ row: [{ k: 'button_background', label: 'Colore pulsanti', type: 'color' }, { k: 'button_color', label: 'Testo pulsanti', type: 'color' }] },
{ heading: 'Link social' },
{ row: [{ k: 'social.facebook', label: 'Facebook', type: 'url' }, { k: 'social.instagram', label: 'Instagram', type: 'url' }] },
{ row: [{ k: 'social.youtube', label: 'YouTube', type: 'url' }, { k: 'social.whatsapp', label: 'WhatsApp (https://wa.me/39…)', type: 'url' }] }
];
/* ================= Stato ================= */
let D = null; // bozza corrente
let openId = null; // blocco aperto
let editSeq = 0; // contatore modifiche
let savedSeq = 0; // ultima modifica salvata sul server
let saving = false;
let saveTimer = null, firstDirtyAt = 0, retryDelay = 3000;
let conflict = false;
let previewTimer = null, previewReq = 0;
let lastChecks = [];
const $ = (s, el = document) => el.querySelector(s);
const $$ = (s, el = document) => Array.from(el.querySelectorAll(s));
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const uid = () => Math.random().toString(16).slice(2, 14);
const isDirty = () => editSeq !== savedSeq;
const lsGet = () => { try { return JSON.parse(localStorage.getItem(LS_KEY) || 'null'); } catch (e) { return null; } };
const lsSet = v => { try { localStorage.setItem(LS_KEY, JSON.stringify(v)); } catch (e) { } };
const lsDel = () => { try { localStorage.removeItem(LS_KEY); } catch (e) { } };
function content() {
return { title: D.title, subject: D.subject, preheader: D.preheader, blocks: D.blocks, settings: D.settings };
}
async function apiPost(action, data) {
const body = new FormData();
body.append('action', action);
Object.entries(data || {}).forEach(([k, v]) => body.append(k, v));
const r = await fetch(API, { method: 'POST', body, credentials: 'same-origin' });
let j = null;
try { j = await r.json(); } catch (e) { }
if (!j) throw Object.assign(new Error('Il server non risponde correttamente.'), { network: true });
j.httpStatus = r.status;
return j;
}
async function apiGet(params) {
const r = await fetch(API + '?' + new URLSearchParams(params), { credentials: 'same-origin' });
return r.json();
}
/* ================= Stato di salvataggio ================= */
function setStatus(kind, text) {
const el = $('#nlStatus');
el.className = 'nl-status ' + (kind || '');
$('.t', el).textContent = text;
}
const hhmm = d => (d ? new Date(d.replace(' ', 'T')) : new Date()).toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' });
function changed(opts = {}) {
editSeq++;
lsSet({ seq: editSeq, base: D.revision, at: Date.now(), data: content() });
if (!conflict) setStatus('busy', 'Modifiche non salvate…');
if (!firstDirtyAt) firstDirtyAt = Date.now();
clearTimeout(saveTimer);
// salva 1,5 s dopo l'ultima modifica, ma almeno ogni 10 s mentre scrivi
saveTimer = setTimeout(save, Date.now() - firstDirtyAt > 10000 ? 0 : 1500);
if (opts.preview !== false) schedulePreview();
if (opts.summary !== false) refreshSummaries();
}
async function save(force = false) {
if (conflict && !force) return;
if (saving || !isDirty()) return;
saving = true;
clearTimeout(saveTimer);
const seq = editSeq;
let retryIn = 0;
setStatus('busy', 'Salvataggio…');
const c = content();
try {
const j = await apiPost('save', {
id: DRAFT_ID, revision: D.revision, force: force ? '1' : '',
title: c.title, subject: c.subject, preheader: c.preheader,
blocks: JSON.stringify(c.blocks), settings: JSON.stringify(c.settings)
});
if (j.success) {
D.revision = j.revision;
savedSeq = seq;
retryDelay = 3000;
if (conflict) { conflict = false; $('#bnConflict').classList.remove('show'); layout(); }
if (!isDirty()) { lsDel(); firstDirtyAt = 0; setStatus('ok', 'Salvato alle ' + hhmm(j.updated_at)); }
else retryIn = 800; // altre modifiche arrivate durante il salvataggio
} else if (j.conflict) {
showConflict(j);
} else {
setStatus('err', (j.message || 'Salvataggio non riuscito') + ' – le modifiche restano in questo browser, riprovo…');
retryIn = retryDelay;
retryDelay = Math.min(retryDelay * 2, 60000);
}
} catch (e) {
setStatus('err', 'Senza connessione: le modifiche sono al sicuro in questo browser, riprovo…');
retryIn = retryDelay;
retryDelay = Math.min(retryDelay * 2, 60000);
} finally {
saving = false;
if (retryIn && !conflict) { clearTimeout(saveTimer); saveTimer = setTimeout(save, retryIn); }
}
}
async function flush() {
clearTimeout(saveTimer);
for (let i = 0; i < 20 && (saving || isDirty()) && !conflict; i++) {
if (!saving) await save();
if (saving || isDirty()) await new Promise(r => setTimeout(r, 300));
}
if (conflict) throw new Error('Prima risolvi il conflitto di salvataggio (vedi il riquadro rosso).');
if (isDirty()) throw new Error('Non riesco a salvare: controlla la connessione e riprova.');
}
function showConflict(j) {
conflict = true;
setStatus('err', 'Conflitto di salvataggio');
const b = $('#bnConflict');
b.innerHTML = '<strong>Attenzione:</strong> questa newsletter è stata salvata da un\'altra parte alle ' + esc(hhmm(j.updated_at)) +
' (un\'altra scheda o un altro computer). Le tue modifiche sono al sicuro in questo browser. ' +
'<div class="mt-2 d-flex gap-2"><button class="btn btn-sm btn-light" id="cfReload">Carica quella salvata (perdo le mie)</button>' +
'<button class="btn btn-sm btn-danger" id="cfForce">Tieni le mie e sovrascrivi</button></div>';
b.classList.add('show');
layout();
$('#cfReload').onclick = () => { lsDel(); location.reload(); };
$('#cfForce').onclick = () => save(true);
}
window.addEventListener('beforeunload', e => {
if (isDirty() || saving) { e.preventDefault(); e.returnValue = ''; }
});
document.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') { e.preventDefault(); save(); }
});
/* ================= Anteprima ================= */
function schedulePreview() {
clearTimeout(previewTimer);
previewTimer = setTimeout(renderPreview, 600);
}
async function renderPreview() {
const req = ++previewReq;
const c = content();
try {
const j = await apiPost('preview', {
subject: c.subject, preheader: c.preheader,
blocks: JSON.stringify(c.blocks), settings: JSON.stringify(c.settings)
});
if (req !== previewReq || !j.success) return;
const f = $('#nlFrame');
let y = 0;
try { y = f.contentWindow.scrollY; } catch (e) { }
f.onload = () => { try { f.contentWindow.scrollTo(0, y); } catch (e) { } };
f.srcdoc = j.html;
$('#nlKb').textContent = j.kb + ' KB' + (j.kb > 100 ? ' – oltre il limite di Gmail!' : '');
lastChecks = j.checks || [];
$('#nlChecks').innerHTML = lastChecks.map(c =>
'<div class="' + c[0] + '"><i class="fas ' + (c[0] === 'error' ? 'fa-circle-xmark' : 'fa-triangle-exclamation') + ' me-1"></i>' + esc(c[1]) + '</div>').join('');
} catch (e) { /* l'anteprima riprova alla prossima modifica */ }
}
$$('.nl-device button').forEach(b => b.addEventListener('click', () => {
$$('.nl-device button').forEach(x => x.classList.toggle('active', x === b));
$('#nlFrame').classList.toggle('mobile', b.dataset.dev === 'mobile');
}));
/* ================= Campi ================= */
function getVal(obj, k) {
return k.split('.').reduce((o, p) => (o == null ? undefined : o[p]), obj);
}
function setVal(obj, k, v) {
const parts = k.split('.');
let o = obj;
parts.slice(0, -1).forEach(p => { if (typeof o[p] !== 'object' || o[p] === null) o[p] = {}; o = o[p]; });
o[parts[parts.length - 1]] = v;
}
function fieldHtml(f, obj) {
if (f.row) return '<div class="nl-row">' + f.row.map(x => fieldHtml(x, obj)).join('') + '</div>';
if (f.note) return '<div class="nl-hint">' + f.note + '</div>';
if (f.heading) return '<div class="nl-field"><strong style="font-size:13px">' + esc(f.heading) + '</strong></div>';
const v = getVal(obj, f.k);
const val = v == null ? '' : v;
const lab = f.label ? '<label>' + esc(f.label) + '</label>' : '';
const hint = f.hint ? '<div class="nl-hint">' + esc(f.hint) + '</div>' : '';
const data = 'data-k="' + esc(f.k) + '" data-t="' + f.type + '"';
switch (f.type) {
case 'textarea':
return '<div class="nl-field">' + lab + '<textarea class="form-control" rows="' + (f.rows || 3) + '" ' + data + '>' + esc(val) + '</textarea>' + hint + '</div>';
case 'number':
return '<div class="nl-field">' + lab + '<input type="number" class="form-control" ' + data + ' value="' + esc(val) + '"' +
(f.min != null ? ' min="' + f.min + '"' : '') + (f.max != null ? ' max="' + f.max + '"' : '') + '>' + hint + '</div>';
case 'select':
return '<div class="nl-field">' + lab + '<select class="form-select" ' + data + '>' +
f.options.map(o => '<option value="' + esc(o[0]) + '"' + (String(val) === o[0] ? ' selected' : '') + '>' + esc(o[1]) + '</option>').join('') + '</select>' + hint + '</div>';
case 'color':
return '<div class="nl-field">' + lab + '<div class="nl-color">' +
'<input type="color" value="' + (/^#[0-9a-f]{6}$/i.test(val) ? val : '#ffffff') + '" data-pick="' + esc(f.k) + '">' +
'<input type="text" class="form-control form-control-sm" placeholder="predefinito" ' + data + ' value="' + esc(val) + '">' +
'<button type="button" class="btn btn-sm btn-light" data-clear="' + esc(f.k) + '" title="Usa il colore predefinito"><i class="fas fa-rotate-left"></i></button>' +
'</div></div>';
case 'image':
return '<div class="nl-field">' + lab + '<div class="nl-img"><div class="thumb" style="background-image:url(\'' + esc(val) + '\')"></div><div class="grow">' +
'<input type="url" class="form-control form-control-sm mb-1" placeholder="https://…" ' + data + ' value="' + esc(val) + '">' +
'<label class="btn btn-sm btn-outline-primary mb-0"><i class="fas fa-upload me-1"></i> Carica dal computer' +
'<input type="file" accept="image/*" hidden data-upload="' + esc(f.k) + '"></label>' +
'<div class="nl-hint" data-upinfo>Le foto vengono ridotte e compresse automaticamente.</div></div></div></div>';
case 'rich':
return '<div class="nl-field"><textarea class="nl-rich" ' + data + '>' + esc(val) + '</textarea></div>';
default:
return '<div class="nl-field">' + lab + '<input type="' + (f.type === 'url' ? 'url' : 'text') + '" class="form-control" ' + data + ' value="' + esc(val) + '"' + (f.type === 'url' ? ' placeholder="https://…"' : '') + '>' + hint + '</div>';
}
}
/** Collega i campi di un contenitore all'oggetto (blocco o impostazioni). */
function bindFields(root, obj, onChange) {
$$('[data-k]', root).forEach(inp => {
if (inp.dataset.t === 'rich') return;
const ev = (inp.tagName === 'SELECT' || inp.type === 'number') ? 'change' : 'input';
inp.addEventListener(ev, () => {
let v = inp.value;
if (inp.dataset.t === 'number') v = v === '' ? '' : Number(v);
setVal(obj, inp.dataset.k, v);
if (inp.dataset.t === 'color') {
const p = $('[data-pick="' + inp.dataset.k + '"]', root);
if (p && /^#[0-9a-f]{6}$/i.test(v)) p.value = v;
}
if (inp.dataset.t === 'image') {
const th = inp.closest('.nl-img').querySelector('.thumb');
th.style.backgroundImage = "url('" + v.replace(/'/g, '%27') + "')";
}
onChange();
});
});
$$('[data-pick]', root).forEach(p => p.addEventListener('input', () => {
const t = $('[data-k="' + p.dataset.pick + '"]', root);
t.value = p.value;
t.dispatchEvent(new Event('input'));
}));
$$('[data-clear]', root).forEach(b => b.addEventListener('click', () => {
const t = $('[data-k="' + b.dataset.clear + '"]', root);
t.value = '';
t.dispatchEvent(new Event('input'));
}));
$$('[data-upload]', root).forEach(f => f.addEventListener('change', () => uploadImage(f, root)));
}
async function uploadImage(fileInput, root) {
const file = fileInput.files[0];
if (!file) return;
const info = fileInput.closest('.grow').querySelector('[data-upinfo]');
info.textContent = 'Caricamento…';
const body = new FormData();
body.append('action', 'upload_image');
body.append('image', file);
try {
const r = await fetch(API, { method: 'POST', body, credentials: 'same-origin' });
const j = await r.json();
if (!j.success) throw new Error(j.message);
const t = $('[data-k="' + fileInput.dataset.upload + '"]', root);
t.value = j.url;
t.dispatchEvent(new Event('input'));
info.textContent = 'Caricata: ' + j.width + '×' + j.height + ' px, ' + j.kb + ' KB';
} catch (e) {
info.textContent = '';
Swal.fire('Immagine non caricata', e.message || 'Errore di rete.', 'error');
}
fileInput.value = '';
}
/* ================= Editor di testo (TinyMCE) ================= */
function initRich(card, block) {
$$('.nl-rich', card).forEach(ta => {
const key = ta.dataset.k;
if (typeof tinymce === 'undefined') {
// Se l'editor ricco non si carica, resta un campo HTML semplice
ta.classList.add('form-control');
ta.rows = 10;
ta.addEventListener('input', () => { block[key] = ta.value; changed(); });
return;
}
tinymce.init({
target: ta,
menubar: false,
branding: false,
promotion: false,
statusbar: false,
plugins: 'link lists autoresize',
toolbar: 'blocks | bold italic underline | forecolor | alignleft aligncenter alignright | bullist numlist | link | nome | removeformat',
block_formats: 'Paragrafo=p; Titolo grande=h1; Titolo=h2; Sottotitolo=h3',
autoresize_bottom_margin: 10,
min_height: 180,
max_height: 600,
convert_urls: false,
relative_urls: false,
entity_encoding: 'raw',
paste_data_images: false,
invalid_elements: 'script,iframe,object,embed,style',
link_default_target: '_blank',
link_title: false,
content_style: 'body{font-family:' + D.settings.font_family + ';font-size:' + D.settings.font_size + 'px;color:' + D.settings.text_color + ';line-height:1.5;margin:10px} p{margin:0 0 15px}',
setup: ed => {
ed.ui.registry.addButton('nome', {
text: '{name}',
tooltip: 'Inserisci il nome di chi riceve',
onAction: () => ed.insertContent('{name}')
});
const sync = () => {
const html = ed.getContent();
if (html !== block[key]) { block[key] = html; changed(); }
};
ed.on('input change undo redo keyup ExecCommand', sync);
ed.on('PastePostProcess', () => setTimeout(sync, 0));
}
});
});
}
function destroyRich() {
if (typeof tinymce !== 'undefined') tinymce.remove();
}
/* ================= Blocchi ================= */
function summary(b) {
const strip = h => String(h || '').replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim();
switch (b.type) {
case 'heading': return b.text;
case 'text': case 'columns': return strip(b.html);
case 'image': return b.url ? (b.alt || b.url.split('/').pop()) : 'Nessuna immagine';
case 'button': return strip(b.label) + (b.url ? ' → ' + b.url : '');
case 'raw': return 'tipo: ' + (b.wp_id || '?');
default: return '';
}
}
function refreshSummaries() {
D.blocks.forEach(b => {
const el = $('.nl-card[data-id="' + b.id + '"] .sum');
if (el) el.textContent = summary(b);
});
}
function renderBlocks() {
destroyRich();
const box = $('#nlBlocks');
box.innerHTML = D.blocks.map((b, i) => {
const t = TYPES[b.type] || { label: b.type, icon: 'fa-square' };
return '<div class="nl-card' + (b.id === openId ? ' open' : '') + '" data-id="' + esc(b.id) + '">' +
'<div class="nl-card-head"><div class="ico"><i class="fas ' + t.icon + '"></i></div>' +
'<div class="txt"><div class="lbl">' + esc(t.label) + '</div><div class="sum">' + esc(summary(b)) + '</div></div>' +
'<div class="acts">' +
'<button data-act="up" title="Sposta su"' + (i === 0 ? ' disabled' : '') + '><i class="fas fa-arrow-up"></i></button>' +
'<button data-act="down" title="Sposta giù"' + (i === D.blocks.length - 1 ? ' disabled' : '') + '><i class="fas fa-arrow-down"></i></button>' +
'<button data-act="add" title="Aggiungi un blocco sotto"><i class="fas fa-plus"></i></button>' +
'<button data-act="dup" title="Duplica"><i class="fas fa-copy"></i></button>' +
'<button data-act="del" class="del" title="Elimina"><i class="fas fa-trash"></i></button>' +
'</div></div>' +
'<div class="nl-card-body">' + (b.id === openId ? (FIELDS[b.type] || []).map(f => fieldHtml(f, b)).join('') : '') + '</div>' +
'</div>';
}).join('');
$$('.nl-card', box).forEach(card => {
const b = D.blocks.find(x => x.id === card.dataset.id);
$('.nl-card-head', card).addEventListener('click', ev => {
const act = ev.target.closest('[data-act]');
if (act) { ev.stopPropagation(); blockAction(act.dataset.act, b.id); return; }
openId = openId === b.id ? null : b.id;
renderBlocks();
if (openId) card.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
});
if (b.id === openId) {
bindFields($('.nl-card-body', card), b, () => changed());
initRich(card, b);
}
});
}
function blockAction(act, id) {
const i = D.blocks.findIndex(x => x.id === id);
if (i < 0) return;
if (act === 'up' && i > 0) { [D.blocks[i - 1], D.blocks[i]] = [D.blocks[i], D.blocks[i - 1]]; }
else if (act === 'down' && i < D.blocks.length - 1) { [D.blocks[i + 1], D.blocks[i]] = [D.blocks[i], D.blocks[i + 1]]; }
else if (act === 'dup') { const c = JSON.parse(JSON.stringify(D.blocks[i])); c.id = uid(); D.blocks.splice(i + 1, 0, c); openId = c.id; }
else if (act === 'add') { return chooseBlock(i + 1); }
else if (act === 'del') {
return Swal.fire({
title: 'Eliminare il blocco?', text: (TYPES[D.blocks[i].type] || {}).label || '', icon: 'warning',
showCancelButton: true, confirmButtonColor: '#d33', confirmButtonText: 'Elimina', cancelButtonText: 'Annulla'
}).then(r => {
if (!r.isConfirmed) return;
D.blocks.splice(i, 1);
renderBlocks();
changed();
});
}
renderBlocks();
changed();
}
function chooseBlock(at) {
const types = Object.keys(TEMPLATES);
Swal.fire({
title: 'Aggiungi un blocco',
html: '<div class="nl-add-menu">' + types.map(t =>
'<button data-type="' + t + '"><i class="fas ' + TYPES[t].icon + '"></i>' + esc(TYPES[t].label) + '</button>').join('') + '</div>',
showConfirmButton: false,
showCloseButton: true,
didOpen: el => $$('[data-type]', el).forEach(btn => btn.addEventListener('click', () => {
const b = JSON.parse(JSON.stringify(TEMPLATES[btn.dataset.type]));
b.id = uid();
D.blocks.splice(at, 0, b);
openId = b.id;
Swal.close();
renderBlocks();
changed();
const c = $('.nl-card[data-id="' + b.id + '"]');
if (c) c.scrollIntoView({ block: 'center', behavior: 'smooth' });
}))
});
}
$('#btnAdd').addEventListener('click', () => chooseBlock(D.blocks.length));
/* ================= Campi in alto ================= */
function counter(el, out, max, label) {
const n = el.value.trim().length;
out.textContent = n ? n + ' caratteri' + (n > max ? ' – ' + label : '') : '';
out.classList.toggle('warn', n > max);
}
function bindTop() {
const f = { fTitle: 'title', fSubject: 'subject', fPreheader: 'preheader' };
Object.entries(f).forEach(([elId, key]) => {
const el = $('#' + elId);
el.value = D[key] || '';
el.addEventListener('input', () => {
D[key] = el.value;
if (key === 'subject') counter(el, $('#cSubject'), 60, 'sul telefono si vede solo l\'inizio');
if (key === 'preheader') counter(el, $('#cPreheader'), 110, 'troppo lungo');
changed({ summary: false, preview: key !== 'title' });
});
});
counter($('#fSubject'), $('#cSubject'), 60, 'sul telefono si vede solo l\'inizio');
counter($('#fPreheader'), $('#cPreheader'), 110, 'troppo lungo');
}
function renderWpBadge() {
const el = $('#nlWp');
const btn = $('#btnPublish');
const labels = { new: 'bozza da inviare', sending: 'in invio', paused: 'invio in pausa', sent: 'già inviata', error: 'errore di invio', deleted: 'eliminata in WordPress' };
if (!D.wp_email_id) { el.innerHTML = ''; btn.innerHTML = '<i class="fas fa-upload me-1"></i> Pubblica su The Newsletter'; btn.disabled = false; return; }
el.innerHTML = '<i class="fas fa-plug me-1"></i>The Newsletter n. ' + D.wp_email_id + (D.wp_status ? ' · ' + esc(labels[D.wp_status] || D.wp_status) : '');
const locked = ['sending', 'paused', 'sent'].includes(D.wp_status);
btn.disabled = locked;
btn.title = locked ? 'Già partita in WordPress: duplica la newsletter per farne una nuova.' : '';
btn.innerHTML = '<i class="fas fa-upload me-1"></i> ' + (locked ? 'Già inviata' : 'Aggiorna su The Newsletter');
}
/* ================= Stile ================= */
$('#btnStyle').addEventListener('click', () => {
const body = $('#styleBody');
body.innerHTML = STYLE_FIELDS.map(f => fieldHtml(f, D.settings)).join('');
bindFields(body, D.settings, () => changed({ summary: false }));
bootstrap.Modal.getOrCreateInstance($('#mStyle')).show();
});
$('#mStyle').addEventListener('hidden.bs.modal', () => { if (openId) renderBlocks(); });
/* ================= Versioni ================= */
async function loadVersions() {
const list = $('#versList');
list.innerHTML = '<div class="text-muted p-2">Caricamento…</div>';
const j = await apiGet({ action: 'versions', id: DRAFT_ID });
if (!j.success) { list.innerHTML = '<div class="text-danger p-2">' + esc(j.message) + '</div>'; return; }
if (!j.versions.length) { list.innerHTML = '<div class="text-muted p-2">Nessuna versione ancora: se ne crea una ogni 10 minuti di lavoro.</div>'; return; }
list.innerHTML = j.versions.map(v =>
'<div class="vers-item"><div><strong>' + esc(v.note) + '</strong><br><small>' +
esc(new Date(v.created_at.replace(' ', 'T')).toLocaleString('it-IT', { dateStyle: 'short', timeStyle: 'short' })) +
(v.author ? ' · ' + esc(v.author) : '') + '</small></div>' +
'<div class="text-nowrap"><button class="btn btn-sm btn-light" data-see="' + v.idversion + '">Vedi</button> ' +
'<button class="btn btn-sm btn-outline-primary" data-restore="' + v.idversion + '">Ripristina</button></div></div>').join('');
$$('[data-see]', list).forEach(b => b.addEventListener('click', () => {
$('#versFrame').src = API + '?action=version_html&idversion=' + b.dataset.see;
}));
$$('[data-restore]', list).forEach(b => b.addEventListener('click', () => restoreVersion(b.dataset.restore)));
}
async function restoreVersion(idv) {
const r = await Swal.fire({
title: 'Ripristinare questa versione?',
text: 'La versione attuale viene salvata nello storico prima del ripristino, quindi puoi sempre tornare indietro.',
icon: 'question', showCancelButton: true, confirmButtonText: 'Ripristina', cancelButtonText: 'Annulla'
});
if (!r.isConfirmed) return;
try {
await flush();
const j = await apiPost('restore', { id: DRAFT_ID, idversion: idv });
if (!j.success) throw new Error(j.message);
applyDraft(j.draft);
bootstrap.Modal.getOrCreateInstance($('#mVersions')).hide();
Swal.fire({ icon: 'success', title: 'Versione ripristinata', timer: 1500, showConfirmButton: false });
} catch (e) { Swal.fire('Errore', e.message, 'error'); }
}
$('#btnVersions').addEventListener('click', async () => {
$('#versFrame').removeAttribute('src');
bootstrap.Modal.getOrCreateInstance($('#mVersions')).show();
try { await flush(); } catch (e) { }
loadVersions();
});
$('#btnSnapshot').addEventListener('click', async () => {
try {
await flush();
const j = await apiPost('snapshot', { id: DRAFT_ID, note: $('#versNote').value });
if (!j.success) throw new Error(j.message);
$('#versNote').value = '';
loadVersions();
} catch (e) { Swal.fire('Errore', e.message, 'error'); }
});
/* ================= Prova e pubblicazione ================= */
$('#btnTest').addEventListener('click', async () => {
let last = MY_EMAIL;
try { last = localStorage.getItem('yb_nl_test_to') || MY_EMAIL; } catch (e) { }
const r = await Swal.fire({
title: 'Invia una prova',
input: 'email',
inputLabel: 'A quale indirizzo?',
inputValue: last,
showCancelButton: true,
confirmButtonText: 'Invia',
cancelButtonText: 'Annulla',
showLoaderOnConfirm: true,
preConfirm: async to => {
try {
await flush();
const j = await apiPost('send_test', { id: DRAFT_ID, to });
if (!j.success) throw new Error(j.message);
try { localStorage.setItem('yb_nl_test_to', to); } catch (e) { }
return j;
} catch (e) { Swal.showValidationMessage(e.message); }
}
});
if (r.isConfirmed && r.value) Swal.fire({ icon: 'success', title: 'Prova inviata', text: r.value.message });
});
async function publish(confirmed) {
try {
await flush();
Swal.fire({ title: 'Pubblico su The Newsletter…', allowOutsideClick: false, didOpen: () => Swal.showLoading() });
const j = await apiPost('publish', { id: DRAFT_ID, confirm: confirmed ? '1' : '' });
if (j.needs_confirm) {
const html = '<div style="text-align:left">' + j.checks.map(c =>
'<div style="padding:4px 0;color:' + (c[0] === 'error' ? '#b02a37' : '#8a5a1a') + '"><i class="fas ' +
(c[0] === 'error' ? 'fa-circle-xmark' : 'fa-triangle-exclamation') + ' me-1"></i>' + esc(c[1]) + '</div>').join('') + '</div>';
const r = await Swal.fire({
title: 'Controlla prima di pubblicare', html, icon: 'warning',
showCancelButton: true, confirmButtonText: 'Pubblica comunque', cancelButtonText: 'Torno a sistemare'
});
if (r.isConfirmed) return publish(true);
return;
}
if (!j.success) throw new Error(j.message);
D.wp_email_id = j.wp_id;
D.wp_status = 'new';
renderWpBadge();
Swal.fire({
icon: 'success',
title: j.created ? 'Creata in The Newsletter' : 'Aggiornata in The Newsletter',
html: esc(j.message) + '<br><br>Ora apri WordPress, scegli le liste e invia come sempre.' +
'<br><a class="btn btn-primary mt-3" href="' + esc(j.admin_url) + '" target="_blank" rel="noopener">Apri The Newsletter</a>',
showConfirmButton: false, showCloseButton: true
});
} catch (e) { Swal.fire('Non pubblicata', e.message, 'error'); }
}
$('#btnPublish').addEventListener('click', () => publish(false));
/* ================= Impaginazione: l'anteprima resta sotto la barra in alto ================= */
function layout() {
const top = $('.nl-top');
const wrap = $('.nl-preview-wrap');
if (window.innerWidth < 992) { wrap.style.top = ''; $('#nlFrame').style.height = ''; return; }
const offset = parseFloat(getComputedStyle(top).top) || 70;
const t = offset + top.getBoundingClientRect().height + 12;
wrap.style.top = t + 'px';
$('#nlFrame').style.height = Math.max(420, window.innerHeight - t - 110) + 'px';
}
window.addEventListener('resize', layout);
/* ================= Avvio ================= */
function applyDraft(d) {
D = d;
if (!D.settings) D.settings = {};
if (!D.settings.social) D.settings.social = {};
editSeq = savedSeq = 0;
lsDel();
['fTitle', 'fSubject', 'fPreheader'].forEach(i => { const el = $('#' + i); el.value = D[{ fTitle: 'title', fSubject: 'subject', fPreheader: 'preheader' }[i]] || ''; });
renderBlocks();
renderPreview();
renderWpBadge();
setStatus('ok', 'Salvato alle ' + hhmm(D.updated_at));
}
async function start() {
const j = await apiGet({ action: 'load', id: DRAFT_ID });
if (!j.success) { setStatus('err', j.message || 'Impossibile aprire la newsletter'); return; }
D = j.draft;
if (!D.settings.social) D.settings.social = {};
bindTop();
layout();
renderBlocks();
renderPreview();
renderWpBadge();
setStatus('ok', 'Salvato alle ' + hhmm(D.updated_at));
// Modifiche rimaste solo nel browser (connessione caduta, scheda chiusa…)
const bk = lsGet();
if (bk && bk.data && JSON.stringify(bk.data) !== JSON.stringify(content())) {
const when = new Date(bk.at).toLocaleString('it-IT', { dateStyle: 'short', timeStyle: 'short' });
const b = $('#bnRestore');
b.innerHTML = '<i class="fas fa-life-ring me-1"></i> In questo browser ci sono modifiche del <strong>' + esc(when) +
'</strong> che non erano arrivate al server.' +
'<div class="mt-2 d-flex gap-2"><button class="btn btn-sm btn-warning" id="bkYes">Recupera le modifiche</button>' +
'<button class="btn btn-sm btn-light" id="bkNo">Scartale</button></div>';
b.classList.add('show');
layout();
$('#bkYes').onclick = () => {
Object.assign(D, bk.data);
['fTitle', 'fSubject', 'fPreheader'].forEach(i => { const el = $('#' + i); el.value = D[{ fTitle: 'title', fSubject: 'subject', fPreheader: 'preheader' }[i]] || ''; });
b.classList.remove('show');
layout();
renderBlocks();
changed();
};
$('#bkNo').onclick = () => { lsDel(); b.classList.remove('show'); layout(); };
}
}
start();
})();
</script>
</body>
</html>