Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f980c66257 | ||
|
|
ffe4ccbb06 | ||
|
|
c16d604ea2 | ||
|
|
3038d75045 | ||
|
|
0d3cf5aeaa | ||
|
|
4ffa385b2c | ||
|
|
cce365225b | ||
|
|
419abac97f | ||
|
|
0b2236bd93 | ||
|
|
5dc0b887b2 | ||
|
|
7d1618cbe7 | ||
|
|
f17f45f5fd | ||
|
|
45c432916a | ||
|
|
74c01d236e | ||
|
|
c7b621a3ea | ||
|
|
45279d53f1 | ||
|
|
6decde261b | ||
|
|
275dc5755f | ||
|
|
dec2fdf60e | ||
|
|
075628f5a9 | ||
|
|
7c1e76e8cd | ||
|
|
b3e4f4051c |
@@ -38,3 +38,11 @@ public/yogasoulscript/venv//index.html
|
||||
/public/.well-known/
|
||||
/index.html
|
||||
/public/.well-known/
|
||||
|
||||
# File generati sul server: mai in git
|
||||
public/logs/*
|
||||
cron/preview/*
|
||||
public/assets/uploads/*
|
||||
public/assets/images/teachers/*
|
||||
public/uploads/*
|
||||
!public/uploads/.htaccess
|
||||
@@ -1 +0,0 @@
|
||||
<!doctype html><html lang="it"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Hatha Yoga</title></head><body style="margin:0;padding:0;background:#f3f5f7;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f3f5f7;padding:24px 12px;"><tr><td align="center"><table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:14px;overflow:hidden;box-shadow:0 3px 16px rgba(0,0,0,0.06);"><tr><td style="height:8px;background:#fa9b9b;font-size:0;line-height:0;"> </td></tr><tr><td style="padding:26px 28px 6px;"><div style="font-size:12px;font-weight:700;color:#1ebf73;text-transform:uppercase;letter-spacing:0.06em;">YogiBook · Riepilogo classe</div><h1 style="margin:8px 0 6px;font-size:24px;color:#1f2937;">Hatha Yoga</h1><div style="font-size:15px;color:#374151;font-weight:600;">Lunedì 5 Ottobre 2026 · ore 19:30</div><div style="font-size:13px;color:#6b7280;margin-top:4px;">via Valassina 62/B Seregno - Sala Contesto Yoga</div></td></tr><tr><td style="padding:16px 22px 0;"><table role="presentation" width="100%" cellpadding="0" cellspacing="6"><tr><td align="center" style="padding:12px 6px;background:#f8fafc;border-radius:10px;"><div style="font-size:22px;font-weight:800;color:#c0392b;line-height:1.1;">1/1</div><div style="font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.04em;margin-top:4px;">Iscritti</div></td><td align="center" style="padding:12px 6px;background:#f8fafc;border-radius:10px;"><div style="font-size:22px;font-weight:800;color:#374151;line-height:1.1;">0</div><div style="font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.04em;margin-top:4px;">Posti liberi</div></td><td align="center" style="padding:12px 6px;background:#f8fafc;border-radius:10px;"><div style="font-size:22px;font-weight:800;color:#8a6d00;line-height:1.1;">0</div><div style="font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.04em;margin-top:4px;">In attesa</div></td><td align="center" style="padding:12px 6px;background:#f8fafc;border-radius:10px;"><div style="font-size:22px;font-weight:800;color:#d1698a;line-height:1.1;">0</div><div style="font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.04em;margin-top:4px;">In coda</div></td></tr></table><div style="padding:0 6px;"></div></td></tr><tr><td style="padding:22px 28px 0;"><div style="font-size:13px;font-weight:700;color:#374151;text-transform:uppercase;letter-spacing:0.04em;margin-bottom:8px;">Partecipanti confermati (1)</div><table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f8fafc;border:1px solid #eef0f3;border-radius:10px;"><tr><td style="padding:10px 14px;border-bottom:1px solid #eef0f3;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td width="34" valign="top" style="font-weight:800;color:#1ebf73;font-size:14px;padding-top:1px;">1.</td><td valign="top" style="font-size:15px;color:#1f2937;font-weight:600;">Amelia Benucci<br><span style="font-size:12px;color:#6b7280;font-weight:400;">info@claudiosironi.com</span></td><td valign="top" align="right" width="100"><span style="display:inline-block;background:#e3f2ec;color:#1a7f52;font-size:11px;font-weight:700;padding:3px 10px;border-radius:20px;white-space:nowrap;">Confermata</span></td></tr></table></td></tr></table></td></tr><tr><td style="padding:26px 28px;font-size:12px;color:#9ca3af;text-align:center;">Generato automaticamente da YogiBook il 01/10/2026 alle 11:15</td></tr></table></td></tr></table></body></html>
|
||||
@@ -1 +0,0 @@
|
||||
<!doctype html><html lang="it"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Hatha Yoga</title></head><body style="margin:0;padding:0;background:#f3f5f7;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f3f5f7;padding:24px 12px;"><tr><td align="center"><table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:14px;overflow:hidden;box-shadow:0 3px 16px rgba(0,0,0,0.06);"><tr><td style="height:8px;background:#fa9b9b;font-size:0;line-height:0;"> </td></tr><tr><td style="padding:26px 28px 6px;"><div style="font-size:12px;font-weight:700;color:#1ebf73;text-transform:uppercase;letter-spacing:0.06em;">YogiBook · Riepilogo classe</div><h1 style="margin:8px 0 6px;font-size:24px;color:#1f2937;">Hatha Yoga</h1><div style="font-size:15px;color:#374151;font-weight:600;">Lunedì 5 Ottobre 2026 · ore 19:30</div><div style="font-size:13px;color:#6b7280;margin-top:4px;">via Valassina 62/B Seregno - Sala Contesto Yoga</div><div style="font-size:13px;color:#6b7280;margin-top:4px;">Insegnante: <strong style="color:#374151;">Francesca -</strong></div></td></tr><tr><td style="padding:16px 22px 0;"><table role="presentation" width="100%" cellpadding="0" cellspacing="6"><tr><td align="center" style="padding:12px 6px;background:#f8fafc;border-radius:10px;"><div style="font-size:22px;font-weight:800;color:#c0392b;line-height:1.1;">1/1</div><div style="font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.04em;margin-top:4px;">Iscritti</div></td><td align="center" style="padding:12px 6px;background:#f8fafc;border-radius:10px;"><div style="font-size:22px;font-weight:800;color:#374151;line-height:1.1;">0</div><div style="font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.04em;margin-top:4px;">Posti liberi</div></td><td align="center" style="padding:12px 6px;background:#f8fafc;border-radius:10px;"><div style="font-size:22px;font-weight:800;color:#8a6d00;line-height:1.1;">0</div><div style="font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.04em;margin-top:4px;">In attesa</div></td><td align="center" style="padding:12px 6px;background:#f8fafc;border-radius:10px;"><div style="font-size:22px;font-weight:800;color:#d1698a;line-height:1.1;">0</div><div style="font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.04em;margin-top:4px;">In coda</div></td></tr></table><div style="padding:0 6px;"></div></td></tr><tr><td style="padding:22px 28px 0;"><div style="font-size:13px;font-weight:700;color:#374151;text-transform:uppercase;letter-spacing:0.04em;margin-bottom:8px;">Partecipanti confermati (1)</div><table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f8fafc;border:1px solid #eef0f3;border-radius:10px;"><tr><td style="padding:10px 14px;border-bottom:1px solid #eef0f3;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td width="34" valign="top" style="font-weight:800;color:#1ebf73;font-size:14px;padding-top:1px;">1.</td><td valign="top" style="font-size:15px;color:#1f2937;font-weight:600;">Amelia B.</td><td valign="top" align="right" width="100"><span style="display:inline-block;background:#e3f2ec;color:#1a7f52;font-size:11px;font-weight:700;padding:3px 10px;border-radius:20px;white-space:nowrap;">Confermata</span></td></tr></table></td></tr></table></td></tr><tr><td style="padding:26px 28px;font-size:12px;color:#9ca3af;text-align:center;">Generato automaticamente da YogiBook il 01/10/2026 alle 11:30</td></tr></table></td></tr></table></body></html>
|
||||
@@ -1 +0,0 @@
|
||||
<!doctype html><html lang="it"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Hatha Yoga</title></head><body style="margin:0;padding:0;background:#f3f5f7;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f3f5f7;padding:24px 12px;"><tr><td align="center"><table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:14px;overflow:hidden;box-shadow:0 3px 16px rgba(0,0,0,0.06);"><tr><td style="height:8px;background:#fa9b9b;font-size:0;line-height:0;"> </td></tr><tr><td style="padding:14px 28px 0;"><div style="background:#f5eee4;color:#5a4630;border-radius:10px;padding:10px 14px;font-size:13px;"><strong>Copia per il titolare</strong> · classe di Francesca -</div></td></tr><tr><td style="padding:26px 28px 6px;"><div style="font-size:12px;font-weight:700;color:#1ebf73;text-transform:uppercase;letter-spacing:0.06em;">YogiBook · Riepilogo classe</div><h1 style="margin:8px 0 6px;font-size:24px;color:#1f2937;">Hatha Yoga</h1><div style="font-size:15px;color:#374151;font-weight:600;">Lunedì 5 Ottobre 2026 · ore 19:30</div><div style="font-size:13px;color:#6b7280;margin-top:4px;">via Valassina 62/B Seregno - Sala Contesto Yoga</div><div style="font-size:13px;color:#6b7280;margin-top:4px;">Insegnante: <strong style="color:#374151;">Francesca -</strong></div></td></tr><tr><td style="padding:16px 22px 0;"><table role="presentation" width="100%" cellpadding="0" cellspacing="6"><tr><td align="center" style="padding:12px 6px;background:#f8fafc;border-radius:10px;"><div style="font-size:22px;font-weight:800;color:#c0392b;line-height:1.1;">1/1</div><div style="font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.04em;margin-top:4px;">Iscritti</div></td><td align="center" style="padding:12px 6px;background:#f8fafc;border-radius:10px;"><div style="font-size:22px;font-weight:800;color:#374151;line-height:1.1;">0</div><div style="font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.04em;margin-top:4px;">Posti liberi</div></td><td align="center" style="padding:12px 6px;background:#f8fafc;border-radius:10px;"><div style="font-size:22px;font-weight:800;color:#8a6d00;line-height:1.1;">0</div><div style="font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.04em;margin-top:4px;">In attesa</div></td><td align="center" style="padding:12px 6px;background:#f8fafc;border-radius:10px;"><div style="font-size:22px;font-weight:800;color:#d1698a;line-height:1.1;">0</div><div style="font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.04em;margin-top:4px;">In coda</div></td></tr></table><div style="padding:0 6px;"></div></td></tr><tr><td style="padding:22px 28px 0;"><div style="font-size:13px;font-weight:700;color:#374151;text-transform:uppercase;letter-spacing:0.04em;margin-bottom:8px;">Partecipanti confermati (1)</div><table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f8fafc;border:1px solid #eef0f3;border-radius:10px;"><tr><td style="padding:10px 14px;border-bottom:1px solid #eef0f3;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td width="34" valign="top" style="font-weight:800;color:#1ebf73;font-size:14px;padding-top:1px;">1.</td><td valign="top" style="font-size:15px;color:#1f2937;font-weight:600;">Amelia Benucci<br><span style="font-size:12px;color:#6b7280;font-weight:400;">info@claudiosironi.com</span></td><td valign="top" align="right" width="100"><span style="display:inline-block;background:#e3f2ec;color:#1a7f52;font-size:11px;font-weight:700;padding:3px 10px;border-radius:20px;white-space:nowrap;">Confermata</span></td></tr></table></td></tr></table></td></tr><tr><td style="padding:26px 28px;font-size:12px;color:#9ca3af;text-align:center;">Generato automaticamente da YogiBook il 01/10/2026 alle 11:30</td></tr></table></td></tr></table></body></html>
|
||||
@@ -1 +0,0 @@
|
||||
<!doctype html><html lang="it"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Aerial Yoga - mix</title></head><body style="margin:0;padding:0;background:#f3f5f7;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f3f5f7;padding:24px 12px;"><tr><td align="center"><table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:14px;overflow:hidden;box-shadow:0 3px 16px rgba(0,0,0,0.06);"><tr><td style="height:8px;background:#6ED4E4;font-size:0;line-height:0;"> </td></tr><tr><td style="padding:26px 28px 6px;"><div style="font-size:12px;font-weight:700;color:#1ebf73;text-transform:uppercase;letter-spacing:0.06em;">YogiBook · Riepilogo classe</div><h1 style="margin:8px 0 6px;font-size:24px;color:#1f2937;">Aerial Yoga - mix</h1><div style="font-size:15px;color:#374151;font-weight:600;">Giovedì 24 Settembre 2026 · ore 19:00</div><div style="font-size:13px;color:#6b7280;margin-top:4px;">via Valassina 62/B Seregno - Sala Contesto Yoga</div></td></tr><tr><td style="padding:16px 22px 0;"><table role="presentation" width="100%" cellpadding="0" cellspacing="6"><tr><td align="center" style="padding:12px 6px;background:#f8fafc;border-radius:10px;"><div style="font-size:22px;font-weight:800;color:#1a7f52;line-height:1.1;">2/8</div><div style="font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.04em;margin-top:4px;">Iscritti</div></td><td align="center" style="padding:12px 6px;background:#f8fafc;border-radius:10px;"><div style="font-size:22px;font-weight:800;color:#374151;line-height:1.1;">6</div><div style="font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.04em;margin-top:4px;">Posti liberi</div></td><td align="center" style="padding:12px 6px;background:#f8fafc;border-radius:10px;"><div style="font-size:22px;font-weight:800;color:#8a6d00;line-height:1.1;">0</div><div style="font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.04em;margin-top:4px;">In attesa</div></td><td align="center" style="padding:12px 6px;background:#f8fafc;border-radius:10px;"><div style="font-size:22px;font-weight:800;color:#d1698a;line-height:1.1;">0</div><div style="font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.04em;margin-top:4px;">In coda</div></td></tr></table><div style="padding:0 6px;"></div></td></tr><tr><td style="padding:22px 28px 0;"><div style="font-size:13px;font-weight:700;color:#374151;text-transform:uppercase;letter-spacing:0.04em;margin-bottom:8px;">Partecipanti confermati (2)</div><table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f8fafc;border:1px solid #eef0f3;border-radius:10px;"><tr><td style="padding:10px 14px;border-bottom:1px solid #eef0f3;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td width="34" valign="top" style="font-weight:800;color:#1ebf73;font-size:14px;padding-top:1px;">1.</td><td valign="top" style="font-size:15px;color:#1f2937;font-weight:600;">Amelia Benucci<br><span style="font-size:12px;color:#6b7280;font-weight:400;">info@claudiosironi.com</span></td><td valign="top" align="right" width="100"><span style="display:inline-block;background:#e3f2ec;color:#1a7f52;font-size:11px;font-weight:700;padding:3px 10px;border-radius:20px;white-space:nowrap;">Confermata</span></td></tr></table></td></tr><tr><td style="padding:10px 14px;border-bottom:1px solid #eef0f3;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td width="34" valign="top" style="font-weight:800;color:#1ebf73;font-size:14px;padding-top:1px;">2.</td><td valign="top" style="font-size:15px;color:#1f2937;font-weight:600;">Aurora Masiero<br><span style="font-size:12px;color:#6b7280;font-weight:400;">info@yogasoul.it</span></td><td valign="top" align="right" width="100"><span style="display:inline-block;background:#e3f2ec;color:#1a7f52;font-size:11px;font-weight:700;padding:3px 10px;border-radius:20px;white-space:nowrap;">Confermata</span></td></tr></table></td></tr></table></td></tr><tr><td style="padding:26px 28px;font-size:12px;color:#9ca3af;text-align:center;">Generato automaticamente da YogiBook il 24/09/2026 alle 10:55</td></tr></table></td></tr></table></body></html>
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
final class CreateBookingclassCancellations extends AbstractMigration
|
||||
{
|
||||
/**
|
||||
* Change Method.
|
||||
*
|
||||
* Write your reversible migrations using this method.
|
||||
*
|
||||
* More information on writing migrations is available here:
|
||||
* https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
|
||||
*
|
||||
* Remember to call "create()" or "update()" and NOT "save()" when working
|
||||
* with the Table class.
|
||||
*/
|
||||
public function change(): void
|
||||
{
|
||||
// Storico delle lezioni cancellate dagli allievi.
|
||||
// Nessuna pagina esistente legge questa tabella: non cambia il resto del sistema.
|
||||
$this->table('bookingclass_cancellations', ['id' => 'idcancellation'])
|
||||
->addColumn('idbookingclass', 'integer')
|
||||
->addColumn('idserviceschedule', 'integer', ['null' => true])
|
||||
->addColumn('bookingstart', 'datetime')
|
||||
->addColumn('status', 'string', ['limit' => 30])
|
||||
->addColumn('idservice', 'integer')
|
||||
->addColumn('iduser', 'integer', ['signed' => false])
|
||||
->addColumn('idorder', 'integer', ['null' => true])
|
||||
->addColumn('prevbookingstart', 'datetime', ['null' => true])
|
||||
->addColumn('is_reprogrammed', 'char', ['limit' => 1, 'default' => 'N'])
|
||||
->addColumn('is_gift', 'char', ['limit' => 1, 'default' => 'N'])
|
||||
->addColumn('booking_created_at', 'timestamp', ['null' => true])
|
||||
->addColumn('cancelled_at', 'datetime')
|
||||
->addColumn('cancelled_by', 'integer', ['signed' => false])
|
||||
->addColumn('reprogrammed_incremented', 'char', ['limit' => 1, 'default' => 'N'])
|
||||
->addColumn('idschool', 'integer', ['signed' => false, 'default' => 1])
|
||||
->addIndex(['iduser'])
|
||||
->addIndex(['idorder'])
|
||||
->create();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -555,10 +555,11 @@ function teacherOptions(array $teachers, int $selectedId): string
|
||||
<a href="updateservice.php?idservice=<?php echo (int) $svc['idservice']; ?>" class="btn btn-warning btn-sm">
|
||||
<i class="mdi mdi-lead-pencil font-size-16 align-middle"></i>
|
||||
</a>
|
||||
<a href="cancelservice.php?idservice=<?php echo (int) $svc['idservice']; ?>">
|
||||
<button type="button" class="btn btn-danger btn-sm">
|
||||
<i class="bx bx-block font-size-16 align-middle"></i>
|
||||
</button>
|
||||
<a href="cancelservice.php?idservice=<?php echo (int) $svc['idservice']; ?>"
|
||||
class="btn btn-danger btn-sm"
|
||||
title="Elimina classe"
|
||||
onclick="return confirm(<?php echo e(json_encode('Eliminare la classe ' . $svc['servicename'] . ' (' . $svc['day'] . ' ' . $svc['time'] . ')? L\'operazione non si può annullare.')); ?>);">
|
||||
<i class="bx bx-block font-size-16 align-middle"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Cancellazione di una prenotazione da parte della scuola (dettaglio lezioni in Statistiche).
|
||||
*
|
||||
* POST: id = idbookingclass
|
||||
* send_email = Y/N invia email di conferma all'allieva
|
||||
* count_reprog = Y/N conta come riprogrammazione (orderbook.reprogrammed + 1)
|
||||
*
|
||||
* Copia la prenotazione nello storico (bookingclass_cancellations), poi la elimina.
|
||||
* Tutto in una transazione; l'email parte solo dopo il commit.
|
||||
*/
|
||||
ob_start();
|
||||
require_once('include/headscript.php');
|
||||
|
||||
function jsonOut(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');
|
||||
echo json_encode($payload, JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
jsonOut(['success' => false, 'message' => 'Metodo non consentito.'], 405);
|
||||
}
|
||||
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
$sendEmail = ($_POST['send_email'] ?? 'N') === 'Y';
|
||||
$countReprog = ($_POST['count_reprog'] ?? 'N') === 'Y';
|
||||
$staffId = (int) $iduserlogin; // chi cancella: sempre l'utente della sessione
|
||||
|
||||
if ($id <= 0) {
|
||||
jsonOut(['success' => false, 'message' => 'Prenotazione non valida.'], 400);
|
||||
}
|
||||
|
||||
$pdo = DBHandlerSelect::getInstance()->getConnection();
|
||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
|
||||
$bk = null;
|
||||
$incremented = 'N';
|
||||
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
|
||||
// Prenotazione + dati per l'email, riga bloccata contro doppi click
|
||||
$st = $pdo->prepare("
|
||||
SELECT bc.idbookingclass, bc.idorder, bc.bookingstart,
|
||||
s.servicename, ss.dateschedule,
|
||||
u.email, u.first_name
|
||||
FROM bookingclass bc
|
||||
LEFT JOIN service s ON s.idservice = bc.idservice
|
||||
LEFT JOIN serviceschedule ss ON ss.idserviceschedule = bc.idserviceschedule
|
||||
LEFT JOIN auth_users u ON u.id = bc.iduser
|
||||
WHERE bc.idbookingclass = ?
|
||||
FOR UPDATE
|
||||
");
|
||||
$st->execute([$id]);
|
||||
$bk = $st->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$bk) {
|
||||
throw new RuntimeException('notfound');
|
||||
}
|
||||
|
||||
// Conta come riprogrammazione (solo se c'è un ordine collegato)
|
||||
if ($countReprog && $bk['idorder'] !== null) {
|
||||
$up = $pdo->prepare("UPDATE orderbook SET reprogrammed = reprogrammed + 1 WHERE idorderbook = ?");
|
||||
$up->execute([(int) $bk['idorder']]);
|
||||
if ($up->rowCount() === 1) {
|
||||
$incremented = 'Y';
|
||||
}
|
||||
}
|
||||
|
||||
// Copia nello storico
|
||||
$ins = $pdo->prepare("
|
||||
INSERT INTO bookingclass_cancellations
|
||||
(idbookingclass, idserviceschedule, bookingstart, status, idservice, iduser, idorder,
|
||||
prevbookingstart, is_reprogrammed, is_gift, booking_created_at,
|
||||
cancelled_at, cancelled_by, reprogrammed_incremented)
|
||||
SELECT idbookingclass, idserviceschedule, bookingstart, status, idservice, iduser, idorder,
|
||||
prevbookingstart, is_reprogrammed, is_gift, created_at,
|
||||
NOW(), ?, ?
|
||||
FROM bookingclass
|
||||
WHERE idbookingclass = ?
|
||||
");
|
||||
$ins->execute([$staffId, $incremented, $id]);
|
||||
|
||||
// Eliminazione
|
||||
$del = $pdo->prepare("DELETE FROM bookingclass WHERE idbookingclass = ?");
|
||||
$del->execute([$id]);
|
||||
|
||||
$pdo->commit();
|
||||
} catch (PDOException $e) {
|
||||
if ($pdo->inTransaction()) $pdo->rollBack();
|
||||
error_log("admin_cancel_booking #{$id} (staff #{$staffId}): " . $e->getMessage());
|
||||
jsonOut(['success' => false, 'message' => 'Errore durante la cancellazione. Riprova.'], 500);
|
||||
} catch (RuntimeException $e) {
|
||||
if ($pdo->inTransaction()) $pdo->rollBack();
|
||||
jsonOut(['success' => false, 'message' => 'Prenotazione non trovata (forse già cancellata).'], 404);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Email di conferma (dopo il commit, solo se richiesta)
|
||||
* ---------------------------------------------------------------------- */
|
||||
$emailNote = '';
|
||||
|
||||
if ($sendEmail) {
|
||||
$to = trim((string) ($bk['email'] ?? ''));
|
||||
|
||||
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) {
|
||||
$emailNote = ' Email non inviata: indirizzo dell\'allieva mancante o non valido.';
|
||||
} else {
|
||||
try {
|
||||
require_once(__DIR__ . '/class/mailer.php');
|
||||
|
||||
$clean = fn($v) => trim((string) $v, "\" \t\n\r\0\x0B");
|
||||
$enc = strtolower($clean($_ENV['MAIL_ENCRYPTION'] ?? 'ssl'));
|
||||
|
||||
$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->Port = (int) ($_ENV['MAIL_PORT'] ?? 465);
|
||||
if (in_array($enc, ['tls', 'ssl'], true)) {
|
||||
$mail->SMTPSecure = $enc;
|
||||
}
|
||||
$mail->Timeout = 20;
|
||||
$mail->CharSet = 'UTF-8';
|
||||
|
||||
$fromName = $clean($_ENV['MAIL_FROM_NAME'] ?? 'YogiBook');
|
||||
$mail->setFrom($clean($_ENV['MAIL_FROM_ADDRESS'] ?? ''), $fromName);
|
||||
|
||||
$nome = trim((string) ($bk['first_name'] ?? ''));
|
||||
$className = $bk['servicename'] ?: 'Lezione';
|
||||
$dateRaw = $bk['dateschedule'] ?: $bk['bookingstart'];
|
||||
$dateTxt = $dateRaw ? date('d/m/Y H:i', strtotime($dateRaw)) : '';
|
||||
|
||||
$h = fn($v) => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
$mail->addAddress($to, $nome);
|
||||
$mail->isHTML(true);
|
||||
$mail->Subject = 'Lezione cancellata - ' . $className;
|
||||
$mail->Body = "
|
||||
<div style='font-family:Arial,sans-serif;font-size:14px;color:#222'>
|
||||
<p>Ciao " . $h($nome !== '' ? $nome : 'Yogi') . ",</p>
|
||||
<p>ti confermiamo che la tua lezione è stata <b>cancellata</b>.</p>
|
||||
<p>
|
||||
<b>Lezione:</b> " . $h($className) . "<br>
|
||||
<b>Data/ora:</b> " . $h($dateTxt) . "
|
||||
</p>
|
||||
<p>Se hai domande, rispondi pure a questa email.</p>
|
||||
<p>A presto,<br>" . $h($fromName) . "</p>
|
||||
</div>";
|
||||
$mail->AltBody = "Ciao " . ($nome !== '' ? $nome : 'Yogi') . ",\n"
|
||||
. "ti confermiamo che la tua lezione è stata cancellata.\n"
|
||||
. "Lezione: {$className}\nData/ora: {$dateTxt}\n\n{$fromName}";
|
||||
|
||||
$mail->send();
|
||||
$emailNote = ' Email di conferma inviata.';
|
||||
} catch (Throwable $e) {
|
||||
error_log("admin_cancel_booking #{$id}: email non inviata: " . $e->getMessage());
|
||||
$emailNote = ' Attenzione: la lezione è stata cancellata ma l\'email non è partita.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$reprogNote = $incremented === 'Y' ? ' Conteggiata come riprogrammazione.' : '';
|
||||
|
||||
jsonOut(['success' => true, 'message' => 'Lezione cancellata.' . $reprogNote . $emailNote]);
|
||||
@@ -0,0 +1,214 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Riprogrammazione di una prenotazione da parte della scuola.
|
||||
*
|
||||
* POST: id = idbookingclass
|
||||
* idschedule = idserviceschedule della nuova lezione
|
||||
* send_email = Y/N email all'allieva con la nuova data
|
||||
* count_reprog = Y/N conta come riprogrammazione (orderbook.reprogrammed + 1)
|
||||
*
|
||||
* Sposta la prenotazione salvando la vecchia lezione in idprevserviceschedule / prevbookingstart.
|
||||
* Controlla posti liberi e doppia prenotazione con le righe bloccate (FOR UPDATE).
|
||||
*/
|
||||
ob_start();
|
||||
require_once('include/headscript.php');
|
||||
|
||||
function jsonOut(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');
|
||||
echo json_encode($payload, JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
jsonOut(['success' => false, 'message' => 'Metodo non consentito.'], 405);
|
||||
}
|
||||
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
$idSchedule = (int) ($_POST['idschedule'] ?? 0);
|
||||
$sendEmail = ($_POST['send_email'] ?? 'N') === 'Y';
|
||||
$countReprog = ($_POST['count_reprog'] ?? 'N') === 'Y';
|
||||
$staffId = (int) $iduserlogin;
|
||||
|
||||
if ($id <= 0 || $idSchedule <= 0) {
|
||||
jsonOut(['success' => false, 'message' => 'Seleziona la nuova lezione.'], 400);
|
||||
}
|
||||
|
||||
$errors = [
|
||||
'notfound' => 'Prenotazione non trovata (forse è stata cancellata).',
|
||||
'notarget' => 'La lezione scelta non esiste più.',
|
||||
'same' => 'Hai scelto la stessa lezione di adesso.',
|
||||
'past' => 'La lezione scelta è già iniziata o passata.',
|
||||
'already' => 'L\'allieva è già iscritta alla lezione scelta.',
|
||||
'full' => 'La lezione scelta è piena.',
|
||||
];
|
||||
|
||||
$pdo = DBHandlerSelect::getInstance()->getConnection();
|
||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
|
||||
$bk = null;
|
||||
$target = null;
|
||||
$incremented = 'N';
|
||||
|
||||
try {
|
||||
$pdo->beginTransaction();
|
||||
|
||||
// Prenotazione attuale (bloccata)
|
||||
$st = $pdo->prepare("
|
||||
SELECT bc.idbookingclass, bc.iduser, bc.idorder, bc.idserviceschedule, bc.bookingstart,
|
||||
s.servicename AS old_servicename, ss.dateschedule AS old_date,
|
||||
u.email, u.first_name
|
||||
FROM bookingclass bc
|
||||
LEFT JOIN service s ON s.idservice = bc.idservice
|
||||
LEFT JOIN serviceschedule ss ON ss.idserviceschedule = bc.idserviceschedule
|
||||
LEFT JOIN auth_users u ON u.id = bc.iduser
|
||||
WHERE bc.idbookingclass = ?
|
||||
FOR UPDATE
|
||||
");
|
||||
$st->execute([$id]);
|
||||
$bk = $st->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$bk) throw new RuntimeException('notfound');
|
||||
|
||||
if ((int) $bk['idserviceschedule'] === $idSchedule) throw new RuntimeException('same');
|
||||
|
||||
// Nuova lezione (bloccata: evita due spostamenti contemporanei sull'ultimo posto)
|
||||
$st = $pdo->prepare("
|
||||
SELECT ss.idserviceschedule, ss.idservice, ss.dateschedule, s.servicename, s.maxcapacity
|
||||
FROM serviceschedule ss
|
||||
JOIN service s ON s.idservice = ss.idservice
|
||||
WHERE ss.idserviceschedule = ?
|
||||
FOR UPDATE
|
||||
");
|
||||
$st->execute([$idSchedule]);
|
||||
$target = $st->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$target) throw new RuntimeException('notarget');
|
||||
|
||||
if (strtotime($target['dateschedule']) <= time()) throw new RuntimeException('past');
|
||||
|
||||
// L'allieva è già su quella lezione?
|
||||
$st = $pdo->prepare("SELECT COUNT(*) FROM bookingclass WHERE idserviceschedule = ? AND iduser = ?");
|
||||
$st->execute([$idSchedule, (int) $bk['iduser']]);
|
||||
if ((int) $st->fetchColumn() > 0) throw new RuntimeException('already');
|
||||
|
||||
// Posti liberi (booked + pending)
|
||||
if ($target['maxcapacity'] !== null) {
|
||||
$st = $pdo->prepare("SELECT COUNT(*) FROM bookingclass WHERE idserviceschedule = ? AND status IN ('booked','pending')");
|
||||
$st->execute([$idSchedule]);
|
||||
if ((int) $st->fetchColumn() >= (int) $target['maxcapacity']) throw new RuntimeException('full');
|
||||
}
|
||||
|
||||
// Spostamento: la vecchia lezione resta in idprevserviceschedule / prevbookingstart
|
||||
$up = $pdo->prepare("
|
||||
UPDATE bookingclass
|
||||
SET idprevserviceschedule = :prevsched,
|
||||
prevbookingstart = :prevstart,
|
||||
idserviceschedule = :sched,
|
||||
idservice = :svc,
|
||||
bookingstart = :start,
|
||||
is_reprogrammed = 'Y'
|
||||
WHERE idbookingclass = :id
|
||||
");
|
||||
$up->execute([
|
||||
':prevsched' => $bk['idserviceschedule'] !== null ? (int) $bk['idserviceschedule'] : null,
|
||||
':prevstart' => $bk['bookingstart'],
|
||||
':sched' => $idSchedule,
|
||||
':svc' => (int) $target['idservice'],
|
||||
':start' => $target['dateschedule'],
|
||||
':id' => $id,
|
||||
]);
|
||||
|
||||
// Conta come riprogrammazione
|
||||
if ($countReprog && $bk['idorder'] !== null) {
|
||||
$st = $pdo->prepare("UPDATE orderbook SET reprogrammed = reprogrammed + 1 WHERE idorderbook = ?");
|
||||
$st->execute([(int) $bk['idorder']]);
|
||||
if ($st->rowCount() === 1) $incremented = 'Y';
|
||||
}
|
||||
|
||||
$pdo->commit();
|
||||
} catch (PDOException $e) {
|
||||
if ($pdo->inTransaction()) $pdo->rollBack();
|
||||
error_log("admin_reprogram_booking #{$id} -> {$idSchedule} (staff #{$staffId}): " . $e->getMessage());
|
||||
jsonOut(['success' => false, 'message' => 'Errore durante la riprogrammazione. Riprova.'], 500);
|
||||
} catch (RuntimeException $e) {
|
||||
if ($pdo->inTransaction()) $pdo->rollBack();
|
||||
$code = $e->getMessage();
|
||||
jsonOut(['success' => false, 'message' => $errors[$code] ?? 'Errore durante la riprogrammazione.'], 409);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Email all'allieva (dopo il commit, solo se richiesta)
|
||||
* ---------------------------------------------------------------------- */
|
||||
$fmt = fn($d) => $d ? date('d/m/Y H:i', strtotime($d)) : '';
|
||||
$emailNote = '';
|
||||
|
||||
if ($sendEmail) {
|
||||
$to = trim((string) ($bk['email'] ?? ''));
|
||||
|
||||
if (!filter_var($to, FILTER_VALIDATE_EMAIL)) {
|
||||
$emailNote = ' Email non inviata: indirizzo dell\'allieva mancante o non valido.';
|
||||
} else {
|
||||
try {
|
||||
require_once(__DIR__ . '/class/mailer.php');
|
||||
|
||||
$clean = fn($v) => trim((string) $v, "\" \t\n\r\0\x0B");
|
||||
$enc = strtolower($clean($_ENV['MAIL_ENCRYPTION'] ?? 'ssl'));
|
||||
|
||||
$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->Port = (int) ($_ENV['MAIL_PORT'] ?? 465);
|
||||
if (in_array($enc, ['tls', 'ssl'], true)) {
|
||||
$mail->SMTPSecure = $enc;
|
||||
}
|
||||
$mail->Timeout = 20;
|
||||
$mail->CharSet = 'UTF-8';
|
||||
|
||||
$fromName = $clean($_ENV['MAIL_FROM_NAME'] ?? 'YogiBook');
|
||||
$mail->setFrom($clean($_ENV['MAIL_FROM_ADDRESS'] ?? ''), $fromName);
|
||||
|
||||
$h = fn($v) => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
|
||||
$nome = trim((string) ($bk['first_name'] ?? ''));
|
||||
$oldName = $bk['old_servicename'] ?: 'Lezione';
|
||||
$oldDate = $fmt($bk['old_date'] ?: $bk['bookingstart']);
|
||||
$newName = $target['servicename'] ?: 'Lezione';
|
||||
$newDate = $fmt($target['dateschedule']);
|
||||
|
||||
$mail->addAddress($to, $nome);
|
||||
$mail->isHTML(true);
|
||||
$mail->Subject = 'Lezione riprogrammata - ' . $newName . ' ' . $newDate;
|
||||
$mail->Body = "
|
||||
<div style='font-family:Arial,sans-serif;font-size:14px;color:#222'>
|
||||
<p>Ciao " . $h($nome !== '' ? $nome : 'Yogi') . ",</p>
|
||||
<p>la tua lezione è stata <b>riprogrammata</b>.</p>
|
||||
<p>
|
||||
<b>Prima:</b> " . $h($oldName) . " – " . $h($oldDate) . "<br>
|
||||
<b>Adesso:</b> " . $h($newName) . " – <b>" . $h($newDate) . "</b>
|
||||
</p>
|
||||
<p>Se hai domande, rispondi pure a questa email.</p>
|
||||
<p>A presto,<br>" . $h($fromName) . "</p>
|
||||
</div>";
|
||||
$mail->AltBody = "Ciao " . ($nome !== '' ? $nome : 'Yogi') . ",\n"
|
||||
. "la tua lezione è stata riprogrammata.\n"
|
||||
. "Prima: {$oldName} - {$oldDate}\nAdesso: {$newName} - {$newDate}\n\n{$fromName}";
|
||||
|
||||
$mail->send();
|
||||
$emailNote = ' Email inviata all\'allieva.';
|
||||
} catch (Throwable $e) {
|
||||
error_log("admin_reprogram_booking #{$id}: email non inviata: " . $e->getMessage());
|
||||
$emailNote = ' Attenzione: la lezione è stata spostata ma l\'email non è partita.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$reprogNote = $incremented === 'Y' ? ' Conteggiata come riprogrammazione.' : '';
|
||||
|
||||
jsonOut([
|
||||
'success' => true,
|
||||
'message' => 'Lezione spostata a ' . $fmt($target['dateschedule']) . '.' . $reprogNote . $emailNote,
|
||||
]);
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Elenco delle lezioni in calendario verso cui spostare una prenotazione (uso scuola).
|
||||
* GET: id = idbookingclass
|
||||
*
|
||||
* Restituisce le lezioni future (prossimi 90 giorni) della stessa classe
|
||||
* e delle classi associate (associateclass), con posti occupati e flag.
|
||||
*/
|
||||
ob_start();
|
||||
require_once('include/headscript.php');
|
||||
|
||||
function jsonOut(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');
|
||||
echo json_encode($payload, JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$id = (int) ($_GET['id'] ?? 0);
|
||||
if ($id <= 0) {
|
||||
jsonOut(['success' => false, 'message' => 'Prenotazione non valida.'], 400);
|
||||
}
|
||||
|
||||
$pdo = DBHandlerSelect::getInstance()->getConnection();
|
||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
|
||||
try {
|
||||
// Prenotazione + ordine collegato
|
||||
$st = $pdo->prepare("
|
||||
SELECT bc.idbookingclass, bc.iduser, bc.idservice, bc.idserviceschedule, bc.idorder,
|
||||
o.expireon, o.reprogrammed, o.maxreschedule
|
||||
FROM bookingclass bc
|
||||
LEFT JOIN orderbook o ON o.idorderbook = bc.idorder
|
||||
WHERE bc.idbookingclass = ?
|
||||
");
|
||||
$st->execute([$id]);
|
||||
$bk = $st->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$bk) {
|
||||
jsonOut(['success' => false, 'message' => 'Prenotazione non trovata.'], 404);
|
||||
}
|
||||
|
||||
// Classi ammesse: la stessa + quelle associate
|
||||
$services = [(int) $bk['idservice']];
|
||||
$as = $pdo->prepare("SELECT idassociateservice FROM associateclass WHERE idmainservice = ?");
|
||||
$as->execute([(int) $bk['idservice']]);
|
||||
foreach ($as->fetchAll(PDO::FETCH_COLUMN) as $sid) {
|
||||
$services[] = (int) $sid;
|
||||
}
|
||||
$services = array_values(array_unique($services));
|
||||
$ph = implode(',', array_fill(0, count($services), '?'));
|
||||
|
||||
$sql = "
|
||||
SELECT ss.idserviceschedule, ss.dateschedule,
|
||||
s.servicename, s.maxcapacity,
|
||||
(SELECT COUNT(*) FROM bookingclass b
|
||||
WHERE b.idserviceschedule = ss.idserviceschedule
|
||||
AND b.status IN ('booked','pending')) AS occupied,
|
||||
(SELECT COUNT(*) FROM bookingclass b2
|
||||
WHERE b2.idserviceschedule = ss.idserviceschedule
|
||||
AND b2.iduser = ?) AS mine
|
||||
FROM serviceschedule ss
|
||||
JOIN service s ON s.idservice = ss.idservice
|
||||
WHERE ss.idservice IN ($ph)
|
||||
AND ss.dateschedule > NOW()
|
||||
AND ss.dateschedule <= DATE_ADD(NOW(), INTERVAL 90 DAY)
|
||||
ORDER BY ss.dateschedule
|
||||
LIMIT 200";
|
||||
$ls = $pdo->prepare($sql);
|
||||
$ls->execute(array_merge([(int) $bk['iduser']], $services));
|
||||
|
||||
$giorni = ['Dom', 'Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab'];
|
||||
$expiry = $bk['expireon'] ? strtotime($bk['expireon'] . ' 23:59:59') : null;
|
||||
|
||||
$lessons = [];
|
||||
foreach ($ls->fetchAll(PDO::FETCH_ASSOC) as $l) {
|
||||
$ts = strtotime($l['dateschedule']);
|
||||
$max = $l['maxcapacity'] !== null ? (int) $l['maxcapacity'] : null;
|
||||
$occ = (int) $l['occupied'];
|
||||
|
||||
$lessons[] = [
|
||||
'id' => (int) $l['idserviceschedule'],
|
||||
'label' => $giorni[(int) date('w', $ts)] . ' ' . date('d/m/Y H:i', $ts),
|
||||
'servicename' => $l['servicename'],
|
||||
'occupied' => $occ,
|
||||
'max' => $max,
|
||||
'full' => $max !== null && $occ >= $max,
|
||||
'mine' => (int) $l['mine'] > 0,
|
||||
'after_expiry' => $expiry !== null && $ts > $expiry,
|
||||
];
|
||||
}
|
||||
|
||||
jsonOut([
|
||||
'success' => true,
|
||||
'lessons' => $lessons,
|
||||
'order' => [
|
||||
'has_order' => $bk['idorder'] !== null,
|
||||
'reprogrammed' => (int) ($bk['reprogrammed'] ?? 0),
|
||||
'maxreschedule' => (int) ($bk['maxreschedule'] ?? 0),
|
||||
'expireon' => $bk['expireon'] ? date('d/m/Y', strtotime($bk['expireon'])) : null,
|
||||
],
|
||||
]);
|
||||
} catch (PDOException $e) {
|
||||
error_log("admin_reprogram_lessons #{$id}: " . $e->getMessage());
|
||||
jsonOut(['success' => false, 'message' => 'Errore nel caricamento delle lezioni.'], 500);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Correzione della data di scadenza di un certificato (uso scuola, pagina Certificati).
|
||||
* POST: id = idcertificateuserprofile
|
||||
* expiry = nuova data (YYYY-MM-DD)
|
||||
*/
|
||||
ob_start();
|
||||
require_once('include/headscript.php');
|
||||
|
||||
function jsonOut(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');
|
||||
echo json_encode($payload, JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
jsonOut(['success' => false, 'message' => 'Metodo non consentito.'], 405);
|
||||
}
|
||||
|
||||
$id = (int) ($_POST['id'] ?? 0);
|
||||
$expiry = trim((string) ($_POST['expiry'] ?? ''));
|
||||
|
||||
if ($id <= 0) {
|
||||
jsonOut(['success' => false, 'message' => 'Certificato non valido.'], 400);
|
||||
}
|
||||
|
||||
// Data valida e nel formato esatto YYYY-MM-DD
|
||||
$d = DateTime::createFromFormat('!Y-m-d', $expiry);
|
||||
$err = DateTime::getLastErrors();
|
||||
if (!$d || ($err && ($err['warning_count'] || $err['error_count'])) || $d->format('Y-m-d') !== $expiry) {
|
||||
jsonOut(['success' => false, 'message' => 'Data non valida.'], 422);
|
||||
}
|
||||
|
||||
// Intervallo ragionevole: evita errori di battitura sull'anno
|
||||
$year = (int) $d->format('Y');
|
||||
if ($year < 2000 || $year > (int) date('Y') + 3) {
|
||||
jsonOut(['success' => false, 'message' => 'Controlla l\'anno: la data sembra sbagliata.'], 422);
|
||||
}
|
||||
|
||||
$pdo = DBHandlerSelect::getInstance()->getConnection();
|
||||
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
|
||||
try {
|
||||
$st = $pdo->prepare("SELECT 1 FROM certificateuserprofile WHERE idcertificateuserprofile = ?");
|
||||
$st->execute([$id]);
|
||||
if (!$st->fetchColumn()) {
|
||||
jsonOut(['success' => false, 'message' => 'Certificato non trovato.'], 404);
|
||||
}
|
||||
|
||||
$up = $pdo->prepare("UPDATE certificateuserprofile SET expirydatedocument = ? WHERE idcertificateuserprofile = ?");
|
||||
$up->execute([$expiry, $id]);
|
||||
} catch (PDOException $e) {
|
||||
error_log("admin_update_certificate_expiry #{$id}: " . $e->getMessage());
|
||||
jsonOut(['success' => false, 'message' => 'Errore durante il salvataggio.'], 500);
|
||||
}
|
||||
|
||||
jsonOut([
|
||||
'success' => true,
|
||||
'expiry' => $expiry,
|
||||
'expired' => $expiry < date('Y-m-d'),
|
||||
'message' => 'Scadenza aggiornata.',
|
||||
]);
|
||||
@@ -0,0 +1,306 @@
|
||||
<?php
|
||||
require_once('include/headscript.php');
|
||||
|
||||
/**
|
||||
* Storico delle lezioni cancellate dagli allievi (tabella bookingclass_cancellations).
|
||||
* Sola consultazione: questa pagina non modifica nessun dato.
|
||||
*/
|
||||
$pdo = DBHandlerSelect::getInstance()->getConnection();
|
||||
|
||||
function e($value): string
|
||||
{
|
||||
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
|
||||
}
|
||||
|
||||
// Preavviso leggibile: minuti, ore o giorni
|
||||
function formatPreavviso(?int $minutes): string
|
||||
{
|
||||
if ($minutes === null) return '-';
|
||||
if ($minutes < 60) return $minutes . ' min';
|
||||
if ($minutes < 48 * 60) return floor($minutes / 60) . ' ore';
|
||||
return floor($minutes / 1440) . ' giorni';
|
||||
}
|
||||
|
||||
$sql = "
|
||||
SELECT
|
||||
c.idcancellation,
|
||||
c.iduser,
|
||||
c.idorder,
|
||||
c.is_gift,
|
||||
c.reprogrammed_incremented,
|
||||
c.cancelled_at,
|
||||
COALESCE(ss.dateschedule, c.bookingstart) AS lesson_date,
|
||||
TIMESTAMPDIFF(MINUTE, c.cancelled_at, COALESCE(ss.dateschedule, c.bookingstart)) AS preavviso_min,
|
||||
u.first_name,
|
||||
u.last_name,
|
||||
u.email,
|
||||
s.servicename,
|
||||
o.reprogrammed,
|
||||
o.maxreschedule
|
||||
FROM bookingclass_cancellations c
|
||||
LEFT JOIN auth_users u ON u.id = c.iduser
|
||||
LEFT JOIN service s ON s.idservice = c.idservice
|
||||
LEFT JOIN serviceschedule ss ON ss.idserviceschedule = c.idserviceschedule
|
||||
LEFT JOIN orderbook o ON o.idorderbook = c.idorder
|
||||
ORDER BY c.cancelled_at DESC";
|
||||
|
||||
$rows = $pdo->query($sql)->fetchAll();
|
||||
|
||||
// Contatori ultimi 7 / 30 giorni
|
||||
$limit7 = strtotime('-7 days');
|
||||
$limit30 = strtotime('-30 days');
|
||||
$count7 = 0;
|
||||
$count30 = 0;
|
||||
foreach ($rows as $r) {
|
||||
$t = strtotime($r['cancelled_at']);
|
||||
if ($t >= $limit7) $count7++;
|
||||
if ($t >= $limit30) $count30++;
|
||||
}
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="it">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>YogiBook - Cancellazioni</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta content="YogiBook" name="description" />
|
||||
<meta content="Advanced Creative Solutions" name="author" />
|
||||
<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">
|
||||
<link rel="stylesheet" href="https://cdn.datatables.net/1.13.6/css/jquery.dataTables.min.css">
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
<script src="https://cdn.datatables.net/1.13.6/js/jquery.dataTables.min.js"></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: 0.04em;
|
||||
text-transform: uppercase;
|
||||
color: #6b7280;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.section-lead {
|
||||
color: #9ca3af;
|
||||
font-size: 14px;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.counter {
|
||||
border-radius: 8px;
|
||||
padding: 8px 14px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
background: #fbe4c4;
|
||||
color: #8a5a1a;
|
||||
}
|
||||
|
||||
.counter.counter-30 {
|
||||
background: #c4e1fb;
|
||||
color: #1a4f7a;
|
||||
}
|
||||
|
||||
#cancTable thead th {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
#cancTable td {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.user-email {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.badge-soft {
|
||||
display: inline-block;
|
||||
border-radius: 6px;
|
||||
padding: 2px 8px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-late {
|
||||
background: #fbc7c4;
|
||||
color: #a12820;
|
||||
}
|
||||
|
||||
.badge-ok {
|
||||
background: #cdfbc4;
|
||||
color: #1a7f2e;
|
||||
}
|
||||
|
||||
.badge-gift {
|
||||
background: #e2d3f5;
|
||||
color: #4c2c92;
|
||||
}
|
||||
|
||||
.badge-neutral {
|
||||
background: #eef0f3;
|
||||
color: #4b5563;
|
||||
}
|
||||
</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">Cancellazioni</h4>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex">
|
||||
<?php include('include/languageselection.php'); ?>
|
||||
<?php include('include/profiletopbar.php'); ?>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<?php include('include/sidebar.php'); ?>
|
||||
<header class="ishorizontal-topbar">
|
||||
<div class="navbar-header">
|
||||
<div class="d-flex"></div>
|
||||
</div>
|
||||
<div class="topnav">
|
||||
<div class="container-fluid">
|
||||
<nav class="navbar navbar-light navbar-expand-lg topnav-menu"></nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="main-content">
|
||||
<div class="page-content">
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card admin-card">
|
||||
<div class="card-body">
|
||||
<div class="section-title">Lezioni cancellate dagli allievi</div>
|
||||
<p class="section-lead">Storico delle cancellazioni, dalla più recente. Usa la ricerca per filtrare per nome, lezione o data.</p>
|
||||
|
||||
<div class="d-flex align-items-center gap-3 flex-wrap mb-3">
|
||||
<span class="counter"><i class="fas fa-calendar-week me-1"></i> Ultimi 7 giorni: <strong><?php echo $count7; ?></strong></span>
|
||||
<span class="counter counter-30"><i class="fas fa-calendar-alt me-1"></i> Ultimi 30 giorni: <strong><?php echo $count30; ?></strong></span>
|
||||
<span class="counter badge-neutral"><i class="fas fa-list me-1"></i> Totale: <strong><?php echo count($rows); ?></strong></span>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table id="cancTable" class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Allievo</th>
|
||||
<th>Lezione</th>
|
||||
<th>Data lezione</th>
|
||||
<th>Cancellata il</th>
|
||||
<th>Preavviso</th>
|
||||
<th>Ordine</th>
|
||||
<th>Conta come riprog.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($rows as $r) :
|
||||
$nome = trim(($r['first_name'] ?? '') . ' ' . ($r['last_name'] ?? ''));
|
||||
if ($nome === '') $nome = 'Utente #' . (int) $r['iduser'];
|
||||
$lessonTs = $r['lesson_date'] ? strtotime($r['lesson_date']) : null;
|
||||
$cancTs = strtotime($r['cancelled_at']);
|
||||
$preMin = $r['preavviso_min'] !== null ? (int) $r['preavviso_min'] : null;
|
||||
?>
|
||||
<tr>
|
||||
<td>
|
||||
<?php echo e($nome); ?>
|
||||
<?php if (!empty($r['email'])) : ?>
|
||||
<span class="user-email"><?php echo e($r['email']); ?></span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?php echo e($r['servicename'] ?? '-'); ?></td>
|
||||
<td data-order="<?php echo $lessonTs ? date('Y-m-d H:i', $lessonTs) : ''; ?>">
|
||||
<?php echo $lessonTs ? e(date('d/m/Y H:i', $lessonTs)) : '-'; ?>
|
||||
</td>
|
||||
<td data-order="<?php echo e(date('Y-m-d H:i:s', $cancTs)); ?>">
|
||||
<?php echo e(date('d/m/Y H:i', $cancTs)); ?>
|
||||
</td>
|
||||
<td data-order="<?php echo $preMin ?? 0; ?>">
|
||||
<?php if ($preMin !== null && $preMin < 24 * 60) : ?>
|
||||
<span class="badge-soft badge-late"><?php echo e(formatPreavviso($preMin)); ?></span>
|
||||
<?php else : ?>
|
||||
<span class="badge-soft badge-ok"><?php echo e(formatPreavviso($preMin)); ?></span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?php echo $r['idorder'] !== null ? '#' . (int) $r['idorder'] : '-'; ?></td>
|
||||
<td>
|
||||
<?php if ($r['is_gift'] === 'Y' || $r['idorder'] === null) : ?>
|
||||
<span class="badge-soft badge-gift">Omaggio</span>
|
||||
<?php elseif ($r['reprogrammed_incremented'] === 'L') : ?>
|
||||
<span class="badge-soft badge-late">Limite superato: lezione persa</span>
|
||||
<?php elseif ($r['reprogrammed_incremented'] === 'Y') : ?>
|
||||
<span class="badge-soft badge-neutral">
|
||||
Sì<?php if ($r['reprogrammed'] !== null) : ?> (<?php echo (int) $r['reprogrammed']; ?>/<?php echo (int) $r['maxreschedule']; ?>)<?php endif; ?>
|
||||
</span>
|
||||
<?php else : ?>
|
||||
<span class="badge-soft badge-neutral">No</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
$('#cancTable').DataTable({
|
||||
paging: true,
|
||||
pageLength: 50,
|
||||
searching: true,
|
||||
ordering: true,
|
||||
order: [
|
||||
[3, 'desc']
|
||||
],
|
||||
language: {
|
||||
url: "//cdn.datatables.net/plug-ins/1.13.6/i18n/it-IT.json"
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
+82
-3
@@ -58,7 +58,7 @@ $today = date('Y-m-d');
|
||||
|
||||
<script>
|
||||
$(function() {
|
||||
$('#certificateTable').DataTable({
|
||||
window.certTable = $('#certificateTable').DataTable({
|
||||
"language": {
|
||||
"url": "//cdn.datatables.net/plug-ins/1.11.5/i18n/it-IT.json"
|
||||
},
|
||||
@@ -85,6 +85,66 @@ $today = date('Y-m-d');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Chip della scadenza (stesso aspetto dopo la modifica)
|
||||
function expChipHtml(id, user, date, expired) {
|
||||
var chip = $('<span>')
|
||||
.addClass('exp-chip exp-edit ' + (expired ? 'exp-expired' : 'exp-ok'))
|
||||
.attr({
|
||||
'data-id': id,
|
||||
'data-date': date,
|
||||
'data-user': user,
|
||||
'title': 'Clicca per modificare la scadenza'
|
||||
})
|
||||
.text(date + ' ')
|
||||
.append('<i class="fas fa-pen"></i>');
|
||||
return $('<div>').append(chip).html();
|
||||
}
|
||||
|
||||
// Clic sulla scadenza: scegli la nuova data e salva
|
||||
$(document).on('click', '.exp-edit', function() {
|
||||
var chip = $(this);
|
||||
var id = chip.attr('data-id');
|
||||
var user = chip.attr('data-user') || '';
|
||||
var current = chip.attr('data-date') || '';
|
||||
|
||||
Swal.fire({
|
||||
title: 'Modifica scadenza',
|
||||
html: 'Certificato di <strong>' + $('<div>').text(user).html() + '</strong>',
|
||||
input: 'date',
|
||||
inputValue: (/^\d{4}-\d{2}-\d{2}$/.test(current) && current !== '0000-00-00') ? current : '',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Salva',
|
||||
cancelButtonText: 'Annulla',
|
||||
confirmButtonColor: '#1ebf73',
|
||||
showLoaderOnConfirm: true,
|
||||
inputValidator: (v) => !v ? 'Scegli una data.' : undefined,
|
||||
preConfirm: (v) => {
|
||||
return $.post('admin_update_certificate_expiry.php', {
|
||||
id: id,
|
||||
expiry: v
|
||||
}, null, 'json')
|
||||
.then(function(resp) {
|
||||
if (!resp || !resp.success) throw new Error((resp && resp.message) || 'Errore durante il salvataggio.');
|
||||
return resp;
|
||||
})
|
||||
.catch(function(err) {
|
||||
Swal.showValidationMessage((err.responseJSON && err.responseJSON.message) || err.message || 'Errore durante il salvataggio.');
|
||||
});
|
||||
}
|
||||
}).then(function(r) {
|
||||
if (!r.isConfirmed || !r.value) return;
|
||||
var td = chip.closest('td');
|
||||
window.certTable.cell(td).data(expChipHtml(id, user, r.value.expiry, r.value.expired)).draw(false);
|
||||
Swal.fire({
|
||||
title: 'Fatto',
|
||||
text: 'Scadenza aggiornata al ' + r.value.expiry.split('-').reverse().join('/') + '.',
|
||||
icon: 'success',
|
||||
timer: 1800,
|
||||
showConfirmButton: false
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -156,6 +216,21 @@ $today = date('Y-m-d');
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.exp-edit {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.exp-edit i {
|
||||
font-size: 10px;
|
||||
opacity: 0.6;
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
.exp-edit:hover {
|
||||
filter: brightness(0.95);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.exp-ok {
|
||||
background: #e3f2ec;
|
||||
color: #1a7f52;
|
||||
@@ -256,8 +331,12 @@ $today = date('Y-m-d');
|
||||
</td>
|
||||
<td><?php echo e($d['documentdescription']); ?></td>
|
||||
<td>
|
||||
<span class="exp-chip <?php echo $isExpired ? 'exp-expired' : 'exp-ok'; ?>">
|
||||
<?php echo e($d['expirydatedocument']); ?>
|
||||
<span class="exp-chip exp-edit <?php echo $isExpired ? 'exp-expired' : 'exp-ok'; ?>"
|
||||
data-id="<?php echo $idc; ?>"
|
||||
data-date="<?php echo e($d['expirydatedocument']); ?>"
|
||||
data-user="<?php echo e($fullName); ?>"
|
||||
title="Clicca per modificare la scadenza">
|
||||
<?php echo e($d['expirydatedocument']); ?> <i class="fas fa-pen"></i>
|
||||
</span>
|
||||
</td>
|
||||
<td><?php echo e($d['uploaded_at'] ?? 'N/D'); ?></td>
|
||||
|
||||
@@ -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>
|
||||
+123
-11
@@ -51,6 +51,9 @@ $userName = trim(($u['first_name'] ?? '') . ' ' . ($u['last_name'] ?? ''));
|
||||
$stmt = $conn->prepare("
|
||||
SELECT
|
||||
bc.idbookingclass,
|
||||
bc.idorder,
|
||||
bc.status,
|
||||
bc.bookingstart,
|
||||
s.servicename,
|
||||
ss.dateschedule
|
||||
FROM bookingclass bc
|
||||
@@ -77,18 +80,126 @@ $classDateTxt = $classDateRaw ? date("d-m-Y H:i", strtotime($classDateRaw)) : '
|
||||
$cancelDateTxt = date("d-m-Y H:i");
|
||||
|
||||
/***********************
|
||||
* 3) DELETE (safe)
|
||||
* 3) CONTROLLI (stesse regole di userpanel.php)
|
||||
***********************/
|
||||
$stmt = $conn->prepare("DELETE FROM bookingclass WHERE idbookingclass = ? AND iduser = ?");
|
||||
$stmt->bind_param("ii", $id, $userId);
|
||||
|
||||
if (!$stmt->execute()) {
|
||||
$err = $stmt->error;
|
||||
$stmt->close();
|
||||
die("Errore durante la cancellazione del record: " . htmlspecialchars($err));
|
||||
// Solo prenotazioni confermate: pending e lista d'attesa hanno i loro percorsi
|
||||
if (($bk['status'] ?? '') !== 'booked') {
|
||||
header("Location: userpanel.php?deleted=0&err=status");
|
||||
exit();
|
||||
}
|
||||
|
||||
$stmt->close();
|
||||
// Finestra oraria
|
||||
$classTime = new DateTime($classDateRaw ?: $bk['bookingstart']);
|
||||
$currentTime = new DateTime();
|
||||
|
||||
// Lezione già iniziata o passata: non si cancella
|
||||
if ($classTime <= $currentTime) {
|
||||
header("Location: userpanel.php?deleted=0&err=window");
|
||||
exit();
|
||||
}
|
||||
|
||||
// Lezione di oggi: fino alle 17:00 entro le 00:01, dopo le 17:00 entro le 12:00
|
||||
if ($classTime->format('Y-m-d') === $currentTime->format('Y-m-d')) {
|
||||
$classHour = (int) $classTime->format('H');
|
||||
$classMinute = (int) $classTime->format('i');
|
||||
$isBefore1700 = ($classHour < 17) || ($classHour === 17 && $classMinute === 0);
|
||||
$deadline = new DateTime($classTime->format($isBefore1700 ? 'Y-m-d 00:01:00' : 'Y-m-d 12:00:00'));
|
||||
if ($currentTime > $deadline) {
|
||||
header("Location: userpanel.php?deleted=0&err=window");
|
||||
exit();
|
||||
}
|
||||
}
|
||||
|
||||
/***********************
|
||||
* 3b) STORICO + RIPROGRAMMAZIONE + CANCELLAZIONE
|
||||
* Tutto in una transazione: o riesce tutto o niente.
|
||||
***********************/
|
||||
$idOrder = $bk['idorder'] !== null ? (int) $bk['idorder'] : null;
|
||||
$ord = null;
|
||||
$incremented = 'N';
|
||||
$ticketLost = false;
|
||||
|
||||
try {
|
||||
$conn->begin_transaction();
|
||||
|
||||
// Limite riprogrammazioni (stessa regola del pulsante Riprogramma).
|
||||
// FOR UPDATE: blocca la riga dell'ordine contro doppi click.
|
||||
if ($idOrder !== null) {
|
||||
$stmt = $conn->prepare("SELECT reprogrammed, maxreschedule FROM orderbook WHERE idorderbook = ? FOR UPDATE");
|
||||
$stmt->bind_param("i", $idOrder);
|
||||
$stmt->execute();
|
||||
$ord = $stmt->get_result()->fetch_assoc();
|
||||
$stmt->close();
|
||||
|
||||
if ($ord) {
|
||||
if ((int) $ord['reprogrammed'] >= (int) $ord['maxreschedule']) {
|
||||
// Limite raggiunto: può cancellare (libera il posto) ma il ticket va perso
|
||||
$ticketLost = true;
|
||||
} else {
|
||||
$incremented = 'Y';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Copia nello storico (prima di eliminare)
|
||||
$stmt = $conn->prepare("
|
||||
INSERT INTO bookingclass_cancellations
|
||||
(idbookingclass, idserviceschedule, bookingstart, status, idservice, iduser, idorder,
|
||||
prevbookingstart, is_reprogrammed, is_gift, booking_created_at,
|
||||
cancelled_at, cancelled_by, reprogrammed_incremented)
|
||||
SELECT idbookingclass, idserviceschedule, bookingstart, status, idservice, iduser, idorder,
|
||||
prevbookingstart, is_reprogrammed, is_gift, created_at,
|
||||
NOW(), ?, ?
|
||||
FROM bookingclass
|
||||
WHERE idbookingclass = ? AND iduser = ?
|
||||
");
|
||||
$storicoFlag = $ticketLost ? 'L' : $incremented;
|
||||
$stmt->bind_param("isii", $userId, $storicoFlag, $id, $userId);
|
||||
$stmt->execute();
|
||||
$copied = $stmt->affected_rows;
|
||||
$stmt->close();
|
||||
if ($copied !== 1) {
|
||||
throw new RuntimeException('notfound');
|
||||
}
|
||||
|
||||
// La cancellazione conta come una riprogrammazione
|
||||
if ($incremented === 'Y') {
|
||||
$stmt = $conn->prepare("UPDATE orderbook SET reprogrammed = reprogrammed + 1 WHERE idorderbook = ?");
|
||||
$stmt->bind_param("i", $idOrder);
|
||||
$stmt->execute();
|
||||
$stmt->close();
|
||||
}
|
||||
|
||||
// Limite raggiunto: la prenotazione resta come persa (status 'lost'),
|
||||
// il posto in sala si libera ma il ticket non torna da programmare.
|
||||
// Altrimenti eliminazione normale: il ticket torna disponibile.
|
||||
if ($ticketLost) {
|
||||
$stmt = $conn->prepare("UPDATE bookingclass SET status = 'lost', lostlesson = 'Y' WHERE idbookingclass = ? AND iduser = ?");
|
||||
} else {
|
||||
$stmt = $conn->prepare("DELETE FROM bookingclass WHERE idbookingclass = ? AND iduser = ?");
|
||||
}
|
||||
$stmt->bind_param("ii", $id, $userId);
|
||||
$stmt->execute();
|
||||
$deleted = $stmt->affected_rows;
|
||||
$stmt->close();
|
||||
if ($deleted !== 1) {
|
||||
throw new RuntimeException('notfound');
|
||||
}
|
||||
|
||||
$conn->commit();
|
||||
} catch (Throwable $e) {
|
||||
try {
|
||||
$conn->rollback();
|
||||
} catch (Throwable $ignored) {
|
||||
}
|
||||
|
||||
$code = in_array($e->getMessage(), ['limit', 'notfound'], true) ? $e->getMessage() : 'db';
|
||||
if ($code === 'db') {
|
||||
error_log("delete_lesson #{$id} utente #{$userId}: " . $e->getMessage());
|
||||
}
|
||||
header("Location: userpanel.php?deleted=0&err=" . $code);
|
||||
exit();
|
||||
}
|
||||
// Connessione $bkngstm gestita globalmente: non la chiudiamo qui
|
||||
|
||||
/***********************
|
||||
@@ -144,7 +255,8 @@ try {
|
||||
$mail->Body = "
|
||||
<div style='font-family:Arial,sans-serif;font-size:14px;color:#222'>
|
||||
<p>Ciao " . htmlspecialchars($userName ?: 'Yogi') . ",</p>
|
||||
<p>La tua lezione è stata <b>cancellata</b>.</p>
|
||||
<p>La tua lezione è stata <b>cancellata</b>.</p>" . ($ticketLost ? "
|
||||
<p>Avendo già usato tutte le riprogrammazioni del pacchetto, questa lezione non potrà essere recuperata.</p>" : "") . "
|
||||
<p>
|
||||
<b>Lezione:</b> " . htmlspecialchars($className) . "<br>
|
||||
<b>Data/ora prevista:</b> " . htmlspecialchars($classDateTxt) . "<br>
|
||||
@@ -164,5 +276,5 @@ try {
|
||||
}
|
||||
|
||||
// Redirect
|
||||
header("Location: userpanel.php?deleted=1");
|
||||
header("Location: userpanel.php?deleted=" . ($ticketLost ? 'lost' : '1'));
|
||||
exit();
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<div class="dropdown d-inline-block">
|
||||
<button type="button" class="btn header-item user text-start d-flex align-items-center" id="page-header-user-dropdown-v"
|
||||
data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<img class="rounded-circle header-profile-user" src="upload/users/<?php echo $avatarname; ?>"
|
||||
alt="Header Avatar">
|
||||
<span class="d-none d-xl-inline-block ms-2 fw-medium font-size-15"><?php echo $nameuser; ?> </span>
|
||||
</button>
|
||||
<div class="dropdown-menu dropdown-menu-end pt-0">
|
||||
<div class="p-3 border-bottom">
|
||||
<h6 class="mb-0"><?php echo $nameuser; ?> </h6>
|
||||
|
||||
</div>
|
||||
<a class="dropdown-item" href="profile"><i class="ti-user text-muted mr-2"></i> Profilo utente</a>
|
||||
<button type="button" class="btn header-item user text-start d-flex align-items-center" id="page-header-user-dropdown-v"
|
||||
data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<img class="rounded-circle header-profile-user" src="upload/users/<?php echo $avatarname; ?>"
|
||||
alt="Header Avatar">
|
||||
<span class="d-none d-xl-inline-block ms-2 fw-medium font-size-15"><?php echo $nameuser; ?> </span>
|
||||
</button>
|
||||
<div class="dropdown-menu dropdown-menu-end pt-0">
|
||||
<div class="p-3 border-bottom">
|
||||
<h6 class="mb-0"><?php echo $nameuser; ?> </h6>
|
||||
|
||||
</div>
|
||||
<a class="dropdown-item" href="userdetails.php"><i class="ti-user text-muted mr-2"></i> Profilo utente</a>
|
||||
|
||||
|
||||
<a class="dropdown-item" href="notification.php"><i class="ti-settings text-muted mr-2"></i> Notifiche</a>
|
||||
<div class="dropdown-divider"></div>
|
||||
<a class="dropdown-item" href="logout"><i class="mdi mdi-logout text-muted font-size-16 align-middle me-2"></i> <span class="align-middle">Logout</span></a>
|
||||
</div>
|
||||
</div>
|
||||
<a class="dropdown-item" href="notification.php"><i class="ti-settings text-muted mr-2"></i> Notifiche</a>
|
||||
<div class="dropdown-divider"></div>
|
||||
<a class="dropdown-item" href="logout"><i class="mdi mdi-logout text-muted font-size-16 align-middle me-2"></i> <span class="align-middle">Logout</span></a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -157,6 +157,12 @@
|
||||
<span class="menu-item">Statistiche</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="sommario.php" class="">
|
||||
<i class="bx bx-certification icon nav-icon"></i>
|
||||
<span class="menu-item">Sommario</span>
|
||||
</a>
|
||||
</li>
|
||||
<!-- Impostazioni -->
|
||||
<li class="menu-title" data-key="t-admin-settings">Impostazioni</li>
|
||||
|
||||
@@ -172,7 +178,24 @@
|
||||
<span class="menu-item">Insegnanti</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<a href="profile" class="">
|
||||
<i class="bx bx-slider-alt icon nav-icon"></i>
|
||||
<span class="menu-item">Utenti DB</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="cancellazioni.php" class="">
|
||||
<i class="bx bx-slider-alt icon nav-icon"></i>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
[2026-02-18 13:43:57] ERROR sending. booking_id=29 user_id=2 to=info@claudiosironi.com :: SMTP Error: Could not connect to SMTP host. Failed to connect to server
|
||||
[2026-02-18 13:46:10] ERROR sending. booking_id=30 user_id=2 to=info@claudiosironi.com :: SMTP Error: Could not connect to SMTP host. Failed to connect to server
|
||||
@@ -0,0 +1,418 @@
|
||||
<?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'); ?>
|
||||
<header class="ishorizontal-topbar">
|
||||
<div class="navbar-header">
|
||||
<div class="d-flex"></div>
|
||||
</div>
|
||||
<div class="topnav">
|
||||
<div class="container-fluid">
|
||||
<nav class="navbar navbar-light navbar-expand-lg topnav-menu"></nav>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="main-content">
|
||||
<div class="page-content">
|
||||
<div class="container-fluid">
|
||||
<?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,478 @@
|
||||
<?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_NO_FILE) !== UPLOAD_ERR_OK) {
|
||||
$why = [
|
||||
UPLOAD_ERR_INI_SIZE => 'il file supera il limite del server (upload_max_filesize)',
|
||||
UPLOAD_ERR_FORM_SIZE => 'il file è troppo grande',
|
||||
UPLOAD_ERR_PARTIAL => 'caricamento interrotto',
|
||||
UPLOAD_ERR_NO_FILE => 'nessun file ricevuto',
|
||||
UPLOAD_ERR_NO_TMP_DIR => 'manca la cartella temporanea sul server',
|
||||
UPLOAD_ERR_CANT_WRITE => 'il server non riesce a scrivere il file',
|
||||
][$f['error'] ?? UPLOAD_ERR_NO_FILE] ?? 'errore sconosciuto';
|
||||
fail('Caricamento non riuscito: ' . $why . '.');
|
||||
}
|
||||
if ($f['size'] > 15 * 1024 * 1024) fail('Immagine troppo grande (massimo 15 MB).');
|
||||
|
||||
$info = @getimagesize($f['tmp_name']);
|
||||
$mime = $info['mime'] ?? '';
|
||||
$ext = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp'][$mime] ?? null;
|
||||
if (!$ext) 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 ' . $dirRel . ': controlla i permessi di assets/uploads.', 500);
|
||||
}
|
||||
if (!is_writable($dirAbs)) fail('La cartella ' . $dirRel . ' non è scrivibile: controlla i permessi.', 500);
|
||||
|
||||
$name = date('Ymd_His') . '_' . bin2hex(random_bytes(4));
|
||||
[$w, $h] = $info;
|
||||
$note = '';
|
||||
|
||||
// Si può ottimizzare? Serve GD con il formato giusto
|
||||
$reader = ['image/jpeg' => 'imagecreatefromjpeg', 'image/png' => 'imagecreatefrompng', 'image/webp' => 'imagecreatefromwebp'][$mime] ?? null;
|
||||
$canResize = $mime !== 'image/gif' && $reader && function_exists($reader) && function_exists('imagecreatetruecolor');
|
||||
|
||||
if (!$canResize) {
|
||||
// GIF (forse animata) oppure server senza GD: salviamo l'originale
|
||||
if ($mime !== 'image/gif' && $f['size'] > 3 * 1024 * 1024) {
|
||||
fail('Il server non può ridimensionare le immagini (manca la libreria GD) e questa supera 3 MB: riducila prima di caricarla.');
|
||||
}
|
||||
$file = $name . '.' . $ext;
|
||||
if (!move_uploaded_file($f['tmp_name'], $dirAbs . '/' . $file)) fail('Impossibile salvare l\'immagine.', 500);
|
||||
if ($mime !== 'image/gif') $note = 'Caricata senza ottimizzazione (sul server manca la libreria GD).';
|
||||
} else {
|
||||
try {
|
||||
@ini_set('memory_limit', '512M'); // le foto dal telefono sono grandi
|
||||
$src = @$reader($f['tmp_name']);
|
||||
if (!$src) fail('Immagine non leggibile o danneggiata.');
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
$nw = min($w, 1200); // il doppio della larghezza email: nitida sugli schermi retina
|
||||
$nh = max(1, (int) round($h * $nw / $w));
|
||||
|
||||
// PNG con trasparenza resta PNG, il resto diventa JPG (molto più leggero)
|
||||
$keepPng = false;
|
||||
if ($mime === 'image/png') {
|
||||
$ct = ord((string) 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 ottimizzata.', 500);
|
||||
[$w, $h] = [$nw, $nh];
|
||||
} catch (Throwable $e) {
|
||||
error_log('newsletter_api [upload_image]: ' . $e->getMessage());
|
||||
fail('Ottimizzazione non riuscita: ' . $e->getMessage(), 500);
|
||||
}
|
||||
}
|
||||
|
||||
out([
|
||||
'success' => true,
|
||||
'url' => baseUrl() . '/' . $dirRel . '/' . $file,
|
||||
'width' => $w,
|
||||
'height' => $h,
|
||||
'kb' => (int) round(filesize($dirAbs . '/' . $file) / 1024),
|
||||
'note' => $note,
|
||||
]);
|
||||
|
||||
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);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -41,7 +41,7 @@ function generateEmailTemplate($message)
|
||||
text-align: center;
|
||||
}
|
||||
.header img {
|
||||
max-width: 100px; /* Ridotto da 150px a 100px per rimpicciolire il logo */
|
||||
max-width: 120px;
|
||||
}
|
||||
.content {
|
||||
padding: 30px;
|
||||
@@ -76,11 +76,10 @@ function generateEmailTemplate($message)
|
||||
<div class="container">
|
||||
<div class="yoga-decoration"></div>
|
||||
<div class="header">
|
||||
<img src="https://yogasoul.it/wp-content/uploads/2021/03/logotransizione_final2.png"
|
||||
alt="YogaSoul - My WordPress Blog"
|
||||
width="120"
|
||||
style="display: block; width: 120px; max-width: 120px; height: auto; margin: 0 auto;">
|
||||
|
||||
<img src="https://yogasoul.it/wp-content/uploads/2021/03/logotransizione_final2.png"
|
||||
alt="YogaSoul"
|
||||
width="120"
|
||||
style="display: block; width: 120px; max-width: 120px; height: auto; margin: 0 auto;">
|
||||
</div>
|
||||
<div class="content">
|
||||
<p>$message</p>
|
||||
@@ -183,7 +182,7 @@ try {
|
||||
$message .= '. Non inviate (' . count($failed) . '): ' . implode(', ', $failed);
|
||||
}
|
||||
|
||||
echo json_encode(['success' => true, 'message' => $message]);
|
||||
echo json_encode(['success' => true, 'message' => $message, 'sent' => $sent, 'failed' => $failed]);
|
||||
} catch (\Throwable $e) {
|
||||
error_log('Errore in send_email.php: ' . $e->getMessage() . ' in ' . $e->getFile() . ' at line ' . $e->getLine());
|
||||
echo json_encode(['success' => false, 'message' => "Errore nell'invio dell'email: " . $e->getMessage()]);
|
||||
|
||||
+258
-152
@@ -3,7 +3,8 @@ require_once('include/headscript.php');
|
||||
|
||||
/**
|
||||
* Statistiche utenti: lezioni acquistate/praticate/programmate/perse/da programmare,
|
||||
* con dettaglio lezioni per utente, riprogrammazione e invio email (singola/massa).
|
||||
* abbonamento attivo, certificato medico, dettaglio lezioni per utente,
|
||||
* riprogrammazione e invio email (singola/massa).
|
||||
*/
|
||||
$pdo = DBHandlerSelect::getInstance()->getConnection();
|
||||
|
||||
@@ -25,10 +26,10 @@ SELECT
|
||||
MAX(cert.ultima_scadenza) AS ultima_scadenza_cert,
|
||||
MAX(CASE WHEN cert.ultima_scadenza IS NULL OR cert.ultima_scadenza < CURDATE() THEN 1 ELSE 0 END) AS certificato_scaduto,
|
||||
COUNT(CASE WHEN bookingclass.bookingstart <= CURDATE() AND bookingclass.lostlesson = 'N' THEN 1 END) AS lezioni_praticate,
|
||||
COUNT(CASE WHEN bookingclass.bookingstart > CURDATE() THEN 1 END) AS lezioni_programmate,
|
||||
COUNT(CASE WHEN bookingclass.bookingstart > CURDATE() AND bookingclass.lostlesson = 'N' THEN 1 END)AS lezioni_programmate,
|
||||
COUNT(CASE WHEN bookingclass.lostlesson = 'Y' THEN 1 END) AS lezioni_perse,
|
||||
COALESCE(orderbook_lessons.lezioni_acquistate, 0)
|
||||
- COUNT(CASE WHEN bookingclass.bookingstart > CURDATE() THEN 1 END)
|
||||
- COUNT(CASE WHEN bookingclass.bookingstart > CURDATE() AND bookingclass.lostlesson = 'N' THEN 1 END)
|
||||
- COUNT(CASE WHEN bookingclass.bookingstart <= CURDATE() AND bookingclass.lostlesson = 'N' THEN 1 END)
|
||||
- COUNT(CASE WHEN bookingclass.lostlesson = 'Y' THEN 1 END) AS lezioni_da_programmare
|
||||
FROM auth_users
|
||||
@@ -474,12 +475,18 @@ $expiredCertCount = count(array_filter($users, fn($u) => (int) $u['ha_ordine_att
|
||||
<div class="modal-body">
|
||||
<form id="reprogramForm">
|
||||
<div class="mb-3">
|
||||
<label for="classSelect" class="form-label">Seleziona classe</label>
|
||||
<select class="form-control" id="classSelect" required></select>
|
||||
<label for="lessonSelect" class="form-label">Nuova lezione</label>
|
||||
<select class="form-select" id="lessonSelect"></select>
|
||||
<div class="form-text">Lezioni della stessa classe e delle classi associate, nei prossimi 90 giorni.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="reprogramDate" class="form-label">Seleziona data</label>
|
||||
<input type="text" class="form-control" id="reprogramDate" readonly required>
|
||||
<div id="reprogramInfo" class="text-muted mb-3" style="font-size:13px;"></div>
|
||||
<div class="form-check mb-2">
|
||||
<input class="form-check-input" type="checkbox" id="rpSendEmail" checked>
|
||||
<label class="form-check-label" for="rpSendEmail">Invia email all'allieva con la nuova data</label>
|
||||
</div>
|
||||
<div class="form-check" id="rpCountWrap">
|
||||
<input class="form-check-input" type="checkbox" id="rpCountReprog">
|
||||
<label class="form-check-label" for="rpCountReprog">Conta come riprogrammazione</label>
|
||||
</div>
|
||||
<input type="hidden" id="reprogramIdBookingClass">
|
||||
</form>
|
||||
@@ -501,28 +508,30 @@ $expiredCertCount = count(array_filter($users, fn($u) => (int) $u['ha_ordine_att
|
||||
<script>
|
||||
const serverNow = '<?php echo $serverCurrentDateTime; ?>';
|
||||
|
||||
// Testo predefinito per l'email "certificato scaduto".
|
||||
// Il .replace finale toglie gli spazi a inizio riga se l'editor li rientra.
|
||||
const TESTO_CERTIFICATO = `Gentile {nome},
|
||||
|
||||
ti ricordiamo che il certificato di idoneità alla pratica è **obbligatorio** per la partecipazione alle attività Yoga svolte presso YogaSoul.
|
||||
Come riportato nella descrizione di ogni pacchetto da 4 o 12 lezioni acquistato, è necessario munirsi del documento di buona salute e 👉 **caricarlo sulla piattaforma YogiBOOK** ➡️ https://yogibook.yogasoul.it/ per partecipare alle lezioni.
|
||||
ti ricordiamo che il certificato di idoneità alla pratica è **obbligatorio** per la partecipazione alle attività Yoga svolte presso YogaSoul.
|
||||
Come riportato nella descrizione di ogni pacchetto da 4 o 12 lezioni acquistato, è necessario munirsi del documento di buona salute e 👉 **caricarlo sulla piattaforma YogiBOOK** ➡️ https://yogibook.yogasoul.it/ per partecipare alle lezioni.
|
||||
|
||||
👉 **Come fare:**
|
||||
👉 **Come fare:**
|
||||
|
||||
- Accedi alla tua **area personale** (le credenziali ti sono state fornite per mail)
|
||||
- Seleziona la sezione **Certificati**
|
||||
- Carica il file in formato **PDF o JPG**
|
||||
- La data di scadenza che dovrà essere da voi inserita sarà **la stessa riportata sul documento +364 giorni** ➡️ anno 2027
|
||||
- Il documento verrà da noi controllato e archiviato
|
||||
• Accedi alla tua **area personale** (le credenziali ti sono state fornite per mail)
|
||||
• Seleziona la sezione **Certificati**
|
||||
• Carica il file in formato **PDF o JPG**
|
||||
• La data di scadenza che dovrà essere da voi inserita sarà **la stessa riportata sul documento +364 giorni** ➡️ anno 2027
|
||||
• Il documento verrà da noi controllato e archiviato
|
||||
|
||||
👉 **Se il tuo documento è in scadenza o già scaduto** ti chiediamo di controllare e provvedere ad inserire quello nuovo.
|
||||
👉 **Se il tuo documento è in scadenza o già scaduto** ti chiediamo di controllare e provvedere ad inserire quello nuovo.
|
||||
|
||||
Il certificato è indispensabile per garantire una pratica sicura e conforme alle normative vigenti ed essere in regola qualora ci fossero dei controlli interni.
|
||||
**In assenza del certificato medico, la partecipazione alle attività non sarà consentita** e YogaSoul declina ogni responsabilità per documenti non caricati o non consegnati.
|
||||
Ti invitiamo pertanto a procedere quanto prima, al fine di evitare sospensioni dei pacchetti o limitazioni nella partecipazione alle lezioni.
|
||||
Il certificato è indispensabile per garantire una pratica sicura e conforme alle normative vigenti ed essere in regola qualora ci fossero dei controlli interni.
|
||||
**In assenza del certificato medico, la partecipazione alle attività non sarà consentita** e YogaSoul declina ogni responsabilità per documenti non caricati o non consegnati.
|
||||
Ti invitiamo pertanto a procedere quanto prima, al fine di evitare sospensioni dei pacchetti o limitazioni nella partecipazione alle lezioni.
|
||||
|
||||
Ti ringraziamo per la collaborazione e per l’attenzione alla sicurezza di tutti.
|
||||
Ti ringraziamo per la collaborazione e per l’attenzione alla sicurezza di tutti.
|
||||
|
||||
**TEAM YogaSoul**`.replace(/^[ \t]+/gm, '');
|
||||
**TEAM YogaSoul**`.replace(/^[ \t]+/gm, '');
|
||||
|
||||
// Helper Swal che rimuove aria-hidden (fix focus modali Bootstrap)
|
||||
function alertBox(opts) {
|
||||
@@ -614,7 +623,8 @@ $expiredCertCount = count(array_filter($users, fn($u) => (int) $u['ha_ordine_att
|
||||
actionTd.append($('<button>').addClass('btn btn-sm btn-danger remove-class action-icon')
|
||||
.attr('title', 'Rimuovi')
|
||||
.html('<i class="fas fa-trash"></i>')
|
||||
.data('id', row.idbookingclass));
|
||||
.data('id', row.idbookingclass)
|
||||
.data('order', row.idorder || ''));
|
||||
} else {
|
||||
actionTd.text('-');
|
||||
}
|
||||
@@ -636,44 +646,70 @@ $expiredCertCount = count(array_filter($users, fn($u) => (int) $u['ha_ordine_att
|
||||
refreshTable(id, nome);
|
||||
});
|
||||
|
||||
// Rimuovi lezione
|
||||
// Rimuovi lezione: l'insegnante sceglie email e conteggio riprogrammazione
|
||||
$(document).on('click', '.remove-class', function() {
|
||||
var id = $(this).data('id');
|
||||
var hasOrder = String($(this).data('order') || '') !== '';
|
||||
var idU = $('#classModal').data('current-id'),
|
||||
nomeU = $('#userName').text();
|
||||
|
||||
var html = '<p>La lezione verrà rimossa e salvata nello storico delle cancellazioni.</p>' +
|
||||
'<div class="text-start" style="max-width:360px;margin:0 auto;">' +
|
||||
'<div class="form-check mb-2">' +
|
||||
'<input class="form-check-input" type="checkbox" id="swSendEmail" checked>' +
|
||||
'<label class="form-check-label" for="swSendEmail">Invia email di conferma all\'allieva</label>' +
|
||||
'</div>' +
|
||||
(hasOrder ?
|
||||
'<div class="form-check">' +
|
||||
'<input class="form-check-input" type="checkbox" id="swCountReprog">' +
|
||||
'<label class="form-check-label" for="swCountReprog">Conta come riprogrammazione</label>' +
|
||||
'</div>' :
|
||||
'<div class="text-muted" style="font-size:13px;">Lezione omaggio: nessuna riprogrammazione da conteggiare.</div>') +
|
||||
'</div>';
|
||||
|
||||
alertBox({
|
||||
title: 'Sei sicuro?',
|
||||
text: 'Questa lezione verrà rimossa definitivamente.',
|
||||
title: 'Cancellare la lezione?',
|
||||
html: html,
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonColor: '#d33',
|
||||
confirmButtonText: 'Sì, rimuovi',
|
||||
cancelButtonText: 'Annulla'
|
||||
confirmButtonText: 'Sì, cancella',
|
||||
cancelButtonText: 'Annulla',
|
||||
preConfirm: () => ({
|
||||
send: $('#swSendEmail').is(':checked') ? 'Y' : 'N',
|
||||
count: $('#swCountReprog').is(':checked') ? 'Y' : 'N'
|
||||
})
|
||||
}).then((r) => {
|
||||
if (r.isConfirmed) {
|
||||
$.post('deleteclass.php', {
|
||||
id: id
|
||||
}, null, 'json')
|
||||
.done(function(resp) {
|
||||
if (resp.success) {
|
||||
alertBox({
|
||||
title: 'Fatto',
|
||||
text: 'Lezione rimossa.',
|
||||
icon: 'success'
|
||||
});
|
||||
refreshTable(idU, nomeU);
|
||||
} else alertBox({
|
||||
if (!r.isConfirmed) return;
|
||||
$.post('admin_cancel_booking.php', {
|
||||
id: id,
|
||||
send_email: r.value.send,
|
||||
count_reprog: r.value.count
|
||||
}, null, 'json')
|
||||
.done(function(resp) {
|
||||
if (resp && resp.success) {
|
||||
alertBox({
|
||||
title: 'Fatto',
|
||||
text: resp.message,
|
||||
icon: 'success'
|
||||
});
|
||||
refreshTable(idU, nomeU);
|
||||
} else {
|
||||
alertBox({
|
||||
title: 'Errore',
|
||||
text: resp.message || 'Errore durante la rimozione.',
|
||||
text: (resp && resp.message) || 'Errore durante la cancellazione.',
|
||||
icon: 'error'
|
||||
});
|
||||
})
|
||||
.fail((xhr) => alertBox({
|
||||
}
|
||||
})
|
||||
.fail(function(xhr) {
|
||||
var msg = (xhr.responseJSON && xhr.responseJSON.message) || 'Errore durante la cancellazione.';
|
||||
alertBox({
|
||||
title: 'Errore',
|
||||
text: 'Errore durante l\'invio: ' + (xhr.responseText || xhr.statusText || 'nessuna risposta').substring(0, 400),
|
||||
text: msg,
|
||||
icon: 'error'
|
||||
}));
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -718,115 +754,112 @@ $expiredCertCount = count(array_filter($users, fn($u) => (int) $u['ha_ordine_att
|
||||
});
|
||||
});
|
||||
|
||||
// Riprogramma: apri modale
|
||||
// Riprogramma (scuola): scegli una lezione già in calendario
|
||||
$(document).on('click', '.reprogram-class', function() {
|
||||
var id = $(this).data('id'),
|
||||
servicename = $(this).data('servicename'),
|
||||
day = $(this).data('day'),
|
||||
time = $(this).data('time');
|
||||
var id = $(this).data('id');
|
||||
$('#reprogramIdBookingClass').val(id);
|
||||
$('#reprogramModalLabel').text('Riprogramma: ' + servicename);
|
||||
$.ajax({
|
||||
url: 'fetch_available_classes.php',
|
||||
method: 'GET',
|
||||
dataType: 'json',
|
||||
success: function(data) {
|
||||
var sel = $('#classSelect').empty();
|
||||
$.each(data, function(i, c) {
|
||||
var opt = $('<option>').val(c.id).text(c.servicename + ' (' + c.day + ' ' + c.time + ')');
|
||||
if (c.servicename === servicename && c.day === day && c.time === time) opt.prop('selected', true);
|
||||
sel.append(opt);
|
||||
});
|
||||
$('#reprogramDate').datepicker({
|
||||
dateFormat: 'yy-mm-dd',
|
||||
changeMonth: true,
|
||||
changeYear: true
|
||||
});
|
||||
loadAvailableDates(sel.val());
|
||||
$('#reprogramModal').modal('show');
|
||||
},
|
||||
error: () => alertBox({
|
||||
title: 'Errore',
|
||||
text: 'Errore nel caricamento delle classi.',
|
||||
icon: 'error'
|
||||
})
|
||||
});
|
||||
});
|
||||
$('#reprogramModalLabel').text('Riprogramma: ' + $(this).data('servicename'));
|
||||
$('#lessonSelect').html('<option>Caricamento…</option>').prop('disabled', true);
|
||||
$('#reprogramInfo').text('');
|
||||
$('#rpSendEmail').prop('checked', true);
|
||||
$('#rpCountReprog').prop('checked', false);
|
||||
$('#reprogramModal').data('hasorder', false).modal('show');
|
||||
|
||||
$('#classSelect').on('change', function() {
|
||||
loadAvailableDates($(this).val());
|
||||
});
|
||||
|
||||
function loadAvailableDates(classId) {
|
||||
$.ajax({
|
||||
url: 'fetch_available_dates.php',
|
||||
method: 'POST',
|
||||
data: {
|
||||
class_id: classId
|
||||
},
|
||||
dataType: 'json',
|
||||
success: function(data) {
|
||||
$('#reprogramDate').datepicker('option', 'beforeShowDay', function(date) {
|
||||
var s = $.datepicker.formatDate('yy-mm-dd', date);
|
||||
return [data.availableDates.includes(s), ''];
|
||||
});
|
||||
},
|
||||
error: () => alertBox({
|
||||
title: 'Errore',
|
||||
text: 'Errore nel caricamento delle date.',
|
||||
icon: 'error'
|
||||
$.getJSON('admin_reprogram_lessons.php', {
|
||||
id: id
|
||||
})
|
||||
});
|
||||
}
|
||||
.done(function(data) {
|
||||
var sel = $('#lessonSelect').empty();
|
||||
if (!data.success) {
|
||||
sel.append($('<option>').text(data.message || 'Errore'));
|
||||
return;
|
||||
}
|
||||
sel.prop('disabled', false)
|
||||
.append($('<option value="">').text('— Scegli la nuova lezione —'));
|
||||
if (!data.lessons.length) {
|
||||
sel.append($('<option disabled>').text('Nessuna lezione nei prossimi 90 giorni'));
|
||||
}
|
||||
$.each(data.lessons, function(i, l) {
|
||||
var txt = l.label + ' · ' + l.servicename + ' · ' + l.occupied + (l.max !== null ? '/' + l.max : '') + ' posti';
|
||||
if (l.mine) txt += ' (già iscritta)';
|
||||
else if (l.full) txt += ' (piena)';
|
||||
else if (l.after_expiry) txt += ' (oltre la scadenza del pacchetto)';
|
||||
sel.append($('<option>').val(l.id).text(txt).prop('disabled', l.mine || l.full));
|
||||
});
|
||||
|
||||
var o = data.order;
|
||||
$('#reprogramModal').data('hasorder', o.has_order);
|
||||
$('#rpCountWrap').toggle(o.has_order);
|
||||
$('#reprogramInfo').text(o.has_order ?
|
||||
'Riprogrammazioni usate: ' + o.reprogrammed + ' su ' + o.maxreschedule +
|
||||
(o.expireon ? ' · Scadenza pacchetto: ' + o.expireon : '') :
|
||||
'Lezione omaggio: nessuna riprogrammazione da conteggiare.');
|
||||
})
|
||||
.fail(function() {
|
||||
$('#lessonSelect').html('<option>Errore nel caricamento delle lezioni</option>');
|
||||
});
|
||||
});
|
||||
|
||||
$('#reprogramBtn').on('click', function() {
|
||||
var btn = $(this);
|
||||
var id = $('#reprogramIdBookingClass').val(),
|
||||
classId = $('#classSelect').val(),
|
||||
newDate = $('#reprogramDate').val();
|
||||
sched = $('#lessonSelect').val();
|
||||
var idU = $('#classModal').data('current-id'),
|
||||
nomeU = $('#userName').text();
|
||||
if (!newDate || !classId) {
|
||||
|
||||
if (!sched) {
|
||||
alertBox({
|
||||
title: 'Errore',
|
||||
text: 'Seleziona classe e data.',
|
||||
icon: 'error'
|
||||
title: 'Attenzione',
|
||||
text: 'Scegli la nuova lezione.',
|
||||
icon: 'warning'
|
||||
});
|
||||
return;
|
||||
}
|
||||
$.post('reprogram_class.php', {
|
||||
|
||||
btn.prop('disabled', true);
|
||||
$.post('admin_reprogram_booking.php', {
|
||||
id: id,
|
||||
class_id: classId,
|
||||
new_date: newDate
|
||||
idschedule: sched,
|
||||
send_email: $('#rpSendEmail').is(':checked') ? 'Y' : 'N',
|
||||
count_reprog: ($('#reprogramModal').data('hasorder') && $('#rpCountReprog').is(':checked')) ? 'Y' : 'N'
|
||||
}, null, 'json')
|
||||
.done(function(resp) {
|
||||
if (resp.success) {
|
||||
if (resp && resp.success) {
|
||||
$('#reprogramModal').modal('hide');
|
||||
alertBox({
|
||||
title: 'Fatto',
|
||||
text: 'Lezione riprogrammata.',
|
||||
text: resp.message,
|
||||
icon: 'success'
|
||||
});
|
||||
$('#reprogramModal').modal('hide');
|
||||
refreshTable(idU, nomeU);
|
||||
} else alertBox({
|
||||
} else {
|
||||
alertBox({
|
||||
title: 'Errore',
|
||||
text: (resp && resp.message) || 'Errore durante la riprogrammazione.',
|
||||
icon: 'error'
|
||||
});
|
||||
}
|
||||
})
|
||||
.fail(function(xhr) {
|
||||
var msg = (xhr.responseJSON && xhr.responseJSON.message) || 'Errore durante la riprogrammazione.';
|
||||
alertBox({
|
||||
title: 'Errore',
|
||||
text: resp.message || 'Errore durante la riprogrammazione.',
|
||||
text: msg,
|
||||
icon: 'error'
|
||||
});
|
||||
})
|
||||
.fail(() => alertBox({
|
||||
title: 'Errore',
|
||||
text: 'Errore durante la riprogrammazione.',
|
||||
icon: 'error'
|
||||
}));
|
||||
.always(function() {
|
||||
btn.prop('disabled', false);
|
||||
});
|
||||
});
|
||||
|
||||
// Email singola
|
||||
$('.send-email').on('click', function() {
|
||||
var id = $(this).data('id'),
|
||||
nome = $(this).data('nome'),
|
||||
email = $(this).data('email');
|
||||
$('#emailModalLabel').text('Invia email a ' + nome);
|
||||
$('#emailBody').val('Ciao ' + nome.split(' ')[0] + ',\n\n');
|
||||
$('#emailSubject').val('');
|
||||
$('#emailBody').val('Ciao {nome},\n\n');
|
||||
$('#emailRecipientCount').text('Destinatario: ' + email);
|
||||
$('#emailRecipients').val(JSON.stringify([{
|
||||
id: id,
|
||||
@@ -836,12 +869,12 @@ $expiredCertCount = count(array_filter($users, fn($u) => (int) $u['ha_ordine_att
|
||||
$('#emailModal').modal('show');
|
||||
});
|
||||
|
||||
// Email a TUTTI con routine di conferma robusta
|
||||
// Email di massa: abbonati attivi oppure abbonati con certificato scaduto
|
||||
$('#sendEmailToAll, #sendEmailCertExpired').on('click', function() {
|
||||
var selector = this.id === 'sendEmailCertExpired' ?
|
||||
var isCert = this.id === 'sendEmailCertExpired';
|
||||
var selector = isCert ?
|
||||
'.send-email[data-attivo="1"][data-certscaduto="1"]' :
|
||||
'.send-email[data-attivo="1"]';
|
||||
var isCert = this.id === 'sendEmailCertExpired';
|
||||
var recipients = [];
|
||||
$(selector).each(function() {
|
||||
recipients.push({
|
||||
@@ -903,11 +936,23 @@ $expiredCertCount = count(array_filter($users, fn($u) => (int) $u['ha_ordine_att
|
||||
});
|
||||
});
|
||||
|
||||
// Invio effettivo email
|
||||
// Invio effettivo email: a gruppi, con barra di avanzamento
|
||||
var invioInCorso = false;
|
||||
|
||||
window.addEventListener('beforeunload', function(e) {
|
||||
if (invioInCorso) {
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
}
|
||||
});
|
||||
|
||||
$('#sendEmailBtn').on('click', function() {
|
||||
if (invioInCorso) return;
|
||||
|
||||
var subject = $('#emailSubject').val(),
|
||||
body = $('#emailBody').val();
|
||||
var recipients = $('#emailRecipients').val();
|
||||
var recipients = JSON.parse($('#emailRecipients').val() || '[]');
|
||||
|
||||
if (!subject || !body) {
|
||||
alertBox({
|
||||
title: 'Errore',
|
||||
@@ -916,31 +961,92 @@ $expiredCertCount = count(array_filter($users, fn($u) => (int) $u['ha_ordine_att
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
$.post('send_email.php', {
|
||||
subject: subject,
|
||||
body: body,
|
||||
recipients: recipients
|
||||
}, null, 'json')
|
||||
.done(function(resp) {
|
||||
if (resp.success) {
|
||||
alertBox({
|
||||
title: 'Fatto',
|
||||
text: resp.message,
|
||||
icon: 'success'
|
||||
});
|
||||
$('#emailModal').modal('hide');
|
||||
} else alertBox({
|
||||
title: 'Errore',
|
||||
text: resp.message,
|
||||
icon: 'error'
|
||||
});
|
||||
})
|
||||
.fail(() => alertBox({
|
||||
if (recipients.length === 0) {
|
||||
alertBox({
|
||||
title: 'Errore',
|
||||
text: 'Errore durante l\'invio.',
|
||||
text: 'Nessun destinatario.',
|
||||
icon: 'error'
|
||||
}));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
invioInCorso = true;
|
||||
$('#sendEmailBtn').prop('disabled', true);
|
||||
$('#emailModal').modal('hide');
|
||||
|
||||
var BATCH = 5; // email per ogni richiesta
|
||||
var total = recipients.length,
|
||||
done = 0,
|
||||
sent = 0,
|
||||
failed = [];
|
||||
|
||||
alertBox({
|
||||
title: 'Invio in corso…',
|
||||
html: '<p id="sendProgressText">0 di ' + total + '</p>' +
|
||||
'<div class="progress" style="height:22px;">' +
|
||||
'<div id="sendProgressBar" class="progress-bar progress-bar-striped progress-bar-animated" style="width:0%">0%</div>' +
|
||||
'</div>' +
|
||||
'<p class="mt-3 mb-0 text-muted" style="font-size:13px;">Non chiudere questa pagina finché l\'invio non è terminato.</p>',
|
||||
allowOutsideClick: false,
|
||||
allowEscapeKey: false,
|
||||
showConfirmButton: false
|
||||
});
|
||||
|
||||
function aggiorna() {
|
||||
var pct = Math.round(done / total * 100);
|
||||
$('#sendProgressBar').css('width', pct + '%').text(pct + '%');
|
||||
$('#sendProgressText').text(done + ' di ' + total);
|
||||
}
|
||||
|
||||
function fine() {
|
||||
invioInCorso = false;
|
||||
$('#sendEmailBtn').prop('disabled', false);
|
||||
var html = 'Email inviate: <strong>' + sent + '</strong> su ' + total + '.';
|
||||
if (failed.length) {
|
||||
html += '<br><br>Non inviate (' + failed.length + '):<br>' +
|
||||
$('<div>').text(failed.join(', ')).html();
|
||||
}
|
||||
alertBox({
|
||||
title: failed.length ? 'Invio completato con errori' : 'Invio completato',
|
||||
html: html,
|
||||
icon: failed.length ? 'warning' : 'success'
|
||||
});
|
||||
}
|
||||
|
||||
function inviaGruppo(start) {
|
||||
if (start >= total) {
|
||||
fine();
|
||||
return;
|
||||
}
|
||||
var gruppo = recipients.slice(start, start + BATCH);
|
||||
$.post('send_email.php', {
|
||||
subject: subject,
|
||||
body: body,
|
||||
recipients: JSON.stringify(gruppo)
|
||||
}, null, 'json')
|
||||
.done(function(resp) {
|
||||
if (resp && resp.success) {
|
||||
sent += resp.sent || 0;
|
||||
failed = failed.concat(resp.failed || []);
|
||||
} else {
|
||||
failed = failed.concat(gruppo.map(function(r) {
|
||||
return r.email;
|
||||
}));
|
||||
}
|
||||
})
|
||||
.fail(function() {
|
||||
failed = failed.concat(gruppo.map(function(r) {
|
||||
return r.email;
|
||||
}));
|
||||
})
|
||||
.always(function() {
|
||||
done += gruppo.length;
|
||||
aggiorna();
|
||||
inviaGruppo(start + BATCH);
|
||||
});
|
||||
}
|
||||
|
||||
inviaGruppo(0);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
+1156
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 129 KiB |
File diff suppressed because it is too large
Load Diff
+44
-16
@@ -498,19 +498,23 @@ $italianMonths = [
|
||||
});
|
||||
}
|
||||
|
||||
function confirmDeleteOnly(idbookingclass, expirydate) {
|
||||
function confirmDeleteOnly(idbookingclass, expirydate, ticketLost) {
|
||||
var html = ticketLost ?
|
||||
`<strong>Confermi di voler cancellare questa lezione?</strong><br><br>
|
||||
Hai già usato <strong>tutte le riprogrammazioni</strong> del tuo pacchetto.<br>
|
||||
Cancellando liberi il posto in sala, ma
|
||||
<strong style="color:#d33;">la lezione non potrà essere recuperata.</strong> ` :
|
||||
`<strong>Confermi di voler cancellare questa lezione?</strong><br><br>
|
||||
<strong>RICORDA:</strong> dovrai riprogrammarla entro la scadenza del tuo abbonamento per non perderla!<br><br>
|
||||
La cancellazione conta come <strong>una delle riprogrammazioni</strong> disponibili per il tuo pacchetto.<br><br>
|
||||
<span style="font-size:20px;color:#d33;font-weight:bold;">Scadenza: ${expirydate}</span>`;
|
||||
|
||||
Swal.fire({
|
||||
title: "Attenzione",
|
||||
html: `
|
||||
<strong>Confermi di voler cancellare questa lezione?</strong><br><br>
|
||||
<strong>RICORDA:</strong> dovrai riprogrammarla entro la scadenza del tuo abbonamento per non perderla!<br><br>
|
||||
<span style="font-size:20px;color:#d33;font-weight:bold;">
|
||||
Scadenza: ${expirydate}
|
||||
</span>
|
||||
`,
|
||||
html: html,
|
||||
icon: "warning",
|
||||
showCancelButton: true,
|
||||
confirmButtonText: "Procedi",
|
||||
confirmButtonText: ticketLost ? "Cancella comunque" : "Procedi",
|
||||
cancelButtonText: "Annulla",
|
||||
confirmButtonColor: "#d33",
|
||||
cancelButtonColor: "#3085d6"
|
||||
@@ -651,7 +655,29 @@ $italianMonths = [
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
<?php } ?>
|
||||
<?php if (isset($_GET['deleted'])) { ?>
|
||||
<?php if (($_GET['deleted'] ?? '') === '0') {
|
||||
$cancelErrors = [
|
||||
'window' => 'Non è più possibile cancellare questa lezione: il tempo per la cancellazione è scaduto.',
|
||||
'limit' => 'Hai raggiunto il numero massimo di riprogrammazioni per questo pacchetto.',
|
||||
'status' => 'Questa prenotazione non può essere cancellata da qui.',
|
||||
'notfound' => 'Prenotazione non trovata.',
|
||||
];
|
||||
$cancelMsg = $cancelErrors[$_GET['err'] ?? ''] ?? 'Si è verificato un errore: la lezione non è stata cancellata. Riprova o contattaci.';
|
||||
?>
|
||||
<div class="alert alert-danger alert-dismissible fade show" role="alert">
|
||||
<i class="fas fa-ban me-2"></i>
|
||||
<?php echo e($cancelMsg); ?>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
<?php } ?>
|
||||
<?php if (($_GET['deleted'] ?? '') === 'lost') { ?>
|
||||
<div class="alert alert-warning alert-dismissible fade show" role="alert">
|
||||
<i class="fas fa-info-circle me-2"></i>
|
||||
Lezione cancellata.
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
<?php } ?>
|
||||
<?php if (($_GET['deleted'] ?? '') === '1') { ?>
|
||||
<div class="alert alert-success alert-dismissible fade show" role="alert">
|
||||
<i class="mdi mdi-check-all me-2"></i>
|
||||
Lezione cancellata con successo! Grazie per aver liberato il posto!
|
||||
@@ -864,16 +890,18 @@ $italianMonths = [
|
||||
<button class="custom-action-button" onclick="addToCalendar(this)" data-eventname="<?php echo e($servicename); ?>" data-eventdate="<?php echo e($newDateFormat); ?>">
|
||||
<i class="far fa-calendar-plus"></i> Cal
|
||||
</button>
|
||||
<?php if ($canBeDeleted && $canReprogram) : ?>
|
||||
<button class="custom-action-button" onclick="confirmDelete(<?php echo $idbookingclass; ?>, <?php echo $idservice; ?>, 'bookingpanel.php')">
|
||||
<i class="fas fa-calendar-alt"></i> Riprogramma
|
||||
</button>
|
||||
<?php if ($canBeDeleted) : ?>
|
||||
<?php if ($canReprogram) : ?>
|
||||
<button class="custom-action-button" onclick="confirmDelete(<?php echo $idbookingclass; ?>, <?php echo $idservice; ?>, 'bookingpanel.php')">
|
||||
<i class="fas fa-calendar-alt"></i> Riprogramma
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<button class="custom-action-button"
|
||||
onclick="confirmDeleteOnly(<?php echo $idbookingclass; ?>, '<?php echo e($expirydate); ?>')">
|
||||
onclick="confirmDeleteOnly(<?php echo $idbookingclass; ?>, '<?php echo e($expirydate); ?>', <?php echo $canReprogram ? 'false' : 'true'; ?>)">
|
||||
<i class="fas fa-trash"></i> Cancella
|
||||
</button>
|
||||
<?php else : ?>
|
||||
<button class="custom-action-button"><i class="fas fa-exclamation-circle"></i> Non puoi riprogrammare</button>
|
||||
<button class="custom-action-button"><i class="fas fa-exclamation-circle"></i> Non più cancellabile</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+101
-15
@@ -17,8 +17,8 @@ $uploadFeedback = null; // ['type' => 'success'|'error', 'text' => '...']
|
||||
/* -------------------------------------------------------------------------
|
||||
* Upload certificato (POST) con validazione robusta
|
||||
* ---------------------------------------------------------------------- */
|
||||
$allowedExt = ['pdf', 'jpg', 'jpeg', 'png'];
|
||||
$allowedMime = ['application/pdf', 'image/jpeg', 'image/png'];
|
||||
$allowedExt = ['pdf', 'jpg', 'jpeg', 'png', 'webp', 'avif'];
|
||||
$allowedMime = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp', 'image/avif'];
|
||||
$maxBytes = 16 * 1024 * 1024; // 16 MB
|
||||
$uploadDir = 'user/document/';
|
||||
|
||||
@@ -30,10 +30,17 @@ if (
|
||||
|
||||
$file = $_FILES['fileToUpload'];
|
||||
$documentDescription = trim($_POST['documentDescription'] ?? '');
|
||||
$expiryDate = trim($_POST['expiryDate'] ?? '');
|
||||
$expiryInput = trim($_POST['expiryDate'] ?? '');
|
||||
|
||||
if ($documentDescription === '' || $expiryDate === '') {
|
||||
// Conversione GG/MM/AAAA -> AAAA-MM-GG per il database
|
||||
$expiryObj = DateTime::createFromFormat('!d/m/Y', $expiryInput);
|
||||
$expiryValid = $expiryObj && $expiryObj->format('d/m/Y') === $expiryInput;
|
||||
$expiryDate = $expiryValid ? $expiryObj->format('Y-m-d') : '';
|
||||
|
||||
if ($documentDescription === '' || $expiryInput === '') {
|
||||
$uploadFeedback = ['type' => 'error', 'text' => 'Descrizione e data di scadenza sono obbligatorie.'];
|
||||
} elseif (!$expiryValid) {
|
||||
$uploadFeedback = ['type' => 'error', 'text' => 'Data di scadenza non valida. Usa il formato GG/MM/AAAA.'];
|
||||
} elseif ($file['size'] > $maxBytes) {
|
||||
$uploadFeedback = ['type' => 'error', 'text' => 'Il file supera la dimensione massima di 16 MB.'];
|
||||
} else {
|
||||
@@ -42,7 +49,7 @@ if (
|
||||
$realMime = $finfo->file($file['tmp_name']);
|
||||
|
||||
if (!in_array($ext, $allowedExt, true) || !in_array($realMime, $allowedMime, true)) {
|
||||
$uploadFeedback = ['type' => 'error', 'text' => 'Formato non consentito. Carica un PDF, JPG o PNG.'];
|
||||
$uploadFeedback = ['type' => 'error', 'text' => 'Formato non consentito. Carica un PDF, JPG, PNG, WebP o AVIF.'];
|
||||
} else {
|
||||
$safeName = bin2hex(random_bytes(16)) . '.' . $ext;
|
||||
$destination = $uploadDir . $safeName;
|
||||
@@ -110,12 +117,30 @@ $documents = $stmtDocs->fetchAll();
|
||||
|
||||
<script>
|
||||
$(function() {
|
||||
$.datepicker.regional['it'] = {
|
||||
closeText: 'Chiudi',
|
||||
prevText: '<Prec',
|
||||
nextText: 'Succ>',
|
||||
currentText: 'Oggi',
|
||||
monthNames: ['Gennaio', 'Febbraio', 'Marzo', 'Aprile', 'Maggio', 'Giugno', 'Luglio', 'Agosto', 'Settembre', 'Ottobre', 'Novembre', 'Dicembre'],
|
||||
monthNamesShort: ['Gen', 'Feb', 'Mar', 'Apr', 'Mag', 'Giu', 'Lug', 'Ago', 'Set', 'Ott', 'Nov', 'Dic'],
|
||||
dayNames: ['Domenica', 'Lunedì', 'Martedì', 'Mercoledì', 'Giovedì', 'Venerdì', 'Sabato'],
|
||||
dayNamesShort: ['Dom', 'Lun', 'Mar', 'Mer', 'Gio', 'Ven', 'Sab'],
|
||||
dayNamesMin: ['Do', 'Lu', 'Ma', 'Me', 'Gi', 'Ve', 'Sa'],
|
||||
weekHeader: 'Sm',
|
||||
firstDay: 1,
|
||||
isRTL: false
|
||||
};
|
||||
$.datepicker.setDefaults($.datepicker.regional['it']);
|
||||
|
||||
$("#expiryDate").datepicker({
|
||||
dateFormat: "yy-mm-dd",
|
||||
dateFormat: "dd/mm/yy",
|
||||
changeYear: true,
|
||||
changeMonth: true,
|
||||
minDate: 0
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
function confirmDeleteCertificate(id) {
|
||||
@@ -137,15 +162,21 @@ $documents = $stmtDocs->fetchAll();
|
||||
|
||||
function handleFileChange(input) {
|
||||
var label = document.getElementById('fileChosenLabel');
|
||||
var btn = document.querySelector('.btn-save');
|
||||
label.style.color = '';
|
||||
|
||||
if (!input.files.length) {
|
||||
label.textContent = 'Nessun file selezionato';
|
||||
return;
|
||||
}
|
||||
var f = input.files[0];
|
||||
var allowed = ['application/pdf', 'image/jpeg', 'image/png'];
|
||||
var allowed = ['application/pdf', 'image/jpeg', 'image/png', 'image/webp', 'image/avif'];
|
||||
var allowedExt = ['pdf', 'jpg', 'jpeg', 'png', 'webp', 'avif'];
|
||||
var ext = f.name.split('.').pop().toLowerCase();
|
||||
var maxBytes = 16 * 1024 * 1024;
|
||||
if (allowed.indexOf(f.type) === -1) {
|
||||
label.textContent = 'Formato non valido: usa PDF, JPG o PNG';
|
||||
|
||||
if (allowed.indexOf(f.type) === -1 && allowedExt.indexOf(ext) === -1) {
|
||||
label.textContent = 'Formato non valido: usa PDF, JPG, PNG, WebP o AVIF';
|
||||
label.style.color = '#dc3545';
|
||||
input.value = '';
|
||||
return;
|
||||
@@ -156,8 +187,63 @@ $documents = $stmtDocs->fetchAll();
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
label.textContent = f.name;
|
||||
label.style.color = '#198754';
|
||||
|
||||
// PDF o immagini già leggere: nessuna compressione
|
||||
var mustConvert = (ext === 'webp' || ext === 'avif');
|
||||
if (f.type === 'application/pdf' || ext === 'pdf' || (!mustConvert && f.size < 800 * 1024)) {
|
||||
label.textContent = f.name;
|
||||
label.style.color = '#198754';
|
||||
return;
|
||||
}
|
||||
|
||||
label.textContent = 'Ottimizzazione immagine in corso...';
|
||||
btn.disabled = true;
|
||||
|
||||
compressImage(f, 1800, 0.8).then(function(blob) {
|
||||
if (!mustConvert && blob.size >= f.size) throw new Error('nessun guadagno');
|
||||
var newName = f.name.replace(/\.[^.]+$/, '') + '.jpg';
|
||||
var newFile = new File([blob], newName, {
|
||||
type: 'image/jpeg'
|
||||
});
|
||||
var dt = new DataTransfer();
|
||||
dt.items.add(newFile);
|
||||
input.files = dt.files;
|
||||
label.textContent = newName + ' (' + Math.round(blob.size / 1024) + ' KB, ottimizzata)';
|
||||
}).catch(function() {
|
||||
// Browser vecchio o errore: si carica l'originale
|
||||
label.textContent = f.name;
|
||||
}).finally(function() {
|
||||
label.style.color = '#198754';
|
||||
btn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function compressImage(file, maxSide, quality) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
var url = URL.createObjectURL(file);
|
||||
var img = new Image();
|
||||
img.onload = function() {
|
||||
var scale = Math.min(1, maxSide / Math.max(img.naturalWidth, img.naturalHeight));
|
||||
var w = Math.round(img.naturalWidth * scale);
|
||||
var h = Math.round(img.naturalHeight * scale);
|
||||
var canvas = document.createElement('canvas');
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
var ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = '#fff'; // sfondo bianco per PNG trasparenti
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
ctx.drawImage(img, 0, 0, w, h);
|
||||
URL.revokeObjectURL(url);
|
||||
canvas.toBlob(function(b) {
|
||||
b ? resolve(b) : reject();
|
||||
}, 'image/jpeg', quality);
|
||||
};
|
||||
img.onerror = function() {
|
||||
URL.revokeObjectURL(url);
|
||||
reject();
|
||||
};
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -374,7 +460,7 @@ $documents = $stmtDocs->fetchAll();
|
||||
<?php foreach ($documents as $document) : ?>
|
||||
<tr>
|
||||
<td><?php echo e($document['documentdescription']); ?></td>
|
||||
<td><?php echo e($document['expirydatedocument']); ?></td>
|
||||
<td><?php echo e(date('d/m/Y', strtotime($document['expirydatedocument']))); ?></td>
|
||||
<td>
|
||||
<a class="doc-link" href="user/document/<?php echo e(rawurlencode($document['filenamedocument'])); ?>" target="_blank" rel="noopener">
|
||||
<i class="fas fa-file-arrow-down"></i> Apri
|
||||
@@ -402,7 +488,7 @@ $documents = $stmtDocs->fetchAll();
|
||||
<div class="card cert-card">
|
||||
<div class="card-body">
|
||||
<div class="section-title">Carica un nuovo documento</div>
|
||||
<p class="section-lead">Formati accettati: PDF, JPG o PNG (max 16 MB).</p>
|
||||
<p class="section-lead">Formati accettati: PDF, JPG, PNG, WebP o AVIF (max 16 MB).</p>
|
||||
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<div class="row">
|
||||
@@ -416,7 +502,7 @@ $documents = $stmtDocs->fetchAll();
|
||||
<div class="col-md-6">
|
||||
<div class="form-field">
|
||||
<label for="expiryDate">Data di scadenza</label>
|
||||
<input type="text" id="expiryDate" class="form-control" name="expiryDate" placeholder="AAAA-MM-GG" required>
|
||||
<input type="text" id="expiryDate" class="form-control" name="expiryDate" placeholder="GG/MM/AAAA" inputmode="none" autocomplete="off" required>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -429,7 +515,7 @@ $documents = $stmtDocs->fetchAll();
|
||||
<span class="file-chosen" id="fileChosenLabel">Nessun file selezionato</span>
|
||||
</label>
|
||||
<input type="file" id="fileToUpload" name="fileToUpload" class="d-none"
|
||||
accept=".pdf,.jpg,.jpeg,.png" onchange="handleFileChange(this)" required>
|
||||
accept=".pdf,.jpg,.jpeg,.png,.webp,.avif" onchange="handleFileChange(this)" required>
|
||||
</div>
|
||||
|
||||
<p class="privacy-note">
|
||||
|
||||
Reference in New Issue
Block a user