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/cron/class_report.php b/cron/class_report.php new file mode 100644 index 0000000..00f7384 --- /dev/null +++ b/cron/class_report.php @@ -0,0 +1,447 @@ + versione riservata: nomi "Mario R.", senza email dei partecipanti + * - al titolare, se diverso dall'insegnante (se notifiche attive e con email) + * -> copia separata con i dati completi + * - se l'insegnante È il titolare: una sola mail completa + * + * Uso: + * php cron/class_report.php -> classi di oggi + * php cron/class_report.php 2026-09-25 -> classi di una data specifica + * php cron/class_report.php --dry-run -> NON invia, salva le anteprime HTML in cron/preview/ + * php cron/class_report.php --only-at=17 -> esce senza fare nulla se in Italia non sono le 17 + */ + +use PHPMailer\PHPMailer\PHPMailer; +use PHPMailer\PHPMailer\Exception as MailException; + +if (PHP_SAPI !== 'cli') { + http_response_code(403); + exit('Accesso consentito solo da CLI'); +} + +date_default_timezone_set('Europe/Rome'); + +// --only-at=17 : esce senza fare nulla se in Italia non sono le 17 (server in UTC) +foreach ($argv as $a) { + if (preg_match('/^--only-at=(\d{1,2})$/', $a, $m) && (int) date('G') !== (int) $m[1]) { + exit(0); + } +} + +$root = dirname(__DIR__); +chdir($root); +require_once $root . '/public/class/db-functions.php'; // include anche vendor/autoload.php + +$pdo = DBHandlerSelect::getInstance()->getConnection(); // carica anche il .env + +/* ---------------------------------------------------------------- helper */ + +function cfg(string $key, $default = null) +{ + $v = $_ENV[$key] ?? $_SERVER[$key] ?? getenv($key); + return ($v === false || $v === null || $v === '') ? $default : $v; +} + +function h($v): string +{ + return htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); +} + +function logmsg(string $m): void +{ + echo '[' . date('Y-m-d H:i:s') . '] ' . $m . PHP_EOL; +} + +function chip(string $label, string $bg, string $fg): string +{ + return '' . h($label) . ''; +} + +/** Nome completo (versione titolare) */ +function fullName(array $p): string +{ + $n = trim(($p['first_name'] ?? '') . ' ' . ($p['last_name'] ?? '')); + return $n !== '' ? $n : 'Utente #' . (int) ($p['iduser'] ?? 0); +} + +/** Nome + iniziale del cognome (versione insegnante): "Mario R." */ +function shortName(array $p): string +{ + $first = trim((string) ($p['first_name'] ?? '')); + $last = trim((string) ($p['last_name'] ?? '')); + if ($first === '' && $last === '') { + return 'Utente #' . (int) ($p['iduser'] ?? 0); + } + $ini = $last !== '' ? ' ' . mb_strtoupper(mb_substr($last, 0, 1)) . '.' : ''; + return ($first !== '' ? $first : '') . $ini; +} + +function personName(array $p, bool $full): string +{ + return $full ? fullName($p) : shortName($p); +} + +function teacherName(?array $t): string +{ + return $t ? trim($t['nameemployee'] . ' ' . $t['surnameemployee']) : ''; +} + +function italianDate(DateTime $d): string +{ + $days = ['Domenica', 'Lunedì', 'Martedì', 'Mercoledì', 'Giovedì', 'Venerdì', 'Sabato']; + $months = ['', 'Gennaio', 'Febbraio', 'Marzo', 'Aprile', 'Maggio', 'Giugno', 'Luglio', 'Agosto', 'Settembre', 'Ottobre', 'Novembre', 'Dicembre']; + return $days[(int) $d->format('w')] . ' ' . (int) $d->format('j') . ' ' . $months[(int) $d->format('n')] . ' ' . $d->format('Y'); +} + +/** Motivo per cui non può ricevere ('' = può ricevere) */ +function whyNot(?array $t): string +{ + if (!$t) return 'nessun insegnante assegnato'; + if (($t['active'] ?? 'N') !== 'Y') return 'insegnante non attivo'; + if (($t['notifications'] ?? 'N') !== 'Y') return 'notifiche disattivate'; + if (empty($t['email'])) return 'email mancante'; + if (!filter_var($t['email'], FILTER_VALIDATE_EMAIL)) return 'email non valida'; + return ''; +} + +/** Può ricevere mail? notifiche attive, attivo, email valida */ +function canReceive(?array $t): bool +{ + return $t + && ($t['notifications'] ?? 'N') === 'Y' + && ($t['active'] ?? 'N') === 'Y' + && !empty($t['email']) + && filter_var($t['email'], FILTER_VALIDATE_EMAIL); +} + +/* ----------------------------------------------------------- argomenti */ + +$dryRun = in_array('--dry-run', $argv, true); +$day = date('Y-m-d'); +foreach (array_slice($argv, 1) as $a) { + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $a)) { + $day = $a; + } +} + +$location = cfg('CLASS_LOCATION', 'via Valassina 62/B Seregno - Sala Contesto Yoga'); + +// Le mail agli insegnanti (non titolari) partono solo se l'opzione "Mostra insegnante" è attiva +$optRow = $pdo->query("SELECT show_teacher FROM `option` LIMIT 1")->fetch(); +$teacherMailsEnabled = ($optRow['show_teacher'] ?? 'N') === 'Y'; + +/* ---------------------------------------------------------- insegnanti */ + +$employees = []; +$owner = null; +foreach ($pdo->query("SELECT idemployee, nameemployee, surnameemployee, email, is_owner, notifications, active FROM employee")->fetchAll() as $t) { + $employees[(int) $t['idemployee']] = $t; + if ($t['is_owner'] === 'Y' && $owner === null) { + $owner = $t; + } +} + +/* ------------------------------------------------------ classi del giorno */ + +$stmt = $pdo->prepare( + "SELECT ss.idserviceschedule, ss.dateschedule, s.servicename, s.colorclass, s.maxcapacity, s.idteacher + FROM serviceschedule ss + LEFT JOIN service s ON ss.idservice = s.idservice + WHERE ss.dateschedule >= :d1 AND ss.dateschedule < :d2 + ORDER BY ss.dateschedule" +); +$stmt->execute([ + ':d1' => $day . ' 00:00:00', + ':d2' => date('Y-m-d', strtotime($day . ' +1 day')) . ' 00:00:00', +]); +$classes = $stmt->fetchAll(); + +if (!$classes) { + logmsg("Nessuna classe il $day. Nessuna mail inviata."); + exit(0); +} + +$ids = array_map(fn($c) => (int) $c['idserviceschedule'], $classes); +$ph = implode(',', array_fill(0, count($ids), '?')); +$stmtB = $pdo->prepare( + "SELECT bc.idbookingclass, bc.idserviceschedule, bc.lostlesson, bc.status, + u.id AS iduser, u.first_name, u.last_name, u.email + FROM bookingclass bc + LEFT JOIN auth_users u ON bc.iduser = u.id + WHERE bc.idserviceschedule IN ($ph) + AND bc.status IN ('booked', 'pending', 'waiting') + ORDER BY bc.created_at, u.last_name, u.first_name" +); +$stmtB->execute($ids); + +$bookingsBySchedule = []; +foreach ($stmtB->fetchAll() as $b) { + $bookingsBySchedule[(int) $b['idserviceschedule']][] = $b; +} + +/* ------------------------------------------------------------ template */ + +function personRows(array $list, string $type, bool $full): string +{ + if (!$list) { + return 'Nessun partecipante'; + } + $numColor = ['booked' => '#1ebf73', 'pending' => '#8a6d00', 'waiting' => '#d1698a'][$type]; + $html = ''; + $i = 1; + foreach ($list as $p) { + $isLost = ($type === 'booked' && ($p['lostlesson'] ?? '') === 'Y'); + if ($type === 'booked') { + $chipHtml = $isLost ? chip('Persa', '#1f2937', '#ffffff') : chip('Confermata', '#e3f2ec', '#1a7f52'); + } elseif ($type === 'pending') { + $chipHtml = chip('In attesa', '#fff4d6', '#8a6d00'); + } else { + $chipHtml = chip('In coda', '#fbeef3', '#d1698a'); + } + $nameColor = $isLost ? '#9ca3af' : '#1f2937'; + $email = ($full && !empty($p['email'])) + ? '
' . h($p['email']) . '' + : ''; + + $html .= '' + . '' + . '' + . '' + . '' + . '
' . $i . ($type === 'waiting' ? '°' : '.') . '' . h(personName($p, $full)) . $email . '' . $chipHtml . '
'; + $i++; + } + return $html; +} + +function section(string $title, string $color, string $rows): string +{ + return '' + . '
' . $title . '
' + . '' . $rows . '
' + . ''; +} + +function statBox(string $value, string $label, string $color): string +{ + return '' + . '
' . $value . '
' + . '
' . $label . '
' + . ''; +} + +/** + * @param bool $full true = versione titolare (nomi completi + email) + * @param string $teacherFull nome insegnante da mostrare in testata + * @param string|null $copyNote nota "copia per il titolare" (solo se il titolare non è l'insegnante) + */ +function renderEmail(array $cls, array $booked, array $pending, array $waiting, string $location, bool $full, string $teacherFull, ?string $copyNote): string +{ + $d = new DateTime($cls['dateschedule']); + $accent = $cls['colorclass'] ?: '#1ebf73'; + $max = (int) $cls['maxcapacity']; + $nBooked = count($booked); + $nLost = count(array_filter($booked, fn($b) => ($b['lostlesson'] ?? '') === 'Y')); + $free = $max > 0 ? max(0, $max - $nBooked) : '–'; + $isFull = $max > 0 && $nBooked >= $max; + + $stats = '' + . statBox($nBooked . '/' . $max, 'Iscritti', $isFull ? '#c0392b' : '#1a7f52') + . statBox((string) $free, 'Posti liberi', '#374151') + . statBox((string) count($pending), 'In attesa', '#8a6d00') + . statBox((string) count($waiting), 'In coda', '#d1698a') + . '
'; + + $lostNote = $nLost > 0 + ? '
di cui ' . $nLost . ' segnat' . ($nLost === 1 ? 'a' : 'e') . ' come pers' . ($nLost === 1 ? 'a' : 'e') . '
' + : ''; + + $body = section('Partecipanti confermati (' . $nBooked . ')', '#374151', personRows($booked, 'booked', $full)); + if ($pending) { + $body .= section('In attesa di conferma (' . count($pending) . ')', '#8a6d00', personRows($pending, 'pending', $full)); + } + if ($waiting) { + $body .= section('Lista d\'attesa (' . count($waiting) . ')', '#d1698a', personRows($waiting, 'waiting', $full)); + } + + $copyBanner = $copyNote + ? '
' . $copyNote . '
' + : ''; + + $teacherLine = $teacherFull !== '' + ? '
Insegnante: ' . h($teacherFull) . '
' + : ''; + + return '' . h($cls['servicename']) . '' + . '' + . '
' + . '' + . '' + . $copyBanner + . '' + . '' + . $body + . '' + . '
 
' + . '
YogiBook · Riepilogo classe
' + . '

' . h($cls['servicename']) . '

' + . '
' . h(italianDate($d)) . ' · ore ' . $d->format('H:i') . '
' + . '
' . h($location) . '
' + . $teacherLine + . '
' . $stats . '
' . $lostNote . '
Generato automaticamente da YogiBook il ' . date('d/m/Y \a\l\l\e H:i') . '
'; +} + +function renderText(array $cls, array $booked, array $pending, array $waiting, string $location, bool $full, string $teacherFull, ?string $copyNote): string +{ + $d = new DateTime($cls['dateschedule']); + $max = (int) $cls['maxcapacity']; + $t = ''; + if ($copyNote) { + $t .= strip_tags($copyNote) . "\n\n"; + } + $t .= $cls['servicename'] . "\n" . italianDate($d) . ' ore ' . $d->format('H:i') . "\n" . $location . "\n"; + if ($teacherFull !== '') { + $t .= 'Insegnante: ' . $teacherFull . "\n"; + } + $t .= "\nIscritti: " . count($booked) . '/' . $max . ' | In attesa: ' . count($pending) . ' | In coda: ' . count($waiting) . "\n\n"; + $blocks = ['CONFERMATI' => $booked, 'IN ATTESA DI CONFERMA' => $pending, "LISTA D'ATTESA" => $waiting]; + foreach ($blocks as $title => $list) { + if ($title !== 'CONFERMATI' && !$list) { + continue; + } + $t .= $title . "\n"; + if (!$list) { + $t .= " Nessun partecipante\n"; + } + $i = 1; + foreach ($list as $p) { + $lost = ($p['status'] === 'booked' && ($p['lostlesson'] ?? '') === 'Y') ? ' (PERSA)' : ''; + $t .= ' ' . $i++ . '. ' . personName($p, $full) . $lost . "\n"; + } + $t .= "\n"; + } + return $t; +} + +/* -------------------------------------------------------------- mailer */ + +function makeMailer(): PHPMailer +{ + $m = new PHPMailer(true); + $m->isSMTP(); + $m->Host = cfg('MAIL_HOST'); + $m->Port = (int) cfg('MAIL_PORT', 465); + $m->SMTPAuth = true; + $m->Username = cfg('MAIL_USERNAME'); + $m->Password = cfg('MAIL_PASSWORD'); + $enc = strtolower((string) cfg('MAIL_ENCRYPTION', 'ssl')); + $m->SMTPSecure = $enc === 'ssl' ? PHPMailer::ENCRYPTION_SMTPS : ($enc === 'tls' ? PHPMailer::ENCRYPTION_STARTTLS : ''); + $m->SMTPKeepAlive = true; + $m->CharSet = 'UTF-8'; + $m->Encoding = 'base64'; + $m->setFrom(cfg('MAIL_FROM_ADDRESS'), cfg('MAIL_FROM_NAME', 'YogiBook')); + $m->isHTML(true); + return $m; +} + +/* --------------------------------------------------------------- invio */ + +$mailer = $dryRun ? null : makeMailer(); +$sent = 0; +$failed = 0; +$skipped = 0; + +foreach ($classes as $cls) { + $sid = (int) $cls['idserviceschedule']; + $all = $bookingsBySchedule[$sid] ?? []; + $booked = array_values(array_filter($all, fn($b) => $b['status'] === 'booked')); + $pending = array_values(array_filter($all, fn($b) => $b['status'] === 'pending')); + $waiting = array_values(array_filter($all, fn($b) => $b['status'] === 'waiting')); + + $teacher = $employees[(int) $cls['idteacher']] ?? null; + $teacherIsOwner = $teacher && $owner && (int) $teacher['idemployee'] === (int) $owner['idemployee']; + $tName = teacherName($teacher); + + $d = new DateTime($cls['dateschedule']); + $baseSubject = $cls['servicename'] . ' · ' . $d->format('d/m') . ' ore ' . $d->format('H:i') + . ' · ' . count($booked) . '/' . (int) $cls['maxcapacity'] . ' iscritti'; + if ($pending) { + $baseSubject .= ' · ' . count($pending) . ' in attesa'; + } + if ($waiting) { + $baseSubject .= ' · ' . count($waiting) . ' in coda'; + } + + // Chi riceve cosa + $deliveries = []; + if ($teacherIsOwner) { + if (canReceive($owner)) { + $deliveries[] = ['to' => $owner, 'full' => true, 'copy' => null, 'tag' => 'titolare']; + } + } else { + if (!$teacherMailsEnabled) { + logmsg("Classe #$sid ({$cls['servicename']}): mail all'insegnante non inviata (opzione insegnanti disattivata)"); + } elseif (canReceive($teacher)) { + $deliveries[] = ['to' => $teacher, 'full' => false, 'copy' => null, 'tag' => 'insegnante']; + } else { + logmsg("Classe #$sid ({$cls['servicename']}): insegnante " . ($tName ?: '-') . " escluso: " . whyNot($teacher)); + } + if (canReceive($owner)) { + $note = $tName !== '' + ? 'Copia per il titolare · classe di ' . h($tName) + : 'Copia per il titolare · classe senza insegnante assegnato'; + $deliveries[] = ['to' => $owner, 'full' => true, 'copy' => $note, 'tag' => 'titolare']; + } + } + + if (!$deliveries) { + $skipped++; + logmsg("SALTATA classe #$sid ({$cls['servicename']}): nessun destinatario (notifiche disattivate o email mancante)."); + continue; + } + + foreach ($deliveries as $dv) { + $subject = ($dv['copy'] ? '[Copia] ' : '') . $baseSubject; + $html = renderEmail($cls, $booked, $pending, $waiting, $location, $dv['full'], $tName, $dv['copy']); + $text = renderText($cls, $booked, $pending, $waiting, $location, $dv['full'], $tName, $dv['copy']); + $toName = teacherName($dv['to']); + + if ($dryRun) { + $dir = __DIR__ . '/preview'; + if (!is_dir($dir)) { + mkdir($dir, 0775, true); + } + $file = $dir . '/classe_' . $sid . '_' . $dv['tag'] . '.html'; + file_put_contents($file, $html); + logmsg("[DRY-RUN] a {$toName} <{$dv['to']['email']}> ({$dv['tag']}): $subject -> $file"); + continue; + } + + try { + $mailer->clearAddresses(); + $mailer->addAddress($dv['to']['email'], $toName); + $mailer->Subject = $subject; + $mailer->Body = $html; + $mailer->AltBody = $text; + $mailer->send(); + $sent++; + logmsg("OK a {$toName} ({$dv['tag']}): $subject"); + } catch (MailException $e) { + $failed++; + logmsg("ERRORE classe #$sid a {$dv['to']['email']}: " . $mailer->ErrorInfo); + } + sleep(1); // evita limiti di invio del server SMTP + } +} + +if ($mailer) { + $mailer->smtpClose(); +} + +logmsg("Fine: $sent inviate, $failed errori, $skipped classi senza destinatari (" . count($classes) . " classi il $day)."); +exit($failed > 0 ? 1 : 0); diff --git a/cron/preview/classe_307.html b/cron/preview/classe_307.html new file mode 100644 index 0000000..94ae61f --- /dev/null +++ b/cron/preview/classe_307.html @@ -0,0 +1 @@ +Hatha Yoga
 
YogiBook · Riepilogo classe

Hatha Yoga

Lunedì 5 Ottobre 2026 · ore 19:30
via Valassina 62/B Seregno - Sala Contesto Yoga
1/1
Iscritti
0
Posti liberi
0
In attesa
0
In coda
Partecipanti confermati (1)
1.Amelia Benucci
info@claudiosironi.com
Confermata
Generato automaticamente da YogiBook il 01/10/2026 alle 11:15
\ No newline at end of file diff --git a/cron/preview/classe_307_insegnante.html b/cron/preview/classe_307_insegnante.html new file mode 100644 index 0000000..8a936cc --- /dev/null +++ b/cron/preview/classe_307_insegnante.html @@ -0,0 +1 @@ +Hatha Yoga
 
YogiBook · Riepilogo classe

Hatha Yoga

Lunedì 5 Ottobre 2026 · ore 19:30
via Valassina 62/B Seregno - Sala Contesto Yoga
Insegnante: Francesca -
1/1
Iscritti
0
Posti liberi
0
In attesa
0
In coda
Partecipanti confermati (1)
1.Amelia B.Confermata
Generato automaticamente da YogiBook il 01/10/2026 alle 11:30
\ No newline at end of file diff --git a/cron/preview/classe_307_titolare.html b/cron/preview/classe_307_titolare.html new file mode 100644 index 0000000..d9f04e1 --- /dev/null +++ b/cron/preview/classe_307_titolare.html @@ -0,0 +1 @@ +Hatha Yoga
 
Copia per il titolare · classe di Francesca -
YogiBook · Riepilogo classe

Hatha Yoga

Lunedì 5 Ottobre 2026 · ore 19:30
via Valassina 62/B Seregno - Sala Contesto Yoga
Insegnante: Francesca -
1/1
Iscritti
0
Posti liberi
0
In attesa
0
In coda
Partecipanti confermati (1)
1.Amelia Benucci
info@claudiosironi.com
Confermata
Generato automaticamente da YogiBook il 01/10/2026 alle 11:30
\ No newline at end of file diff --git a/cron/preview/classe_314.html b/cron/preview/classe_314.html new file mode 100644 index 0000000..ed5b51d --- /dev/null +++ b/cron/preview/classe_314.html @@ -0,0 +1 @@ +Aerial Yoga - mix
 
YogiBook · Riepilogo classe

Aerial Yoga - mix

Giovedì 24 Settembre 2026 · ore 19:00
via Valassina 62/B Seregno - Sala Contesto Yoga
2/8
Iscritti
6
Posti liberi
0
In attesa
0
In coda
Partecipanti confermati (2)
1.Amelia Benucci
info@claudiosironi.com
Confermata
2.Aurora Masiero
info@yogasoul.it
Confermata
Generato automaticamente da YogiBook il 24/09/2026 alle 10:55
\ No newline at end of file diff --git a/db/migrations/20260916124349_add_waiting_list_support.php b/db/migrations/20260916124349_add_waiting_list_support.php new file mode 100644 index 0000000..8060c84 --- /dev/null +++ b/db/migrations/20260916124349_add_waiting_list_support.php @@ -0,0 +1,20 @@ +table('bookingclass'); + $table + ->addColumn('created_at', 'timestamp', [ + 'default' => 'CURRENT_TIMESTAMP', + 'null' => false, + 'after' => 'cancellation_token', + ]) + ->update(); + } +} diff --git a/db/migrations/20260916131403_add_waiting_list_enabled_to_option.php b/db/migrations/20260916131403_add_waiting_list_enabled_to_option.php new file mode 100644 index 0000000..d89337b --- /dev/null +++ b/db/migrations/20260916131403_add_waiting_list_enabled_to_option.php @@ -0,0 +1,20 @@ +table('option') + ->addColumn('waitinglist_enabled', 'char', [ + 'limit' => 1, + 'default' => 'N', + 'null' => false, + 'after' => 'maxbeforetimecancell', + ]) + ->update(); + } +} diff --git a/db/migrations/20260917062954_create_events_table.php b/db/migrations/20260917062954_create_events_table.php new file mode 100644 index 0000000..e44e93c --- /dev/null +++ b/db/migrations/20260917062954_create_events_table.php @@ -0,0 +1,51 @@ +table('events', [ + 'id' => false, + 'primary_key' => ['id'], + 'engine' => 'InnoDB', + 'encoding' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + ]); + + $table + ->addColumn('id', 'biginteger', ['identity' => true, 'signed' => false]) + ->addColumn('user_id', 'integer', ['signed' => false, 'null' => true, 'comment' => 'insegnante/creatore -> auth_users.id']) + ->addColumn('title', 'string', ['limit' => 191, 'null' => false]) + ->addColumn('slug', 'string', ['limit' => 191, 'null' => false, 'comment' => 'URL pubblico / widget']) + ->addColumn('description', 'text', ['limit' => MysqlAdapter::TEXT_LONG, 'null' => true, 'comment' => 'HTML editor']) + ->addColumn('organizer_name', 'string', ['limit' => 191, 'null' => true, 'comment' => 'nome libero; se null usa il creatore']) + ->addColumn('starts_at', 'datetime', ['null' => false]) + ->addColumn('ends_at', 'datetime', ['null' => true]) + ->addColumn('address', 'string', ['limit' => 255, 'null' => true, 'comment' => 'testo per Google Maps']) + ->addColumn('lat', 'decimal', ['precision' => 10, 'scale' => 7, 'null' => true]) + ->addColumn('lng', 'decimal', ['precision' => 10, 'scale' => 7, 'null' => true]) + ->addColumn('cost', 'decimal', ['precision' => 8, 'scale' => 2, 'null' => false, 'default' => 0]) + ->addColumn('currency', 'string', ['limit' => 3, 'null' => false, 'default' => 'EUR']) + ->addColumn('max_seats', 'integer', ['signed' => false, 'null' => false, 'default' => 0]) + ->addColumn('max_per_booking', 'integer', ['signed' => false, 'null' => false, 'default' => 1]) + ->addColumn('color', 'string', ['limit' => 7, 'null' => false, 'default' => '#4CAF50', 'comment' => 'hex card']) + ->addColumn('cover_image', 'string', ['limit' => 191, 'null' => true, 'comment' => 'gallery in futuro']) + ->addColumn('status', 'string', ['limit' => 20, 'null' => false, 'default' => 'draft', 'comment' => 'draft|published|cancelled']) + ->addColumn('created_at', 'timestamp', ['null' => true, 'default' => null]) + ->addColumn('updated_at', 'timestamp', ['null' => true, 'default' => null]) + ->addIndex(['slug'], ['unique' => true, 'name' => 'events_slug_unique']) + ->addIndex(['status', 'starts_at'], ['name' => 'events_status_starts_at_index']) + ->addIndex(['user_id'], ['name' => 'events_user_id_index']) + ->addForeignKey('user_id', 'auth_users', 'id', [ + 'delete' => 'SET_NULL', + 'update' => 'NO_ACTION', + 'constraint' => 'events_user_id_foreign', + ]) + ->create(); + } +} diff --git a/db/migrations/20260917063005_create_event_bookings_table.php b/db/migrations/20260917063005_create_event_bookings_table.php new file mode 100644 index 0000000..bd7d325 --- /dev/null +++ b/db/migrations/20260917063005_create_event_bookings_table.php @@ -0,0 +1,50 @@ +table('event_bookings', [ + 'id' => false, + 'primary_key' => ['id'], + 'engine' => 'InnoDB', + 'encoding' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + ]); + + $table + ->addColumn('id', 'biginteger', ['identity' => true, 'signed' => false]) + ->addColumn('event_id', 'biginteger', ['signed' => false, 'null' => false]) + ->addColumn('user_id', 'integer', ['signed' => false, 'null' => true, 'comment' => 'null = ospite non registrato']) + ->addColumn('name', 'string', ['limit' => 191, 'null' => false]) + ->addColumn('email', 'string', ['limit' => 191, 'null' => false, 'comment' => 'raccolta contatti']) + ->addColumn('phone', 'string', ['limit' => 40, 'null' => true]) + ->addColumn('seats', 'integer', ['signed' => false, 'null' => false, 'default' => 1]) + ->addColumn('status', 'string', ['limit' => 20, 'null' => false, 'default' => 'pending', 'comment' => 'pending|confirmed|cancelled']) + ->addColumn('amount', 'decimal', ['precision' => 8, 'scale' => 2, 'null' => true, 'comment' => 'pronto per pagamenti']) + ->addColumn('token', 'string', ['limit' => 64, 'null' => false, 'comment' => 'gestione/annullo ospite via link email']) + ->addColumn('consent_at', 'datetime', ['null' => true, 'comment' => 'accettazione privacy/termini']) + ->addColumn('marketing_opt_in', 'char', ['limit' => 1, 'null' => false, 'default' => 'N', 'comment' => 'consenso marketing Y/N']) + ->addColumn('created_at', 'timestamp', ['null' => true, 'default' => null]) + ->addColumn('updated_at', 'timestamp', ['null' => true, 'default' => null]) + ->addIndex(['token'], ['unique' => true, 'name' => 'event_bookings_token_unique']) + ->addIndex(['event_id'], ['name' => 'event_bookings_event_id_index']) + ->addIndex(['email'], ['name' => 'event_bookings_email_index']) + ->addIndex(['user_id'], ['name' => 'event_bookings_user_id_index']) + ->addForeignKey('event_id', 'events', 'id', [ + 'delete' => 'CASCADE', + 'update' => 'NO_ACTION', + 'constraint' => 'event_bookings_event_id_foreign', + ]) + ->addForeignKey('user_id', 'auth_users', 'id', [ + 'delete' => 'SET_NULL', + 'update' => 'NO_ACTION', + 'constraint' => 'event_bookings_user_id_foreign', + ]) + ->create(); + } +} diff --git a/db/migrations/20260917063022_add_event_defaults_to_option_table.php b/db/migrations/20260917063022_add_event_defaults_to_option_table.php new file mode 100644 index 0000000..cea4b55 --- /dev/null +++ b/db/migrations/20260917063022_add_event_defaults_to_option_table.php @@ -0,0 +1,18 @@ +table('option') + ->addColumn('event_default_color', 'string', ['limit' => 7, 'null' => true, 'default' => '#4CAF50', 'after' => 'colorcancelled']) + ->addColumn('event_default_max_per_booking', 'integer', ['null' => false, 'default' => 1, 'after' => 'event_default_color']) + ->addColumn('event_notify_email', 'string', ['limit' => 191, 'null' => true, 'after' => 'event_default_max_per_booking']) + ->addColumn('event_terms_text', 'text', ['null' => true, 'after' => 'event_notify_email']) + ->update(); + } +} diff --git a/db/migrations/20260917065654_create_event_locations_table.php b/db/migrations/20260917065654_create_event_locations_table.php new file mode 100644 index 0000000..8f268cf --- /dev/null +++ b/db/migrations/20260917065654_create_event_locations_table.php @@ -0,0 +1,42 @@ +table('event_locations', [ + 'id' => false, + 'primary_key' => ['id'], + 'engine' => 'InnoDB', + 'encoding' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + ]); + + $table + ->addColumn('id', 'biginteger', ['identity' => true, 'signed' => false]) + ->addColumn('name', 'string', ['limit' => 191, 'null' => false, 'comment' => 'etichetta sede, es. "Studio YogaSoul"']) + ->addColumn('address', 'string', ['limit' => 255, 'null' => false]) + ->addColumn('lat', 'decimal', ['precision' => 10, 'scale' => 7, 'null' => true]) + ->addColumn('lng', 'decimal', ['precision' => 10, 'scale' => 7, 'null' => true]) + ->addColumn('is_default', 'char', ['limit' => 1, 'null' => false, 'default' => 'N', 'comment' => 'Y = preselezionata']) + ->addColumn('created_at', 'timestamp', ['null' => true, 'default' => null]) + ->addColumn('updated_at', 'timestamp', ['null' => true, 'default' => null]) + ->addIndex(['is_default'], ['name' => 'event_locations_is_default_index']) + ->create(); + + // Sede di default (MODIFICA con indirizzo e coordinate reali della scuola) + $this->table('event_locations')->insert([ + 'name' => 'Studio YogaSoul', + 'address' => 'Indirizzo da compilare', + 'lat' => null, + 'lng' => null, + 'is_default' => 'Y', + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ])->saveData(); + } +} diff --git a/db/migrations/20260917073036_add_details_and_gallery_to_events.php b/db/migrations/20260917073036_add_details_and_gallery_to_events.php new file mode 100644 index 0000000..0f06c82 --- /dev/null +++ b/db/migrations/20260917073036_add_details_and_gallery_to_events.php @@ -0,0 +1,18 @@ +table('events') + ->addColumn('instructions', 'text', ['null' => true, 'after' => 'description']) + ->addColumn('what_to_bring', 'text', ['null' => true, 'after' => 'instructions']) + ->addColumn('suitable_for', 'text', ['null' => true, 'after' => 'what_to_bring']) + ->addColumn('gallery', 'text', ['null' => true, 'comment' => 'JSON: array di path immagini', 'after' => 'suitable_for']) + ->update(); + } +} diff --git a/db/migrations/20260917080550_add_waitlist_to_events.php b/db/migrations/20260917080550_add_waitlist_to_events.php new file mode 100644 index 0000000..b39fd6a --- /dev/null +++ b/db/migrations/20260917080550_add_waitlist_to_events.php @@ -0,0 +1,21 @@ +table('events') + ->addColumn('waitlist_enabled', 'char', [ + 'limit' => 1, + 'null' => false, + 'default' => 'Y', + 'comment' => "Y = lista d'attesa quando pieno", + 'after' => 'max_per_booking' + ]) + ->update(); + } +} 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/db/migrations/20260918133221_add_publish_at_to_events.php b/db/migrations/20260918133221_add_publish_at_to_events.php new file mode 100644 index 0000000..7736bf1 --- /dev/null +++ b/db/migrations/20260918133221_add_publish_at_to_events.php @@ -0,0 +1,15 @@ +table('events') + ->addColumn('publish_at', 'datetime', ['null' => true, 'comment' => 'apertura prenotazioni programmata', 'after' => 'status']) + ->update(); + } +} diff --git a/db/migrations/20261001073831_extend_employee_for_teachers.php b/db/migrations/20261001073831_extend_employee_for_teachers.php new file mode 100644 index 0000000..3b69d99 --- /dev/null +++ b/db/migrations/20261001073831_extend_employee_for_teachers.php @@ -0,0 +1,51 @@ +table('employee') + ->addColumn('email', 'string', ['limit' => 150, 'null' => true, 'after' => 'surnameemployee']) + ->addColumn('phone', 'string', ['limit' => 40, 'null' => true, 'after' => 'email']) + ->addColumn('photo', 'string', ['limit' => 191, 'null' => true, 'after' => 'phone']) + ->addColumn('is_owner', 'char', [ + 'limit' => 1, + 'null' => false, + 'default' => 'N', + 'comment' => 'Y = titolare della scuola (uno solo)', + 'after' => 'photo', + ]) + ->addColumn('notifications', 'char', [ + 'limit' => 1, + 'null' => false, + 'default' => 'Y', + 'comment' => 'Y = riceve le email', + 'after' => 'is_owner', + ]) + ->addColumn('active', 'char', ['limit' => 1, 'null' => false, 'default' => 'Y', 'after' => 'notifications']) + ->addColumn('created_at', 'timestamp', ['null' => true, 'default' => 'CURRENT_TIMESTAMP']) + ->addColumn('updated_at', 'timestamp', ['null' => true, 'default' => null, 'update' => 'CURRENT_TIMESTAMP']) + ->update(); + + // L'insegnante già presente (id più basso) diventa il titolare + $this->execute("UPDATE `employee` SET `is_owner` = 'Y' ORDER BY `idemployee` LIMIT 1"); + } + + public function down(): void + { + $this->table('employee') + ->removeColumn('email') + ->removeColumn('phone') + ->removeColumn('photo') + ->removeColumn('is_owner') + ->removeColumn('notifications') + ->removeColumn('active') + ->removeColumn('created_at') + ->removeColumn('updated_at') + ->update(); + } +} diff --git a/db/migrations/20261001075259_add_show_teacher_to_option.php b/db/migrations/20261001075259_add_show_teacher_to_option.php new file mode 100644 index 0000000..2d0e12d --- /dev/null +++ b/db/migrations/20261001075259_add_show_teacher_to_option.php @@ -0,0 +1,21 @@ +table('option') + ->addColumn('show_teacher', 'char', [ + 'limit' => 1, + 'null' => false, + 'default' => 'N', + 'comment' => 'Y = mostra l\'insegnante nelle card delle classi', + 'after' => 'waitinglist_enabled', + ]) + ->update(); + } +} diff --git a/db/migrations/20261003120418_create_schools_table.php b/db/migrations/20261003120418_create_schools_table.php new file mode 100644 index 0000000..3a6ca15 --- /dev/null +++ b/db/migrations/20261003120418_create_schools_table.php @@ -0,0 +1,52 @@ +table('schools', [ + 'id' => false, + 'primary_key' => ['idschool'], + 'engine' => 'InnoDB', + 'collation' => 'utf8mb4_unicode_ci', + ]) + ->addColumn('idschool', 'integer', ['signed' => false, 'identity' => true]) + ->addColumn('name', 'string', ['limit' => 191]) + ->addColumn('slug', 'string', ['limit' => 100, 'comment' => 'usato per URL/sottodominio/widget']) + ->addColumn('email', 'string', ['limit' => 191, 'null' => true]) + ->addColumn('phone', 'string', ['limit' => 40, 'null' => true]) + ->addColumn('address', 'string', ['limit' => 255, 'null' => true]) + ->addColumn('city', 'string', ['limit' => 100, 'null' => true]) + ->addColumn('postcode', 'string', ['limit' => 20, 'null' => true]) + ->addColumn('country', 'string', ['limit' => 2, 'default' => 'IT']) + ->addColumn('vat_number', 'string', ['limit' => 30, 'null' => true]) + ->addColumn('logo', 'string', ['limit' => 191, 'null' => true]) + ->addColumn('timezone', 'string', ['limit' => 50, 'default' => 'Europe/Rome']) + ->addColumn('locale', 'string', ['limit' => 5, 'default' => 'it']) + ->addColumn('currency', 'string', ['limit' => 3, 'default' => 'EUR']) + ->addColumn('status', 'string', ['limit' => 20, 'default' => 'active', 'comment' => 'active|suspended|trial']) + ->addColumn('created_at', 'timestamp', ['null' => true, 'default' => 'CURRENT_TIMESTAMP']) + ->addColumn('updated_at', 'timestamp', ['null' => true, 'default' => null, 'update' => 'CURRENT_TIMESTAMP']) + ->addIndex(['slug'], ['unique' => true, 'name' => 'schools_slug_unique']) + ->addIndex(['status'], ['name' => 'schools_status_index']) + ->create(); + + // Seregno = scuola 1 (dati da completare poi dal pannello) + $this->table('schools')->insert([ + 'idschool' => 1, + 'name' => 'Scuola Yoga Seregno', + 'slug' => 'seregno', + 'city' => 'Seregno', + 'country' => 'IT', + ])->saveData(); + } + + public function down(): void + { + $this->table('schools')->drop()->save(); + } +} diff --git a/db/migrations/20261003120419_create_school_user_table.php b/db/migrations/20261003120419_create_school_user_table.php new file mode 100644 index 0000000..404a094 --- /dev/null +++ b/db/migrations/20261003120419_create_school_user_table.php @@ -0,0 +1,40 @@ +table('school_user', [ + 'id' => false, + 'primary_key' => ['idschooluser'], + 'engine' => 'InnoDB', + 'collation' => 'utf8mb4_unicode_ci', + ]) + ->addColumn('idschooluser', 'integer', ['signed' => false, 'identity' => true]) + ->addColumn('user_id', 'integer', ['signed' => false, 'comment' => 'auth_users.id']) + ->addColumn('idschool', 'integer', ['signed' => false]) + ->addColumn('school_role', 'string', ['limit' => 20, 'default' => 'student', 'comment' => 'student|teacher|admin|owner']) + ->addColumn('active', 'char', ['limit' => 1, 'default' => 'Y']) + ->addColumn('joined_at', 'timestamp', ['null' => true, 'default' => 'CURRENT_TIMESTAMP']) + ->addIndex(['user_id', 'idschool'], ['unique' => true, 'name' => 'school_user_unique']) + ->addIndex(['idschool'], ['name' => 'school_user_idschool_index']) + ->addForeignKey('user_id', 'auth_users', 'id', ['delete' => 'CASCADE', 'update' => 'NO_ACTION']) + ->addForeignKey('idschool', 'schools', 'idschool', ['delete' => 'CASCADE', 'update' => 'NO_ACTION']) + ->create(); + + // Tutti gli utenti esistenti appartengono a Seregno (1) + $this->execute(" + INSERT INTO school_user (user_id, idschool, school_role) + SELECT id, 1, 'student' FROM auth_users + "); + } + + public function down(): void + { + $this->table('school_user')->drop()->save(); + } +} diff --git a/db/migrations/20261003120420_add_idschool_to_domain_tables.php b/db/migrations/20261003120420_add_idschool_to_domain_tables.php new file mode 100644 index 0000000..77f1b5f --- /dev/null +++ b/db/migrations/20261003120420_add_idschool_to_domain_tables.php @@ -0,0 +1,79 @@ +table($name) + ->addColumn('idschool', 'integer', [ + 'signed' => false, + 'null' => false, + 'default' => 1, // TEMPORANEO: verrà rimosso quando tutte le INSERT passeranno idschool + ]) + ->addIndex(['idschool'], ['name' => "idx_{$name}_idschool"]) + ->update(); + } + + // Slug eventi: unico per scuola, non più globale + $this->table('events') + ->removeIndexByName('events_slug_unique') + ->addIndex(['idschool', 'slug'], ['unique' => true, 'name' => 'events_school_slug_unique']) + ->update(); + + // Impostazioni: una sola riga per scuola + $this->table('option') + ->addIndex(['idschool'], ['unique' => true, 'name' => 'option_idschool_unique']) + ->update(); + } + + public function down(): void + { + $this->table('option') + ->removeIndexByName('option_idschool_unique') + ->update(); + + $this->table('events') + ->removeIndexByName('events_school_slug_unique') + ->addIndex(['slug'], ['unique' => true, 'name' => 'events_slug_unique']) + ->update(); + + foreach (self::TABLES as $name) { + $this->table($name) + ->removeIndexByName("idx_{$name}_idschool") + ->removeColumn('idschool') + ->update(); + } + } +} diff --git a/db/migrations/AddIdschoolToDomainTables.php b/db/migrations/AddIdschoolToDomainTables.php new file mode 100644 index 0000000..77f1b5f --- /dev/null +++ b/db/migrations/AddIdschoolToDomainTables.php @@ -0,0 +1,79 @@ +table($name) + ->addColumn('idschool', 'integer', [ + 'signed' => false, + 'null' => false, + 'default' => 1, // TEMPORANEO: verrà rimosso quando tutte le INSERT passeranno idschool + ]) + ->addIndex(['idschool'], ['name' => "idx_{$name}_idschool"]) + ->update(); + } + + // Slug eventi: unico per scuola, non più globale + $this->table('events') + ->removeIndexByName('events_slug_unique') + ->addIndex(['idschool', 'slug'], ['unique' => true, 'name' => 'events_school_slug_unique']) + ->update(); + + // Impostazioni: una sola riga per scuola + $this->table('option') + ->addIndex(['idschool'], ['unique' => true, 'name' => 'option_idschool_unique']) + ->update(); + } + + public function down(): void + { + $this->table('option') + ->removeIndexByName('option_idschool_unique') + ->update(); + + $this->table('events') + ->removeIndexByName('events_school_slug_unique') + ->addIndex(['slug'], ['unique' => true, 'name' => 'events_slug_unique']) + ->update(); + + foreach (self::TABLES as $name) { + $this->table($name) + ->removeIndexByName("idx_{$name}_idschool") + ->removeColumn('idschool') + ->update(); + } + } +} diff --git a/db/migrations/CreateSchoolUserTable.php b/db/migrations/CreateSchoolUserTable.php new file mode 100644 index 0000000..ec80732 --- /dev/null +++ b/db/migrations/CreateSchoolUserTable.php @@ -0,0 +1,40 @@ +table('school_user', [ + 'id' => false, + 'primary_key' => ['idschooluser'], + 'engine' => 'InnoDB', + 'collation' => 'utf8mb4_unicode_ci', + ]) + ->addColumn('idschooluser', 'integer', ['signed' => false, 'identity' => true]) + ->addColumn('user_id', 'integer', ['signed' => false, 'comment' => 'auth_users.id']) + ->addColumn('idschool', 'integer', ['signed' => false]) + ->addColumn('school_role', 'string', ['limit' => 20, 'default' => 'student', 'comment' => 'student|teacher|admin|owner']) + ->addColumn('active', 'char', ['limit' => 1, 'default' => 'Y']) + ->addColumn('joined_at', 'timestamp', ['null' => true, 'default' => 'CURRENT_TIMESTAMP']) + ->addIndex(['user_id', 'idschool'], ['unique' => true, 'name' => 'school_user_unique']) + ->addIndex(['idschool'], ['name' => 'school_user_idschool_index']) + ->addForeignKey('user_id', 'auth_users', 'id', ['delete' => 'CASCADE', 'update' => 'NO_ACTION']) + ->addForeignKey('idschool', 'schools', 'idschool', ['delete' => 'CASCADE', 'update' => 'NO_ACTION']) + ->create(); + + // Tutti gli utenti esistenti appartengono a Seregno (1) + $this->execute(" + INSERT INTO school_user (user_id, idschool, school_role) + SELECT id, 1, 'student' FROM auth_users + "); + } + + public function down(): void + { + $this->table('school_user')->drop()->save(); + } +} diff --git a/db/migrations/CreateSchoolsTable.php b/db/migrations/CreateSchoolsTable.php new file mode 100644 index 0000000..ef1bbe4 --- /dev/null +++ b/db/migrations/CreateSchoolsTable.php @@ -0,0 +1,52 @@ +table('schools', [ + 'id' => false, + 'primary_key' => ['idschool'], + 'engine' => 'InnoDB', + 'collation' => 'utf8mb4_unicode_ci', + ]) + ->addColumn('idschool', 'integer', ['signed' => false, 'identity' => true]) + ->addColumn('name', 'string', ['limit' => 191]) + ->addColumn('slug', 'string', ['limit' => 100, 'comment' => 'usato per URL/sottodominio/widget']) + ->addColumn('email', 'string', ['limit' => 191, 'null' => true]) + ->addColumn('phone', 'string', ['limit' => 40, 'null' => true]) + ->addColumn('address', 'string', ['limit' => 255, 'null' => true]) + ->addColumn('city', 'string', ['limit' => 100, 'null' => true]) + ->addColumn('postcode', 'string', ['limit' => 20, 'null' => true]) + ->addColumn('country', 'string', ['limit' => 2, 'default' => 'IT']) + ->addColumn('vat_number', 'string', ['limit' => 30, 'null' => true]) + ->addColumn('logo', 'string', ['limit' => 191, 'null' => true]) + ->addColumn('timezone', 'string', ['limit' => 50, 'default' => 'Europe/Rome']) + ->addColumn('locale', 'string', ['limit' => 5, 'default' => 'it']) + ->addColumn('currency', 'string', ['limit' => 3, 'default' => 'EUR']) + ->addColumn('status', 'string', ['limit' => 20, 'default' => 'active', 'comment' => 'active|suspended|trial']) + ->addColumn('created_at', 'timestamp', ['null' => true, 'default' => 'CURRENT_TIMESTAMP']) + ->addColumn('updated_at', 'timestamp', ['null' => true, 'default' => null, 'update' => 'CURRENT_TIMESTAMP']) + ->addIndex(['slug'], ['unique' => true, 'name' => 'schools_slug_unique']) + ->addIndex(['status'], ['name' => 'schools_status_index']) + ->create(); + + // Seregno = scuola 1 (dati da completare poi dal pannello) + $this->table('schools')->insert([ + 'idschool' => 1, + 'name' => 'Scuola Yoga Seregno', + 'slug' => 'seregno', + 'city' => 'Seregno', + 'country' => 'IT', + ])->saveData(); + } + + public function down(): void + { + $this->table('schools')->drop()->save(); + } +} diff --git a/public/admin-services.php b/public/admin-services.php index 5fa0656..605e3fe 100644 --- a/public/admin-services.php +++ b/public/admin-services.php @@ -11,9 +11,56 @@ function e($value): string return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); } +function teacherInitials(string $first, string $last): string +{ + $ini = mb_strtoupper(mb_substr(trim($first), 0, 1) . mb_substr(trim($last), 0, 1)); + return $ini !== '' ? $ini : '?'; +} + +function teacherColor(string $seed): string +{ + $palette = ['#1ebf73', '#2980b9', '#8e44ad', '#e67e22', '#16a085', '#c0392b', '#2c3e50', '#b08d57']; + return $palette[abs(crc32($seed)) % count($palette)]; +} + $message = $_GET['message'] ?? 'N'; $insertFeedback = null; +/* ------------------------------------------------------------------------- + * Insegnanti (titolare per primo, poi attivi, poi alfabetico) + * ---------------------------------------------------------------------- */ +$teachers = $pdo->query( + "SELECT idemployee, nameemployee, surnameemployee, photo, is_owner, active + FROM employee + ORDER BY is_owner DESC, active DESC, surnameemployee, nameemployee" +)->fetchAll(); + +$teachersById = []; +$ownerId = 0; +foreach ($teachers as $t) { + $teachersById[(int) $t['idemployee']] = $t; + if ($t['is_owner'] === 'Y' && !$ownerId) { + $ownerId = (int) $t['idemployee']; + } +} + +/* ------------------------------------------------------------------------- + * Assegnazione insegnante a una classe esistente (POST) + * ---------------------------------------------------------------------- */ +if (($_POST['action'] ?? '') === 'assignteacher') { + $idservice = (int) ($_POST['idservice'] ?? 0); + $idteacher = (int) ($_POST['idteacher'] ?? 0); + + if ($idservice > 0 && isset($teachersById[$idteacher])) { + $stmt = $pdo->prepare("UPDATE service SET idteacher = :t WHERE idservice = :s"); + $stmt->execute([':t' => $idteacher, ':s' => $idservice]); + header('Location: ' . $_SERVER['PHP_SELF'] . '?message=teacherassigned'); + } else { + header('Location: ' . $_SERVER['PHP_SELF'] . '?message=teachererror'); + } + exit; +} + /* ------------------------------------------------------------------------- * Inserimento nuova classe (POST) con prepared statement * ---------------------------------------------------------------------- */ @@ -26,14 +73,17 @@ if (isset($_POST['classinsert']) && $_POST['classinsert'] === 'Y') { $maxcapacity = $_POST['maxcapacity'] ?? ''; $colorclass = trim($_POST['colorclass'] ?? ''); $classduration = $_POST['classduration'] ?? ''; + $idteacher = (int) ($_POST['idteacher'] ?? 0); if ($servicename === '') { $insertFeedback = ['type' => 'error', 'text' => 'La descrizione della classe è obbligatoria.']; + } elseif (!isset($teachersById[$idteacher])) { + $insertFeedback = ['type' => 'error', 'text' => 'Seleziona l\'insegnante della classe.']; } else { $sql = "INSERT INTO service - (servicename, wpcatalognumber, day, time, category, maxcapacity, colorclass, classduration) + (servicename, wpcatalognumber, day, time, category, maxcapacity, colorclass, classduration, idteacher) VALUES - (:servicename, :wpcatalog, :day, :time, :category, :maxcapacity, :colorclass, :classduration)"; + (:servicename, :wpcatalog, :day, :time, :category, :maxcapacity, :colorclass, :classduration, :idteacher)"; $stmt = $pdo->prepare($sql); $ok = $stmt->execute([ ':servicename' => $servicename, @@ -44,6 +94,7 @@ if (isset($_POST['classinsert']) && $_POST['classinsert'] === 'Y') { ':maxcapacity' => $maxcapacity !== '' ? (int) $maxcapacity : null, ':colorclass' => $colorclass, ':classduration' => $classduration !== '' ? (float) $classduration : null, + ':idteacher' => $idteacher, ]); if ($ok) { @@ -83,6 +134,32 @@ $propRows = $pdo->query( foreach ($propRows as $pr) { $propagationsByService[$pr['idservice']][] = $pr; } + +/** + * Opzioni del select insegnanti. + * Gli insegnanti non attivi compaiono solo se già assegnati a quella classe. + */ +function teacherOptions(array $teachers, int $selectedId): string +{ + $html = ''; + $found = false; + foreach ($teachers as $t) { + $tid = (int) $t['idemployee']; + $isSel = $tid === $selectedId; + if ($t['active'] !== 'Y' && !$isSel) { + continue; + } + if ($isSel) $found = true; + $label = trim($t['nameemployee'] . ' ' . $t['surnameemployee']) + . ($t['is_owner'] === 'Y' ? ' (titolare)' : '') + . ($t['active'] !== 'Y' ? ' (non attivo)' : ''); + $html .= ''; + } + if (!$found) { + $html = '' . $html; + } + return $html; +} ?> @@ -197,6 +274,43 @@ foreach ($propRows as $pr) { .action-btns .btn { margin: 2px; } + + .teacher-cell { + display: flex; + align-items: center; + gap: 8px; + min-width: 210px; + } + + .teacher-mini { + flex: 0 0 30px; + width: 30px; + height: 30px; + border-radius: 50%; + object-fit: cover; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-size: 11px; + font-weight: 800; + } + + .teacher-mini.none { + background: #f3f4f6; + color: #c0c4cc; + } + + .teacher-select { + padding: 5px 30px 5px 10px !important; + font-size: 13px; + } + + .teacher-select.unassigned { + border-color: #f0b27a; + background-color: #fff8f1; + color: #b9770e; + } @@ -238,6 +352,12 @@ foreach ($propRows as $pr) { + + + + + + @@ -261,6 +381,11 @@ foreach ($propRows as $pr) { + + +
@@ -343,6 +468,14 @@ foreach ($propRows as $pr) {
+
+
+ + +
+
@@ -358,7 +491,7 @@ foreach ($propRows as $pr) {
Classi esistenti
-

Propaga una classe sul calendario, associala o modificane i dettagli.

+

Propaga una classe sul calendario, associala, assegna l'insegnante o modificane i dettagli.

@@ -369,16 +502,20 @@ foreach ($propRows as $pr) { + - + - + + - '; + if ($addr !== '') { + $mapsLink = $maps !== '' ? '  apri mappa' : ''; + $rows .= ''; + } + + $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 = '
Orario Durata CategoriaInsegnante Azioni
Nessuna classe presente.Nessuna classe presente.
@@ -391,6 +528,25 @@ foreach ($propRows as $pr) { +
+ + + + + + + + + + +
+
@@ -407,7 +563,7 @@ foreach ($propRows as $pr) {
+
@@ -514,6 +670,17 @@ foreach ($propRows as $pr) { $('#colorclass').val(col.toHexString()); } }); + + // Cambio insegnante dalla tabella: conferma e salva + $('.teacher-select').on('change', function() { + var sel = $(this); + var teacherName = sel.find('option:selected').text(); + if (confirm('Assegnare ' + teacherName + ' alla classe ' + sel.data('classname') + '?')) { + sel.closest('form')[0].submit(); + } else { + sel.val(String(sel.data('current')) === '0' ? '' : String(sel.data('current'))); + } + }); }); // Espansione riga propagazione diff --git a/public/adminpanel.php b/public/adminpanel.php index 30f262f..4c9d4e1 100644 --- a/public/adminpanel.php +++ b/public/adminpanel.php @@ -28,8 +28,16 @@ function avatarColor(string $seed): string return $palette[$idx]; } -$optRow = $pdo->query("SELECT maxbeforetimecancell FROM `option` LIMIT 1")->fetch(); +$optRow = $pdo->query("SELECT maxbeforetimecancell, show_teacher FROM `option` LIMIT 1")->fetch(); $timetocancel = $optRow ? (int) $optRow['maxbeforetimecancell'] : 24; +$showTeacher = ($optRow['show_teacher'] ?? 'N') === 'Y'; + +$teachersById = []; +if ($showTeacher) { + foreach ($pdo->query("SELECT idemployee, nameemployee, surnameemployee, photo FROM employee")->fetchAll() as $t) { + $teachersById[(int) $t['idemployee']] = $t; + } +} if (isset($_GET['prev_month'])) { $currentMonthStart = preg_replace('/[^0-9\-]/', '', $_GET['prev_month']) . '-01'; @@ -44,7 +52,7 @@ if (strtotime($currentMonthStart) === false) { $currentMonthEnd = date("Y-m-t", strtotime($currentMonthStart)); $sqlSchedule = "SELECT serviceschedule.*, service.servicename, service.colorclass, - service.maxcapacity, service.idservice + service.maxcapacity, service.idservice, service.idteacher FROM serviceschedule LEFT JOIN service ON serviceschedule.idservice = service.idservice WHERE serviceschedule.dateschedule BETWEEN :start AND DATE_ADD(:end, INTERVAL 1 DAY) @@ -73,9 +81,9 @@ if (!empty($scheduleIds)) { auth_users.first_name, auth_users.last_name, auth_users.id AS iduser FROM bookingclass LEFT JOIN auth_users ON bookingclass.iduser = auth_users.id - WHERE bookingclass.idserviceschedule IN ($ph) - AND bookingclass.status IN ('booked', 'pending') - ORDER BY auth_users.last_name, auth_users.first_name"; + WHERE bookingclass.idserviceschedule IN ($ph) + AND bookingclass.status IN ('booked', 'pending', 'waiting') + ORDER BY bookingclass.created_at, auth_users.last_name, auth_users.first_name"; $stmtBookings = $pdo->prepare($sqlBookings); $stmtBookings->execute(array_values($scheduleIds)); foreach ($stmtBookings->fetchAll() as $b) { @@ -198,8 +206,8 @@ $italianMonths = [ .class-date { flex: 0 0 110px; - background: #1ebf73; - color: #fff; + background: #E1D0B8; + color: #ffffff; display: flex; flex-direction: column; justify-content: center; @@ -231,6 +239,38 @@ $italianMonths = [ margin: 0 0 4px; } + .class-teacher { + display: flex; + align-items: center; + gap: 8px; + margin-top: 4px; + } + + .class-teacher strong { + color: #374151; + font-weight: 600; + } + + .teacher-avatar-sm { + flex: 0 0 26px; + width: 26px; + height: 26px; + border-radius: 50%; + object-fit: cover; + background: #E1D0B8; + color: #5a4630; + font-size: 11px; + font-weight: 800; + display: inline-flex; + align-items: center; + justify-content: center; + } + + .teacher-avatar-sm.none { + background: #fdf0e3; + color: #e67e22; + } + .class-meta { color: #6b7280; font-size: 14px; @@ -575,8 +615,10 @@ $italianMonths = [ // separa confermati (booked) da in attesa (pending) $bookings = array_filter($allRows, fn($b) => $b['status'] === 'booked'); $pendings = array_filter($allRows, fn($b) => $b['status'] === 'pending'); + $waitings = array_filter($allRows, fn($b) => $b['status'] === 'waiting'); $countPersons = count($bookings); // contatore = solo confermati $countPending = count($pendings); + $countWaiting = count($waitings); $maxcap = (int) $sch['maxcapacity']; $isFull = $maxcap > 0 && $countPersons >= $maxcap; $colorclass = $sch['colorclass'] ?: '#e6f3ff'; @@ -599,9 +641,31 @@ $italianMonths = [ in attesa + 0) : ?> + + + in coda + +

via Valassina 62/B Seregno - Sala Contesto Yoga

+ +

+ + + + + + + Insegnante: + + + Insegnante non assegnato + +

+
@@ -676,6 +740,36 @@ $italianMonths = [
+ 0) : ?> +
+ Lista d'attesa () +
+
+ +
+
+
+ + ° + + + In coda +
+
+ +
+
+ +
+ +
Aggiungi partecipante diff --git a/public/apiwp/book.php b/public/apiwp/book.php new file mode 100644 index 0000000..cf15e44 --- /dev/null +++ b/public/apiwp/book.php @@ -0,0 +1,189 @@ + honeypot anti-spam (deve restare vuoto) + * + * Logica posti (conferma immediata): + * - seats <= posti rimanenti -> status 'confirmed' + * - evento pieno + lista d'attesa ON -> status 'waiting' + * - evento pieno + lista d'attesa OFF -> errore sold_out + * - 0 < rimanenti < seats -> errore 'partial' (avvisa: solo N posti) + * + * Anti-oversell: transazione + lock della riga evento (SELECT ... FOR UPDATE), + * così due prenotazioni simultanee non sforano la capienza. + */ +if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') { + send_json(['ok' => false, 'code' => 'method', 'error' => 'Metodo non consentito.'], 405); +} + +// Input: form-encoded oppure JSON +$in = $_POST; +if (empty($in)) { + $raw = file_get_contents('php://input'); + if ($raw) { + $j = json_decode($raw, true); + if (is_array($j)) { + $in = $j; + } + } +} + +// Honeypot: se compilato, è un bot +if (!empty($in['website'])) { + send_json(['ok' => false, 'code' => 'invalid', 'error' => 'Richiesta non valida.'], 400); +} + +$eventId = (int) ($in['event_id'] ?? 0); +$name = trim((string) ($in['name'] ?? '')); +$surname = trim((string) ($in['surname'] ?? '')); +$email = trim((string) ($in['email'] ?? '')); +$seats = (int) ($in['seats'] ?? 0); +$consent = !empty($in['consent']) && $in['consent'] !== '0'; +$marketing = (!empty($in['marketing']) && $in['marketing'] !== '0') ? 'Y' : 'N'; + +// Validazioni +if ($eventId <= 0 || $name === '' || $surname === '') { + send_json(['ok' => false, 'code' => 'invalid', 'error' => 'Compila nome, cognome ed evento.'], 400); +} +if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { + send_json(['ok' => false, 'code' => 'invalid', 'error' => 'Email non valida.'], 400); +} +if (!$consent) { + send_json(['ok' => false, 'code' => 'consent', 'error' => "Devi accettare l'informativa privacy."], 400); +} +if ($seats < 1) { + $seats = 1; +} + +$pdo = pdo_conn(); + +try { + $pdo->beginTransaction(); + + // Lock della riga evento: serializza le prenotazioni dello stesso evento + $es = $pdo->prepare("SELECT * FROM events WHERE id = ? FOR UPDATE"); + $es->execute([$eventId]); + $event = $es->fetch(PDO::FETCH_ASSOC); + + if (!$event || $event['status'] !== 'published') { + $pdo->rollBack(); + send_json(['ok' => false, 'code' => 'not_available', 'error' => 'Evento non disponibile.'], 404); + } + + // Prenotazioni chiuse all'orario di inizio + if (strtotime($event['starts_at']) <= time()) { + $pdo->rollBack(); + send_json(['ok' => false, 'code' => 'closed', 'error' => 'Le prenotazioni per questo evento sono chiuse.'], 409); + } + + $maxSeats = (int) $event['max_seats']; + $maxPerBooking = max(1, (int) $event['max_per_booking']); + $waitlistOn = ($event['waitlist_enabled'] ?? 'N') === 'Y'; + + if ($seats > $maxPerBooking) { + $pdo->rollBack(); + send_json([ + 'ok' => false, + 'code' => 'too_many', + 'max' => $maxPerBooking, + 'error' => "Puoi prenotare al massimo {$maxPerBooking} posti per prenotazione." + ], 400); + } + + // Posti occupati (solo confermate/pending): protetto dal lock evento + $os = $pdo->prepare("SELECT COALESCE(SUM(seats),0) FROM event_bookings + WHERE event_id = ? AND status IN ('confirmed','pending','offered')"); + $os->execute([$eventId]); + $occupied = (int) $os->fetchColumn(); + $remaining = max(0, $maxSeats - $occupied); + + // Decisione stato + if ($seats <= $remaining) { + $status = 'confirmed'; + } elseif ($remaining <= 0 && $waitlistOn) { + $status = 'waiting'; + } elseif ($remaining <= 0 && !$waitlistOn) { + $pdo->rollBack(); + send_json(['ok' => false, 'code' => 'sold_out', 'error' => 'Evento esaurito.'], 409); + } else { + // 0 < remaining < seats: avvisa, non prenota + $pdo->rollBack(); + send_json([ + 'ok' => false, + 'code' => 'partial', + 'remaining' => $remaining, + 'error' => "Sono rimasti solo {$remaining} posti. Riduci il numero di persone; per gli altri potrai iscriverti alla lista d'attesa.", + ], 409); + } + + // Token univoco per gestione/disdetta da link email + $token = ''; + for ($i = 0; $i < 5; $i++) { + $candidate = bin2hex(random_bytes(16)); + $ck = $pdo->prepare("SELECT 1 FROM event_bookings WHERE token = ? LIMIT 1"); + $ck->execute([$candidate]); + if (!$ck->fetchColumn()) { + $token = $candidate; + break; + } + } + if ($token === '') { + throw new RuntimeException('Impossibile generare il token.'); + } + + $amount = round($seats * (float) $event['cost'], 2); + $fullName = trim($name . ' ' . $surname); + + $ins = $pdo->prepare("INSERT INTO event_bookings + (event_id, user_id, name, email, phone, seats, status, amount, token, + consent_at, marketing_opt_in, created_at, updated_at) + VALUES + (:event_id, NULL, :name, :email, NULL, :seats, :status, :amount, :token, + NOW(), :marketing, NOW(), NOW())"); + $ins->execute([ + ':event_id' => $eventId, + ':name' => $fullName, + ':email' => $email, + ':seats' => $seats, + ':status' => $status, + ':amount' => $amount, + ':token' => $token, + ':marketing' => $marketing, + ]); + + $pdo->commit(); + + // 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.' + : "Sei in lista d'attesa: ti avviseremo appena si libera un posto."; + + send_json([ + 'ok' => true, + 'status' => $status, + 'seats' => $seats, + 'amount' => $amount, + 'token' => $token, + 'message' => $msg, + ]); +} catch (Throwable $ex) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + error_log('[apiwp/book] ' . $ex->getMessage()); + send_json(['ok' => false, 'code' => 'server', 'error' => 'Errore durante la prenotazione. Riprova.'], 500); +} diff --git a/public/apiwp/bootstrap.php b/public/apiwp/bootstrap.php new file mode 100644 index 0000000..90ec89b --- /dev/null +++ b/public/apiwp/bootstrap.php @@ -0,0 +1,63 @@ + ../class/db-functions.php) +require_once dirname(__DIR__) . '/class/db-functions.php'; + +// URL pubblico di yogibook: usato per rendere assoluti i path delle immagini. +if (!defined('YB_PUBLIC_BASE')) { + define('YB_PUBLIC_BASE', 'https://yogibook.yogasoul.it/'); +} + +// Domini WordPress autorizzati (per la prenotazione via browser). +$YB_ALLOWED_ORIGINS = ['https://yogasoul.it', 'https://www.yogasoul.it']; + +$origin = $_SERVER['HTTP_ORIGIN'] ?? ''; +if ($origin !== '' && in_array($origin, $YB_ALLOWED_ORIGINS, true)) { + header('Access-Control-Allow-Origin: ' . $origin); + header('Vary: Origin'); + header('Access-Control-Allow-Methods: GET, POST, OPTIONS'); + header('Access-Control-Allow-Headers: Content-Type'); +} + +// Preflight CORS +if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'OPTIONS') { + http_response_code(204); + exit(); +} + +function pdo_conn(): PDO +{ + return DBHandlerSelect::getInstance()->getConnection(); +} + +/** Rende assoluto un path immagine relativo (es. assets/uploads/events/x.jpg). */ +function public_url(?string $rel): ?string +{ + if ($rel === null || $rel === '') { + return null; + } + if (preg_match('#^https?://#i', $rel)) { + return $rel; + } + return YB_PUBLIC_BASE . ltrim($rel, '/'); +} + +/** Risponde in JSON e termina. */ +function send_json($data, int $code = 200): void +{ + if (function_exists('ob_get_level') && ob_get_level()) { + ob_clean(); + } + http_response_code($code); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit(); +} diff --git a/public/apiwp/cancel.php b/public/apiwp/cancel.php new file mode 100644 index 0000000..c48fd3c --- /dev/null +++ b/public/apiwp/cancel.php @@ -0,0 +1,283 @@ + 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'; + } +} + +if (!function_exists('e')) { + 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/cron_reminders.php b/public/apiwp/cron_reminders.php new file mode 100644 index 0000000..4a5c7e1 --- /dev/null +++ b/public/apiwp/cron_reminders.php @@ -0,0 +1,127 @@ + false, 'error' => 'Forbidden'], 403); +} + +$report = ['published' => 0, 'reminders' => 0, 'offers_expired' => 0, 'promoted' => 0]; + +// Default scuola +$opt = []; +try { + $opt = $pdo->query("SELECT * FROM `option` LIMIT 1")->fetch(PDO::FETCH_ASSOC) ?: []; +} catch (Throwable $e) { + $opt = []; +} +$defRemEnabled = ($opt['event_reminder_enabled'] ?? 'N') === 'Y'; +$defRemHours = (int) ($opt['event_reminder_hours'] ?? 24); +$defOfferHours = (int) ($opt['event_offer_expiry_hours'] ?? 24); + +/* 1) Pubblica gli eventi programmati arrivati a scadenza */ +try { + $pub = $pdo->prepare("UPDATE events SET status = 'published', updated_at = NOW() + WHERE status = 'scheduled' AND publish_at IS NOT NULL AND publish_at <= NOW()"); + $pub->execute(); + $report['published'] = $pub->rowCount(); +} catch (Throwable $e) { + error_log('[cron publish] ' . $e->getMessage()); +} + +/* 2) Promemoria automatici (una sola volta per evento) */ +try { + $evs = $pdo->query("SELECT * FROM events + WHERE status = 'published' AND reminder_sent_at IS NULL AND starts_at > NOW()") + ->fetchAll(PDO::FETCH_ASSOC); + foreach ($evs as $ev) { + $enabled = $ev['reminder_enabled'] !== null ? ($ev['reminder_enabled'] === 'Y') : $defRemEnabled; + if (!$enabled) { + continue; + } + $hours = $ev['reminder_hours'] !== null ? (int) $ev['reminder_hours'] : $defRemHours; + if (strtotime($ev['starts_at']) - $hours * 3600 > time()) { + continue; // non ancora l'ora del promemoria + } + $ps = $pdo->prepare("SELECT * FROM event_bookings WHERE event_id = ? AND status = 'confirmed'"); + $ps->execute([$ev['id']]); + foreach ($ps->fetchAll(PDO::FETCH_ASSOC) as $p) { + try { + yse_mail_reminder($p, $ev); + $report['reminders']++; + } catch (Throwable $me) { + error_log('[cron reminder] ' . $me->getMessage()); + } + } + $pdo->prepare("UPDATE events SET reminder_sent_at = NOW() WHERE id = ?")->execute([$ev['id']]); + } +} catch (Throwable $e) { + error_log('[cron reminders] ' . $e->getMessage()); +} + +/* 3) Scadenza offerte lista d'attesa */ +try { + $offers = $pdo->query("SELECT b.*, e.offer_expiry_hours AS ev_offer_hours + FROM event_bookings b JOIN events e ON e.id = b.event_id + WHERE b.status = 'offered' AND b.offered_at IS NOT NULL") + ->fetchAll(PDO::FETCH_ASSOC); + foreach ($offers as $o) { + $hours = $o['ev_offer_hours'] !== null ? (int) $o['ev_offer_hours'] : $defOfferHours; + if (strtotime($o['offered_at']) + $hours * 3600 > time()) { + continue; // offerta ancora valida + } + $promoted = []; + try { + $pdo->beginTransaction(); + $pdo->prepare("SELECT id FROM events WHERE id = ? FOR UPDATE")->execute([(int) $o['event_id']]); + $st = $pdo->prepare("SELECT status FROM event_bookings WHERE id = ?"); + $st->execute([(int) $o['id']]); + if ($st->fetchColumn() === 'offered') { + $pdo->prepare("UPDATE event_bookings SET status = 'cancelled', updated_at = NOW() WHERE id = ?") + ->execute([(int) $o['id']]); + $promoted = promoteFromWaitlist($pdo, (int) $o['event_id']); + $report['offers_expired']++; + } + $pdo->commit(); + + if (!empty($promoted)) { + $ev = $pdo->prepare("SELECT * FROM events WHERE id = ?"); + $ev->execute([(int) $o['event_id']]); + $evRow = $ev->fetch(PDO::FETCH_ASSOC); + foreach ($promoted as $pr) { + try { + yse_mail_promotion($pr, $evRow); + $report['promoted']++; + } catch (Throwable $pe) { + error_log('[cron promo] ' . $pe->getMessage()); + } + } + } + } catch (Throwable $te) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + error_log('[cron offer] ' . $te->getMessage()); + } + } +} catch (Throwable $e) { + error_log('[cron offers] ' . $e->getMessage()); +} + +send_json(['ok' => true, 'report' => $report, 'at' => date('c')]); diff --git a/public/apiwp/events.php b/public/apiwp/events.php new file mode 100644 index 0000000..85fd31c --- /dev/null +++ b/public/apiwp/events.php @@ -0,0 +1,133 @@ + un solo evento + * ?ids=12,15,20 -> solo questi eventi (nell'ordine per data) + * ?limit=3 -> massimo N eventi + * Senza parametri: tutti i pubblicati futuri, per data crescente. + * + * Regole di visibilità: + * status = 'published' AND COALESCE(ends_at, starts_at) >= NOW() + * (l'evento sparisce da solo quando è terminato) + */ +try { + $pdo = pdo_conn(); + + $id = isset($_GET['id']) ? (int) $_GET['id'] : 0; + $idsParam = trim($_GET['ids'] ?? ''); + $limit = isset($_GET['limit']) ? max(1, min(50, (int) $_GET['limit'])) : 0; + + $where = ["e.status IN ('published','scheduled')", "COALESCE(e.ends_at, e.starts_at) >= NOW()"]; + $params = []; + + if ($id > 0) { + $where[] = 'e.id = ?'; + $params[] = $id; + } elseif ($idsParam !== '') { + $ids = array_values(array_filter(array_map('intval', explode(',', $idsParam)), function ($v) { + return $v > 0; + })); + if (!empty($ids)) { + $where[] = 'e.id IN (' . implode(',', array_fill(0, count($ids), '?')) . ')'; + foreach ($ids as $v) { + $params[] = $v; + } + } else { + send_json(['ok' => true, 'count' => 0, 'events' => []]); + } + } + + $sql = "SELECT e.*, u.first_name AS cf, u.last_name AS cl, + COALESCE(b.occupied, 0) AS occupied, + COALESCE(w.waiting, 0) AS waiting + FROM events e + 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','offered') + GROUP BY event_id + ) b ON b.event_id = e.id + LEFT JOIN ( + SELECT event_id, SUM(seats) AS waiting + FROM event_bookings WHERE status = 'waiting' + GROUP BY event_id + ) w ON w.event_id = e.id + WHERE " . implode(' AND ', $where) . " + ORDER BY e.starts_at ASC"; + + if ($limit > 0) { + $sql .= ' LIMIT ' . (int) $limit; + } + + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + $rows = $stmt->fetchAll(PDO::FETCH_ASSOC); + + $symbols = ['EUR' => '€', 'USD' => '$', 'GBP' => '£', 'CHF' => 'CHF']; + $events = []; + + foreach ($rows as $r) { + $maxSeats = (int) $r['max_seats']; + $occupied = (int) $r['occupied']; + $remaining = max(0, $maxSeats - $occupied); + + $gallery = []; + if (!empty($r['gallery'])) { + $g = json_decode($r['gallery'], true); + if (is_array($g)) { + foreach ($g as $path) { + if (is_string($path) && $path !== '') { + $gallery[] = public_url($path); + } + } + } + } + + $organizer = trim((string) $r['organizer_name']) !== '' + ? $r['organizer_name'] + : trim(($r['cf'] ?? '') . ' ' . ($r['cl'] ?? '')); + + $events[] = [ + 'id' => (int) $r['id'], + 'title' => $r['title'], + 'slug' => $r['slug'], + 'status' => $r['status'], + 'scheduled' => $r['status'] === 'scheduled', + 'bookable' => $r['status'] === 'published', + 'publish_at' => $r['publish_at'], + 'description' => $r['description'], // HTML (sanificare al rendering in WP) + 'instructions' => $r['instructions'], + 'what_to_bring' => $r['what_to_bring'], + 'suitable_for' => $r['suitable_for'], + 'organizer' => $organizer !== '' ? $organizer : null, + 'starts_at' => $r['starts_at'], + 'ends_at' => $r['ends_at'], + 'address' => $r['address'], + 'lat' => $r['lat'] !== null ? (float) $r['lat'] : null, + 'lng' => $r['lng'] !== null ? (float) $r['lng'] : null, + 'cost' => (float) $r['cost'], + 'currency' => $r['currency'], + 'currency_symbol' => $symbols[$r['currency']] ?? $r['currency'], + 'color' => $r['color'], + 'cover_image' => public_url($r['cover_image']), + 'gallery' => $gallery, + 'max_seats' => $maxSeats, + 'max_per_booking' => (int) $r['max_per_booking'], + 'occupied' => $occupied, + 'remaining' => $remaining, + 'sold_out' => $remaining <= 0, + 'waitlist_enabled' => ($r['waitlist_enabled'] ?? 'N') === 'Y', + 'waiting' => (int) $r['waiting'], + ]; + } + + send_json(['ok' => true, 'count' => count($events), 'events' => $events]); +} catch (Throwable $ex) { + error_log('[api/events] ' . $ex->getMessage()); + send_json(['ok' => false, 'error' => 'Errore nel recupero degli eventi.'], 500); +} diff --git a/public/assets/images/teachers/teacher_9b5422fa1b0622aa.jpg b/public/assets/images/teachers/teacher_9b5422fa1b0622aa.jpg new file mode 100644 index 0000000..d09f13a Binary files /dev/null and b/public/assets/images/teachers/teacher_9b5422fa1b0622aa.jpg differ diff --git a/public/assets/uploads/events/20260917_070509_9baef25dd9e4.jpg b/public/assets/uploads/events/20260917_070509_9baef25dd9e4.jpg new file mode 100644 index 0000000..9e48fc9 Binary files /dev/null and b/public/assets/uploads/events/20260917_070509_9baef25dd9e4.jpg differ diff --git a/public/assets/uploads/events/20260917_071149_31f20e8129eb.jpg b/public/assets/uploads/events/20260917_071149_31f20e8129eb.jpg new file mode 100644 index 0000000..9e48fc9 Binary files /dev/null and b/public/assets/uploads/events/20260917_071149_31f20e8129eb.jpg differ diff --git a/public/assets/uploads/events/20260917_073200_4533f1a62c79.jpg b/public/assets/uploads/events/20260917_073200_4533f1a62c79.jpg new file mode 100644 index 0000000..2a26679 Binary files /dev/null and b/public/assets/uploads/events/20260917_073200_4533f1a62c79.jpg differ diff --git a/public/assets/uploads/events/20260917_073200_7b41af46191d.jpg b/public/assets/uploads/events/20260917_073200_7b41af46191d.jpg new file mode 100644 index 0000000..e1026b4 Binary files /dev/null and b/public/assets/uploads/events/20260917_073200_7b41af46191d.jpg differ diff --git a/public/assets/uploads/events/20260917_073200_af8526d8af96.jpg b/public/assets/uploads/events/20260917_073200_af8526d8af96.jpg new file mode 100644 index 0000000..e181cb6 Binary files /dev/null and b/public/assets/uploads/events/20260917_073200_af8526d8af96.jpg differ diff --git a/public/bookandgo.php b/public/bookandgo.php index ed71dd1..8ec5eb4 100644 --- a/public/bookandgo.php +++ b/public/bookandgo.php @@ -1,6 +1,7 @@ fetch_assoc()) { } $stmtUser->close(); +// Decide lo stato: pending se c'è posto, waiting se la classe è piena +$bookingStatus = decideBookingStatus($conn, $idnewbooking); + +if ($bookingStatus === 'full') { + // Classe piena e lista d'attesa disattivata: non inseriamo nulla + header("Location: userpanel.php?full=Y"); + exit(); +} + // Inserisce la nuova prenotazione in bookingclass $insertQuery = "INSERT INTO bookingclass (idserviceschedule, iduser, prevbookingstart, idprevserviceschedule, idservice, idorder, bookingstart, status) - VALUES (?, ?, NULL, '0', ?, ?, ?, 'pending')"; + VALUES (?, ?, NULL, '0', ?, ?, ?, ?)"; $stmt = $conn->prepare($insertQuery); -$stmt->bind_param("iiiis", $idnewbooking, $iduser, $idservicenew, $idorder, $newtime); +$stmt->bind_param("iiiiss", $idnewbooking, $iduser, $idservicenew, $idorder, $newtime, $bookingStatus); if (!$stmt->execute()) { $stmt->close(); die("Errore durante l'inserimento della nuova prenotazione: " . $conn->error); @@ -78,9 +88,12 @@ $stmt->close(); // Connessione $bkngstm gestita globalmente: non la chiudiamo qui +// Parametro di ritorno diverso se finito in lista d'attesa +$redirectFlag = ($bookingStatus === 'waiting') ? 'waiting=Y' : 'reprogram=Y'; + // Se non abbiamo un'email valida, salta l'invio ma completa comunque il flusso if ($emailuser === '') { - header("Location: userpanel.php?reprogram=Y"); + header("Location: userpanel.php?$redirectFlag"); exit(); } @@ -168,5 +181,5 @@ try { } // Esegui il reindirizzamento -header("Location: userpanel.php?reprogram=Y"); +header("Location: userpanel.php?$redirectFlag"); exit(); diff --git a/public/bookingpanel.php b/public/bookingpanel.php index 6858ef3..20947e7 100644 --- a/public/bookingpanel.php +++ b/public/bookingpanel.php @@ -507,7 +507,7 @@ $conn->close(); $mysqli = new mysqli($servername, $username, $password, $dbname); $eventId = $bookedclass->getColumnVal("idserviceschedule"); - $bookingQuery = "SELECT iduser FROM bookingclass WHERE bookingclass.idserviceschedule = ? AND bookingclass.status='booked'"; + $bookingQuery = "SELECT iduser FROM bookingclass WHERE bookingclass.idserviceschedule = ? AND bookingclass.status IN ('booked','pending')"; $stmt = $mysqli->prepare($bookingQuery); $stmt->bind_param("i", $eventId); $stmt->execute(); @@ -538,7 +538,7 @@ $conn->close(); 0) { ?>
-
+
@@ -548,7 +548,7 @@ $conn->close(); 0) { ?>
"> -
+

getColumnVal("servicename")); ?> -/getColumnVal("maxcapacity")); ?>

Quando:

@@ -561,7 +561,11 @@ $conn->close(); 0) { ?> - + getColumnVal("waitinglist_enabled") === 'Y') { ?> + + + +
diff --git a/public/bookingpanelorder.php b/public/bookingpanelorder.php index 4a248d0..77a9c94 100644 --- a/public/bookingpanelorder.php +++ b/public/bookingpanelorder.php @@ -742,7 +742,7 @@ $conn->close(); ?> - +
diff --git a/public/deleteorder.php b/public/deleteorder.php new file mode 100644 index 0000000..d9d34aa --- /dev/null +++ b/public/deleteorder.php @@ -0,0 +1,43 @@ +connect_error) { + die("Connessione fallita: " . $conn->connect_error); +} + +if (!isset($_GET['id']) || !is_numeric($_GET['id'])) { + header("Location: riepilogo.php?message=error_delete"); + exit(); +} + +$idorder = (int) $_GET['id']; + +$conn->begin_transaction(); + +try { + // 1. Cancella tutte le prenotazioni di questo ordine + $stmt1 = $conn->prepare("DELETE FROM bookingclass WHERE idorder = ?"); + $stmt1->bind_param("i", $idorder); + $stmt1->execute(); + $stmt1->close(); + + // 2. Cancella l'ordine + $stmt2 = $conn->prepare("DELETE FROM orderbook WHERE idorderbook = ?"); + $stmt2->bind_param("i", $idorder); + $stmt2->execute(); + $stmt2->close(); + + $conn->commit(); + header("Location: riepilogo.php?message=success_delete"); + exit(); +} catch (Exception $e) { + $conn->rollback(); + header("Location: riepilogo.php?message=error_delete"); + exit(); +} + +$conn->close(); diff --git a/public/event_delete.php b/public/event_delete.php new file mode 100644 index 0000000..54a0509 --- /dev/null +++ b/public/event_delete.php @@ -0,0 +1,58 @@ +getConnection(); + +$id = isset($_GET['id']) ? (int) $_GET['id'] : 0; +$notify = (int) ($_GET['notify'] ?? 0) === 1; + +if ($id <= 0) { + header('Location: events.php'); + exit(); +} + +$stmt = $pdo->prepare("SELECT * FROM events WHERE id = ?"); +$stmt->execute([$id]); +$event = $stmt->fetch(PDO::FETCH_ASSOC); +if (!$event) { + header('Location: events.php?message=error'); + exit(); +} + +// 1) Recupera i partecipanti attivi PRIMA di annullare (servono per la mail) +$pStmt = $pdo->prepare("SELECT * FROM event_bookings WHERE event_id = ? AND status <> 'cancelled'"); +$pStmt->execute([$id]); +$participants = $pStmt->fetchAll(PDO::FETCH_ASSOC); + +try { + // 2) Annulla l'evento (soft) e le relative prenotazioni + $pdo->prepare("UPDATE events SET status = 'cancelled', updated_at = NOW() WHERE id = ?")->execute([$id]); + $pdo->prepare("UPDATE event_bookings SET status = 'cancelled', updated_at = NOW() WHERE event_id = ? AND status <> 'cancelled'")->execute([$id]); + + // 3) Notifica (opzionale). Il template vero arriva nello step successivo. + if ($notify && !empty($participants)) { + foreach ($participants as $p) { + try { + yse_mail_event_cancel($p, $event); + } catch (Throwable $mailEx) { + error_log('[event_delete mail] ' . $mailEx->getMessage()); + } + } + } + + header('Location: events.php?message=cancelled'); + exit(); +} catch (Throwable $ex) { + error_log('[event_delete] ' . $ex->getMessage()); + header('Location: events.php?message=error'); + exit(); +} diff --git a/public/event_form.php b/public/event_form.php new file mode 100644 index 0000000..96d79fb --- /dev/null +++ b/public/event_form.php @@ -0,0 +1,964 @@ + nuovo evento + * - event_form.php?id=123 -> modifica evento esistente + * Invia a event_save.php (insert/update + redirect). + * + * Extra rispetto alla v1: + * - flatpickr per data/ora (fine = inizio + 1h automatica) + * - sede da event_locations (default scuola) o indirizzo personalizzato + * - "Calcola coordinate" da indirizzo (geocoding via event_geocode.php) + * - copertina drag&drop -> event_upload_cover.php -> assets/uploads/events/ + */ +$pdo = DBHandlerSelect::getInstance()->getConnection(); + +function e($value): string +{ + return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); +} + +$id = isset($_GET['id']) ? (int) $_GET['id'] : 0; +$ev = null; + +if ($id > 0) { + $stmt = $pdo->prepare("SELECT * FROM events WHERE id = ?"); + $stmt->execute([$id]); + $ev = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$ev) { + header('Location: events.php?message=error'); + exit(); + } +} + +// Default dal record unico di `option` +$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 = []; +try { + $locations = $pdo->query("SELECT * FROM event_locations ORDER BY is_default DESC, name ASC")->fetchAll(PDO::FETCH_ASSOC); +} catch (Throwable $e) { + $locations = []; +} + +$val = [ + 'title' => $ev['title'] ?? '', + 'slug' => $ev['slug'] ?? '', + 'organizer_name' => $ev['organizer_name'] ?? '', + 'starts_at' => !empty($ev['starts_at']) ? date('Y-m-d H:i', strtotime($ev['starts_at'])) : '', + 'ends_at' => !empty($ev['ends_at']) ? date('Y-m-d H:i', strtotime($ev['ends_at'])) : '', + 'address' => $ev['address'] ?? '', + 'lat' => $ev['lat'] ?? '', + 'lng' => $ev['lng'] ?? '', + 'cost' => $ev['cost'] ?? '0.00', + 'currency' => $ev['currency'] ?? 'EUR', + 'max_seats' => $ev['max_seats'] ?? '', + 'max_per_booking' => $ev['max_per_booking'] ?? $defaultMaxPerBooking, + 'color' => $ev['color'] ?? $defaultColor, + 'cover_image' => $ev['cover_image'] ?? '', + 'status' => $ev['status'] ?? 'draft', + 'description' => $ev['description'] ?? '', + 'instructions' => $ev['instructions'] ?? '', + 'what_to_bring' => $ev['what_to_bring'] ?? '', + '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'] ?? '', + 'schedule_enabled' => (($ev['status'] ?? '') === 'scheduled') || !empty($ev['publish_at']), + 'publish_at' => !empty($ev['publish_at']) ? date('Y-m-d H:i', strtotime($ev['publish_at'])) : '', +]; + +$isEdit = $id > 0; +$pageTitle = $isEdit ? 'Modifica evento' : 'Nuovo evento'; +?> + + + + + + YogiBook - <?php echo e($pageTitle); ?> + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+ +
+
+ +
+
+
+ +
+
+
+
+
+
+
+
+

Compila i dati dell'evento. I campi con * sono obbligatori.

+ +
+ + +
+
+ + +
+
+ + +
URL pubblico del widget. Vuoto = generato dal titolo.
+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+
Per un indirizzo personalizzato, premi "Calcola" per ottenere le coordinate mappa.
+
+ +
+ +
+ + +
+
Premi "Calcola coordinate" per ottenere lat/lng dall'indirizzo.
+
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+ +
Testo formattato, elenchi, link e immagini.
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+

Questi campi sono facoltativi e verranno usati anche nella card pubblica e nelle email.

+ + +
+ +
+
+ +

Trascina qui l'immagine oppure sfoglia

+ JPG, PNG, WEBP o GIF · max 5 MB +
+
+ Copertina +
+
+ + +
+ +
Immagine principale mostrata nella card.
+
+ + +
+ + + + +
+ +
+
+ + +
0 = gratuito.
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ > + +
+
+
+ + +
+
+ +
+ > + +
+
+
+ + +
+
+

Se attivo, l'evento resta "Programmato" (visibile ma non prenotabile) finché il cron non lo apre alla data indicata.

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

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

+ +
+ Indietro + +
+
+
+
+
+
+
+ +
+
+
+ + + + + + + + + + + \ No newline at end of file diff --git a/public/event_geocode.php b/public/event_geocode.php new file mode 100644 index 0000000..e108585 --- /dev/null +++ b/public/event_geocode.php @@ -0,0 +1,61 @@ + coordinate. Proxy server-side verso OpenStreetMap / + * Nominatim (gratuito, senza API key). Ritorna JSON: + * { ok:true, lat:45.66, lng:8.79, display:"..." } oppure { ok:false, error:"..." } + * + * Nota: Nominatim chiede max 1 richiesta/secondo e uno User-Agent identificativo + * (impostato sotto). Quando avrai la chiave Google Maps si può passare alla + * Geocoding API di Google per maggiore precisione, cambiando solo questo file. + */ +if (function_exists('ob_get_level') && ob_get_level()) { + ob_clean(); +} +header('Content-Type: application/json; charset=utf-8'); + +$q = trim($_GET['q'] ?? ''); +if ($q === '') { + echo json_encode(['ok' => false, 'error' => 'Indirizzo mancante.']); + exit(); +} + +$url = 'https://nominatim.openstreetmap.org/search?format=json&limit=1&addressdetails=0&q=' . rawurlencode($q); +$ua = 'YogiBook/1.0 (info@yogasoul.it)'; +$json = false; + +if (function_exists('curl_init')) { + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_USERAGENT => $ua, + CURLOPT_TIMEOUT => 8, + CURLOPT_FOLLOWLOCATION => true, + ]); + $json = curl_exec($ch); + curl_close($ch); +} + +if ($json === false) { + $ctx = stream_context_create(['http' => [ + 'method' => 'GET', + 'header' => "User-Agent: {$ua}\r\n", + 'timeout' => 8, + ]]); + $json = @file_get_contents($url, false, $ctx); +} + +$data = $json ? json_decode($json, true) : null; + +if (!empty($data[0]['lat']) && !empty($data[0]['lon'])) { + echo json_encode([ + 'ok' => true, + 'lat' => round((float) $data[0]['lat'], 7), + 'lng' => round((float) $data[0]['lon'], 7), + 'display' => $data[0]['display_name'] ?? '', + ]); +} else { + echo json_encode(['ok' => false, 'error' => 'Indirizzo non trovato. Prova a essere più specifico.']); +} +exit(); diff --git a/public/event_locations.php b/public/event_locations.php new file mode 100644 index 0000000..d7dbac9 --- /dev/null +++ b/public/event_locations.php @@ -0,0 +1,471 @@ + elenco + form nuovo + * - event_locations.php?edit=ID -> form in modifica + * - event_locations.php?delete=ID -> elimina (con conferma via modale) + * - event_locations.php?makedefault=ID -> imposta predefinita + * Il salvataggio (insert/update) passa da event_locations_save.php. + */ +$pdo = DBHandlerSelect::getInstance()->getConnection(); + +function e($value): string +{ + return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); +} + +// Azioni inline (prima di ogni output) +if (isset($_GET['delete'])) { + $delId = (int) $_GET['delete']; + $pdo->prepare("DELETE FROM event_locations WHERE id = ?")->execute([$delId]); + header('Location: event_locations.php?message=deleted'); + exit(); +} +if (isset($_GET['makedefault'])) { + $defId = (int) $_GET['makedefault']; + // Una sola predefinita: Y su questa, N su tutte le altre + $pdo->prepare("UPDATE event_locations SET is_default = IF(id = ?, 'Y', 'N'), updated_at = NOW()")->execute([$defId]); + header('Location: event_locations.php?message=default'); + exit(); +} + +$editId = isset($_GET['edit']) ? (int) $_GET['edit'] : 0; +$edit = null; +if ($editId > 0) { + $st = $pdo->prepare("SELECT * FROM event_locations WHERE id = ?"); + $st->execute([$editId]); + $edit = $st->fetch(PDO::FETCH_ASSOC); +} + +$tableMissing = false; +$locations = []; +try { + $locations = $pdo->query("SELECT * FROM event_locations ORDER BY is_default DESC, name ASC")->fetchAll(PDO::FETCH_ASSOC); +} catch (Throwable $ex) { + $tableMissing = true; +} + +$message = $_GET['message'] ?? ''; + +$val = [ + 'id' => $edit['id'] ?? 0, + 'name' => $edit['name'] ?? '', + 'address' => $edit['address'] ?? '', + 'lat' => $edit['lat'] ?? '', + 'lng' => $edit['lng'] ?? '', + 'is_default' => ($edit['is_default'] ?? 'N') === 'Y', +]; +$isEdit = $editId > 0 && $edit; +?> + + + + + + YogiBook - Sedi + + + + + + + + + + + + + +
+
+ +
+ +
+ +
+
+ +
+
+
+ +
+
+
+ + + + + + + + + + + + + + + +
+ +
+
+
+
+

