From ae981536bd361a7e47a41c9314355dfc1c8215bd Mon Sep 17 00:00:00 2001 From: solocla Date: Fri, 18 Sep 2026 09:52:51 +0200 Subject: [PATCH] added mail to events --- composer.json | 2 +- composer.lock | 21 +- ...20260918063908_add_event_email_options.php | 25 ++ ...071737_add_reminder_and_offer_settings.php | 24 ++ public/apiwp/book.php | 11 +- public/apiwp/cancel.php | 299 ++++++++++++++- public/apiwp/confirm.php | 247 +++++++++++++ public/apiwp/events.php | 2 +- public/event_delete.php | 6 +- public/event_form.php | 27 ++ public/event_options.php | 249 +++++++++++++ public/event_options_save.php | 57 +++ public/event_participants.php | 38 +- public/event_preview.php | 13 +- public/event_reminder.php | 222 ++++++++++++ public/event_reminder_send.php | 58 +++ public/event_save.php | 11 + public/events.php | 5 +- public/include/event_mailer.php | 339 +++++++++++++++--- public/include/event_waitlist.php | 60 ++++ public/include/sidebar.php | 6 + 21 files changed, 1646 insertions(+), 76 deletions(-) create mode 100644 db/migrations/20260918063908_add_event_email_options.php create mode 100644 db/migrations/20260918071737_add_reminder_and_offer_settings.php create mode 100644 public/apiwp/confirm.php create mode 100644 public/event_options.php create mode 100644 public/event_options_save.php create mode 100644 public/event_reminder.php create mode 100644 public/event_reminder_send.php create mode 100644 public/include/event_waitlist.php diff --git a/composer.json b/composer.json index 8640dfd..2c28ed1 100644 --- a/composer.json +++ b/composer.json @@ -40,7 +40,7 @@ "laravel/tinker": "^2.7", "laravel/ui": "^4.0", "laravelcollective/html": "^6.3", - "phpmailer/phpmailer": "^6.8", + "phpmailer/phpmailer": "^7.1", "proengsoft/laravel-jsvalidation": "^4.0.0", "robmorgan/phinx": "^0.16.12", "spatie/laravel-query-builder": "^5.0", diff --git a/composer.lock b/composer.lock index faffb17..86e7c10 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "9b689efb6d29cb572794705682d6edc4", + "content-hash": "9307a6b8f5f1c5b0a5efb10e35038f7f", "packages": [ { "name": "akaunting/laravel-setting", @@ -3428,16 +3428,16 @@ }, { "name": "phpmailer/phpmailer", - "version": "v6.9.3", + "version": "v7.1.1", "source": { "type": "git", "url": "https://github.com/PHPMailer/PHPMailer.git", - "reference": "2f5c94fe7493efc213f643c23b1b1c249d40f47e" + "reference": "1bc1716a507a65e039d4ac9d9adebbbd0d346e15" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/2f5c94fe7493efc213f643c23b1b1c249d40f47e", - "reference": "2f5c94fe7493efc213f643c23b1b1c249d40f47e", + "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/1bc1716a507a65e039d4ac9d9adebbbd0d346e15", + "reference": "1bc1716a507a65e039d4ac9d9adebbbd0d346e15", "shasum": "" }, "require": { @@ -3451,13 +3451,14 @@ "doctrine/annotations": "^1.2.6 || ^1.13.3", "php-parallel-lint/php-console-highlighter": "^1.0.0", "php-parallel-lint/php-parallel-lint": "^1.3.2", - "phpcompatibility/php-compatibility": "^9.3.5", - "roave/security-advisories": "dev-latest", - "squizlabs/php_codesniffer": "^3.7.2", + "phpcompatibility/php-compatibility": "^10.0.0@dev", + "squizlabs/php_codesniffer": "^3.13.5", "yoast/phpunit-polyfills": "^1.0.4" }, "suggest": { "decomplexity/SendOauth2": "Adapter for using XOAUTH2 authentication", + "directorytree/imapengine": "For uploading sent messages via IMAP, see gmail example", + "ext-imap": "Needed to support advanced email address parsing according to RFC822", "ext-mbstring": "Needed to send email in multibyte encoding charset or decode encoded addresses", "ext-openssl": "Needed for secure SMTP sending and DKIM signing", "greew/oauth2-azure-provider": "Needed for Microsoft Azure XOAUTH2 authentication", @@ -3497,7 +3498,7 @@ "description": "PHPMailer is a full-featured email creation and transfer class for PHP", "support": { "issues": "https://github.com/PHPMailer/PHPMailer/issues", - "source": "https://github.com/PHPMailer/PHPMailer/tree/v6.9.3" + "source": "https://github.com/PHPMailer/PHPMailer/tree/v7.1.1" }, "funding": [ { @@ -3505,7 +3506,7 @@ "type": "github" } ], - "time": "2024-11-24T18:04:13+00:00" + "time": "2026-05-18T08:06:14+00:00" }, { "name": "phpoption/phpoption", diff --git a/db/migrations/20260918063908_add_event_email_options.php b/db/migrations/20260918063908_add_event_email_options.php new file mode 100644 index 0000000..c8f69e7 --- /dev/null +++ b/db/migrations/20260918063908_add_event_email_options.php @@ -0,0 +1,25 @@ +table('option') + ->addColumn('event_cc_booking', 'char', ['limit' => 1, 'null' => false, 'default' => 'N', 'after' => 'event_terms_text']) + ->addColumn('event_cc_promotion', 'char', ['limit' => 1, 'null' => false, 'default' => 'N', 'after' => 'event_cc_booking']) + ->addColumn('event_cc_user_cancel', 'char', ['limit' => 1, 'null' => false, 'default' => 'N', 'after' => 'event_cc_promotion']) + ->addColumn('event_cc_event_cancel', 'char', ['limit' => 1, 'null' => false, 'default' => 'Y', 'after' => 'event_cc_user_cancel']) + ->addColumn('event_cc_reminder', 'char', ['limit' => 1, 'null' => false, 'default' => 'N', 'after' => 'event_cc_event_cancel']) + ->addColumn('event_reminder_enabled', 'char', ['limit' => 1, 'null' => false, 'default' => 'N', 'after' => 'event_cc_reminder']) + ->addColumn('event_reminder_hours', 'integer', ['null' => false, 'default' => 24, 'after' => 'event_reminder_enabled']) + ->update(); + + $this->table('event_bookings') + ->addColumn('offered_at', 'datetime', ['null' => true, 'comment' => 'promozione lista attesa in attesa di riconferma', 'after' => 'consent_at']) + ->update(); + } +} diff --git a/db/migrations/20260918071737_add_reminder_and_offer_settings.php b/db/migrations/20260918071737_add_reminder_and_offer_settings.php new file mode 100644 index 0000000..b83569b --- /dev/null +++ b/db/migrations/20260918071737_add_reminder_and_offer_settings.php @@ -0,0 +1,24 @@ +table('option') + ->addColumn('event_offer_expiry_hours', 'integer', ['null' => false, 'default' => 24, 'after' => 'event_reminder_hours']) + ->update(); + + // Override per singolo evento (NULL = usa il default scuola) + $this->table('events') + ->addColumn('reminder_enabled', 'char', ['limit' => 1, 'null' => true, 'default' => null, 'comment' => 'NULL=default scuola; Y/N override', 'after' => 'waitlist_enabled']) + ->addColumn('reminder_hours', 'integer', ['null' => true, 'default' => null, 'comment' => 'NULL=default scuola', 'after' => 'reminder_enabled']) + ->addColumn('offer_expiry_hours', 'integer', ['null' => true, 'default' => null, 'comment' => 'NULL=default scuola', 'after' => 'reminder_hours']) + ->addColumn('reminder_sent_at', 'datetime', ['null' => true, 'after' => 'offer_expiry_hours']) + ->update(); + } +} diff --git a/public/apiwp/book.php b/public/apiwp/book.php index c64f416..cf15e44 100644 --- a/public/apiwp/book.php +++ b/public/apiwp/book.php @@ -1,5 +1,6 @@ prepare("SELECT COALESCE(SUM(seats),0) FROM event_bookings - WHERE event_id = ? AND status IN ('confirmed','pending')"); + WHERE event_id = ? AND status IN ('confirmed','pending','offered')"); $os->execute([$eventId]); $occupied = (int) $os->fetchColumn(); $remaining = max(0, $maxSeats - $occupied); @@ -159,7 +160,13 @@ try { $pdo->commit(); - // TODO Step 4: email di conferma / lista d'attesa all'iscritto (usando $token). + // Email di conferma / lista d'attesa (best-effort: non blocca la prenotazione) + try { + $bookingRow = ['email' => $email, 'name' => $fullName, 'seats' => $seats, 'status' => $status, 'token' => $token]; + yse_mail_booking($bookingRow, $event); + } catch (Throwable $mailEx) { + error_log('[book mail] ' . $mailEx->getMessage()); + } $msg = $status === 'confirmed' ? 'Prenotazione confermata! Ti abbiamo inviato una email di riepilogo.' diff --git a/public/apiwp/cancel.php b/public/apiwp/cancel.php index 21c084a..85ffe57 100644 --- a/public/apiwp/cancel.php +++ b/public/apiwp/cancel.php @@ -1,6 +1,7 @@ commit(); $state = 'done'; + + // Email di conferma disdetta all'utente (best-effort) + if ($didCancel) { + try { + $evStmt = pdo_conn()->prepare("SELECT * FROM events WHERE id = ?"); + $evStmt->execute([(int) $booking['eid']]); + $evRow = $evStmt->fetch(PDO::FETCH_ASSOC); + if ($evRow) { + yse_mail_user_cancel($booking, $evRow); + } + } catch (Throwable $mailEx) { + error_log('[cancel mail] ' . $mailEx->getMessage()); + } + } } catch (Throwable $ex) { if ($pdo->inTransaction()) { $pdo->rollBack(); @@ -254,4 +271,284 @@ function e($v): string + mostra pagina di conferma (NON annulla: + * evita che gli scanner email prefetchino il link e annullino da soli) + * POST (token + confirm=1) -> annulla e, se la prenotazione occupava posti, + * promuove automaticamente la prima in lista d'attesa. + */ + $pdo = pdo_conn(); + $token = trim($_POST['token'] ?? $_GET['token'] ?? ''); + $isPost = ($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST'; + + $booking = null; + if ($token !== '') { + $q = $pdo->prepare("SELECT b.*, e.title AS ev_title, e.starts_at AS ev_starts, e.id AS eid + FROM event_bookings b JOIN events e ON e.id = b.event_id + WHERE b.token = ?"); + $q->execute([$token]); + $booking = $q->fetch(PDO::FETCH_ASSOC); + } + + $state = 'form'; // form | notfound | already | toolate | done + $promotedCount = 0; + $didCancel = false; + $promoted = []; + + if (!$booking) { + $state = 'notfound'; + } elseif ($booking['status'] === 'cancelled') { + $state = 'already'; + } elseif (strtotime($booking['ev_starts']) <= time()) { + $state = 'toolate'; + } elseif ($isPost && !empty($_POST['confirm'])) { + try { + $pdo->beginTransaction(); + // Lock evento per serializzare con eventuali prenotazioni concorrenti + $pdo->prepare("SELECT id FROM events WHERE id = ? FOR UPDATE")->execute([(int) $booking['eid']]); + + // Rileggo lo stato aggiornato della prenotazione + $bl = $pdo->prepare("SELECT * FROM event_bookings WHERE id = ?"); + $bl->execute([(int) $booking['id']]); + $fresh = $bl->fetch(PDO::FETCH_ASSOC); + + if ($fresh && $fresh['status'] !== 'cancelled') { + $wasOccupying = in_array($fresh['status'], ['confirmed', 'pending', 'offered'], true); + $pdo->prepare("UPDATE event_bookings SET status = 'cancelled', updated_at = NOW() WHERE id = ?") + ->execute([(int) $fresh['id']]); + + if ($wasOccupying) { + $promoted = promoteFromWaitlist($pdo, (int) $booking['eid']); + $promotedCount = count($promoted); + } + $didCancel = true; + } + $pdo->commit(); + $state = 'done'; + + // Email dopo il commit (best-effort) + if ($didCancel) { + try { + $evStmt = pdo_conn()->prepare("SELECT * FROM events WHERE id = ?"); + $evStmt->execute([(int) $booking['eid']]); + $evRow = $evStmt->fetch(PDO::FETCH_ASSOC); + if ($evRow) { + yse_mail_user_cancel($booking, $evRow); // B: conferma disdetta + foreach ($promoted as $pr) { // E: promozione con riconferma + try { + yse_mail_promotion($pr, $evRow); + } catch (Throwable $pe) { + error_log('[promo mail] ' . $pe->getMessage()); + } + } + } + } catch (Throwable $mailEx) { + error_log('[cancel mail] ' . $mailEx->getMessage()); + } + } + } catch (Throwable $ex) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + error_log('[apiwp/cancel] ' . $ex->getMessage()); + $state = 'error'; + } + } + + function e($v): string + { + return htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); + } + ?> + + + + + + Disdetta prenotazione — YogaSoul + + + + + + + + +
+ +
✕
+

Link non valido

+

Questa prenotazione non esiste o il link è errato.

+ + +
✓
+

Già annullata

+

Questa prenotazione risulta già annullata.

+ + +
⏱
+

Non più annullabile

+

L'evento è già iniziato: non è più possibile annullare online. Contattaci per assistenza.

+ + +
!
+

Si è verificato un errore

+

Riprova tra poco. Se il problema persiste, contattaci.

+ + +
✓
+

Prenotazione annullata

+

La tua prenotazione per è stata annullata.

+ 0) : ?> +

Un posto è stato assegnato alla prima persona in lista d'attesa.

+ + + +
?
+

Annullare la prenotazione?

+
+
+
· post
+
+

Confermi di voler annullare la prenotazione a nome di ?

+
+ + + +
+

Se hai cambiato idea, chiudi pure questa pagina.

+ +
+ + \ No newline at end of file diff --git a/public/apiwp/confirm.php b/public/apiwp/confirm.php new file mode 100644 index 0000000..bf0d467 --- /dev/null +++ b/public/apiwp/confirm.php @@ -0,0 +1,247 @@ + 'confirmed'). + * GET confirm.php?token=... -> pagina di conferma (non modifica nulla) + * POST (token + confirm=1) -> conferma il posto e invia l'email di conferma. + */ +$pdo = pdo_conn(); +$token = trim($_POST['token'] ?? $_GET['token'] ?? ''); +$isPost = ($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST'; + +$booking = null; +if ($token !== '') { + $q = $pdo->prepare("SELECT b.*, e.title AS ev_title, e.starts_at AS ev_starts, e.id AS eid + FROM event_bookings b JOIN events e ON e.id = b.event_id + WHERE b.token = ?"); + $q->execute([$token]); + $booking = $q->fetch(PDO::FETCH_ASSOC); +} + +$state = 'form'; // form | notfound | already | invalid | toolate | done | error + +if (!$booking) { + $state = 'notfound'; +} elseif ($booking['status'] === 'confirmed') { + $state = 'already'; +} elseif ($booking['status'] !== 'offered') { + // waiting / cancelled / ecc.: non c'è un'offerta da confermare + $state = 'invalid'; +} elseif (strtotime($booking['ev_starts']) <= time()) { + $state = 'toolate'; +} elseif ($isPost && !empty($_POST['confirm'])) { + try { + $upd = $pdo->prepare("UPDATE event_bookings SET status = 'confirmed', updated_at = NOW() WHERE id = ? AND status = 'offered'"); + $upd->execute([(int) $booking['id']]); + + if ($upd->rowCount() > 0) { + $state = 'done'; + // Email di conferma definitiva (best-effort) + try { + $ev = $pdo->prepare("SELECT * FROM events WHERE id = ?"); + $ev->execute([(int) $booking['eid']]); + $evRow = $ev->fetch(PDO::FETCH_ASSOC); + if ($evRow) { + $booking['status'] = 'confirmed'; + yse_mail_booking($booking, $evRow); + } + } catch (Throwable $me) { + error_log('[confirm mail] ' . $me->getMessage()); + } + } else { + $state = 'invalid'; + } + } catch (Throwable $ex) { + error_log('[apiwp/confirm] ' . $ex->getMessage()); + $state = 'error'; + } +} + +function e($v): string +{ + return htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); +} +?> + + + + + + Conferma posto — YogaSoul + + + + + + + + +
+ +
✕
+

Link non valido

+

Questo link non è valido o è scaduto.

+ + +
✓
+

Posto già confermato

+

Il tuo posto per è già confermato. Ci vediamo lì!

+ + +
i
+

Offerta non più disponibile

+

Questa offerta non è più valida. Se pensi ci sia un errore, contattaci.

+ + +
⏱
+

Evento già iniziato

+

Non è più possibile confermare online. Contattaci per assistenza.

+ + +
!
+

Si è verificato un errore

+

Riprova tra poco. Se il problema persiste, contattaci.

+ + +
✓
+

Posto confermato!

+

Grazie , il tuo posto per è confermato. Ti abbiamo inviato l'email con tutti i dettagli.

+ + +
🎉
+

Si è liberato un posto!

+
+
+
· post
+
+

Confermi la tua presenza, ?

+
+ + + +
+

Se non puoi partecipare, ignora pure questa email: il posto passerà a un'altra persona.

+ +
+ + + \ No newline at end of file diff --git a/public/apiwp/events.php b/public/apiwp/events.php index 413e657..cc9c5e2 100644 --- a/public/apiwp/events.php +++ b/public/apiwp/events.php @@ -49,7 +49,7 @@ try { LEFT JOIN auth_users u ON u.id = e.user_id LEFT JOIN ( SELECT event_id, SUM(seats) AS occupied - FROM event_bookings WHERE status IN ('confirmed','pending') + FROM event_bookings WHERE status IN ('confirmed','pending','offered') GROUP BY event_id ) b ON b.event_id = e.id LEFT JOIN ( diff --git a/public/event_delete.php b/public/event_delete.php index 54b9084..54a0509 100644 --- a/public/event_delete.php +++ b/public/event_delete.php @@ -41,7 +41,11 @@ try { // 3) Notifica (opzionale). Il template vero arriva nello step successivo. if ($notify && !empty($participants)) { foreach ($participants as $p) { - sendEventCancellationMail($p['email'], $p['name'], $event); + try { + yse_mail_event_cancel($p, $event); + } catch (Throwable $mailEx) { + error_log('[event_delete mail] ' . $mailEx->getMessage()); + } } } diff --git a/public/event_form.php b/public/event_form.php index f4975ff..e455dcf 100644 --- a/public/event_form.php +++ b/public/event_form.php @@ -37,6 +37,9 @@ if ($id > 0) { $opt = $pdo->query("SELECT * FROM `option` LIMIT 1")->fetch(PDO::FETCH_ASSOC); $defaultColor = $opt['event_default_color'] ?? '#4CAF50'; $defaultMaxPerBooking = (int) ($opt['event_default_max_per_booking'] ?? 1); +$defReminderEnabled = ($opt['event_reminder_enabled'] ?? 'N') === 'Y'; +$defReminderHours = (int) ($opt['event_reminder_hours'] ?? 24); +$defOfferHours = (int) ($opt['event_offer_expiry_hours'] ?? 24); // Sedi salvate (se la tabella non esiste ancora, si degrada su indirizzo libero) $locations = []; @@ -68,6 +71,9 @@ $val = [ 'suitable_for' => $ev['suitable_for'] ?? '', 'gallery' => $ev['gallery'] ?? '', 'waitlist_enabled' => ($ev['waitlist_enabled'] ?? 'Y') === 'Y', + 'reminder_enabled' => $ev['reminder_enabled'] ?? null, + 'reminder_hours' => $ev['reminder_hours'] ?? '', + 'offer_expiry_hours' => $ev['offer_expiry_hours'] ?? '', ]; $isEdit = $id > 0; @@ -549,6 +555,27 @@ $pageTitle = $isEdit ? 'Modifica evento' : 'Nuovo evento'; + +
+
+ + +
+
+ + +
+
+ + +
+
+

Lascia vuoto o "Predefinito" per usare le impostazioni generali (Opzioni Eventi).

+
Indietro +
+

Opzioni Eventi

+
+
+
+ + +
+ + + +
+ +
+
+ +
+
+
+ +
+
+
+ + + + + + +
+
+
+
+
+
Copie alla scuola
+

Scegli su quali email l'insegnante/scuola riceve una copia (CC).

+ +
+ + +
Se vuota, non viene inviata nessuna copia anche se le voci sotto sono attive.
+
+ + +
+
+
+
+
+
+ > +
+
+ +
+
+ +
+
+
Promemoria
+

Invio automatico del promemoria agli iscritti prima dell'evento (richiede il cron, vedi setup).

+ +
+
+
Attiva promemoria
+
Invia un'email di promemoria agli iscritti confermati.
+
+
+ > +
+
+
+
+
Ore prima dell'evento
+
Quante ore prima dell'inizio inviare il promemoria.
+
+
+ +
+
+
+
+
Scadenza offerta lista d'attesa (ore)
+
Default: quanto tempo una persona promossa ha per riconfermare prima che il posto passi al prossimo. Modificabile per singolo evento.
+
+
+ +
+
+ +
+ +
+
+
+
+
+
+
+ +
+
+ + + + + + + + + + \ No newline at end of file diff --git a/public/event_options_save.php b/public/event_options_save.php new file mode 100644 index 0000000..e7b33f7 --- /dev/null +++ b/public/event_options_save.php @@ -0,0 +1,57 @@ +getConnection(); + +function yn(string $k): string +{ + return (isset($_POST[$k]) && $_POST[$k] === 'Y') ? 'Y' : 'N'; +} + +$ccEmail = trim($_POST['event_notify_email'] ?? ''); +$reminderH = max(1, min(168, (int) ($_POST['event_reminder_hours'] ?? 24))); +$offerH = max(1, min(336, (int) ($_POST['event_offer_expiry_hours'] ?? 24))); + +try { + // Individua la riga singola di configurazione + $idption = (int) $pdo->query("SELECT idption FROM `option` ORDER BY idption ASC LIMIT 1")->fetchColumn(); + + $sql = "UPDATE `option` SET + event_notify_email = :cc_email, + event_cc_booking = :cc_booking, + event_cc_promotion = :cc_promotion, + event_cc_user_cancel = :cc_user_cancel, + event_cc_event_cancel = :cc_event_cancel, + event_cc_reminder = :cc_reminder, + event_reminder_enabled = :rem_enabled, + event_reminder_hours = :rem_hours, + event_offer_expiry_hours = :offer_hours + WHERE idption = :idption"; + $pdo->prepare($sql)->execute([ + ':cc_email' => $ccEmail !== '' ? $ccEmail : null, + ':cc_booking' => yn('event_cc_booking'), + ':cc_promotion' => yn('event_cc_promotion'), + ':cc_user_cancel' => yn('event_cc_user_cancel'), + ':cc_event_cancel' => yn('event_cc_event_cancel'), + ':cc_reminder' => yn('event_cc_reminder'), + ':rem_enabled' => yn('event_reminder_enabled'), + ':rem_hours' => $reminderH, + ':offer_hours' => $offerH, + ':idption' => $idption, + ]); + + header('Location: event_options.php?message=success'); + exit(); +} catch (Throwable $ex) { + error_log('[event_options_save] ' . $ex->getMessage()); + header('Location: event_options.php?message=error'); + exit(); +} diff --git a/public/event_participants.php b/public/event_participants.php index 4004be0..e581972 100644 --- a/public/event_participants.php +++ b/public/event_participants.php @@ -1,6 +1,7 @@ beginTransaction(); $pdo->prepare("SELECT id FROM events WHERE id = ? FOR UPDATE")->execute([$eventId]); @@ -44,15 +46,31 @@ if (isset($_GET['cancel_booking'])) { $bStatus = $bl->fetchColumn(); if ($bStatus !== false && $bStatus !== 'cancelled') { - $wasOccupying = in_array($bStatus, ['confirmed', 'pending'], true); + $wasOccupying = in_array($bStatus, ['confirmed', 'pending', 'offered'], true); $pdo->prepare("UPDATE event_bookings SET status = 'cancelled', updated_at = NOW() WHERE id = ? AND event_id = ?") ->execute([$bookingId, $eventId]); if ($wasOccupying) { - // libera posti -> promuove la prima in lista d'attesa - promoteFromWaitlist($pdo, $eventId); + // libera posti -> offre alla prima in lista d'attesa + $promoted = promoteFromWaitlist($pdo, $eventId); } } $pdo->commit(); + + // Email di promozione (E, con riconferma) ai nuovi "offered" + if (!empty($promoted)) { + $ev = $pdo->prepare("SELECT * FROM events WHERE id = ?"); + $ev->execute([$eventId]); + $evRow = $ev->fetch(PDO::FETCH_ASSOC); + if ($evRow) { + foreach ($promoted as $pr) { + try { + yse_mail_promotion($pr, $evRow); + } catch (Throwable $me) { + error_log('[promo mail] ' . $me->getMessage()); + } + } + } + } } catch (Throwable $ex) { if ($pdo->inTransaction()) { $pdo->rollBack(); @@ -75,13 +93,13 @@ $bStmt = $pdo->prepare("SELECT * FROM event_bookings WHERE event_id = ? ORDER BY $bStmt->execute([$eventId]); $bookings = $bStmt->fetchAll(PDO::FETCH_ASSOC); -// Riepilogo: i posti occupati sono solo confermate/pending; la lista d'attesa a parte +// Riepilogo: posti occupati = confermate/pending/offerte (in riconferma); lista d'attesa a parte $bookedSeats = 0; $waitingSeats = 0; $activeCount = 0; $revenue = 0.0; foreach ($bookings as $b) { - if (in_array($b['status'], ['confirmed', 'pending'], true)) { + if (in_array($b['status'], ['confirmed', 'pending', 'offered'], true)) { $bookedSeats += (int) $b['seats']; $activeCount++; $revenue += (float) $b['amount']; @@ -96,13 +114,15 @@ function bookingBadge(string $status): array { switch ($status) { case 'confirmed': - return ['Confermata', '#e7f7ee', '#1a7f52']; + return ['Confermata', '#e7f7ee', '#1a7f52']; case 'cancelled': - return ['Annullata', '#fdecea', '#c0392b']; + return ['Annullata', '#fdecea', '#c0392b']; case 'waiting': - return ["Lista d'attesa", '#eef2fb', '#3a5bb8']; + return ["Lista d'attesa", '#eef2fb', '#3a5bb8']; + case 'offered': + return ['Da riconfermare', '#fff4e5', '#b26a00']; default: - return ['In attesa', '#fff4e5', '#b26a00']; + return ['In attesa', '#fff4e5', '#b26a00']; } } ?> diff --git a/public/event_preview.php b/public/event_preview.php index adddb45..f320dcd 100644 --- a/public/event_preview.php +++ b/public/event_preview.php @@ -35,7 +35,7 @@ if (!$ev) { exit(); } -$bs = $pdo->prepare("SELECT COALESCE(SUM(seats),0) FROM event_bookings WHERE event_id = ? AND status IN ('confirmed','pending')"); +$bs = $pdo->prepare("SELECT COALESCE(SUM(seats),0) FROM event_bookings WHERE event_id = ? AND status IN ('confirmed','pending','offered')"); $bs->execute([$id]); $booked = (int) $bs->fetchColumn(); $maxSeats = (int) $ev['max_seats']; @@ -186,6 +186,14 @@ function textBlock(?string $t): string line-height: 1; } + .chead-thumb { + width: 76px; + height: 76px; + border-radius: 12px; + object-fit: cover; + flex-shrink: 0; + } + .chead .main { flex: 1; min-width: 0; @@ -702,6 +710,9 @@ function textBlock(?string $t): string
+ + <?php echo e($ev['title']); ?> +

diff --git a/public/event_reminder.php b/public/event_reminder.php new file mode 100644 index 0000000..002885e --- /dev/null +++ b/public/event_reminder.php @@ -0,0 +1,222 @@ +getConnection(); + +function e($value): string +{ + return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); +} + +$eventId = isset($_GET['id']) ? (int) $_GET['id'] : 0; +if ($eventId <= 0) { + header('Location: events.php'); + exit(); +} + +$stmt = $pdo->prepare("SELECT * FROM events WHERE id = ?"); +$stmt->execute([$eventId]); +$event = $stmt->fetch(PDO::FETCH_ASSOC); +if (!$event) { + header('Location: events.php?message=error'); + exit(); +} + +$cnt = $pdo->prepare("SELECT COUNT(*) FROM event_bookings WHERE event_id = ? AND status = 'confirmed'"); +$cnt->execute([$eventId]); +$recipients = (int) $cnt->fetchColumn(); + +$defaultMsg = "Ti ricordiamo il tuo prossimo appuntamento. Ti aspettiamo, a presto!"; +$meetingLink = yse_maps_link($event); // prefill: link mappa derivato dall'indirizzo/coordinate +$message = $_GET['message'] ?? ''; +?> + + + + + + YogiBook - Promemoria + + + + + + + + + + + + +
+
+ +
+ +
+ +
+
+ +
+
+
+ +
+
+
+ + + + + + +
+
+
+
+
Invia promemoria
+

Controlla il testo e il punto di ritrovo, poi invia agli iscritti confermati.

+ +
+ ·
+ Destinatari confermati: +
+ +
+ + +
+ + +
Data, ora e luogo dell'evento vengono aggiunti automaticamente sotto al messaggio.
+
+ +
+ + +
Prefissato dal luogo dell'evento. Puoi incollare un link di Google Maps più preciso (es. la condivisione della posizione). Lascia vuoto per non includerlo.
+
+ +
+ Indietro + +
+
+
+
+
+
+
+ +
+
+
+ + + + + + + + + + \ No newline at end of file diff --git a/public/event_reminder_send.php b/public/event_reminder_send.php new file mode 100644 index 0000000..3987943 --- /dev/null +++ b/public/event_reminder_send.php @@ -0,0 +1,58 @@ +getConnection(); + +$eventId = (int) ($_POST['id'] ?? 0); +$message = trim($_POST['message'] ?? ''); +$meetingLink = trim($_POST['meeting_link'] ?? ''); + +if ($eventId <= 0) { + header('Location: events.php'); + exit(); +} + +// Accetta solo link http/https come punto di ritrovo +if ($meetingLink !== '' && !preg_match('#^https?://#i', $meetingLink)) { + $meetingLink = ''; +} + +try { + $es = $pdo->prepare("SELECT * FROM events WHERE id = ?"); + $es->execute([$eventId]); + $event = $es->fetch(PDO::FETCH_ASSOC); + if (!$event) { + header('Location: events.php?message=error'); + exit(); + } + + $ps = $pdo->prepare("SELECT * FROM event_bookings WHERE event_id = ? AND status = 'confirmed'"); + $ps->execute([$eventId]); + $participants = $ps->fetchAll(PDO::FETCH_ASSOC); + + foreach ($participants as $p) { + try { + yse_mail_reminder($p, $event, $message, $meetingLink); + } catch (Throwable $me) { + error_log('[reminder send] ' . $me->getMessage()); + } + } + + $pdo->prepare("UPDATE events SET reminder_sent_at = NOW() WHERE id = ?")->execute([$eventId]); + + header('Location: event_reminder.php?id=' . $eventId . '&message=sent'); + exit(); +} catch (Throwable $ex) { + error_log('[event_reminder_send] ' . $ex->getMessage()); + header('Location: event_reminder.php?id=' . $eventId . '&message=error'); + exit(); +} diff --git a/public/event_save.php b/public/event_save.php index 4389b2c..8279617 100644 --- a/public/event_save.php +++ b/public/event_save.php @@ -112,6 +112,11 @@ $suitableFor = trim($_POST['suitable_for'] ?? ''); $waitlistEnabled = (isset($_POST['waitlist_enabled']) && $_POST['waitlist_enabled'] === 'Y') ? 'Y' : 'N'; +// Override per-evento (NULL = usa default scuola) +$reminderEnabled = (isset($_POST['reminder_enabled']) && in_array($_POST['reminder_enabled'], ['Y', 'N'], true)) ? $_POST['reminder_enabled'] : null; +$reminderHours = (($_POST['reminder_hours'] ?? '') !== '') ? max(1, min(168, (int) $_POST['reminder_hours'])) : null; +$offerHours = (($_POST['offer_expiry_hours'] ?? '') !== '') ? max(1, min(336, (int) $_POST['offer_expiry_hours'])) : null; + // Galleria: array JSON di path -> validazione e re-encode pulito $gallery = null; $galleryRaw = $_POST['gallery'] ?? ''; @@ -138,6 +143,7 @@ try { address = :address, lat = :lat, lng = :lng, cost = :cost, currency = :currency, max_seats = :max_seats, max_per_booking = :max_per_booking, color = :color, waitlist_enabled = :waitlist_enabled, + reminder_enabled = :reminder_enabled, reminder_hours = :reminder_hours, offer_expiry_hours = :offer_expiry_hours, cover_image = :cover_image, gallery = :gallery, status = :status, updated_at = NOW() WHERE id = :id"; $params = [':id' => $id]; @@ -147,12 +153,14 @@ try { organizer_name, starts_at, ends_at, address, lat, lng, cost, currency, max_seats, max_per_booking, color, waitlist_enabled, + reminder_enabled, reminder_hours, offer_expiry_hours, cover_image, gallery, status, created_at, updated_at) VALUES (:user_id, :title, :slug, :description, :instructions, :what_to_bring, :suitable_for, :organizer_name, :starts_at, :ends_at, :address, :lat, :lng, :cost, :currency, :max_seats, :max_per_booking, :color, :waitlist_enabled, + :reminder_enabled, :reminder_hours, :offer_expiry_hours, :cover_image, :gallery, :status, NOW(), NOW())"; $params = [':user_id' => $currentUserId]; } @@ -175,6 +183,9 @@ try { ':max_seats' => $maxSeats, ':max_per_booking' => $maxPerBooking, ':waitlist_enabled' => $waitlistEnabled, + ':reminder_enabled' => $reminderEnabled, + ':reminder_hours' => $reminderHours, + ':offer_expiry_hours' => $offerHours, ':color' => $color, ':cover_image' => $coverImage !== '' ? $coverImage : null, ':gallery' => $gallery, diff --git a/public/events.php b/public/events.php index 6943f5b..c17a791 100644 --- a/public/events.php +++ b/public/events.php @@ -30,7 +30,7 @@ $sql = "SELECT e.*, SUM(seats) AS booked, COUNT(*) AS bookings_count FROM event_bookings - WHERE status <> 'cancelled' + WHERE status IN ('confirmed','pending','offered') GROUP BY event_id ) b ON b.event_id = e.id LEFT JOIN auth_users u ON u.id = e.user_id @@ -391,6 +391,9 @@ function statusBadge(string $status): array Anteprima Partecipanti Modifica + time()) : ?> + Promemoria +
'], "\n", $html)) + )); + + return $mail->send(); + } catch (Throwable $e) { + error_log('[event mail] ' . $e->getMessage()); + return false; + } +} + +/* ---------- Helper contenuto ---------- */ +function yse_h($v): string +{ + return htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); +} + +function yse_fmt_datetime(array $event): string +{ + $mesi = [ + 1 => 'gennaio', + 2 => 'febbraio', + 3 => 'marzo', + 4 => 'aprile', + 5 => 'maggio', + 6 => 'giugno', + 7 => 'luglio', + 8 => 'agosto', + 9 => 'settembre', + 10 => 'ottobre', + 11 => 'novembre', + 12 => 'dicembre' + ]; + $t = strtotime($event['starts_at']); + $s = (int) date('j', $t) . ' ' . $mesi[(int) date('n', $t)] . ' ' . date('Y', $t) . ' · ' . date('H:i', $t); + if (!empty($event['ends_at'])) { + $s .= '–' . date('H:i', strtotime($event['ends_at'])); + } + return $s; +} + +function yse_maps_link(array $event): string +{ + $q = trim((string) ($event['address'] ?? '')); + if ($q === '' && !empty($event['lat']) && !empty($event['lng'])) { + $q = $event['lat'] . ',' . $event['lng']; + } + return $q !== '' ? 'https://www.google.com/maps/search/?api=1&query=' . rawurlencode($q) : ''; +} + +function yse_event_info_block(array $event): string +{ + $accent = (isset($event['color']) && preg_match('/^#[0-9a-fA-F]{6}$/', $event['color'])) ? $event['color'] : '#4CAF50'; + $when = yse_h(yse_fmt_datetime($event)); + $addr = trim((string) ($event['address'] ?? '')); + $maps = yse_maps_link($event); + + $rows = 'Quando' . $when . ''; + if ($addr !== '') { + $mapsLink = $maps !== '' ? '  apri mappa' : ''; + $rows .= 'Dove' . yse_h($addr) . $mapsLink . ''; + } + + $info = ''; + foreach ([['suitable_for', 'Per chi è adatto'], ['what_to_bring', 'Cosa portare'], ['instructions', 'Istruzioni']] as $f) { + $val = trim((string) ($event[$f[0]] ?? '')); + if ($val !== '') { + $info .= '

' . $f[1] . ':
' . nl2br(yse_h($val)) . '

'; + } + } + + $html = '' . $rows . '
'; + if ($info !== '') { + $html .= '
' . $info . '
'; + } + return $html; +} + +function yse_email_layout(array $event, string $heading, string $bodyHtml): string +{ + $accent = (isset($event['color']) && preg_match('/^#[0-9a-fA-F]{6}$/', $event['color'])) ? $event['color'] : '#4CAF50'; + $from = $_ENV['MAIL_FROM_NAME'] ?? 'YogaSoul'; + $title = yse_h($event['title'] ?? ''); + return ' +
+
+ + + + + +
+
' . yse_h($from) . '
+

' . $heading . '

+
' . $title . '
+
' . $bodyHtml . '
Questa email è stata inviata da ' . yse_h($from) . '.
+
+
'; +} + +function yse_button(string $url, string $label, string $accent): string +{ + return '' . yse_h($label) . ''; +} + +function yse_cancel_url(array $booking): string +{ + return YB_PUBLIC_BASE . 'apiwp/cancel.php?token=' . rawurlencode($booking['token']); +} + +function yse_confirm_url(array $booking): string +{ + return YB_PUBLIC_BASE . 'apiwp/confirm.php?token=' . rawurlencode($booking['token']); +} + +/* ---------- Builder per tipo ---------- */ + +/** A) Prenotazione (confermata o lista d'attesa) */ +function yse_mail_booking(array $booking, array $event): bool +{ + $accent = $event['color'] ?? '#4CAF50'; + $waiting = ($booking['status'] ?? '') === 'waiting'; + $parts = explode(' ', trim($booking['name'])); + $name = $parts[0] ?? ''; + + if ($waiting) { + $heading = "Sei in lista d'attesa"; + $intro = '

Ciao ' . yse_h($name) . ", ti abbiamo messo in lista d'attesa per questo evento. Ti avviseremo appena si libera un posto.

"; + } else { + $heading = 'Prenotazione confermata'; + $intro = '

Ciao ' . yse_h($name) . ', la tua prenotazione è confermata. Ecco i dettagli:

'; + } + + $body = $intro; + $body .= yse_event_info_block($event); + $body .= '

Posti prenotati: ' . (int) $booking['seats'] . '

'; + $body .= '

Se non puoi più partecipare, puoi annullare qui:

'; + $body .= '

' . yse_button(yse_cancel_url($booking), 'Annulla prenotazione', $accent) . '

'; + + $subject = ($waiting ? "Lista d'attesa: " : 'Prenotazione confermata: ') . $event['title']; + $html = yse_email_layout($event, $heading, $body); + return yse_send($booking['email'], $booking['name'], $subject, $html, yse_mail_options()['cc']['booking']); +} + +/** B) Disdetta fatta dall'utente */ +function yse_mail_user_cancel(array $booking, array $event): bool +{ + $parts = explode(' ', trim($booking['name'])); + $name = $parts[0] ?? ''; + $body = '

Ciao ' . yse_h($name) . ', la tua prenotazione per questo evento è stata annullata.

'; + $body .= yse_event_info_block($event); + $body .= '

Ci dispiace non vederti questa volta. A presto!

'; + + $subject = 'Prenotazione annullata: ' . $event['title']; + $html = yse_email_layout($event, 'Prenotazione annullata', $body); + return yse_send($booking['email'], $booking['name'], $subject, $html, yse_mail_options()['cc']['user_cancel']); +} + +/** C) Evento annullato dall'insegnante (a ciascun iscritto) */ +function yse_mail_event_cancel(array $booking, array $event): bool +{ + $parts = explode(' ', trim($booking['name'])); + $name = $parts[0] ?? ''; + $body = '

Ciao ' . yse_h($name) . ", ci dispiace: l'evento è stato annullato.

"; + $body .= yse_event_info_block($event); + $body .= '

La tua prenotazione è stata annullata automaticamente. Per informazioni, rispondi pure a questa email.

'; + + $subject = 'Evento annullato: ' . $event['title']; + $html = yse_email_layout($event, 'Evento annullato', $body); + return yse_send($booking['email'], $booking['name'], $subject, $html, yse_mail_options()['cc']['event_cancel']); +} + +/** E) Promozione da lista d'attesa — con RICONFERMA */ +function yse_mail_promotion(array $booking, array $event): bool +{ + $accent = $event['color'] ?? '#4CAF50'; + $parts = explode(' ', trim($booking['name'])); + $name = $parts[0] ?? ''; + $body = '

Ciao ' . yse_h($name) . ", buone notizie: si è liberato un posto e tocca a te!

"; + $body .= yse_event_info_block($event); + $body .= '

Conferma entro breve per tenere il posto:

'; + $body .= '

' . yse_button(yse_confirm_url($booking), 'Confermo, ci sarò', $accent) . '

'; + $body .= '

Se non puoi partecipare, annulla qui così offriamo il posto a un\'altra persona.

'; + + $subject = 'Si è liberato un posto: ' . $event['title']; + $html = yse_email_layout($event, 'Un posto per te', $body); + return yse_send($booking['email'], $booking['name'], $subject, $html, yse_mail_options()['cc']['promotion']); +} + +/** D) Promemoria prima dell'evento (testo e punto di ritrovo opzionali) */ +function yse_mail_reminder(array $booking, array $event, string $message = '', string $meetingLink = ''): bool +{ + $accent = $event['color'] ?? '#4CAF50'; + $parts = explode(' ', trim($booking['name'])); + $name = $parts[0] ?? ''; + $note = $message !== '' ? nl2br(yse_h($message)) : 'Ti ricordiamo il tuo prossimo appuntamento.'; + + $body = '

Ciao ' . yse_h($name) . ',
' . $note . '

'; + $body .= yse_event_info_block($event); + if ($meetingLink !== '') { + $body .= '

Punto di ritrovo: apri la posizione sulla mappa

'; + } + $body .= '

Se non puoi più partecipare, avvisaci annullando:

'; + $body .= '

' . yse_button(yse_cancel_url($booking), 'Annulla prenotazione', $accent) . '

'; + + $subject = 'Promemoria: ' . $event['title']; + $html = yse_email_layout($event, 'Ci vediamo presto', $body); + return yse_send($booking['email'], $booking['name'], $subject, $html, yse_mail_options()['cc']['reminder']); +} diff --git a/public/include/event_waitlist.php b/public/include/event_waitlist.php new file mode 100644 index 0000000..2258334 --- /dev/null +++ b/public/include/event_waitlist.php @@ -0,0 +1,60 @@ + le prenotazioni promosse (per inviare le email nello Step 4) + */ +if (!function_exists('promoteFromWaitlist')) { + function promoteFromWaitlist(PDO $pdo, int $eventId): array + { + $ev = $pdo->prepare("SELECT max_seats FROM events WHERE id = ?"); + $ev->execute([$eventId]); + $maxSeats = (int) $ev->fetchColumn(); + if ($maxSeats <= 0) { + return []; + } + + $os = $pdo->prepare("SELECT COALESCE(SUM(seats),0) FROM event_bookings + WHERE event_id = ? AND status IN ('confirmed','pending','offered')"); + $os->execute([$eventId]); + $free = $maxSeats - (int) $os->fetchColumn(); + if ($free <= 0) { + return []; + } + + $ws = $pdo->prepare("SELECT * FROM event_bookings + WHERE event_id = ? AND status = 'waiting' + ORDER BY created_at ASC, id ASC"); + $ws->execute([$eventId]); + $waiting = $ws->fetchAll(PDO::FETCH_ASSOC); + + $promoted = []; + // Promozione = "offerta": tiene il posto ma richiede riconferma (confirm.php) + $upd = $pdo->prepare("UPDATE event_bookings SET status = 'offered', offered_at = NOW(), updated_at = NOW() WHERE id = ?"); + foreach ($waiting as $w) { + $need = (int) $w['seats']; + if ($need <= $free) { + $upd->execute([$w['id']]); + $free -= $need; + $w['status'] = 'offered'; + $promoted[] = $w; + if ($free <= 0) { + break; + } + } else { + break; // rispetta la coda + } + } + return $promoted; + } +} diff --git a/public/include/sidebar.php b/public/include/sidebar.php index 176b103..d144a9f 100644 --- a/public/include/sidebar.php +++ b/public/include/sidebar.php @@ -114,6 +114,12 @@ Sedi +
  • + + + Opzione Eventi + +