diff --git a/db/migrations/20261016150000_create_newsletter_drafts.php b/db/migrations/20261016150000_create_newsletter_drafts.php new file mode 100644 index 0000000..62b2dfe --- /dev/null +++ b/db/migrations/20261016150000_create_newsletter_drafts.php @@ -0,0 +1,47 @@ +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(); + } +} diff --git a/public/class/NewsletterRenderer.php b/public/class/NewsletterRenderer.php new file mode 100644 index 0000000..dde19ef --- /dev/null +++ b/public/class/NewsletterRenderer.php @@ -0,0 +1,658 @@ + '#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' => '

Ciao {name}

come stai?

Scrivi qui il testo della newsletter…

']), + 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' => '

Testo…

', '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' => '

Testo accanto all\'immagine…

', '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 + . '' . "\n" + . '
' . self::h($preheader) . "
\n\n" + . "\n\n" + . "\n\n
" + . $body + . "\n
\n\n\n"; + + 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(['
', '
', '
'], ' ', (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 '' . "\n" + . '
' + . '' + . '' . "\n" + . '
'; + } + + /* ---- 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 !== '' + ? 'YogaSoul' + : ''; + $inner = $link !== '' && $img !== '' ? '' . $img . '' : $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 = '' + . '
' + . '
' + . nl2br(self::h($text)) + . '
'; + + 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 '' + . '
' + . $html + . '
'; + } + + 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 = '
Immagine non ancora scelta
'; + } else { + $img = '' . self::h($alt) . ''; + $inner = $link !== '' ? '' . $img . '' : $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'] ?? ''), '
'); + $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 = '' + . '
' + . '' + . $label + . '
'; + + 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 !== '' + ? '' . $alt . '' + : '
Immagine
'; + if ($url !== '' && $link !== '') { + $img = '' . $img . ''; + } + + $imgCell = '' . $img . ''; + $textCell = '' . $html . ''; + + $table = '' + . ($right ? $textCell . $imgCell : $imgCell . $textCell) + . '
'; + + 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 = '' + . '' + . '
 
'; + + 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 .= '' + . '' . $net . '  '; + } + + $inner = '
' + . $links . '
'; + + 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 = '   |   '; + + $parts = []; + if (trim((string) ($b['profile_label'] ?? '')) !== '') { + $parts[] = '' . self::h($b['profile_label']) . ''; + } + if (trim((string) ($b['view_label'] ?? '')) !== '') { + $parts[] = '' . self::h($b['view_label']) . ''; + } + if (trim((string) ($b['unsub_label'] ?? '')) !== '') { + $parts[] = '' . self::h($b['unsub_label']) . ''; + } + + 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, ''); + $end = strrpos($html, ''); + $body = ($start !== false && $end !== false) ? substr($html, $start + 12, $end - $start - 12) : $html; + + // Ogni blocco inizia con la tabella "tnpc-row" + preg_match_all('/]*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('/]*src="([^"]+)"/i', $chunk, $im); + preg_match('/]*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)[^>]*>.*?#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('#]*href=["\']([^"\']+)["\'][^>]*>(.*?)#is', '$2 ($1)', $html); + $html = preg_replace('##i', "\n", $html); + $html = preg_replace('##i', "\n", $html); + $html = preg_replace('#]*>#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)); + } +} diff --git a/public/class/NewsletterWp.php b/public/class/NewsletterWp.php new file mode 100644 index 0000000..c7e3e9b --- /dev/null +++ b/public/class/NewsletterWp.php @@ -0,0 +1,164 @@ + 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]; + } +} diff --git a/public/class/newsletter_head.html b/public/class/newsletter_head.html new file mode 100644 index 0000000..54a5da9 --- /dev/null +++ b/public/class/newsletter_head.html @@ -0,0 +1,100 @@ + + + +{email_subject} + + + + + + + + + + diff --git a/public/include/sidebar.php b/public/include/sidebar.php index fa7302f..b3957ae 100644 --- a/public/include/sidebar.php +++ b/public/include/sidebar.php @@ -184,6 +184,12 @@ Cancellazioni +
  • + + + Newsletter + +
  • diff --git a/public/newsletter.php b/public/newsletter.php new file mode 100644 index 0000000..c059e45 --- /dev/null +++ b/public/newsletter.php @@ -0,0 +1,280 @@ +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'], +]; +?> + + + + + + YogiBook - Newsletter + + + + + + + + + + + + +
    +
    + +
    + + +
    +
    +
    + +
    + + +
    +
    +
    +
    +
    Newsletter
    +

    Scrivi qui, con salvataggio automatico. Poi pubblica su The Newsletter e invia da WordPress come sempre.

    +
    +
    + + +
    +
    + +
    + + + + + + + + + + + + + + + + + + + + + + +
    NewsletterUltima modificaThe NewsletterAzioni
    Nessuna newsletter. Creane una nuova o importane una da The Newsletter.
    + + + + + + + + n. + + Apri + + +
    +
    +
    +
    +
    + +
    +
    +
    + + + + + + + + + + + diff --git a/public/newsletter_api.php b/public/newsletter_api.php new file mode 100644 index 0000000..4876947 --- /dev/null +++ b/public/newsletter_api.php @@ -0,0 +1,435 @@ + 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); +} diff --git a/public/newsletter_editor.php b/public/newsletter_editor.php new file mode 100644 index 0000000..f2bb2b9 --- /dev/null +++ b/public/newsletter_editor.php @@ -0,0 +1,998 @@ +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; +?> + + + + + + YogiBook - Editor newsletter + + + + + + + + + + + + + +
    +
    + +
    + + +
    +
    +
    + + +
    +
    + + + Caricamento… + +
    + + + + +
    +
    +
    +
    + +
    +
    +
    + +
    +
    +
    + +
    +
    +
    + +
    +
    +
    + +

    Si salva da solo mentre scrivi. Ctrl+S salva subito. Nel testo usa {name} per il nome di chi riceve.

    +
    +
    +
    +
    +
    + +
    + +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    + + + + + + + + + + + + + + + + +