Le sedi salvate compaiono nella tendina "Sede" del form evento.

+ +
+ + +
+ + +
+ +
+ +
+ + +
+
Premi "Calcola" per ottenere le coordinate dall'indirizzo.
+
+ +
+
+ + +
+
+ + +
+
+ +
+ > + +
+ +
+ + + Annulla + +
+
+
+
+
+ + +
+
+
+
Sedi salvate
+

Rubrica delle sedi riutilizzabili.

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + +
SedeIndirizzoCoordinateAzioni
+
+ + Nessuna sede salvata. Aggiungine una dal form a sinistra. +
+
+ + + Predefinita + + —'; ?> + Modifica + + Predefinita + + +
+
+
+
+
+
+ +
+ +
+
+
+ + + + + + + + + + + \ No newline at end of file diff --git a/public/event_locations_save.php b/public/event_locations_save.php new file mode 100644 index 0000000..4c61683 --- /dev/null +++ b/public/event_locations_save.php @@ -0,0 +1,67 @@ +getConnection(); + +$id = isset($_POST['id']) ? (int) $_POST['id'] : 0; +$name = trim($_POST['name'] ?? ''); +$address = trim($_POST['address'] ?? ''); +$isDefault = (isset($_POST['is_default']) && $_POST['is_default'] === 'Y') ? 'Y' : 'N'; + +$lat = ($_POST['lat'] ?? '') !== '' ? (float) $_POST['lat'] : null; +$lng = ($_POST['lng'] ?? '') !== '' ? (float) $_POST['lng'] : null; + +if ($name === '' || $address === '') { + header('Location: event_locations.php?message=error'); + exit(); +} + +try { + if ($id > 0) { + $stmt = $pdo->prepare("UPDATE event_locations + SET name = :name, address = :address, lat = :lat, lng = :lng, + is_default = :is_default, updated_at = NOW() + WHERE id = :id"); + $stmt->execute([ + ':name' => $name, + ':address' => $address, + ':lat' => $lat, + ':lng' => $lng, + ':is_default' => $isDefault, + ':id' => $id, + ]); + $savedId = $id; + } else { + $stmt = $pdo->prepare("INSERT INTO event_locations (name, address, lat, lng, is_default, created_at, updated_at) + VALUES (:name, :address, :lat, :lng, :is_default, NOW(), NOW())"); + $stmt->execute([ + ':name' => $name, + ':address' => $address, + ':lat' => $lat, + ':lng' => $lng, + ':is_default' => $isDefault, + ]); + $savedId = (int) $pdo->lastInsertId(); + } + + // Una sola predefinita + if ($isDefault === 'Y') { + $pdo->prepare("UPDATE event_locations SET is_default = IF(id = ?, 'Y', 'N'), updated_at = NOW()")->execute([$savedId]); + } + + header('Location: event_locations.php?message=success'); + exit(); +} catch (Throwable $ex) { + error_log('[event_locations_save] ' . $ex->getMessage()); + header('Location: event_locations.php?message=error'); + exit(); +} diff --git a/public/event_options.php b/public/event_options.php new file mode 100644 index 0000000..390b76b --- /dev/null +++ b/public/event_options.php @@ -0,0 +1,249 @@ +getConnection(); + +function e($value): string +{ + return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); +} + +$opt = $pdo->query("SELECT * FROM `option` LIMIT 1")->fetch(PDO::FETCH_ASSOC) ?: []; +$message = $_GET['message'] ?? ''; + +$ccEmail = $opt['event_notify_email'] ?? ''; +$reminderH = (int) ($opt['event_reminder_hours'] ?? 24); +$offerH = (int) ($opt['event_offer_expiry_hours'] ?? 24); +function isY($opt, $k) +{ + return ($opt[$k] ?? 'N') === 'Y'; +} + +$types = [ + ['event_cc_booking', 'Prenotazione', 'Quando qualcuno prenota (o entra in lista d\'attesa).'], + ['event_cc_promotion', 'Promozione lista d\'attesa', 'Quando una persona in attesa viene promossa.'], + ['event_cc_user_cancel', 'Disdetta utente', 'Quando un iscritto annulla la sua prenotazione.'], + ['event_cc_event_cancel', 'Annullo evento', 'Quando annulli un intero evento (a tutti gli iscritti).'], + ['event_cc_reminder', 'Promemoria', 'Il promemoria inviato prima dell\'evento.'], +]; +?> + + + + + + YogiBook - 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 new file mode 100644 index 0000000..e581972 --- /dev/null +++ b/public/event_participants.php @@ -0,0 +1,489 @@ +getConnection(); + +function e($value): string +{ + return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); +} + +function initials(string $first, string $last): string +{ + $ini = strtoupper(mb_substr(trim($first), 0, 1) . mb_substr(trim($last), 0, 1)); + return $ini !== '' ? $ini : '?'; +} + +function avatarColor(string $seed): string +{ + $palette = ['#1ebf73', '#2980b9', '#8e44ad', '#e67e22', '#16a085', '#c0392b', '#2c3e50', '#d35400']; + return $palette[abs(crc32($seed)) % count($palette)]; +} + +$eventId = isset($_GET['id']) ? (int) $_GET['id'] : 0; +if ($eventId <= 0) { + header('Location: events.php'); + exit(); +} + +// Annullamento singola iscrizione (deve avvenire prima di ogni output) +if (isset($_GET['cancel_booking'])) { + $bookingId = (int) $_GET['cancel_booking']; + $promoted = []; + try { + $pdo->beginTransaction(); + $pdo->prepare("SELECT id FROM events WHERE id = ? FOR UPDATE")->execute([$eventId]); + + $bl = $pdo->prepare("SELECT status FROM event_bookings WHERE id = ? AND event_id = ?"); + $bl->execute([$bookingId, $eventId]); + $bStatus = $bl->fetchColumn(); + + if ($bStatus !== false && $bStatus !== 'cancelled') { + $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 -> 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(); + } + error_log('[participants cancel] ' . $ex->getMessage()); + } + header('Location: event_participants.php?id=' . $eventId . '&message=booking_cancelled'); + 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(); +} + +$bStmt = $pdo->prepare("SELECT * FROM event_bookings WHERE event_id = ? ORDER BY created_at DESC, id DESC"); +$bStmt->execute([$eventId]); +$bookings = $bStmt->fetchAll(PDO::FETCH_ASSOC); + +// 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', 'offered'], true)) { + $bookedSeats += (int) $b['seats']; + $activeCount++; + $revenue += (float) $b['amount']; + } elseif ($b['status'] === 'waiting') { + $waitingSeats += (int) $b['seats']; + } +} +$maxSeats = (int) $event['max_seats']; +$message = $_GET['message'] ?? ''; + +function bookingBadge(string $status): array +{ + switch ($status) { + case 'confirmed': + return ['Confermata', '#e7f7ee', '#1a7f52']; + case 'cancelled': + return ['Annullata', '#fdecea', '#c0392b']; + case 'waiting': + return ["Lista d'attesa", '#eef2fb', '#3a5bb8']; + case 'offered': + return ['Da riconfermare', '#fff4e5', '#b26a00']; + default: + return ['In attesa', '#fff4e5', '#b26a00']; + } +} +?> + + + + + + YogiBook - Partecipanti + + + + + + + + + + + + + +
+
+ +
+ +
+ +
+
+ +
+
+
+ +
+
+
+ + + + + +
+
+
+
+
+
+
Partecipanti
+

+ + · +

+
+ Tutti gli eventi +
+ +
+
+
+
/
+
Posti prenotati
+
+
+
+
+
+
In lista d'attesa
+
+
+
+
+
+
Iscrizioni attive
+
+
+
+
+
+
Incasso stimato
+
+
+
+
+
+
+
+ +
+
+
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PartecipanteEmailTelefonoPostiMarketingIscritto ilStatoAzioni
+
+ + Ancora nessun iscritto a questo evento. +
+
+
+
+ +
+ + ospite + +
+
+ + Sì + + No + + + + + + — + +
+
+
+
+
+
+ +
+ +
+
+
+ + + + + + + + + + + \ No newline at end of file diff --git a/public/event_preview.php b/public/event_preview.php new file mode 100644 index 0000000..f320dcd --- /dev/null +++ b/public/event_preview.php @@ -0,0 +1,1031 @@ +getConnection(); + +function e($value): string +{ + return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); +} + +$id = isset($_GET['id']) ? (int) $_GET['id'] : 0; +if ($id <= 0) { + http_response_code(404); + echo 'Evento non trovato'; + exit(); +} + +$st = $pdo->prepare("SELECT e.*, u.first_name AS cf, u.last_name AS cl + FROM events e LEFT JOIN auth_users u ON u.id = e.user_id + WHERE e.id = ?"); +$st->execute([$id]); +$ev = $st->fetch(PDO::FETCH_ASSOC); +if (!$ev) { + http_response_code(404); + echo 'Evento non trovato'; + exit(); +} + +$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']; +$remaining = max(0, $maxSeats - $booked); +$soldOut = $remaining <= 0; +$maxPer = max(1, (int) $ev['max_per_booking']); +$waitlistOn = ($ev['waitlist_enabled'] ?? 'N') === 'Y'; +$waitMode = $soldOut && $waitlistOn; // pieno ma con lista d'attesa +$hardFull = $soldOut && !$waitlistOn; // pieno senza lista d'attesa +$maxQty = $soldOut ? ($waitMode ? $maxPer : 0) : max(1, min($maxPer, $remaining)); + +$cost = (float) $ev['cost']; +$curr = $ev['currency'] ?: 'EUR'; +$symbols = ['EUR' => '€', 'USD' => '$', 'GBP' => '£', 'CHF' => 'CHF']; +$sym = $symbols[$curr] ?? $curr; +$priceLabel = $cost > 0 ? $sym . number_format($cost, 2, ',', '.') : 'Gratuito'; + +$color = (isset($ev['color']) && preg_match('/^#[0-9a-fA-F]{6}$/', $ev['color'])) ? $ev['color'] : '#4CAF50'; + +$gallery = []; +if (!empty($ev['gallery'])) { + $g = json_decode($ev['gallery'], true); + if (is_array($g)) { + $gallery = $g; + } +} + +$mesiAbbr = [1 => 'GEN', 2 => 'FEB', 3 => 'MAR', 4 => 'APR', 5 => 'MAG', 6 => 'GIU', 7 => 'LUG', 8 => 'AGO', 9 => 'SET', 10 => 'OTT', 11 => 'NOV', 12 => 'DIC']; +$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']; + +$startTs = strtotime($ev['starts_at']); +$dayNum = (int) date('j', $startTs); +$monAbbr = $mesiAbbr[(int) date('n', $startTs)]; +$dateLong = $dayNum . ' ' . $mesi[(int) date('n', $startTs)] . ' ' . date('Y', $startTs); +$timeLabel = date('H:i', $startTs); +if (!empty($ev['ends_at'])) { + $timeLabel .= '–' . date('H:i', strtotime($ev['ends_at'])); +} + +$organizer = trim((string) $ev['organizer_name']) !== '' + ? $ev['organizer_name'] + : trim(($ev['cf'] ?? '') . ' ' . ($ev['cl'] ?? '')); + +$mapQuery = trim((string) $ev['address']) !== '' + ? $ev['address'] + : (($ev['lat'] !== null && $ev['lng'] !== null) ? ($ev['lat'] . ',' . $ev['lng']) : ''); + +$hasDetails = trim((string) $ev['description']) !== '' + || trim((string) $ev['suitable_for']) !== '' + || trim((string) $ev['what_to_bring']) !== '' + || trim((string) $ev['instructions']) !== '' + || !empty($ev['cover_image']) + || !empty($gallery) + || $mapQuery !== ''; + +function textBlock(?string $t): string +{ + return nl2br(e((string) $t)); +} +?> + + + + + + <?php echo e($ev['title']); ?> — Anteprima + + + + + + + + + +
Anteprima — la card parte compatta; “Dettagli” apre la scheda completa. Il pulsante non invia nulla.
+ +
+
+ + +
+
+
+
+
+ + <?php echo e($ev['title']); ?> + +
+
+

