Merge branch 'feature/waiting-list'

This commit is contained in:
2026-10-03 14:12:52 +02:00
78 changed files with 11378 additions and 388 deletions
+1 -1
View File
@@ -40,7 +40,7 @@
"laravel/tinker": "^2.7", "laravel/tinker": "^2.7",
"laravel/ui": "^4.0", "laravel/ui": "^4.0",
"laravelcollective/html": "^6.3", "laravelcollective/html": "^6.3",
"phpmailer/phpmailer": "^6.8", "phpmailer/phpmailer": "^7.1",
"proengsoft/laravel-jsvalidation": "^4.0.0", "proengsoft/laravel-jsvalidation": "^4.0.0",
"robmorgan/phinx": "^0.16.12", "robmorgan/phinx": "^0.16.12",
"spatie/laravel-query-builder": "^5.0", "spatie/laravel-query-builder": "^5.0",
Generated
+11 -10
View File
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "9b689efb6d29cb572794705682d6edc4", "content-hash": "9307a6b8f5f1c5b0a5efb10e35038f7f",
"packages": [ "packages": [
{ {
"name": "akaunting/laravel-setting", "name": "akaunting/laravel-setting",
@@ -3428,16 +3428,16 @@
}, },
{ {
"name": "phpmailer/phpmailer", "name": "phpmailer/phpmailer",
"version": "v6.9.3", "version": "v7.1.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/PHPMailer/PHPMailer.git", "url": "https://github.com/PHPMailer/PHPMailer.git",
"reference": "2f5c94fe7493efc213f643c23b1b1c249d40f47e" "reference": "1bc1716a507a65e039d4ac9d9adebbbd0d346e15"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/2f5c94fe7493efc213f643c23b1b1c249d40f47e", "url": "https://api.github.com/repos/PHPMailer/PHPMailer/zipball/1bc1716a507a65e039d4ac9d9adebbbd0d346e15",
"reference": "2f5c94fe7493efc213f643c23b1b1c249d40f47e", "reference": "1bc1716a507a65e039d4ac9d9adebbbd0d346e15",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -3451,13 +3451,14 @@
"doctrine/annotations": "^1.2.6 || ^1.13.3", "doctrine/annotations": "^1.2.6 || ^1.13.3",
"php-parallel-lint/php-console-highlighter": "^1.0.0", "php-parallel-lint/php-console-highlighter": "^1.0.0",
"php-parallel-lint/php-parallel-lint": "^1.3.2", "php-parallel-lint/php-parallel-lint": "^1.3.2",
"phpcompatibility/php-compatibility": "^9.3.5", "phpcompatibility/php-compatibility": "^10.0.0@dev",
"roave/security-advisories": "dev-latest", "squizlabs/php_codesniffer": "^3.13.5",
"squizlabs/php_codesniffer": "^3.7.2",
"yoast/phpunit-polyfills": "^1.0.4" "yoast/phpunit-polyfills": "^1.0.4"
}, },
"suggest": { "suggest": {
"decomplexity/SendOauth2": "Adapter for using XOAUTH2 authentication", "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-mbstring": "Needed to send email in multibyte encoding charset or decode encoded addresses",
"ext-openssl": "Needed for secure SMTP sending and DKIM signing", "ext-openssl": "Needed for secure SMTP sending and DKIM signing",
"greew/oauth2-azure-provider": "Needed for Microsoft Azure XOAUTH2 authentication", "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", "description": "PHPMailer is a full-featured email creation and transfer class for PHP",
"support": { "support": {
"issues": "https://github.com/PHPMailer/PHPMailer/issues", "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": [ "funding": [
{ {
@@ -3505,7 +3506,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2024-11-24T18:04:13+00:00" "time": "2026-05-18T08:06:14+00:00"
}, },
{ {
"name": "phpoption/phpoption", "name": "phpoption/phpoption",
+447
View File
@@ -0,0 +1,447 @@
<?php
/**
* YogiBook - Riepilogo classi giornaliero.
* Invia UNA mail per ogni classe del giorno:
* - all'insegnante della classe (se notifiche attive, attivo e con email)
* -> 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 '<span style="display:inline-block;background:' . $bg . ';color:' . $fg . ';font-size:11px;font-weight:700;padding:3px 10px;border-radius:20px;white-space:nowrap;">' . h($label) . '</span>';
}
/** 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 '<tr><td style="padding:16px;color:#9ca3af;font-size:14px;text-align:center;">Nessun partecipante</td></tr>';
}
$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']))
? '<br><span style="font-size:12px;color:#6b7280;font-weight:400;">' . h($p['email']) . '</span>'
: '';
$html .= '<tr><td style="padding:10px 14px;border-bottom:1px solid #eef0f3;">'
. '<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr>'
. '<td width="34" valign="top" style="font-weight:800;color:' . $numColor . ';font-size:14px;padding-top:1px;">' . $i . ($type === 'waiting' ? '°' : '.') . '</td>'
. '<td valign="top" style="font-size:15px;color:' . $nameColor . ';font-weight:600;">' . h(personName($p, $full)) . $email . '</td>'
. '<td valign="top" align="right" width="100">' . $chipHtml . '</td>'
. '</tr></table></td></tr>';
$i++;
}
return $html;
}
function section(string $title, string $color, string $rows): string
{
return '<tr><td style="padding:22px 28px 0;">'
. '<div style="font-size:13px;font-weight:700;color:' . $color . ';text-transform:uppercase;letter-spacing:0.04em;margin-bottom:8px;">' . $title . '</div>'
. '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f8fafc;border:1px solid #eef0f3;border-radius:10px;">' . $rows . '</table>'
. '</td></tr>';
}
function statBox(string $value, string $label, string $color): string
{
return '<td align="center" style="padding:12px 6px;background:#f8fafc;border-radius:10px;">'
. '<div style="font-size:22px;font-weight:800;color:' . $color . ';line-height:1.1;">' . $value . '</div>'
. '<div style="font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.04em;margin-top:4px;">' . $label . '</div>'
. '</td>';
}
/**
* @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 = '<table role="presentation" width="100%" cellpadding="0" cellspacing="6"><tr>'
. 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')
. '</tr></table>';
$lostNote = $nLost > 0
? '<div style="font-size:13px;color:#6b7280;margin-top:6px;">di cui ' . $nLost . ' segnat' . ($nLost === 1 ? 'a' : 'e') . ' come pers' . ($nLost === 1 ? 'a' : 'e') . '</div>'
: '';
$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
? '<tr><td style="padding:14px 28px 0;"><div style="background:#f5eee4;color:#5a4630;border-radius:10px;padding:10px 14px;font-size:13px;">' . $copyNote . '</div></td></tr>'
: '';
$teacherLine = $teacherFull !== ''
? '<div style="font-size:13px;color:#6b7280;margin-top:4px;">Insegnante: <strong style="color:#374151;">' . h($teacherFull) . '</strong></div>'
: '';
return '<!doctype html><html lang="it"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>' . h($cls['servicename']) . '</title></head>'
. '<body style="margin:0;padding:0;background:#f3f5f7;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;">'
. '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f3f5f7;padding:24px 12px;"><tr><td align="center">'
. '<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:14px;overflow:hidden;box-shadow:0 3px 16px rgba(0,0,0,0.06);">'
. '<tr><td style="height:8px;background:' . h($accent) . ';font-size:0;line-height:0;">&nbsp;</td></tr>'
. $copyBanner
. '<tr><td style="padding:26px 28px 6px;">'
. '<div style="font-size:12px;font-weight:700;color:#1ebf73;text-transform:uppercase;letter-spacing:0.06em;">YogiBook · Riepilogo classe</div>'
. '<h1 style="margin:8px 0 6px;font-size:24px;color:#1f2937;">' . h($cls['servicename']) . '</h1>'
. '<div style="font-size:15px;color:#374151;font-weight:600;">' . h(italianDate($d)) . ' · ore ' . $d->format('H:i') . '</div>'
. '<div style="font-size:13px;color:#6b7280;margin-top:4px;">' . h($location) . '</div>'
. $teacherLine
. '</td></tr>'
. '<tr><td style="padding:16px 22px 0;">' . $stats . '<div style="padding:0 6px;">' . $lostNote . '</div></td></tr>'
. $body
. '<tr><td style="padding:26px 28px;font-size:12px;color:#9ca3af;text-align:center;">Generato automaticamente da YogiBook il ' . date('d/m/Y \a\l\l\e H:i') . '</td></tr>'
. '</table></td></tr></table></body></html>';
}
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 !== ''
? '<strong>Copia per il titolare</strong> · classe di ' . h($tName)
: '<strong>Copia per il titolare</strong> · 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);
+1
View File
@@ -0,0 +1 @@
<!doctype html><html lang="it"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Hatha Yoga</title></head><body style="margin:0;padding:0;background:#f3f5f7;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f3f5f7;padding:24px 12px;"><tr><td align="center"><table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:14px;overflow:hidden;box-shadow:0 3px 16px rgba(0,0,0,0.06);"><tr><td style="height:8px;background:#fa9b9b;font-size:0;line-height:0;">&nbsp;</td></tr><tr><td style="padding:26px 28px 6px;"><div style="font-size:12px;font-weight:700;color:#1ebf73;text-transform:uppercase;letter-spacing:0.06em;">YogiBook · Riepilogo classe</div><h1 style="margin:8px 0 6px;font-size:24px;color:#1f2937;">Hatha Yoga</h1><div style="font-size:15px;color:#374151;font-weight:600;">Lunedì 5 Ottobre 2026 · ore 19:30</div><div style="font-size:13px;color:#6b7280;margin-top:4px;">via Valassina 62/B Seregno - Sala Contesto Yoga</div></td></tr><tr><td style="padding:16px 22px 0;"><table role="presentation" width="100%" cellpadding="0" cellspacing="6"><tr><td align="center" style="padding:12px 6px;background:#f8fafc;border-radius:10px;"><div style="font-size:22px;font-weight:800;color:#c0392b;line-height:1.1;">1/1</div><div style="font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.04em;margin-top:4px;">Iscritti</div></td><td align="center" style="padding:12px 6px;background:#f8fafc;border-radius:10px;"><div style="font-size:22px;font-weight:800;color:#374151;line-height:1.1;">0</div><div style="font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.04em;margin-top:4px;">Posti liberi</div></td><td align="center" style="padding:12px 6px;background:#f8fafc;border-radius:10px;"><div style="font-size:22px;font-weight:800;color:#8a6d00;line-height:1.1;">0</div><div style="font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.04em;margin-top:4px;">In attesa</div></td><td align="center" style="padding:12px 6px;background:#f8fafc;border-radius:10px;"><div style="font-size:22px;font-weight:800;color:#d1698a;line-height:1.1;">0</div><div style="font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.04em;margin-top:4px;">In coda</div></td></tr></table><div style="padding:0 6px;"></div></td></tr><tr><td style="padding:22px 28px 0;"><div style="font-size:13px;font-weight:700;color:#374151;text-transform:uppercase;letter-spacing:0.04em;margin-bottom:8px;">Partecipanti confermati (1)</div><table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f8fafc;border:1px solid #eef0f3;border-radius:10px;"><tr><td style="padding:10px 14px;border-bottom:1px solid #eef0f3;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td width="34" valign="top" style="font-weight:800;color:#1ebf73;font-size:14px;padding-top:1px;">1.</td><td valign="top" style="font-size:15px;color:#1f2937;font-weight:600;">Amelia Benucci<br><span style="font-size:12px;color:#6b7280;font-weight:400;">info@claudiosironi.com</span></td><td valign="top" align="right" width="100"><span style="display:inline-block;background:#e3f2ec;color:#1a7f52;font-size:11px;font-weight:700;padding:3px 10px;border-radius:20px;white-space:nowrap;">Confermata</span></td></tr></table></td></tr></table></td></tr><tr><td style="padding:26px 28px;font-size:12px;color:#9ca3af;text-align:center;">Generato automaticamente da YogiBook il 01/10/2026 alle 11:15</td></tr></table></td></tr></table></body></html>
+1
View File
@@ -0,0 +1 @@
<!doctype html><html lang="it"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Hatha Yoga</title></head><body style="margin:0;padding:0;background:#f3f5f7;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f3f5f7;padding:24px 12px;"><tr><td align="center"><table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:14px;overflow:hidden;box-shadow:0 3px 16px rgba(0,0,0,0.06);"><tr><td style="height:8px;background:#fa9b9b;font-size:0;line-height:0;">&nbsp;</td></tr><tr><td style="padding:26px 28px 6px;"><div style="font-size:12px;font-weight:700;color:#1ebf73;text-transform:uppercase;letter-spacing:0.06em;">YogiBook · Riepilogo classe</div><h1 style="margin:8px 0 6px;font-size:24px;color:#1f2937;">Hatha Yoga</h1><div style="font-size:15px;color:#374151;font-weight:600;">Lunedì 5 Ottobre 2026 · ore 19:30</div><div style="font-size:13px;color:#6b7280;margin-top:4px;">via Valassina 62/B Seregno - Sala Contesto Yoga</div><div style="font-size:13px;color:#6b7280;margin-top:4px;">Insegnante: <strong style="color:#374151;">Francesca -</strong></div></td></tr><tr><td style="padding:16px 22px 0;"><table role="presentation" width="100%" cellpadding="0" cellspacing="6"><tr><td align="center" style="padding:12px 6px;background:#f8fafc;border-radius:10px;"><div style="font-size:22px;font-weight:800;color:#c0392b;line-height:1.1;">1/1</div><div style="font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.04em;margin-top:4px;">Iscritti</div></td><td align="center" style="padding:12px 6px;background:#f8fafc;border-radius:10px;"><div style="font-size:22px;font-weight:800;color:#374151;line-height:1.1;">0</div><div style="font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.04em;margin-top:4px;">Posti liberi</div></td><td align="center" style="padding:12px 6px;background:#f8fafc;border-radius:10px;"><div style="font-size:22px;font-weight:800;color:#8a6d00;line-height:1.1;">0</div><div style="font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.04em;margin-top:4px;">In attesa</div></td><td align="center" style="padding:12px 6px;background:#f8fafc;border-radius:10px;"><div style="font-size:22px;font-weight:800;color:#d1698a;line-height:1.1;">0</div><div style="font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.04em;margin-top:4px;">In coda</div></td></tr></table><div style="padding:0 6px;"></div></td></tr><tr><td style="padding:22px 28px 0;"><div style="font-size:13px;font-weight:700;color:#374151;text-transform:uppercase;letter-spacing:0.04em;margin-bottom:8px;">Partecipanti confermati (1)</div><table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f8fafc;border:1px solid #eef0f3;border-radius:10px;"><tr><td style="padding:10px 14px;border-bottom:1px solid #eef0f3;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td width="34" valign="top" style="font-weight:800;color:#1ebf73;font-size:14px;padding-top:1px;">1.</td><td valign="top" style="font-size:15px;color:#1f2937;font-weight:600;">Amelia B.</td><td valign="top" align="right" width="100"><span style="display:inline-block;background:#e3f2ec;color:#1a7f52;font-size:11px;font-weight:700;padding:3px 10px;border-radius:20px;white-space:nowrap;">Confermata</span></td></tr></table></td></tr></table></td></tr><tr><td style="padding:26px 28px;font-size:12px;color:#9ca3af;text-align:center;">Generato automaticamente da YogiBook il 01/10/2026 alle 11:30</td></tr></table></td></tr></table></body></html>
+1
View File
@@ -0,0 +1 @@
<!doctype html><html lang="it"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Hatha Yoga</title></head><body style="margin:0;padding:0;background:#f3f5f7;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f3f5f7;padding:24px 12px;"><tr><td align="center"><table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:14px;overflow:hidden;box-shadow:0 3px 16px rgba(0,0,0,0.06);"><tr><td style="height:8px;background:#fa9b9b;font-size:0;line-height:0;">&nbsp;</td></tr><tr><td style="padding:14px 28px 0;"><div style="background:#f5eee4;color:#5a4630;border-radius:10px;padding:10px 14px;font-size:13px;"><strong>Copia per il titolare</strong> · classe di Francesca -</div></td></tr><tr><td style="padding:26px 28px 6px;"><div style="font-size:12px;font-weight:700;color:#1ebf73;text-transform:uppercase;letter-spacing:0.06em;">YogiBook · Riepilogo classe</div><h1 style="margin:8px 0 6px;font-size:24px;color:#1f2937;">Hatha Yoga</h1><div style="font-size:15px;color:#374151;font-weight:600;">Lunedì 5 Ottobre 2026 · ore 19:30</div><div style="font-size:13px;color:#6b7280;margin-top:4px;">via Valassina 62/B Seregno - Sala Contesto Yoga</div><div style="font-size:13px;color:#6b7280;margin-top:4px;">Insegnante: <strong style="color:#374151;">Francesca -</strong></div></td></tr><tr><td style="padding:16px 22px 0;"><table role="presentation" width="100%" cellpadding="0" cellspacing="6"><tr><td align="center" style="padding:12px 6px;background:#f8fafc;border-radius:10px;"><div style="font-size:22px;font-weight:800;color:#c0392b;line-height:1.1;">1/1</div><div style="font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.04em;margin-top:4px;">Iscritti</div></td><td align="center" style="padding:12px 6px;background:#f8fafc;border-radius:10px;"><div style="font-size:22px;font-weight:800;color:#374151;line-height:1.1;">0</div><div style="font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.04em;margin-top:4px;">Posti liberi</div></td><td align="center" style="padding:12px 6px;background:#f8fafc;border-radius:10px;"><div style="font-size:22px;font-weight:800;color:#8a6d00;line-height:1.1;">0</div><div style="font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.04em;margin-top:4px;">In attesa</div></td><td align="center" style="padding:12px 6px;background:#f8fafc;border-radius:10px;"><div style="font-size:22px;font-weight:800;color:#d1698a;line-height:1.1;">0</div><div style="font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.04em;margin-top:4px;">In coda</div></td></tr></table><div style="padding:0 6px;"></div></td></tr><tr><td style="padding:22px 28px 0;"><div style="font-size:13px;font-weight:700;color:#374151;text-transform:uppercase;letter-spacing:0.04em;margin-bottom:8px;">Partecipanti confermati (1)</div><table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f8fafc;border:1px solid #eef0f3;border-radius:10px;"><tr><td style="padding:10px 14px;border-bottom:1px solid #eef0f3;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td width="34" valign="top" style="font-weight:800;color:#1ebf73;font-size:14px;padding-top:1px;">1.</td><td valign="top" style="font-size:15px;color:#1f2937;font-weight:600;">Amelia Benucci<br><span style="font-size:12px;color:#6b7280;font-weight:400;">info@claudiosironi.com</span></td><td valign="top" align="right" width="100"><span style="display:inline-block;background:#e3f2ec;color:#1a7f52;font-size:11px;font-weight:700;padding:3px 10px;border-radius:20px;white-space:nowrap;">Confermata</span></td></tr></table></td></tr></table></td></tr><tr><td style="padding:26px 28px;font-size:12px;color:#9ca3af;text-align:center;">Generato automaticamente da YogiBook il 01/10/2026 alle 11:30</td></tr></table></td></tr></table></body></html>
+1
View File
@@ -0,0 +1 @@
<!doctype html><html lang="it"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Aerial Yoga - mix</title></head><body style="margin:0;padding:0;background:#f3f5f7;font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f3f5f7;padding:24px 12px;"><tr><td align="center"><table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;background:#ffffff;border-radius:14px;overflow:hidden;box-shadow:0 3px 16px rgba(0,0,0,0.06);"><tr><td style="height:8px;background:#6ED4E4;font-size:0;line-height:0;">&nbsp;</td></tr><tr><td style="padding:26px 28px 6px;"><div style="font-size:12px;font-weight:700;color:#1ebf73;text-transform:uppercase;letter-spacing:0.06em;">YogiBook · Riepilogo classe</div><h1 style="margin:8px 0 6px;font-size:24px;color:#1f2937;">Aerial Yoga - mix</h1><div style="font-size:15px;color:#374151;font-weight:600;">Giovedì 24 Settembre 2026 · ore 19:00</div><div style="font-size:13px;color:#6b7280;margin-top:4px;">via Valassina 62/B Seregno - Sala Contesto Yoga</div></td></tr><tr><td style="padding:16px 22px 0;"><table role="presentation" width="100%" cellpadding="0" cellspacing="6"><tr><td align="center" style="padding:12px 6px;background:#f8fafc;border-radius:10px;"><div style="font-size:22px;font-weight:800;color:#1a7f52;line-height:1.1;">2/8</div><div style="font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.04em;margin-top:4px;">Iscritti</div></td><td align="center" style="padding:12px 6px;background:#f8fafc;border-radius:10px;"><div style="font-size:22px;font-weight:800;color:#374151;line-height:1.1;">6</div><div style="font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.04em;margin-top:4px;">Posti liberi</div></td><td align="center" style="padding:12px 6px;background:#f8fafc;border-radius:10px;"><div style="font-size:22px;font-weight:800;color:#8a6d00;line-height:1.1;">0</div><div style="font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.04em;margin-top:4px;">In attesa</div></td><td align="center" style="padding:12px 6px;background:#f8fafc;border-radius:10px;"><div style="font-size:22px;font-weight:800;color:#d1698a;line-height:1.1;">0</div><div style="font-size:11px;color:#6b7280;text-transform:uppercase;letter-spacing:0.04em;margin-top:4px;">In coda</div></td></tr></table><div style="padding:0 6px;"></div></td></tr><tr><td style="padding:22px 28px 0;"><div style="font-size:13px;font-weight:700;color:#374151;text-transform:uppercase;letter-spacing:0.04em;margin-bottom:8px;">Partecipanti confermati (2)</div><table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f8fafc;border:1px solid #eef0f3;border-radius:10px;"><tr><td style="padding:10px 14px;border-bottom:1px solid #eef0f3;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td width="34" valign="top" style="font-weight:800;color:#1ebf73;font-size:14px;padding-top:1px;">1.</td><td valign="top" style="font-size:15px;color:#1f2937;font-weight:600;">Amelia Benucci<br><span style="font-size:12px;color:#6b7280;font-weight:400;">info@claudiosironi.com</span></td><td valign="top" align="right" width="100"><span style="display:inline-block;background:#e3f2ec;color:#1a7f52;font-size:11px;font-weight:700;padding:3px 10px;border-radius:20px;white-space:nowrap;">Confermata</span></td></tr></table></td></tr><tr><td style="padding:10px 14px;border-bottom:1px solid #eef0f3;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td width="34" valign="top" style="font-weight:800;color:#1ebf73;font-size:14px;padding-top:1px;">2.</td><td valign="top" style="font-size:15px;color:#1f2937;font-weight:600;">Aurora Masiero<br><span style="font-size:12px;color:#6b7280;font-weight:400;">info@yogasoul.it</span></td><td valign="top" align="right" width="100"><span style="display:inline-block;background:#e3f2ec;color:#1a7f52;font-size:11px;font-weight:700;padding:3px 10px;border-radius:20px;white-space:nowrap;">Confermata</span></td></tr></table></td></tr></table></td></tr><tr><td style="padding:26px 28px;font-size:12px;color:#9ca3af;text-align:center;">Generato automaticamente da YogiBook il 24/09/2026 alle 10:55</td></tr></table></td></tr></table></body></html>
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class AddWaitingListSupport extends AbstractMigration
{
public function change(): void
{
$table = $this->table('bookingclass');
$table
->addColumn('created_at', 'timestamp', [
'default' => 'CURRENT_TIMESTAMP',
'null' => false,
'after' => 'cancellation_token',
])
->update();
}
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class AddWaitingListEnabledToOption extends AbstractMigration
{
public function change(): void
{
$this->table('option')
->addColumn('waitinglist_enabled', 'char', [
'limit' => 1,
'default' => 'N',
'null' => false,
'after' => 'maxbeforetimecancell',
])
->update();
}
}
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
use Phinx\Db\Adapter\MysqlAdapter;
final class CreateEventsTable extends AbstractMigration
{
public function change(): void
{
$table = $this->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();
}
}
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class CreateEventBookingsTable extends AbstractMigration
{
public function change(): void
{
$table = $this->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();
}
}
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class AddEventDefaultsToOptionTable extends AbstractMigration
{
public function change(): void
{
$this->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();
}
}
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class CreateEventLocationsTable extends AbstractMigration
{
public function change(): void
{
$table = $this->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();
}
}
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class AddDetailsAndGalleryToEvents extends AbstractMigration
{
public function change(): void
{
$this->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();
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class AddWaitlistToEvents extends AbstractMigration
{
public function change(): void
{
$this->table('events')
->addColumn('waitlist_enabled', 'char', [
'limit' => 1,
'null' => false,
'default' => 'Y',
'comment' => "Y = lista d'attesa quando pieno",
'after' => 'max_per_booking'
])
->update();
}
}
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class AddEventEmailOptions extends AbstractMigration
{
public function change(): void
{
$this->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();
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class AddReminderAndOfferSettings extends AbstractMigration
{
public function change(): void
{
// Default generali (scuola)
$this->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();
}
}
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class AddPublishAtToEvents extends AbstractMigration
{
public function change(): void
{
$this->table('events')
->addColumn('publish_at', 'datetime', ['null' => true, 'comment' => 'apertura prenotazioni programmata', 'after' => 'status'])
->update();
}
}
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class ExtendEmployeeForTeachers extends AbstractMigration
{
public function up(): void
{
$this->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();
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class AddShowTeacherToOption extends AbstractMigration
{
public function change(): void
{
$this->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();
}
}
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class CreateSchoolsTable extends AbstractMigration
{
public function up(): void
{
$this->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();
}
}
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class CreateSchoolUserTable extends AbstractMigration
{
public function up(): void
{
$this->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();
}
}
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class AddIdschoolToDomainTables extends AbstractMigration
{
/**
* Tabelle applicative che diventano "per scuola".
* Le tabelle auth_* (Vanguard) restano globali: il legame
* utente-scuola passa da school_user.
*/
private const TABLES = [
'associateclass',
'bookingclass',
'certificateuserprofile',
'copyorder',
'copyorder_items',
'dayoff',
'employee',
'events',
'event_bookings',
'event_locations',
'expiryparameter',
'location',
'option',
'orderbook',
'propagation',
'service',
'servicecategory',
'serviceschedule',
'userprofile',
];
public function up(): void
{
foreach (self::TABLES as $name) {
$this->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();
}
}
}
@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class AddIdschoolToDomainTables extends AbstractMigration
{
/**
* Tabelle applicative che diventano "per scuola".
* Le tabelle auth_* (Vanguard) restano globali: il legame
* utente-scuola passa da school_user.
*/
private const TABLES = [
'associateclass',
'bookingclass',
'certificateuserprofile',
'copyorder',
'copyorder_items',
'dayoff',
'employee',
'events',
'event_bookings',
'event_locations',
'expiryparameter',
'location',
'option',
'orderbook',
'propagation',
'service',
'servicecategory',
'serviceschedule',
'userprofile',
];
public function up(): void
{
foreach (self::TABLES as $name) {
$this->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();
}
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class CreateSchoolUserTable extends AbstractMigration
{
public function up(): void
{
$this->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();
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
final class CreateSchoolsTable extends AbstractMigration
{
public function up(): void
{
$this->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();
}
}
+173 -6
View File
@@ -11,9 +11,56 @@ function e($value): string
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); 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'; $message = $_GET['message'] ?? 'N';
$insertFeedback = null; $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 * Inserimento nuova classe (POST) con prepared statement
* ---------------------------------------------------------------------- */ * ---------------------------------------------------------------------- */
@@ -26,14 +73,17 @@ if (isset($_POST['classinsert']) && $_POST['classinsert'] === 'Y') {
$maxcapacity = $_POST['maxcapacity'] ?? ''; $maxcapacity = $_POST['maxcapacity'] ?? '';
$colorclass = trim($_POST['colorclass'] ?? ''); $colorclass = trim($_POST['colorclass'] ?? '');
$classduration = $_POST['classduration'] ?? ''; $classduration = $_POST['classduration'] ?? '';
$idteacher = (int) ($_POST['idteacher'] ?? 0);
if ($servicename === '') { if ($servicename === '') {
$insertFeedback = ['type' => 'error', 'text' => 'La descrizione della classe è obbligatoria.']; $insertFeedback = ['type' => 'error', 'text' => 'La descrizione della classe è obbligatoria.'];
} elseif (!isset($teachersById[$idteacher])) {
$insertFeedback = ['type' => 'error', 'text' => 'Seleziona l\'insegnante della classe.'];
} else { } else {
$sql = "INSERT INTO service $sql = "INSERT INTO service
(servicename, wpcatalognumber, day, time, category, maxcapacity, colorclass, classduration) (servicename, wpcatalognumber, day, time, category, maxcapacity, colorclass, classduration, idteacher)
VALUES VALUES
(:servicename, :wpcatalog, :day, :time, :category, :maxcapacity, :colorclass, :classduration)"; (:servicename, :wpcatalog, :day, :time, :category, :maxcapacity, :colorclass, :classduration, :idteacher)";
$stmt = $pdo->prepare($sql); $stmt = $pdo->prepare($sql);
$ok = $stmt->execute([ $ok = $stmt->execute([
':servicename' => $servicename, ':servicename' => $servicename,
@@ -44,6 +94,7 @@ if (isset($_POST['classinsert']) && $_POST['classinsert'] === 'Y') {
':maxcapacity' => $maxcapacity !== '' ? (int) $maxcapacity : null, ':maxcapacity' => $maxcapacity !== '' ? (int) $maxcapacity : null,
':colorclass' => $colorclass, ':colorclass' => $colorclass,
':classduration' => $classduration !== '' ? (float) $classduration : null, ':classduration' => $classduration !== '' ? (float) $classduration : null,
':idteacher' => $idteacher,
]); ]);
if ($ok) { if ($ok) {
@@ -83,6 +134,32 @@ $propRows = $pdo->query(
foreach ($propRows as $pr) { foreach ($propRows as $pr) {
$propagationsByService[$pr['idservice']][] = $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 .= '<option value="' . $tid . '"' . ($isSel ? ' selected' : '') . '>' . e($label) . '</option>';
}
if (!$found) {
$html = '<option value="" selected disabled>— Non assegnato —</option>' . $html;
}
return $html;
}
?> ?>
<!doctype html> <!doctype html>
<html lang="it"> <html lang="it">
@@ -197,6 +274,43 @@ foreach ($propRows as $pr) {
.action-btns .btn { .action-btns .btn {
margin: 2px; 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;
}
</style> </style>
</head> </head>
@@ -238,6 +352,12 @@ foreach ($propRows as $pr) {
<?php if ($message === 'inserted') : ?> <?php if ($message === 'inserted') : ?>
<div class="alert alert-success" role="alert">Classe inserita con successo.</div> <div class="alert alert-success" role="alert">Classe inserita con successo.</div>
<?php endif; ?> <?php endif; ?>
<?php if ($message === 'teacherassigned') : ?>
<div class="alert alert-success" role="alert">Insegnante assegnato con successo.</div>
<?php endif; ?>
<?php if ($message === 'teachererror') : ?>
<div class="alert alert-danger" role="alert">Impossibile assegnare l'insegnante. Riprova.</div>
<?php endif; ?>
<?php if ($message === 'success') : ?> <?php if ($message === 'success') : ?>
<div class="alert alert-success" role="alert">Propagazione avvenuta con successo.</div> <div class="alert alert-success" role="alert">Propagazione avvenuta con successo.</div>
<?php endif; ?> <?php endif; ?>
@@ -261,6 +381,11 @@ foreach ($propRows as $pr) {
<?php echo e($insertFeedback['text']); ?> <?php echo e($insertFeedback['text']); ?>
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php if (empty($teachers)) : ?>
<div class="alert alert-warning" role="alert">
Non ci sono insegnanti: <a href="insegnanti.php">aggiungine almeno uno</a> prima di creare una classe.
</div>
<?php endif; ?>
<!-- Form inserimento classe --> <!-- Form inserimento classe -->
<div class="row"> <div class="row">
@@ -343,6 +468,14 @@ foreach ($propRows as $pr) {
<input type="text" class="form-control" id="colorclass" name="colorclass" value="#1ebf73"> <input type="text" class="form-control" id="colorclass" name="colorclass" value="#1ebf73">
</div> </div>
</div> </div>
<div class="col-lg-4">
<div class="mb-3">
<label for="idteacher" class="form-label">Insegnante</label>
<select name="idteacher" id="idteacher" class="form-select" required>
<?php echo teacherOptions($teachers, $ownerId); ?>
</select>
</div>
</div>
</div> </div>
<button type="submit" class="btn btn-primary btn-save">Inserisci classe</button> <button type="submit" class="btn btn-primary btn-save">Inserisci classe</button>
@@ -358,7 +491,7 @@ foreach ($propRows as $pr) {
<div class="card admin-card"> <div class="card admin-card">
<div class="card-body"> <div class="card-body">
<div class="section-title">Classi esistenti</div> <div class="section-title">Classi esistenti</div>
<p class="section-lead">Propaga una classe sul calendario, associala o modificane i dettagli.</p> <p class="section-lead">Propaga una classe sul calendario, associala, assegna l'insegnante o modificane i dettagli.</p>
<div class="table-responsive"> <div class="table-responsive">
<table class="table class-table align-middle mb-0"> <table class="table class-table align-middle mb-0">
@@ -369,16 +502,20 @@ foreach ($propRows as $pr) {
<th>Orario</th> <th>Orario</th>
<th>Durata</th> <th>Durata</th>
<th>Categoria</th> <th>Categoria</th>
<th>Insegnante</th>
<th class="text-end">Azioni</th> <th class="text-end">Azioni</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php if (empty($services)) : ?> <?php if (empty($services)) : ?>
<tr> <tr>
<td colspan="6" class="text-center text-muted py-4">Nessuna classe presente.</td> <td colspan="7" class="text-center text-muted py-4">Nessuna classe presente.</td>
</tr> </tr>
<?php else : ?> <?php else : ?>
<?php foreach ($services as $svc) : ?> <?php foreach ($services as $svc) :
$svcTeacherId = (int) $svc['idteacher'];
$svcTeacher = $teachersById[$svcTeacherId] ?? null;
?>
<tr> <tr>
<td> <td>
<span class="color-dot" style="background-color: <?php echo e($svc['colorclass'] ?: '#ccc'); ?>;"></span> <span class="color-dot" style="background-color: <?php echo e($svc['colorclass'] ?: '#ccc'); ?>;"></span>
@@ -391,6 +528,25 @@ foreach ($propRows as $pr) {
<?php echo e($svc['classduration']); ?> <?php echo e($svc['classduration']); ?>
</td> </td>
<td><?php echo e($svc['namecategory']); ?></td> <td><?php echo e($svc['namecategory']); ?></td>
<td>
<form method="post" class="teacher-cell">
<input type="hidden" name="action" value="assignteacher">
<input type="hidden" name="idservice" value="<?php echo (int) $svc['idservice']; ?>">
<?php if ($svcTeacher && $svcTeacher['photo']) : ?>
<img class="teacher-mini" src="assets/images/teachers/<?php echo e($svcTeacher['photo']); ?>" alt="">
<?php elseif ($svcTeacher) :
$tName = trim($svcTeacher['nameemployee'] . ' ' . $svcTeacher['surnameemployee']); ?>
<span class="teacher-mini" style="background:<?php echo teacherColor($tName); ?>;"><?php echo e(teacherInitials($svcTeacher['nameemployee'], $svcTeacher['surnameemployee'])); ?></span>
<?php else : ?>
<span class="teacher-mini none"><i class="fas fa-user"></i></span>
<?php endif; ?>
<select name="idteacher" class="form-select teacher-select <?php echo $svcTeacher ? '' : 'unassigned'; ?>"
data-current="<?php echo $svcTeacherId; ?>"
data-classname="<?php echo e($svc['servicename'] . ' ' . $svc['day'] . ' ' . $svc['time']); ?>">
<?php echo teacherOptions($teachers, $svcTeacherId); ?>
</select>
</form>
</td>
<td class="text-end action-btns"> <td class="text-end action-btns">
<button type="button" class="btn btn-primary btn-sm toggle-form">Propaga</button> <button type="button" class="btn btn-primary btn-sm toggle-form">Propaga</button>
<a href="associate-services.php?id=<?php echo (int) $svc['idservice']; ?>"> <a href="associate-services.php?id=<?php echo (int) $svc['idservice']; ?>">
@@ -407,7 +563,7 @@ foreach ($propRows as $pr) {
</td> </td>
</tr> </tr>
<tr class="expanded-row"> <tr class="expanded-row">
<td colspan="6"> <td colspan="7">
<div class="expanded-content"> <div class="expanded-content">
<form class="calendar-form" method="post" action="nextdateclass.php"> <form class="calendar-form" method="post" action="nextdateclass.php">
<div class="row"> <div class="row">
@@ -514,6 +670,17 @@ foreach ($propRows as $pr) {
$('#colorclass').val(col.toHexString()); $('#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 // Espansione riga propagazione
+100 -6
View File
@@ -28,8 +28,16 @@ function avatarColor(string $seed): string
return $palette[$idx]; 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; $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'])) { if (isset($_GET['prev_month'])) {
$currentMonthStart = preg_replace('/[^0-9\-]/', '', $_GET['prev_month']) . '-01'; $currentMonthStart = preg_replace('/[^0-9\-]/', '', $_GET['prev_month']) . '-01';
@@ -44,7 +52,7 @@ if (strtotime($currentMonthStart) === false) {
$currentMonthEnd = date("Y-m-t", strtotime($currentMonthStart)); $currentMonthEnd = date("Y-m-t", strtotime($currentMonthStart));
$sqlSchedule = "SELECT serviceschedule.*, service.servicename, service.colorclass, $sqlSchedule = "SELECT serviceschedule.*, service.servicename, service.colorclass,
service.maxcapacity, service.idservice service.maxcapacity, service.idservice, service.idteacher
FROM serviceschedule FROM serviceschedule
LEFT JOIN service ON serviceschedule.idservice = service.idservice LEFT JOIN service ON serviceschedule.idservice = service.idservice
WHERE serviceschedule.dateschedule BETWEEN :start AND DATE_ADD(:end, INTERVAL 1 DAY) WHERE serviceschedule.dateschedule BETWEEN :start AND DATE_ADD(:end, INTERVAL 1 DAY)
@@ -74,8 +82,8 @@ if (!empty($scheduleIds)) {
FROM bookingclass FROM bookingclass
LEFT JOIN auth_users ON bookingclass.iduser = auth_users.id LEFT JOIN auth_users ON bookingclass.iduser = auth_users.id
WHERE bookingclass.idserviceschedule IN ($ph) WHERE bookingclass.idserviceschedule IN ($ph)
AND bookingclass.status IN ('booked', 'pending') AND bookingclass.status IN ('booked', 'pending', 'waiting')
ORDER BY auth_users.last_name, auth_users.first_name"; ORDER BY bookingclass.created_at, auth_users.last_name, auth_users.first_name";
$stmtBookings = $pdo->prepare($sqlBookings); $stmtBookings = $pdo->prepare($sqlBookings);
$stmtBookings->execute(array_values($scheduleIds)); $stmtBookings->execute(array_values($scheduleIds));
foreach ($stmtBookings->fetchAll() as $b) { foreach ($stmtBookings->fetchAll() as $b) {
@@ -198,8 +206,8 @@ $italianMonths = [
.class-date { .class-date {
flex: 0 0 110px; flex: 0 0 110px;
background: #1ebf73; background: #E1D0B8;
color: #fff; color: #ffffff;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
@@ -231,6 +239,38 @@ $italianMonths = [
margin: 0 0 4px; 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 { .class-meta {
color: #6b7280; color: #6b7280;
font-size: 14px; font-size: 14px;
@@ -575,8 +615,10 @@ $italianMonths = [
// separa confermati (booked) da in attesa (pending) // separa confermati (booked) da in attesa (pending)
$bookings = array_filter($allRows, fn($b) => $b['status'] === 'booked'); $bookings = array_filter($allRows, fn($b) => $b['status'] === 'booked');
$pendings = array_filter($allRows, fn($b) => $b['status'] === 'pending'); $pendings = array_filter($allRows, fn($b) => $b['status'] === 'pending');
$waitings = array_filter($allRows, fn($b) => $b['status'] === 'waiting');
$countPersons = count($bookings); // contatore = solo confermati $countPersons = count($bookings); // contatore = solo confermati
$countPending = count($pendings); $countPending = count($pendings);
$countWaiting = count($waitings);
$maxcap = (int) $sch['maxcapacity']; $maxcap = (int) $sch['maxcapacity'];
$isFull = $maxcap > 0 && $countPersons >= $maxcap; $isFull = $maxcap > 0 && $countPersons >= $maxcap;
$colorclass = $sch['colorclass'] ?: '#e6f3ff'; $colorclass = $sch['colorclass'] ?: '#e6f3ff';
@@ -599,9 +641,31 @@ $italianMonths = [
<?php echo $countPending; ?> in attesa <?php echo $countPending; ?> in attesa
</span> </span>
<?php endif; ?> <?php endif; ?>
<?php if ($countWaiting > 0) : ?>
<span class="capacity-pill" style="background:#fbeef3;color:#d1698a;">
<i class="fas fa-list-ol" style="font-size:11px;"></i>
<?php echo $countWaiting; ?> in coda
</span>
<?php endif; ?>
</h3> </h3>
<p class="class-meta"><i class="far fa-clock me-1"></i><?php echo e($whenFmt); ?></p> <p class="class-meta"><i class="far fa-clock me-1"></i><?php echo e($whenFmt); ?></p>
<p class="class-meta"><i class="fas fa-location-dot me-1"></i>via Valassina 62/B Seregno - Sala Contesto Yoga</p> <p class="class-meta"><i class="fas fa-location-dot me-1"></i>via Valassina 62/B Seregno - Sala Contesto Yoga</p>
<?php if ($showTeacher) :
$tch = $teachersById[(int) ($sch['idteacher'] ?? 0)] ?? null; ?>
<p class="class-meta class-teacher">
<?php if ($tch) : ?>
<?php if (!empty($tch['photo'])) : ?>
<img class="teacher-avatar-sm" src="assets/images/teachers/<?php echo e($tch['photo']); ?>" alt="">
<?php else : ?>
<span class="teacher-avatar-sm"><?php echo e(initials($tch['nameemployee'], $tch['surnameemployee'])); ?></span>
<?php endif; ?>
Insegnante: <strong><?php echo e(trim($tch['nameemployee'] . ' ' . $tch['surnameemployee'])); ?></strong>
<?php else : ?>
<span class="teacher-avatar-sm none"><i class="fas fa-user" style="font-size:10px;"></i></span>
<span style="color:#e67e22;font-weight:600;">Insegnante non assegnato</span>
<?php endif; ?>
</p>
<?php endif; ?>
<div class="class-toolbar"> <div class="class-toolbar">
<button class="btn-soft btn-bookings" onclick="togglePanel(this)"><i class="fas fa-list-ul me-1"></i> Vedi prenotazioni</button> <button class="btn-soft btn-bookings" onclick="togglePanel(this)"><i class="fas fa-list-ul me-1"></i> Vedi prenotazioni</button>
@@ -676,6 +740,36 @@ $italianMonths = [
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php if ($countWaiting > 0) : ?>
<div class="bookings-header" style="margin-top:18px;">
<span class="title" style="color:#d1698a;"><i class="fas fa-list-ol me-1"></i> Lista d'attesa (<?php echo $countWaiting; ?>)</span>
</div>
<div class="booking-list">
<?php $qpos = 1;
foreach ($waitings as $b) :
$idbc = (int) $b['idbookingclass'];
$fullName = trim($b['first_name'] . ' ' . $b['last_name']);
$ini = initials($b['first_name'] ?? '', $b['last_name'] ?? '');
$avColor = avatarColor($fullName);
?>
<div class="booking-item" style="background:#fdf4f8;border-color:#f0d6e2;" data-iduser="<?php echo (int) $b['iduser']; ?>">
<div class="avatar" style="background: <?php echo $avColor; ?>;"><?php echo e($ini); ?></div>
<div class="booking-info">
<span class="booking-name">
<span style="display:inline-block;min-width:22px;font-weight:800;color:#d1698a;"><?php echo $qpos; ?>°</span>
<?php echo e($fullName); ?>
</span>
<span class="status-chip" style="background:#fbeef3;color:#d1698a;">In coda</span>
</div>
<div class="booking-actions">
<button class="mini-btn remove" onclick="handleRemoveClick(<?php echo $idbc; ?>)"><i class="fas fa-trash"></i></button>
</div>
</div>
<?php $qpos++;
endforeach; ?>
</div>
<?php endif; ?>
<div class="add-box"> <div class="add-box">
<strong class="d-block mb-2" style="font-size:14px;"><i class="fas fa-user-plus me-1" style="color:#1ebf73;"></i> Aggiungi partecipante</strong> <strong class="d-block mb-2" style="font-size:14px;"><i class="fas fa-user-plus me-1" style="color:#1ebf73;"></i> Aggiungi partecipante</strong>
<form class="add-booking-form" action="inserisci_record.php" method="post"> <form class="add-booking-form" action="inserisci_record.php" method="post">
+189
View File
@@ -0,0 +1,189 @@
<?php
require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/include/event_mailer.php';
/**
* Endpoint pubblico PRENOTAZIONE (POST).
* Riceve i dati dal widget WordPress e crea la prenotazione.
*
* Campi attesi (POST form-encoded o JSON):
* event_id, name, surname, email, seats, consent (1), marketing (1|0)
* website -> 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);
}
+63
View File
@@ -0,0 +1,63 @@
<?php
/**
* Bootstrap per gli endpoint PUBBLICI del modulo Eventi (cartella /api).
* NON include headscript.php: niente login/redirect. Serve solo il DB.
*
* Posiziona la cartella /api allo stesso livello di events.php, class/, include/.
* Requisito: class/db-functions.php (DBHandlerSelect) già presente e autonomo.
*/
// Path assoluto a DBHandlerSelect (api/ -> ../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();
}
+283
View File
@@ -0,0 +1,283 @@
<?php
require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/include/event_waitlist.php';
require_once dirname(__DIR__) . '/include/event_mailer.php';
/**
* Disdetta pubblica di una prenotazione tramite token (link email, senza login).
*
* GET apiwp/cancel.php?token=... -> 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');
}
}
?>
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8">
<title>Disdetta prenotazione — YogaSoul</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--accent: #b07aa1;
--ink: #2b2622;
--muted: #8c837c;
--line: #ece7e2;
--paper: #faf8f6;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--paper);
color: var(--ink);
font-family: 'Inter', system-ui, sans-serif;
display: flex;
min-height: 100vh;
align-items: center;
justify-content: center;
padding: 24px;
}
.card {
background: #fff;
border-radius: 18px;
box-shadow: 0 14px 40px rgba(43, 38, 34, .10);
max-width: 460px;
width: 100%;
padding: 34px 30px;
text-align: center;
}
.icn {
width: 58px;
height: 58px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 16px;
}
.icn.warn {
background: #fdecea;
color: #c0392b;
}
.icn.ok {
background: #e7f7ee;
color: #1a7f52;
}
.icn.info {
background: #eef0f3;
color: #6b7280;
}
h1 {
font-family: 'Fraunces', serif;
font-weight: 600;
font-size: 23px;
margin: 0 0 8px;
}
p {
color: #4a433d;
font-size: 15px;
margin: 6px 0;
}
.ev {
background: var(--paper);
border-radius: 12px;
padding: 14px 16px;
margin: 18px 0;
}
.ev .t {
font-weight: 600;
}
.ev .d {
color: var(--muted);
font-size: 14px;
margin-top: 2px;
}
.btn {
border: none;
border-radius: 11px;
padding: 14px 20px;
font-size: 15px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
width: 100%;
margin-top: 6px;
}
.btn-danger {
background: #e74c3c;
color: #fff;
}
.btn-danger:hover {
filter: brightness(.94);
}
.btn-light {
background: #f3f4f6;
color: #374151;
text-decoration: none;
display: inline-block;
}
.muted {
color: var(--muted);
font-size: 13px;
margin-top: 14px;
}
</style>
</head>
<body>
<div class="card">
<?php if ($state === 'notfound') : ?>
<div class="icn warn">✕</div>
<h1>Link non valido</h1>
<p>Questa prenotazione non esiste o il link è errato.</p>
<?php elseif ($state === 'already') : ?>
<div class="icn info">✓</div>
<h1>Già annullata</h1>
<p>Questa prenotazione risulta già annullata.</p>
<?php elseif ($state === 'toolate') : ?>
<div class="icn info">⏱</div>
<h1>Non più annullabile</h1>
<p>L'evento è già iniziato: non è più possibile annullare online. Contattaci per assistenza.</p>
<?php elseif ($state === 'error') : ?>
<div class="icn warn">!</div>
<h1>Si è verificato un errore</h1>
<p>Riprova tra poco. Se il problema persiste, contattaci.</p>
<?php elseif ($state === 'done') : ?>
<div class="icn ok">✓</div>
<h1>Prenotazione annullata</h1>
<p>La tua prenotazione per <strong><?php echo e($booking['ev_title']); ?></strong> è stata annullata.</p>
<?php if ($promotedCount > 0) : ?>
<p class="muted">Un posto è stato assegnato alla prima persona in lista d'attesa.</p>
<?php endif; ?>
<?php else : /* form */ ?>
<div class="icn warn">?</div>
<h1>Annullare la prenotazione?</h1>
<div class="ev">
<div class="t"><?php echo e($booking['ev_title']); ?></div>
<div class="d"><?php echo e(date('d/m/Y H:i', strtotime($booking['ev_starts']))); ?> · <?php echo (int) $booking['seats']; ?> post<?php echo (int) $booking['seats'] === 1 ? 'o' : 'i'; ?><?php echo $booking['status'] === 'waiting' ? ' (in lista d\'attesa)' : ''; ?></div>
</div>
<p>Confermi di voler annullare la prenotazione a nome di <strong><?php echo e($booking['name']); ?></strong>?</p>
<form method="POST">
<input type="hidden" name="token" value="<?php echo e($token); ?>">
<input type="hidden" name="confirm" value="1">
<button type="submit" class="btn btn-danger">Sì, annulla la prenotazione</button>
</form>
<p class="muted">Se hai cambiato idea, chiudi pure questa pagina.</p>
<?php endif; ?>
</div>
</body>
</html>
+247
View File
@@ -0,0 +1,247 @@
<?php
require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/include/event_mailer.php';
/**
* Riconferma di una promozione da lista d'attesa (stato 'offered' -> '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');
}
?>
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8">
<title>Conferma posto — YogaSoul</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,600&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--accent: #1a7f52;
--ink: #2b2622;
--muted: #8c837c;
--line: #ece7e2;
--paper: #faf8f6;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--paper);
color: var(--ink);
font-family: 'Inter', system-ui, sans-serif;
display: flex;
min-height: 100vh;
align-items: center;
justify-content: center;
padding: 24px;
}
.card {
background: #fff;
border-radius: 18px;
box-shadow: 0 14px 40px rgba(43, 38, 34, .10);
max-width: 460px;
width: 100%;
padding: 34px 30px;
text-align: center;
}
.icn {
width: 58px;
height: 58px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 16px;
font-size: 26px;
}
.icn.ok {
background: #e7f7ee;
color: #1a7f52;
}
.icn.info {
background: #eef0f3;
color: #6b7280;
}
.icn.warn {
background: #fdecea;
color: #c0392b;
}
h1 {
font-family: 'Fraunces', serif;
font-weight: 600;
font-size: 23px;
margin: 0 0 8px;
}
p {
color: #4a433d;
font-size: 15px;
margin: 6px 0;
}
.ev {
background: var(--paper);
border-radius: 12px;
padding: 14px 16px;
margin: 18px 0;
}
.ev .t {
font-weight: 600;
}
.ev .d {
color: var(--muted);
font-size: 14px;
margin-top: 2px;
}
.btn {
border: none;
border-radius: 11px;
padding: 14px 20px;
font-size: 15px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
width: 100%;
margin-top: 6px;
}
.btn-ok {
background: #1a7f52;
color: #fff;
}
.btn-ok:hover {
filter: brightness(.94);
}
.muted {
color: var(--muted);
font-size: 13px;
margin-top: 14px;
}
</style>
</head>
<body>
<div class="card">
<?php if ($state === 'notfound') : ?>
<div class="icn warn">✕</div>
<h1>Link non valido</h1>
<p>Questo link non è valido o è scaduto.</p>
<?php elseif ($state === 'already') : ?>
<div class="icn ok">✓</div>
<h1>Posto già confermato</h1>
<p>Il tuo posto per <strong><?php echo e($booking['ev_title']); ?></strong> è già confermato. Ci vediamo lì!</p>
<?php elseif ($state === 'invalid') : ?>
<div class="icn info">i</div>
<h1>Offerta non più disponibile</h1>
<p>Questa offerta non è più valida. Se pensi ci sia un errore, contattaci.</p>
<?php elseif ($state === 'toolate') : ?>
<div class="icn info">⏱</div>
<h1>Evento già iniziato</h1>
<p>Non è più possibile confermare online. Contattaci per assistenza.</p>
<?php elseif ($state === 'error') : ?>
<div class="icn warn">!</div>
<h1>Si è verificato un errore</h1>
<p>Riprova tra poco. Se il problema persiste, contattaci.</p>
<?php elseif ($state === 'done') : ?>
<div class="icn ok">✓</div>
<h1>Posto confermato!</h1>
<p>Grazie <strong><?php echo e($booking['name']); ?></strong>, il tuo posto per <strong><?php echo e($booking['ev_title']); ?></strong> è confermato. Ti abbiamo inviato l'email con tutti i dettagli.</p>
<?php else : /* form */ ?>
<div class="icn ok">🎉</div>
<h1>Si è liberato un posto!</h1>
<div class="ev">
<div class="t"><?php echo e($booking['ev_title']); ?></div>
<div class="d"><?php echo e(date('d/m/Y H:i', strtotime($booking['ev_starts']))); ?> · <?php echo (int) $booking['seats']; ?> post<?php echo (int) $booking['seats'] === 1 ? 'o' : 'i'; ?></div>
</div>
<p>Confermi la tua presenza, <strong><?php echo e($booking['name']); ?></strong>?</p>
<form method="POST">
<input type="hidden" name="token" value="<?php echo e($token); ?>">
<input type="hidden" name="confirm" value="1">
<button type="submit" class="btn btn-ok">Sì, confermo il posto</button>
</form>
<p class="muted">Se non puoi partecipare, ignora pure questa email: il posto passerà a un'altra persona.</p>
<?php endif; ?>
</div>
</body>
</html>
+127
View File
@@ -0,0 +1,127 @@
<?php
require_once __DIR__ . '/bootstrap.php';
require_once dirname(__DIR__) . '/include/event_waitlist.php';
require_once dirname(__DIR__) . '/include/event_mailer.php';
/**
* CRON unico del modulo Eventi. Da richiamare periodicamente (es. ogni 10 min).
* Protezione: chiave segreta in .env (YSE_CRON_KEY) passata come ?key=...
*
* Fa tre cose a ogni giro:
* 1) Pubblica gli eventi programmati arrivati alla data di apertura.
* 2) Invia i promemoria automatici (ore effettive per-evento o default), 1 sola volta.
* 3) Applica la scadenza delle offerte lista d'attesa (offered non riconfermati):
* li annulla e offre il posto al prossimo in coda (email E).
*/
$pdo = pdo_conn(); // carica anche il .env (per la chiave e le MAIL_*)
$expected = $_ENV['YSE_CRON_KEY'] ?? '';
$provided = (string) ($_GET['key'] ?? '');
if ($expected === '' || !hash_equals($expected, $provided)) {
send_json(['ok' => 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')]);
+133
View File
@@ -0,0 +1,133 @@
<?php
require_once __DIR__ . '/bootstrap.php';
/**
* Endpoint pubblico LISTA eventi (sola lettura).
* Ritorna gli eventi PUBBLICATI e NON ancora terminati, con posti/prezzo/gallery.
*
* Parametri (query string):
* ?id=12 -> 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);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

+17 -4
View File
@@ -1,6 +1,7 @@
<?php <?php
require_once('include/headscript.php'); require_once('include/headscript.php');
require_once __DIR__ . '/class/mailer.php'; require_once __DIR__ . '/class/mailer.php';
require_once('include/waitinglist.php');
use PHPMailer\PHPMailer\PHPMailer; use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception; use PHPMailer\PHPMailer\Exception;
@@ -55,11 +56,20 @@ if ($rowUser = $resUser->fetch_assoc()) {
} }
$stmtUser->close(); $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 // Inserisce la nuova prenotazione in bookingclass
$insertQuery = "INSERT INTO bookingclass (idserviceschedule, iduser, prevbookingstart, idprevserviceschedule, idservice, idorder, bookingstart, status) $insertQuery = "INSERT INTO bookingclass (idserviceschedule, iduser, prevbookingstart, idprevserviceschedule, idservice, idorder, bookingstart, status)
VALUES (?, ?, NULL, '0', ?, ?, ?, 'pending')"; VALUES (?, ?, NULL, '0', ?, ?, ?, ?)";
$stmt = $conn->prepare($insertQuery); $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()) { if (!$stmt->execute()) {
$stmt->close(); $stmt->close();
die("Errore durante l'inserimento della nuova prenotazione: " . $conn->error); die("Errore durante l'inserimento della nuova prenotazione: " . $conn->error);
@@ -78,9 +88,12 @@ $stmt->close();
// Connessione $bkngstm gestita globalmente: non la chiudiamo qui // 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 // Se non abbiamo un'email valida, salta l'invio ma completa comunque il flusso
if ($emailuser === '') { if ($emailuser === '') {
header("Location: userpanel.php?reprogram=Y"); header("Location: userpanel.php?$redirectFlag");
exit(); exit();
} }
@@ -168,5 +181,5 @@ try {
} }
// Esegui il reindirizzamento // Esegui il reindirizzamento
header("Location: userpanel.php?reprogram=Y"); header("Location: userpanel.php?$redirectFlag");
exit(); exit();
+7 -3
View File
@@ -507,7 +507,7 @@ $conn->close();
$mysqli = new mysqli($servername, $username, $password, $dbname); $mysqli = new mysqli($servername, $username, $password, $dbname);
$eventId = $bookedclass->getColumnVal("idserviceschedule"); $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 = $mysqli->prepare($bookingQuery);
$stmt->bind_param("i", $eventId); $stmt->bind_param("i", $eventId);
$stmt->execute(); $stmt->execute();
@@ -538,7 +538,7 @@ $conn->close();
<?php } elseif ($freeplace > 0) { ?> <?php } elseif ($freeplace > 0) { ?>
<div class="custom-date-box" style="background-color:#1ebf73"> <div class="custom-date-box" style="background-color:#1ebf73">
<?php } else { ?> <?php } else { ?>
<div class="custom-date-box" style="background-color:#9C9C9C"> <div class="custom-date-box" style="background-color:#d1698a">
<?php } ?> <?php } ?>
<div class="custom-day"><?php echo $dayInItalian; ?></div> <div class="custom-day"><?php echo $dayInItalian; ?></div>
<div class="custom-month"><?php echo $monthInItalian; ?></div> <div class="custom-month"><?php echo $monthInItalian; ?></div>
@@ -548,7 +548,7 @@ $conn->close();
<?php } elseif ($freeplace > 0) { ?> <?php } elseif ($freeplace > 0) { ?>
<div class="custom-event-details" style="border-left-color:<?php echo ($bookedclass->getColumnVal("colorclass")); ?>"> <div class="custom-event-details" style="border-left-color:<?php echo ($bookedclass->getColumnVal("colorclass")); ?>">
<?php } else { ?> <?php } else { ?>
<div class="custom-event-details" style="background-color:#CDCDCD;border-left-color:#9C9C9C"> <div class="custom-event-details" style="background-color:#fbeef3;border-left-color:#d1698a">
<?php } ?> <?php } ?>
<h2 class="custom-heading"><?php echo ($bookedclass->getColumnVal("servicename")); ?> -<?php echo $countPersons; ?>/<?php echo ($bookedclass->getColumnVal("maxcapacity")); ?></h2> <h2 class="custom-heading"><?php echo ($bookedclass->getColumnVal("servicename")); ?> -<?php echo $countPersons; ?>/<?php echo ($bookedclass->getColumnVal("maxcapacity")); ?></h2>
<p class="custom-paragraph">Quando: <?php echo $newDateFormat; ?></p> <p class="custom-paragraph">Quando: <?php echo $newDateFormat; ?></p>
@@ -560,9 +560,13 @@ $conn->close();
<button class="booking-button"><i class="fa fa-check"></i> Sei già prenotata/o</button> <button class="booking-button"><i class="fa fa-check"></i> Sei già prenotata/o</button>
<?php } elseif ($freeplace > 0) { ?> <?php } elseif ($freeplace > 0) { ?>
<a href="rebookandgo.php?idpreviousbooking=<?php echo $idpreviousbooking; ?>&idservicenew=<?php echo $idservicenew; ?>&idnewbooking=<?php echo $idnewbooking; ?>&iduser=<?php echo $iduserlogin; ?>"><button class="booking-button"><i class="fas fa-arrow-circle-right"></i> Riprogramma Classe</button></a> <a href="rebookandgo.php?idpreviousbooking=<?php echo $idpreviousbooking; ?>&idservicenew=<?php echo $idservicenew; ?>&idnewbooking=<?php echo $idnewbooking; ?>&iduser=<?php echo $iduserlogin; ?>"><button class="booking-button"><i class="fas fa-arrow-circle-right"></i> Riprogramma Classe</button></a>
<?php } else { ?>
<?php if ($optionquery->getColumnVal("waitinglist_enabled") === 'Y') { ?>
<a href="rebookandgo.php?idpreviousbooking=<?php echo $idpreviousbooking; ?>&idservicenew=<?php echo $idservicenew; ?>&idnewbooking=<?php echo $idnewbooking; ?>&iduser=<?php echo $iduserlogin; ?>"><button class="booking-button" style="background-color:#d1698a;color:#fff;"><i class="fas fa-hourglass-half"></i> Mettiti in lista d'attesa</button></a>
<?php } else { ?> <?php } else { ?>
<button class="booking-button"><i class="fa fa-stop"></i> Classe Piena</button> <button class="booking-button"><i class="fa fa-stop"></i> Classe Piena</button>
<?php } ?> <?php } ?>
<?php } ?>
</div> </div>
</div> </div>
</div> </div>
+1 -1
View File
@@ -742,7 +742,7 @@ $conn->close();
?> ?>
<a href="bookandgo.php?idpreviousbooking=<?php echo '0'; ?>&idorder=<?php echo $idorder; ?>&idservicenew=<?php echo $idservicenew; ?>&idnewbooking=<?php echo $idnewbooking; ?>&iduser=<?php echo $iduserlogin; ?>" ?><button class="booking-button"><i class="fas fa-arrow-circle-right"></i> Riprogramma Classe</button></a> <a href="bookandgo.php?idpreviousbooking=<?php echo '0'; ?>&idorder=<?php echo $idorder; ?>&idservicenew=<?php echo $idservicenew; ?>&idnewbooking=<?php echo $idnewbooking; ?>&iduser=<?php echo $iduserlogin; ?>" ?><button class="booking-button"><i class="fas fa-arrow-circle-right"></i> Riprogramma Classe</button></a>
<?php } else { ?> <?php } else { ?>
<button class="booking-button"><i class="fa fa-stop"></i> Classe Piena</button> <a href="bookandgo.php?idpreviousbooking=0&idorder=<?php echo $idorder; ?>&idservicenew=<?php echo $idservicenew; ?>&idnewbooking=<?php echo $idnewbooking; ?>&iduser=<?php echo $iduserlogin; ?>"><button class="booking-button" style="background-color:#f0ad4e;color:#fff;"><i class="fas fa-hourglass-half"></i> Mettiti in lista d'attesa</button></a>
<?php } ?> <?php } ?>
</div> </div>
+43
View File
@@ -0,0 +1,43 @@
<?php
$conn = new mysqli($servername, $username, $password, $dbname);
require_once('include/headscript.php'); // per avere le credenziali DB, se sono lì
// Se le credenziali sono in headscript.php, sposta la connessione DOPO il require.
// Adatta questa parte a come sono definite $servername ecc. nelle tue altre pagine.
if ($conn->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();
+58
View File
@@ -0,0 +1,58 @@
<?php
require_once('include/headscript.php');
require_once('include/event_mailer.php'); // stub mailer (template vero: step successivo)
/**
* Annullamento evento.
* Sceglierne uno soft (consigliato): imposta status='cancelled' e mantiene le
* prenotazioni/contatti raccolti. Se notify=1 invia la mail di annullamento a
* tutti i partecipanti attivi (recuperati PRIMA di annullare).
*
* Chiamato da events.php: event_delete.php?id=123&notify=1|0
*/
$pdo = DBHandlerSelect::getInstance()->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();
}
+964
View File
@@ -0,0 +1,964 @@
<?php
require_once('include/headscript.php');
/**
* Modulo Eventi - form creazione/modifica (una sola pagina).
* - event_form.php -> 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';
?>
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8" />
<title>YogiBook - <?php echo e($pageTitle); ?></title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" href="assets/images/favicon.ico">
<link href="assets/css/bootstrap.min.css" id="bootstrap-style" rel="stylesheet" type="text/css" />
<link href="assets/css/icons.min.css" rel="stylesheet" type="text/css" />
<link href="assets/css/app.min.css" id="app-style" rel="stylesheet" type="text/css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
<!-- Date/ora -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/flatpickr/4.6.13/flatpickr.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/flatpickr/4.6.13/flatpickr.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/flatpickr/4.6.13/l10n/it.min.js"></script>
<!-- Editor testo ricco -->
<link href="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.snow.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/quill@2/dist/quill.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<style>
.admin-card {
border: none;
border-radius: 14px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06);
}
.admin-card .card-body {
padding: 26px 30px;
}
.section-title {
font-size: 15px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #6b7280;
margin-bottom: 4px;
}
.section-lead {
color: #9ca3af;
font-size: 14px;
margin-bottom: 22px;
}
.form-label {
font-weight: 600;
font-size: 14px;
color: #374151;
}
.form-control,
.form-select {
border-radius: 8px;
border: 1px solid #e2e4e9;
padding: 10px 12px;
}
.form-control:focus,
.form-select:focus {
border-color: #1ebf73;
box-shadow: 0 0 0 3px rgba(30, 191, 115, 0.12);
}
.form-control[readonly] {
background: #f7f8fa;
color: #6b7280;
}
.hint {
color: #9ca3af;
font-size: 12px;
margin-top: 4px;
}
#editor {
height: 240px;
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
}
.ql-toolbar.ql-snow,
.ql-container.ql-snow {
border-color: #e2e4e9;
}
.ql-toolbar.ql-snow {
border-top-left-radius: 8px;
border-top-right-radius: 8px;
}
input[type="color"].form-control {
padding: 4px;
height: 44px;
width: 100%;
}
.btn-geocode {
border: 1px solid #e2e4e9;
background: #fff;
border-radius: 8px;
padding: 10px 14px;
font-weight: 600;
font-size: 13px;
color: #2f7be7;
white-space: nowrap;
}
.btn-geocode:hover {
background: #f3f4f6;
}
.btn-save {
border-radius: 8px;
padding: 10px 26px;
font-weight: 600;
}
.btn-back {
border-radius: 8px;
padding: 10px 20px;
font-weight: 600;
}
/* Dropzone copertina */
.dropzone {
border: 2px dashed #d5d9e0;
border-radius: 12px;
background: #fafbfc;
padding: 22px;
text-align: center;
transition: border-color 0.15s, background 0.15s;
cursor: pointer;
}
.dropzone.dragover {
border-color: #1ebf73;
background: #eef6f1;
}
.dz-empty i {
font-size: 34px;
color: #b6bcc6;
display: block;
margin-bottom: 8px;
}
.dz-empty p {
margin: 0;
color: #4b5563;
font-size: 14px;
}
.dz-empty small {
color: #9ca3af;
}
.dz-browse {
color: #2f7be7;
font-weight: 600;
text-decoration: underline;
}
.dz-preview img {
max-height: 170px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.12);
}
.dz-remove {
margin-top: 12px;
border: 1px solid #f2c9c4;
background: #fff;
color: #c0392b;
border-radius: 8px;
padding: 6px 14px;
font-weight: 600;
font-size: 13px;
cursor: pointer;
}
.dz-remove:hover {
background: #fdecea;
}
.dz-progress {
color: #6b7280;
font-weight: 600;
}
.coord-flash {
border-color: #1ebf73 !important;
box-shadow: 0 0 0 3px rgba(30, 191, 115, 0.18) !important;
transition: box-shadow .2s;
}
.gallery-grid {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 12px;
}
.gallery-cell {
position: relative;
width: 92px;
height: 92px;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.10);
}
.gallery-cell img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.gallery-remove {
position: absolute;
top: 4px;
right: 4px;
width: 22px;
height: 22px;
border: none;
border-radius: 50%;
background: rgba(0, 0, 0, 0.55);
color: #fff;
font-size: 15px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.gallery-remove:hover {
background: #c0392b;
}
</style>
</head>
<body>
<div id="layout-wrapper">
<header id="page-topbar" class="isvertical-topbar">
<div class="navbar-header">
<div class="d-flex">
<?php include('include/logoarea.php'); ?>
<button type="button" class="btn btn-sm px-3 font-size-24 header-item waves-effect vertical-menu-btn">
<i class="bx bx-menu align-middle"></i>
</button>
<div class="page-title-box align-self-center d-none d-md-block">
<h4 class="page-title mb-0"><?php echo e($pageTitle); ?></h4>
</div>
</div>
<div class="d-flex">
<?php include('include/languageselection.php'); ?>
<?php include('include/profiletopbar.php'); ?>
</div>
</div>
</header>
<?php include('include/sidebar.php'); ?>
<header class="ishorizontal-topbar">
<div class="navbar-header">
<div class="d-flex"></div>
</div>
<div class="topnav">
<div class="container-fluid">
<nav class="navbar navbar-light navbar-expand-lg topnav-menu"></nav>
</div>
</div>
</header>
<div class="main-content">
<div class="page-content">
<div class="container-fluid">
<div class="row">
<div class="col-lg-9">
<div class="card admin-card">
<div class="card-body">
<div class="section-title"><?php echo e($pageTitle); ?></div>
<p class="section-lead">Compila i dati dell'evento. I campi con * sono obbligatori.</p>
<form action="event_save.php" method="POST" id="event-form">
<input type="hidden" name="id" value="<?php echo (int) $id; ?>">
<div class="row">
<div class="col-md-8 mb-3">
<label class="form-label">Titolo *</label>
<input type="text" class="form-control" name="title" required
value="<?php echo e($val['title']); ?>" placeholder="Es. Yoga in Gravidanza & Amaca">
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Slug</label>
<input type="text" class="form-control" name="slug"
value="<?php echo e($val['slug']); ?>" placeholder="generato dal titolo">
<div class="hint">URL pubblico del widget. Vuoto = generato dal titolo.</div>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Data e ora inizio *</label>
<input type="text" class="form-control" id="starts_at" name="starts_at" required
value="<?php echo e($val['starts_at']); ?>" placeholder="Scegli data e ora" autocomplete="off">
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Data e ora fine</label>
<input type="text" class="form-control" id="ends_at" name="ends_at"
value="<?php echo e($val['ends_at']); ?>" placeholder="Auto: inizio + 1h" autocomplete="off">
</div>
</div>
<!-- Sede / indirizzo -->
<div class="row">
<?php if (!empty($locations)) : ?>
<div class="col-md-5 mb-3">
<label class="form-label">Sede</label>
<select id="location-select" class="form-select">
<?php foreach ($locations as $loc) : ?>
<option value="<?php echo (int) $loc['id']; ?>"
data-address="<?php echo e($loc['address']); ?>"
data-lat="<?php echo e($loc['lat']); ?>"
data-lng="<?php echo e($loc['lng']); ?>">
<?php echo e($loc['name']); ?>
</option>
<?php endforeach; ?>
<option value="custom">➕ Indirizzo personalizzato</option>
</select>
</div>
<div class="col-md-7 mb-3">
<label class="form-label">Indirizzo</label>
<div class="d-flex gap-2">
<input type="text" class="form-control" id="address-input" name="address"
value="<?php echo e($val['address']); ?>" placeholder="Via, numero, città">
<button type="button" class="btn-geocode" id="btn-geocode"><i class="bx bx-map-pin"></i> Calcola</button>
</div>
<div class="hint" id="geo-hint">Per un indirizzo personalizzato, premi "Calcola" per ottenere le coordinate mappa.</div>
</div>
<?php else : ?>
<div class="col-md-12 mb-3">
<label class="form-label">Indirizzo</label>
<div class="d-flex gap-2">
<input type="text" class="form-control" id="address-input" name="address"
value="<?php echo e($val['address']); ?>" placeholder="Via, numero, città">
<button type="button" class="btn-geocode" id="btn-geocode"><i class="bx bx-map-pin"></i> Calcola coordinate</button>
</div>
<div class="hint" id="geo-hint">Premi "Calcola coordinate" per ottenere lat/lng dall'indirizzo.</div>
</div>
<?php endif; ?>
</div>
<div class="row" id="coords-row">
<div class="col-md-3 mb-3">
<label class="form-label">Latitudine</label>
<input type="text" class="form-control" id="lat-input" name="lat" value="<?php echo e($val['lat']); ?>" placeholder="45.6600">
</div>
<div class="col-md-3 mb-3">
<label class="form-label">Longitudine</label>
<input type="text" class="form-control" id="lng-input" name="lng" value="<?php echo e($val['lng']); ?>" placeholder="8.7900">
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Organizzatore</label>
<input type="text" class="form-control" name="organizer_name"
value="<?php echo e($val['organizer_name']); ?>" placeholder="Lascia vuoto = tu">
</div>
</div>
<div class="mb-3">
<label class="form-label">Descrizione</label>
<div id="editor"></div>
<input type="hidden" name="description" id="description-input">
<div class="hint">Testo formattato, elenchi, link e immagini.</div>
</div>
<!-- Dettagli aggiuntivi (facoltativi) -->
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label">Istruzioni</label>
<textarea class="form-control" name="instructions" rows="3" placeholder="Come arrivare, orari, note pratiche…"><?php echo e($val['instructions']); ?></textarea>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Cosa portare</label>
<textarea class="form-control" name="what_to_bring" rows="3" placeholder="Tappetino, acqua, abbigliamento comodo…"><?php echo e($val['what_to_bring']); ?></textarea>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Per chi è adatto</label>
<textarea class="form-control" name="suitable_for" rows="3" placeholder="Principianti, gravidanza, tutti i livelli…"><?php echo e($val['suitable_for']); ?></textarea>
</div>
</div>
<p class="hint" style="margin-top:-8px;">Questi campi sono facoltativi e verranno usati anche nella card pubblica e nelle email.</p>
<!-- Copertina drag&drop -->
<div class="mb-3">
<label class="form-label">Immagine di copertina</label>
<div class="dropzone" id="cover-dropzone">
<div class="dz-empty" id="dz-empty" style="<?php echo $val['cover_image'] ? 'display:none;' : ''; ?>">
<i class="bx bx-cloud-upload"></i>
<p>Trascina qui l'immagine oppure <span class="dz-browse">sfoglia</span></p>
<small>JPG, PNG, WEBP o GIF · max 5 MB</small>
</div>
<div class="dz-preview" id="dz-preview" style="<?php echo $val['cover_image'] ? '' : 'display:none;'; ?>">
<img id="dz-img" src="<?php echo e($val['cover_image']); ?>" alt="Copertina">
<div><button type="button" class="dz-remove" id="dz-remove"><i class="bx bx-trash"></i> Rimuovi</button></div>
</div>
<div class="dz-progress" id="dz-progress" style="display:none;"><i class="bx bx-loader bx-spin"></i> Caricamento…</div>
<input type="file" id="cover-file" accept="image/*" hidden>
</div>
<input type="hidden" name="cover_image" id="cover_image" value="<?php echo e($val['cover_image']); ?>">
<div class="hint">Immagine principale mostrata nella card.</div>
</div>
<!-- Galleria foto -->
<div class="mb-3">
<label class="form-label">Galleria foto</label>
<div class="dropzone" id="gallery-dropzone">
<div class="dz-empty">
<i class="bx bx-images"></i>
<p>Trascina qui le immagini oppure <span class="dz-browse">sfoglia</span></p>
<small>Più immagini insieme · JPG, PNG, WEBP o GIF · max 5 MB l'una</small>
</div>
<input type="file" id="gallery-file" accept="image/*" multiple hidden>
</div>
<div class="gallery-grid" id="gallery-grid"></div>
<input type="hidden" name="gallery" id="gallery-input" value="<?php echo e($val['gallery']); ?>">
</div>
<div class="row">
<div class="col-md-3 mb-3">
<label class="form-label">Costo</label>
<input type="number" step="0.01" min="0" class="form-control" name="cost" value="<?php echo e($val['cost']); ?>">
<div class="hint">0 = gratuito.</div>
</div>
<div class="col-md-3 mb-3">
<label class="form-label">Valuta</label>
<select name="currency" class="form-select">
<?php foreach (['EUR', 'USD', 'GBP', 'CHF'] as $cur) : ?>
<option value="<?php echo $cur; ?>" <?php echo $val['currency'] === $cur ? 'selected' : ''; ?>><?php echo $cur; ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="col-md-3 mb-3">
<label class="form-label">Posti totali *</label>
<input type="number" min="1" class="form-control" name="max_seats" required value="<?php echo e($val['max_seats']); ?>" placeholder="Es. 15">
</div>
<div class="col-md-3 mb-3">
<label class="form-label">Max posti per persona</label>
<input type="number" min="1" class="form-control" name="max_per_booking" value="<?php echo e($val['max_per_booking']); ?>">
</div>
</div>
<div class="row">
<div class="col-md-3 mb-3">
<label class="form-label">Colore card</label>
<input type="color" class="form-control" name="color" value="<?php echo e($val['color']); ?>">
</div>
<div class="col-md-5 mb-3">
<label class="form-label">Stato</label>
<select name="status" class="form-select">
<option value="draft" <?php echo $val['status'] === 'draft' ? 'selected' : ''; ?>>Bozza</option>
<option value="published" <?php echo $val['status'] === 'published' ? 'selected' : ''; ?>>Pubblicato</option>
<option value="cancelled" <?php echo $val['status'] === 'cancelled' ? 'selected' : ''; ?>>Annullato</option>
</select>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Lista d'attesa</label>
<div class="form-check form-switch" style="padding-top:8px;">
<input class="form-check-input" type="checkbox" role="switch" id="waitlist_enabled" name="waitlist_enabled" value="Y" <?php echo $val['waitlist_enabled'] ? 'checked' : ''; ?>>
<label class="form-check-label" for="waitlist_enabled" style="font-weight:400;">Consenti iscrizione a evento pieno</label>
</div>
</div>
</div>
<!-- Apertura programmata -->
<div class="row">
<div class="col-md-5 mb-3">
<label class="form-label">Apertura prenotazioni</label>
<div class="form-check form-switch" style="padding-top:8px;">
<input class="form-check-input" type="checkbox" role="switch" id="schedule_enabled" name="schedule_enabled" value="Y" <?php echo $val['schedule_enabled'] ? 'checked' : ''; ?>>
<label class="form-check-label" for="schedule_enabled" style="font-weight:400;">Apri in una data futura (programmato)</label>
</div>
</div>
<div class="col-md-4 mb-3" id="publish-at-wrap" style="<?php echo $val['schedule_enabled'] ? '' : 'display:none;'; ?>">
<label class="form-label">Data/ora apertura</label>
<input type="text" class="form-control" id="publish_at" name="publish_at" value="<?php echo e($val['publish_at']); ?>" placeholder="Quando aprire le prenotazioni" autocomplete="off">
</div>
</div>
<p class="hint" style="margin-top:-8px;">Se attivo, l'evento resta "Programmato" (visibile ma non prenotabile) finché il cron non lo apre alla data indicata.</p>
<!-- Promemoria e lista d'attesa (override per evento) -->
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label">Promemoria automatico</label>
<select name="reminder_enabled" class="form-select">
<option value="">Predefinito (scuola: <?php echo $defReminderEnabled ? 'attivo' : 'disattivo'; ?>)</option>
<option value="Y" <?php echo $val['reminder_enabled'] === 'Y' ? 'selected' : ''; ?>>Attivo</option>
<option value="N" <?php echo $val['reminder_enabled'] === 'N' ? 'selected' : ''; ?>>Disattivo</option>
</select>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Promemoria: ore prima</label>
<input type="number" min="1" max="168" class="form-control" name="reminder_hours" value="<?php echo e($val['reminder_hours']); ?>" placeholder="Default: <?php echo $defReminderHours; ?>">
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Scadenza offerta lista d'attesa (ore)</label>
<input type="number" min="1" max="336" class="form-control" name="offer_expiry_hours" value="<?php echo e($val['offer_expiry_hours']); ?>" placeholder="Default: <?php echo $defOfferHours; ?>">
</div>
</div>
<p class="hint" style="margin-top:-8px;">Lascia vuoto o "Predefinito" per usare le impostazioni generali (Opzioni Eventi).</p>
<div class="d-flex justify-content-between mt-2">
<a href="events.php" class="btn btn-light btn-back"><i class="bx bx-arrow-back"></i> Indietro</a>
<button type="submit" class="btn btn-primary btn-save">
<i class="bx bx-save"></i> <?php echo $isEdit ? 'Salva modifiche' : 'Crea evento'; ?>
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<?php include('include/footer.php'); ?>
</div>
</div>
</div>
<script src="assets/libs/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="assets/libs/metismenujs/metismenujs.min.js"></script>
<script src="assets/libs/simplebar/simplebar.min.js"></script>
<script src="assets/libs/eva-icons/eva.min.js"></script>
<script src="assets/js/app.js"></script>
<script>
/* ---------- Date/ora con flatpickr (fine = inizio + 1h) ---------- */
var fpOpts = {
enableTime: true,
time_24hr: true,
minuteIncrement: 15,
dateFormat: "Y-m-d H:i",
altInput: true,
altFormat: "l j F Y, H:i",
locale: "it"
};
var endPicker = flatpickr("#ends_at", fpOpts);
flatpickr("#publish_at", fpOpts);
document.getElementById('schedule_enabled').addEventListener('change', function() {
document.getElementById('publish-at-wrap').style.display = this.checked ? '' : 'none';
});
var startPicker = flatpickr("#starts_at", Object.assign({}, fpOpts, {
onChange: function(dates) {
if (!dates[0]) return;
endPicker.set('minDate', dates[0]);
var end = endPicker.selectedDates[0];
if (!end || end <= dates[0]) {
endPicker.setDate(new Date(dates[0].getTime() + 60 * 60 * 1000), false);
}
}
}));
/* ---------- Sede / indirizzo ---------- */
var addressInput = document.getElementById('address-input');
var latInput = document.getElementById('lat-input');
var lngInput = document.getElementById('lng-input');
var geoHint = document.getElementById('geo-hint');
var locationSelect = document.getElementById('location-select');
var currentAddress = <?php echo json_encode($val['address'], JSON_UNESCAPED_UNICODE); ?>;
function clearCoords() {
latInput.value = '';
lngInput.value = '';
}
// Dichiarazione (hoisted): usata anche in fase di preselezione qui sotto.
function applyLocation() {
if (!locationSelect) return;
var opt = locationSelect.options[locationSelect.selectedIndex];
if (locationSelect.value === 'custom') {
// Indirizzo personalizzato: campo vuoto, nessuna coordinata finché non premi "Calcola"
addressInput.value = '';
clearCoords();
geoHint.textContent = 'Scrivi l\'indirizzo e premi "Calcola" per le coordinate.';
addressInput.focus();
} else {
// Sede salvata: indirizzo e coordinate REALI della sede
addressInput.value = opt.getAttribute('data-address') || '';
latInput.value = opt.getAttribute('data-lat') || '';
lngInput.value = opt.getAttribute('data-lng') || '';
geoHint.textContent = (latInput.value && lngInput.value) ?
'Coordinate della sede caricate.' :
'Questa sede non ha coordinate salvate: premi "Calcola".';
}
}
if (locationSelect) {
var matched = null;
Array.prototype.forEach.call(locationSelect.options, function(opt) {
if (opt.value !== 'custom' && currentAddress !== '' && opt.getAttribute('data-address') === currentAddress) {
matched = opt.value;
}
});
if (currentAddress === '') {
locationSelect.selectedIndex = 0; // nuovo evento -> sede di default (scuola)
applyLocation();
} else if (matched) {
locationSelect.value = matched; // l'indirizzo salvato combacia con una sede
applyLocation();
} else {
locationSelect.value = 'custom'; // indirizzo personalizzato salvato: NON toccare i valori esistenti
}
locationSelect.addEventListener('change', applyLocation);
}
// Se modifichi l'indirizzo a mano, le vecchie coordinate non valgono più:
// le azzero subito, così non restano valori "di default" fuorvianti.
addressInput.addEventListener('input', function() {
clearCoords();
geoHint.textContent = 'Indirizzo modificato: premi "Calcola" per aggiornare le coordinate.';
});
/* ---------- Geocoding indirizzo -> lat/lng ---------- */
document.getElementById('btn-geocode').addEventListener('click', function() {
var q = addressInput.value.trim();
if (q === '') {
addressInput.focus();
return;
}
geoHint.textContent = 'Ricerca coordinate…';
fetch('event_geocode.php?q=' + encodeURIComponent(q))
.then(function(r) {
return r.json();
})
.then(function(data) {
if (data.ok) {
latInput.value = data.lat;
lngInput.value = data.lng;
latInput.classList.add('coord-flash');
lngInput.classList.add('coord-flash');
setTimeout(function() {
latInput.classList.remove('coord-flash');
lngInput.classList.remove('coord-flash');
}, 900);
geoHint.innerHTML = '<span style="color:#1a7f52;font-weight:600;">✓ Coordinate aggiornate:</span> ' +
data.lat + ', ' + data.lng + (data.display ? ' — ' + data.display : '');
} else {
geoHint.textContent = data.error || 'Indirizzo non trovato.';
}
})
.catch(function() {
geoHint.textContent = 'Errore durante il geocoding.';
});
});
/* ---------- Editor Quill ---------- */
var quill = new Quill('#editor', {
theme: 'snow',
modules: {
toolbar: [
[{
'header': [2, 3, false]
}],
['bold', 'italic', 'underline'],
[{
'list': 'ordered'
}, {
'list': 'bullet'
}],
['link', 'image'],
['clean']
]
}
});
var initialHtml = <?php echo json_encode($val['description'], JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP); ?>;
if (initialHtml && initialHtml.trim() !== '') {
quill.clipboard.dangerouslyPasteHTML(initialHtml);
}
/* ---------- Copertina drag&drop ---------- */
var dz = document.getElementById('cover-dropzone');
var fileInput = document.getElementById('cover-file');
var dzEmpty = document.getElementById('dz-empty');
var dzPreview = document.getElementById('dz-preview');
var dzProgress = document.getElementById('dz-progress');
var dzImg = document.getElementById('dz-img');
var coverHidden = document.getElementById('cover_image');
dz.addEventListener('click', function(ev) {
if (ev.target.id === 'dz-remove' || ev.target.closest('#dz-remove')) return;
fileInput.click();
});
['dragenter', 'dragover'].forEach(function(t) {
dz.addEventListener(t, function(ev) {
ev.preventDefault();
dz.classList.add('dragover');
});
});
['dragleave', 'drop'].forEach(function(t) {
dz.addEventListener(t, function(ev) {
ev.preventDefault();
dz.classList.remove('dragover');
});
});
dz.addEventListener('drop', function(ev) {
if (ev.dataTransfer.files.length) uploadCover(ev.dataTransfer.files[0]);
});
fileInput.addEventListener('change', function() {
if (fileInput.files.length) uploadCover(fileInput.files[0]);
});
document.getElementById('dz-remove').addEventListener('click', function() {
coverHidden.value = '';
dzImg.src = '';
dzPreview.style.display = 'none';
dzEmpty.style.display = '';
fileInput.value = '';
});
function uploadCover(file) {
if (!file.type.startsWith('image/')) {
Swal.fire('Formato non valido', 'Seleziona un file immagine.', 'warning');
return;
}
if (file.size > 5 * 1024 * 1024) {
Swal.fire('File troppo grande', "L'immagine supera i 5 MB.", 'warning');
return;
}
dzEmpty.style.display = 'none';
dzPreview.style.display = 'none';
dzProgress.style.display = 'block';
var fd = new FormData();
fd.append('cover', file);
fetch('event_upload_cover.php', {
method: 'POST',
body: fd
})
.then(function(r) {
return r.json();
})
.then(function(data) {
dzProgress.style.display = 'none';
if (data.ok) {
coverHidden.value = data.path;
dzImg.src = data.path + '?t=' + Date.now();
dzPreview.style.display = 'block';
} else {
dzEmpty.style.display = '';
Swal.fire('Errore', data.error || 'Caricamento non riuscito.', 'error');
}
})
.catch(function() {
dzProgress.style.display = 'none';
dzEmpty.style.display = '';
Swal.fire('Errore', 'Caricamento non riuscito.', 'error');
});
}
/* ---------- Galleria foto (multi-upload) ---------- */
var galleryInput = document.getElementById('gallery-input');
var galleryGrid = document.getElementById('gallery-grid');
var galleryFile = document.getElementById('gallery-file');
var galleryDz = document.getElementById('gallery-dropzone');
var galleryItems = [];
try {
galleryItems = JSON.parse(galleryInput.value || '[]');
if (!Array.isArray(galleryItems)) galleryItems = [];
} catch (e) {
galleryItems = [];
}
function renderGallery() {
galleryGrid.innerHTML = '';
galleryItems.forEach(function(path, idx) {
var cell = document.createElement('div');
cell.className = 'gallery-cell';
var img = document.createElement('img');
img.src = path;
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'gallery-remove';
btn.setAttribute('data-idx', idx);
btn.innerHTML = '&times;';
cell.appendChild(img);
cell.appendChild(btn);
galleryGrid.appendChild(cell);
});
galleryInput.value = JSON.stringify(galleryItems);
}
renderGallery();
galleryGrid.addEventListener('click', function(ev) {
var btn = ev.target.closest('.gallery-remove');
if (!btn) return;
galleryItems.splice(parseInt(btn.getAttribute('data-idx'), 10), 1);
renderGallery();
});
galleryDz.addEventListener('click', function() {
galleryFile.click();
});
['dragenter', 'dragover'].forEach(function(t) {
galleryDz.addEventListener(t, function(ev) {
ev.preventDefault();
galleryDz.classList.add('dragover');
});
});
['dragleave', 'drop'].forEach(function(t) {
galleryDz.addEventListener(t, function(ev) {
ev.preventDefault();
galleryDz.classList.remove('dragover');
});
});
galleryDz.addEventListener('drop', function(ev) {
if (ev.dataTransfer.files.length) uploadGallery(ev.dataTransfer.files);
});
galleryFile.addEventListener('change', function() {
if (galleryFile.files.length) uploadGallery(galleryFile.files);
});
function uploadGallery(files) {
Array.prototype.forEach.call(files, function(file) {
if (!file.type.startsWith('image/')) return;
if (file.size > 5 * 1024 * 1024) {
Swal.fire('File troppo grande', file.name + ' supera i 5 MB.', 'warning');
return;
}
var fd = new FormData();
fd.append('cover', file); // stesso handler della copertina
fetch('event_upload_cover.php', {
method: 'POST',
body: fd
})
.then(function(r) {
return r.json();
})
.then(function(data) {
if (data.ok) {
galleryItems.push(data.path);
renderGallery();
} else {
Swal.fire('Errore', data.error || 'Caricamento non riuscito.', 'error');
}
})
.catch(function() {
Swal.fire('Errore', 'Caricamento non riuscito.', 'error');
});
});
galleryFile.value = '';
}
/* ---------- Slug suggerito dal titolo ---------- */
(function() {
var titleInput = document.querySelector('input[name="title"]');
var slugInput = document.querySelector('input[name="slug"]');
if (!titleInput || !slugInput) return;
// In modifica lo slug esiste già: lo consideriamo "toccato" e non lo sovrascriviamo.
var slugEdited = slugInput.value.trim() !== '';
function slugify(s) {
return s.toString().normalize('NFD').replace(/[\u0300-\u036f]/g, '')
.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
}
slugInput.addEventListener('input', function() {
slugEdited = slugInput.value.trim() !== '';
});
titleInput.addEventListener('input', function() {
if (!slugEdited) slugInput.value = slugify(titleInput.value);
});
})();
/* ---------- Submit ---------- */
document.getElementById('event-form').addEventListener('submit', function() {
var html = quill.root.innerHTML;
document.getElementById('description-input').value = (html === '<p><br></p>') ? '' : html;
});
</script>
</body>
</html>
+61
View File
@@ -0,0 +1,61 @@
<?php
require_once('include/headscript.php');
/**
* Geocoding indirizzo -> 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();
+471
View File
@@ -0,0 +1,471 @@
<?php
require_once('include/headscript.php');
/**
* Modulo Eventi - gestione Sedi (event_locations): elenco, aggiungi, modifica,
* elimina e "imposta come predefinita".
* - event_locations.php -> 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;
?>
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8" />
<title>YogiBook - Sedi</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" href="assets/images/favicon.ico">
<link href="assets/css/bootstrap.min.css" id="bootstrap-style" rel="stylesheet" type="text/css" />
<link href="assets/css/icons.min.css" rel="stylesheet" type="text/css" />
<link href="assets/css/app.min.css" id="app-style" rel="stylesheet" type="text/css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<style>
.admin-card {
border: none;
border-radius: 14px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06);
}
.admin-card .card-body {
padding: 26px 30px;
}
.section-title {
font-size: 15px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #6b7280;
margin-bottom: 4px;
}
.section-lead {
color: #9ca3af;
font-size: 14px;
margin-bottom: 22px;
}
.form-label {
font-weight: 600;
font-size: 14px;
color: #374151;
}
.form-control {
border-radius: 8px;
border: 1px solid #e2e4e9;
padding: 10px 12px;
}
.form-control:focus {
border-color: #1ebf73;
box-shadow: 0 0 0 3px rgba(30, 191, 115, 0.12);
}
.hint {
color: #9ca3af;
font-size: 12px;
margin-top: 4px;
}
.btn-geocode {
border: 1px solid #e2e4e9;
background: #fff;
border-radius: 8px;
padding: 10px 14px;
font-weight: 600;
font-size: 13px;
color: #2f7be7;
white-space: nowrap;
}
.btn-geocode:hover {
background: #f3f4f6;
}
.coord-flash {
border-color: #1ebf73 !important;
box-shadow: 0 0 0 3px rgba(30, 191, 115, 0.18) !important;
transition: box-shadow .2s;
}
.loc-table thead th {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.03em;
color: #6b7280;
border-bottom: 2px solid #eef0f3;
white-space: nowrap;
}
.loc-table td {
vertical-align: middle;
}
.loc-name {
font-weight: 600;
color: #1f2937;
}
.default-badge {
display: inline-block;
font-size: 12px;
font-weight: 700;
padding: 3px 10px;
border-radius: 20px;
background: #e7f7ee;
color: #1a7f52;
}
.btn-act {
border: 1px solid #e2e4e9;
background: #fff;
border-radius: 8px;
padding: 6px 12px;
font-size: 13px;
font-weight: 600;
color: #374151;
cursor: pointer;
text-decoration: none;
white-space: nowrap;
}
.btn-act:hover {
background: #f3f4f6;
color: #111827;
}
.btn-act.danger {
color: #c0392b;
border-color: #f2c9c4;
}
.btn-act.danger:hover {
background: #fdecea;
}
.empty-state {
text-align: center;
padding: 40px 10px;
color: #9ca3af;
}
.empty-state i {
font-size: 34px;
margin-bottom: 10px;
display: block;
color: #d5d9e0;
}
.btn-save {
border-radius: 8px;
padding: 10px 26px;
font-weight: 600;
}
</style>
</head>
<body>
<div id="layout-wrapper">
<header id="page-topbar" class="isvertical-topbar">
<div class="navbar-header">
<div class="d-flex">
<?php include('include/logoarea.php'); ?>
<button type="button" class="btn btn-sm px-3 font-size-24 header-item waves-effect vertical-menu-btn">
<i class="bx bx-menu align-middle"></i>
</button>
<div class="page-title-box align-self-center d-none d-md-block">
<h4 class="page-title mb-0">Sedi</h4>
</div>
</div>
<div class="d-flex">
<?php include('include/languageselection.php'); ?>
<?php include('include/profiletopbar.php'); ?>
</div>
</div>
</header>
<?php include('include/sidebar.php'); ?>
<header class="ishorizontal-topbar">
<div class="navbar-header">
<div class="d-flex"></div>
</div>
<div class="topnav">
<div class="container-fluid">
<nav class="navbar navbar-light navbar-expand-lg topnav-menu"></nav>
</div>
</div>
</header>
<div class="main-content">
<div class="page-content">
<div class="container-fluid">
<?php if ($message === 'success') : ?>
<div class="alert alert-success" role="alert">Sede salvata.</div>
<?php elseif ($message === 'deleted') : ?>
<div class="alert alert-warning" role="alert">Sede eliminata.</div>
<?php elseif ($message === 'default') : ?>
<div class="alert alert-success" role="alert">Sede predefinita aggiornata.</div>
<?php elseif ($message === 'error') : ?>
<div class="alert alert-danger" role="alert">Errore: nome e indirizzo sono obbligatori.</div>
<?php endif; ?>
<?php if ($tableMissing) : ?>
<div class="alert alert-danger" role="alert">
La tabella <code>event_locations</code> non esiste ancora. Esegui la migration Phinx <code>CreateEventLocationsTable</code> e ricarica.
</div>
<?php endif; ?>
<div class="row">
<!-- Form -->
<div class="col-12">
<div class="card admin-card mb-4">
<div class="card-body">
<div class="section-title"><?php echo $isEdit ? 'Modifica sede' : 'Nuova sede'; ?></div>
<p class="section-lead">Le sedi salvate compaiono nella tendina "Sede" del form evento.</p>
<form action="event_locations_save.php" method="POST" id="loc-form">
<input type="hidden" name="id" value="<?php echo (int) $val['id']; ?>">
<div class="mb-3">
<label class="form-label">Nome sede *</label>
<input type="text" class="form-control" name="name" required
value="<?php echo e($val['name']); ?>" placeholder="Es. Studio YogaSoul">
</div>
<div class="mb-3">
<label class="form-label">Indirizzo *</label>
<div class="d-flex gap-2">
<input type="text" class="form-control" id="address-input" name="address" required
value="<?php echo e($val['address']); ?>" placeholder="Via, numero, città">
<button type="button" class="btn-geocode" id="btn-geocode"><i class="bx bx-map-pin"></i> Calcola</button>
</div>
<div class="hint" id="geo-hint">Premi "Calcola" per ottenere le coordinate dall'indirizzo.</div>
</div>
<div class="row">
<div class="col-6 mb-3">
<label class="form-label">Latitudine</label>
<input type="text" class="form-control" id="lat-input" name="lat" value="<?php echo e($val['lat']); ?>" placeholder="45.6600">
</div>
<div class="col-6 mb-3">
<label class="form-label">Longitudine</label>
<input type="text" class="form-control" id="lng-input" name="lng" value="<?php echo e($val['lng']); ?>" placeholder="8.7900">
</div>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="is_default" name="is_default" value="Y" <?php echo $val['is_default'] ? 'checked' : ''; ?>>
<label class="form-check-label" for="is_default">Imposta come sede predefinita</label>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary btn-save"><i class="bx bx-save"></i> <?php echo $isEdit ? 'Salva' : 'Aggiungi'; ?></button>
<?php if ($isEdit) : ?>
<a href="event_locations.php" class="btn btn-light btn-save">Annulla</a>
<?php endif; ?>
</div>
</form>
</div>
</div>
</div>
<!-- Elenco -->
<div class="col-12">
<div class="card admin-card">
<div class="card-body">
<div class="section-title">Sedi salvate</div>
<p class="section-lead">Rubrica delle sedi riutilizzabili.</p>
<div class="table-responsive">
<table class="table loc-table align-middle">
<thead>
<tr>
<th>Sede</th>
<th>Indirizzo</th>
<th>Coordinate</th>
<th class="text-end">Azioni</th>
</tr>
</thead>
<tbody>
<?php if (empty($locations)) : ?>
<tr>
<td colspan="4">
<div class="empty-state">
<i class="fas fa-map-marker-alt"></i>
Nessuna sede salvata. Aggiungine una dal form a sinistra.
</div>
</td>
</tr>
<?php else :
foreach ($locations as $loc) :
$hasCoords = $loc['lat'] !== null && $loc['lng'] !== null && $loc['lat'] !== '' && $loc['lng'] !== '';
?>
<tr>
<td>
<span class="loc-name"><?php echo e($loc['name']); ?></span>
<?php if ($loc['is_default'] === 'Y') : ?>
<span class="default-badge"><i class="bx bxs-star"></i> Predefinita</span>
<?php endif; ?>
</td>
<td><?php echo e($loc['address']); ?></td>
<td><?php echo $hasCoords ? e($loc['lat']) . ', ' . e($loc['lng']) : '<span style="color:#9ca3af;">—</span>'; ?></td>
<td class="text-end">
<a href="event_locations.php?edit=<?php echo (int) $loc['id']; ?>" class="btn-act"><i class="bx bx-edit"></i> Modifica</a>
<?php if ($loc['is_default'] !== 'Y') : ?>
<a href="event_locations.php?makedefault=<?php echo (int) $loc['id']; ?>" class="btn-act"><i class="bx bx-star"></i> Predefinita</a>
<?php endif; ?>
<button type="button" class="btn-act danger btn-del"
data-id="<?php echo (int) $loc['id']; ?>"
data-name="<?php echo e($loc['name']); ?>">
<i class="bx bx-trash"></i> Elimina
</button>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<?php include('include/footer.php'); ?>
</div>
</div>
</div>
<script src="assets/libs/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="assets/libs/metismenujs/metismenujs.min.js"></script>
<script src="assets/libs/simplebar/simplebar.min.js"></script>
<script src="assets/libs/eva-icons/eva.min.js"></script>
<script src="assets/js/app.js"></script>
<script>
var addressInput = document.getElementById('address-input');
var latInput = document.getElementById('lat-input');
var lngInput = document.getElementById('lng-input');
var geoHint = document.getElementById('geo-hint');
// Modifica indirizzo -> le vecchie coordinate non valgono più
addressInput.addEventListener('input', function() {
latInput.value = '';
lngInput.value = '';
geoHint.textContent = 'Indirizzo modificato: premi "Calcola" per aggiornare le coordinate.';
});
document.getElementById('btn-geocode').addEventListener('click', function() {
var q = addressInput.value.trim();
if (q === '') {
addressInput.focus();
return;
}
geoHint.textContent = 'Ricerca coordinate…';
fetch('event_geocode.php?q=' + encodeURIComponent(q))
.then(function(r) {
return r.json();
})
.then(function(data) {
if (data.ok) {
latInput.value = data.lat;
lngInput.value = data.lng;
latInput.classList.add('coord-flash');
lngInput.classList.add('coord-flash');
setTimeout(function() {
latInput.classList.remove('coord-flash');
lngInput.classList.remove('coord-flash');
}, 900);
geoHint.innerHTML = '<span style="color:#1a7f52;font-weight:600;">✓ Coordinate aggiornate:</span> ' +
data.lat + ', ' + data.lng + (data.display ? ' — ' + data.display : '');
} else {
geoHint.textContent = data.error || 'Indirizzo non trovato.';
}
})
.catch(function() {
geoHint.textContent = 'Errore durante il geocoding.';
});
});
$(document).ready(function() {
$('.btn-del').on('click', function() {
var id = $(this).data('id');
var name = $(this).data('name');
Swal.fire({
title: 'Eliminare la sede?',
html: 'Stai per eliminare <strong>' + $('<div>').text(name).html() + '</strong>.<br><small>Gli eventi che la usavano mantengono il loro indirizzo.</small>',
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Elimina',
cancelButtonText: 'Chiudi',
confirmButtonColor: '#e74c3c',
reverseButtons: true
}).then(function(result) {
if (result.isConfirmed) {
window.location.href = 'event_locations.php?delete=' + id;
}
});
});
});
</script>
</body>
</html>
+67
View File
@@ -0,0 +1,67 @@
<?php
require_once('include/headscript.php');
/**
* Salvataggio sede (insert/update) da event_locations.php.
* Se "predefinita" è spuntata, la imposta come unica default.
*/
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: event_locations.php');
exit();
}
$pdo = DBHandlerSelect::getInstance()->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();
}
+249
View File
@@ -0,0 +1,249 @@
<?php
require_once('include/headscript.php');
/**
* Opzioni Eventi (lato scuola): indirizzo per le copie, quali email mettere in
* copia (per tipo), e impostazioni promemoria. Salva in event_options_save.php.
*/
$pdo = DBHandlerSelect::getInstance()->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.'],
];
?>
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8" />
<title>YogiBook - Opzioni Eventi</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" href="assets/images/favicon.ico">
<link href="assets/css/bootstrap.min.css" id="bootstrap-style" rel="stylesheet" type="text/css" />
<link href="assets/css/icons.min.css" rel="stylesheet" type="text/css" />
<link href="assets/css/app.min.css" id="app-style" rel="stylesheet" type="text/css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
<style>
.admin-card {
border: none;
border-radius: 14px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06);
}
.admin-card .card-body {
padding: 26px 30px;
}
.section-title {
font-size: 15px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #6b7280;
margin-bottom: 4px;
}
.section-lead {
color: #9ca3af;
font-size: 14px;
margin-bottom: 22px;
}
.form-label {
font-weight: 600;
font-size: 14px;
color: #374151;
}
.form-control {
border-radius: 8px;
border: 1px solid #e2e4e9;
padding: 10px 12px;
}
.form-control:focus {
border-color: #1ebf73;
box-shadow: 0 0 0 3px rgba(30, 191, 115, 0.12);
}
.opt-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 0;
border-bottom: 1px solid #f0f0f0;
}
.opt-row:last-child {
border-bottom: none;
}
.opt-label {
font-weight: 600;
color: #1f2937;
font-size: 15px;
}
.opt-help {
color: #9ca3af;
font-size: 13px;
margin-top: 3px;
}
.form-switch .form-check-input {
width: 3em;
height: 1.5em;
cursor: pointer;
}
.save-bar {
margin-top: 24px;
text-align: right;
}
</style>
</head>
<body>
<div id="layout-wrapper">
<header id="page-topbar" class="isvertical-topbar">
<div class="navbar-header">
<div class="d-flex">
<?php include('include/logoarea.php'); ?>
<button type="button" class="btn btn-sm px-3 font-size-24 header-item waves-effect vertical-menu-btn">
<i class="bx bx-menu align-middle"></i>
</button>
<div class="page-title-box align-self-center d-none d-md-block">
<h4 class="page-title mb-0">Opzioni Eventi</h4>
</div>
</div>
<div class="d-flex">
<?php include('include/languageselection.php'); ?>
<?php include('include/profiletopbar.php'); ?>
</div>
</div>
</header>
<?php include('include/sidebar.php'); ?>
<header class="ishorizontal-topbar">
<div class="navbar-header">
<div class="d-flex"></div>
</div>
<div class="topnav">
<div class="container-fluid">
<nav class="navbar navbar-light navbar-expand-lg topnav-menu"></nav>
</div>
</div>
</header>
<div class="main-content">
<div class="page-content">
<div class="container-fluid">
<?php if ($message === 'success') : ?>
<div class="alert alert-success" role="alert">Opzioni salvate.</div>
<?php elseif ($message === 'error') : ?>
<div class="alert alert-danger" role="alert">Errore durante il salvataggio.</div>
<?php endif; ?>
<form action="event_options_save.php" method="POST">
<div class="row">
<div class="col-lg-8">
<div class="card admin-card mb-4">
<div class="card-body">
<div class="section-title">Copie alla scuola</div>
<p class="section-lead">Scegli su quali email l'insegnante/scuola riceve una copia (CC).</p>
<div class="mb-3">
<label class="form-label">Email della scuola per le copie</label>
<input type="email" class="form-control" name="event_notify_email" value="<?php echo e($ccEmail); ?>" placeholder="info@yogasoul.it">
<div class="opt-help">Se vuota, non viene inviata nessuna copia anche se le voci sotto sono attive.</div>
</div>
<?php foreach ($types as $t) : ?>
<div class="opt-row">
<div>
<div class="opt-label"><?php echo e($t[1]); ?></div>
<div class="opt-help"><?php echo e($t[2]); ?></div>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" name="<?php echo e($t[0]); ?>" value="Y" <?php echo isY($opt, $t[0]) ? 'checked' : ''; ?>>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<div class="card admin-card">
<div class="card-body">
<div class="section-title">Promemoria</div>
<p class="section-lead">Invio automatico del promemoria agli iscritti prima dell'evento (richiede il cron, vedi setup).</p>
<div class="opt-row">
<div>
<div class="opt-label">Attiva promemoria</div>
<div class="opt-help">Invia un'email di promemoria agli iscritti confermati.</div>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" name="event_reminder_enabled" value="Y" <?php echo isY($opt, 'event_reminder_enabled') ? 'checked' : ''; ?>>
</div>
</div>
<div class="opt-row">
<div>
<div class="opt-label">Ore prima dell'evento</div>
<div class="opt-help">Quante ore prima dell'inizio inviare il promemoria.</div>
</div>
<div style="width:120px;">
<input type="number" min="1" max="168" class="form-control" name="event_reminder_hours" value="<?php echo e($reminderH); ?>">
</div>
</div>
<div class="opt-row">
<div>
<div class="opt-label">Scadenza offerta lista d'attesa (ore)</div>
<div class="opt-help">Default: quanto tempo una persona promossa ha per riconfermare prima che il posto passi al prossimo. Modificabile per singolo evento.</div>
</div>
<div style="width:120px;">
<input type="number" min="1" max="336" class="form-control" name="event_offer_expiry_hours" value="<?php echo e($offerH); ?>">
</div>
</div>
<div class="save-bar">
<button type="submit" class="btn btn-success"><i class="fas fa-save me-1"></i> Salva opzioni</button>
</div>
</div>
</div>
</div>
</div>
</form>
</div>
<?php include('include/footer.php'); ?>
</div>
</div>
</div>
<script src="assets/libs/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="assets/libs/metismenujs/metismenujs.min.js"></script>
<script src="assets/libs/simplebar/simplebar.min.js"></script>
<script src="assets/libs/eva-icons/eva.min.js"></script>
<script src="assets/js/app.js"></script>
</body>
</html>
+57
View File
@@ -0,0 +1,57 @@
<?php
require_once('include/headscript.php');
/**
* Salvataggio Opzioni Eventi (email/copie/promemoria) nella tabella `option`.
*/
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: event_options.php');
exit();
}
$pdo = DBHandlerSelect::getInstance()->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();
}
+489
View File
@@ -0,0 +1,489 @@
<?php
require_once('include/headscript.php');
require_once('include/event_waitlist.php');
require_once('include/event_mailer.php');
/**
* Modulo Eventi - elenco partecipanti di un evento.
* Mostra riepilogo (posti, iscrizioni, incasso) e la tabella delle prenotazioni.
* Gestisce anche l'annullamento della singola iscrizione via ?cancel_booking=ID.
*/
$pdo = DBHandlerSelect::getInstance()->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'];
}
}
?>
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8" />
<title>YogiBook - Partecipanti</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" href="assets/images/favicon.ico">
<link href="assets/css/bootstrap.min.css" id="bootstrap-style" rel="stylesheet" type="text/css" />
<link href="assets/css/icons.min.css" rel="stylesheet" type="text/css" />
<link href="assets/css/app.min.css" id="app-style" rel="stylesheet" type="text/css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<style>
.admin-card {
border: none;
border-radius: 14px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06);
}
.admin-card .card-body {
padding: 26px 30px;
}
.section-title {
font-size: 15px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #6b7280;
margin-bottom: 4px;
}
.section-lead {
color: #9ca3af;
font-size: 14px;
margin-bottom: 22px;
}
.stat-box {
background: #f7f8fa;
border-radius: 12px;
padding: 18px 20px;
}
.stat-value {
font-size: 26px;
font-weight: 700;
color: #1f2937;
}
.stat-label {
color: #6b7280;
font-size: 13px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.part-table thead th {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.03em;
color: #6b7280;
border-bottom: 2px solid #eef0f3;
white-space: nowrap;
}
.part-table td {
vertical-align: middle;
}
.user-cell {
display: flex;
align-items: center;
gap: 10px;
}
.avatar {
width: 36px;
height: 36px;
border-radius: 50%;
color: #fff;
font-weight: 700;
font-size: 13px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.user-name {
font-weight: 600;
color: #1f2937;
}
.guest-tag {
font-size: 11px;
font-weight: 600;
color: #6b7280;
background: #eef0f3;
border-radius: 10px;
padding: 1px 7px;
margin-left: 6px;
}
.status-badge {
display: inline-block;
font-size: 12px;
font-weight: 700;
padding: 3px 10px;
border-radius: 20px;
}
.btn-cancel-b {
border: 1px solid #f2c9c4;
background: #fff;
color: #c0392b;
border-radius: 8px;
padding: 5px 11px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
}
.btn-cancel-b:hover {
background: #fdecea;
}
.empty-state {
text-align: center;
padding: 44px 10px;
color: #9ca3af;
}
.empty-state i {
font-size: 34px;
margin-bottom: 10px;
display: block;
color: #d5d9e0;
}
.yes-mark {
color: #1a7f52;
font-weight: 700;
}
.no-mark {
color: #c0392b;
font-weight: 700;
}
</style>
</head>
<body>
<div id="layout-wrapper">
<header id="page-topbar" class="isvertical-topbar">
<div class="navbar-header">
<div class="d-flex">
<?php include('include/logoarea.php'); ?>
<button type="button" class="btn btn-sm px-3 font-size-24 header-item waves-effect vertical-menu-btn">
<i class="bx bx-menu align-middle"></i>
</button>
<div class="page-title-box align-self-center d-none d-md-block">
<h4 class="page-title mb-0">Partecipanti</h4>
</div>
</div>
<div class="d-flex">
<?php include('include/languageselection.php'); ?>
<?php include('include/profiletopbar.php'); ?>
</div>
</div>
</header>
<?php include('include/sidebar.php'); ?>
<header class="ishorizontal-topbar">
<div class="navbar-header">
<div class="d-flex"></div>
</div>
<div class="topnav">
<div class="container-fluid">
<nav class="navbar navbar-light navbar-expand-lg topnav-menu"></nav>
</div>
</div>
</header>
<div class="main-content">
<div class="page-content">
<div class="container-fluid">
<?php if ($message === 'booking_cancelled') : ?>
<div class="alert alert-warning" role="alert">Iscrizione annullata.</div>
<?php endif; ?>
<div class="row">
<div class="col-12">
<div class="card admin-card mb-4">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="section-title">Partecipanti</div>
<p class="section-lead mb-0">
<strong style="color:#1f2937;"><?php echo e($event['title']); ?></strong>
&middot; <?php echo e(date('d-m-Y H:i', strtotime($event['starts_at']))); ?>
</p>
</div>
<a href="events.php" class="btn btn-light" style="border-radius:8px;font-weight:600;"><i class="bx bx-arrow-back"></i> Tutti gli eventi</a>
</div>
<div class="row mt-4">
<div class="col-md-3 mb-3">
<div class="stat-box">
<div class="stat-value"><?php echo $bookedSeats; ?> / <?php echo $maxSeats; ?></div>
<div class="stat-label">Posti prenotati</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="stat-box">
<div class="stat-value"><?php echo $waitingSeats; ?></div>
<div class="stat-label">In lista d'attesa</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="stat-box">
<div class="stat-value"><?php echo $activeCount; ?></div>
<div class="stat-label">Iscrizioni attive</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="stat-box">
<div class="stat-value"><?php echo e(number_format($revenue, 2, ',', '.')); ?> <?php echo e($event['currency']); ?></div>
<div class="stat-label">Incasso stimato</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card admin-card">
<div class="card-body">
<div class="table-responsive">
<table class="table part-table align-middle">
<thead>
<tr>
<th>Partecipante</th>
<th>Email</th>
<th>Telefono</th>
<th>Posti</th>
<th>Marketing</th>
<th>Iscritto il</th>
<th>Stato</th>
<th class="text-end">Azioni</th>
</tr>
</thead>
<tbody>
<?php if (empty($bookings)) : ?>
<tr>
<td colspan="8">
<div class="empty-state">
<i class="fas fa-users"></i>
Ancora nessun iscritto a questo evento.
</div>
</td>
</tr>
<?php else :
foreach ($bookings as $b) :
// Il nome può essere un unico campo: proviamo a spezzarlo per le iniziali
$nameParts = preg_split('/\s+/', trim($b['name']), 2);
$first = $nameParts[0] ?? '';
$last = $nameParts[1] ?? '';
$isGuest = empty($b['user_id']);
[$stLabel, $stBg, $stColor] = bookingBadge($b['status']);
?>
<tr>
<td>
<div class="user-cell">
<div class="avatar" style="background: <?php echo avatarColor($b['name']); ?>;">
<?php echo e(initials($first, $last)); ?>
</div>
<span class="user-name"><?php echo e($b['name']); ?>
<?php if ($isGuest) : ?><span class="guest-tag">ospite</span><?php endif; ?>
</span>
</div>
</td>
<td><?php echo e($b['email']); ?></td>
<td><?php echo e($b['phone'] ?: '—'); ?></td>
<td><?php echo (int) $b['seats']; ?></td>
<td>
<?php if (($b['marketing_opt_in'] ?? 'N') === 'Y') : ?>
<span class="yes-mark">Sì</span>
<?php else : ?>
<span class="no-mark">No</span>
<?php endif; ?>
</td>
<td><?php echo e($b['created_at'] ? date('d-m-Y', strtotime($b['created_at'])) : '—'); ?></td>
<td><span class="status-badge" style="background: <?php echo $stBg; ?>; color: <?php echo $stColor; ?>;"><?php echo e($stLabel); ?></span></td>
<td class="text-end">
<?php if ($b['status'] !== 'cancelled') : ?>
<button type="button" class="btn-cancel-b btn-cancel-booking"
data-id="<?php echo (int) $b['id']; ?>"
data-name="<?php echo e($b['name']); ?>">
<i class="bx bx-block"></i> Annulla
</button>
<?php else : ?>
<span style="color:#9ca3af;font-size:13px;">—</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<?php include('include/footer.php'); ?>
</div>
</div>
</div>
<script src="assets/libs/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="assets/libs/metismenujs/metismenujs.min.js"></script>
<script src="assets/libs/simplebar/simplebar.min.js"></script>
<script src="assets/libs/eva-icons/eva.min.js"></script>
<script src="assets/js/app.js"></script>
<script>
$(document).ready(function() {
$('.btn-cancel-booking').on('click', function() {
var id = $(this).data('id');
var name = $(this).data('name');
Swal.fire({
title: 'Annullare iscrizione?',
html: 'Stai per annullare l\'iscrizione di <strong>' + $('<div>').text(name).html() + '</strong>.',
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Annulla iscrizione',
cancelButtonText: 'Chiudi',
confirmButtonColor: '#e74c3c',
reverseButtons: true
}).then(function(result) {
if (result.isConfirmed) {
window.location.href = 'event_participants.php?id=<?php echo $eventId; ?>&cancel_booking=' + id;
}
});
});
});
</script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+222
View File
@@ -0,0 +1,222 @@
<?php
require_once('include/headscript.php');
require_once('include/event_mailer.php');
/**
* Promemoria manuale: apre un editor col testo prefissato (modificabile) e il
* punto di ritrovo come link mappa (prefill dal luogo evento). Invio da
* event_reminder_send.php a tutti gli iscritti confermati.
*/
$pdo = DBHandlerSelect::getInstance()->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'] ?? '';
?>
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8" />
<title>YogiBook - Promemoria</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" href="assets/images/favicon.ico">
<link href="assets/css/bootstrap.min.css" id="bootstrap-style" rel="stylesheet" type="text/css" />
<link href="assets/css/icons.min.css" rel="stylesheet" type="text/css" />
<link href="assets/css/app.min.css" id="app-style" rel="stylesheet" type="text/css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<style>
.admin-card {
border: none;
border-radius: 14px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06);
}
.admin-card .card-body {
padding: 26px 30px;
}
.section-title {
font-size: 15px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #6b7280;
margin-bottom: 4px;
}
.section-lead {
color: #9ca3af;
font-size: 14px;
margin-bottom: 22px;
}
.form-label {
font-weight: 600;
font-size: 14px;
color: #374151;
}
.form-control {
border-radius: 8px;
border: 1px solid #e2e4e9;
padding: 10px 12px;
}
.form-control:focus {
border-color: #1ebf73;
box-shadow: 0 0 0 3px rgba(30, 191, 115, 0.12);
}
.hint {
color: #9ca3af;
font-size: 12px;
margin-top: 4px;
}
.recap {
background: #f7f8fa;
border-radius: 10px;
padding: 12px 14px;
font-size: 14px;
color: #4b5563;
margin-bottom: 18px;
}
</style>
</head>
<body>
<div id="layout-wrapper">
<header id="page-topbar" class="isvertical-topbar">
<div class="navbar-header">
<div class="d-flex">
<?php include('include/logoarea.php'); ?>
<button type="button" class="btn btn-sm px-3 font-size-24 header-item waves-effect vertical-menu-btn">
<i class="bx bx-menu align-middle"></i>
</button>
<div class="page-title-box align-self-center d-none d-md-block">
<h4 class="page-title mb-0">Promemoria</h4>
</div>
</div>
<div class="d-flex">
<?php include('include/languageselection.php'); ?>
<?php include('include/profiletopbar.php'); ?>
</div>
</div>
</header>
<?php include('include/sidebar.php'); ?>
<header class="ishorizontal-topbar">
<div class="navbar-header">
<div class="d-flex"></div>
</div>
<div class="topnav">
<div class="container-fluid">
<nav class="navbar navbar-light navbar-expand-lg topnav-menu"></nav>
</div>
</div>
</header>
<div class="main-content">
<div class="page-content">
<div class="container-fluid">
<?php if ($message === 'sent') : ?>
<div class="alert alert-success" role="alert">Promemoria inviato.</div>
<?php elseif ($message === 'error') : ?>
<div class="alert alert-danger" role="alert">Errore durante l'invio.</div>
<?php endif; ?>
<div class="row">
<div class="col-lg-8">
<div class="card admin-card">
<div class="card-body">
<div class="section-title">Invia promemoria</div>
<p class="section-lead">Controlla il testo e il punto di ritrovo, poi invia agli iscritti confermati.</p>
<div class="recap">
<strong style="color:#1f2937;"><?php echo e($event['title']); ?></strong> · <?php echo e(date('d-m-Y H:i', strtotime($event['starts_at']))); ?><br>
Destinatari confermati: <strong><?php echo $recipients; ?></strong>
</div>
<form action="event_reminder_send.php" method="POST" id="rem-form">
<input type="hidden" name="id" value="<?php echo (int) $eventId; ?>">
<div class="mb-3">
<label class="form-label">Messaggio</label>
<textarea class="form-control" name="message" rows="5"><?php echo e($defaultMsg); ?></textarea>
<div class="hint">Data, ora e luogo dell'evento vengono aggiunti automaticamente sotto al messaggio.</div>
</div>
<div class="mb-3">
<label class="form-label">Punto di ritrovo (link mappa)</label>
<input type="url" class="form-control" name="meeting_link" value="<?php echo e($meetingLink); ?>" placeholder="Incolla qui il link di Google Maps del punto di ritrovo">
<div class="hint">Prefissato dal luogo dell'evento. Puoi incollare un link di Google Maps più preciso (es. la condivisione della posizione). Lascia vuoto per non includerlo.</div>
</div>
<div class="d-flex justify-content-between mt-2">
<a href="events.php" class="btn btn-light" style="border-radius:8px;font-weight:600;"><i class="bx bx-arrow-back"></i> Indietro</a>
<button type="submit" class="btn btn-success" style="border-radius:8px;font-weight:600;" <?php echo $recipients === 0 ? 'disabled' : ''; ?>>
<i class="bx bx-send"></i> Invia a <?php echo $recipients; ?> iscritt<?php echo $recipients === 1 ? 'o' : 'i'; ?>
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<?php include('include/footer.php'); ?>
</div>
</div>
</div>
<script src="assets/libs/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="assets/libs/metismenujs/metismenujs.min.js"></script>
<script src="assets/libs/simplebar/simplebar.min.js"></script>
<script src="assets/libs/eva-icons/eva.min.js"></script>
<script src="assets/js/app.js"></script>
<script>
document.getElementById('rem-form').addEventListener('submit', function(ev) {
ev.preventDefault();
var form = this;
Swal.fire({
title: 'Inviare il promemoria?',
text: 'Verrà inviato a <?php echo $recipients; ?> iscritti confermati.',
icon: 'question',
showCancelButton: true,
confirmButtonText: 'Invia',
cancelButtonText: 'Annulla',
confirmButtonColor: '#1ebf73',
reverseButtons: true
}).then(function(r) {
if (r.isConfirmed) form.submit();
});
});
</script>
</body>
</html>
+58
View File
@@ -0,0 +1,58 @@
<?php
require_once('include/headscript.php');
require_once('include/event_mailer.php');
/**
* Invio del promemoria manuale a tutti gli iscritti confermati di un evento.
*/
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: events.php');
exit();
}
$pdo = DBHandlerSelect::getInstance()->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();
}
+214
View File
@@ -0,0 +1,214 @@
<?php
require_once('include/headscript.php');
/**
* Handler salvataggio evento (insert + update). Riceve il POST di event_form.php,
* genera lo slug se mancante, valida i campi e fa redirect con ?message=.
*/
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: events.php');
exit();
}
$pdo = DBHandlerSelect::getInstance()->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();
}
+62
View File
@@ -0,0 +1,62 @@
<?php
require_once('include/headscript.php');
/**
* Upload copertina evento (chiamato in AJAX dalla dropzone di event_form.php).
* Valida tipo/dimensione, salva in assets/uploads/events/ e ritorna JSON:
* { ok:true, path:"assets/uploads/events/xxxx.jpg" } oppure { ok:false, error:"..." }
*/
if (function_exists('ob_get_level') && ob_get_level()) {
ob_clean(); // pulisce eventuali whitespace dell'include prima del JSON
}
header('Content-Type: application/json; charset=utf-8');
$fail = function (string $msg): void {
echo json_encode(['ok' => 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();
+59
View File
@@ -0,0 +1,59 @@
<?php
/**
* Lista d'attesa — promozione automatica.
*
* IMPORTANTE: chiamare DENTRO una transazione in cui la riga dell'evento è già
* bloccata (SELECT ... FOR UPDATE), tipicamente subito dopo aver liberato posti
* (cancellazione di una prenotazione confermata). Così due operazioni simultanee
* non promuovono oltre la capienza.
*
* Promuove in ordine di arrivo (FIFO) le prenotazioni 'waiting' che entrano nei
* posti liberi e si ferma alla prima che non ci sta, per rispettare la coda
* (non "salta" chi è davanti).
*
* @return array<int,array> 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;
}
}
+523
View File
@@ -0,0 +1,523 @@
<?php
require_once('include/headscript.php');
/**
* Modulo Eventi - elenco lato insegnante (stile Amelia).
* Mostra gli eventi raggruppati per data con posti prenotati, stato e azioni:
* - Partecipanti -> 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'];
}
}
?>
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8" />
<title>YogiBook - Eventi</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta content="YogiBook - Prenotazione facile YogaSoul" name="description" />
<link rel="shortcut icon" href="assets/images/favicon.ico">
<link href="assets/css/bootstrap.min.css" id="bootstrap-style" rel="stylesheet" type="text/css" />
<link href="assets/css/icons.min.css" rel="stylesheet" type="text/css" />
<link href="assets/css/app.min.css" id="app-style" rel="stylesheet" type="text/css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<style>
.admin-card {
border: none;
border-radius: 14px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06);
}
.admin-card .card-body {
padding: 26px 30px;
}
.section-title {
font-size: 15px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #6b7280;
margin-bottom: 4px;
}
.section-lead {
color: #9ca3af;
font-size: 14px;
margin-bottom: 22px;
}
.events-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.btn-new-event {
background: #2f7be7;
border: none;
color: #fff;
border-radius: 8px;
padding: 9px 18px;
font-weight: 600;
font-size: 14px;
white-space: nowrap;
}
.btn-new-event:hover {
filter: brightness(0.94);
color: #fff;
}
.events-table thead th {
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.03em;
color: #6b7280;
border-bottom: 2px solid #eef0f3;
white-space: nowrap;
}
.events-table td {
vertical-align: middle;
}
.date-group td {
background: #f7f8fa;
font-weight: 600;
color: #4b5563;
font-size: 13px;
text-transform: capitalize;
}
.event-name {
font-weight: 600;
color: #1f2937;
display: flex;
align-items: center;
gap: 9px;
}
.color-dot {
display: inline-block;
width: 12px;
height: 12px;
border-radius: 3px;
flex-shrink: 0;
}
.event-thumb {
width: 42px;
height: 42px;
border-radius: 8px;
object-fit: cover;
flex-shrink: 0;
}
.event-thumb.placeholder {
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
}
.event-id {
color: #9ca3af;
font-weight: 400;
font-size: 12px;
}
.seats-pill {
display: inline-block;
background: #eef6f1;
color: #1a7f52;
font-weight: 700;
font-size: 13px;
padding: 2px 10px;
border-radius: 20px;
}
.seats-pill.full {
background: #fdecea;
color: #c0392b;
}
.wait-pill {
display: inline-block;
background: #eef2fb;
color: #3a5bb8;
font-weight: 700;
font-size: 12px;
padding: 2px 9px;
border-radius: 20px;
margin-left: 6px;
}
.id-chip {
display: inline-block;
background: #eef0f3;
color: #4b5563;
font-weight: 700;
font-size: 11px;
padding: 1px 8px;
border-radius: 6px;
margin-left: 6px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.status-badge {
display: inline-block;
font-size: 12px;
font-weight: 700;
padding: 3px 10px;
border-radius: 20px;
}
.btn-act {
border: 1px solid #e2e4e9;
background: #fff;
border-radius: 8px;
padding: 6px 12px;
font-size: 13px;
font-weight: 600;
color: #374151;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
white-space: nowrap;
text-decoration: none;
}
.btn-act:hover {
background: #f3f4f6;
color: #111827;
}
.btn-act.danger {
color: #c0392b;
border-color: #f2c9c4;
}
.btn-act.danger:hover {
background: #fdecea;
}
.empty-state {
text-align: center;
padding: 44px 10px;
color: #9ca3af;
}
.empty-state i {
font-size: 34px;
margin-bottom: 10px;
display: block;
color: #d5d9e0;
}
</style>
</head>
<body>
<div id="layout-wrapper">
<header id="page-topbar" class="isvertical-topbar">
<div class="navbar-header">
<div class="d-flex">
<?php include('include/logoarea.php'); ?>
<button type="button" class="btn btn-sm px-3 font-size-24 header-item waves-effect vertical-menu-btn">
<i class="bx bx-menu align-middle"></i>
</button>
<div class="page-title-box align-self-center d-none d-md-block">
<h4 class="page-title mb-0">Eventi</h4>
</div>
</div>
<div class="d-flex">
<?php include('include/languageselection.php'); ?>
<?php include('include/profiletopbar.php'); ?>
</div>
</div>
</header>
<?php include('include/sidebar.php'); ?>
<header class="ishorizontal-topbar">
<div class="navbar-header">
<div class="d-flex"></div>
</div>
<div class="topnav">
<div class="container-fluid">
<nav class="navbar navbar-light navbar-expand-lg topnav-menu"></nav>
</div>
</div>
</header>
<div class="main-content">
<div class="page-content">
<div class="container-fluid">
<?php if ($message === 'success') : ?>
<div class="alert alert-success" role="alert">Evento salvato con successo.</div>
<?php elseif ($message === 'cancelled') : ?>
<div class="alert alert-warning" role="alert">Evento annullato.</div>
<?php elseif ($message === 'error') : ?>
<div class="alert alert-danger" role="alert">Si è verificato un errore. Controlla i dati e riprova.</div>
<?php endif; ?>
<div class="row">
<div class="col-12">
<div class="card admin-card">
<div class="card-body">
<div class="events-head">
<div>
<div class="section-title">Eventi</div>
<p class="section-lead mb-0">Ciao <?php echo e($firstname ?? ''); ?>, gestisci qui i tuoi eventi: crea, modifica, vedi i partecipanti o annulla.</p>
</div>
<a href="event_form.php" class="btn btn-new-event"><i class="bx bx-plus align-middle"></i> Nuovo evento</a>
</div>
<div class="table-responsive mt-3">
<table class="table events-table align-middle">
<thead>
<tr>
<th>Orario</th>
<th>Nome</th>
<th>Organizzatore</th>
<th>Prenotato</th>
<th>Stato</th>
<th class="text-end">Azioni</th>
</tr>
</thead>
<tbody>
<?php if (empty($events)) : ?>
<tr>
<td colspan="6">
<div class="empty-state">
<i class="fas fa-calendar-plus"></i>
Nessun evento ancora. Crea il primo con "Nuovo evento".
</div>
</td>
</tr>
<?php else :
$currentDate = null;
foreach ($events as $ev) :
$d = date('Y-m-d', strtotime($ev['starts_at']));
if ($d !== $currentDate) :
$currentDate = $d; ?>
<tr class="date-group">
<td colspan="6"><?php echo e(dataItaliana($ev['starts_at'], $mesi)); ?></td>
</tr>
<?php endif;
$organizer = trim((string) $ev['organizer_name']) !== ''
? $ev['organizer_name']
: trim(($ev['creator_first'] ?? '') . ' ' . ($ev['creator_last'] ?? ''));
if ($organizer === '') {
$organizer = '—';
}
$booked = (int) $ev['booked'];
$maxSeats = (int) $ev['max_seats'];
$isFull = $maxSeats > 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']));
}
?>
<tr>
<td><?php echo e($timeLabel); ?></td>
<td>
<div class="event-name">
<?php if (!empty($ev['cover_image'])) : ?>
<img class="event-thumb" src="<?php echo e($ev['cover_image']); ?>" alt="">
<?php else : ?>
<span class="event-thumb placeholder" style="background: <?php echo e($ev['color']); ?>22;"><i class="bx bx-image" style="color: <?php echo e($ev['color']); ?>;"></i></span>
<?php endif; ?>
<span class="color-dot" style="background: <?php echo e($ev['color']); ?>;"></span>
<span><?php echo e($ev['title']); ?> <span class="id-chip">ID <?php echo (int) $ev['id']; ?></span></span>
</div>
</td>
<td><?php echo e($organizer); ?></td>
<td>
<span class="seats-pill <?php echo $isFull ? 'full' : ''; ?>">
<?php echo $booked; ?> / <?php echo $maxSeats; ?>
</span>
<?php if ((int) $ev['waiting'] > 0) : ?>
<span class="wait-pill" title="In lista d'attesa">+<?php echo (int) $ev['waiting']; ?> in lista</span>
<?php endif; ?>
</td>
<td>
<span class="status-badge" style="background: <?php echo $stBg; ?>; color: <?php echo $stColor; ?>;"><?php echo e($stLabel); ?></span>
</td>
<td class="text-end">
<button type="button" class="btn-act btn-shortcode" data-code='[yogasoul_eventi ids="<?php echo (int) $ev['id']; ?>"]'><i class="bx bx-code-alt"></i> Shortcode</button>
<a href="event_preview.php?id=<?php echo (int) $ev['id']; ?>" target="_blank" class="btn-act"><i class="bx bx-show"></i> Anteprima</a>
<a href="event_participants.php?id=<?php echo (int) $ev['id']; ?>" class="btn-act"><i class="bx bx-group"></i> Partecipanti</a>
<a href="event_form.php?id=<?php echo (int) $ev['id']; ?>" class="btn-act"><i class="bx bx-edit"></i> Modifica</a>
<?php if ($ev['status'] === 'published' && strtotime($ev['starts_at']) > time()) : ?>
<a href="event_reminder.php?id=<?php echo (int) $ev['id']; ?>" class="btn-act"><i class="bx bx-bell"></i> Promemoria</a>
<?php endif; ?>
<?php if ($ev['status'] !== 'cancelled') : ?>
<button type="button" class="btn-act danger btn-annulla"
data-id="<?php echo (int) $ev['id']; ?>"
data-title="<?php echo e($ev['title']); ?>"
data-booked="<?php echo $booked; ?>">
<i class="bx bx-block"></i> Annulla
</button>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<?php include('include/footer.php'); ?>
</div>
</div>
</div>
<script src="assets/libs/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="assets/libs/metismenujs/metismenujs.min.js"></script>
<script src="assets/libs/simplebar/simplebar.min.js"></script>
<script src="assets/libs/eva-icons/eva.min.js"></script>
<script src="assets/js/app.js"></script>
<script>
$(document).ready(function() {
// Copia shortcode
$('.btn-shortcode').on('click', function() {
var code = $(this).data('code');
var btn = $(this);
var original = btn.html();
function done() {
btn.html('<i class="bx bx-check"></i> Copiato!');
setTimeout(function() {
btn.html(original);
}, 1500);
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(code).then(done).catch(function() {
window.prompt('Copia lo shortcode:', code);
});
} else {
window.prompt('Copia lo shortcode:', code);
}
});
$('.btn-annulla').on('click', function() {
var id = $(this).data('id');
var title = $(this).data('title');
var booked = parseInt($(this).data('booked'), 10) || 0;
Swal.fire({
title: "Annullare l'evento?",
html: '<p style="margin-bottom:12px;">Stai per annullare <strong>' + $('<div>').text(title).html() + '</strong>.</p>' +
'<div style="text-align:left; background:#f7f8fa; border-radius:8px; padding:12px;">' +
' <label style="display:flex; align-items:flex-start; gap:8px; cursor:pointer; margin:0;">' +
' <input type="checkbox" id="notify-check" ' + (booked > 0 ? 'checked' : '') + '>' +
' <span>Invia email di annullamento ai partecipanti' +
(booked > 0 ? ' <strong>(' + booked + ' post' + (booked === 1 ? 'o' : 'i') + ' prenotat' + (booked === 1 ? 'o' : 'i') + ')</strong>' : ' <em>(nessun iscritto)</em>') +
'</span>' +
' </label>' +
'</div>',
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Annulla evento',
cancelButtonText: 'Chiudi',
confirmButtonColor: '#e74c3c',
reverseButtons: true,
preConfirm: function() {
return document.getElementById('notify-check').checked ? 1 : 0;
}
}).then(function(result) {
if (result.isConfirmed) {
window.location.href = 'event_delete.php?id=' + id + '&notify=' + result.value;
}
});
});
});
</script>
</body>
</html>
+299
View File
@@ -0,0 +1,299 @@
<?php
/**
* Motore email del modulo Eventi (PHPMailer, legge le MAIL_* dal .env).
*
* Requisito una tantum: composer require phpmailer/phpmailer
*
* Builder per i 5 tipi:
* A) yse_mail_booking() prenotazione (confermata o lista d'attesa)
* B) yse_mail_user_cancel() disdetta fatta dall'utente
* C) yse_mail_event_cancel() evento annullato dall'insegnante (a ciascun iscritto)
* E) yse_mail_promotion() promozione da lista d'attesa (con RICONFERMA)
* D) yse_mail_reminder() promemoria prima dell'evento
*
* Le copie alla scuola (CC) sono decise per-tipo dalla pagina "Opzioni Eventi".
* L'indirizzo di copia è option.event_notify_email.
*/
// Assicura l'autoload (di norma già caricato da db-functions.php)
if (!class_exists('PHPMailer\\PHPMailer\\PHPMailer')) {
$__al = dirname(__DIR__, 2) . '/vendor/autoload.php';
if (is_file($__al)) {
require_once $__al;
}
}
if (!defined('YB_PUBLIC_BASE')) {
define('YB_PUBLIC_BASE', 'https://yogibook.yogasoul.it/');
}
/* ---------- Opzioni ---------- */
function yse_mail_options(): array
{
static $opt = null;
if ($opt !== null) {
return $opt;
}
$row = [];
try {
$pdo = DBHandlerSelect::getInstance()->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(['</p>', '<br>', '<br/>', '<br />', '</div>'], "\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 = '<tr><td style="padding:6px 0;color:#8c837c;width:90px;">Quando</td><td style="padding:6px 0;color:#2b2622;font-weight:600;">' . $when . '</td></tr>';
if ($addr !== '') {
$mapsLink = $maps !== '' ? ' &nbsp;<a href="' . yse_h($maps) . '" style="color:' . $accent . ';">apri mappa</a>' : '';
$rows .= '<tr><td style="padding:6px 0;color:#8c837c;">Dove</td><td style="padding:6px 0;color:#2b2622;font-weight:600;">' . yse_h($addr) . $mapsLink . '</td></tr>';
}
$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 .= '<p style="margin:10px 0 0;"><strong style="color:#2b2622;">' . $f[1] . ':</strong><br>' . nl2br(yse_h($val)) . '</p>';
}
}
$html = '<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="font-size:14px;border-collapse:collapse;margin:10px 0;">' . $rows . '</table>';
if ($info !== '') {
$html .= '<div style="background:#faf8f6;border-radius:10px;padding:12px 14px;font-size:14px;color:#4a433d;">' . $info . '</div>';
}
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 '
<div style="background:#f2efec;padding:24px 0;font-family:Arial,Helvetica,sans-serif;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"><tr><td align="center">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" style="max-width:600px;width:100%;background:#fff;border-radius:16px;overflow:hidden;">
<tr><td style="background:' . $accent . ';height:6px;"></td></tr>
<tr><td style="padding:28px 30px 6px;">
<div style="font-size:13px;letter-spacing:.05em;text-transform:uppercase;color:#8c837c;">' . yse_h($from) . '</div>
<h1 style="margin:6px 0 2px;font-size:22px;color:#2b2622;">' . $heading . '</h1>
<div style="font-size:17px;color:' . $accent . ';font-weight:600;">' . $title . '</div>
</td></tr>
<tr><td style="padding:10px 30px 26px;color:#4a433d;font-size:15px;line-height:1.6;">' . $bodyHtml . '</td></tr>
<tr><td style="padding:16px 30px;background:#faf8f6;color:#9ca3af;font-size:12px;">Questa email è stata inviata da ' . yse_h($from) . '.</td></tr>
</table>
</td></tr></table>
</div>';
}
function yse_button(string $url, string $label, string $accent): string
{
return '<a href="' . yse_h($url) . '" style="display:inline-block;background:' . $accent . ';color:#fff;text-decoration:none;font-weight:600;padding:12px 22px;border-radius:10px;">' . yse_h($label) . '</a>';
}
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 = '<p>Ciao ' . yse_h($name) . ", ti abbiamo messo in lista d'attesa per questo evento. Ti avviseremo appena si libera un posto.</p>";
} else {
$heading = 'Prenotazione confermata';
$intro = '<p>Ciao ' . yse_h($name) . ', la tua prenotazione è confermata. Ecco i dettagli:</p>';
}
$body = $intro;
$body .= yse_event_info_block($event);
$body .= '<p style="margin-top:8px;">Posti prenotati: <strong>' . (int) $booking['seats'] . '</strong></p>';
$body .= '<p style="margin:22px 0 6px;">Se non puoi più partecipare, puoi annullare qui:</p>';
$body .= '<p>' . yse_button(yse_cancel_url($booking), 'Annulla prenotazione', $accent) . '</p>';
$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 = '<p>Ciao ' . yse_h($name) . ', la tua prenotazione per questo evento è stata <strong>annullata</strong>.</p>';
$body .= yse_event_info_block($event);
$body .= '<p style="margin-top:14px;">Ci dispiace non vederti questa volta. A presto!</p>';
$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 = '<p>Ciao ' . yse_h($name) . ", ci dispiace: l'evento è stato <strong>annullato</strong>.</p>";
$body .= yse_event_info_block($event);
$body .= '<p style="margin-top:14px;">La tua prenotazione è stata annullata automaticamente. Per informazioni, rispondi pure a questa email.</p>';
$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 = '<p>Ciao ' . yse_h($name) . ", buone notizie: si è liberato un posto e tocca a te!</p>";
$body .= yse_event_info_block($event);
$body .= '<p style="margin:22px 0 6px;"><strong>Conferma entro breve</strong> per tenere il posto:</p>';
$body .= '<p>' . yse_button(yse_confirm_url($booking), 'Confermo, ci sarò', $accent) . '</p>';
$body .= '<p style="margin-top:16px;font-size:13px;color:#8c837c;">Se non puoi partecipare, <a href="' . yse_h(yse_cancel_url($booking)) . '" style="color:' . $accent . ';">annulla qui</a> così offriamo il posto a un\'altra persona.</p>';
$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 = '<p>Ciao ' . yse_h($name) . ',<br>' . $note . '</p>';
$body .= yse_event_info_block($event);
if ($meetingLink !== '') {
$body .= '<p style="margin:16px 0 6px;"><strong>Punto di ritrovo:</strong> <a href="' . yse_h($meetingLink) . '" style="color:' . $accent . ';">apri la posizione sulla mappa</a></p>';
}
$body .= '<p style="margin:22px 0 6px;">Se non puoi più partecipare, avvisaci annullando:</p>';
$body .= '<p>' . yse_button(yse_cancel_url($booking), 'Annulla prenotazione', $accent) . '</p>';
$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']);
}
+60
View File
@@ -0,0 +1,60 @@
<?php
/**
* Lista d'attesa — promozione automatica.
*
* IMPORTANTE: chiamare DENTRO una transazione in cui la riga dell'evento è già
* bloccata (SELECT ... FOR UPDATE), tipicamente subito dopo aver liberato posti
* (cancellazione di una prenotazione confermata). Così due operazioni simultanee
* non promuovono oltre la capienza.
*
* Promuove in ordine di arrivo (FIFO) le prenotazioni 'waiting' che entrano nei
* posti liberi e si ferma alla prima che non ci sta, per rispettare la coda
* (non "salta" chi è davanti).
*
* @return array<int,array> 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;
}
}
+77 -24
View File
@@ -70,57 +70,110 @@
</li> </li>
<?php if (Auth::user()->hasRole('Admin')): ?> <?php if (Auth::user()->hasRole('Admin')): ?>
<li class="menu-title" data-key="t-applications">Amministrazione</li>
<li> <!-- Classi & Prenotazioni -->
<a href="admin-services.php" class=""> <li class="menu-title" data-key="t-admin-classi">Classi &amp; Prenotazioni</li>
<i class="bx bx-edit-alt icon nav-icon"></i>
<span class="menu-item" data-key="t-email">Servizi</span>
</a>
</li>
<li> <li>
<a href="adminpanel.php" class=""> <a href="adminpanel.php" class="">
<i class="bx bxs-school icon nav-icon"></i> <i class="bx bxs-school icon nav-icon"></i>
<span class="menu-item" data-key="t-email">Classi</span> <span class="menu-item">Classi</span>
</a>
</li>
<li>
<a href="admin-services.php" class="">
<i class="bx bx-edit-alt icon nav-icon"></i>
<span class="menu-item">Servizi</span>
</a> </a>
</li> </li>
<li> <li>
<a href="pendingbookedclass.php" class=""> <a href="pendingbookedclass.php" class="">
<i class="bx bx-time-five icon nav-icon"></i> <i class="bx bx-time-five icon nav-icon"></i>
<span class="menu-item" data-key="t-email">Pending/Waiting</span> <span class="menu-item">Pending/Waiting</span>
</a> </a>
</li> </li>
<li> <li>
<a href="dayoff.php" class=""> <a href="dayoff.php" class="">
<i class="bx bx-power-off icon nav-icon"></i> <i class="bx bx-power-off icon nav-icon"></i>
<span class="menu-item" data-key="t-email">Day Off</span> <span class="menu-item">Day Off</span>
</a> </a>
</li> </li>
<!-- Eventi -->
<li class="menu-title" data-key="t-admin-eventi">Eventi</li>
<li>
<a href="events.php" class="">
<i class="bx bx-calendar-star icon nav-icon"></i>
<span class="menu-item">Eventi</span>
</a>
</li>
<li>
<a href="event_locations.php" class="">
<i class="bx bx-map icon nav-icon"></i>
<span class="menu-item">Sedi</span>
</a>
</li>
<li>
<a href="event_options.php" class="">
<i class="bx bx-map icon nav-icon"></i>
<span class="menu-item">Opzione Eventi</span>
</a>
</li>
<!-- Ordini -->
<li class="menu-title" data-key="t-admin-ordini">Ordini</li>
<li> <li>
<a href="orderbooklist.php" class=""> <a href="orderbooklist.php" class="">
<i class="bx bx-list-check icon nav-icon"></i> <i class="bx bx-list-check icon nav-icon"></i>
<span class="menu-item" data-key="t-email">Order Pending</span> <span class="menu-item">Order Pending</span>
</a>
</li>
<li>
<a href="certificati.php" class="">
<i class="bx bx-certification icon nav-icon"></i>
<span class="menu-item" data-key="t-email">Certificati</span>
</a>
</li>
<li>
<a href="situationusers.php" class="">
<i class="bx bx-bar-chart-alt-2 icon nav-icon"></i>
<span class="menu-item" data-key="t-email">Situazione</span>
</a> </a>
</li> </li>
<li> <li>
<a href="ordersummary.php" class=""> <a href="ordersummary.php" class="">
<i class="bx bx-receipt icon nav-icon"></i> <i class="bx bx-receipt icon nav-icon"></i>
<span class="menu-item" data-key="t-email">Sommario Ordini</span> <span class="menu-item">Sommario Ordini</span>
</a> </a>
</li> </li>
<!-- Report & Certificati -->
<li class="menu-title" data-key="t-admin-report">Report &amp; Certificati</li>
<li>
<a href="situationusers.php" class="">
<i class="bx bx-bar-chart-alt-2 icon nav-icon"></i>
<span class="menu-item">Situazione</span>
</a>
</li>
<li>
<a href="certificati.php" class="">
<i class="bx bx-certification icon nav-icon"></i>
<span class="menu-item">Certificati</span>
</a>
</li>
<li>
<a href="statistiche.php" class="">
<i class="bx bx-certification icon nav-icon"></i>
<span class="menu-item">Statistiche</span>
</a>
</li>
<!-- Impostazioni -->
<li class="menu-title" data-key="t-admin-settings">Impostazioni</li>
<li>
<a href="optionsschool.php" class="">
<i class="bx bx-slider-alt icon nav-icon"></i>
<span class="menu-item">Opzioni Scuola</span>
</a>
</li>
<li>
<a href="insegnanti.php" class="">
<i class="bx bx-slider-alt icon nav-icon"></i>
<span class="menu-item">Insegnanti</span>
</a>
</li>
<!-- Template (demo) -->
<li class="menu-title" data-key="t-applications">Template</li> <li class="menu-title" data-key="t-applications">Template</li>
<li> <li>
+111
View File
@@ -0,0 +1,111 @@
<?php
/**
* Helper per la gestione della lista d'attesa (waiting list).
*/
/**
* La scuola ha attivato la lista d'attesa?
* Legge l'unico record della tabella option.
*/
function isWaitingListEnabled(mysqli $conn): bool
{
$res = $conn->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;
}
+783
View File
@@ -0,0 +1,783 @@
<?php ob_start();
require_once('include/headscript.php');
/**
* YogiBook - Gestione insegnanti (CRUD)
* Tabella: employee
* - un solo titolare (is_owner = 'Y'), non eliminabile né disattivabile
* - notifiche email attivabili/disattivabili
* - foto opzionale (JPG/PNG/WEBP, max 3 MB) in assets/images/teachers/
*/
$pdo = DBHandlerSelect::getInstance()->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();
?>
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8" />
<title>YogiBook - Insegnanti</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta content="YogiBook - Prenotazione facile YogaSoul" name="description" />
<meta content="Advanced Creative Solutions" name="author" />
<link rel="shortcut icon" href="assets/images/favicon.ico">
<link href="assets/css/bootstrap.min.css" id="bootstrap-style" rel="stylesheet" type="text/css" />
<link href="assets/css/icons.min.css" rel="stylesheet" type="text/css" />
<link href="assets/css/app.min.css" id="app-style" rel="stylesheet" type="text/css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<style>
.teachers-head {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.teachers-head h5 {
margin: 0;
font-weight: 700;
color: #374151;
}
.teacher-card {
background: #fff;
border-radius: 14px;
box-shadow: 0 3px 16px rgba(0, 0, 0, 0.06);
padding: 20px;
margin-bottom: 20px;
height: calc(100% - 20px);
display: flex;
flex-direction: column;
border-top: 5px solid #eef0f3;
transition: box-shadow 0.2s;
}
.teacher-card:hover {
box-shadow: 0 6px 22px rgba(0, 0, 0, 0.1);
}
.teacher-card.owner {
border-top-color: #E1D0B8;
}
.teacher-card.inactive {
opacity: 0.6;
}
.teacher-top {
display: flex;
gap: 14px;
align-items: center;
margin-bottom: 14px;
}
.teacher-avatar {
flex: 0 0 64px;
width: 64px;
height: 64px;
border-radius: 50%;
object-fit: cover;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 22px;
font-weight: 800;
border: 3px solid #f3f4f6;
}
.teacher-name {
font-size: 17px;
font-weight: 700;
color: #1f2937;
line-height: 1.2;
}
.t-badge {
display: inline-block;
font-size: 11px;
font-weight: 700;
padding: 3px 10px;
border-radius: 20px;
margin-top: 5px;
margin-right: 4px;
}
.t-badge.owner {
background: #E1D0B8;
color: #5a4630;
}
.t-badge.inactive {
background: #eef0f3;
color: #6b7280;
}
.teacher-info {
font-size: 14px;
color: #4b5563;
margin-bottom: 4px;
word-break: break-all;
}
.teacher-info i {
width: 18px;
color: #9ca3af;
}
.teacher-info a {
color: #4b5563;
}
.teacher-info .missing {
color: #c0c4cc;
font-style: italic;
}
.teacher-foot {
margin-top: auto;
padding-top: 14px;
border-top: 1px solid #f0f2f5;
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
justify-content: space-between;
}
.notify-form .form-check-label {
font-size: 13px;
font-weight: 600;
color: #4b5563;
cursor: pointer;
}
.notify-form .form-check-input {
cursor: pointer;
}
.notify-form .form-check-input:checked {
background-color: #1ebf73;
border-color: #1ebf73;
}
.notify-warning {
font-size: 11px;
color: #e67e22;
font-weight: 600;
}
.t-actions {
display: flex;
gap: 6px;
}
.t-btn {
border: none;
border-radius: 8px;
padding: 6px 11px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: filter 0.15s;
}
.t-btn:hover {
filter: brightness(0.93);
}
.t-btn.edit {
background: #eef2f7;
color: #374151;
}
.t-btn.owner {
background: #f5eee4;
color: #8a6a3f;
}
.t-btn.del {
background: #fdecea;
color: #c0392b;
}
.photo-preview {
width: 84px;
height: 84px;
border-radius: 50%;
object-fit: cover;
background: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
color: #9ca3af;
font-size: 28px;
border: 3px solid #eef0f3;
overflow: hidden;
}
.photo-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.empty-teachers {
text-align: center;
color: #9ca3af;
padding: 50px 10px;
}
</style>
</head>
<body>
<div id="layout-wrapper">
<header id="page-topbar" class="isvertical-topbar">
<div class="navbar-header">
<div class="d-flex">
<?php include('include/logoarea.php'); ?>
<button type="button" class="btn btn-sm px-3 font-size-24 header-item waves-effect vertical-menu-btn">
<i class="bx bx-menu align-middle"></i>
</button>
<div class="page-title-box align-self-center d-none d-md-block">
<h4 class="page-title mb-0">Insegnanti</h4>
</div>
</div>
<div class="d-flex">
<?php include('include/languageselection.php'); ?>
<?php include('include/profiletopbar.php'); ?>
</div>
</div>
</header>
<?php include('include/sidebar.php'); ?>
<header class="ishorizontal-topbar">
<div class="navbar-header">
<div class="d-flex"></div>
</div>
<div class="topnav">
<div class="container-fluid">
<nav class="navbar navbar-light navbar-expand-lg topnav-menu"></nav>
</div>
</div>
</header>
<div class="main-content">
<div class="page-content">
<div class="container-fluid">
<?php if ($flash) : ?>
<div class="alert alert-<?php echo e($flash[0]); ?> alert-dismissible fade show" role="alert">
<?php echo e($flash[1]); ?>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Chiudi"></button>
</div>
<?php endif; ?>
<div class="teachers-head">
<div>
<h5>Insegnanti della scuola</h5>
<small class="text-muted"><?php echo count($teachers); ?> insegnant<?php echo count($teachers) === 1 ? 'e' : 'i'; ?></small>
</div>
<button type="button" class="btn btn-primary" onclick="openTeacherModal(null)">
<i class="fas fa-user-plus me-1"></i> Nuovo insegnante
</button>
</div>
<?php if (empty($teachers)) : ?>
<div class="empty-teachers">
<i class="fas fa-chalkboard-teacher fa-2x mb-2"></i>
<p>Nessun insegnante. Aggiungi il primo: diventerà automaticamente il titolare.</p>
</div>
<?php else : ?>
<div class="row">
<?php foreach ($teachers as $t) :
$tid = (int) $t['idemployee'];
$isOwner = $t['is_owner'] === 'Y';
$isActive = $t['active'] === 'Y';
$fullName = trim($t['nameemployee'] . ' ' . $t['surnameemployee']);
$photoUrl = $t['photo'] ? PHOTO_DIR_REL . $t['photo'] : '';
$payload = [
'id' => $tid,
'nameemployee' => $t['nameemployee'],
'surnameemployee' => $t['surnameemployee'],
'email' => $t['email'],
'phone' => $t['phone'],
'photo' => $photoUrl,
'notifications' => $t['notifications'] === 'Y',
'active' => $isActive,
'is_owner' => $isOwner,
];
?>
<div class="col-md-6 col-xl-4">
<div class="teacher-card <?php echo $isOwner ? 'owner' : ''; ?> <?php echo $isActive ? '' : 'inactive'; ?>">
<div class="teacher-top">
<?php if ($photoUrl) : ?>
<img class="teacher-avatar" src="<?php echo e($photoUrl); ?>" alt="<?php echo e($fullName); ?>">
<?php else : ?>
<div class="teacher-avatar" style="background:<?php echo teacherColor($fullName); ?>;"><?php echo e(teacherInitials($t['nameemployee'], $t['surnameemployee'])); ?></div>
<?php endif; ?>
<div>
<div class="teacher-name"><?php echo e($fullName); ?></div>
<?php if ($isOwner) : ?><span class="t-badge owner"><i class="fas fa-crown me-1"></i>Titolare</span><?php endif; ?>
<?php if (!$isActive) : ?><span class="t-badge inactive">Non attivo</span><?php endif; ?>
</div>
</div>
<div class="teacher-info"><i class="far fa-envelope"></i>
<?php echo $t['email'] ? '<a href="mailto:' . e($t['email']) . '">' . e($t['email']) . '</a>' : '<span class="missing">email non inserita</span>'; ?>
</div>
<div class="teacher-info"><i class="fas fa-phone"></i>
<?php echo $t['phone'] ? '<a href="tel:' . e(preg_replace('/[^0-9+]/', '', $t['phone'])) . '">' . e($t['phone']) . '</a>' : '<span class="missing">telefono non inserito</span>'; ?>
</div>
<div class="teacher-info"><i class="fas fa-layer-group"></i>
<?php echo (int) $t['nservices']; ?> class<?php echo (int) $t['nservices'] === 1 ? 'e' : 'i'; ?> assegnat<?php echo (int) $t['nservices'] === 1 ? 'a' : 'e'; ?>
</div>
<div class="teacher-foot">
<form method="post" class="notify-form">
<input type="hidden" name="csrf" value="<?php echo e($csrf); ?>">
<input type="hidden" name="action" value="toggle_notifications">
<input type="hidden" name="id" value="<?php echo $tid; ?>">
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" role="switch" id="notify<?php echo $tid; ?>"
<?php echo $t['notifications'] === 'Y' ? 'checked' : ''; ?> onchange="this.form.submit()">
<label class="form-check-label" for="notify<?php echo $tid; ?>">Notifiche email</label>
</div>
<?php if ($t['notifications'] === 'Y' && !$t['email']) : ?>
<div class="notify-warning"><i class="fas fa-exclamation-triangle"></i> manca l'email</div>
<?php endif; ?>
</form>
<div class="t-actions">
<button type="button" class="t-btn edit" title="Modifica"
data-teacher="<?php echo e(json_encode($payload)); ?>"
onclick="openTeacherModal(JSON.parse(this.dataset.teacher))">
<i class="fas fa-pen"></i>
</button>
<?php if (!$isOwner) : ?>
<form method="post" class="confirm-form"
data-title="Impostare come titolare?"
data-text="<?php echo e($fullName); ?> diventerà il titolare della scuola al posto dell'attuale.">
<input type="hidden" name="csrf" value="<?php echo e($csrf); ?>">
<input type="hidden" name="action" value="set_owner">
<input type="hidden" name="id" value="<?php echo $tid; ?>">
<button type="submit" class="t-btn owner" title="Imposta come titolare"><i class="fas fa-crown"></i></button>
</form>
<form method="post" class="confirm-form" data-danger="1"
data-title="Eliminare <?php echo e($fullName); ?>?"
data-text="L'operazione non è reversibile.">
<input type="hidden" name="csrf" value="<?php echo e($csrf); ?>">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="id" value="<?php echo $tid; ?>">
<button type="submit" class="t-btn del" title="Elimina"><i class="fas fa-trash"></i></button>
</form>
<?php endif; ?>
</div>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<?php include('include/footer.php'); ?>
</div>
</div>
</div>
<!-- ============================================ MODALE CREA / MODIFICA -->
<div class="modal fade" id="teacherModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<form class="modal-content" method="post" enctype="multipart/form-data" id="teacherForm">
<div class="modal-header">
<h5 class="modal-title" id="teacherModalTitle">Nuovo insegnante</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Chiudi"></button>
</div>
<div class="modal-body">
<input type="hidden" name="csrf" value="<?php echo e($csrf); ?>">
<input type="hidden" name="action" value="save">
<input type="hidden" name="id" id="t_id" value="0">
<div class="d-flex align-items-center gap-3 mb-3">
<div class="photo-preview" id="t_preview"><i class="fas fa-user"></i></div>
<div class="flex-grow-1">
<label class="form-label mb-1">Foto <small class="text-muted">(facoltativa, JPG/PNG/WEBP, max 3 MB)</small></label>
<input type="file" name="photo" id="t_photo" class="form-control form-control-sm" accept="image/jpeg,image/png,image/webp">
<div class="form-check mt-2" id="t_remove_wrap" style="display:none;">
<input class="form-check-input" type="checkbox" name="remove_photo" id="t_remove">
<label class="form-check-label" for="t_remove">Rimuovi foto attuale</label>
</div>
</div>
</div>
<div class="row g-2">
<div class="col-6">
<label class="form-label mb-1">Nome *</label>
<input type="text" name="nameemployee" id="t_name" class="form-control" maxlength="100" required>
</div>
<div class="col-6">
<label class="form-label mb-1">Cognome *</label>
<input type="text" name="surnameemployee" id="t_surname" class="form-control" maxlength="100" required>
</div>
<div class="col-12">
<label class="form-label mb-1">Email</label>
<input type="email" name="email" id="t_email" class="form-control" maxlength="150">
</div>
<div class="col-12">
<label class="form-label mb-1">Telefono</label>
<input type="text" name="phone" id="t_phone" class="form-control" maxlength="40">
</div>
</div>
<div class="mt-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="notifications" id="t_notify" checked>
<label class="form-check-label" for="t_notify">Riceve notifiche email</label>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="active" id="t_active" checked>
<label class="form-check-label" for="t_active">Attivo</label>
</div>
<div class="text-muted" id="t_owner_note" style="font-size:12px;display:none;">
<i class="fas fa-crown me-1" style="color:#b08d57;"></i> È il titolare: non può essere disattivato.
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Annulla</button>
<button type="submit" class="btn btn-primary"><i class="fas fa-save me-1"></i> Salva</button>
</div>
</form>
</div>
</div>
<script src="assets/libs/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="assets/libs/metismenujs/metismenujs.min.js"></script>
<script src="assets/libs/simplebar/simplebar.min.js"></script>
<script src="assets/libs/eva-icons/eva.min.js"></script>
<script src="assets/js/app.js"></script>
<script>
var teacherModal = null;
function setPreview(url) {
var box = document.getElementById('t_preview');
box.innerHTML = url ? '<img src="' + url + '" alt="">' : '<i class="fas fa-user"></i>';
}
function openTeacherModal(t) {
var isEdit = !!t;
document.getElementById('teacherModalTitle').textContent = isEdit ? 'Modifica insegnante' : 'Nuovo insegnante';
document.getElementById('t_id').value = isEdit ? t.id : 0;
document.getElementById('t_name').value = isEdit ? (t.nameemployee || '') : '';
document.getElementById('t_surname').value = isEdit ? (t.surnameemployee || '') : '';
document.getElementById('t_email').value = isEdit ? (t.email || '') : '';
document.getElementById('t_phone').value = isEdit ? (t.phone || '') : '';
document.getElementById('t_notify').checked = isEdit ? t.notifications : true;
var active = document.getElementById('t_active');
active.checked = isEdit ? t.active : true;
active.disabled = isEdit && t.is_owner;
document.getElementById('t_owner_note').style.display = (isEdit && t.is_owner) ? 'block' : 'none';
document.getElementById('t_photo').value = '';
document.getElementById('t_remove').checked = false;
document.getElementById('t_remove_wrap').style.display = (isEdit && t.photo) ? 'block' : 'none';
setPreview(isEdit ? t.photo : '');
if (!teacherModal) teacherModal = new bootstrap.Modal(document.getElementById('teacherModal'));
teacherModal.show();
}
// il titolare ha "Attivo" disabilitato: lo riabilitiamo al submit così il valore viene inviato
document.getElementById('teacherForm').addEventListener('submit', function() {
document.getElementById('t_active').disabled = false;
});
// anteprima foto + controllo dimensione
document.getElementById('t_photo').addEventListener('change', function() {
var f = this.files[0];
if (!f) return;
if (f.size > 3 * 1024 * 1024) {
Swal.fire('Foto troppo grande', 'La foto deve essere al massimo di 3 MB.', 'warning');
this.value = '';
return;
}
var reader = new FileReader();
reader.onload = function(ev) {
setPreview(ev.target.result);
};
reader.readAsDataURL(f);
document.getElementById('t_remove').checked = false;
});
// conferma per titolare / elimina
document.querySelectorAll('.confirm-form').forEach(function(form) {
form.addEventListener('submit', function(ev) {
if (form.dataset.confirmed === '1') return;
ev.preventDefault();
var danger = form.dataset.danger === '1';
Swal.fire({
title: form.dataset.title,
text: form.dataset.text,
icon: danger ? 'warning' : 'question',
showCancelButton: true,
confirmButtonColor: danger ? '#e74c3c' : '#b08d57',
cancelButtonColor: '#6b7280',
confirmButtonText: danger ? 'Sì, elimina' : 'Sì, conferma',
cancelButtonText: 'Annulla'
}).then(function(r) {
if (r.isConfirmed) {
form.dataset.confirmed = '1';
form.submit();
}
});
});
});
</script>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
<?php
require_once('include/headscript.php');
$conn = new mysqli($servername, $username, $password, $dbname);
if ($conn->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();
+33
View File
@@ -0,0 +1,33 @@
<?php
require_once('include/headscript.php');
$pdo = DBHandlerSelect::getInstance()->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;
+200
View File
@@ -0,0 +1,200 @@
<?php
require_once('include/headscript.php');
$conn = new mysqli($servername, $username, $password, $dbname);
if ($conn->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';
?>
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8" />
<title>YogiBook - Opzioni</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" href="assets/images/favicon.ico">
<link href="assets/css/bootstrap.min.css" id="bootstrap-style" rel="stylesheet" type="text/css" />
<link href="assets/css/icons.min.css" rel="stylesheet" type="text/css" />
<link href="assets/css/app.min.css" id="app-style" rel="stylesheet" type="text/css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<style>
.admin-card {
border: none;
border-radius: 14px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06);
}
.admin-card .card-body {
padding: 26px 30px;
}
.section-title {
font-size: 15px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #6b7280;
margin-bottom: 4px;
}
.section-lead {
color: #9ca3af;
font-size: 14px;
margin-bottom: 22px;
}
.option-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 0;
border-bottom: 1px solid #f0f0f0;
}
.option-row:last-child {
border-bottom: none;
}
.option-label {
font-weight: 600;
color: #1f2937;
font-size: 15px;
}
.option-help {
color: #9ca3af;
font-size: 13px;
margin-top: 3px;
}
.form-switch .form-check-input {
width: 3em;
height: 1.5em;
cursor: pointer;
}
.save-bar {
margin-top: 24px;
text-align: right;
}
</style>
</head>
<body>
<div id="layout-wrapper">
<header id="page-topbar" class="isvertical-topbar">
<div class="navbar-header">
<div class="d-flex">
<?php include('include/logoarea.php'); ?>
<button type="button" class="btn btn-sm px-3 font-size-24 header-item waves-effect vertical-menu-btn">
<i class="bx bx-menu align-middle"></i>
</button>
<div class="page-title-box align-self-center d-none d-md-block">
<h4 class="page-title mb-0">Opzioni</h4>
</div>
</div>
<div class="d-flex">
<?php include('include/languageselection.php'); ?>
<?php include('include/profiletopbar.php'); ?>
</div>
</div>
</header>
<?php include('include/sidebar.php'); ?>
<header class="ishorizontal-topbar">
<div class="navbar-header">
<div class="d-flex"></div>
</div>
<div class="topnav">
<div class="container-fluid">
<nav class="navbar navbar-light navbar-expand-lg topnav-menu"></nav>
</div>
</div>
</header>
<div class="main-content">
<div class="page-content">
<div class="container-fluid">
<?php if ($message === 'success') : ?>
<div class="alert alert-success" role="alert">Impostazioni salvate con successo.</div>
<?php elseif ($message === 'error') : ?>
<div class="alert alert-danger" role="alert">Errore durante il salvataggio.</div>
<?php endif; ?>
<div class="row">
<div class="col-lg-8">
<div class="card admin-card">
<div class="card-body">
<div class="section-title">Impostazioni prenotazioni</div>
<p class="section-lead">Configura il comportamento del sistema di prenotazione.</p>
<form action="options_save.php" method="POST">
<div class="option-row">
<div>
<div class="option-label">Lista d'attesa</div>
<div class="option-help">Se attiva, quando una classe è piena gli utenti possono mettersi in coda. Alla liberazione di un posto verranno gestiti in ordine di arrivo.</div>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="waitinglist_enabled" name="waitinglist_enabled" value="Y" <?php echo $waitingEnabled ? 'checked' : ''; ?>>
</div>
</div>
<div class="option-row">
<div>
<div class="option-label">Mostra insegnante nelle classi</div>
<div class="option-help">Se attiva, nelle card delle classi viene indicato il nome (e la foto) dell'insegnante.</div>
</div>
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" role="switch" id="show_teacher" name="show_teacher" value="Y" <?php echo $showTeacher ? 'checked' : ''; ?>>
</div>
</div>
<div class="option-row">
<div>
<div class="option-label">Ore minime per l'annullamento</div>
<div class="option-help">Quante ore prima dell'inizio di una lezione è ancora possibile annullarla.</div>
</div>
<div style="width:120px;">
<input type="number" min="0" class="form-control" name="maxbeforetimecancell" value="<?php echo e($maxCancel); ?>">
</div>
</div>
<div class="save-bar">
<button type="submit" class="btn btn-success"><i class="fas fa-save me-1"></i> Salva impostazioni</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<?php include('include/footer.php'); ?>
</div>
</div>
</div>
<script src="assets/libs/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="assets/libs/metismenujs/metismenujs.min.js"></script>
<script src="assets/libs/simplebar/simplebar.min.js"></script>
<script src="assets/libs/eva-icons/eva.min.js"></script>
<script src="assets/js/app.js"></script>
</body>
</html>
+492 -286
View File
@@ -1,299 +1,170 @@
<?php require_once('include/headscript.php'); ?> <?php require_once('include/headscript.php'); ?>
<?php <?php
// Creazione della connessione /**
$conn = new mysqli($servername, $username, $password, $dbname); * Riepilogo ordini (amministratore)
* - filtro Attivi / Scaduti (server)
* - ricerca istantanea per nome, cognome, email, n. ordine, classe (client)
* - modifica al click di: scadenza, max riprogrammazioni, riprogrammazioni effettuate
*/
$pdo = DBHandlerSelect::getInstance()->getConnection();
// Verifica della connessione if (!function_exists('e')) {
if ($conn->connect_error) { function e($value): string
die("Connessione fallita: " . $conn->connect_error); {
} return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
// 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 function reschedBadge($max, $done): string
$filter = isset($_GET['filter']) ? $_GET['filter'] : 'all'; {
$today = date("Y-m-d"); $done = (int) $done;
$whereClause = ""; if ($max === null || $max === '') {
if ($filter == 'active') { return '<span class="badge bg-light text-dark">' . $done . '</span>';
$whereClause = "WHERE expireon > '$today'"; }
} elseif ($filter == 'expired') { $max = (int) $max;
$whereClause = "WHERE expireon <= '$today'"; $cls = $done >= $max ? 'bg-danger' : ($done > 0 ? 'bg-warning text-dark' : 'bg-success');
return '<span class="badge ' . $cls . '">' . $done . ' / ' . $max . '</span>';
} }
// Query SQL per recuperare tutti gli ordini con join su service $message = $_GET['message'] ?? 'n';
$query = "SELECT o.idorderbook, o.first_name, o.last_name, s.servicename, o.expireon, o.status, o.nticket
$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 FROM orderbook o
LEFT JOIN service s ON o.idservice = s.idservice LEFT JOIN service s ON o.idservice = s.idservice
$whereClause $where
ORDER BY o.idorderbook DESC"; ORDER BY o.idorderbook DESC";
$result = $conn->query($query); $stmt = $pdo->prepare($sql);
$orders = []; $stmt->execute($params);
if ($result->num_rows > 0) { $orders = $stmt->fetchAll();
while ($row = $result->fetch_assoc()) {
$orders[] = $row;
}
}
// Query SQL per i DayOff $qParam = $q !== '' ? '&q=' . urlencode($q) : '';
$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;
}
}
?> ?>
<!doctype html> <!doctype html>
<html lang="en"> <html lang="it">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>YogiBook - Prenotazioni YogaSoul</title> <title>YogiBook - Riepilogo Ordini</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta content="YogiBook - Prenotazione facile YogaSOul" name="description" /> <meta content="YogiBook - Prenotazione facile YogaSoul" name="description" />
<meta content="Advanced Creative Solutions" name="author" /> <meta content="Advanced Creative Solutions" name="author" />
<link rel="shortcut icon" href="assets/images/favicon.ico"> <link rel="shortcut icon" href="assets/images/favicon.ico">
<link href="assets/css/bootstrap.min.css" id="bootstrap-style" rel="stylesheet" type="text/css" /> <link href="assets/css/bootstrap.min.css" id="bootstrap-style" rel="stylesheet" type="text/css" />
<link href="assets/css/icons.min.css" rel="stylesheet" type="text/css" /> <link href="assets/css/icons.min.css" rel="stylesheet" type="text/css" />
<link href="assets/css/app.min.css" id="app-style" rel="stylesheet" type="text/css" /> <link href="assets/css/app.min.css" id="app-style" rel="stylesheet" type="text/css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@10"></script>
<link rel="stylesheet" href="https://code.jquery.com/ui/1.12.1/themes/base/jquery-ui.css"> <link rel="stylesheet" href="https://code.jquery.com/ui/1.12.1/themes/base/jquery-ui.css">
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@10"></script>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script> <script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://code.jquery.com/ui/1.12.1/jquery-ui.js"></script> <script src="https://code.jquery.com/ui/1.12.1/jquery-ui.js"></script>
<script>
$(function() {
// Inizializzazione del Datepicker per i campi DayOff
$("#dayoff").datepicker({
dateFormat: "yy-mm-dd",
minDate: 0
});
// Inizializzazione del Datepicker per i campi di scadenza editabili
$(document).on('focus', '.expiryDateInput', function() {
$(this).datepicker({
dateFormat: "yy-mm-dd",
minDate: 0,
onSelect: function(dateText) {
let orderId = $(this).data('order-id');
console.log("Datepicker selezionato - idorderbook: " + orderId + ", newExpiry: " + dateText);
confirmUpdate(orderId, dateText);
}
});
});
// Rendere il campo scadenza editabile al click
$(document).on('click', '.expiry-date', function() {
let orderId = $(this).data('order-id');
let currentDate = $(this).text().trim();
console.log("Clic su expiry-date - idorderbook: " + orderId + ", currentDate: " + currentDate);
if (!orderId) {
Swal.fire('Errore!', 'ID ordine non valido.', 'error');
return;
}
$(this).html(`<input type="text" class="form-control expiryDateInput" data-order-id="${orderId}" value="${currentDate}" />`);
$(this).find('.expiryDateInput').focus();
});
// Funzione per confermare l'aggiornamento della scadenza
function confirmUpdate(orderId, newExpiry) {
let originalCell = $(`td.expiry-date[data-order-id="${orderId}"]`);
let originalDate = originalCell.text().trim();
console.log("Invio AJAX - idorderbook: " + orderId + ", new_expiry: " + newExpiry);
Swal.fire({
title: "Sei sicuro?",
text: "La data di scadenza verrà modificata!",
icon: "warning",
showCancelButton: true,
confirmButtonColor: "#3085d6",
cancelButtonColor: "#d33",
confirmButtonText: "Sì, modifica!",
cancelButtonText: "Annulla"
}).then((result) => {
if (result.isConfirmed) {
$.ajax({
url: 'update_expiry.php',
type: 'POST',
data: {
order_id: orderId,
new_expiry: newExpiry,
update_expiry: 1
},
dataType: 'json',
success: function(response) {
console.log("Risposta AJAX:", response);
if (response.status === 'success') {
Swal.fire('Successo!', response.message, 'success');
originalCell.text(newExpiry);
} else {
Swal.fire('Errore!', response.message, 'error');
originalCell.text(originalDate);
}
},
error: function(xhr, status, error) {
console.error('Errore AJAX:', {
status: status,
error: error,
responseText: xhr.responseText
});
Swal.fire('Errore!', 'Errore durante la richiesta al server: ' + error, 'error');
originalCell.text(originalDate);
}
});
} else {
originalCell.text(originalDate);
}
});
}
// Funzione per confermare la cancellazione
function confirmDelete(id, deletePageUrl) {
Swal.fire({
title: "Sei sicuro?",
text: "Questa prenotazione verrà cancellata definitivamente! Ricordati poi di riprogrammare la tua lezione!",
icon: "warning",
showCancelButton: true,
confirmButtonColor: "#d33",
cancelButtonColor: "#3085d6",
confirmButtonText: "Sì, cancella!",
cancelButtonText: "Annulla"
}).then((result) => {
if (result.isConfirmed) {
window.location.href = `deleteclass.php?id=${id}`;
}
});
}
});
</script>
<style> <style>
.custom-card { .orders-toolbar {
margin: 10px auto;
display: flex; display: flex;
width: 90%; flex-wrap: wrap;
max-width: 700px; gap: 8px;
background-color: white;
box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1);
border-radius: 8px;
overflow: hidden;
cursor: pointer;
transition: transform 0.2s;
}
.custom-card:hover {
transform: translateY(-5px);
}
.custom-date-box {
flex: 1;
background-color: red;
color: white;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center; align-items: center;
padding: 0; margin-bottom: 12px;
font-size: 60px;
font-weight: bold;
border-top-left-radius: 8px;
border-bottom-left-radius: 8px;
} }
.custom-day { .orders-search {
line-height: 1; margin-left: auto;
position: relative;
min-width: 300px;
} }
.custom-month { .orders-search i {
font-size: 28px; position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: #9ca3af;
} }
.custom-event-details { .orders-search input {
flex: 2; padding-left: 34px;
display: flex;
flex-direction: column;
padding: 10px 20px;
background-color: lightblue;
} }
.custom-heading { #searchCount {
margin-top: 0; font-size: 13px;
font-size: 24px; color: #6b7280;
min-height: 20px;
margin-bottom: 6px;
} }
.custom-paragraph { .editable {
margin-bottom: 5px;
}
.custom-actions {
display: none;
flex-direction: row;
justify-content: space-between;
margin-top: 10px;
}
.custom-card.expanded .custom-actions {
display: flex;
}
.custom-action-button {
background-color: #f0f0f0;
border: none;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer; cursor: pointer;
transition: background-color 0.2s; white-space: nowrap;
} }
.custom-action-button:hover { .editable:hover {
background-color: #e0e0e0; background-color: #eef6f1 !important;
box-shadow: inset 0 0 0 1px #cfe8db;
} }
.expiry-date { #ordersTable th.sortable {
cursor: pointer; cursor: pointer;
user-select: none;
white-space: nowrap;
} }
.expiry-date:hover { #ordersTable th.sortable:hover {
background-color: #f0f0f0; color: #1ebf73;
}
#ordersTable th.sortable::after {
content: "\2195";
margin-left: 5px;
font-size: 11px;
opacity: 0.35;
}
#ordersTable th.sortable.asc::after {
content: "\25B2";
opacity: 1;
}
#ordersTable th.sortable.desc::after {
content: "\25BC";
opacity: 1;
}
.num-input {
width: 80px;
}
.expiryDateInput {
width: 130px;
}
.edit-hint {
font-size: 12px;
color: #9ca3af;
margin-bottom: 10px;
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.custom-card { .orders-search {
flex-direction: column; margin-left: 0;
} min-width: 100%;
.custom-date-box,
.custom-event-details {
width: 100%;
border-radius: 0;
}
.custom-event-time {
font-size: 24px;
} }
} }
</style> </style>
@@ -314,21 +185,6 @@ if ($result_dayoff->num_rows > 0) {
</div> </div>
<div class="d-flex"> <div class="d-flex">
<?php include('include/languageselection.php'); ?> <?php include('include/languageselection.php'); ?>
<div class="dropdown d-inline-block">
<button type="button" class="btn header-item noti-icon" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="bx bx-search icon-sm align-middle"></i>
</button>
<div class="dropdown-menu dropdown-menu-lg dropdown-menu-end p-0">
<form class="p-2">
<div class="search-box">
<div class="position-relative">
<input type="text" class="form-control rounded bg-light border-0" placeholder="Search...">
<i class="bx bx-search search-icon"></i>
</div>
</form>
</div>
</div>
</div>
<?php include('include/profiletopbar.php'); ?> <?php include('include/profiletopbar.php'); ?>
</div> </div>
</div> </div>
@@ -354,42 +210,83 @@ if ($result_dayoff->num_rows > 0) {
<div class="card-body"> <div class="card-body">
<h5>Riepilogo Ordini (Amministratore)</h5> <h5>Riepilogo Ordini (Amministratore)</h5>
<p>Tutti gli ordini registrati</p> <p>Tutti gli ordini registrati</p>
<div class="mb-3">
<a href="?filter=all" class="btn btn-secondary <?php echo $filter == 'all' ? 'active' : ''; ?>">Tutti</a> <div class="orders-toolbar">
<a href="?filter=active" class="btn btn-success <?php echo $filter == 'active' ? 'active' : ''; ?>">Attivi</a> <a href="?filter=all<?php echo e($qParam); ?>" data-filter="all" class="filter-link btn btn-secondary <?php echo $filter === 'all' ? 'active' : ''; ?>">Tutti</a>
<a href="?filter=expired" class="btn btn-danger <?php echo $filter == 'expired' ? 'active' : ''; ?>">Scaduti</a> <a href="?filter=active<?php echo e($qParam); ?>" data-filter="active" class="filter-link btn btn-success <?php echo $filter === 'active' ? 'active' : ''; ?>">Attivi</a>
<a href="?filter=expired<?php echo e($qParam); ?>" data-filter="expired" class="filter-link btn btn-danger <?php echo $filter === 'expired' ? 'active' : ''; ?>">Scaduti</a>
<div class="orders-search">
<i class="fas fa-search"></i>
<input type="search" id="orderSearch" class="form-control" autocomplete="off"
placeholder="Cerca nome, cognome, email, n. ordine…" value="<?php echo e($q); ?>">
</div> </div>
</div>
<div id="searchCount"></div>
<div class="edit-hint"><i class="fas fa-pen me-1"></i> Clicca su scadenza, max riprogr. o riprogrammate per modificarle.</div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-striped mb-0"> <table class="table table-striped mb-0" id="ordersTable">
<thead> <thead>
<tr> <tr>
<th>Stato</th> <th class="sortable" data-type="text">Stato</th>
<th>Numero Ordine</th> <th class="sortable desc" data-type="num">N. Ordine</th>
<th>Nome</th> <th class="sortable" data-type="text">Nome</th>
<th>Cognome</th> <th class="sortable" data-type="text">Cognome</th>
<th>Nome Classe</th> <th class="sortable" data-type="text">Classe</th>
<th>Numero Ticket</th> <th class="sortable" data-type="num">Ticket</th>
<th>Scadenza</th> <th class="sortable" data-type="date">Scadenza</th>
<th>Status</th> <th class="sortable" data-type="num">Max riprogr.</th>
<th class="sortable" data-type="num">Riprogrammate</th>
<th class="sortable" data-type="text">Status</th>
<th>Azioni</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<?php foreach ($orders as $order) { <?php foreach ($orders as $order) :
$isActive = ($order["expireon"] > $today); $id = (int) $order['idorderbook'];
$badgeClass = $isActive ? 'badge bg-success' : 'badge bg-danger'; $isActive = ($order['expireon'] !== null && $order['expireon'] > $today);
$badgeText = $isActive ? 'Attivo' : 'Scaduto'; $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'],
]);
?> ?>
<tr> <tr class="order-row" data-search="<?php echo e($search); ?>">
<td><span class="<?php echo $badgeClass; ?>"><?php echo $badgeText; ?></span></td> <td class="state-cell">
<td><?php echo $order["idorderbook"]; ?></td> <span class="badge <?php echo $isActive ? 'bg-success' : 'bg-danger'; ?>"><?php echo $isActive ? 'Attivo' : 'Scaduto'; ?></span>
<td><?php echo $order["first_name"]; ?></td> </td>
<td><?php echo $order["last_name"]; ?></td> <td><?php echo $id; ?></td>
<td><?php echo $order["servicename"]; ?></td> <td><?php echo e($order['first_name']); ?></td>
<td><?php echo $order["nticket"]; ?></td> <td><?php echo e($order['last_name']); ?></td>
<td class="expiry-date" data-order-id="<?php echo $order['idorderbook']; ?>"><?php echo $order["expireon"]; ?></td> <td><?php echo e($order['servicename']); ?></td>
<td><?php echo $order["status"]; ?></td> <td><?php echo e($order['nticket']); ?></td>
<td class="expiry-date editable" data-order-id="<?php echo $id; ?>" title="Clicca per modificare"><?php echo e($order['expireon']); ?></td>
<td class="num-edit editable" data-order-id="<?php echo $id; ?>" data-field="maxreschedule"
data-value="<?php echo $max === null ? '' : (int) $max; ?>" title="Clicca per modificare">
<?php echo $max === null ? '<span class="text-muted">–</span>' : (int) $max; ?>
</td>
<td class="num-edit editable" data-order-id="<?php echo $id; ?>" data-field="reprogrammed"
data-value="<?php echo $done; ?>" title="Clicca per modificare">
<?php echo reschedBadge($max, $done); ?>
</td>
<td><?php echo e($order['status']); ?></td>
<td>
<button type="button" class="btn btn-sm btn-danger" onclick="confirmDeleteOrder(<?php echo $id; ?>)">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
<?php endforeach; ?>
<tr id="noResults" style="display:none;">
<td colspan="11" class="text-center text-muted py-4">Nessun ordine trovato.</td>
</tr> </tr>
<?php } ?>
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -401,12 +298,321 @@ if ($result_dayoff->num_rows > 0) {
<?php include('include/footer.php'); ?> <?php include('include/footer.php'); ?>
</div> </div>
</div> </div>
</div>
<script src="assets/libs/bootstrap/js/bootstrap.bundle.min.js"></script> <script src="assets/libs/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="assets/libs/metismenujs/metismenujs.min.js"></script> <script src="assets/libs/metismenujs/metismenujs.min.js"></script>
<script src="assets/libs/simplebar/simplebar.min.js"></script> <script src="assets/libs/simplebar/simplebar.min.js"></script>
<script src="assets/libs/eva-icons/eva.min.js"></script> <script src="assets/libs/eva-icons/eva.min.js"></script>
<script src="assets/js/app.js"></script> <script src="assets/js/app.js"></script>
<script>
function confirmDeleteOrder(id) {
Swal.fire({
title: "Sei sicuro?",
text: "Verranno cancellati l'ordine e TUTTE le prenotazioni collegate a questa persona. L'azione è irreversibile!",
icon: "warning",
showCancelButton: true,
confirmButtonColor: "#d33",
cancelButtonColor: "#3085d6",
confirmButtonText: "Sì, cancella tutto!",
cancelButtonText: "Annulla"
}).then((result) => {
if (result.isConfirmed) {
window.location.href = 'deleteorder.php?id=' + id;
}
});
}
$(function() {
var today = '<?php echo $today; ?>';
/* ===================== RICERCA ===================== */
function norm(s) {
return (s || '').toString().toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
}
$('#ordersTable tr.order-row').each(function() {
$(this).data('hay', norm($(this).attr('data-search')));
});
function applySearch() {
var raw = $('#orderSearch').val().trim();
var terms = norm(raw).split(/\s+/).filter(Boolean);
var visible = 0;
$('#ordersTable tr.order-row').each(function() {
var hay = $(this).data('hay');
var ok = terms.every(function(t) {
return hay.indexOf(t) !== -1;
});
$(this).toggle(ok);
if (ok) visible++;
});
$('#noResults').toggle(visible === 0);
$('#searchCount').text(terms.length ? visible + (visible === 1 ? ' ordine trovato' : ' ordini trovati') : '');
// i pulsanti Tutti/Attivi/Scaduti mantengono la ricerca
$('.filter-link').each(function() {
$(this).attr('href', '?filter=' + $(this).data('filter') + (raw ? '&q=' + encodeURIComponent(raw) : ''));
});
var url = new URL(window.location.href);
if (raw) url.searchParams.set('q', raw);
else url.searchParams.delete('q');
url.searchParams.delete('message');
history.replaceState(null, '', url.toString());
}
$('#orderSearch').on('input', applySearch);
applySearch();
/* ===================== ORDINAMENTO ===================== */
var sortCol = 1,
sortDir = -1; // di partenza: N. Ordine decrescente, come la query
function cellValue(row, idx, type) {
var td = $(row).children().eq(idx);
if (td.find('input').length) {
// cella in modifica: usa il valore originale
var orig = td.data('original');
if (typeof orig === 'undefined') orig = td.attr('data-value');
td = $('<td>').text(orig || '');
}
var v = td.attr('data-value');
if (typeof v === 'undefined') v = td.text();
v = String(v).trim();
if (v === '' || v === '–') return null;
if (type === 'num') {
var num = parseFloat(v.replace(',', '.'));
return isNaN(num) ? null : num;
}
if (type === 'date') return v; // formato AAAA-MM-GG: ordinabile come testo
return norm(v);
}
$('#ordersTable thead').on('click', 'th.sortable', function() {
var th = $(this);
var idx = th.index();
var type = th.data('type');
sortDir = (sortCol === idx) ? -sortDir : 1;
sortCol = idx;
$('#ordersTable thead th.sortable').removeClass('asc desc');
th.addClass(sortDir === 1 ? 'asc' : 'desc');
var rows = $('#ordersTable tbody tr.order-row').get();
rows.sort(function(a, b) {
var va = cellValue(a, idx, type);
var vb = cellValue(b, idx, type);
if (va === null && vb === null) return 0;
if (va === null) return 1; // vuoti sempre in fondo
if (vb === null) return -1;
if (va < vb) return -sortDir;
if (va > vb) return sortDir;
return 0;
});
$('#noResults').before(rows);
});
/* ===================== SCADENZA ===================== */
$(document).on('click', 'td.expiry-date', function() {
var cell = $(this);
if (cell.find('input').length) return;
var orderId = cell.data('order-id');
var current = cell.text().trim();
cell.data('original', current).data('selected', false);
var input = $('<input type="text" class="form-control form-control-sm expiryDateInput" readonly>').val(current);
cell.empty().append(input);
input.datepicker({
dateFormat: 'yy-mm-dd',
minDate: 0,
onSelect: function(dateText) {
cell.data('selected', true);
confirmExpiry(cell, orderId, dateText);
},
onClose: function() {
setTimeout(function() {
if (!cell.data('selected')) cell.text(cell.data('original'));
}, 0);
}
});
input.datepicker('show');
});
function confirmExpiry(cell, orderId, newExpiry) {
var original = cell.data('original');
if (newExpiry === original) {
cell.text(original);
return;
}
Swal.fire({
title: "Sei sicuro?",
html: "La scadenza passerà a <strong>" + newExpiry + "</strong>.",
icon: "warning",
showCancelButton: true,
confirmButtonColor: "#3085d6",
cancelButtonColor: "#d33",
confirmButtonText: "Sì, modifica!",
cancelButtonText: "Annulla"
}).then(function(result) {
if (!result.isConfirmed) {
cell.text(original);
return;
}
$.ajax({
url: 'update_expiry.php',
type: 'POST',
dataType: 'json',
data: {
order_id: orderId,
new_expiry: newExpiry,
update_expiry: 1
}
}).done(function(res) {
if (res.status === 'success') {
cell.text(newExpiry);
var active = newExpiry > today;
cell.closest('tr').find('.state-cell').html(
'<span class="badge ' + (active ? 'bg-success' : 'bg-danger') + '">' + (active ? 'Attivo' : 'Scaduto') + '</span>'
);
Swal.fire('Successo!', res.message, 'success');
} else {
cell.text(original);
Swal.fire('Errore!', res.message, 'error');
}
}).fail(function(xhr, status, error) {
console.error('Errore AJAX:', xhr.responseText);
cell.text(original);
Swal.fire('Errore!', 'Errore durante la richiesta al server: ' + error, 'error');
});
});
}
/* ============ MAX RIPROGRAMMAZIONI / RIPROGRAMMATE ============ */
function badgeHtml(max, done) {
done = parseInt(done, 10) || 0;
if (max === null || max === '' || typeof max === 'undefined') {
return '<span class="badge bg-light text-dark">' + done + '</span>';
}
max = parseInt(max, 10);
var cls = done >= max ? 'bg-danger' : (done > 0 ? 'bg-warning text-dark' : 'bg-success');
return '<span class="badge ' + cls + '">' + done + ' / ' + max + '</span>';
}
function renderResched(row, max, done) {
var maxCell = row.find('td[data-field="maxreschedule"]');
var doneCell = row.find('td[data-field="reprogrammed"]');
var hasMax = !(max === null || max === '' || typeof max === 'undefined');
maxCell.attr('data-value', hasMax ? max : '').html(hasMax ? max : '<span class="text-muted">–</span>');
doneCell.attr('data-value', done).html(badgeHtml(max, done));
}
$(document).on('click', 'td.num-edit', function() {
var cell = $(this);
if (cell.find('input').length) return;
var field = cell.data('field');
var orderId = cell.data('order-id');
var current = String(cell.attr('data-value'));
var input = $('<input type="number" min="0" max="999" step="1" class="form-control form-control-sm num-input">').val(current);
cell.data('html', cell.html()).empty().append(input);
input.trigger('focus').trigger('select');
var finished = false;
function finish(save) {
if (finished) return;
finished = true;
var val = String(input.val()).trim();
if (!save || val === '' || val === current) {
cell.html(cell.data('html'));
return;
}
if (!/^\d{1,3}$/.test(val)) {
cell.html(cell.data('html'));
Swal.fire('Valore non valido', 'Inserisci un numero intero da 0 a 999.', 'warning');
return;
}
confirmNumber(cell, orderId, field, parseInt(val, 10));
}
input.on('keydown', function(ev) {
if (ev.key === 'Enter') {
ev.preventDefault();
finish(true);
} else if (ev.key === 'Escape') {
finish(false);
}
});
input.on('blur', function() {
finish(true);
});
});
function confirmNumber(cell, orderId, field, value) {
var label = field === 'maxreschedule' ? 'Max riprogrammazioni' : 'Riprogrammate';
Swal.fire({
title: 'Confermi la modifica?',
html: label + ': <strong>' + value + '</strong>',
icon: 'question',
showCancelButton: true,
confirmButtonColor: '#3085d6',
cancelButtonColor: '#d33',
confirmButtonText: 'Sì, modifica!',
cancelButtonText: 'Annulla'
}).then(function(result) {
if (!result.isConfirmed) {
cell.html(cell.data('html'));
return;
}
$.ajax({
url: 'update_reschedule.php',
type: 'POST',
dataType: 'json',
data: {
order_id: orderId,
field: field,
value: value
}
}).done(function(res) {
if (res.status === 'success') {
renderResched(cell.closest('tr'), res.maxreschedule, res.reprogrammed);
Swal.fire({
icon: 'success',
title: 'Aggiornato',
timer: 1200,
showConfirmButton: false
});
} else {
cell.html(cell.data('html'));
Swal.fire('Errore!', res.message || 'Aggiornamento non riuscito.', 'error');
}
}).fail(function(xhr) {
console.error('Errore AJAX:', xhr.responseText);
cell.html(cell.data('html'));
Swal.fire('Errore!', 'Errore di comunicazione con il server.', 'error');
});
});
}
});
</script>
<?php if ($message === 'success_delete') { ?>
<script>
Swal.fire('Fatto!', 'Ordine e prenotazioni cancellati.', 'success');
</script>
<?php } elseif ($message === 'error_delete') { ?>
<script>
Swal.fire('Errore!', 'Errore durante la cancellazione.', 'error');
</script>
<?php } ?>
</body> </body>
</html> </html>
+1
View File
@@ -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: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: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 [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
+9 -2
View File
@@ -103,7 +103,11 @@ if ($is_openflexy || $service_schedule_result->num_rows > 0) {
$idservice = $schedule_row["idservice"]; $idservice = $schedule_row["idservice"];
$idserviceschedule = $schedule_row["idserviceschedule"]; $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 = $conn->prepare($querychk);
$stmt_chk->bind_param("ii", $selected_user_id, $idserviceschedule); $stmt_chk->bind_param("ii", $selected_user_id, $idserviceschedule);
$stmt_chk->execute(); $stmt_chk->execute();
@@ -119,7 +123,10 @@ if ($is_openflexy || $service_schedule_result->num_rows > 0) {
$maxcapacity = $row['maxcapacity']; $maxcapacity = $row['maxcapacity'];
$stmt_maxcapacity->close(); $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 = $conn->prepare($bookingclass_count_query);
$stmt_count->bind_param("i", $idserviceschedule); $stmt_count->bind_param("i", $idserviceschedule);
$stmt_count->execute(); $stmt_count->execute();
+11 -5
View File
@@ -1,6 +1,7 @@
<?php <?php
require_once('include/headscript.php'); require_once('include/headscript.php');
require_once __DIR__ . '/class/mailer.php'; require_once __DIR__ . '/class/mailer.php';
require_once('include/waitinglist.php');
use PHPMailer\PHPMailer\PHPMailer; use PHPMailer\PHPMailer\PHPMailer;
use PHPMailer\PHPMailer\Exception; use PHPMailer\PHPMailer\Exception;
@@ -53,12 +54,15 @@ $stmt->bind_param("i", $idpreviousbooking);
$stmt->execute(); $stmt->execute();
$stmt->close(); $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( $stmt = $conn->prepare(
"INSERT INTO bookingclass (idserviceschedule, iduser, prevbookingstart, idprevserviceschedule, idservice, idorder, bookingstart, status) "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->execute();
$stmt->close(); $stmt->close();
@@ -83,9 +87,11 @@ $stmtUser->close();
$newtimeformat = date("d-m-Y H:i", strtotime($newtime)); $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 // Se manca l'email, completa comunque il flusso senza inviare
if ($emailuser === '') { if ($emailuser === '') {
header("Location: userpanel.php?reprogram=Y"); header("Location: userpanel.php?$redirectFlag");
exit(); exit();
} }
@@ -138,5 +144,5 @@ try {
error_log("Errore invio mail riprogrammazione (iduser $iduser): " . $mail->ErrorInfo); error_log("Errore invio mail riprogrammazione (iduser $iduser): " . $mail->ErrorInfo);
} }
header("Location: userpanel.php?reprogram=Y"); header("Location: userpanel.php?$redirectFlag");
exit(); exit();
File diff suppressed because it is too large Load Diff
+65
View File
@@ -0,0 +1,65 @@
<?php
/**
* Aggiorna maxreschedule o reprogrammed di un ordine (chiamato via AJAX dal riepilogo ordini).
* Risponde sempre in JSON: {status, message, maxreschedule, reprogrammed}
*/
ob_start();
require_once('include/headscript.php');
ob_end_clean(); // scarta eventuale output di headscript, così il JSON resta pulito
header('Content-Type: application/json; charset=utf-8');
function jsonOut(array $data, int $code = 200): void
{
http_response_code($code);
echo json_encode($data);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonOut(['status' => '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);
}
+185 -3
View File
@@ -59,7 +59,7 @@ $stmtBooked->execute([
$bookedRows = $stmtBooked->fetchAll(); $bookedRows = $stmtBooked->fetchAll();
// Lezioni in attesa di conferma (pending) dell'utente, nel mese visualizzato // 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 FROM bookingclass
LEFT JOIN service ON bookingclass.idservice = service.idservice LEFT JOIN service ON bookingclass.idservice = service.idservice
LEFT JOIN serviceschedule ON bookingclass.idserviceschedule = serviceschedule.idserviceschedule LEFT JOIN serviceschedule ON bookingclass.idserviceschedule = serviceschedule.idserviceschedule
@@ -75,6 +75,40 @@ $stmtPending->execute([
]); ]);
$pendingRows = $stmtPending->fetchAll(); $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 * 3) Somma dei ticket acquistati dall'utente
* ---------------------------------------------------------------------- */ * ---------------------------------------------------------------------- */
@@ -123,6 +157,18 @@ $pending = (int) ($rowCounts['pending'] ?? 0);
$toprogram = $totalTickets - $passedRecords - $futureRecords - $pending - $lost; $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 * 5) Pre-caricamento limiti di riprogrammazione per gli ordini coinvolti
* (una sola query invece di una per ogni card, dentro il loop) * (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'); 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 = '<img class="teacher-avatar-sm" src="assets/images/teachers/' . e($t['photo']) . '" alt="">';
} else {
$ini = mb_strtoupper(mb_substr($t['nameemployee'], 0, 1) . mb_substr($t['surnameemployee'], 0, 1));
$avatar = '<span class="teacher-avatar-sm">' . e($ini) . '</span>';
}
return '<p class="custom-paragraph teacher-line">' . $avatar . 'Insegnante: <strong>' . e($name) . '</strong></p>';
}
$italianMonths = [ $italianMonths = [
"January" => "Gennaio", "January" => "Gennaio",
"February" => "Febbraio", "February" => "Febbraio",
@@ -233,8 +295,8 @@ $italianMonths = [
.custom-date-box { .custom-date-box {
flex: 0 0 110px; flex: 0 0 110px;
background: #1ebf73; background: #E1D0B8;
color: #fff; color: #ffffff;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
@@ -271,6 +333,33 @@ $italianMonths = [
margin: 0 0 4px; 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 { .custom-paragraph {
color: #6b7280; color: #6b7280;
font-size: 14px; font-size: 14px;
@@ -541,6 +630,27 @@ $italianMonths = [
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div> </div>
<?php } ?> <?php } ?>
<?php if (isset($_GET['waiting'])) { ?>
<div class="alert alert-warning alert-dismissible fade show" role="alert">
<i class="fas fa-hourglass-half me-2"></i>
La classe era piena: sei stata/o inserita/o in <strong>lista d'attesa</strong>. Se si libera un posto verrai avvisata/o.
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<?php } ?>
<?php if (isset($_GET['leftwaiting'])) { ?>
<div class="alert alert-info alert-dismissible fade show" role="alert">
<i class="fas fa-check me-2"></i>
Sei uscita/o dalla lista d'attesa.
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<?php } ?>
<?php if (isset($_GET['full'])) { ?>
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="fas fa-ban me-2"></i>
La classe è al completo e la lista d'attesa non è attiva. Prova con un altro orario.
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
<?php } ?>
<?php if (isset($_GET['deleted'])) { ?> <?php if (isset($_GET['deleted'])) { ?>
<div class="alert alert-success alert-dismissible fade show" role="alert"> <div class="alert alert-success alert-dismissible fade show" role="alert">
<i class="mdi mdi-check-all me-2"></i> <i class="mdi mdi-check-all me-2"></i>
@@ -626,6 +736,7 @@ $italianMonths = [
<span style="display:inline-block;background:#fff4d6;color:#8a6d00;font-size:11px;font-weight:700;padding:2px 9px;border-radius:20px;margin-left:6px;vertical-align:middle;">In attesa</span> <span style="display:inline-block;background:#fff4d6;color:#8a6d00;font-size:11px;font-weight:700;padding:2px 9px;border-radius:20px;margin-left:6px;vertical-align:middle;">In attesa</span>
</h2> </h2>
<p class="custom-paragraph">Quando: <?php echo e($pWhen); ?></p> <p class="custom-paragraph">Quando: <?php echo e($pWhen); ?></p>
<?php echo teacherLine($prow, $showTeacher, $teachersById); ?>
<p class="custom-paragraph">Luogo: via Valassina 62/B Seregno - Sala Contesto Yoga</p> <p class="custom-paragraph">Luogo: via Valassina 62/B Seregno - Sala Contesto Yoga</p>
</div> </div>
</div> </div>
@@ -636,6 +747,59 @@ $italianMonths = [
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php if (!empty($waitingRows)) : ?>
<div class="row">
<div class="col-xl-12">
<div class="card">
<div class="card-body">
<h5 style="color:#8a6d00;margin-bottom:14px;">
<i class="fas fa-hourglass-half me-1"></i> In lista d'attesa
</h5>
<?php foreach ($waitingRows as $wrow) :
$wDateObj = new DateTime($wrow['dateschedule']);
$wDay = $wDateObj->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;
?>
<div class="custom-card pending-card">
<div class="custom-date-box" style="background:#9C9C9C;">
<div class="custom-day"><?php echo e($wDay); ?></div>
<div class="custom-month"><?php echo e($wMonth); ?></div>
</div>
<div class="custom-event-details" style="border-left-color:<?php echo e($wColor); ?>">
<h2 class="custom-heading">
<?php echo e($wName); ?>
<span style="display:inline-block;background:#eee;color:#555;font-size:11px;font-weight:700;padding:2px 9px;border-radius:20px;margin-left:6px;vertical-align:middle;">In coda</span>
</h2>
<p class="custom-paragraph">Quando: <?php echo e($wWhen); ?></p>
<?php echo teacherLine($wrow, $showTeacher, $teachersById); ?>
<p class="custom-paragraph">Luogo: via Valassina 62/B Seregno - Sala Contesto Yoga</p>
<?php if ($wPos > 0) : ?>
<p class="custom-paragraph" style="font-weight:700;color:#8a6d00;">
<i class="fas fa-list-ol me-1"></i> La tua posizione in coda: <?php echo e($wPos); ?>
</p>
<?php endif; ?>
<p class="custom-paragraph" style="font-size:13px;color:#9ca3af;">
Se si libera un posto verrai avvisata/o. Non serve fare nulla.
</p>
<div style="margin-top:8px;">
<button class="custom-action-button" style="background:#fbeef3;color:#d1698a;"
onclick="confirmLeaveWaiting(<?php echo (int) $wrow['idbookingclass']; ?>)">
<i class="fas fa-times-circle"></i> Esci dalla lista d'attesa
</button>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
</div>
</div>
<?php endif; ?>
<div class="row"> <div class="row">
<div class="col-xl-12"> <div class="col-xl-12">
<div class="card"> <div class="card">
@@ -694,6 +858,7 @@ $italianMonths = [
<div class="custom-event-details" style="border-left-color:<?php echo e($colorclass); ?>"> <div class="custom-event-details" style="border-left-color:<?php echo e($colorclass); ?>">
<h2 class="custom-heading"><?php echo e($servicename); ?></h2> <h2 class="custom-heading"><?php echo e($servicename); ?></h2>
<p class="custom-paragraph">Quando: <?php echo e($newDateFormat); ?></p> <p class="custom-paragraph">Quando: <?php echo e($newDateFormat); ?></p>
<?php echo teacherLine($row, $showTeacher, $teachersById); ?>
<p class="custom-paragraph">Luogo: via Valassina 62/B Seregno - Sala Contesto Yoga</p> <p class="custom-paragraph">Luogo: via Valassina 62/B Seregno - Sala Contesto Yoga</p>
<div class="custom-actions"> <div class="custom-actions">
<button class="custom-action-button" onclick="addToCalendar(this)" data-eventname="<?php echo e($servicename); ?>" data-eventdate="<?php echo e($newDateFormat); ?>"> <button class="custom-action-button" onclick="addToCalendar(this)" data-eventname="<?php echo e($servicename); ?>" data-eventdate="<?php echo e($newDateFormat); ?>">
@@ -741,6 +906,23 @@ $italianMonths = [
const outlookCalendarLink = `webcal://outlook.live.com/calendar/0/deeplink/compose?path=/calendar/action/compose&subject=${encodeURIComponent(eventName)}&startdt=${encodeURIComponent(eventDate)}`; const outlookCalendarLink = `webcal://outlook.live.com/calendar/0/deeplink/compose?path=/calendar/action/compose&subject=${encodeURIComponent(eventName)}&startdt=${encodeURIComponent(eventDate)}`;
window.open(outlookCalendarLink, '_blank'); window.open(outlookCalendarLink, '_blank');
} }
function confirmLeaveWaiting(id) {
Swal.fire({
title: "Uscire dalla lista d'attesa?",
text: "Verrai rimossa/o dalla coda per questa lezione.",
icon: "warning",
showCancelButton: true,
confirmButtonColor: "#d1698a",
cancelButtonColor: "#3085d6",
confirmButtonText: "Sì, esci",
cancelButtonText: "Annulla"
}).then((result) => {
if (result.isConfirmed) {
window.location.href = "leavewaiting.php?id=" + id;
}
});
}
</script> </script>
<script src="assets/libs/bootstrap/js/bootstrap.bundle.min.js"></script> <script src="assets/libs/bootstrap/js/bootstrap.bundle.min.js"></script>
Binary file not shown.
@@ -0,0 +1,153 @@
/* YogaSoul Eventi — stili widget.
Tutti i selettori sono prefissati con .yse-widget per alzare la specificità
sopra le regole del tema WordPress (evita che tema "vinca" su colori/font). */
.yse-widget {
--yse-ink: #2b2622;
--yse-muted: #8c837c;
--yse-line: #ece7e2;
--yse-card: #ffffff;
max-width: 1140px; /* largo come Amelia; su schermi piccoli riempie il contenitore */
width: 100%;
margin: 0 auto;
font-family: inherit;
color: var(--yse-ink);
line-height: 1.55;
text-align: left;
}
.yse-widget, .yse-widget * { box-sizing: border-box; }
/* --- Reset mirati contro il tema --- */
.yse-widget button {
font-family: inherit;
background: none !important;
border: none !important;
box-shadow: none !important;
text-transform: none !important;
letter-spacing: normal !important;
min-height: 0 !important;
width: auto;
margin: 0;
line-height: 1.2;
}
.yse-widget button:hover { filter: none; }
.yse-widget input {
font-family: inherit;
margin: 0;
width: 100%;
min-height: 0;
box-shadow: none;
}
.yse-widget h3, .yse-widget h4, .yse-widget p { margin: 0; padding: 0; }
.yse-widget img { max-width: 100%; height: auto; }
.yse-widget a { text-decoration: none; }
.yse-widget svg { vertical-align: middle; }
.yse-empty { text-align: center; color: var(--yse-muted); padding: 26px; }
.yse-widget .yse-card {
--yse-accent: #4CAF50;
--yse-accent-soft: color-mix(in srgb, var(--yse-accent) 10%, white);
--yse-accent-ink: color-mix(in srgb, var(--yse-accent) 62%, black);
background: var(--yse-card);
border-radius: 18px;
box-shadow: 0 14px 40px rgba(43, 38, 34, 0.10);
overflow: hidden;
}
.yse-widget .yse-card + .yse-card { margin-top: 24px; }
/* Header compatto */
.yse-widget .yse-chead { display: flex; gap: 18px; padding: 20px 26px; align-items: flex-start; }
.yse-widget .yse-datechip { flex-shrink: 0; border: 1px solid var(--yse-line); border-radius: 12px; padding: 7px 12px 5px; text-align: center; min-width: 58px; }
.yse-widget .yse-datechip .yse-m { font-size: 11px; font-weight: 600; letter-spacing: .12em; color: var(--yse-accent-ink); }
.yse-widget .yse-datechip .yse-d { font-family: inherit; font-size: 24px; font-weight: 600; color: var(--yse-ink); line-height: 1; }
.yse-widget .yse-main { flex: 1; min-width: 0; }
.yse-widget .yse-title-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.yse-widget .yse-title-row h3 { font-family: inherit; font-weight: 500; font-size: 23px; margin: 0; color: var(--yse-ink); }
.yse-widget .yse-toggle { background: none !important; border: none !important; color: var(--yse-accent-ink) !important; font-weight: 600; font-size: 13px; cursor: pointer; display: inline-flex; align-items: center; gap: 4px; padding: 2px; border-radius: 0; }
.yse-widget .yse-toggle svg { width: 15px; height: 15px; transition: transform .2s; }
.yse-widget .yse-toggle.yse-on svg { transform: rotate(180deg); }
.yse-widget .yse-meta { display: flex; flex-wrap: wrap; gap: 6px 22px; margin-top: 10px; }
.yse-widget .yse-item { display: flex; align-items: center; gap: 7px; color: var(--yse-muted); font-size: 14px; }
.yse-widget .yse-item svg { width: 17px; height: 17px; color: var(--yse-accent-ink); flex-shrink: 0; }
.yse-widget .yse-item strong { color: var(--yse-ink); font-weight: 600; }
.yse-widget .yse-price { flex-shrink: 0; align-self: flex-start; background: var(--yse-accent) !important; color: #fff; font-weight: 600; font-size: 15px; padding: 8px 15px; border-radius: 999px; }
/* Dettagli espandibili */
.yse-widget .yse-details { display: none; border-top: 1px solid var(--yse-line); }
.yse-widget .yse-details.yse-open { display: block; }
.yse-widget .yse-hero { position: relative; height: 300px; background: linear-gradient(135deg, var(--yse-accent-ink), var(--yse-accent)); }
.yse-widget .yse-hero img { width: 100%; height: 100%; object-fit: cover; display: block; }
.yse-widget .yse-emblem { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: rgba(255,255,255,.35); }
.yse-widget .yse-emblem svg { width: 78px; height: 78px; }
.yse-widget .yse-body { padding: 24px 26px; }
.yse-widget .yse-section { margin-top: 26px; }
.yse-widget .yse-body h4 { font-family: inherit; font-weight: 600; font-size: 19px; color: var(--yse-ink); margin: 0 0 10px; display: flex; align-items: center; gap: 9px; }
.yse-widget .yse-body h4 svg { width: 20px; height: 20px; color: var(--yse-accent-ink); }
.yse-widget .yse-desc { font-size: 15.5px; color: #4a433d; }
.yse-widget .yse-desc p { margin: 0 0 10px; }
.yse-widget .yse-desc img { max-width: 100%; border-radius: 10px; }
.yse-widget .yse-detail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; margin-top: 24px; }
.yse-widget .yse-detail { background: var(--yse-accent-soft); border: 1px solid color-mix(in srgb, var(--yse-accent) 18%, white); border-radius: 14px; padding: 15px 16px; }
.yse-widget .yse-dh { display: flex; align-items: center; gap: 8px; font-weight: 600; color: var(--yse-accent-ink); margin-bottom: 6px; font-size: 14px; }
.yse-widget .yse-dh svg { width: 17px; height: 17px; }
.yse-widget .yse-dt { font-size: 14px; color: #4a433d; }
.yse-widget .yse-gallery { display: flex; gap: 10px; flex-wrap: wrap; }
.yse-widget .yse-gallery img { width: 108px; height: 108px; object-fit: cover; border-radius: 12px; cursor: pointer; transition: transform .15s, box-shadow .15s; }
.yse-widget .yse-gallery img:hover { transform: translateY(-2px); box-shadow: 0 8px 18px rgba(0,0,0,.15); }
.yse-widget .yse-map { border: 0; width: 100%; height: 220px; border-radius: 14px; display: block; }
/* Booking */
.yse-widget .yse-booking { padding: 22px 26px; border-top: 1px solid var(--yse-line); }
.yse-widget .yse-soldout { text-align: center; font-weight: 600; color: #c0392b; }
.yse-widget .yse-wl-banner { background: color-mix(in srgb, var(--yse-accent) 12%, white); color: var(--yse-accent-ink); border-radius: 12px; padding: 12px 14px; font-size: 13.5px; margin-bottom: 16px; }
.yse-widget .yse-bcompact { display: flex; align-items: flex-end; gap: 22px; flex-wrap: wrap; }
.yse-widget .yse-btitle { font-family: inherit; font-weight: 600; font-size: 19px; color: var(--yse-ink); margin-right: auto; align-self: center; }
.yse-widget .yse-lbl { font-size: 12px; color: var(--yse-muted); text-transform: uppercase; letter-spacing: .05em; margin-bottom: 5px; }
.yse-widget .yse-stepper { display: flex; align-items: center; border: 1px solid var(--yse-line); border-radius: 11px; overflow: hidden; background: #fff; }
.yse-widget .yse-stepper button { background: #fff !important; width: 44px; height: 46px; font-size: 22px; color: var(--yse-accent-ink) !important; cursor: pointer; }
.yse-widget .yse-stepper button:disabled { color: #cfc8c2 !important; cursor: not-allowed; }
.yse-widget .yse-num { width: 54px; text-align: center; font-size: 17px; font-weight: 600; }
.yse-widget .yse-total .yse-val { font-family: inherit; font-size: 26px; font-weight: 600; color: var(--yse-accent-ink); }
.yse-widget .yse-cta { background: var(--yse-accent) !important; color: #fff !important; font-size: 15px; font-weight: 600; border-radius: 11px; cursor: pointer; transition: filter .15s; }
.yse-widget .yse-cta:hover { filter: brightness(.94) !important; }
.yse-widget .yse-cta:disabled { background: #cfc8c2 !important; cursor: not-allowed; }
.yse-widget .yse-open { padding: 14px 26px; }
.yse-widget .yse-full { width: 100%; padding: 15px; margin-top: 6px; }
.yse-widget .yse-bform { display: none; margin-top: 20px; padding-top: 20px; border-top: 1px dashed var(--yse-line); }
.yse-widget .yse-bform.yse-open { display: block; }
.yse-widget .yse-frow { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.yse-widget .yse-field { margin-bottom: 12px; }
.yse-widget .yse-field label { display: block; font-size: 13px; font-weight: 600; color: var(--yse-ink); margin-bottom: 5px; }
.yse-widget .yse-field input { border: 1px solid var(--yse-line) !important; border-radius: 10px; padding: 12px 13px; font-size: 15px; background: #fff !important; color: var(--yse-ink) !important; }
.yse-widget .yse-field input:focus { outline: none; border-color: var(--yse-accent) !important; box-shadow: 0 0 0 3px color-mix(in srgb, var(--yse-accent) 22%, white) !important; }
.yse-widget .yse-consent { font-size: 13px; color: #4a433d; display: flex; gap: 9px; align-items: flex-start; margin-bottom: 9px; }
.yse-widget .yse-consent input { width: auto; margin-top: 3px; }
.yse-widget .yse-hp { position: absolute !important; left: -9999px !important; width: 1px !important; height: 1px !important; }
.yse-widget .yse-error { color: #c0392b; font-size: 13px; margin-top: 8px; min-height: 18px; }
.yse-widget .yse-done { text-align: center; padding: 10px 4px; }
.yse-widget .yse-done .yse-ok { width: 54px; height: 54px; border-radius: 50%; background: var(--yse-accent); color: #fff; display: flex; align-items: center; justify-content: center; margin: 0 auto 12px; }
.yse-widget .yse-done h3 { margin: 0 0 6px; font-family: inherit; font-size: 21px; }
.yse-widget .yse-done p { color: #4a433d; font-size: 14px; margin: 2px 0; }
/* Lightbox (montato su <body>, fuori da .yse-widget) */
.yse-lightbox { position: fixed; inset: 0; background: rgba(24,19,16,.9); display: none; align-items: center; justify-content: center; z-index: 99999; padding: 24px; }
.yse-lightbox.yse-open { display: flex; }
.yse-lightbox img { max-width: 92vw; max-height: 86vh; border-radius: 12px; }
.yse-lightbox .yse-lbclose { position: absolute; top: 18px; right: 22px; color: #fff !important; font-size: 34px; cursor: pointer; background: none !important; border: none !important; line-height: 1; padding: 0; }
@media (max-width: 600px) {
.yse-widget .yse-hero { height: 210px; }
.yse-widget .yse-title-row h3 { font-size: 20px; }
.yse-widget .yse-frow { grid-template-columns: 1fr; }
.yse-widget .yse-btitle { width: 100%; margin-bottom: 4px; }
.yse-widget .yse-chead, .yse-widget .yse-booking, .yse-widget .yse-body { padding-left: 18px; padding-right: 18px; }
}
@media (prefers-reduced-motion: reduce) { .yse-widget * { transition: none !important; } }
/* Miniatura copertina nella testata compatta */
.yse-widget .yse-thumb { width: 76px; height: 76px; border-radius: 12px; object-fit: cover; flex-shrink: 0; }
@media (max-width: 600px) { .yse-widget .yse-thumb { width: 60px; height: 60px; } }
/* Evento programmato (prenotazioni non ancora aperte) */
.yse-widget .yse-scheduled { text-align: center; font-weight: 600; color: var(--yse-accent-ink); background: color-mix(in srgb, var(--yse-accent) 12%, white); border-radius: 12px; padding: 12px 14px; }
@@ -0,0 +1,246 @@
/* YogaSoul Eventi — interazioni widget */
(function () {
"use strict";
function fmtMoney(v, sym) {
if (v <= 0) return "Gratuito";
return (
sym +
v.toLocaleString("it-IT", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
);
}
function apiBase(el) {
var w = el.closest(".yse-widget");
return (w && w.getAttribute("data-api")) || "";
}
function initCard(card) {
var api = apiBase(card);
var eid = parseInt(card.getAttribute("data-eid"), 10);
var cost = parseFloat(card.getAttribute("data-cost")) || 0;
var sym = card.getAttribute("data-sym") || "€";
var maxQty = parseInt(card.getAttribute("data-maxqty"), 10) || 0;
var waitMode = card.getAttribute("data-waitmode") === "1";
// Toggle dettagli
var toggle = card.querySelector(".yse-toggle");
if (toggle) {
toggle.addEventListener("click", function () {
var det = card.querySelector(".yse-details");
if (!det) return;
var open = det.classList.toggle("yse-open");
toggle.classList.toggle("yse-on", open);
toggle.setAttribute("aria-expanded", open ? "true" : "false");
var lbl = toggle.querySelector(".yse-tlabel");
if (lbl) lbl.textContent = open ? "Chiudi" : "Dettagli";
});
}
var booking = card.querySelector(".yse-booking");
var numEl = card.querySelector(".yse-num");
if (!numEl) return; // esaurito senza lista d'attesa: nessuna prenotazione
var totEl = card.querySelector(".yse-total .yse-val");
var minus = card.querySelector(".yse-minus");
var plus = card.querySelector(".yse-plus");
var openBtn = card.querySelector(".yse-open");
var form = card.querySelector(".yse-bform");
var submit = card.querySelector(".yse-submit");
var qty = 1;
function refresh() {
numEl.textContent = qty;
if (totEl) totEl.textContent = fmtMoney(qty * cost, sym);
minus.disabled = qty <= 1;
plus.disabled = qty >= maxQty;
if (submit) {
submit.textContent = waitMode
? "Iscriviti alla lista d'attesa"
: "Conferma prenotazione" +
(cost > 0 ? " · " + fmtMoney(qty * cost, sym) : "");
}
}
minus.addEventListener("click", function () {
if (qty > 1) {
qty--;
refresh();
}
});
plus.addEventListener("click", function () {
if (qty < maxQty) {
qty++;
refresh();
}
});
refresh();
openBtn.addEventListener("click", function () {
form.classList.add("yse-open");
openBtn.style.display = "none";
var n = card.querySelector(".yse-name");
if (n) n.focus();
});
submit.addEventListener("click", function () {
var name = card.querySelector(".yse-name").value.trim();
var surname = card.querySelector(".yse-surname").value.trim();
var email = card.querySelector(".yse-email").value.trim();
var privacy = card.querySelector(".yse-c-privacy").checked;
var mkt = card.querySelector(".yse-c-mkt").checked;
var hp = card.querySelector(".yse-hp").value;
var err = card.querySelector(".yse-error");
if (!name || !surname) {
err.textContent = "Inserisci nome e cognome.";
return;
}
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
err.textContent = "Inserisci un'email valida.";
return;
}
if (!privacy) {
err.textContent = "Devi accettare l'informativa privacy.";
return;
}
err.textContent = "";
submit.disabled = true;
submit.textContent = "Invio in corso…";
var body = new URLSearchParams();
body.set("event_id", eid);
body.set("name", name);
body.set("surname", surname);
body.set("email", email);
body.set("seats", qty);
body.set("consent", "1");
body.set("marketing", mkt ? "1" : "0");
body.set("website", hp); // honeypot
fetch(api + "/book.php", {
method: "POST",
headers: {
"Content-Type":
"application/x-www-form-urlencoded;charset=UTF-8",
},
body: body.toString(),
})
.then(function (r) {
return r.json().then(function (j) {
return { status: r.status, j: j };
});
})
.then(function (res) {
var j = res.j || {};
if (j.ok) {
showDone(booking, j, name, surname, email);
return;
}
// Errori gestiti
submit.disabled = false;
refresh();
if (j.code === "partial") {
err.textContent =
j.error ||
"Sono rimasti solo " + j.remaining + " posti.";
if (
typeof j.remaining === "number" &&
j.remaining > 0
) {
maxQty = j.remaining;
if (qty > maxQty) {
qty = maxQty;
refresh();
}
}
} else {
err.textContent =
j.error || "Prenotazione non riuscita. Riprova.";
}
})
.catch(function () {
submit.disabled = false;
refresh();
err.textContent = "Errore di rete. Riprova.";
});
});
function showDone(container, j, name, surname, email) {
var waiting = j.status === "waiting";
var title = waiting
? "Sei in lista d'attesa"
: "Prenotazione confermata!";
var msg =
j.message ||
(waiting
? "Ti avviseremo appena si libera un posto."
: "Ti abbiamo inviato una email di riepilogo.");
container.innerHTML =
'<div class="yse-done">' +
' <div class="yse-ok"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" width="24" height="24">' +
' <path d="M20 6 9 17l-5-5"/></svg></div>' +
" <h3>" +
title +
"</h3>" +
" <p><strong>" +
escapeHtml(name + " " + surname) +
"</strong> · " +
escapeHtml(email) +
"</p>" +
" <p>" +
escapeHtml(msg) +
"</p>" +
"</div>";
}
}
function escapeHtml(s) {
var d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
// Lightbox condiviso
function ensureLightbox() {
var lb = document.querySelector(".yse-lightbox");
if (lb) return lb;
lb = document.createElement("div");
lb.className = "yse-lightbox";
lb.innerHTML =
'<button class="yse-lbclose" aria-label="Chiudi">&times;</button><img src="" alt="">';
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();
}
})();
@@ -0,0 +1,276 @@
<?php
/**
* Plugin Name: YogaSoul Eventi
* Description: Mostra gli eventi di YogiBook con card di prenotazione (lista d'attesa inclusa). Shortcode: [yogasoul_eventi], [yogasoul_eventi ids="12,15"], [yogasoul_eventi id="12"], [yogasoul_eventi limit="3"].
* Version: 1.0.0
* Author: YogaSoul
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* Base dell'API YogiBook. Sovrascrivibile in wp-config.php con:
* define('YSE_API_BASE', 'https://yogibook.yogasoul.it/apiwp');
*/
if (!defined('YSE_API_BASE')) {
define('YSE_API_BASE', 'https://yogibook.yogasoul.it/apiwp');
}
/* Registra gli asset (accodati solo quando lo shortcode è in pagina).
Nessun Google Font: il widget eredita il carattere del tema. */
add_action('wp_enqueue_scripts', function () {
wp_register_style('yse-eventi', plugins_url('assets/yogasoul-eventi.css', __FILE__), [], '1.0.1');
wp_register_script('yse-eventi', plugins_url('assets/yogasoul-eventi.js', __FILE__), [], '1.0.0', true);
});
add_shortcode('yogasoul_eventi', 'yse_render_shortcode');
function yse_render_shortcode($atts)
{
$atts = shortcode_atts([
'ids' => '',
'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 '<div class="yse-widget"><p class="yse-empty">Eventi non disponibili al momento.</p></div>';
}
$data = json_decode(wp_remote_retrieve_body($resp), true);
if (!is_array($data) || empty($data['ok'])) {
return '<div class="yse-widget"><p class="yse-empty">Eventi non disponibili al momento.</p></div>';
}
if (empty($data['events'])) {
return '<div class="yse-widget"><p class="yse-empty">Nessun evento in programma al momento.</p></div>';
}
$out = '<div class="yse-widget" data-api="' . esc_attr(YSE_API_BASE) . '">';
foreach ($data['events'] as $ev) {
$out .= yse_render_card($ev);
}
$out .= '</div>';
return $out;
}
/* Icona SVG inline (stroke = currentColor) */
function yse_icon($name)
{
$p = [
'user' => '<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/>',
'pin' => '<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/>',
'clock' => '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
'heart' => '<path d="M20.8 5.6a5 5 0 0 0-7-.1L12 7l-1.8-1.5a5 5 0 1 0-6.6 7.5L12 21l8.4-8a5 5 0 0 0 .4-7.4z"/>',
'bag' => '<path d="M6 7h12l1 13H5L6 7z"/><path d="M9 7a3 3 0 0 1 6 0"/>',
'info' => '<circle cx="12" cy="12" r="9"/><path d="M12 11v5"/><path d="M12 8h.01"/>',
'img' => '<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-5-5L5 21"/>',
'chev' => '<path d="m6 9 6 6 6-6"/>',
];
$inner = $p[$name] ?? '';
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7">' . $inner . '</svg>';
}
/* 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();
?>
<div class="yse-card" style="--yse-accent: <?php echo esc_attr($color); ?>;"
data-eid="<?php echo $id; ?>"
data-cost="<?php echo esc_attr($cost); ?>"
data-sym="<?php echo esc_attr($sym); ?>"
data-maxqty="<?php echo (int) $maxQty; ?>"
data-waitmode="<?php echo $waitMode ? 1 : 0; ?>">
<!-- HEADER COMPATTO -->
<div class="yse-chead">
<div class="yse-datechip">
<div class="yse-m"><?php echo esc_html($monAbbr); ?></div>
<div class="yse-d"><?php echo $dayNum; ?></div>
</div>
<?php if (!empty($ev['cover_image'])) : ?>
<img class="yse-thumb" src="<?php echo esc_url($ev['cover_image']); ?>" alt="<?php echo esc_attr($ev['title']); ?>">
<?php endif; ?>
<div class="yse-main">
<div class="yse-title-row">
<h3><?php echo esc_html($ev['title']); ?></h3>
<?php if ($hasDetails) : ?>
<button type="button" class="yse-toggle" aria-expanded="false">
<span class="yse-tlabel">Dettagli</span><?php echo yse_icon('chev'); ?>
</button>
<?php endif; ?>
</div>
<div class="yse-meta">
<span class="yse-item"><?php echo yse_icon('user'); ?> Capacità <strong><?php echo (int) $ev['occupied']; ?>/<?php echo (int) $ev['max_seats']; ?></strong></span>
<?php if ($address !== '') : ?>
<span class="yse-item"><?php echo yse_icon('pin'); ?> <?php echo esc_html($address); ?></span>
<?php endif; ?>
<span class="yse-item"><?php echo yse_icon('clock'); ?> <?php echo esc_html($dateLong); ?> · <?php echo esc_html($timeLabel); ?></span>
</div>
</div>
<div class="yse-price"><?php echo esc_html($price); ?></div>
</div>
<!-- DETTAGLI ESPANDIBILI -->
<?php if ($hasDetails) : ?>
<div class="yse-details">
<div class="yse-hero <?php echo empty($ev['cover_image']) ? 'yse-nocover' : ''; ?>">
<?php if (!empty($ev['cover_image'])) : ?>
<img src="<?php echo esc_url($ev['cover_image']); ?>" alt="<?php echo esc_attr($ev['title']); ?>">
<?php else : ?>
<div class="yse-emblem"><?php echo yse_icon('img'); ?></div>
<?php endif; ?>
</div>
<div class="yse-body">
<?php if (trim((string) ($ev['description'] ?? '')) !== '') : ?>
<div class="yse-desc"><?php echo wp_kses_post($ev['description']); ?></div>
<?php endif; ?>
<?php if (trim((string) ($ev['suitable_for'] ?? '')) !== '' || trim((string) ($ev['what_to_bring'] ?? '')) !== '' || trim((string) ($ev['instructions'] ?? '')) !== '') : ?>
<div class="yse-detail-grid">
<?php if (trim((string) ($ev['suitable_for'] ?? '')) !== '') : ?>
<div class="yse-detail"><div class="yse-dh"><?php echo yse_icon('heart'); ?> Per chi è adatto</div><div class="yse-dt"><?php echo nl2br(esc_html($ev['suitable_for'])); ?></div></div>
<?php endif; ?>
<?php if (trim((string) ($ev['what_to_bring'] ?? '')) !== '') : ?>
<div class="yse-detail"><div class="yse-dh"><?php echo yse_icon('bag'); ?> Cosa portare</div><div class="yse-dt"><?php echo nl2br(esc_html($ev['what_to_bring'])); ?></div></div>
<?php endif; ?>
<?php if (trim((string) ($ev['instructions'] ?? '')) !== '') : ?>
<div class="yse-detail"><div class="yse-dh"><?php echo yse_icon('info'); ?> Istruzioni</div><div class="yse-dt"><?php echo nl2br(esc_html($ev['instructions'])); ?></div></div>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if (!empty($gallery)) : ?>
<div class="yse-section">
<h4><?php echo yse_icon('img'); ?> Galleria</h4>
<div class="yse-gallery">
<?php foreach ($gallery as $g) : ?>
<img src="<?php echo esc_url($g); ?>" alt="" data-full="<?php echo esc_url($g); ?>">
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<?php if ($mapQuery !== '') : ?>
<div class="yse-section">
<h4><?php echo yse_icon('pin'); ?> Dove</h4>
<iframe class="yse-map" loading="lazy" src="https://www.google.com/maps?q=<?php echo rawurlencode($mapQuery); ?>&output=embed"></iframe>
</div>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<!-- BOOKING -->
<div class="yse-booking">
<?php if (!empty($ev['scheduled'])) :
$pt = !empty($ev['publish_at']) ? strtotime($ev['publish_at']) : 0;
$ptLabel = $pt ? ((int) date('j', $pt) . ' ' . $mesi[(int) date('n', $pt)] . ' ' . date('Y', $pt) . ' alle ' . date('H:i', $pt)) : '';
?>
<div class="yse-scheduled">Prenotazioni aperte dal <strong><?php echo esc_html($ptLabel); ?></strong></div>
<?php elseif ($hardFull) : ?>
<div class="yse-soldout">Evento esaurito — non ci sono più posti disponibili.</div>
<?php else : ?>
<?php if ($waitMode) :
$waitingCount = (int) ($ev['waiting'] ?? 0);
$waitingTxt = $waitingCount > 0
? ' Al momento ' . ($waitingCount === 1 ? "c'è 1 persona" : "ci sono {$waitingCount} persone") . ' in coda.'
: '';
?>
<div class="yse-wl-banner">Posti esauriti — puoi iscriverti alla <strong>lista d'attesa</strong>: ti avviseremo se si libera un posto.<?php echo esc_html($waitingTxt); ?></div>
<?php endif; ?>
<div class="yse-bcompact">
<div class="yse-btitle"><?php echo $waitMode ? "Lista d'attesa" : 'Prenota questo evento'; ?></div>
<div class="yse-bblock">
<div class="yse-lbl">Persone</div>
<div class="yse-stepper">
<button type="button" class="yse-minus">−</button>
<span class="yse-num">1</span>
<button type="button" class="yse-plus">+</button>
</div>
</div>
<div class="yse-bblock yse-total">
<div class="yse-lbl">Totale</div>
<div class="yse-val"><?php echo esc_html($price); ?></div>
</div>
<button type="button" class="yse-cta yse-open"><?php echo $waitMode ? "Lista d'attesa" : 'Prenota'; ?></button>
</div>
<div class="yse-bform">
<div class="yse-frow">
<div class="yse-field"><label>Nome *</label><input type="text" class="yse-name" placeholder="Il tuo nome"></div>
<div class="yse-field"><label>Cognome *</label><input type="text" class="yse-surname" placeholder="Il tuo cognome"></div>
</div>
<div class="yse-field"><label>Email *</label><input type="email" class="yse-email" placeholder="latua@email.it"></div>
<label class="yse-consent"><input type="checkbox" class="yse-c-privacy"> <span>Ho letto e accetto l'informativa privacy. *</span></label>
<label class="yse-consent"><input type="checkbox" class="yse-c-mkt"> <span>Desidero ricevere aggiornamenti e novità via email.</span></label>
<!-- honeypot anti-bot -->
<input type="text" class="yse-hp" name="website" tabindex="-1" autocomplete="off">
<button type="button" class="yse-cta yse-full yse-submit"><?php echo $waitMode ? "Iscriviti alla lista d'attesa" : 'Conferma prenotazione'; ?></button>
<div class="yse-error"></div>
</div>
<?php endif; ?>
</div>
</div>
<?php
return ob_get_clean();
}