added newsletter
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Bozze delle newsletter scritte in YogiBook (pubblicate poi su The Newsletter)
|
||||
* e storico delle versioni per non perdere mai il testo.
|
||||
*/
|
||||
final class CreateNewsletterDrafts extends AbstractMigration
|
||||
{
|
||||
public function change(): void
|
||||
{
|
||||
$this->table('newsletter_drafts', ['id' => 'iddraft'])
|
||||
->addColumn('title', 'string', ['limit' => 255, 'default' => ''])
|
||||
->addColumn('subject', 'string', ['limit' => 255, 'default' => ''])
|
||||
->addColumn('preheader', 'string', ['limit' => 255, 'default' => ''])
|
||||
->addColumn('blocks', 'text', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::TEXT_LONG, 'null' => true])
|
||||
->addColumn('settings', 'text', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::TEXT_LONG, 'null' => true])
|
||||
->addColumn('revision', 'integer', ['default' => 0, 'signed' => false])
|
||||
->addColumn('wp_email_id', 'integer', ['null' => true])
|
||||
->addColumn('wp_pushed_at', 'datetime', ['null' => true])
|
||||
->addColumn('created_by', 'integer', ['signed' => false, 'default' => 0])
|
||||
->addColumn('updated_by', 'integer', ['signed' => false, 'default' => 0])
|
||||
->addColumn('created_at', 'datetime')
|
||||
->addColumn('updated_at', 'datetime')
|
||||
->addColumn('deleted_at', 'datetime', ['null' => true])
|
||||
->addColumn('idschool', 'integer', ['signed' => false, 'default' => 1])
|
||||
->addIndex(['updated_at'])
|
||||
->addIndex(['idschool'])
|
||||
->create();
|
||||
|
||||
$this->table('newsletter_draft_versions', ['id' => 'idversion'])
|
||||
->addColumn('iddraft', 'integer')
|
||||
->addColumn('revision', 'integer', ['signed' => false, 'default' => 0])
|
||||
->addColumn('subject', 'string', ['limit' => 255, 'default' => ''])
|
||||
->addColumn('preheader', 'string', ['limit' => 255, 'default' => ''])
|
||||
->addColumn('blocks', 'text', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::TEXT_LONG, 'null' => true])
|
||||
->addColumn('settings', 'text', ['limit' => \Phinx\Db\Adapter\MysqlAdapter::TEXT_LONG, 'null' => true])
|
||||
->addColumn('note', 'string', ['limit' => 100, 'default' => ''])
|
||||
->addColumn('created_by', 'integer', ['signed' => false, 'default' => 0])
|
||||
->addColumn('created_at', 'datetime')
|
||||
->addIndex(['iddraft', 'created_at'])
|
||||
->create();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,658 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Trasforma i blocchi della newsletter (JSON di YogiBook) in HTML email
|
||||
* compatibile con il composer di The Newsletter (editor = 2):
|
||||
* ogni blocco ha il wrapper "tnpc-row" e le sue opzioni in data-json (base64),
|
||||
* così in WordPress la newsletter resta modificabile nel composer.
|
||||
*
|
||||
* Funziona anche al contrario: fromComposerHtml() importa una newsletter del plugin.
|
||||
*/
|
||||
class NewsletterRenderer
|
||||
{
|
||||
public const WIDTH = 600;
|
||||
|
||||
public const BLOCK_TYPES = ['header', 'heading', 'text', 'image', 'button', 'columns', 'separator', 'social', 'footer', 'raw'];
|
||||
|
||||
/** Impostazioni grafiche predefinite (come le newsletter YogaSoul attuali). */
|
||||
public static function defaultSettings(): array
|
||||
{
|
||||
return [
|
||||
'background' => '#f4f4f4',
|
||||
'block_background' => '#ffffff',
|
||||
'font_family' => 'Verdana, Geneva, sans-serif',
|
||||
'font_size' => 16,
|
||||
'text_color' => '#222222',
|
||||
'link_color' => '#007bff',
|
||||
'title_font_size' => 28,
|
||||
'title_color' => '#222222',
|
||||
'button_background' => '#256f9c',
|
||||
'button_color' => '#ffffff',
|
||||
'social' => [
|
||||
'facebook' => 'https://www.facebook.com/yogasoul.it/',
|
||||
'instagram' => 'https://www.instagram.com/aura_yogasoul/',
|
||||
'youtube' => 'https://www.youtube.com/@yogasoul6746',
|
||||
'whatsapp' => 'https://wa.me/393534982534',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/** Blocchi di una newsletter nuova. */
|
||||
public static function starterBlocks(): array
|
||||
{
|
||||
return [
|
||||
self::newBlock('header'),
|
||||
self::newBlock('text', ['html' => '<h1 style="text-align: center;">Ciao {name}</h1><p>come stai?</p><p>Scrivi qui il testo della newsletter…</p>']),
|
||||
self::newBlock('image'),
|
||||
self::newBlock('button'),
|
||||
self::newBlock('separator'),
|
||||
self::newBlock('social'),
|
||||
self::newBlock('footer'),
|
||||
];
|
||||
}
|
||||
|
||||
/** Valori predefiniti per tipo di blocco. */
|
||||
public static function newBlock(string $type, array $values = []): array
|
||||
{
|
||||
$defaults = [
|
||||
'header' => ['logo_url' => 'https://yogasoul.it/wp-content/uploads/2025/01/Logo-YogaSoul_parziale-1.png', 'logo_width' => 80, 'link' => 'https://yogasoul.it', 'pad_top' => 24, 'pad_bottom' => 24],
|
||||
'heading' => ['text' => 'Titolo', 'align' => 'center', 'font_size' => '', 'color' => '', 'background' => '', 'pad_top' => 30, 'pad_bottom' => 10],
|
||||
'text' => ['html' => '<p>Testo…</p>', 'background' => '', 'background_2' => '', 'pad_top' => 20, 'pad_bottom' => 20],
|
||||
'image' => ['url' => '', 'alt' => '', 'link' => '', 'width' => 600, 'pad_top' => 0, 'pad_bottom' => 0],
|
||||
'button' => ['label' => 'Prenota la tua lezione', 'url' => 'https://yogasoul.it', 'button_bg' => '', 'color' => '', 'font_size' => 20, 'width' => 300, 'radius' => 6, 'pad_top' => 20, 'pad_bottom' => 20],
|
||||
'columns' => ['image_url' => '', 'image_alt' => '', 'link' => '', 'image_side' => 'left', 'html' => '<p>Testo accanto all\'immagine…</p>', 'pad_top' => 20, 'pad_bottom' => 20],
|
||||
'separator' => ['color' => '#dddddd', 'height' => 1, 'pad_top' => 20, 'pad_bottom' => 20],
|
||||
'social' => ['icon_size' => 32, 'pad_top' => 15, 'pad_bottom' => 15],
|
||||
'footer' => ['profile_label' => "Modifica l'iscrizione", 'view_label' => 'Vedi online', 'unsub_label' => 'Disiscriviti', 'pad_top' => 15, 'pad_bottom' => 15],
|
||||
'raw' => ['html' => '', 'wp_id' => ''],
|
||||
];
|
||||
|
||||
return array_merge(
|
||||
['id' => bin2hex(random_bytes(6)), 'type' => $type],
|
||||
$defaults[$type] ?? [],
|
||||
$values
|
||||
);
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
* Rendering
|
||||
* ===================================================================== */
|
||||
|
||||
/**
|
||||
* HTML completo della newsletter.
|
||||
* $forPreview = true sostituisce i tag del plugin con valori di esempio.
|
||||
*/
|
||||
public static function renderDocument(array $blocks, array $settings, string $subject, string $preheader, bool $forPreview = false): string
|
||||
{
|
||||
$s = self::settings($settings);
|
||||
|
||||
$head = file_get_contents(__DIR__ . '/newsletter_head.html');
|
||||
$head = str_replace('{{LINK_COLOR}}', self::color($s['link_color'], '#007bff'), $head);
|
||||
|
||||
$body = '';
|
||||
foreach ($blocks as $b) {
|
||||
if (is_array($b) && isset($b['type'])) {
|
||||
$body .= self::renderBlock($b, $s) . "\n";
|
||||
}
|
||||
}
|
||||
|
||||
$bg = self::color($s['background'], '#f4f4f4');
|
||||
|
||||
$html = $head
|
||||
. '<body style="margin: 0; padding: 0; line-height: normal; word-spacing: normal;" dir="ltr">' . "\n"
|
||||
. '<div style="display:none;font-size:1px;color:#ffffff;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;">' . self::h($preheader) . "</div>\n\n"
|
||||
. "<table cellpadding='0' cellspacing='0' border='0' width='100%' role='presentation'>\n<tr>\n"
|
||||
. "<td bgcolor='{$bg}' valign='top' class='wrapper'><!-- tnp -->"
|
||||
. $body
|
||||
. "<!-- /tnp -->\n</td>\n</tr>\n</table>\n\n</body>\n</html>";
|
||||
|
||||
if ($forPreview) {
|
||||
$html = strtr($html, [
|
||||
'{name}' => 'Maria',
|
||||
'{email_subject}' => self::h($subject),
|
||||
'{unsubscription_url}' => '#',
|
||||
'{profile_url}' => '#',
|
||||
'{email_url}' => '#',
|
||||
'{subscription_confirm_url}' => '#',
|
||||
]);
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/** Versione solo testo (campo message_text del plugin). */
|
||||
public static function renderText(array $blocks, string $preheader): string
|
||||
{
|
||||
$out = [];
|
||||
if ($preheader !== '') $out[] = $preheader;
|
||||
|
||||
foreach ($blocks as $b) {
|
||||
switch ($b['type'] ?? '') {
|
||||
case 'heading':
|
||||
$out[] = strtoupper(trim((string) ($b['text'] ?? '')));
|
||||
break;
|
||||
case 'text':
|
||||
case 'columns':
|
||||
case 'raw':
|
||||
$out[] = self::htmlToText((string) ($b['html'] ?? ''));
|
||||
break;
|
||||
case 'button':
|
||||
$out[] = trim(strip_tags(str_ireplace(['<br>', '<br/>', '<br />'], ' ', (string) ($b['label'] ?? '')))) . ': ' . ($b['url'] ?? '');
|
||||
break;
|
||||
case 'image':
|
||||
if (!empty($b['link'])) $out[] = ($b['alt'] ?: 'Immagine') . ' (' . $b['link'] . ')';
|
||||
break;
|
||||
case 'footer':
|
||||
$out[] = "Modifica l'iscrizione: {profile_url}";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return trim(preg_replace("/\n{3,}/", "\n\n", implode("\n\n", array_filter($out, fn($x) => trim($x) !== ''))));
|
||||
}
|
||||
|
||||
public static function renderBlock(array $b, array $s): string
|
||||
{
|
||||
$type = $b['type'];
|
||||
|
||||
if ($type === 'raw') {
|
||||
return (string) ($b['html'] ?? '');
|
||||
}
|
||||
|
||||
$padTop = (int) ($b['pad_top'] ?? 15);
|
||||
$padBottom = (int) ($b['pad_bottom'] ?? 15);
|
||||
$bg = self::color($b['background'] ?? '', self::color($s['block_background'], '#ffffff'));
|
||||
// Sfumatura (solo blocchi di testo, come fa il composer)
|
||||
$bg2 = in_array($type, ['text', 'columns'], true) ? self::color($b['background_2'] ?? '', '') : '';
|
||||
$bgCss = 'background-color: ' . $bg . ';' . ($bg2 !== '' ? ' background-image: linear-gradient(180deg, ' . $bg . ', ' . $bg2 . ');' : '');
|
||||
|
||||
switch ($type) {
|
||||
case 'header':
|
||||
[$inner, $padLR, $wpId, $opts] = self::header($b, $s);
|
||||
break;
|
||||
case 'heading':
|
||||
[$inner, $padLR, $wpId, $opts] = self::heading($b, $s);
|
||||
break;
|
||||
case 'text':
|
||||
[$inner, $padLR, $wpId, $opts] = self::text($b, $s);
|
||||
break;
|
||||
case 'image':
|
||||
[$inner, $padLR, $wpId, $opts] = self::image($b, $s);
|
||||
break;
|
||||
case 'button':
|
||||
[$inner, $padLR, $wpId, $opts] = self::button($b, $s);
|
||||
break;
|
||||
case 'columns':
|
||||
[$inner, $padLR, $wpId, $opts] = self::columns($b, $s);
|
||||
break;
|
||||
case 'separator':
|
||||
[$inner, $padLR, $wpId, $opts] = self::separator($b, $s);
|
||||
break;
|
||||
case 'social':
|
||||
[$inner, $padLR, $wpId, $opts] = self::social($b, $s);
|
||||
break;
|
||||
case 'footer':
|
||||
[$inner, $padLR, $wpId, $opts] = self::footer($b, $s);
|
||||
break;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
|
||||
$opts = array_merge([
|
||||
'block_padding_top' => $padTop,
|
||||
'block_padding_bottom' => $padBottom,
|
||||
'block_padding_right' => $padLR,
|
||||
'block_padding_left' => $padLR,
|
||||
'block_background' => self::color($b['background'] ?? '', ''),
|
||||
'block_background_2' => $bg2,
|
||||
'block_width' => (string) self::WIDTH,
|
||||
'block_align' => 'center',
|
||||
'block_border_color' => '',
|
||||
'block_border_radius' => '',
|
||||
'inline_edits' => '',
|
||||
'block_background_angle' => '180',
|
||||
'block_background_wide' => '0',
|
||||
'block_class' => '',
|
||||
], $opts, ['block_id' => $wpId]);
|
||||
|
||||
$json = base64_encode(json_encode($opts));
|
||||
$w = self::WIDTH;
|
||||
|
||||
// Colonna centrale di 600px con lo sfondo del blocco, fuori lo sfondo generale
|
||||
// (come le newsletter YogaSoul attuali: block_background_wide = 0)
|
||||
return '<table border="0" cellpadding="0" cellspacing="0" align="center" width="100%" style="border-collapse: collapse; width: 100%;" class="tnpc-row tnpc-row-block" data-id="' . $wpId . '">' . "\n"
|
||||
. '<tbody><tr><td align="center" style="padding: 0;" class="edit-block">'
|
||||
. '<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" align="center" cellspacing="0" width="' . $w . '"><tr><td width="' . $w . '" style="vertical-align:top;width:' . $w . 'px;"><![endif]-->'
|
||||
. '<table role="presentation" type="options" data-json="' . $json . '" class="tnpc-block-content" border="0" cellpadding="0" align="center" cellspacing="0" width="100%" style="border-collapse: separate !important; width: 100%!important; max-width: ' . $w . 'px!important">' . "\n"
|
||||
. '<tbody><tr><td align="center" style="text-align: center; line-height: normal !important; letter-spacing: normal; mso-line-height-rule: exactly; outline: none; padding: ' . $padTop . 'px ' . $padLR . 'px ' . $padBottom . 'px ' . $padLR . 'px;' . $bgCss . '" bgcolor="' . $bg . '" class="">'
|
||||
. $inner
|
||||
. '</td></tr></tbody></table><!--[if mso | IE]></td></tr></table><![endif]--></td></tr></tbody></table>';
|
||||
}
|
||||
|
||||
/* ---- singoli blocchi: [html interno, padding laterale, id composer, opzioni composer] ---- */
|
||||
|
||||
private static function header(array $b, array $s): array
|
||||
{
|
||||
$logo = self::url($b['logo_url'] ?? '');
|
||||
$width = max(20, min(600, (int) ($b['logo_width'] ?? 80)));
|
||||
$link = self::url($b['link'] ?? '');
|
||||
|
||||
$img = $logo !== ''
|
||||
? '<img src="' . self::h($logo) . '" width="' . $width . '" border="0" alt="YogaSoul" style="display:inline-block; height:auto; max-width:100%;">'
|
||||
: '';
|
||||
$inner = $link !== '' && $img !== '' ? '<a href="' . self::h($link) . '" target="_blank">' . $img . '</a>' : $img;
|
||||
|
||||
return [$inner, 16, 'header', [
|
||||
'font_family' => '', 'font_size' => '', 'font_color' => '', 'font_weight' => '',
|
||||
'logo_width' => (string) $width, 'block_layout' => 'logo', 'block_style' => '',
|
||||
'title_font_family' => '', 'title_font_size' => '', 'title_font_weight' => '', 'title_font_color' => '',
|
||||
]];
|
||||
}
|
||||
|
||||
private static function heading(array $b, array $s): array
|
||||
{
|
||||
$size = (int) ($b['font_size'] ?: $s['title_font_size']);
|
||||
$color = self::color($b['color'] ?? '', self::color($s['title_color'], '#222222'));
|
||||
$align = in_array($b['align'] ?? '', ['left', 'center', 'right'], true) ? $b['align'] : 'center';
|
||||
$font = self::font($s['font_family']);
|
||||
$text = (string) ($b['text'] ?? '');
|
||||
|
||||
$inner = '<table border="0" cellspacing="0" cellpadding="0" width="100%" role="presentation"><tbody><tr>'
|
||||
. '<td align="' . $align . '" valign="middle" dir="ltr" style="text-align: ' . $align . ';">'
|
||||
. '<div style="font-size: ' . $size . 'px;font-family: ' . $font . ';font-weight: bold;color: ' . $color . '; padding: 0; line-height: 130% !important; letter-spacing: normal;" role="heading" aria-level="1">'
|
||||
. nl2br(self::h($text))
|
||||
. '</div></td></tr></tbody></table>';
|
||||
|
||||
return [$inner, 15, 'heading', [
|
||||
'text' => $text, 'align' => $align, 'font_family' => '', 'font_size' => (string) $size,
|
||||
'font_color' => $color, 'font_weight' => 'bold',
|
||||
]];
|
||||
}
|
||||
|
||||
private static function text(array $b, array $s): array
|
||||
{
|
||||
$html = self::cleanHtml((string) ($b['html'] ?? ''));
|
||||
$inner = self::textCell($html, $s);
|
||||
|
||||
return [$inner, 15, 'text', ['html' => $html]];
|
||||
}
|
||||
|
||||
private static function textCell(string $html, array $s): string
|
||||
{
|
||||
return '<table width="100%" style="width: 100%!important" border="0" cellpadding="0" cellspacing="0"><tbody><tr>'
|
||||
. '<td width="100%" valign="top" align="left" class="text" style="mso-line-height-rule: exactly; font-size: ' . (int) $s['font_size'] . 'px;font-family: ' . self::font($s['font_family']) . ';font-weight: normal;color: ' . self::color($s['text_color'], '#222222') . '; line-height: 1.5;">'
|
||||
. $html
|
||||
. '</td></tr></tbody></table>';
|
||||
}
|
||||
|
||||
private static function image(array $b, array $s): array
|
||||
{
|
||||
$url = self::url($b['url'] ?? '');
|
||||
$alt = (string) ($b['alt'] ?? '');
|
||||
$link = self::url($b['link'] ?? '');
|
||||
$width = max(50, min(self::WIDTH, (int) ($b['width'] ?? self::WIDTH)));
|
||||
|
||||
if ($url === '') {
|
||||
$inner = '<div style="padding:30px;border:2px dashed #cccccc;color:#999999;font-family:Arial,sans-serif;font-size:14px;">Immagine non ancora scelta</div>';
|
||||
} else {
|
||||
$img = '<img src="' . self::h($url) . '" width="' . $width . '" alt="' . self::h($alt) . '" border="0" style="display: block; margin: 0 auto; height: auto; max-width: ' . $width . 'px !important; width: 100%; padding: 0; border: 0; font-size: 12px">';
|
||||
$inner = $link !== '' ? '<a href="' . self::h($link) . '" target="_blank" style="display:block;">' . $img . '</a>' : $img;
|
||||
}
|
||||
|
||||
return [$inner, 0, 'image', [
|
||||
'image' => ['id' => '', 'url' => $url],
|
||||
'image-alt' => '', 'image_alt' => $alt, 'url' => $link, 'image-url' => $link,
|
||||
'width' => (string) $width, 'align' => 'center', 'placeholder' => '',
|
||||
]];
|
||||
}
|
||||
|
||||
private static function button(array $b, array $s): array
|
||||
{
|
||||
$label = self::cleanHtml((string) ($b['label'] ?? ''), '<br><strong><em><b><i>');
|
||||
$url = self::url($b['url'] ?? '');
|
||||
$bg = self::color($b['button_bg'] ?? '', self::color($s['button_background'], '#256f9c'));
|
||||
$color = self::color($b['color'] ?? '', self::color($s['button_color'], '#ffffff'));
|
||||
$size = max(10, min(60, (int) ($b['font_size'] ?? 20)));
|
||||
$width = max(80, min(self::WIDTH, (int) ($b['width'] ?? 300)));
|
||||
$radius = max(0, min(50, (int) ($b['radius'] ?? 6)));
|
||||
$font = self::font($s['font_family']);
|
||||
|
||||
$inner = '<table border="0" cellpadding="0" cellspacing="0" role="presentation" align="center" style="border-collapse:separate !important;line-height:100%;width:' . $width . 'px;max-width:100%;"><tbody><tr>'
|
||||
. '<td align="center" bgcolor="' . $bg . '" role="presentation" style="border-collapse:separate !important;cursor:auto;mso-padding-alt:12px 25px;background:' . $bg . ';border-radius:' . $radius . 'px;" valign="middle">'
|
||||
. '<a href="' . self::h($url) . '" style="display:inline-block;color:' . $color . ';font-family:' . $font . ';font-size:' . $size . 'px;font-weight:bold;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:12px 25px;mso-padding-alt:0px;border-radius:' . $radius . 'px;" target="_blank">'
|
||||
. $label
|
||||
. '</a></td></tr></tbody></table>';
|
||||
|
||||
return [$inner, 0, 'cta', [
|
||||
'button_label' => $label, 'button_url' => $url,
|
||||
'button_font_family' => $s['font_family'], 'button_font_size' => (string) $size, 'button_font_weight' => 'bold',
|
||||
'button_font_color' => $color, 'button_background' => $bg, 'button_border_color' => '',
|
||||
'align' => 'center', 'button_width' => (string) $width, 'button_align' => 'center',
|
||||
'block_style' => '', 'button_border_radius' => (string) $radius, 'list' => '', 'unlist' => '',
|
||||
]];
|
||||
}
|
||||
|
||||
/** Immagine + testo affiancati (in WordPress diventa un blocco "testo"). */
|
||||
private static function columns(array $b, array $s): array
|
||||
{
|
||||
$url = self::url($b['image_url'] ?? '');
|
||||
$alt = self::h($b['image_alt'] ?? '');
|
||||
$link = self::url($b['link'] ?? '');
|
||||
$html = self::cleanHtml((string) ($b['html'] ?? ''));
|
||||
$right = ($b['image_side'] ?? 'left') === 'right';
|
||||
|
||||
$img = $url !== ''
|
||||
? '<img src="' . self::h($url) . '" width="270" alt="' . $alt . '" border="0" style="display:block;width:100%;max-width:270px;height:auto;border:0;">'
|
||||
: '<div style="padding:30px 10px;border:2px dashed #cccccc;color:#999999;font-size:13px;text-align:center;">Immagine</div>';
|
||||
if ($url !== '' && $link !== '') {
|
||||
$img = '<a href="' . self::h($link) . '" target="_blank">' . $img . '</a>';
|
||||
}
|
||||
|
||||
$imgCell = '<td class="responsive" width="50%" valign="top" style="padding:0 10px 10px 10px;">' . $img . '</td>';
|
||||
$textCell = '<td class="responsive" width="50%" valign="top" style="padding:0 10px 10px 10px;text-align:left;">' . $html . '</td>';
|
||||
|
||||
$table = '<table width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation"><tbody><tr>'
|
||||
. ($right ? $textCell . $imgCell : $imgCell . $textCell)
|
||||
. '</tr></tbody></table>';
|
||||
|
||||
return [self::textCell($table, $s), 15, 'text', ['html' => $table]];
|
||||
}
|
||||
|
||||
private static function separator(array $b, array $s): array
|
||||
{
|
||||
$color = self::color($b['color'] ?? '', '#dddddd');
|
||||
$height = max(1, min(10, (int) ($b['height'] ?? 1)));
|
||||
|
||||
$inner = '<table border="0" cellpadding="0" cellspacing="0" width="100%"><tbody><tr>'
|
||||
. '<td style="border-bottom: ' . $height . 'px solid ' . $color . '; font-size:0; line-height:0;"> </td>'
|
||||
. '</tr></tbody></table>';
|
||||
|
||||
return [$inner, 20, 'separator', ['color' => $color, 'height' => $height]];
|
||||
}
|
||||
|
||||
private static function social(array $b, array $s): array
|
||||
{
|
||||
$size = max(16, min(64, (int) ($b['icon_size'] ?? 32)));
|
||||
$links = '';
|
||||
|
||||
foreach (['facebook', 'youtube', 'instagram', 'whatsapp'] as $net) {
|
||||
$url = self::url($s['social'][$net] ?? '');
|
||||
if ($url === '') continue;
|
||||
$links .= '<a href="' . self::h($url) . '" style="line-height: normal; text-decoration: none;" target="_blank">'
|
||||
. '<img src="https://yogasoul.it/wp-content/plugins/newsletter/images/social-1/' . $net . '.png" width="' . $size . '" height="' . $size . '" alt="' . $net . '" border="0"></a> ';
|
||||
}
|
||||
|
||||
$inner = '<table border="0" cellspacing="0" cellpadding="0" width="100%" role="presentation"><tbody><tr><td align="center" valign="middle">'
|
||||
. $links . '</td></tr></tbody></table>';
|
||||
|
||||
return [$inner, 15, 'social', ['type' => '1', 'width' => (string) $size]];
|
||||
}
|
||||
|
||||
private static function footer(array $b, array $s): array
|
||||
{
|
||||
$style = 'font-size: 13px; font-family: Helvetica, Arial, sans-serif; font-weight: normal; color: #444444; text-decoration: none;';
|
||||
$sep = '<span style="' . $style . '"> | </span>';
|
||||
|
||||
$parts = [];
|
||||
if (trim((string) ($b['profile_label'] ?? '')) !== '') {
|
||||
$parts[] = '<a style="' . $style . '" href="{profile_url}">' . self::h($b['profile_label']) . '</a>';
|
||||
}
|
||||
if (trim((string) ($b['view_label'] ?? '')) !== '') {
|
||||
$parts[] = '<a style="' . $style . '" href="{email_url}">' . self::h($b['view_label']) . '</a>';
|
||||
}
|
||||
if (trim((string) ($b['unsub_label'] ?? '')) !== '') {
|
||||
$parts[] = '<a style="' . $style . '" href="{unsubscription_url}">' . self::h($b['unsub_label']) . '</a>';
|
||||
}
|
||||
|
||||
return [implode($sep, $parts), 15, 'footer', [
|
||||
'view' => (string) ($b['view_label'] ?? ''), 'profile' => (string) ($b['profile_label'] ?? ''),
|
||||
'font_family' => 'Helvetica, Arial, sans-serif', 'font_size' => '13', 'font_color' => '#444444', 'font_weight' => 'normal',
|
||||
]];
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
* Import da The Newsletter (HTML del composer -> blocchi YogiBook)
|
||||
* ===================================================================== */
|
||||
|
||||
public static function fromComposerHtml(string $html, string $options = ''): array
|
||||
{
|
||||
$blocks = [];
|
||||
|
||||
$start = strpos($html, '<!-- tnp -->');
|
||||
$end = strrpos($html, '<!-- /tnp -->');
|
||||
$body = ($start !== false && $end !== false) ? substr($html, $start + 12, $end - $start - 12) : $html;
|
||||
|
||||
// Ogni blocco inizia con la tabella "tnpc-row"
|
||||
preg_match_all('/<table[^>]*class="[^"]*tnpc-row[^"]*"[^>]*>/i', $body, $m, PREG_OFFSET_CAPTURE);
|
||||
$starts = array_map(fn($x) => $x[1], $m[0]);
|
||||
|
||||
foreach ($starts as $i => $pos) {
|
||||
$chunk = substr($body, $pos, isset($starts[$i + 1]) ? $starts[$i + 1] - $pos : null);
|
||||
$chunk = rtrim($chunk);
|
||||
|
||||
preg_match('/data-id="([^"]+)"/', $chunk, $idm);
|
||||
preg_match('/data-json="([^"]+)"/', $chunk, $jm);
|
||||
$wpId = $idm[1] ?? '';
|
||||
$o = $jm ? (json_decode(base64_decode($jm[1]), true) ?: []) : [];
|
||||
|
||||
$pad = ['pad_top' => (int) ($o['block_padding_top'] ?? 15), 'pad_bottom' => (int) ($o['block_padding_bottom'] ?? 15)];
|
||||
$bg = ['background' => (string) ($o['block_background'] ?? '')];
|
||||
|
||||
switch ($wpId) {
|
||||
case 'text':
|
||||
$blocks[] = self::newBlock('text', $pad + $bg + [
|
||||
'html' => (string) ($o['html'] ?? ''),
|
||||
'background_2' => (string) ($o['block_background_2'] ?? ''),
|
||||
]);
|
||||
break;
|
||||
case 'heading':
|
||||
$blocks[] = self::newBlock('heading', $pad + $bg + [
|
||||
'text' => (string) ($o['text'] ?? ''), 'align' => $o['align'] ?? 'center',
|
||||
'font_size' => (string) ($o['font_size'] ?? ''), 'color' => (string) ($o['font_color'] ?? ''),
|
||||
]);
|
||||
break;
|
||||
case 'image':
|
||||
$blocks[] = self::newBlock('image', $pad + [
|
||||
'url' => (string) ($o['image']['url'] ?? ''), 'alt' => (string) ($o['image_alt'] ?? ''),
|
||||
'link' => (string) ($o['url'] ?? ''),
|
||||
'width' => min(self::WIDTH, (int) ($o['width'] ?? self::WIDTH) ?: self::WIDTH),
|
||||
]);
|
||||
break;
|
||||
case 'cta':
|
||||
$blocks[] = self::newBlock('button', $pad + [
|
||||
'label' => (string) ($o['button_label'] ?? ''), 'url' => (string) ($o['button_url'] ?? ''),
|
||||
'button_bg' => (string) ($o['button_background'] ?? ''), 'color' => (string) ($o['button_font_color'] ?? ''),
|
||||
'font_size' => (int) ($o['button_font_size'] ?? 20) ?: 20,
|
||||
'width' => (int) ($o['button_width'] ?? 300) ?: 300,
|
||||
'radius' => (int) ($o['button_border_radius'] ?? 0),
|
||||
]);
|
||||
break;
|
||||
case 'separator':
|
||||
$blocks[] = self::newBlock('separator', $pad + ['color' => (string) ($o['color'] ?? '#dddddd'), 'height' => (int) ($o['height'] ?? 1) ?: 1]);
|
||||
break;
|
||||
case 'social':
|
||||
$blocks[] = self::newBlock('social', $pad + ['icon_size' => (int) ($o['width'] ?? 32) ?: 32]);
|
||||
break;
|
||||
case 'footer':
|
||||
$blocks[] = self::newBlock('footer', $pad + [
|
||||
'profile_label' => (string) ($o['profile'] ?? "Modifica l'iscrizione"),
|
||||
'view_label' => (string) ($o['view'] ?? 'Vedi online'),
|
||||
'unsub_label' => '',
|
||||
]);
|
||||
break;
|
||||
case 'header':
|
||||
preg_match('/<img[^>]*src="([^"]+)"/i', $chunk, $im);
|
||||
preg_match('/<a[^>]*href="([^"]+)"/i', $chunk, $am);
|
||||
$blocks[] = self::newBlock('header', $pad + [
|
||||
'logo_url' => $im[1] ?? '', 'link' => $am[1] ?? '', 'logo_width' => (int) ($o['logo_width'] ?? 80) ?: 80,
|
||||
]);
|
||||
break;
|
||||
default:
|
||||
// Blocco che YogiBook non sa modificare: lo conserviamo identico
|
||||
$blocks[] = self::newBlock('raw', ['html' => $chunk, 'wp_id' => $wpId]);
|
||||
}
|
||||
}
|
||||
|
||||
return $blocks;
|
||||
}
|
||||
|
||||
/** Impostazioni grafiche ricavate dalle opzioni serializzate del plugin. */
|
||||
public static function settingsFromWpOptions(array $o): array
|
||||
{
|
||||
$s = self::defaultSettings();
|
||||
$map = [
|
||||
'composer_background' => 'background',
|
||||
'composer_block_background' => 'block_background',
|
||||
'composer_text_font_family' => 'font_family',
|
||||
'composer_text_font_size' => 'font_size',
|
||||
'composer_text_font_color' => 'text_color',
|
||||
'composer_title_font_color' => 'title_color',
|
||||
'composer_button_background_color' => 'button_background',
|
||||
'composer_button_font_color' => 'button_color',
|
||||
];
|
||||
foreach ($map as $wp => $ours) {
|
||||
if (!empty($o[$wp])) $s[$ours] = is_numeric($o[$wp]) ? (int) $o[$wp] : (string) $o[$wp];
|
||||
}
|
||||
return $s;
|
||||
}
|
||||
|
||||
/** Opzioni "composer_*" da scrivere nel plugin. */
|
||||
public static function wpComposerOptions(array $settings): array
|
||||
{
|
||||
$s = self::settings($settings);
|
||||
return [
|
||||
'composer_background' => $s['background'],
|
||||
'composer_block_background' => $s['block_background'],
|
||||
'composer_width' => (string) self::WIDTH,
|
||||
'composer_text_font_family' => $s['font_family'],
|
||||
'composer_text_font_size' => (string) $s['font_size'],
|
||||
'composer_text_font_weight' => 'normal',
|
||||
'composer_text_font_color' => $s['text_color'],
|
||||
'composer_title_font_family' => $s['font_family'],
|
||||
'composer_title_font_size' => (string) $s['title_font_size'],
|
||||
'composer_title_font_weight' => 'bold',
|
||||
'composer_title_font_color' => $s['title_color'],
|
||||
'composer_button_font_family' => $s['font_family'],
|
||||
'composer_button_font_size' => '16',
|
||||
'composer_button_font_weight' => 'bold',
|
||||
'composer_button_font_color' => $s['button_color'],
|
||||
'composer_button_background_color' => $s['button_background'],
|
||||
'composer_button_background' => $s['button_background'],
|
||||
'composer_button_border_color' => $s['button_background'],
|
||||
];
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
* Controlli prima di pubblicare
|
||||
* ===================================================================== */
|
||||
|
||||
public static function checks(array $blocks, string $subject, string $preheader, string $html): array
|
||||
{
|
||||
$warn = [];
|
||||
$len = fn($t) => mb_strlen(trim($t));
|
||||
|
||||
if ($len($subject) === 0) $warn[] = ['error', "Manca l'oggetto."];
|
||||
elseif ($len($subject) > 70) $warn[] = ['warning', "Oggetto lungo (" . $len($subject) . " caratteri): sul telefono viene tagliato verso i 40-60."];
|
||||
|
||||
if ($len($preheader) === 0) $warn[] = ['warning', "Manca il preheader (la riga di anteprima sotto l'oggetto)."];
|
||||
elseif ($len($preheader) > 120) $warn[] = ['warning', 'Preheader molto lungo: viene mostrato solo l\'inizio.'];
|
||||
|
||||
$hasFooter = false;
|
||||
foreach ($blocks as $i => $b) {
|
||||
$n = $i + 1;
|
||||
switch ($b['type'] ?? '') {
|
||||
case 'image':
|
||||
if (empty($b['url'])) $warn[] = ['error', "Blocco $n: immagine non scelta."];
|
||||
elseif (trim((string) ($b['alt'] ?? '')) === '') $warn[] = ['warning', "Blocco $n: immagine senza testo alternativo (serve se le immagini sono bloccate)."];
|
||||
break;
|
||||
case 'columns':
|
||||
if (empty($b['image_url'])) $warn[] = ['warning', "Blocco $n: colonne senza immagine."];
|
||||
break;
|
||||
case 'button':
|
||||
if (trim((string) ($b['url'] ?? '')) === '' || ($b['url'] ?? '') === '#') $warn[] = ['error', "Blocco $n: pulsante senza link."];
|
||||
break;
|
||||
case 'footer':
|
||||
$hasFooter = true;
|
||||
break;
|
||||
case 'raw':
|
||||
if (preg_match('/unsubscription_url|profile_url/', (string) ($b['html'] ?? ''))) $hasFooter = true;
|
||||
break;
|
||||
}
|
||||
$all = json_encode($b);
|
||||
if (strpos($all, 'data:image') !== false) $warn[] = ['error', "Blocco $n: contiene un'immagine incollata dentro il testo. Usa un blocco immagine."];
|
||||
if (preg_match('/src=\\\\?"http:\/\//', $all)) $warn[] = ['warning', "Blocco $n: immagine con indirizzo http (non sicuro)."];
|
||||
}
|
||||
|
||||
if (!$hasFooter) $warn[] = ['error', 'Manca il piè di pagina con il link per gestire o annullare l\'iscrizione.'];
|
||||
|
||||
$kb = (int) round(strlen($html) / 1024);
|
||||
if ($kb > 100) $warn[] = ['error', "La newsletter pesa {$kb} KB: Gmail taglia i messaggi oltre 102 KB e nasconde il fondo (con il link di disiscrizione)."];
|
||||
elseif ($kb > 85) $warn[] = ['warning', "La newsletter pesa {$kb} KB: vicina al limite di Gmail (102 KB)."];
|
||||
|
||||
return $warn;
|
||||
}
|
||||
|
||||
/* =====================================================================
|
||||
* Utilità
|
||||
* ===================================================================== */
|
||||
|
||||
public static function settings(array $settings): array
|
||||
{
|
||||
$s = array_merge(self::defaultSettings(), $settings);
|
||||
$s['social'] = array_merge(self::defaultSettings()['social'], is_array($settings['social'] ?? null) ? $settings['social'] : []);
|
||||
$s['font_size'] = max(12, min(22, (int) $s['font_size']));
|
||||
$s['title_font_size'] = max(16, min(60, (int) $s['title_font_size']));
|
||||
return $s;
|
||||
}
|
||||
|
||||
public static function h($v): string
|
||||
{
|
||||
return htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
/** Colore esadecimale valido oppure $fallback. */
|
||||
public static function color($c, string $fallback): string
|
||||
{
|
||||
$c = trim((string) $c);
|
||||
return preg_match('/^#[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/', $c) ? $c : $fallback;
|
||||
}
|
||||
|
||||
/** Solo link http(s), mailto, tel o tag del plugin. */
|
||||
public static function url($u): string
|
||||
{
|
||||
$u = trim((string) $u);
|
||||
if ($u === '') return '';
|
||||
if (preg_match('#^(https?://|mailto:|tel:|\{[a-z_]+\})#i', $u)) return $u;
|
||||
if (preg_match('#^[\w.-]+\.[a-z]{2,}#i', $u)) return 'https://' . $u;
|
||||
return '';
|
||||
}
|
||||
|
||||
private static function font($f): string
|
||||
{
|
||||
$f = preg_replace('/[^a-zA-Z0-9 ,\'-]/', '', (string) $f);
|
||||
return $f !== '' ? $f : 'Verdana, Geneva, sans-serif';
|
||||
}
|
||||
|
||||
/** Toglie script, eventi e altri pericoli dall'HTML scritto nell'editor. */
|
||||
public static function cleanHtml(string $html, ?string $allowed = null): string
|
||||
{
|
||||
$html = preg_replace('#<(script|style|iframe|object|embed|form)[^>]*>.*?</\1>#is', '', $html);
|
||||
$html = preg_replace('#<(script|style|iframe|object|embed|form|input|button|meta|link)[^>]*/?>#i', '', $html);
|
||||
$html = preg_replace('/\son\w+\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)/i', '', $html);
|
||||
$html = preg_replace('/(href|src)\s*=\s*(["\'])\s*javascript:[^"\']*\2/i', '$1="#"', $html);
|
||||
if ($allowed !== null) $html = strip_tags($html, $allowed);
|
||||
return trim($html);
|
||||
}
|
||||
|
||||
private static function htmlToText(string $html): string
|
||||
{
|
||||
$html = preg_replace('#<a[^>]*href=["\']([^"\']+)["\'][^>]*>(.*?)</a>#is', '$2 ($1)', $html);
|
||||
$html = preg_replace('#<br\s*/?>#i', "\n", $html);
|
||||
$html = preg_replace('#</(p|h[1-6]|div|li|tr)>#i', "\n", $html);
|
||||
$html = preg_replace('#<li[^>]*>#i', '- ', $html);
|
||||
$text = html_entity_decode(strip_tags($html), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||
$text = preg_replace("/[ \t\x{00A0}]+/u", ' ', $text);
|
||||
$text = preg_replace("/ *\n */", "\n", $text);
|
||||
return trim(preg_replace("/\n{3,}/", "\n\n", $text));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Collegamento al database di WordPress per The Newsletter.
|
||||
*
|
||||
* Usa un utente MySQL dedicato che può leggere/scrivere SOLO la tabella delle newsletter.
|
||||
* Variabili nel .env di YogiBook:
|
||||
* WP_DB_HOST, WP_DB_NAME, WP_DB_USER, WP_DB_PASSWORD, WP_DB_PREFIX (default wp_),
|
||||
* WP_ADMIN_URL (default https://yogasoul.it/wp-admin/)
|
||||
*/
|
||||
class NewsletterWp
|
||||
{
|
||||
private static ?PDO $pdo = null;
|
||||
|
||||
public static function isConfigured(): bool
|
||||
{
|
||||
return !empty($_ENV['WP_DB_NAME']) && !empty($_ENV['WP_DB_USER']);
|
||||
}
|
||||
|
||||
public static function adminUrl(): string
|
||||
{
|
||||
return rtrim($_ENV['WP_ADMIN_URL'] ?? 'https://yogasoul.it/wp-admin/', '/') . '/admin.php?page=newsletter_emails_index';
|
||||
}
|
||||
|
||||
private static function table(): string
|
||||
{
|
||||
$prefix = preg_replace('/[^a-zA-Z0-9_]/', '', $_ENV['WP_DB_PREFIX'] ?? 'wp_');
|
||||
return '`' . $prefix . 'newsletter_emails`';
|
||||
}
|
||||
|
||||
private static function db(): PDO
|
||||
{
|
||||
if (!self::isConfigured()) {
|
||||
throw new RuntimeException('Collegamento a WordPress non configurato (manca WP_DB_* nel file .env).');
|
||||
}
|
||||
if (self::$pdo === null) {
|
||||
$host = $_ENV['WP_DB_HOST'] ?? 'localhost';
|
||||
$port = '3306';
|
||||
if (strpos($host, ':') !== false) {
|
||||
[$host, $port] = explode(':', $host, 2);
|
||||
}
|
||||
try {
|
||||
self::$pdo = new PDO(
|
||||
"mysql:host={$host};port={$port};dbname={$_ENV['WP_DB_NAME']};charset=utf8mb4",
|
||||
$_ENV['WP_DB_USER'],
|
||||
$_ENV['WP_DB_PASSWORD'] ?? '',
|
||||
[
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_TIMEOUT => 5,
|
||||
]
|
||||
);
|
||||
} catch (PDOException $e) {
|
||||
error_log('NewsletterWp: connessione fallita: ' . $e->getMessage());
|
||||
throw new RuntimeException('Collegamento al database di WordPress non riuscito: controlla WP_DB_* nel file .env.');
|
||||
}
|
||||
}
|
||||
return self::$pdo;
|
||||
}
|
||||
|
||||
private static function unserializeOptions(?string $raw): array
|
||||
{
|
||||
if ($raw === null || $raw === '') return [];
|
||||
$o = @unserialize($raw, ['allowed_classes' => false]);
|
||||
return is_array($o) ? $o : [];
|
||||
}
|
||||
|
||||
/** Ultime newsletter del plugin (per l'import e per lo stato). */
|
||||
public static function listEmails(int $limit = 30): array
|
||||
{
|
||||
$st = self::db()->prepare("SELECT id, subject, status, created, sent, total FROM " . self::table() . "
|
||||
WHERE type = 'message' ORDER BY id DESC LIMIT " . max(1, min(100, $limit)));
|
||||
$st->execute();
|
||||
return $st->fetchAll();
|
||||
}
|
||||
|
||||
/** Stato di più newsletter in una sola query: [id => status]. */
|
||||
public static function statuses(array $ids): array
|
||||
{
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $ids))));
|
||||
if (!$ids) return [];
|
||||
$ph = implode(',', array_fill(0, count($ids), '?'));
|
||||
$st = self::db()->prepare("SELECT id, status FROM " . self::table() . " WHERE id IN ($ph)");
|
||||
$st->execute($ids);
|
||||
$out = [];
|
||||
foreach ($st->fetchAll() as $r) $out[(int) $r['id']] = $r['status'];
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** Legge una newsletter del plugin e la converte in blocchi YogiBook. */
|
||||
public static function import(int $id): array
|
||||
{
|
||||
$st = self::db()->prepare("SELECT id, subject, message, options, editor FROM " . self::table() . " WHERE id = ?");
|
||||
$st->execute([$id]);
|
||||
$row = $st->fetch();
|
||||
if (!$row) {
|
||||
throw new RuntimeException('Newsletter non trovata in WordPress.');
|
||||
}
|
||||
|
||||
$opts = self::unserializeOptions($row['options']);
|
||||
|
||||
return [
|
||||
'subject' => (string) $row['subject'],
|
||||
'preheader' => (string) ($opts['preheader'] ?? ''),
|
||||
'blocks' => NewsletterRenderer::fromComposerHtml((string) $row['message']),
|
||||
'settings' => NewsletterRenderer::settingsFromWpOptions($opts),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Crea o aggiorna la bozza in The Newsletter.
|
||||
* Aggiorna solo se è ancora "new" (mai partita). Restituisce [id, created(bool)].
|
||||
*/
|
||||
public static function push(?int $wpId, string $subject, string $preheader, string $html, string $text, array $settings): array
|
||||
{
|
||||
$db = self::db();
|
||||
$composerOpts = NewsletterRenderer::wpComposerOptions($settings);
|
||||
|
||||
if ($wpId) {
|
||||
$st = $db->prepare("SELECT status, options FROM " . self::table() . " WHERE id = ?");
|
||||
$st->execute([$wpId]);
|
||||
$row = $st->fetch();
|
||||
|
||||
if ($row) {
|
||||
if ($row['status'] !== 'new') {
|
||||
throw new RuntimeException('In WordPress questa newsletter è già "' . $row['status'] . '": non la modifico. Duplica la bozza per crearne una nuova.');
|
||||
}
|
||||
|
||||
// Mantiene tutto quello scelto in WordPress (liste, mittente, utm...)
|
||||
$opts = array_merge(self::unserializeOptions($row['options']), $composerOpts, ['preheader' => $preheader]);
|
||||
unset($opts['error_message']);
|
||||
|
||||
$up = $db->prepare("UPDATE " . self::table() . "
|
||||
SET subject = ?, message = ?, message_text = ?, options = ?, editor = 2, updated = ?
|
||||
WHERE id = ? AND status = 'new'");
|
||||
$up->execute([$subject, $html, $text, serialize($opts), time(), $wpId]);
|
||||
|
||||
return [$wpId, false];
|
||||
}
|
||||
// Cancellata in WordPress: la ricreiamo
|
||||
}
|
||||
|
||||
// Nuova: il pubblico parte uguale all'ultima newsletter (si controlla in WordPress)
|
||||
$base = [];
|
||||
$last = $db->query("SELECT options FROM " . self::table() . " WHERE type = 'message' ORDER BY id DESC LIMIT 1")->fetchColumn();
|
||||
if ($last) {
|
||||
$prev = self::unserializeOptions($last);
|
||||
foreach (['status', 'wp_users', 'lists', 'lists_operator', 'lists_exclude', 'countries', 'regions', 'cities',
|
||||
'sender_email', 'sender_name', 'text_message_mode', 'utm_source', 'utm_campaign', 'utm_medium', 'utm_term', 'utm_content'] as $k) {
|
||||
if (array_key_exists($k, $prev)) $base[$k] = $prev[$k];
|
||||
}
|
||||
}
|
||||
$opts = array_merge(['status' => 'C', 'wp_users' => '0', 'lists' => [], 'lists_operator' => 'or', 'lists_exclude' => []], $base, $composerOpts, ['preheader' => $preheader]);
|
||||
|
||||
$ins = $db->prepare("INSERT INTO " . self::table() . "
|
||||
(language, subject, message, subject2, message2, name2, status, total, last_id, sent, track, list,
|
||||
type, query, editor, sex, theme, message_text, preferences, send_on, token, options, private, version, updated)
|
||||
VALUES ('', ?, ?, '', '', '', 'new', 0, 0, 0, 1, 0,
|
||||
'message', '', 2, '', '', ?, '', ?, '', ?, 0, '', ?)");
|
||||
$ins->execute([$subject, $html, $text, time(), serialize($opts), time()]);
|
||||
|
||||
return [(int) $db->lastInsertId(), true];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns="https://www.w3.org/1999/xhtml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
<head>
|
||||
<title>{email_subject}</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="format-detection" content="address=no">
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="format-detection" content="email=no">
|
||||
<meta name="x-apple-disable-message-reformatting">
|
||||
<!--[if gte mso 9]><xml><o:OfficeDocumentSettings><o:AllowPNG/><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml><![endif]-->
|
||||
<style type="text/css">
|
||||
#outlook a{padding:0;}
|
||||
.ReadMsgBody{width:100%;} .ExternalClass{width:100%;}
|
||||
.ExternalClass, .ExternalClass p, .ExternalClass span, .ExternalClass font, .ExternalClass td, .ExternalClass div {line-height: 100%;}
|
||||
|
||||
body { margin: 0; padding: 0; height: 100%!important; width: 100%!important; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; mso-line-height-rule: exactly;}
|
||||
table,td { border-collapse: collapse !important; mso-table-lspace: 0pt; mso-table-rspace: 0pt;}
|
||||
img { border: 0; height: auto; line-height: 100%; outline: none; text-decoration: none; max-width: 100%!important; -ms-interpolation-mode: bicubic;}
|
||||
img.aligncenter { display: block; margin: 0 auto;}
|
||||
/* TinyMCE of WP */
|
||||
.alignleft { float: left; margin: 7px 15px 7px 0px; }
|
||||
.alignright { float: right; margin: 7px 0px 7px 15px; }
|
||||
.aligncenter { display: block; margin-left: auto; margin-right: auto; }
|
||||
.screen-reader-text { display: none }
|
||||
figcaption { display: block; }
|
||||
@media screen and (max-width: 525px) {
|
||||
.wrapper { padding-left: px; padding-right: px; padding-bottom: px; padding-top: px;}
|
||||
.m-d-block {display: block}
|
||||
.m-d-none {display: none; visibility: hidden; color: transparent;}
|
||||
.m-pb-1 { padding-bottom: 16px!important; }
|
||||
.p-0 { padding: 0 !important; }
|
||||
.pt-1, .padding-top-15 { padding-top: 15px!important; }
|
||||
.pb-1, .padding-bottom-15 { padding-bottom: 15px!important; }
|
||||
.dh { display: hidden }
|
||||
.fs-90 {
|
||||
font-size: 90%;
|
||||
}
|
||||
.responsive { width:100%!important; }
|
||||
table.responsive { width:100%!important; float: none; display: table; padding-left: 0; padding-right: 0; }
|
||||
table[class="responsive"] { width:100%!important; float: none; display: table; padding-left: 0; padding-right: 0; }
|
||||
img { max-width: 100%!important }
|
||||
img[class="responsive"] { max-width: 100%!important; }
|
||||
/* "width: auto" restores the natural dimensions forced with attributes for Outlook */
|
||||
.fluid { max-width: 100%!important; width: auto; }
|
||||
img[class="fluid"] { max-width: 100%!important; width: auto; }
|
||||
|
||||
.block { display: block; }
|
||||
|
||||
td[class="responsive"]{width:100%!important; max-width: 100%!important; display: block; padding-left: 0 !important; padding-right: 0!important; float: none; }
|
||||
td.responsive { width:100%!important; max-width: 100%!important; display: block; padding-left: 0 !important; padding-right: 0!important; float: none; }
|
||||
|
||||
td[class="section-padding-bottom-image"]{
|
||||
padding: 50px 15px 0 15px !important;
|
||||
}
|
||||
/* For grids */
|
||||
.max-width-100 { max-width: 100%!important; }
|
||||
|
||||
/* Obsolete */
|
||||
.tnp-grid-column {
|
||||
max-width: 100%!important;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/* Custom CSS */
|
||||
/* End Custom CSS */
|
||||
|
||||
|
||||
/* Text */
|
||||
.text p {
|
||||
margin: 0 0 15px 0 !important;
|
||||
}
|
||||
.text a {
|
||||
color: {{LINK_COLOR}};
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Html */
|
||||
.html-td-global p {
|
||||
font-family: Helvetica, Arial, sans-serif;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
|
||||
/* Last posts */
|
||||
@media (max-width: 525px) {
|
||||
.posts-1-column {
|
||||
width: 100%!important;
|
||||
}
|
||||
.posts-1-image {
|
||||
width: 100%!important;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
</head>
|
||||
@@ -184,6 +184,12 @@
|
||||
<span class="menu-item">Cancellazioni</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="newsletter.php" class="">
|
||||
<i class="bx bx-slider-alt icon nav-icon"></i>
|
||||
<span class="menu-item">Newsletter</span>
|
||||
</a>
|
||||
</li>
|
||||
<!-- Template (demo) -->
|
||||
<li class="menu-title" data-key="t-applications">Template</li>
|
||||
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
<?php
|
||||
require_once('include/headscript.php');
|
||||
require_once(__DIR__ . '/class/mailer.php');
|
||||
require_once(__DIR__ . '/class/NewsletterRenderer.php');
|
||||
require_once(__DIR__ . '/class/NewsletterWp.php');
|
||||
|
||||
/**
|
||||
* Elenco delle newsletter scritte in YogiBook.
|
||||
* 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');
|
||||
}
|
||||
|
||||
$drafts = $pdo->query("
|
||||
SELECT d.iddraft, d.title, d.subject, d.updated_at, d.wp_email_id, d.wp_pushed_at,
|
||||
TRIM(CONCAT(COALESCE(u.first_name, ''), ' ', COALESCE(u.last_name, ''))) AS author
|
||||
FROM newsletter_drafts d
|
||||
LEFT JOIN auth_users u ON u.id = d.updated_by
|
||||
WHERE d.deleted_at IS NULL
|
||||
ORDER BY d.updated_at DESC
|
||||
")->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Stato in The Newsletter (una sola query)
|
||||
$wpStatus = [];
|
||||
$wpError = null;
|
||||
if (NewsletterWp::isConfigured()) {
|
||||
try {
|
||||
$wpStatus = NewsletterWp::statuses(array_column($drafts, 'wp_email_id'));
|
||||
} catch (Throwable $e) {
|
||||
$wpError = $e->getMessage();
|
||||
}
|
||||
} else {
|
||||
$wpError = 'Collegamento a WordPress non ancora configurato: puoi scrivere le newsletter, ma non pubblicarle.';
|
||||
}
|
||||
|
||||
$statusLabel = [
|
||||
'new' => ['Bozza in The Newsletter', 'st-new'],
|
||||
'sending' => ['In invio', 'st-sending'],
|
||||
'paused' => ['Invio in pausa', 'st-sending'],
|
||||
'sent' => ['Inviata', 'st-sent'],
|
||||
'error' => ['Errore di invio', 'st-error'],
|
||||
'deleted' => ['Eliminata in WordPress', 'st-error'],
|
||||
];
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="it">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>YogiBook - 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>
|
||||
|
||||
<style>
|
||||
.admin-card { border: none; border-radius: 14px; box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06); }
|
||||
.admin-card .card-body { padding: 26px 30px; }
|
||||
.section-title { font-size: 15px; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; color: #6b7280; margin-bottom: 4px; }
|
||||
.section-lead { color: #9ca3af; font-size: 14px; margin-bottom: 22px; }
|
||||
.nl-table thead th { font-size: 12px; text-transform: uppercase; letter-spacing: .03em; color: #6b7280; }
|
||||
.nl-table td { vertical-align: middle; }
|
||||
.nl-title { font-weight: 600; color: #1f2937; }
|
||||
.nl-subject { display: block; font-size: 13px; color: #6b7280; }
|
||||
.st { display: inline-block; border-radius: 20px; padding: 3px 10px; font-size: 12px; font-weight: 600; white-space: nowrap; }
|
||||
.st-none { background: #eef0f3; color: #4b5563; }
|
||||
.st-new { background: #c4e1fb; color: #1a4f7a; }
|
||||
.st-sending { background: #fbe4c4; color: #8a5a1a; }
|
||||
.st-sent { background: #cdfbc4; color: #1a7f2e; }
|
||||
.st-error { background: #fbc7c4; color: #a12820; }
|
||||
.wp-list { max-height: 380px; overflow-y: auto; text-align: left; }
|
||||
.wp-item { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 9px 4px; border-bottom: 1px solid #f1f2f4; }
|
||||
.wp-item small { color: #9ca3af; }
|
||||
</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">Newsletter</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex">
|
||||
<?php include('include/languageselection.php'); ?>
|
||||
<?php include('include/profiletopbar.php'); ?>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<?php include('include/sidebar.php'); ?>
|
||||
|
||||
<div class="main-content">
|
||||
<div class="page-content">
|
||||
<div class="container-fluid">
|
||||
<?php if ($wpError) : ?>
|
||||
<div class="alert alert-warning"><i class="fas fa-plug me-1"></i> <?php echo e($wpError); ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div class="card admin-card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-start flex-wrap gap-2">
|
||||
<div>
|
||||
<div class="section-title">Newsletter</div>
|
||||
<p class="section-lead mb-3">Scrivi qui, con salvataggio automatico. Poi pubblica su The Newsletter e invia da WordPress come sempre.</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-outline-secondary" id="btnImport" <?php echo NewsletterWp::isConfigured() ? '' : 'disabled'; ?>>
|
||||
<i class="fas fa-file-import me-1"></i> Importa da The Newsletter
|
||||
</button>
|
||||
<button class="btn btn-primary" id="btnNew"><i class="fas fa-plus me-1"></i> Nuova newsletter</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table nl-table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Newsletter</th>
|
||||
<th>Ultima modifica</th>
|
||||
<th>The Newsletter</th>
|
||||
<th class="text-end">Azioni</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (!$drafts) : ?>
|
||||
<tr><td colspan="4" class="text-center text-muted py-4">Nessuna newsletter. Creane una nuova o importane una da The Newsletter.</td></tr>
|
||||
<?php endif; ?>
|
||||
<?php foreach ($drafts as $d) :
|
||||
$wpId = $d['wp_email_id'] !== null ? (int) $d['wp_email_id'] : null;
|
||||
$st = $wpId ? ($wpStatus[$wpId] ?? null) : null;
|
||||
[$stText, $stClass] = $wpId
|
||||
? ($statusLabel[$st ?? ''] ?? ['Pubblicata (n. ' . $wpId . ')', 'st-new'])
|
||||
: ['Non pubblicata', 'st-none'];
|
||||
?>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="newsletter_editor.php?id=<?php echo (int) $d['iddraft']; ?>" class="nl-title"><?php echo e($d['title'] ?: '(senza titolo)'); ?></a>
|
||||
<span class="nl-subject"><?php echo e($d['subject'] ?: 'Oggetto non ancora scritto'); ?></span>
|
||||
</td>
|
||||
<td>
|
||||
<?php echo e(date('d/m/Y H:i', strtotime($d['updated_at']))); ?>
|
||||
<?php if ($d['author']) : ?><span class="nl-subject"><?php echo e($d['author']); ?></span><?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<span class="st <?php echo $stClass; ?>"><?php echo e($stText); ?></span>
|
||||
<?php if ($wpId) : ?><span class="nl-subject">n. <?php echo $wpId; ?></span><?php endif; ?>
|
||||
</td>
|
||||
<td class="text-end text-nowrap">
|
||||
<a href="newsletter_editor.php?id=<?php echo (int) $d['iddraft']; ?>" class="btn btn-sm btn-primary"><i class="fas fa-pen"></i> Apri</a>
|
||||
<button class="btn btn-sm btn-outline-secondary js-dup" data-id="<?php echo (int) $d['iddraft']; ?>" title="Duplica"><i class="fas fa-copy"></i></button>
|
||||
<button class="btn btn-sm btn-outline-danger js-del" data-id="<?php echo (int) $d['iddraft']; ?>" data-title="<?php echo e($d['title']); ?>" title="Elimina"><i class="fas fa-trash"></i></button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php include('include/footer.php'); ?>
|
||||
</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>
|
||||
const API = 'newsletter_api.php';
|
||||
|
||||
async function api(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 || !j.success) throw new Error((j && j.message) || 'Errore del server.');
|
||||
return j;
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
|
||||
document.getElementById('btnNew').addEventListener('click', async () => {
|
||||
const r = await Swal.fire({
|
||||
title: 'Nuova newsletter',
|
||||
input: 'text',
|
||||
inputLabel: 'Nome interno (lo vedi solo tu)',
|
||||
inputValue: 'Newsletter del ' + new Date().toLocaleDateString('it-IT'),
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Crea',
|
||||
cancelButtonText: 'Annulla'
|
||||
});
|
||||
if (!r.isConfirmed) return;
|
||||
try {
|
||||
const j = await api('create', { title: r.value });
|
||||
location.href = 'newsletter_editor.php?id=' + j.id;
|
||||
} catch (e) { Swal.fire('Errore', e.message, 'error'); }
|
||||
});
|
||||
|
||||
document.querySelectorAll('.js-dup').forEach(b => b.addEventListener('click', async () => {
|
||||
try {
|
||||
const j = await api('duplicate', { id: b.dataset.id });
|
||||
location.href = 'newsletter_editor.php?id=' + j.id;
|
||||
} catch (e) { Swal.fire('Errore', e.message, 'error'); }
|
||||
}));
|
||||
|
||||
document.querySelectorAll('.js-del').forEach(b => b.addEventListener('click', async () => {
|
||||
const r = await Swal.fire({
|
||||
title: 'Eliminare la newsletter?',
|
||||
html: '<strong>' + esc(b.dataset.title) + '</strong><br><small>Quella eventualmente pubblicata in The Newsletter non viene toccata.</small>',
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: '#d33',
|
||||
confirmButtonText: 'Elimina',
|
||||
cancelButtonText: 'Annulla'
|
||||
});
|
||||
if (!r.isConfirmed) return;
|
||||
try { await api('delete', { id: b.dataset.id }); location.reload(); } catch (e) { Swal.fire('Errore', e.message, 'error'); }
|
||||
}));
|
||||
|
||||
document.getElementById('btnImport').addEventListener('click', async () => {
|
||||
Swal.fire({ title: 'Carico le newsletter…', didOpen: () => Swal.showLoading() });
|
||||
let list;
|
||||
try {
|
||||
const r = await fetch(API + '?action=list_wp', { credentials: 'same-origin' });
|
||||
const j = await r.json();
|
||||
if (!j.success) throw new Error(j.message);
|
||||
list = j.emails;
|
||||
} catch (e) { return Swal.fire('Errore', e.message || 'Impossibile leggere The Newsletter.', 'error'); }
|
||||
|
||||
const labels = { new: 'bozza', sending: 'in invio', paused: 'in pausa', sent: 'inviata', error: 'errore' };
|
||||
const html = '<div class="wp-list">' + list.map(m =>
|
||||
'<div class="wp-item"><div><strong>' + esc(m.subject || '(senza oggetto)') + '</strong><br>' +
|
||||
'<small>n. ' + m.id + ' · ' + esc(labels[m.status] || m.status) + ' · ' + esc((m.created || '').slice(0, 10)) + '</small></div>' +
|
||||
'<button class="btn btn-sm btn-primary js-imp" data-id="' + m.id + '">Importa</button></div>'
|
||||
).join('') + '</div>' +
|
||||
'<p class="mt-3 mb-0" style="font-size:13px;color:#6b7280;">Le bozze non ancora inviate restano collegate: "Pubblica" aggiornerà quella stessa newsletter.</p>';
|
||||
|
||||
Swal.fire({
|
||||
title: 'Importa da The Newsletter',
|
||||
html,
|
||||
width: 640,
|
||||
showConfirmButton: false,
|
||||
showCloseButton: true,
|
||||
didOpen: (el) => {
|
||||
el.querySelectorAll('.js-imp').forEach(b => b.addEventListener('click', async () => {
|
||||
b.disabled = true;
|
||||
b.textContent = '…';
|
||||
try {
|
||||
const j = await api('import_wp', { wp_id: b.dataset.id });
|
||||
location.href = 'newsletter_editor.php?id=' + j.id;
|
||||
} catch (e) { Swal.fire('Errore', e.message, 'error'); }
|
||||
}));
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,435 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* API dell'editor newsletter (risponde sempre in JSON, tranne "preview" che restituisce HTML).
|
||||
*
|
||||
* GET action=load&id= bozza completa
|
||||
* POST action=save salvataggio automatico (controllo revisione)
|
||||
* GET action=versions&id= storico versioni
|
||||
* GET action=version_html&idversion= anteprima di una versione (HTML)
|
||||
* POST action=snapshot salva una versione con nome
|
||||
* POST action=restore ripristina una versione
|
||||
* POST action=preview HTML di anteprima + controlli
|
||||
* POST action=upload_image carica e ottimizza un'immagine
|
||||
* POST action=send_test invia una prova
|
||||
* POST action=publish crea/aggiorna la bozza in The Newsletter
|
||||
* POST action=create|duplicate|delete
|
||||
* GET action=list_wp newsletter presenti in WordPress
|
||||
* POST action=import_wp importa una newsletter di WordPress
|
||||
*
|
||||
* NB: la protezione della pagina (solo staff) va aggiunta come per le altre pagine admin.
|
||||
*/
|
||||
ob_start();
|
||||
require_once('include/headscript.php');
|
||||
require_once(__DIR__ . '/class/mailer.php');
|
||||
require_once(__DIR__ . '/class/NewsletterRenderer.php');
|
||||
require_once(__DIR__ . '/class/NewsletterWp.php');
|
||||
|
||||
function out(array $payload, int $code = 200): void
|
||||
{
|
||||
if (ob_get_length() !== false) ob_end_clean();
|
||||
http_response_code($code);
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
exit;
|
||||
}
|
||||
|
||||
function fail(string $msg, int $code = 400, array $extra = []): void
|
||||
{
|
||||
out(['success' => false, 'message' => $msg] + $extra, $code);
|
||||
}
|
||||
|
||||
$pdo = DBHandlerSelect::getInstance()->getConnection();
|
||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
|
||||
$userId = (int) $iduserlogin;
|
||||
$action = $_REQUEST['action'] ?? '';
|
||||
$isPost = $_SERVER['REQUEST_METHOD'] === 'POST';
|
||||
|
||||
const NL_MAX_JSON = 2 * 1024 * 1024; // 2 MB di blocchi bastano e avanzano
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Funzioni di supporto
|
||||
* ---------------------------------------------------------------------- */
|
||||
|
||||
function draftRow(PDO $pdo, int $id): array
|
||||
{
|
||||
$st = $pdo->prepare("SELECT * FROM newsletter_drafts WHERE iddraft = ? AND deleted_at IS NULL");
|
||||
$st->execute([$id]);
|
||||
$d = $st->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$d) fail('Newsletter non trovata.', 404);
|
||||
return $d;
|
||||
}
|
||||
|
||||
function draftPayload(array $d): array
|
||||
{
|
||||
$wpStatus = null;
|
||||
if (!empty($d['wp_email_id']) && NewsletterWp::isConfigured()) {
|
||||
try {
|
||||
$wpStatus = NewsletterWp::statuses([(int) $d['wp_email_id']])[(int) $d['wp_email_id']] ?? 'deleted';
|
||||
} catch (Throwable $e) {
|
||||
$wpStatus = null;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => (int) $d['iddraft'],
|
||||
'title' => $d['title'],
|
||||
'subject' => $d['subject'],
|
||||
'preheader' => $d['preheader'],
|
||||
'blocks' => json_decode($d['blocks'] ?: '[]', true) ?: [],
|
||||
'settings' => NewsletterRenderer::settings(json_decode($d['settings'] ?: '{}', true) ?: []),
|
||||
'revision' => (int) $d['revision'],
|
||||
'updated_at' => $d['updated_at'],
|
||||
'wp_email_id' => $d['wp_email_id'] !== null ? (int) $d['wp_email_id'] : null,
|
||||
'wp_pushed_at' => $d['wp_pushed_at'],
|
||||
'wp_status' => $wpStatus,
|
||||
];
|
||||
}
|
||||
|
||||
/** Legge e valida blocchi + impostazioni inviati dall'editor. */
|
||||
function readContent(): array
|
||||
{
|
||||
$blocksRaw = (string) ($_POST['blocks'] ?? '[]');
|
||||
$settingsRaw = (string) ($_POST['settings'] ?? '{}');
|
||||
|
||||
if (strlen($blocksRaw) > NL_MAX_JSON) fail('La newsletter è troppo grande.', 413);
|
||||
|
||||
$blocks = json_decode($blocksRaw, true);
|
||||
$settings = json_decode($settingsRaw, true);
|
||||
if (!is_array($blocks) || !is_array($settings)) fail('Dati non validi.', 422);
|
||||
|
||||
$clean = [];
|
||||
foreach ($blocks as $b) {
|
||||
if (!is_array($b) || !in_array($b['type'] ?? '', NewsletterRenderer::BLOCK_TYPES, true)) continue;
|
||||
$b['id'] = preg_replace('/[^a-z0-9]/i', '', (string) ($b['id'] ?? '')) ?: bin2hex(random_bytes(6));
|
||||
$clean[] = $b;
|
||||
}
|
||||
|
||||
return [
|
||||
'title' => mb_substr(trim((string) ($_POST['title'] ?? '')), 0, 255),
|
||||
'subject' => mb_substr(trim((string) ($_POST['subject'] ?? '')), 0, 255),
|
||||
'preheader' => mb_substr(trim((string) ($_POST['preheader'] ?? '')), 0, 255),
|
||||
'blocks' => $clean,
|
||||
'settings' => $settings,
|
||||
];
|
||||
}
|
||||
|
||||
function addVersion(PDO $pdo, array $d, string $note, int $userId): void
|
||||
{
|
||||
$pdo->prepare("INSERT INTO newsletter_draft_versions
|
||||
(iddraft, revision, subject, preheader, blocks, settings, note, created_by, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NOW())")
|
||||
->execute([(int) $d['iddraft'], (int) $d['revision'], $d['subject'], $d['preheader'], $d['blocks'], $d['settings'], mb_substr($note, 0, 100), $userId]);
|
||||
|
||||
// Tiene le ultime 200 versioni per newsletter
|
||||
$pdo->prepare("DELETE FROM newsletter_draft_versions
|
||||
WHERE iddraft = ? AND idversion NOT IN (
|
||||
SELECT idversion FROM (SELECT idversion FROM newsletter_draft_versions WHERE iddraft = ? ORDER BY idversion DESC LIMIT 200) keep
|
||||
)")->execute([(int) $d['iddraft'], (int) $d['iddraft']]);
|
||||
}
|
||||
|
||||
function createDraft(PDO $pdo, int $userId, string $title, string $subject, string $preheader, array $blocks, array $settings, ?int $wpId = null): int
|
||||
{
|
||||
$pdo->prepare("INSERT INTO newsletter_drafts
|
||||
(title, subject, preheader, blocks, settings, revision, wp_email_id, created_by, updated_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, NOW(), NOW())")
|
||||
->execute([$title, $subject, $preheader, json_encode($blocks, JSON_UNESCAPED_UNICODE), json_encode($settings, JSON_UNESCAPED_UNICODE), $wpId, $userId, $userId]);
|
||||
return (int) $pdo->lastInsertId();
|
||||
}
|
||||
|
||||
function baseUrl(): string
|
||||
{
|
||||
if (!empty($_ENV['NEWSLETTER_BASE_URL'])) return rtrim($_ENV['NEWSLETTER_BASE_URL'], '/');
|
||||
$https = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') || ($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https';
|
||||
$dir = rtrim(str_replace('\\', '/', dirname($_SERVER['SCRIPT_NAME'] ?? '/')), '/');
|
||||
return ($https ? 'https' : 'http') . '://' . ($_SERVER['HTTP_HOST'] ?? 'localhost') . $dir;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Azioni
|
||||
* ---------------------------------------------------------------------- */
|
||||
try {
|
||||
switch ($action) {
|
||||
|
||||
case 'load':
|
||||
out(['success' => true, 'draft' => draftPayload(draftRow($pdo, (int) ($_GET['id'] ?? 0)))]);
|
||||
|
||||
case 'save':
|
||||
if (!$isPost) fail('Metodo non consentito.', 405);
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
$rev = (int) ($_POST['revision'] ?? -1);
|
||||
$force = ($_POST['force'] ?? '') === '1';
|
||||
$c = readContent();
|
||||
|
||||
$sql = "UPDATE newsletter_drafts
|
||||
SET title = ?, subject = ?, preheader = ?, blocks = ?, settings = ?,
|
||||
revision = revision + 1, updated_by = ?, updated_at = NOW()
|
||||
WHERE iddraft = ? AND deleted_at IS NULL" . ($force ? '' : ' AND revision = ?');
|
||||
$params = [$c['title'], $c['subject'], $c['preheader'],
|
||||
json_encode($c['blocks'], JSON_UNESCAPED_UNICODE), json_encode($c['settings'], JSON_UNESCAPED_UNICODE),
|
||||
$userId, $id];
|
||||
if (!$force) $params[] = $rev;
|
||||
|
||||
$st = $pdo->prepare($sql);
|
||||
$st->execute($params);
|
||||
|
||||
$d = draftRow($pdo, $id);
|
||||
if ($st->rowCount() === 0) {
|
||||
fail('Questa newsletter è stata salvata da un\'altra parte (un\'altra scheda o un altro computer).', 409, [
|
||||
'conflict' => true, 'revision' => (int) $d['revision'], 'updated_at' => $d['updated_at'],
|
||||
]);
|
||||
}
|
||||
|
||||
// Versione automatica ogni 10 minuti di lavoro
|
||||
$st = $pdo->prepare("SELECT MAX(created_at) FROM newsletter_draft_versions WHERE iddraft = ?");
|
||||
$st->execute([$id]);
|
||||
$last = $st->fetchColumn();
|
||||
if (!$last || strtotime($last) < time() - 600) {
|
||||
addVersion($pdo, $d, 'Automatica', $userId);
|
||||
}
|
||||
|
||||
out(['success' => true, 'revision' => (int) $d['revision'], 'updated_at' => $d['updated_at']]);
|
||||
|
||||
case 'versions':
|
||||
$st = $pdo->prepare("SELECT v.idversion, v.revision, v.note, v.created_at, v.subject,
|
||||
TRIM(CONCAT(COALESCE(u.first_name, ''), ' ', COALESCE(u.last_name, ''))) AS author
|
||||
FROM newsletter_draft_versions v
|
||||
LEFT JOIN auth_users u ON u.id = v.created_by
|
||||
WHERE v.iddraft = ?
|
||||
ORDER BY v.idversion DESC LIMIT 200");
|
||||
$st->execute([(int) ($_GET['id'] ?? 0)]);
|
||||
out(['success' => true, 'versions' => $st->fetchAll(PDO::FETCH_ASSOC)]);
|
||||
|
||||
case 'version_html':
|
||||
$st = $pdo->prepare("SELECT * FROM newsletter_draft_versions WHERE idversion = ?");
|
||||
$st->execute([(int) ($_GET['idversion'] ?? 0)]);
|
||||
$v = $st->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$v) fail('Versione non trovata.', 404);
|
||||
if (ob_get_length() !== false) ob_end_clean();
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
echo NewsletterRenderer::renderDocument(json_decode($v['blocks'] ?: '[]', true) ?: [], json_decode($v['settings'] ?: '{}', true) ?: [], $v['subject'], $v['preheader'], true);
|
||||
exit;
|
||||
|
||||
case 'snapshot':
|
||||
if (!$isPost) fail('Metodo non consentito.', 405);
|
||||
$d = draftRow($pdo, (int) ($_POST['id'] ?? 0));
|
||||
addVersion($pdo, $d, trim((string) ($_POST['note'] ?? '')) ?: 'Salvata a mano', $userId);
|
||||
out(['success' => true]);
|
||||
|
||||
case 'restore':
|
||||
if (!$isPost) fail('Metodo non consentito.', 405);
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
$d = draftRow($pdo, $id);
|
||||
|
||||
$st = $pdo->prepare("SELECT * FROM newsletter_draft_versions WHERE idversion = ? AND iddraft = ?");
|
||||
$st->execute([(int) ($_POST['idversion'] ?? 0), $id]);
|
||||
$v = $st->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$v) fail('Versione non trovata.', 404);
|
||||
|
||||
$pdo->beginTransaction();
|
||||
addVersion($pdo, $d, 'Prima del ripristino', $userId);
|
||||
$pdo->prepare("UPDATE newsletter_drafts SET subject = ?, preheader = ?, blocks = ?, settings = ?,
|
||||
revision = revision + 1, updated_by = ?, updated_at = NOW() WHERE iddraft = ?")
|
||||
->execute([$v['subject'], $v['preheader'], $v['blocks'], $v['settings'], $userId, $id]);
|
||||
$pdo->commit();
|
||||
|
||||
out(['success' => true, 'draft' => draftPayload(draftRow($pdo, $id))]);
|
||||
|
||||
case 'preview':
|
||||
if (!$isPost) fail('Metodo non consentito.', 405);
|
||||
$c = readContent();
|
||||
$real = NewsletterRenderer::renderDocument($c['blocks'], $c['settings'], $c['subject'], $c['preheader']);
|
||||
out([
|
||||
'success' => true,
|
||||
'html' => NewsletterRenderer::renderDocument($c['blocks'], $c['settings'], $c['subject'], $c['preheader'], true),
|
||||
'kb' => (int) round(strlen($real) / 1024),
|
||||
'checks' => NewsletterRenderer::checks($c['blocks'], $c['subject'], $c['preheader'], $real),
|
||||
]);
|
||||
|
||||
case 'upload_image':
|
||||
if (!$isPost) fail('Metodo non consentito.', 405);
|
||||
$f = $_FILES['image'] ?? null;
|
||||
if (!$f || $f['error'] !== UPLOAD_ERR_OK) fail('Caricamento non riuscito.');
|
||||
if ($f['size'] > 15 * 1024 * 1024) fail('Immagine troppo grande (massimo 15 MB).');
|
||||
|
||||
$info = @getimagesize($f['tmp_name']);
|
||||
$mime = $info['mime'] ?? '';
|
||||
if (!in_array($mime, ['image/jpeg', 'image/png', 'image/gif', 'image/webp'], true)) {
|
||||
fail('Formato non supportato: usa JPG, PNG, GIF o WebP.');
|
||||
}
|
||||
|
||||
$dirRel = 'assets/uploads/newsletter/' . date('Y/m');
|
||||
$dirAbs = __DIR__ . '/' . $dirRel;
|
||||
if (!is_dir($dirAbs) && !mkdir($dirAbs, 0755, true)) fail('Impossibile creare la cartella delle immagini.', 500);
|
||||
|
||||
$name = date('Ymd_His') . '_' . bin2hex(random_bytes(4));
|
||||
[$w, $h] = $info;
|
||||
|
||||
if ($mime === 'image/gif') {
|
||||
// Le GIF possono essere animate: le teniamo come sono
|
||||
$file = $name . '.gif';
|
||||
move_uploaded_file($f['tmp_name'], $dirAbs . '/' . $file);
|
||||
} else {
|
||||
$src = match ($mime) {
|
||||
'image/jpeg' => imagecreatefromjpeg($f['tmp_name']),
|
||||
'image/png' => imagecreatefrompng($f['tmp_name']),
|
||||
'image/webp' => imagecreatefromwebp($f['tmp_name']),
|
||||
};
|
||||
if (!$src) fail('Immagine non leggibile.');
|
||||
|
||||
// Foto dal telefono: raddrizza secondo l'orientamento EXIF
|
||||
if ($mime === 'image/jpeg' && function_exists('exif_read_data')) {
|
||||
$o = (int) (@exif_read_data($f['tmp_name'])['Orientation'] ?? 1);
|
||||
$rot = [3 => 180, 6 => -90, 8 => 90][$o] ?? 0;
|
||||
if ($rot) { $src = imagerotate($src, $rot, 0); $w = imagesx($src); $h = imagesy($src); }
|
||||
}
|
||||
|
||||
$maxW = 1200; // il doppio della larghezza email: nitida sugli schermi retina
|
||||
$nw = min($w, $maxW);
|
||||
$nh = (int) round($h * $nw / $w);
|
||||
|
||||
// PNG con trasparenza resta PNG, tutto il resto diventa JPG (molto più leggero)
|
||||
$keepPng = false;
|
||||
if ($mime === 'image/png') {
|
||||
$ct = ord(file_get_contents($f['tmp_name'], false, null, 25, 1));
|
||||
$keepPng = in_array($ct, [4, 6], true);
|
||||
}
|
||||
|
||||
$dst = imagecreatetruecolor($nw, $nh);
|
||||
if ($keepPng) {
|
||||
imagealphablending($dst, false);
|
||||
imagesavealpha($dst, true);
|
||||
} else {
|
||||
imagefill($dst, 0, 0, imagecolorallocate($dst, 255, 255, 255));
|
||||
}
|
||||
imagecopyresampled($dst, $src, 0, 0, 0, 0, $nw, $nh, $w, $h);
|
||||
|
||||
$file = $name . ($keepPng ? '.png' : '.jpg');
|
||||
$ok = $keepPng ? imagepng($dst, $dirAbs . '/' . $file, 9) : imagejpeg($dst, $dirAbs . '/' . $file, 82);
|
||||
imagedestroy($src);
|
||||
imagedestroy($dst);
|
||||
if (!$ok) fail('Impossibile salvare l\'immagine.', 500);
|
||||
[$w, $h] = [$nw, $nh];
|
||||
}
|
||||
|
||||
out([
|
||||
'success' => true,
|
||||
'url' => baseUrl() . '/' . $dirRel . '/' . $file,
|
||||
'width' => $w,
|
||||
'height' => $h,
|
||||
'kb' => (int) round(filesize($dirAbs . '/' . $file) / 1024),
|
||||
]);
|
||||
|
||||
case 'send_test':
|
||||
if (!$isPost) fail('Metodo non consentito.', 405);
|
||||
$d = draftRow($pdo, (int) ($_POST['id'] ?? 0));
|
||||
$to = trim((string) ($_POST['to'] ?? ''));
|
||||
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) fail('Indirizzo email non valido.');
|
||||
|
||||
$p = draftPayload($d);
|
||||
$html = NewsletterRenderer::renderDocument($p['blocks'], $p['settings'], $p['subject'], $p['preheader'], true);
|
||||
|
||||
$clean = fn($v) => trim((string) $v, "\" \t\n\r\0\x0B");
|
||||
$mail = new \PHPMailer\PHPMailer\PHPMailer(true);
|
||||
$mail->isSMTP();
|
||||
$mail->Host = $clean($_ENV['MAIL_HOST'] ?? '');
|
||||
$mail->SMTPAuth = true;
|
||||
$mail->Username = $_ENV['MAIL_USERNAME'] ?? '';
|
||||
$mail->Password = $_ENV['MAIL_PASSWORD'] ?? '';
|
||||
$mail->SMTPSecure = $clean($_ENV['MAIL_ENCRYPTION'] ?? 'ssl');
|
||||
$mail->Port = (int) ($_ENV['MAIL_PORT'] ?? 465);
|
||||
$mail->Timeout = 20;
|
||||
$mail->CharSet = 'UTF-8';
|
||||
$mail->setFrom($clean($_ENV['MAIL_FROM_ADDRESS'] ?? ''), $clean($_ENV['MAIL_FROM_NAME'] ?? 'YogaSoul'));
|
||||
$mail->addAddress($to);
|
||||
$mail->isHTML(true);
|
||||
$mail->Subject = '[PROVA] ' . ($p['subject'] ?: '(senza oggetto)');
|
||||
$mail->Body = $html;
|
||||
$mail->AltBody = str_replace('{name}', 'Maria', NewsletterRenderer::renderText($p['blocks'], $p['preheader']));
|
||||
$mail->send();
|
||||
|
||||
out(['success' => true, 'message' => 'Prova inviata a ' . $to . '.']);
|
||||
|
||||
case 'publish':
|
||||
if (!$isPost) fail('Metodo non consentito.', 405);
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
$d = draftRow($pdo, $id);
|
||||
$p = draftPayload($d);
|
||||
|
||||
$html = NewsletterRenderer::renderDocument($p['blocks'], $p['settings'], $p['subject'], $p['preheader']);
|
||||
$checks = NewsletterRenderer::checks($p['blocks'], $p['subject'], $p['preheader'], $html);
|
||||
$errors = array_filter($checks, fn($c) => $c[0] === 'error');
|
||||
|
||||
if ($errors && ($_POST['confirm'] ?? '') !== '1') {
|
||||
out(['success' => false, 'needs_confirm' => true, 'checks' => $checks, 'message' => 'Ci sono problemi da controllare.']);
|
||||
}
|
||||
if (trim($p['subject']) === '') fail("Manca l'oggetto: in WordPress non si può inviare senza.");
|
||||
|
||||
$text = NewsletterRenderer::renderText($p['blocks'], $p['preheader']);
|
||||
[$wpId, $created] = NewsletterWp::push($p['wp_email_id'], $p['subject'], $p['preheader'], $html, $text, $p['settings']);
|
||||
|
||||
$pdo->prepare("UPDATE newsletter_drafts SET wp_email_id = ?, wp_pushed_at = NOW() WHERE iddraft = ?")->execute([$wpId, $id]);
|
||||
addVersion($pdo, $d, 'Pubblicata su The Newsletter', $userId);
|
||||
|
||||
out([
|
||||
'success' => true,
|
||||
'wp_id' => $wpId,
|
||||
'created' => $created,
|
||||
'admin_url' => NewsletterWp::adminUrl(),
|
||||
'message' => $created
|
||||
? "Creata in The Newsletter (n. $wpId). Il pubblico è impostato come l'ultima newsletter: controllalo prima di inviare."
|
||||
: "Aggiornata in The Newsletter (n. $wpId).",
|
||||
]);
|
||||
|
||||
case 'create':
|
||||
if (!$isPost) fail('Metodo non consentito.', 405);
|
||||
$title = trim((string) ($_POST['title'] ?? '')) ?: 'Newsletter del ' . date('d/m/Y');
|
||||
$id = createDraft($pdo, $userId, $title, '', '', NewsletterRenderer::starterBlocks(), NewsletterRenderer::defaultSettings());
|
||||
out(['success' => true, 'id' => $id]);
|
||||
|
||||
case 'duplicate':
|
||||
if (!$isPost) fail('Metodo non consentito.', 405);
|
||||
$d = draftRow($pdo, (int) ($_POST['id'] ?? 0));
|
||||
$blocks = json_decode($d['blocks'] ?: '[]', true) ?: [];
|
||||
foreach ($blocks as &$b) $b['id'] = bin2hex(random_bytes(6));
|
||||
unset($b);
|
||||
$id = createDraft($pdo, $userId, 'Copia di ' . $d['title'], $d['subject'], $d['preheader'], $blocks, json_decode($d['settings'] ?: '{}', true) ?: []);
|
||||
out(['success' => true, 'id' => $id]);
|
||||
|
||||
case 'delete':
|
||||
if (!$isPost) fail('Metodo non consentito.', 405);
|
||||
$pdo->prepare("UPDATE newsletter_drafts SET deleted_at = NOW(), updated_by = ? WHERE iddraft = ?")
|
||||
->execute([$userId, (int) ($_POST['id'] ?? 0)]);
|
||||
out(['success' => true]);
|
||||
|
||||
case 'list_wp':
|
||||
out(['success' => true, 'emails' => NewsletterWp::listEmails(40)]);
|
||||
|
||||
case 'import_wp':
|
||||
if (!$isPost) fail('Metodo non consentito.', 405);
|
||||
$wpId = (int) ($_POST['wp_id'] ?? 0);
|
||||
$imp = NewsletterWp::import($wpId);
|
||||
// Se in WordPress non è ancora partita, la colleghiamo: "Pubblica" aggiornerà quella
|
||||
$status = NewsletterWp::statuses([$wpId])[$wpId] ?? '';
|
||||
$id = createDraft($pdo, $userId, $imp['subject'] ?: 'Importata da WordPress', $imp['subject'], $imp['preheader'],
|
||||
$imp['blocks'], $imp['settings'], $status === 'new' ? $wpId : null);
|
||||
out(['success' => true, 'id' => $id, 'linked' => $status === 'new']);
|
||||
|
||||
default:
|
||||
fail('Azione sconosciuta.', 404);
|
||||
}
|
||||
} catch (\PHPMailer\PHPMailer\Exception $e) {
|
||||
fail('Invio non riuscito: ' . $e->getMessage(), 502);
|
||||
} catch (PDOException $e) {
|
||||
if ($pdo->inTransaction()) $pdo->rollBack();
|
||||
error_log('newsletter_api [' . $action . '] DB: ' . $e->getMessage());
|
||||
fail('Errore del database. Riprova; se continua controlla il log.', 500);
|
||||
} catch (RuntimeException $e) {
|
||||
fail($e->getMessage(), 409);
|
||||
} catch (Throwable $e) {
|
||||
if ($pdo->inTransaction()) $pdo->rollBack();
|
||||
error_log('newsletter_api [' . $action . ']: ' . $e->getMessage() . ' @' . $e->getFile() . ':' . $e->getLine());
|
||||
fail('Errore del server. Riprova; se continua controlla il log.', 500);
|
||||
}
|
||||
@@ -0,0 +1,998 @@
|
||||
<?php
|
||||
require_once('include/headscript.php');
|
||||
require_once(__DIR__ . '/class/mailer.php');
|
||||
require_once(__DIR__ . '/class/NewsletterRenderer.php');
|
||||
require_once(__DIR__ . '/class/NewsletterWp.php');
|
||||
|
||||
/**
|
||||
* Editor delle newsletter: blocchi + anteprima dal vivo + salvataggio automatico.
|
||||
* NB: protezione "solo staff" da aggiungere come per le altre pagine admin.
|
||||
*/
|
||||
$pdo = DBHandlerSelect::getInstance()->getConnection();
|
||||
|
||||
function e($value): string
|
||||
{
|
||||
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
$id = (int) ($_GET['id'] ?? 0);
|
||||
$st = $pdo->prepare("SELECT iddraft FROM newsletter_drafts WHERE iddraft = ? AND deleted_at IS NULL");
|
||||
$st->execute([$id]);
|
||||
if (!$st->fetchColumn()) {
|
||||
header('Location: newsletter.php');
|
||||
exit;
|
||||
}
|
||||
|
||||
// Email dell'utente collegato (proposta per l'invio di prova)
|
||||
$st = $pdo->prepare("SELECT email FROM auth_users WHERE id = ?");
|
||||
$st->execute([(int) $iduserlogin]);
|
||||
$myEmail = (string) $st->fetchColumn();
|
||||
|
||||
// Valori predefiniti dei blocchi, presi dal PHP (una sola fonte)
|
||||
$templates = [];
|
||||
foreach (NewsletterRenderer::BLOCK_TYPES as $t) {
|
||||
if ($t === 'raw') continue;
|
||||
$b = NewsletterRenderer::newBlock($t);
|
||||
unset($b['id']);
|
||||
$templates[$t] = $b;
|
||||
}
|
||||
|
||||
$jsonFlags = JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE;
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="it">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>YogiBook - Editor newsletter</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="shortcut icon" href="assets/images/favicon.ico">
|
||||
<link href="assets/css/bootstrap.min.css" id="bootstrap-style" rel="stylesheet" type="text/css" />
|
||||
<link href="assets/css/icons.min.css" rel="stylesheet" type="text/css" />
|
||||
<link href="assets/css/app.min.css" id="app-style" rel="stylesheet" type="text/css" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/tinymce@6.8.3/tinymce.min.js" referrerpolicy="origin"></script>
|
||||
|
||||
<style>
|
||||
:root { --nl-border: #e6e8ec; --nl-muted: #6b7280; --nl-accent: #1ebf73; }
|
||||
|
||||
.nl-top { position: sticky; top: 70px; z-index: 20; background: #fff; border-radius: 14px; box-shadow: 0 4px 20px rgba(0,0,0,.06); padding: 14px 18px; margin-bottom: 16px; }
|
||||
.nl-top .form-control { border-radius: 8px; }
|
||||
.nl-status { font-size: 13px; color: var(--nl-muted); white-space: nowrap; }
|
||||
.nl-status .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 5px; background: #cbd5e1; vertical-align: middle; }
|
||||
.nl-status.ok .dot { background: var(--nl-accent); }
|
||||
.nl-status.busy .dot { background: #f0ad4e; }
|
||||
.nl-status.err .dot { background: #dc3545; }
|
||||
.nl-counter { font-size: 11px; color: var(--nl-muted); }
|
||||
.nl-counter.warn { color: #c0392b; font-weight: 600; }
|
||||
|
||||
.nl-card { background: #fff; border: 1px solid var(--nl-border); border-radius: 12px; margin-bottom: 10px; overflow: hidden; }
|
||||
.nl-card.open { border-color: var(--nl-accent); box-shadow: 0 0 0 3px rgba(30,191,115,.12); }
|
||||
.nl-card-head { display: flex; align-items: center; gap: 10px; padding: 10px 12px; cursor: pointer; user-select: none; }
|
||||
.nl-card-head .ico { width: 30px; height: 30px; border-radius: 8px; background: #f1f5f9; display: flex; align-items: center; justify-content: center; color: #475569; flex: 0 0 30px; }
|
||||
.nl-card-head .lbl { font-weight: 600; color: #1f2937; font-size: 14px; }
|
||||
.nl-card-head .sum { font-size: 12px; color: var(--nl-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.nl-card-head .txt { flex: 1; min-width: 0; }
|
||||
.nl-card-head .acts { display: flex; gap: 2px; flex: 0 0 auto; }
|
||||
.nl-card-head .acts button { border: 0; background: transparent; color: #64748b; width: 28px; height: 28px; border-radius: 6px; }
|
||||
.nl-card-head .acts button:hover { background: #f1f5f9; color: #111827; }
|
||||
.nl-card-head .acts button.del:hover { background: #fdecec; color: #c0392b; }
|
||||
.nl-card-body { display: none; padding: 4px 14px 14px; border-top: 1px solid var(--nl-border); }
|
||||
.nl-card.open .nl-card-body { display: block; }
|
||||
.nl-field { margin-top: 10px; }
|
||||
.nl-field label { font-size: 12px; font-weight: 600; color: #374151; margin-bottom: 3px; display: block; }
|
||||
.nl-field .form-control, .nl-field .form-select { font-size: 14px; border-radius: 8px; }
|
||||
.nl-color { display: flex; gap: 6px; align-items: center; }
|
||||
.nl-color input[type=color] { width: 38px; height: 34px; padding: 2px; border: 1px solid var(--nl-border); border-radius: 8px; background: #fff; }
|
||||
.nl-color .form-control { max-width: 120px; }
|
||||
.nl-img { display: flex; gap: 10px; align-items: flex-start; }
|
||||
.nl-img .thumb { width: 84px; height: 64px; border-radius: 8px; border: 1px solid var(--nl-border); background: #f8fafc center/cover no-repeat; flex: 0 0 84px; }
|
||||
.nl-img .grow { flex: 1; min-width: 0; }
|
||||
.nl-hint { font-size: 12px; color: var(--nl-muted); margin-top: 6px; }
|
||||
.nl-row { display: flex; gap: 10px; }
|
||||
.nl-row > * { flex: 1; }
|
||||
|
||||
.nl-add { width: 100%; border: 2px dashed #cbd5e1; background: #fafbfc; color: #475569; border-radius: 12px; padding: 12px; font-weight: 600; }
|
||||
.nl-add:hover { border-color: var(--nl-accent); color: var(--nl-accent); }
|
||||
.nl-add-menu { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
||||
.nl-add-menu button { border: 1px solid var(--nl-border); background: #fff; border-radius: 10px; padding: 12px 6px; font-size: 13px; color: #374151; }
|
||||
.nl-add-menu button:hover { border-color: var(--nl-accent); background: #f3fbf7; }
|
||||
.nl-add-menu i { display: block; font-size: 18px; margin-bottom: 4px; color: #475569; }
|
||||
|
||||
.nl-preview-wrap { position: sticky; top: 230px; }
|
||||
.nl-preview-bar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
|
||||
.nl-device button { border: 1px solid var(--nl-border); background: #fff; padding: 4px 10px; font-size: 13px; }
|
||||
.nl-device button.active { background: #1f2937; color: #fff; border-color: #1f2937; }
|
||||
.nl-device button:first-child { border-radius: 8px 0 0 8px; }
|
||||
.nl-device button:last-child { border-radius: 0 8px 8px 0; }
|
||||
.nl-frame-box { background: #e9ecef; border-radius: 14px; padding: 14px; display: flex; justify-content: center; }
|
||||
#nlFrame { width: 100%; height: calc(100vh - 290px); min-height: 480px; border: 0; background: #fff; border-radius: 6px; transition: width .2s; }
|
||||
#nlFrame.mobile { width: 390px; }
|
||||
.nl-kb { font-size: 12px; color: var(--nl-muted); }
|
||||
.nl-checks { margin-top: 10px; font-size: 13px; }
|
||||
.nl-checks div { padding: 3px 0; }
|
||||
.nl-checks .error { color: #b02a37; }
|
||||
.nl-checks .warning { color: #8a5a1a; }
|
||||
|
||||
.nl-banner { border-radius: 10px; padding: 10px 14px; font-size: 14px; display: none; }
|
||||
.nl-banner.show { display: block; }
|
||||
|
||||
.vers-list { max-height: 60vh; overflow-y: auto; }
|
||||
.vers-item { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 9px 4px; border-bottom: 1px solid #f1f2f4; }
|
||||
.vers-item small { color: #9ca3af; }
|
||||
#versFrame { width: 100%; height: 60vh; border: 1px solid var(--nl-border); border-radius: 8px; }
|
||||
|
||||
@media (max-width: 991px) {
|
||||
.nl-preview-wrap { position: static; margin-top: 16px; }
|
||||
.nl-top { position: static; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="layout-wrapper">
|
||||
<header id="page-topbar" class="isvertical-topbar">
|
||||
<div class="navbar-header">
|
||||
<div class="d-flex">
|
||||
<?php include('include/logoarea.php'); ?>
|
||||
<button type="button" class="btn btn-sm px-3 font-size-24 header-item waves-effect vertical-menu-btn">
|
||||
<i class="bx bx-menu align-middle"></i>
|
||||
</button>
|
||||
<div class="page-title-box align-self-center d-none d-md-block">
|
||||
<h4 class="page-title mb-0">Editor newsletter</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex">
|
||||
<?php include('include/languageselection.php'); ?>
|
||||
<?php include('include/profiletopbar.php'); ?>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<?php include('include/sidebar.php'); ?>
|
||||
|
||||
<div class="main-content">
|
||||
<div class="page-content">
|
||||
<div class="container-fluid">
|
||||
|
||||
<!-- Barra in alto: titolo, oggetto, preheader, stato, azioni -->
|
||||
<div class="nl-top">
|
||||
<div class="d-flex align-items-center gap-2 flex-wrap mb-2">
|
||||
<a href="newsletter.php" class="btn btn-sm btn-light" title="Torna all'elenco"><i class="fas fa-arrow-left"></i></a>
|
||||
<input type="text" class="form-control form-control-sm fw-bold" id="fTitle" style="max-width:280px" placeholder="Nome interno">
|
||||
<span class="nl-status" id="nlStatus"><span class="dot"></span><span class="t">Caricamento…</span></span>
|
||||
<span id="nlWp" class="nl-status"></span>
|
||||
<div class="ms-auto d-flex gap-2 flex-wrap">
|
||||
<button class="btn btn-sm btn-light" id="btnStyle"><i class="fas fa-palette me-1"></i> Stile</button>
|
||||
<button class="btn btn-sm btn-light" id="btnVersions"><i class="fas fa-clock-rotate-left me-1"></i> Versioni</button>
|
||||
<button class="btn btn-sm btn-outline-primary" id="btnTest"><i class="fas fa-paper-plane me-1"></i> Invia prova</button>
|
||||
<button class="btn btn-sm btn-success" id="btnPublish"><i class="fas fa-upload me-1"></i> Pubblica su The Newsletter</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-2">
|
||||
<div class="col-md-6">
|
||||
<input type="text" class="form-control" id="fSubject" placeholder="Oggetto dell'email" maxlength="255">
|
||||
<div class="nl-counter" id="cSubject"></div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<input type="text" class="form-control" id="fPreheader" placeholder="Preheader: la riga di anteprima sotto l'oggetto" maxlength="255">
|
||||
<div class="nl-counter" id="cPreheader"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Avvisi sempre visibili dentro la barra -->
|
||||
<div class="nl-banner alert alert-warning mt-2 mb-0" id="bnRestore"></div>
|
||||
<div class="nl-banner alert alert-danger mt-2 mb-0" id="bnConflict"></div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-5">
|
||||
<div id="nlBlocks"></div>
|
||||
<button class="nl-add" id="btnAdd"><i class="fas fa-plus me-1"></i> Aggiungi blocco</button>
|
||||
<p class="nl-hint mt-2">Si salva da solo mentre scrivi. <kbd>Ctrl</kbd>+<kbd>S</kbd> salva subito. Nel testo usa <code>{name}</code> per il nome di chi riceve.</p>
|
||||
</div>
|
||||
<div class="col-lg-7">
|
||||
<div class="nl-preview-wrap">
|
||||
<div class="nl-preview-bar">
|
||||
<div class="nl-device">
|
||||
<button class="active" data-dev="desktop"><i class="fas fa-desktop"></i> Computer</button><button data-dev="mobile"><i class="fas fa-mobile-screen"></i> Telefono</button>
|
||||
</div>
|
||||
<span class="nl-kb" id="nlKb"></span>
|
||||
</div>
|
||||
<div class="nl-frame-box"><iframe id="nlFrame" title="Anteprima newsletter"></iframe></div>
|
||||
<div class="nl-checks" id="nlChecks"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php include('include/footer.php'); ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Versioni -->
|
||||
<div class="modal fade" id="mVersions" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Versioni salvate</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row">
|
||||
<div class="col-md-5">
|
||||
<div class="d-flex gap-2 mb-2">
|
||||
<input type="text" class="form-control form-control-sm" id="versNote" placeholder="Nome della versione (es. prima della revisione)">
|
||||
<button class="btn btn-sm btn-primary text-nowrap" id="btnSnapshot">Salva versione</button>
|
||||
</div>
|
||||
<div class="vers-list" id="versList"></div>
|
||||
</div>
|
||||
<div class="col-md-7">
|
||||
<iframe id="versFrame" title="Anteprima versione"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stile -->
|
||||
<div class="modal fade" id="mStyle" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Stile della newsletter</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="styleBody"></div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">Fatto</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="assets/libs/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="assets/libs/metismenujs/metismenujs.min.js"></script>
|
||||
<script src="assets/libs/simplebar/simplebar.min.js"></script>
|
||||
<script src="assets/libs/eva-icons/eva.min.js"></script>
|
||||
<script src="assets/js/app.js"></script>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
const API = 'newsletter_api.php';
|
||||
const DRAFT_ID = <?php echo $id; ?>;
|
||||
const MY_EMAIL = <?php echo json_encode($myEmail, $jsonFlags); ?>;
|
||||
const TEMPLATES = <?php echo json_encode($templates, $jsonFlags); ?>;
|
||||
const LS_KEY = 'yb_nl_draft_' + DRAFT_ID;
|
||||
|
||||
const TYPES = {
|
||||
header: { label: 'Logo', icon: 'fa-crown' },
|
||||
heading: { label: 'Titolo', icon: 'fa-heading' },
|
||||
text: { label: 'Testo', icon: 'fa-align-left' },
|
||||
image: { label: 'Immagine', icon: 'fa-image' },
|
||||
button: { label: 'Pulsante', icon: 'fa-hand-pointer' },
|
||||
columns: { label: 'Immagine + testo', icon: 'fa-table-columns' },
|
||||
separator: { label: 'Separatore', icon: 'fa-minus' },
|
||||
social: { label: 'Social', icon: 'fa-share-nodes' },
|
||||
footer: { label: 'Piè di pagina', icon: 'fa-shoe-prints' },
|
||||
raw: { label: 'Blocco WordPress', icon: 'fa-lock' }
|
||||
};
|
||||
|
||||
const PAD = [
|
||||
{ k: 'pad_top', label: 'Spazio sopra', type: 'number', min: 0, max: 120 },
|
||||
{ k: 'pad_bottom', label: 'Spazio sotto', type: 'number', min: 0, max: 120 }
|
||||
];
|
||||
const FIELDS = {
|
||||
header: [
|
||||
{ k: 'logo_url', label: 'Logo', type: 'image' },
|
||||
{ row: [{ k: 'logo_width', label: 'Larghezza (px)', type: 'number', min: 20, max: 600 }, { k: 'link', label: 'Link al clic', type: 'url' }] },
|
||||
{ row: PAD }
|
||||
],
|
||||
heading: [
|
||||
{ k: 'text', label: 'Titolo', type: 'textarea', rows: 2 },
|
||||
{ row: [{ k: 'align', label: 'Allineamento', type: 'select', options: [['left', 'Sinistra'], ['center', 'Centro'], ['right', 'Destra']] }, { k: 'font_size', label: 'Dimensione (vuoto = stile)', type: 'number', min: 14, max: 60 }] },
|
||||
{ row: [{ k: 'color', label: 'Colore', type: 'color' }, { k: 'background', label: 'Sfondo blocco', type: 'color' }] },
|
||||
{ row: PAD }
|
||||
],
|
||||
text: [
|
||||
{ k: 'html', type: 'rich' },
|
||||
{ row: [{ k: 'background', label: 'Sfondo', type: 'color' }, { k: 'background_2', label: 'Sfumatura verso', type: 'color' }] },
|
||||
{ row: PAD }
|
||||
],
|
||||
image: [
|
||||
{ k: 'url', label: 'Immagine', type: 'image' },
|
||||
{ k: 'alt', label: 'Testo alternativo (se le immagini sono bloccate)', type: 'text' },
|
||||
{ row: [{ k: 'link', label: 'Link al clic', type: 'url' }, { k: 'width', label: 'Larghezza (max 600)', type: 'number', min: 50, max: 600 }] },
|
||||
{ row: PAD }
|
||||
],
|
||||
button: [
|
||||
{ k: 'label', label: 'Testo del pulsante', type: 'text', hint: 'Per andare a capo scrivi <br>' },
|
||||
{ k: 'url', label: 'Link', type: 'url' },
|
||||
{ row: [{ k: 'button_bg', label: 'Colore pulsante', type: 'color' }, { k: 'color', label: 'Colore testo', type: 'color' }] },
|
||||
{ row: [{ k: 'font_size', label: 'Testo (px)', type: 'number', min: 10, max: 60 }, { k: 'width', label: 'Larghezza (px)', type: 'number', min: 80, max: 600 }, { k: 'radius', label: 'Angoli', type: 'number', min: 0, max: 50 }] },
|
||||
{ row: PAD }
|
||||
],
|
||||
columns: [
|
||||
{ k: 'image_url', label: 'Immagine', type: 'image' },
|
||||
{ row: [{ k: 'image_alt', label: 'Testo alternativo', type: 'text' }, { k: 'link', label: 'Link immagine', type: 'url' }] },
|
||||
{ k: 'image_side', label: 'Posizione immagine', type: 'select', options: [['left', 'A sinistra'], ['right', 'A destra']] },
|
||||
{ k: 'html', type: 'rich' },
|
||||
{ row: PAD }
|
||||
],
|
||||
separator: [
|
||||
{ row: [{ k: 'color', label: 'Colore', type: 'color' }, { k: 'height', label: 'Spessore', type: 'number', min: 1, max: 10 }] },
|
||||
{ row: PAD }
|
||||
],
|
||||
social: [
|
||||
{ k: 'icon_size', label: 'Dimensione icone', type: 'number', min: 16, max: 64 },
|
||||
{ note: 'I link dei social si cambiano da <strong>Stile</strong>.' },
|
||||
{ row: PAD }
|
||||
],
|
||||
footer: [
|
||||
{ k: 'profile_label', label: 'Testo "gestisci iscrizione"', type: 'text' },
|
||||
{ k: 'view_label', label: 'Testo "vedi online"', type: 'text' },
|
||||
{ k: 'unsub_label', label: 'Testo "disiscriviti" (vuoto = non mostrarlo)', type: 'text' },
|
||||
{ row: PAD }
|
||||
],
|
||||
raw: [
|
||||
{ note: 'Blocco importato da WordPress che qui non si può modificare: viene pubblicato identico. Puoi spostarlo o eliminarlo.' }
|
||||
]
|
||||
};
|
||||
|
||||
const STYLE_FIELDS = [
|
||||
{ row: [{ k: 'background', label: 'Sfondo esterno', type: 'color' }, { k: 'block_background', label: 'Sfondo contenuto', type: 'color' }] },
|
||||
{ row: [{ k: 'font_family', label: 'Carattere', type: 'select', options: [
|
||||
['Verdana, Geneva, sans-serif', 'Verdana'], ['Helvetica, Arial, sans-serif', 'Helvetica / Arial'],
|
||||
['Georgia, serif', 'Georgia'], ["'Trebuchet MS', Tahoma, sans-serif", 'Trebuchet'], ["Tahoma, Geneva, sans-serif", 'Tahoma']] },
|
||||
{ k: 'font_size', label: 'Dimensione testo', type: 'number', min: 12, max: 22 }] },
|
||||
{ row: [{ k: 'text_color', label: 'Colore testo', type: 'color' }, { k: 'link_color', label: 'Colore link', type: 'color' }] },
|
||||
{ row: [{ k: 'title_font_size', label: 'Dimensione titoli', type: 'number', min: 16, max: 60 }, { k: 'title_color', label: 'Colore titoli', type: 'color' }] },
|
||||
{ row: [{ k: 'button_background', label: 'Colore pulsanti', type: 'color' }, { k: 'button_color', label: 'Testo pulsanti', type: 'color' }] },
|
||||
{ heading: 'Link social' },
|
||||
{ row: [{ k: 'social.facebook', label: 'Facebook', type: 'url' }, { k: 'social.instagram', label: 'Instagram', type: 'url' }] },
|
||||
{ row: [{ k: 'social.youtube', label: 'YouTube', type: 'url' }, { k: 'social.whatsapp', label: 'WhatsApp (https://wa.me/39…)', type: 'url' }] }
|
||||
];
|
||||
|
||||
/* ================= Stato ================= */
|
||||
let D = null; // bozza corrente
|
||||
let openId = null; // blocco aperto
|
||||
let editSeq = 0; // contatore modifiche
|
||||
let savedSeq = 0; // ultima modifica salvata sul server
|
||||
let saving = false;
|
||||
let saveTimer = null, firstDirtyAt = 0, retryDelay = 3000;
|
||||
let conflict = false;
|
||||
let previewTimer = null, previewReq = 0;
|
||||
let lastChecks = [];
|
||||
|
||||
const $ = (s, el = document) => el.querySelector(s);
|
||||
const $$ = (s, el = document) => Array.from(el.querySelectorAll(s));
|
||||
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>
|
||||
Reference in New Issue
Block a user