+ + + +
+
+
+ + + + + Capacità / +
+ +
+ + + + + +
+ +
+ + + + + · +
+
+
+
+
+ + + +
+
+ + <?php echo e($ev['title']); ?> + +
+ + + +
+ +
+ +
+ +
+
+
+ + + +
+
+ +
+
+ + Per chi è adatto
+
+
+ + +
+
+ + + Cosa portare
+
+
+ + +
+
+ + + + Istruzioni
+
+
+ +
+
+ + + +
+

+ + + + Galleria

+ +
+ + + +
+

+ + + Dove

+ +
+ +
+
+ + + +
+ +
Evento esaurito — non ci sono più posti disponibili.
+ + +
Posti esauriti — puoi iscriverti alla lista d'attesa: ti avviseremo se si libera un posto.
+ +
+
+
+
Persone
+
+ + 1 + +
+
+
+
Totale
+
+
+ +
+ +
+
+
+ + +
+
+ + +
+
+
+ + +
+ + + +
+
+ +
+
+ +
+ + + + + + + \ No newline at end of file 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 new file mode 100644 index 0000000..7015048 --- /dev/null +++ b/public/event_save.php @@ -0,0 +1,214 @@ +getConnection(); + +// --- ID insegnante loggato ------------------------------------------------- +// headscript.php popola $iduserlogin da Auth::user()->present()->id +$currentUserId = $iduserlogin ?? ($_SESSION['iduserlogin'] ?? null); + +// --- Helper ---------------------------------------------------------------- +function slugify(string $text): string +{ + $text = trim($text); + if (function_exists('iconv')) { + $conv = @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $text); + if ($conv !== false) { + $text = $conv; + } + } + $text = strtolower($text); + $text = preg_replace('/[^a-z0-9]+/', '-', $text); + $text = trim($text, '-'); + return $text !== '' ? $text : 'evento'; +} + +function uniqueSlug(PDO $pdo, string $base, int $excludeId): string +{ + $slug = $base; + $i = 2; + while (true) { + if ($excludeId > 0) { + $st = $pdo->prepare("SELECT COUNT(*) FROM events WHERE slug = ? AND id <> ?"); + $st->execute([$slug, $excludeId]); + } else { + $st = $pdo->prepare("SELECT COUNT(*) FROM events WHERE slug = ?"); + $st->execute([$slug]); + } + if ((int) $st->fetchColumn() === 0) { + return $slug; + } + $slug = $base . '-' . $i; + $i++; + } +} + +function toDateTime(?string $v): ?string +{ + if (!$v) { + return null; + } + $v = str_replace('T', ' ', $v); + $ts = strtotime($v); + return $ts ? date('Y-m-d H:i:s', $ts) : null; +} + +// --- Lettura e validazione input ------------------------------------------- +$id = isset($_POST['id']) ? (int) $_POST['id'] : 0; +$title = trim($_POST['title'] ?? ''); + +$startsAt = toDateTime($_POST['starts_at'] ?? null); +$maxSeats = (int) ($_POST['max_seats'] ?? 0); + +$redirectOnError = $id > 0 ? "event_form.php?id={$id}&message=error" : "event_form.php?message=error"; +if ($title === '' || $startsAt === null || $maxSeats < 1) { + header("Location: {$redirectOnError}"); + exit(); +} + +$slugInput = trim($_POST['slug'] ?? ''); +$slugBase = slugify($slugInput !== '' ? $slugInput : $title); +$slug = uniqueSlug($pdo, $slugBase, $id); + +$endsAt = toDateTime($_POST['ends_at'] ?? null); +$address = trim($_POST['address'] ?? ''); +$organizer = trim($_POST['organizer_name'] ?? ''); +$description = $_POST['description'] ?? ''; +$cost = round((float) ($_POST['cost'] ?? 0), 2); +$maxPerBooking = max(1, (int) ($_POST['max_per_booking'] ?? 1)); +$coverImage = trim($_POST['cover_image'] ?? ''); + +$lat = ($_POST['lat'] ?? '') !== '' ? (float) $_POST['lat'] : null; +$lng = ($_POST['lng'] ?? '') !== '' ? (float) $_POST['lng'] : null; + +$currency = strtoupper(trim($_POST['currency'] ?? 'EUR')); +if (!in_array($currency, ['EUR', 'USD', 'GBP', 'CHF'], true)) { + $currency = 'EUR'; +} + +$color = trim($_POST['color'] ?? '#4CAF50'); +if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) { + $color = '#4CAF50'; +} + +$status = $_POST['status'] ?? 'draft'; +if (!in_array($status, ['draft', 'published', 'cancelled'], true)) { + $status = 'draft'; +} + +// Apertura programmata: se attiva con data futura -> stato 'scheduled' + publish_at +$scheduleEnabled = isset($_POST['schedule_enabled']) && $_POST['schedule_enabled'] === 'Y'; +$publishAt = toDateTime($_POST['publish_at'] ?? null); +if ($scheduleEnabled && $publishAt && strtotime($publishAt) > time()) { + $status = 'scheduled'; +} else { + $publishAt = null; // niente programmazione (o data già passata) +} + +// Campi facoltativi (testo) +$instructions = trim($_POST['instructions'] ?? ''); +$whatToBring = trim($_POST['what_to_bring'] ?? ''); +$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'] ?? ''; +if ($galleryRaw !== '') { + $decoded = json_decode($galleryRaw, true); + if (is_array($decoded)) { + $clean = []; + foreach ($decoded as $p) { + if (is_string($p) && $p !== '') { + $clean[] = $p; + } + } + $gallery = !empty($clean) ? json_encode(array_values($clean), JSON_UNESCAPED_SLASHES) : null; + } +} + +// --- Insert / Update ------------------------------------------------------- +try { + if ($id > 0) { + $sql = "UPDATE events SET + title = :title, slug = :slug, description = :description, + instructions = :instructions, what_to_bring = :what_to_bring, suitable_for = :suitable_for, + organizer_name = :organizer_name, starts_at = :starts_at, ends_at = :ends_at, + 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, publish_at = :publish_at, updated_at = NOW() + WHERE id = :id"; + $params = [':id' => $id]; + } else { + $sql = "INSERT INTO events + (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, publish_at, 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, :publish_at, NOW(), NOW())"; + $params = [':user_id' => $currentUserId]; + } + + $params += [ + ':title' => $title, + ':slug' => $slug, + ':description' => $description !== '' ? $description : null, + ':instructions' => $instructions !== '' ? $instructions : null, + ':what_to_bring' => $whatToBring !== '' ? $whatToBring : null, + ':suitable_for' => $suitableFor !== '' ? $suitableFor : null, + ':organizer_name' => $organizer !== '' ? $organizer : null, + ':starts_at' => $startsAt, + ':ends_at' => $endsAt, + ':address' => $address !== '' ? $address : null, + ':lat' => $lat, + ':lng' => $lng, + ':cost' => $cost, + ':currency' => $currency, + ':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, + ':publish_at' => $publishAt, + ':status' => $status, + ]; + + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + + header('Location: events.php?message=success'); + exit(); +} catch (Throwable $ex) { + error_log('[event_save] ' . $ex->getMessage()); + header("Location: {$redirectOnError}"); + exit(); +} diff --git a/public/event_upload_cover.php b/public/event_upload_cover.php new file mode 100644 index 0000000..b1c436d --- /dev/null +++ b/public/event_upload_cover.php @@ -0,0 +1,62 @@ + false, 'error' => $msg]); + exit(); +}; + +if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !isset($_FILES['cover'])) { + $fail('Richiesta non valida.'); +} + +$file = $_FILES['cover']; +if ($file['error'] !== UPLOAD_ERR_OK) { + $fail('Errore nel caricamento (codice ' . (int) $file['error'] . ').'); +} +if ($file['size'] > 5 * 1024 * 1024) { + $fail("L'immagine supera i 5 MB."); +} + +// Validazione MIME reale (non ci si fida dell'estensione) +$allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp', 'image/gif' => 'gif']; +$info = @getimagesize($file['tmp_name']); +$mime = $info['mime'] ?? ''; +if (!isset($allowed[$mime])) { + $fail('Formato non supportato. Usa JPG, PNG, WEBP o GIF.'); +} +$ext = $allowed[$mime]; + +$relDir = 'assets/uploads/events'; +$absDir = __DIR__ . '/' . $relDir; +if (!is_dir($absDir)) { + @mkdir($absDir, 0775, true); +} +if (!is_dir($absDir) || !is_writable($absDir)) { + $fail('Cartella non scrivibile: ' . $relDir . ' (crea la cartella e dai permessi di scrittura).'); +} + +try { + $rand = bin2hex(random_bytes(6)); +} catch (Throwable $e) { + $rand = uniqid('', true); +} +$name = date('Ymd_His') . '_' . $rand . '.' . $ext; +$dest = $absDir . '/' . $name; + +if (!move_uploaded_file($file['tmp_name'], $dest)) { + $fail('Impossibile salvare il file.'); +} + +echo json_encode(['ok' => true, 'path' => $relDir . '/' . $name]); +exit(); diff --git a/public/event_waitlist.php b/public/event_waitlist.php new file mode 100644 index 0000000..a493cc8 --- /dev/null +++ b/public/event_waitlist.php @@ -0,0 +1,59 @@ + 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')"); + $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 = []; + $upd = $pdo->prepare("UPDATE event_bookings SET status = 'confirmed', updated_at = NOW() WHERE id = ?"); + foreach ($waiting as $w) { + $need = (int) $w['seats']; + if ($need <= $free) { + $upd->execute([$w['id']]); + $free -= $need; + $w['status'] = 'confirmed'; + $promoted[] = $w; + if ($free <= 0) { + break; + } + } else { + break; // rispetta la coda + } + } + return $promoted; + } +} diff --git a/public/events.php b/public/events.php new file mode 100644 index 0000000..c57ce63 --- /dev/null +++ b/public/events.php @@ -0,0 +1,523 @@ + event_participants.php + * - Modifica -> event_form.php?id= + * - Annulla -> modale (SweetAlert2) che chiede se inviare la mail di + * annullamento a tutti i partecipanti, poi event_delete.php + */ +$pdo = DBHandlerSelect::getInstance()->getConnection(); + +function e($value): string +{ + return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); +} + +$message = $_GET['message'] ?? ''; + +// Posti prenotati = SOMMA dei posti delle prenotazioni non annullate. +$sql = "SELECT e.*, + COALESCE(b.booked, 0) AS booked, + COALESCE(b.bookings_count, 0) AS bookings_count, + COALESCE(w.waiting, 0) AS waiting, + u.first_name AS creator_first, + u.last_name AS creator_last + FROM events e + LEFT JOIN ( + SELECT event_id, + SUM(seats) AS booked, + COUNT(*) AS bookings_count + FROM event_bookings + WHERE status IN ('confirmed','pending','offered') + GROUP BY event_id + ) b ON b.event_id = e.id + LEFT JOIN ( + SELECT event_id, SUM(seats) AS waiting + FROM event_bookings + WHERE status = 'waiting' + GROUP BY event_id + ) w ON w.event_id = e.id + LEFT JOIN auth_users u ON u.id = e.user_id + ORDER BY e.starts_at ASC"; +$events = $pdo->query($sql)->fetchAll(PDO::FETCH_ASSOC); + +$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' +]; + +function dataItaliana(string $dt, array $mesi): string +{ + $t = strtotime($dt); + return date('j', $t) . ' ' . $mesi[(int) date('n', $t)] . ' ' . date('Y', $t); +} + +/** @return array [label, bgColor, textColor] */ +function statusBadge(string $status): array +{ + switch ($status) { + case 'published': + return ['Aperto', '#e7f7ee', '#1a7f52']; + case 'scheduled': + return ['Programmato', '#eef2fb', '#3a5bb8']; + case 'cancelled': + return ['Annullato', '#fdecea', '#c0392b']; + default: + return ['Bozza', '#eef0f3', '#6b7280']; + } +} +?> + + + + + + YogiBook - Eventi + + + + + + + + + + + + + + +
+
+ +
+ +
+ +
+
+ +
+
+
+ +
+
+
+ + + + + + + + + +
+
+
+
+
+
+
Eventi
+

