1868 lines
74 KiB
PHP
1868 lines
74 KiB
PHP
<?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 => ({
|
||
'&': '&',
|
||
'<': '<',
|
||
'>': '>',
|
||
'"': '"',
|
||
"'": '''
|
||
} [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(/ /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>
|