Files
yogibook_aury_new/public/newsletter_editor.php
T
2026-10-19 08:41:08 +02:00

1869 lines
74 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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'); ?>
<header class="ishorizontal-topbar">
<div class="navbar-header">
<div class="d-flex"></div>
</div>
<div class="topnav">
<div class="container-fluid">
<nav class="navbar navbar-light navbar-expand-lg topnav-menu"></nav>
</div>
</div>
</header>
<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' + (j.note ? ' – ' + j.note : '');
} 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>