Ciao , gestisci qui i tuoi eventi: crea, modifica, vedi i partecipanti o annulla.

+
+ Nuovo evento +
+ +
+ + + + + + + + + + + + + + + + + + + + + 0 && $booked >= $maxSeats; + [$stLabel, $stBg, $stColor] = statusBadge($ev['status']); + + $timeLabel = date('H:i', strtotime($ev['starts_at'])); + if (!empty($ev['ends_at'])) { + $timeLabel .= ' - ' . date('H:i', strtotime($ev['ends_at'])); + } + ?> + + + + + + + + + + + +
OrarioNomeOrganizzatorePrenotatoStatoAzioni
+
+ + Nessun evento ancora. Crea il primo con "Nuovo evento". +
+
+
+ + + + + + + ID +
+
+ + / + + 0) : ?> + + in lista + + + + + + Anteprima + Partecipanti + Modifica + time()) : ?> + Promemoria + + + + +
+
+
+
+
+
+ +
+ +
+
+
+ + + + + + + + + + + \ No newline at end of file diff --git a/public/include/event_mailer.php b/public/include/event_mailer.php new file mode 100644 index 0000000..40d3368 --- /dev/null +++ b/public/include/event_mailer.php @@ -0,0 +1,299 @@ +getConnection(); + $row = $pdo->query("SELECT * FROM `option` LIMIT 1")->fetch(PDO::FETCH_ASSOC) ?: []; + } catch (Throwable $e) { + $row = []; + } + $opt = [ + 'cc_email' => trim((string) ($row['event_notify_email'] ?? '')), + 'cc' => [ + 'booking' => ($row['event_cc_booking'] ?? 'N') === 'Y', + 'user_cancel' => ($row['event_cc_user_cancel'] ?? 'N') === 'Y', + 'event_cancel' => ($row['event_cc_event_cancel'] ?? 'N') === 'Y', + 'reminder' => ($row['event_cc_reminder'] ?? 'N') === 'Y', + 'promotion' => ($row['event_cc_promotion'] ?? 'N') === 'Y', + ], + ]; + return $opt; +} + +/* ---------- Invio base ---------- */ +function yse_send(string $to, string $toName, string $subject, string $html, bool $ccSchool): bool +{ + $env = $_ENV; + try { + $mail = new \PHPMailer\PHPMailer\PHPMailer(true); + $mail->isSMTP(); + $mail->Host = $env['MAIL_HOST'] ?? ''; + $mail->SMTPAuth = true; + $mail->Username = $env['MAIL_USERNAME'] ?? ''; + $mail->Password = $env['MAIL_PASSWORD'] ?? ''; + $enc = strtolower($env['MAIL_ENCRYPTION'] ?? 'ssl'); + $mail->SMTPSecure = ($enc === 'tls') + ? \PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_STARTTLS + : \PHPMailer\PHPMailer\PHPMailer::ENCRYPTION_SMTPS; + $mail->Port = (int) ($env['MAIL_PORT'] ?? 465); + $mail->CharSet = 'UTF-8'; + + $mail->setFrom($env['MAIL_FROM_ADDRESS'] ?? 'info@yogasoul.it', $env['MAIL_FROM_NAME'] ?? 'YogiBook'); + $mail->addAddress($to, $toName); + + if ($ccSchool) { + $ccEmail = yse_mail_options()['cc_email']; + if ($ccEmail !== '' && strcasecmp($ccEmail, $to) !== 0) { + $mail->addCC($ccEmail); + } + } + + $mail->isHTML(true); + $mail->Subject = $subject; + $mail->Body = $html; + $mail->AltBody = trim(preg_replace( + '/\n{3,}/', + "\n\n", + strip_tags(str_replace(['

', '
', '
', '
', '
'], "\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 . '
Dove' . yse_h($addr) . $mapsLink . '
' . $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 6d71f8e..4c2eb0a 100644 --- a/public/include/sidebar.php +++ b/public/include/sidebar.php @@ -70,57 +70,110 @@ hasRole('Admin')): ?> - -
  • - - - Servizi - -
  • + + +
  • - Classi + Classi + +
  • +
  • + + + Servizi
  • - Pending/Waiting + Pending/Waiting
  • - Day Off + Day Off
  • + + + + +
  • + + + Eventi + +
  • +
  • + + + Sedi + +
  • +
  • + + + Opzione Eventi + +
  • + + +
  • - Order Pending - -
  • -
  • - - - Certificati - -
  • -
  • - - - Situazione + Order Pending
  • - Sommario Ordini + Sommario Ordini
  • + + + +
  • + + + Situazione + +
  • +
  • + + + Certificati + +
  • +
  • + + + Statistiche + +
  • + + + +
  • + + + Opzioni Scuola + +
  • +
  • + + + Insegnanti + +
  • + +
  • diff --git a/public/include/waitinglist.php b/public/include/waitinglist.php new file mode 100644 index 0000000..418feae --- /dev/null +++ b/public/include/waitinglist.php @@ -0,0 +1,111 @@ +query("SELECT waitinglist_enabled FROM `option` LIMIT 1"); + if ($res && ($row = $res->fetch_assoc())) { + return $row['waitinglist_enabled'] === 'Y'; + } + return false; +} + +/** + * Capienza, posti impegnati (booked + pending) e posti liberi di una classe. + * + * @return array{maxcapacity:int, occupied:int, free:int} + */ +function getClassOccupancy(mysqli $conn, int $idserviceschedule): array +{ + // Capienza (da service via serviceschedule) + $cap = 0; + $stmt = $conn->prepare( + "SELECT s.maxcapacity + FROM serviceschedule ss + JOIN service s ON ss.idservice = s.idservice + WHERE ss.idserviceschedule = ?" + ); + $stmt->bind_param("i", $idserviceschedule); + $stmt->execute(); + $res = $stmt->get_result(); + if ($rowCap = $res->fetch_assoc()) { + $cap = (int) $rowCap['maxcapacity']; + } + $stmt->close(); + + // Posti impegnati: booked + pending + $occupied = 0; + $stmt = $conn->prepare( + "SELECT COUNT(*) AS occupied + FROM bookingclass + WHERE idserviceschedule = ? + AND status IN ('booked', 'pending')" + ); + $stmt->bind_param("i", $idserviceschedule); + $stmt->execute(); + $res = $stmt->get_result(); + if ($rowOcc = $res->fetch_assoc()) { + $occupied = (int) $rowOcc['occupied']; + } + $stmt->close(); + + $free = $cap - $occupied; + + return [ + 'maxcapacity' => $cap, + 'occupied' => $occupied, + 'free' => $free < 0 ? 0 : $free, + ]; +} + +/** + * Decide lo stato di una nuova prenotazione: + * - 'pending' -> c'è posto (flusso normale, insegnante approva) + * - 'waiting' -> classe piena E waiting list attiva + * - 'full' -> classe piena E waiting list disattivata (NON prenotabile) + */ +function decideBookingStatus(mysqli $conn, int $idserviceschedule): string +{ + $occ = getClassOccupancy($conn, $idserviceschedule); + + if ($occ['free'] > 0) { + return 'pending'; + } + + return isWaitingListEnabled($conn) ? 'waiting' : 'full'; +} + +/** + * Posizione (1-based) di un utente nella coda waiting di una classe. + * 0 se non è in coda. + */ +function getWaitingPosition(mysqli $conn, int $idserviceschedule, int $iduser): int +{ + $stmt = $conn->prepare( + "SELECT COUNT(*) AS pos + FROM bookingclass + WHERE idserviceschedule = ? + AND status = 'waiting' + AND created_at <= ( + SELECT created_at FROM bookingclass + WHERE idserviceschedule = ? AND iduser = ? AND status = 'waiting' + ORDER BY created_at ASC LIMIT 1 + )" + ); + $stmt->bind_param("iii", $idserviceschedule, $idserviceschedule, $iduser); + $stmt->execute(); + $res = $stmt->get_result(); + $pos = 0; + if ($row = $res->fetch_assoc()) { + $pos = (int) $row['pos']; + } + $stmt->close(); + return $pos; +} diff --git a/public/insegnanti.php b/public/insegnanti.php new file mode 100644 index 0000000..471bdbe --- /dev/null +++ b/public/insegnanti.php @@ -0,0 +1,783 @@ +getConnection(); + +if (!function_exists('e')) { + function e($value): string + { + return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); + } +} + +if (session_status() !== PHP_SESSION_ACTIVE) { + session_start(); +} +if (empty($_SESSION['csrf_teachers'])) { + $_SESSION['csrf_teachers'] = bin2hex(random_bytes(16)); +} +$csrf = $_SESSION['csrf_teachers']; + +define('PHOTO_DIR_REL', 'assets/images/teachers/'); +$photoDirAbs = __DIR__ . '/' . PHOTO_DIR_REL; + +/* ================================================================ helper */ + +function flashRedirect(string $type, string $msg): void +{ + $_SESSION['teachers_flash'] = [$type, $msg]; + header('Location: insegnanti.php'); + exit; +} + +function teacherInitials(string $first, string $last): string +{ + $ini = mb_strtoupper(mb_substr(trim($first), 0, 1) . mb_substr(trim($last), 0, 1)); + return $ini !== '' ? $ini : '?'; +} + +function teacherColor(string $seed): string +{ + $palette = ['#1ebf73', '#2980b9', '#8e44ad', '#e67e22', '#16a085', '#c0392b', '#2c3e50', '#b08d57']; + return $palette[abs(crc32($seed)) % count($palette)]; +} + +function deletePhotoFile(string $dir, ?string $name): void +{ + if ($name && preg_match('/^teacher_[a-f0-9]{16}\.(jpg|png|webp)$/', $name) && is_file($dir . $name)) { + @unlink($dir . $name); + } +} + +/** Ritorna il nome file salvato, null se nessun file caricato. Lancia RuntimeException se non valido. */ +function handlePhotoUpload(array $file, string $dir): ?string +{ + if (($file['error'] ?? UPLOAD_ERR_NO_FILE) === UPLOAD_ERR_NO_FILE) { + return null; + } + if ($file['error'] !== UPLOAD_ERR_OK) { + throw new RuntimeException('Errore nel caricamento della foto.'); + } + if ($file['size'] > 3 * 1024 * 1024) { + throw new RuntimeException('La foto supera i 3 MB.'); + } + $mime = (new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']); + $ext = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp'][$mime] ?? null; + if (!$ext || @getimagesize($file['tmp_name']) === false) { + throw new RuntimeException('Formato foto non valido: usa JPG, PNG o WEBP.'); + } + if (!is_dir($dir) && !@mkdir($dir, 0755, true)) { + throw new RuntimeException('La cartella delle foto non è scrivibile.'); + } + $name = 'teacher_' . bin2hex(random_bytes(8)) . '.' . $ext; + if (!move_uploaded_file($file['tmp_name'], $dir . $name)) { + throw new RuntimeException('Impossibile salvare la foto.'); + } + return $name; +} + +function findTeacher(PDO $pdo, int $id): ?array +{ + $st = $pdo->prepare("SELECT * FROM employee WHERE idemployee = :id"); + $st->execute([':id' => $id]); + $row = $st->fetch(); + return $row ?: null; +} + +/* ================================================================ azioni POST */ + +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + if (!hash_equals($csrf, (string) ($_POST['csrf'] ?? ''))) { + flashRedirect('danger', 'Sessione scaduta, riprova.'); + } + + $action = $_POST['action'] ?? ''; + $id = (int) ($_POST['id'] ?? 0); + + try { + /* ---------- crea / modifica */ + if ($action === 'save') { + $name = trim((string) ($_POST['nameemployee'] ?? '')); + $surname = trim((string) ($_POST['surnameemployee'] ?? '')); + $email = trim((string) ($_POST['email'] ?? '')); + $phone = trim((string) ($_POST['phone'] ?? '')); + $notify = isset($_POST['notifications']) ? 'Y' : 'N'; + $active = isset($_POST['active']) ? 'Y' : 'N'; + + if ($name === '' || $surname === '') { + flashRedirect('danger', 'Nome e cognome sono obbligatori.'); + } + if (mb_strlen($name) > 100 || mb_strlen($surname) > 100) { + flashRedirect('danger', 'Nome o cognome troppo lunghi.'); + } + if ($email !== '' && !filter_var($email, FILTER_VALIDATE_EMAIL)) { + flashRedirect('danger', 'Indirizzo email non valido.'); + } + if ($phone !== '' && !preg_match('/^[0-9 +().\/-]{5,40}$/', $phone)) { + flashRedirect('danger', 'Numero di telefono non valido.'); + } + if ($email !== '') { + $dup = $pdo->prepare("SELECT COUNT(*) AS c FROM employee WHERE email = :email AND idemployee <> :id"); + $dup->execute([':email' => $email, ':id' => $id]); + if ((int) $dup->fetch()['c'] > 0) { + flashRedirect('danger', 'Questa email è già usata da un altro insegnante.'); + } + } + + if ($id > 0) { + $current = findTeacher($pdo, $id); + if (!$current) { + flashRedirect('danger', 'Insegnante non trovato.'); + } + if ($current['is_owner'] === 'Y' && $active === 'N') { + flashRedirect('danger', 'Il titolare non può essere disattivato.'); + } + + $photo = $current['photo']; + $newPhoto = handlePhotoUpload($_FILES['photo'] ?? [], $photoDirAbs); + if ($newPhoto !== null) { + deletePhotoFile($photoDirAbs, $photo); + $photo = $newPhoto; + } elseif (isset($_POST['remove_photo'])) { + deletePhotoFile($photoDirAbs, $photo); + $photo = null; + } + + $st = $pdo->prepare("UPDATE employee SET nameemployee = :n, surnameemployee = :s, email = :e, + phone = :p, photo = :ph, notifications = :nt, active = :a + WHERE idemployee = :id"); + $st->execute([ + ':n' => $name, + ':s' => $surname, + ':e' => $email ?: null, + ':p' => $phone ?: null, + ':ph' => $photo, + ':nt' => $notify, + ':a' => $active, + ':id' => $id, + ]); + flashRedirect('success', 'Insegnante aggiornato.'); + } + + // nuovo + $photo = handlePhotoUpload($_FILES['photo'] ?? [], $photoDirAbs); + $hasOwner = (int) $pdo->query("SELECT COUNT(*) AS c FROM employee WHERE is_owner = 'Y'")->fetch()['c'] > 0; + + $st = $pdo->prepare("INSERT INTO employee + (nameemployee, surnameemployee, email, phone, photo, is_owner, notifications, active, idlocation) + VALUES (:n, :s, :e, :p, :ph, :o, :nt, :a, 0)"); + $st->execute([ + ':n' => $name, + ':s' => $surname, + ':e' => $email ?: null, + ':p' => $phone ?: null, + ':ph' => $photo, + ':o' => $hasOwner ? 'N' : 'Y', + ':nt' => $notify, + ':a' => $hasOwner ? $active : 'Y', + ]); + flashRedirect('success', 'Insegnante aggiunto' . ($hasOwner ? '.' : ' e impostato come titolare.')); + } + + /* ---------- imposta titolare */ + if ($action === 'set_owner' && $id > 0) { + if (!findTeacher($pdo, $id)) { + flashRedirect('danger', 'Insegnante non trovato.'); + } + $pdo->beginTransaction(); + $pdo->exec("UPDATE employee SET is_owner = 'N' WHERE is_owner = 'Y'"); + $st = $pdo->prepare("UPDATE employee SET is_owner = 'Y', active = 'Y' WHERE idemployee = :id"); + $st->execute([':id' => $id]); + $pdo->commit(); + flashRedirect('success', 'Titolare aggiornato.'); + } + + /* ---------- notifiche on/off */ + if ($action === 'toggle_notifications' && $id > 0) { + $st = $pdo->prepare("UPDATE employee SET notifications = IF(notifications = 'Y', 'N', 'Y') WHERE idemployee = :id"); + $st->execute([':id' => $id]); + flashRedirect('success', 'Notifiche aggiornate.'); + } + + /* ---------- elimina */ + if ($action === 'delete' && $id > 0) { + $t = findTeacher($pdo, $id); + if (!$t) { + flashRedirect('danger', 'Insegnante non trovato.'); + } + if ($t['is_owner'] === 'Y') { + flashRedirect('danger', 'Il titolare non può essere eliminato. Imposta prima un altro titolare.'); + } + $cnt = $pdo->prepare("SELECT COUNT(*) AS c FROM service WHERE idteacher = :id"); + $cnt->execute([':id' => $id]); + $nServices = (int) $cnt->fetch()['c']; + if ($nServices > 0) { + flashRedirect('danger', 'Impossibile eliminare: ha ' . $nServices . ' classi assegnate. Riassegnale oppure disattiva l\'insegnante.'); + } + $pdo->beginTransaction(); + $pdo->prepare("DELETE FROM dayoff WHERE idteacher = :id")->execute([':id' => $id]); + $pdo->prepare("DELETE FROM employee WHERE idemployee = :id")->execute([':id' => $id]); + $pdo->commit(); + deletePhotoFile($photoDirAbs, $t['photo']); + flashRedirect('success', 'Insegnante eliminato.'); + } + + flashRedirect('danger', 'Azione non valida.'); + } catch (RuntimeException $ex) { + flashRedirect('danger', $ex->getMessage()); + } catch (Throwable $ex) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + error_log('insegnanti.php: ' . $ex->getMessage()); + flashRedirect('danger', 'Errore durante il salvataggio. Riprova.'); + } +} + +/* ================================================================ lettura */ + +$flash = $_SESSION['teachers_flash'] ?? null; +unset($_SESSION['teachers_flash']); + +$teachers = $pdo->query("SELECT e.*, + (SELECT COUNT(*) FROM service s WHERE s.idteacher = e.idemployee) AS nservices + FROM employee e + ORDER BY e.is_owner DESC, e.active DESC, e.surnameemployee, e.nameemployee")->fetchAll(); +?> + + + + + + YogiBook - Insegnanti + + + + + + + + + + + + + + +
    +
    + +
    + +
    + +
    +
    + +
    +
    +
    + +
    +
    +
    + + + + + +
    +
    +
    Insegnanti della scuola
    + insegnant +
    + +
    + + +
    + +

    Nessun insegnante. Aggiungi il primo: diventerà automaticamente il titolare.

    +
    + +
    + $tid, + 'nameemployee' => $t['nameemployee'], + 'surnameemployee' => $t['surnameemployee'], + 'email' => $t['email'], + 'phone' => $t['phone'], + 'photo' => $photoUrl, + 'notifications' => $t['notifications'] === 'Y', + 'active' => $isActive, + 'is_owner' => $isOwner, + ]; + ?> +
    +
    +
    + + <?php echo e($fullName); ?> + +
    + +
    +
    + Titolare + Non attivo +
    +
    + +
    + ' . e($t['email']) . '' : 'email non inserita'; ?> +
    +
    + ' . e($t['phone']) . '' : 'telefono non inserito'; ?> +
    +
    + class assegnat +
    + +
    +
    + + + +
    + onchange="this.form.submit()"> + +
    + +
    manca l'email
    + +
    + +
    + + +
    + + + + +
    +
    + + + + +
    + +
    +
    +
    +
    + +
    + + +
    + +
    +
    +
    + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/leavewaiting.php b/public/leavewaiting.php new file mode 100644 index 0000000..0eb2c41 --- /dev/null +++ b/public/leavewaiting.php @@ -0,0 +1,26 @@ +connect_error) { + header("Location: userpanel.php"); + exit(); +} + +$iduserlogin = (int) $iduserlogin; +$idbookingclass = isset($_GET['id']) ? (int) $_GET['id'] : 0; + +if ($idbookingclass > 0) { + // Cancella solo se è una riga waiting DELL'utente loggato (sicurezza) + $stmt = $conn->prepare( + "DELETE FROM bookingclass + WHERE idbookingclass = ? AND iduser = ? AND status = 'waiting'" + ); + $stmt->bind_param("ii", $idbookingclass, $iduserlogin); + $stmt->execute(); + $stmt->close(); +} + +$conn->close(); +header("Location: userpanel.php?leftwaiting=Y"); +exit(); diff --git a/public/options_save.php b/public/options_save.php new file mode 100644 index 0000000..1863883 --- /dev/null +++ b/public/options_save.php @@ -0,0 +1,33 @@ +getConnection(); + +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + header('Location: options.php'); + exit; +} + +$waiting = isset($_POST['waitinglist_enabled']) ? 'Y' : 'N'; +$showTeacher = isset($_POST['show_teacher']) ? 'Y' : 'N'; +$maxCancel = max(0, (int) ($_POST['maxbeforetimecancell'] ?? 0)); + +try { + $exists = (int) $pdo->query("SELECT COUNT(*) AS c FROM `option`")->fetch()['c'] > 0; + + if ($exists) { + $st = $pdo->prepare("UPDATE `option` + SET waitinglist_enabled = :w, show_teacher = :s, maxbeforetimecancell = :m + LIMIT 1"); + } else { + $st = $pdo->prepare("INSERT INTO `option` (waitinglist_enabled, show_teacher, maxbeforetimecancell) + VALUES (:w, :s, :m)"); + } + $st->execute([':w' => $waiting, ':s' => $showTeacher, ':m' => (string) $maxCancel]); + + header('Location: optionsschool.php?message=success'); +} catch (Throwable $e) { + error_log('options_save: ' . $e->getMessage()); + header('Location: options.php?message=error'); +} +exit; diff --git a/public/optionsschool.php b/public/optionsschool.php new file mode 100644 index 0000000..53ab9b4 --- /dev/null +++ b/public/optionsschool.php @@ -0,0 +1,200 @@ +connect_error) { + die("Connessione fallita: " . $conn->connect_error); +} + +function e($value): string +{ + return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); +} + +$message = $_GET['message'] ?? 'n'; + +// Carica l'unico record di option +$opt = $conn->query("SELECT * FROM `option` LIMIT 1")->fetch_assoc(); +$conn->close(); + +$waitingEnabled = ($opt['waitinglist_enabled'] ?? 'N') === 'Y'; +$maxCancel = $opt['maxbeforetimecancell'] ?? '0'; +$showTeacher = ($opt['show_teacher'] ?? 'N') === 'Y'; +?> + + + + + + YogiBook - Opzioni + + + + + + + + + + + + +
    +
    + +
    + +
    + +
    +
    + +
    +
    +
    + +
    +
    +
    + + + + + + +
    +
    +
    +
    +
    Impostazioni prenotazioni
    +

    Configura il comportamento del sistema di prenotazione.

    + +
    + +
    +
    +
    Lista d'attesa
    +
    Se attiva, quando una classe è piena gli utenti possono mettersi in coda. Alla liberazione di un posto verranno gestiti in ordine di arrivo.
    +
    +
    + > +
    +
    +
    +
    +
    Mostra insegnante nelle classi
    +
    Se attiva, nelle card delle classi viene indicato il nome (e la foto) dell'insegnante.
    +
    +
    + > +
    +
    + +
    +
    +
    Ore minime per l'annullamento
    +
    Quante ore prima dell'inizio di una lezione è ancora possibile annullarla.
    +
    +
    + +
    +
    + +
    + +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    + + + + + + + + + \ No newline at end of file diff --git a/public/ordersummary.php b/public/ordersummary.php index 78b4085..2bfdf95 100644 --- a/public/ordersummary.php +++ b/public/ordersummary.php @@ -1,299 +1,170 @@ - getConnection(); -// Verifica della connessione -if ($conn->connect_error) { - die("Connessione fallita: " . $conn->connect_error); -} - -// Gestione del messaggio GET -if (isset($_GET['message'])) { - $message = $_GET['message']; -} else { - $message = 'n'; -} - -// ID dell'insegnante (per il form DayOff) -$idTeacher = 1; - -// Gestione dell'inserimento del DayOff -if (isset($_POST['submit'])) { - $dayoffdate = $_POST['dayoff']; - $insertQuery = "INSERT INTO dayoff (idteacher, dayoffdate) VALUES (?, ?)"; - $stmt = $conn->prepare($insertQuery); - $stmt->bind_param("is", $idTeacher, $dayoffdate); - if ($stmt->execute()) { - $message = 'success_dayoff'; - } else { - $message = 'error_dayoff'; - } - $stmt->close(); -} - -// Gestione del filtro per gli ordini -$filter = isset($_GET['filter']) ? $_GET['filter'] : 'all'; -$today = date("Y-m-d"); -$whereClause = ""; -if ($filter == 'active') { - $whereClause = "WHERE expireon > '$today'"; -} elseif ($filter == 'expired') { - $whereClause = "WHERE expireon <= '$today'"; -} - -// Query SQL per recuperare tutti gli ordini con join su service -$query = "SELECT o.idorderbook, o.first_name, o.last_name, s.servicename, o.expireon, o.status, o.nticket - FROM orderbook o - LEFT JOIN service s ON o.idservice = s.idservice - $whereClause - ORDER BY o.idorderbook DESC"; -$result = $conn->query($query); -$orders = []; -if ($result->num_rows > 0) { - while ($row = $result->fetch_assoc()) { - $orders[] = $row; +if (!function_exists('e')) { + function e($value): string + { + return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); } } -// Query SQL per i DayOff -$query_dayoff = "SELECT iddayoff, dayoffdate FROM dayoff WHERE idteacher = '1'"; -$result_dayoff = $conn->query($query_dayoff); -$documents = []; -if ($result_dayoff->num_rows > 0) { - while ($row = $result_dayoff->fetch_assoc()) { - $documents[] = $row; +function reschedBadge($max, $done): string +{ + $done = (int) $done; + if ($max === null || $max === '') { + return '' . $done . ''; } + $max = (int) $max; + $cls = $done >= $max ? 'bg-danger' : ($done > 0 ? 'bg-warning text-dark' : 'bg-success'); + return '' . $done . ' / ' . $max . ''; } + +$message = $_GET['message'] ?? 'n'; + +$filter = $_GET['filter'] ?? 'all'; +if (!in_array($filter, ['all', 'active', 'expired'], true)) { + $filter = 'all'; +} +$q = trim((string) ($_GET['q'] ?? '')); +$today = date('Y-m-d'); + +$where = ''; +$params = []; +if ($filter === 'active') { + $where = 'WHERE o.expireon > :today'; + $params[':today'] = $today; +} elseif ($filter === 'expired') { + $where = 'WHERE o.expireon <= :today'; + $params[':today'] = $today; +} + +$sql = "SELECT o.idorderbook, o.order_id, o.first_name, o.last_name, o.order_billing_email, + s.servicename, o.expireon, o.status, o.nticket, o.maxreschedule, o.reprogrammed + FROM orderbook o + LEFT JOIN service s ON o.idservice = s.idservice + $where + ORDER BY o.idorderbook DESC"; +$stmt = $pdo->prepare($sql); +$stmt->execute($params); +$orders = $stmt->fetchAll(); + +$qParam = $q !== '' ? '&q=' . urlencode($q) : ''; ?> - - + - YogiBook - Prenotazioni YogaSoul + YogiBook - Riepilogo Ordini - + - + - @@ -314,91 +185,118 @@ if ($result_dayoff->num_rows > 0) {
  • - -
    -
    - - -
    - -
    -
    - +
    + +
    + -
    - +
    +
    + +
    +
    + -
    -
    -
    -
    -
    -
    -
    -
    Riepilogo Ordini (Amministratore)
    -

    Tutti gli ordini registrati

    -
    - Tutti - Attivi - Scaduti -
    -
    - - - - - - - - - - - - - - - $today); - $badgeClass = $isActive ? 'badge bg-success' : 'badge bg-danger'; - $badgeText = $isActive ? 'Attivo' : 'Scaduto'; - ?> +
    +
    +
    +
    +
    +
    +
    +
    Riepilogo Ordini (Amministratore)
    +

    Tutti gli ordini registrati

    + +
    + Tutti + Attivi + Scaduti + + +
    + +
    +
    Clicca su scadenza, max riprogr. o riprogrammate per modificarle.
    + +
    +
    StatoNumero OrdineNomeCognomeNome ClasseNumero TicketScadenzaStatus
    + - - - - - - - - + + + + + + + + + + + - - -
    StatoN. OrdineNomeCognomeClasseTicketScadenzaMax riprogr.RiprogrammateStatusAzioni
    + + + $today); + $max = $order['maxreschedule']; + $done = (int) $order['reprogrammed']; + $search = implode(' ', [ + $order['first_name'], + $order['last_name'], + $order['order_billing_email'], + $id, + $order['order_id'], + $order['servicename'], + ]); + ?> + + + + + + + + + + + + –' : (int) $max; ?> + + + + + + + + + + + + Nessun ordine trovato. + + + +
    +
    -
    @@ -407,6 +305,314 @@ if ($result_dayoff->num_rows > 0) { + + + + + + + + \ No newline at end of file diff --git a/public/php_errors.log b/public/php_errors.log index 24e3684..5c97b12 100644 --- a/public/php_errors.log +++ b/public/php_errors.log @@ -1,3 +1,4 @@ [15-Oct-2025 15:13:11 UTC] Tentativo di aggiornamento AJAX - idorderbook: 1, new_expiry: 2025-10-16 [15-Oct-2025 15:13:19 UTC] Tentativo di aggiornamento AJAX - idorderbook: 1, new_expiry: 2025-10-15 [15-Oct-2025 15:13:58 UTC] Tentativo di aggiornamento AJAX - idorderbook: 1, new_expiry: 2025-10-30 +[24-Sep-2026 09:19:47 UTC] Tentativo di aggiornamento AJAX - idorderbook: 7, new_expiry: 2026-11-02 diff --git a/public/propagatesingleorder.php b/public/propagatesingleorder.php index f43c827..b8e29f2 100644 --- a/public/propagatesingleorder.php +++ b/public/propagatesingleorder.php @@ -103,7 +103,11 @@ if ($is_openflexy || $service_schedule_result->num_rows > 0) { $idservice = $schedule_row["idservice"]; $idserviceschedule = $schedule_row["idserviceschedule"]; - $querychk = "SELECT * FROM bookingclass WHERE iduser = ? AND idserviceschedule = ?"; + $querychk = "SELECT 1 FROM bookingclass + WHERE iduser = ? + AND idserviceschedule = ? + AND status <> 'cancelled' + LIMIT 1"; $stmt_chk = $conn->prepare($querychk); $stmt_chk->bind_param("ii", $selected_user_id, $idserviceschedule); $stmt_chk->execute(); @@ -119,7 +123,10 @@ if ($is_openflexy || $service_schedule_result->num_rows > 0) { $maxcapacity = $row['maxcapacity']; $stmt_maxcapacity->close(); - $bookingclass_count_query = "SELECT COUNT(*) as total_records FROM bookingclass WHERE idserviceschedule = ?"; + $bookingclass_count_query = "SELECT COUNT(*) AS total_records + FROM bookingclass + WHERE idserviceschedule = ? + AND status = 'booked'"; $stmt_count = $conn->prepare($bookingclass_count_query); $stmt_count->bind_param("i", $idserviceschedule); $stmt_count->execute(); diff --git a/public/rebookandgo.php b/public/rebookandgo.php index a157e44..24a9e4a 100644 --- a/public/rebookandgo.php +++ b/public/rebookandgo.php @@ -1,6 +1,7 @@ bind_param("i", $idpreviousbooking); $stmt->execute(); $stmt->close(); -// Inserisci la nuova prenotazione (pending) +// Decide lo stato: pending se c'è posto, waiting se la classe è piena +$bookingStatus = decideBookingStatus($conn, $idnewbooking); + +// Inserisci la nuova prenotazione $stmt = $conn->prepare( "INSERT INTO bookingclass (idserviceschedule, iduser, prevbookingstart, idprevserviceschedule, idservice, idorder, bookingstart, status) - VALUES (?, ?, ?, ?, ?, ?, ?, 'pending')" + VALUES (?, ?, ?, ?, ?, ?, ?, ?)" ); -$stmt->bind_param("iisiiss", $idnewbooking, $iduser, $prevBookingStart, $idPrevServiceSchedule, $idservicenew, $globalidorder, $newtime); +$stmt->bind_param("iisiisss", $idnewbooking, $iduser, $prevBookingStart, $idPrevServiceSchedule, $idservicenew, $globalidorder, $newtime, $bookingStatus); $stmt->execute(); $stmt->close(); @@ -83,9 +87,11 @@ $stmtUser->close(); $newtimeformat = date("d-m-Y H:i", strtotime($newtime)); +$redirectFlag = ($bookingStatus === 'waiting') ? 'waiting=Y' : 'reprogram=Y'; + // Se manca l'email, completa comunque il flusso senza inviare if ($emailuser === '') { - header("Location: userpanel.php?reprogram=Y"); + header("Location: userpanel.php?$redirectFlag"); exit(); } @@ -138,5 +144,5 @@ try { error_log("Errore invio mail riprogrammazione (iduser $iduser): " . $mail->ErrorInfo); } -header("Location: userpanel.php?reprogram=Y"); +header("Location: userpanel.php?$redirectFlag"); exit(); diff --git a/public/statistiche.php b/public/statistiche.php new file mode 100644 index 0000000..68dc5e4 --- /dev/null +++ b/public/statistiche.php @@ -0,0 +1,1165 @@ + +getConnection(); + +if (!function_exists('e')) { + function e($value): string + { + return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); + } +} + +function fetchAllQ(PDO $pdo, string $sql, array $params = []): array +{ + $st = $pdo->prepare($sql); + $st->execute($params); + return $st->fetchAll(); +} + +function pct($num, $den): ?float +{ + return $den > 0 ? round($num / $den * 100, 1) : null; +} + +function fmtPct(?float $v): string +{ + return $v === null ? '–' : number_format($v, 0, ',', '.') . '%'; +} + +function n($v): string +{ + return number_format((float) $v, 0, ',', '.'); +} + +function kpi(string $icon, string $color, string $value, string $label, string $sub = ''): string +{ + return '
    ' + . '
    ' + . '
    ' . $value . '
    ' + . '
    ' . $label . '
    ' + . ($sub !== '' ? '
    ' . $sub . '
    ' : '') + . '
    '; +} + +function fillColor(?float $p): string +{ + if ($p === null) return '#d5d9e0'; + if ($p >= 75) return '#1ebf73'; + if ($p >= 45) return '#f1b40f'; + return '#e67e22'; +} + +/* =========================================================== PERIODO */ + +$validDate = function ($d): bool { + if (!is_string($d) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $d)) return false; + [$y, $m, $dd] = array_map('intval', explode('-', $d)); + return checkdate($m, $dd, $y); +}; + +$presets = [ + 'month' => 'Questo mese', + 'lastmonth' => 'Mese scorso', + '3m' => 'Ultimi 3 mesi', + '12m' => 'Ultimi 12 mesi', + 'year' => "Quest'anno", +]; + +$preset = $_GET['preset'] ?? ''; +$fromIn = $_GET['from'] ?? ''; +$toIn = $_GET['to'] ?? ''; +$from = $to = null; + +if (($preset === 'custom' || $preset === '') && $validDate($fromIn) && $validDate($toIn)) { + $preset = 'custom'; + $from = $fromIn; + $to = $toIn; +} + +if ($from === null) { + if (!isset($presets[$preset])) $preset = '3m'; + $firstThis = new DateTime('first day of this month'); + switch ($preset) { + case 'month': + $from = $firstThis->format('Y-m-d'); + $to = $firstThis->format('Y-m-t'); + break; + case 'lastmonth': + $lm = (clone $firstThis)->modify('-1 month'); + $from = $lm->format('Y-m-d'); + $to = $lm->format('Y-m-t'); + break; + case '12m': + $from = (clone $firstThis)->modify('-11 months')->format('Y-m-d'); + $to = $firstThis->format('Y-m-t'); + break; + case 'year': + $from = date('Y-01-01'); + $to = date('Y-12-31'); + break; + default: // 3m + $from = (clone $firstThis)->modify('-2 months')->format('Y-m-d'); + $to = $firstThis->format('Y-m-t'); + } +} +if ($from > $to) { + [$from, $to] = [$to, $from]; +} + +$d1 = $from . ' 00:00:00'; +$d2 = date('Y-m-d', strtotime($to . ' +1 day')) . ' 00:00:00'; +$now = date('Y-m-d H:i:s'); + +/* =========================================================== FILTRI */ + +$fService = (int) ($_GET['service'] ?? 0); +$fCat = (int) ($_GET['category'] ?? 0); +$fTeacher = (int) ($_GET['teacher'] ?? 0); +$topLimit = in_array((int) ($_GET['top'] ?? 10), [10, 25, 50], true) ? (int) ($_GET['top'] ?? 10) : 10; + +$sortMap = [ + 'attended' => ['Lezioni frequentate', 'attended DESC, upcoming DESC'], + 'lost' => ['Lezioni perse', 'lost DESC, attended DESC'], + 'reprog' => ['Riprogrammazioni', 'reprog DESC, attended DESC'], + 'cancelled' => ['Cancellazioni', 'cancelled DESC, attended DESC'], +]; +$topSort = isset($sortMap[$_GET['sort'] ?? '']) ? $_GET['sort'] : 'attended'; + +$sf = ''; +$sp = []; +if ($fService > 0) { + $sf .= ' AND s.idservice = :f_service'; + $sp[':f_service'] = $fService; +} +if ($fCat > 0) { + $sf .= ' AND s.category = :f_cat'; + $sp[':f_cat'] = $fCat; +} +if ($fTeacher > 0) { + $sf .= ' AND s.idteacher = :f_teacher'; + $sp[':f_teacher'] = $fTeacher; +} +$P = [':d1' => $d1, ':d2' => $d2] + $sp; + +/* =========================================================== DATI FILTRI */ + +$services = fetchAllQ($pdo, "SELECT idservice, servicename, day, time FROM service ORDER BY servicename, day, time"); +$categories = fetchAllQ($pdo, "SELECT idservicecategory, namecategory FROM servicecategory ORDER BY namecategory"); +$teachers = fetchAllQ($pdo, "SELECT DISTINCT e.idemployee, e.nameemployee, e.surnameemployee + FROM employee e JOIN service s ON s.idteacher = e.idemployee + ORDER BY e.surnameemployee, e.nameemployee"); + +/* =========================================================== ORDINI */ + +$orderRows = fetchAllQ($pdo, "SELECT o.order_date_created AS d, o.nticket, o.iduser, o.reprogrammed + FROM orderbook o + LEFT JOIN service s ON o.idservice = s.idservice + WHERE o.order_date_created >= :d1 AND o.order_date_created < :d2 $sf", $P); + +$ordersCount = count($orderRows); +$ticketsSold = 0; +$reprogCounter = 0; +$buyers = []; +foreach ($orderRows as $o) { + $ticketsSold += (int) $o['nticket']; + $reprogCounter += (int) $o['reprogrammed']; + if (!empty($o['iduser'])) $buyers[(int) $o['iduser']] = true; +} +$buyersCount = count($buyers); + +// Nuovi clienti: persone il cui PRIMO ordine in assoluto cade nel periodo +$newCustomers = (int) (fetchAllQ($pdo, "SELECT COUNT(*) AS c FROM ( + SELECT iduser, MIN(order_date_created) AS first_order + FROM orderbook WHERE iduser IS NOT NULL GROUP BY iduser + ) t WHERE t.first_order >= :d1 AND t.first_order < :d2", [':d1' => $d1, ':d2' => $d2])[0]['c'] ?? 0); + +/* =========================================================== LEZIONI (KPI) */ + +$lessonBase = "FROM bookingclass bc + JOIN serviceschedule ss ON bc.idserviceschedule = ss.idserviceschedule + JOIN service s ON ss.idservice = s.idservice + WHERE ss.dateschedule >= :d1 AND ss.dateschedule < :d2 $sf"; + +$k = fetchAllQ($pdo, "SELECT + COALESCE(SUM(bc.status = 'booked' AND bc.lostlesson = 'N'), 0) AS booked, + COALESCE(SUM(bc.status = 'booked' AND bc.lostlesson = 'N' AND ss.dateschedule <= :now), 0) AS attended, + COALESCE(SUM(bc.lostlesson = 'Y' AND bc.status <> 'cancelled'), 0) AS lost, + COALESCE(SUM(bc.status = 'cancelled'), 0) AS cancelled, + COALESCE(SUM(bc.status = 'pending'), 0) AS pending, + COALESCE(SUM(bc.status = 'waiting'), 0) AS waiting, + COALESCE(SUM(bc.is_reprogrammed = 'Y' AND bc.status <> 'cancelled'), 0) AS reprog, + COALESCE(SUM(bc.is_gift = 'Y' AND bc.status = 'booked'), 0) AS gifts, + COUNT(DISTINCT CASE WHEN bc.status = 'booked' THEN bc.iduser END) AS people + $lessonBase", $P + [':now' => $now])[0]; + +/* =========================================================== SESSIONI (riempimento) */ + +$sessions = fetchAllQ($pdo, "SELECT ss.idserviceschedule, ss.dateschedule, + s.idservice, s.servicename, s.colorclass, s.maxcapacity, s.day, s.time, + COUNT(CASE WHEN bc.status = 'booked' THEN 1 END) AS booked, + COUNT(CASE WHEN bc.status = 'booked' AND bc.lostlesson = 'N' THEN 1 END) AS present, + COUNT(CASE WHEN bc.lostlesson = 'Y' AND bc.status <> 'cancelled' THEN 1 END) AS lost, + COUNT(CASE WHEN bc.status = 'cancelled' THEN 1 END) AS cancelled, + COUNT(CASE WHEN bc.status = 'waiting' THEN 1 END) AS waiting, + COUNT(CASE WHEN bc.is_reprogrammed = 'Y' AND bc.status <> 'cancelled' THEN 1 END) AS reprog + FROM serviceschedule ss + JOIN service s ON ss.idservice = s.idservice + LEFT JOIN bookingclass bc ON bc.idserviceschedule = ss.idserviceschedule + WHERE ss.dateschedule >= :d1 AND ss.dateschedule < :d2 $sf + GROUP BY ss.idserviceschedule, ss.dateschedule, s.idservice, s.servicename, + s.colorclass, s.maxcapacity, s.day, s.time + ORDER BY ss.dateschedule", $P); + +/* =========================================================== BUCKET TEMPORALI */ + +$rangeDays = (int) round((strtotime($to) - strtotime($from)) / 86400) + 1; +$weekly = $rangeDays <= 62; // periodi brevi: per settimana, altrimenti per mese +$monthsShort = ['', 'Gen', 'Feb', 'Mar', 'Apr', 'Mag', 'Giu', 'Lug', 'Ago', 'Set', 'Ott', 'Nov', 'Dic']; + +$bucketKey = function (string $date) use ($weekly): string { + $ts = strtotime($date); + if ($weekly) { + $dow = (int) date('N', $ts); + return date('Y-m-d', strtotime('-' . ($dow - 1) . ' days', $ts)); + } + return date('Y-m', $ts); +}; + +$buckets = []; +$cursor = strtotime($weekly ? $bucketKey($from) : date('Y-m-01', strtotime($from))); +$endTs = strtotime($to); +while ($cursor <= $endTs) { + $key = $weekly ? date('Y-m-d', $cursor) : date('Y-m', $cursor); + $buckets[$key] = [ + 'label' => $weekly ? date('d/m', $cursor) : $monthsShort[(int) date('n', $cursor)] . ' ' . date('y', $cursor), + 'orders' => 0, + 'tickets' => 0, + 'present' => 0, + 'lost' => 0, + 'cancelled' => 0, + 'reprog' => 0, + 'cap_past' => 0, + 'booked_past' => 0, + ]; + $cursor = strtotime($weekly ? '+1 week' : '+1 month', $cursor); +} + +foreach ($orderRows as $o) { + $bk = $bucketKey($o['d']); + if (isset($buckets[$bk])) { + $buckets[$bk]['orders']++; + $buckets[$bk]['tickets'] += (int) $o['nticket']; + } +} + +/* =========================================================== AGGREGAZIONE PER CLASSE */ + +$byClass = []; +$totCapPast = $totBookedPast = $totSessions = $totPast = $totFull = 0; + +foreach ($sessions as $s) { + $sid = (int) $s['idservice']; + $isPast = $s['dateschedule'] <= $now; + $cap = (int) $s['maxcapacity']; + $booked = (int) $s['booked']; + + if (!isset($byClass[$sid])) { + $byClass[$sid] = [ + 'name' => $s['servicename'], + 'color' => $s['colorclass'] ?: '#1ebf73', + 'day' => $s['day'], + 'time' => $s['time'], + 'sessions' => 0, + 'past' => 0, + 'booked' => 0, + 'booked_past' => 0, + 'present_past' => 0, + 'cap_past' => 0, + 'full' => 0, + 'lost' => 0, + 'cancelled' => 0, + 'reprog' => 0, + 'waiting' => 0, + ]; + } + $c = &$byClass[$sid]; + $c['sessions']++; + $c['booked'] += $booked; + $c['lost'] += (int) $s['lost']; + $c['cancelled'] += (int) $s['cancelled']; + $c['reprog'] += (int) $s['reprog']; + $c['waiting'] += (int) $s['waiting']; + $totSessions++; + + if ($isPast) { + $c['past']++; + $c['booked_past'] += $booked; + $c['present_past'] += (int) $s['present']; + $totPast++; + if ($cap > 0) { + $c['cap_past'] += $cap; + $totCapPast += $cap; + $totBookedPast += $booked; + if ($booked >= $cap) { + $c['full']++; + $totFull++; + } + } + } + unset($c); + + $bk = $bucketKey($s['dateschedule']); + if (isset($buckets[$bk])) { + $buckets[$bk]['present'] += (int) $s['present']; + $buckets[$bk]['lost'] += (int) $s['lost']; + $buckets[$bk]['cancelled'] += (int) $s['cancelled']; + $buckets[$bk]['reprog'] += (int) $s['reprog']; + if ($isPast && $cap > 0) { + $buckets[$bk]['cap_past'] += $cap; + $buckets[$bk]['booked_past'] += $booked; + } + } +} + +foreach ($byClass as &$c) { + $c['fill'] = pct($c['booked_past'], $c['cap_past']); + $c['avg'] = $c['past'] > 0 ? round($c['present_past'] / $c['past'], 1) : null; +} +unset($c); + +uasort($byClass, function ($a, $b) { + return ($b['fill'] ?? -1) <=> ($a['fill'] ?? -1); +}); + +$fillTotal = pct($totBookedPast, $totCapPast); +$lostRate = pct((int) $k['lost'], (int) $k['booked'] + (int) $k['lost']); + +/* =========================================================== CLASSIFICA UTENTI */ + +$topUsers = fetchAllQ($pdo, "SELECT u.id, u.first_name, u.last_name, u.email, + COALESCE(SUM(bc.status = 'booked' AND bc.lostlesson = 'N' AND ss.dateschedule <= :now1), 0) AS attended, + COALESCE(SUM(bc.status = 'booked' AND bc.lostlesson = 'N' AND ss.dateschedule > :now2), 0) AS upcoming, + COALESCE(SUM(bc.lostlesson = 'Y' AND bc.status <> 'cancelled'), 0) AS lost, + COALESCE(SUM(bc.is_reprogrammed = 'Y' AND bc.status <> 'cancelled'), 0) AS reprog, + COALESCE(SUM(bc.status = 'cancelled'), 0) AS cancelled, + MAX(CASE WHEN bc.status = 'booked' AND bc.lostlesson = 'N' AND ss.dateschedule <= :now3 THEN ss.dateschedule END) AS last_lesson + FROM bookingclass bc + JOIN serviceschedule ss ON bc.idserviceschedule = ss.idserviceschedule + JOIN service s ON ss.idservice = s.idservice + JOIN auth_users u ON u.id = bc.iduser + WHERE ss.dateschedule >= :d1 AND ss.dateschedule < :d2 $sf + GROUP BY u.id, u.first_name, u.last_name, u.email + HAVING (attended + upcoming + lost + cancelled) > 0 + ORDER BY " . $sortMap[$topSort][1] . ", u.last_name, u.first_name + LIMIT " . $topLimit, $P + [':now1' => $now, ':now2' => $now, ':now3' => $now]); + +/* =========================================================== DATI GRAFICI */ + +$chart = [ + 'labels' => [], + 'orders' => [], + 'tickets' => [], + 'present' => [], + 'lost' => [], + 'cancelled' => [], + 'reprog' => [], + 'fill' => [], + 'classLabels' => [], + 'classFill' => [], + 'classColors' => [], +]; +foreach ($buckets as $b) { + $chart['labels'][] = $b['label']; + $chart['orders'][] = $b['orders']; + $chart['tickets'][] = $b['tickets']; + $chart['present'][] = $b['present']; + $chart['lost'][] = $b['lost']; + $chart['cancelled'][] = $b['cancelled']; + $chart['reprog'][] = $b['reprog']; + $chart['fill'][] = pct($b['booked_past'], $b['cap_past']); +} +foreach ($byClass as $c) { + if ($c['fill'] === null) continue; + $chart['classLabels'][] = $c['name'] . ($c['day'] ? ' · ' . $c['day'] . ' ' . $c['time'] : ''); + $chart['classFill'][] = $c['fill']; + $chart['classColors'][] = $c['color']; +} + +/* =========================================================== LINK FILTRI */ + +$baseFilters = array_filter([ + 'service' => $fService ?: null, + 'category' => $fCat ?: null, + 'teacher' => $fTeacher ?: null, + 'top' => $topLimit !== 10 ? $topLimit : null, + 'sort' => $topSort !== 'attended' ? $topSort : null, +]); +$periodLabel = date('d/m/Y', strtotime($from)) . ' – ' . date('d/m/Y', strtotime($to)); +?> + + + + + + YogiBook - Statistiche + + + + + + + + + + + + + + +
    +
    + +
    + +
    + +
    +
    + +
    +
    +
    + +
    +
    +
    + + +
    +
    + $plabel) : ?> + + + + Personalizzato + +
    + +
    + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    + 1) : ?> +
    + + +
    + +
    + + Azzera +
    +
    + + +
    +
    + Periodo: + · filtri attivi +
    +
    + + +
    Ordini
    +
    + +
    + +
    Lezioni e persone
    +
    + 0 ? ' · ' . n($k['gifts']) . ' omaggio' : '')); + echo kpi('fas fa-chart-pie', fillColor($fillTotal), fmtPct($fillTotal), 'Riempimento medio', $totPast > 0 ? 'su ' . n($totPast) . ' lezioni svolte' : 'nessuna lezione svolta'); + echo kpi('fas fa-fire', '#c0392b', n($totFull), 'Lezioni al completo', $totPast > 0 ? fmtPct(pct($totFull, $totPast)) . ' delle lezioni svolte' : ''); + ?> +
    + +
    Riprogrammazioni, perse e cancellate
    +
    + +
    + + +
    +
    +
    +
    Andamento lezioni
    +
    +
    +
    +
    +
    +
    Andamento ordini
    +
    +
    +
    +
    +
    +
    Riempimento nel tempo lezioni svolte
    +
    +
    +
    +
    +
    +
    Riempimento per classe lezioni svolte
    + +
    Nessuna lezione svolta nel periodo.
    + +
    + +
    +
    +
    + + +
    +
    Dettaglio per classe ordinato per riempimento
    + +
    Nessuna classe programmata nel periodo.
    + +
    + + + + + + + + + + + + + + + + + 0, 'sessions' => 0, 'booked' => 0, 'present_past' => 0, 'full' => 0, 'lost' => 0, 'cancelled' => 0, 'reprog' => 0, 'waiting' => 0]; + foreach ($byClass as $c) : + foreach ($sum as $sk => $_) $sum[$sk] += $c[$sk]; + ?> + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    ClasseLezioni
    svolte / tot.
    PrenotazioniMedia presentiRiempimentoAl completoPerseCancellateRiprogr.In coda
    + + +
    +
    / + + +
    Totale / 0 ? number_format($sum['present_past'] / $sum['past'], 1, ',', '.') : '–'; ?>
    +
    +
    Prenotazioni = posti occupati (incluse le perse). Media presenti e riempimento considerano solo le lezioni già svolte.
    + +
    + + +
    +
    +
    Classifica utenti nel periodo
    +
    + + + +
    +
    + +
    Nessuna prenotazione nel periodo.
    + +
    + + + + + + + + + + + + + + + + $u) : + $pos = $i + 1; + $presence = pct((int) $u['attended'], (int) $u['attended'] + (int) $u['lost']); + ?> + + + + + + + + + + + + + +
    #UtenteFrequentatePrenotate futurePerseRiprogr.CancellatePresenzaUltima lezione
    +
    + +
    0 ? '' . n($u['lost']) . '' : '0'; ?> + +
    +
    +
    Presenza = lezioni frequentate / (frequentate + perse).
    + +
    + +
    + +
    +
    +
    + + + + + + + + + + + \ No newline at end of file diff --git a/public/update_reschedule.php b/public/update_reschedule.php new file mode 100644 index 0000000..86c047d --- /dev/null +++ b/public/update_reschedule.php @@ -0,0 +1,65 @@ + 'error', 'message' => 'Metodo non consentito.'], 405); +} + +$allowedFields = ['maxreschedule', 'reprogrammed']; +$field = $_POST['field'] ?? ''; +$orderId = (int) ($_POST['order_id'] ?? 0); +$value = (string) ($_POST['value'] ?? ''); + +if (!in_array($field, $allowedFields, true)) { + jsonOut(['status' => 'error', 'message' => 'Campo non valido.'], 400); +} +if ($orderId <= 0) { + jsonOut(['status' => 'error', 'message' => 'ID ordine non valido.'], 400); +} +if (!preg_match('/^\d{1,3}$/', $value)) { + jsonOut(['status' => 'error', 'message' => 'Inserisci un numero intero da 0 a 999.'], 400); +} + +try { + $pdo = DBHandlerSelect::getInstance()->getConnection(); + + $check = $pdo->prepare("SELECT idorderbook FROM orderbook WHERE idorderbook = :id"); + $check->execute([':id' => $orderId]); + if (!$check->fetch()) { + jsonOut(['status' => 'error', 'message' => 'Ordine non trovato.'], 404); + } + + // $field è da whitelist, quindi sicuro da inserire nella query + $upd = $pdo->prepare("UPDATE orderbook SET `$field` = :v WHERE idorderbook = :id"); + $upd->execute([':v' => (int) $value, ':id' => $orderId]); + + $row = $pdo->prepare("SELECT maxreschedule, reprogrammed FROM orderbook WHERE idorderbook = :id"); + $row->execute([':id' => $orderId]); + $r = $row->fetch(); + + jsonOut([ + 'status' => 'success', + 'message' => 'Valore aggiornato.', + 'maxreschedule' => $r['maxreschedule'] === null ? null : (int) $r['maxreschedule'], + 'reprogrammed' => (int) $r['reprogrammed'], + ]); +} catch (Throwable $e) { + error_log('update_reschedule: ' . $e->getMessage()); + jsonOut(['status' => 'error', 'message' => 'Errore durante il salvataggio.'], 500); +} diff --git a/public/userpanel.php b/public/userpanel.php index 1ec6bc1..b62e1c7 100644 --- a/public/userpanel.php +++ b/public/userpanel.php @@ -59,7 +59,7 @@ $stmtBooked->execute([ $bookedRows = $stmtBooked->fetchAll(); // Lezioni in attesa di conferma (pending) dell'utente, nel mese visualizzato -$sqlPending = "SELECT bookingclass.*, service.servicename, service.colorclass, serviceschedule.dateschedule +$sqlPending = "SELECT bookingclass.*, service.servicename, service.colorclass, service.idteacher, serviceschedule.dateschedule FROM bookingclass LEFT JOIN service ON bookingclass.idservice = service.idservice LEFT JOIN serviceschedule ON bookingclass.idserviceschedule = serviceschedule.idserviceschedule @@ -75,6 +75,40 @@ $stmtPending->execute([ ]); $pendingRows = $stmtPending->fetchAll(); +// Lezioni in lista d'attesa (waiting) dell'utente, nel mese visualizzato +$sqlWaiting = "SELECT bookingclass.*, service.servicename, service.colorclass, service.maxcapacity, service.idteacher, serviceschedule.dateschedule + FROM bookingclass + LEFT JOIN service ON bookingclass.idservice = service.idservice + LEFT JOIN serviceschedule ON bookingclass.idserviceschedule = serviceschedule.idserviceschedule + WHERE bookingclass.iduser = :iduser + AND bookingclass.status = 'waiting' + AND serviceschedule.dateschedule BETWEEN :monthStart AND DATE_ADD(:monthEnd, INTERVAL 1 DAY) + ORDER BY serviceschedule.dateschedule"; +$stmtWaiting = $pdo->prepare($sqlWaiting); +$stmtWaiting->execute([ + ':iduser' => $iduserlogin, + ':monthStart' => $currentMonthStart, + ':monthEnd' => $currentMonthEnd, +]); +$waitingRows = $stmtWaiting->fetchAll(); + +// Posizione in coda per ciascuna lezione waiting (1-based, ordine per created_at) +$waitingPositions = []; +foreach ($waitingRows as $wrow) { + $sqlPos = "SELECT COUNT(*) AS pos + FROM bookingclass + WHERE idserviceschedule = :sched + AND status = 'waiting' + AND created_at <= :mycreated"; + $stmtPos = $pdo->prepare($sqlPos); + $stmtPos->execute([ + ':sched' => (int) $wrow['idserviceschedule'], + ':mycreated' => $wrow['created_at'], + ]); + $rowPos = $stmtPos->fetch(); + $waitingPositions[(int) $wrow['idbookingclass']] = (int) ($rowPos['pos'] ?? 0); +} + /* ------------------------------------------------------------------------- * 3) Somma dei ticket acquistati dall'utente * ---------------------------------------------------------------------- */ @@ -123,6 +157,18 @@ $pending = (int) ($rowCounts['pending'] ?? 0); $toprogram = $totalTickets - $passedRecords - $futureRecords - $pending - $lost; +/* ------------------------------------------------------------------------- + * 4b) Insegnanti nelle card (solo se attivo nelle Opzioni) + * ---------------------------------------------------------------------- */ +$optShow = $pdo->query("SELECT show_teacher FROM `option` LIMIT 1")->fetch(); +$showTeacher = ($optShow['show_teacher'] ?? 'N') === 'Y'; + +$teachersById = []; +if ($showTeacher) { + foreach ($pdo->query("SELECT idemployee, nameemployee, surnameemployee, photo FROM employee")->fetchAll() as $t) { + $teachersById[(int) $t['idemployee']] = $t; + } +} /* ------------------------------------------------------------------------- * 5) Pre-caricamento limiti di riprogrammazione per gli ordini coinvolti * (una sola query invece di una per ogni card, dentro il loop) @@ -157,6 +203,22 @@ function e($value): string return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); } +function teacherLine(array $row, bool $show, array $teachersById): string +{ + if (!$show) return ''; + $t = $teachersById[(int) ($row['idteacher'] ?? 0)] ?? null; + if (!$t) return ''; + + $name = trim($t['nameemployee'] . ' ' . $t['surnameemployee']); + if (!empty($t['photo'])) { + $avatar = ''; + } else { + $ini = mb_strtoupper(mb_substr($t['nameemployee'], 0, 1) . mb_substr($t['surnameemployee'], 0, 1)); + $avatar = '' . e($ini) . ''; + } + return '

    ' . $avatar . 'Insegnante: ' . e($name) . '

    '; +} + $italianMonths = [ "January" => "Gennaio", "February" => "Febbraio", @@ -233,8 +295,8 @@ $italianMonths = [ .custom-date-box { flex: 0 0 110px; - background: #1ebf73; - color: #fff; + background: #E1D0B8; + color: #ffffff; display: flex; flex-direction: column; justify-content: center; @@ -271,6 +333,33 @@ $italianMonths = [ margin: 0 0 4px; } + .teacher-line { + display: flex; + align-items: center; + gap: 8px; + margin: 4px 0 6px; + } + + .teacher-line strong { + color: #374151; + font-weight: 600; + } + + .teacher-avatar-sm { + flex: 0 0 26px; + width: 26px; + height: 26px; + border-radius: 50%; + object-fit: cover; + background: #E1D0B8; + color: #5a4630; + font-size: 11px; + font-weight: 800; + display: inline-flex; + align-items: center; + justify-content: center; + } + .custom-paragraph { color: #6b7280; font-size: 14px; @@ -541,6 +630,27 @@ $italianMonths = [ + + + + + + + + + @@ -636,6 +747,59 @@ $italianMonths = [ + +
    +
    +
    +
    +
    + In lista d'attesa +
    + format("d"); + $wMonth = $italianMonths[$wDateObj->format("F")]; + $wWhen = $wDateObj->format("d-m-Y H:i"); + $wColor = $wrow['colorclass'] ?? '#1ebf73'; + $wName = $wrow['servicename'] ?? ''; + $wPos = $waitingPositions[(int) $wrow['idbookingclass']] ?? 0; + ?> +
    +
    +
    +
    +
    +
    +

    + + In coda +

    +

    Quando:

    + +

    Luogo: via Valassina 62/B Seregno - Sala Contesto Yoga

    + 0) : ?> +

    + La tua posizione in coda: +

    + +

    + Se si libera un posto verrai avvisata/o. Non serve fare nulla. +

    +
    + +
    +
    +
    + +
    +
    +
    +
    + +
    @@ -694,6 +858,7 @@ $italianMonths = [

    Quando:

    +

    Luogo: via Valassina 62/B Seregno - Sala Contesto Yoga

    '; + document.body.appendChild(lb); + function close() { + lb.classList.remove("yse-open"); + lb.querySelector("img").src = ""; + } + lb.addEventListener("click", function (e) { + if (e.target === lb || e.target.classList.contains("yse-lbclose")) + close(); + }); + document.addEventListener("keydown", function (e) { + if (e.key === "Escape") close(); + }); + return lb; + } + + function init() { + document.querySelectorAll(".yse-card").forEach(initCard); + document.querySelectorAll(".yse-gallery img").forEach(function (img) { + img.addEventListener("click", function () { + var lb = ensureLightbox(); + lb.querySelector("img").src = img.getAttribute("data-full"); + lb.classList.add("yse-open"); + }); + }); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init); + } else { + init(); + } +})(); diff --git a/wp plugin/yogasoul-eventi/yogasoul-eventi.php b/wp plugin/yogasoul-eventi/yogasoul-eventi.php new file mode 100644 index 0000000..62664a4 --- /dev/null +++ b/wp plugin/yogasoul-eventi/yogasoul-eventi.php @@ -0,0 +1,276 @@ + '', + 'id' => '', + 'limit' => '', + ], $atts, 'yogasoul_eventi'); + + wp_enqueue_style('yse-eventi'); + wp_enqueue_script('yse-eventi'); + + // Costruisce l'URL dell'endpoint lista + $query = []; + if ($atts['id'] !== '') { + $query['id'] = (int) $atts['id']; + } + if ($atts['ids'] !== '') { + $clean = preg_replace('/[^0-9,]/', '', $atts['ids']); + if ($clean !== '') { + $query['ids'] = $clean; + } + } + if ($atts['limit'] !== '') { + $query['limit'] = (int) $atts['limit']; + } + $url = YSE_API_BASE . '/events.php' . (!empty($query) ? '?' . http_build_query($query) : ''); + + // Lettura server-side (niente CORS) + anti-cache + $resp = wp_remote_get($url . (strpos($url, '?') !== false ? '&' : '?') . '_=' . time(), [ + 'timeout' => 8, + 'headers' => ['Cache-Control' => 'no-cache'], + ]); + if (is_wp_error($resp) || (int) wp_remote_retrieve_response_code($resp) !== 200) { + return '

    Eventi non disponibili al momento.

    '; + } + $data = json_decode(wp_remote_retrieve_body($resp), true); + if (!is_array($data) || empty($data['ok'])) { + return '

    Eventi non disponibili al momento.

    '; + } + if (empty($data['events'])) { + return '

    Nessun evento in programma al momento.

    '; + } + + $out = '
    '; + foreach ($data['events'] as $ev) { + $out .= yse_render_card($ev); + } + $out .= '
    '; + return $out; +} + +/* Icona SVG inline (stroke = currentColor) */ +function yse_icon($name) +{ + $p = [ + 'user' => '', + 'pin' => '', + 'clock' => '', + 'heart' => '', + 'bag' => '', + 'info' => '', + 'img' => '', + 'chev' => '', + ]; + $inner = $p[$name] ?? ''; + return '' . $inner . ''; +} + +/* Rende una singola card evento */ +function yse_render_card($ev) +{ + $mesiAbbr = [1 => 'GEN', 2 => 'FEB', 3 => 'MAR', 4 => 'APR', 5 => 'MAG', 6 => 'GIU', 7 => 'LUG', 8 => 'AGO', 9 => 'SET', 10 => 'OTT', 11 => 'NOV', 12 => 'DIC']; + $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']; + + $id = (int) $ev['id']; + $cost = (float) $ev['cost']; + $sym = (string) $ev['currency_symbol']; + $price = $cost > 0 ? $sym . number_format($cost, 2, ',', '.') : 'Gratuito'; + $color = (isset($ev['color']) && preg_match('/^#[0-9a-fA-F]{6}$/', $ev['color'])) ? $ev['color'] : '#4CAF50'; + + $soldOut = !empty($ev['sold_out']); + $waitOn = !empty($ev['waitlist_enabled']); + $waitMode = $soldOut && $waitOn; + $hardFull = $soldOut && !$waitOn; + $maxPer = max(1, (int) $ev['max_per_booking']); + $remaining = (int) $ev['remaining']; + $maxQty = $soldOut ? ($waitMode ? $maxPer : 0) : max(1, min($maxPer, $remaining)); + + $startTs = strtotime($ev['starts_at']); + $dayNum = (int) date('j', $startTs); + $monAbbr = $mesiAbbr[(int) date('n', $startTs)]; + $dateLong = $dayNum . ' ' . $mesi[(int) date('n', $startTs)] . ' ' . date('Y', $startTs); + $timeLabel = date('H:i', $startTs) . (!empty($ev['ends_at']) ? '–' . date('H:i', strtotime($ev['ends_at'])) : ''); + + $address = trim((string) ($ev['address'] ?? '')); + $organizer = trim((string) ($ev['organizer'] ?? '')); + $mapQuery = $address !== '' ? $address : ((isset($ev['lat'], $ev['lng']) && $ev['lat'] && $ev['lng']) ? ($ev['lat'] . ',' . $ev['lng']) : ''); + + $gallery = is_array($ev['gallery'] ?? null) ? $ev['gallery'] : []; + $hasDetails = trim((string) ($ev['description'] ?? '')) !== '' + || trim((string) ($ev['suitable_for'] ?? '')) !== '' + || trim((string) ($ev['what_to_bring'] ?? '')) !== '' + || trim((string) ($ev['instructions'] ?? '')) !== '' + || !empty($ev['cover_image']) || !empty($gallery) || $mapQuery !== ''; + + ob_start(); + ?> +
    + + +
    +
    +
    +
    +
    + + <?php echo esc_attr($ev['title']); ?> + +
    +
    +

    + + + +
    +
    + Capacità / + + + + · +
    +
    +
    +
    + + + +
    +
    + + <?php echo esc_attr($ev['title']); ?> + +
    + +
    +
    + +
    + + + +
    + +
    Per chi è adatto
    + + +
    Cosa portare
    + + +
    Istruzioni
    + +
    + + + +
    +

    Galleria

    + +
    + + + +
    +

    Dove

    + +
    + +
    +
    + + + +
    + +
    Prenotazioni aperte dal
    + +
    Evento esaurito — non ci sono più posti disponibili.
    + + 0 + ? ' Al momento ' . ($waitingCount === 1 ? "c'è 1 persona" : "ci sono {$waitingCount} persone") . ' in coda.' + : ''; + ?> +
    Posti esauriti — puoi iscriverti alla lista d'attesa: ti avviseremo se si libera un posto.
    + +
    +
    +
    +
    Persone
    +
    + + 1 + +
    +
    +
    +
    Totale
    +
    +
    + +
    + +
    +
    +
    +
    +
    +
    + + + + + +
    +
    + +
    +
    +