diff --git a/db/migrations/20260917062954_create_events_table.php b/db/migrations/20260917062954_create_events_table.php new file mode 100644 index 0000000..e44e93c --- /dev/null +++ b/db/migrations/20260917062954_create_events_table.php @@ -0,0 +1,51 @@ +table('events', [ + 'id' => false, + 'primary_key' => ['id'], + 'engine' => 'InnoDB', + 'encoding' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + ]); + + $table + ->addColumn('id', 'biginteger', ['identity' => true, 'signed' => false]) + ->addColumn('user_id', 'integer', ['signed' => false, 'null' => true, 'comment' => 'insegnante/creatore -> auth_users.id']) + ->addColumn('title', 'string', ['limit' => 191, 'null' => false]) + ->addColumn('slug', 'string', ['limit' => 191, 'null' => false, 'comment' => 'URL pubblico / widget']) + ->addColumn('description', 'text', ['limit' => MysqlAdapter::TEXT_LONG, 'null' => true, 'comment' => 'HTML editor']) + ->addColumn('organizer_name', 'string', ['limit' => 191, 'null' => true, 'comment' => 'nome libero; se null usa il creatore']) + ->addColumn('starts_at', 'datetime', ['null' => false]) + ->addColumn('ends_at', 'datetime', ['null' => true]) + ->addColumn('address', 'string', ['limit' => 255, 'null' => true, 'comment' => 'testo per Google Maps']) + ->addColumn('lat', 'decimal', ['precision' => 10, 'scale' => 7, 'null' => true]) + ->addColumn('lng', 'decimal', ['precision' => 10, 'scale' => 7, 'null' => true]) + ->addColumn('cost', 'decimal', ['precision' => 8, 'scale' => 2, 'null' => false, 'default' => 0]) + ->addColumn('currency', 'string', ['limit' => 3, 'null' => false, 'default' => 'EUR']) + ->addColumn('max_seats', 'integer', ['signed' => false, 'null' => false, 'default' => 0]) + ->addColumn('max_per_booking', 'integer', ['signed' => false, 'null' => false, 'default' => 1]) + ->addColumn('color', 'string', ['limit' => 7, 'null' => false, 'default' => '#4CAF50', 'comment' => 'hex card']) + ->addColumn('cover_image', 'string', ['limit' => 191, 'null' => true, 'comment' => 'gallery in futuro']) + ->addColumn('status', 'string', ['limit' => 20, 'null' => false, 'default' => 'draft', 'comment' => 'draft|published|cancelled']) + ->addColumn('created_at', 'timestamp', ['null' => true, 'default' => null]) + ->addColumn('updated_at', 'timestamp', ['null' => true, 'default' => null]) + ->addIndex(['slug'], ['unique' => true, 'name' => 'events_slug_unique']) + ->addIndex(['status', 'starts_at'], ['name' => 'events_status_starts_at_index']) + ->addIndex(['user_id'], ['name' => 'events_user_id_index']) + ->addForeignKey('user_id', 'auth_users', 'id', [ + 'delete' => 'SET_NULL', + 'update' => 'NO_ACTION', + 'constraint' => 'events_user_id_foreign', + ]) + ->create(); + } +} diff --git a/db/migrations/20260917063005_create_event_bookings_table.php b/db/migrations/20260917063005_create_event_bookings_table.php new file mode 100644 index 0000000..bd7d325 --- /dev/null +++ b/db/migrations/20260917063005_create_event_bookings_table.php @@ -0,0 +1,50 @@ +table('event_bookings', [ + 'id' => false, + 'primary_key' => ['id'], + 'engine' => 'InnoDB', + 'encoding' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + ]); + + $table + ->addColumn('id', 'biginteger', ['identity' => true, 'signed' => false]) + ->addColumn('event_id', 'biginteger', ['signed' => false, 'null' => false]) + ->addColumn('user_id', 'integer', ['signed' => false, 'null' => true, 'comment' => 'null = ospite non registrato']) + ->addColumn('name', 'string', ['limit' => 191, 'null' => false]) + ->addColumn('email', 'string', ['limit' => 191, 'null' => false, 'comment' => 'raccolta contatti']) + ->addColumn('phone', 'string', ['limit' => 40, 'null' => true]) + ->addColumn('seats', 'integer', ['signed' => false, 'null' => false, 'default' => 1]) + ->addColumn('status', 'string', ['limit' => 20, 'null' => false, 'default' => 'pending', 'comment' => 'pending|confirmed|cancelled']) + ->addColumn('amount', 'decimal', ['precision' => 8, 'scale' => 2, 'null' => true, 'comment' => 'pronto per pagamenti']) + ->addColumn('token', 'string', ['limit' => 64, 'null' => false, 'comment' => 'gestione/annullo ospite via link email']) + ->addColumn('consent_at', 'datetime', ['null' => true, 'comment' => 'accettazione privacy/termini']) + ->addColumn('marketing_opt_in', 'char', ['limit' => 1, 'null' => false, 'default' => 'N', 'comment' => 'consenso marketing Y/N']) + ->addColumn('created_at', 'timestamp', ['null' => true, 'default' => null]) + ->addColumn('updated_at', 'timestamp', ['null' => true, 'default' => null]) + ->addIndex(['token'], ['unique' => true, 'name' => 'event_bookings_token_unique']) + ->addIndex(['event_id'], ['name' => 'event_bookings_event_id_index']) + ->addIndex(['email'], ['name' => 'event_bookings_email_index']) + ->addIndex(['user_id'], ['name' => 'event_bookings_user_id_index']) + ->addForeignKey('event_id', 'events', 'id', [ + 'delete' => 'CASCADE', + 'update' => 'NO_ACTION', + 'constraint' => 'event_bookings_event_id_foreign', + ]) + ->addForeignKey('user_id', 'auth_users', 'id', [ + 'delete' => 'SET_NULL', + 'update' => 'NO_ACTION', + 'constraint' => 'event_bookings_user_id_foreign', + ]) + ->create(); + } +} diff --git a/db/migrations/20260917063022_add_event_defaults_to_option_table.php b/db/migrations/20260917063022_add_event_defaults_to_option_table.php new file mode 100644 index 0000000..cea4b55 --- /dev/null +++ b/db/migrations/20260917063022_add_event_defaults_to_option_table.php @@ -0,0 +1,18 @@ +table('option') + ->addColumn('event_default_color', 'string', ['limit' => 7, 'null' => true, 'default' => '#4CAF50', 'after' => 'colorcancelled']) + ->addColumn('event_default_max_per_booking', 'integer', ['null' => false, 'default' => 1, 'after' => 'event_default_color']) + ->addColumn('event_notify_email', 'string', ['limit' => 191, 'null' => true, 'after' => 'event_default_max_per_booking']) + ->addColumn('event_terms_text', 'text', ['null' => true, 'after' => 'event_notify_email']) + ->update(); + } +} diff --git a/db/migrations/20260917065654_create_event_locations_table.php b/db/migrations/20260917065654_create_event_locations_table.php new file mode 100644 index 0000000..8f268cf --- /dev/null +++ b/db/migrations/20260917065654_create_event_locations_table.php @@ -0,0 +1,42 @@ +table('event_locations', [ + 'id' => false, + 'primary_key' => ['id'], + 'engine' => 'InnoDB', + 'encoding' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + ]); + + $table + ->addColumn('id', 'biginteger', ['identity' => true, 'signed' => false]) + ->addColumn('name', 'string', ['limit' => 191, 'null' => false, 'comment' => 'etichetta sede, es. "Studio YogaSoul"']) + ->addColumn('address', 'string', ['limit' => 255, 'null' => false]) + ->addColumn('lat', 'decimal', ['precision' => 10, 'scale' => 7, 'null' => true]) + ->addColumn('lng', 'decimal', ['precision' => 10, 'scale' => 7, 'null' => true]) + ->addColumn('is_default', 'char', ['limit' => 1, 'null' => false, 'default' => 'N', 'comment' => 'Y = preselezionata']) + ->addColumn('created_at', 'timestamp', ['null' => true, 'default' => null]) + ->addColumn('updated_at', 'timestamp', ['null' => true, 'default' => null]) + ->addIndex(['is_default'], ['name' => 'event_locations_is_default_index']) + ->create(); + + // Sede di default (MODIFICA con indirizzo e coordinate reali della scuola) + $this->table('event_locations')->insert([ + 'name' => 'Studio YogaSoul', + 'address' => 'Indirizzo da compilare', + 'lat' => null, + 'lng' => null, + 'is_default' => 'Y', + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ])->saveData(); + } +} diff --git a/db/migrations/20260917073036_add_details_and_gallery_to_events.php b/db/migrations/20260917073036_add_details_and_gallery_to_events.php new file mode 100644 index 0000000..0f06c82 --- /dev/null +++ b/db/migrations/20260917073036_add_details_and_gallery_to_events.php @@ -0,0 +1,18 @@ +table('events') + ->addColumn('instructions', 'text', ['null' => true, 'after' => 'description']) + ->addColumn('what_to_bring', 'text', ['null' => true, 'after' => 'instructions']) + ->addColumn('suitable_for', 'text', ['null' => true, 'after' => 'what_to_bring']) + ->addColumn('gallery', 'text', ['null' => true, 'comment' => 'JSON: array di path immagini', 'after' => 'suitable_for']) + ->update(); + } +} diff --git a/db/migrations/20260917080550_add_waitlist_to_events.php b/db/migrations/20260917080550_add_waitlist_to_events.php new file mode 100644 index 0000000..b39fd6a --- /dev/null +++ b/db/migrations/20260917080550_add_waitlist_to_events.php @@ -0,0 +1,21 @@ +table('events') + ->addColumn('waitlist_enabled', 'char', [ + 'limit' => 1, + 'null' => false, + 'default' => 'Y', + 'comment' => "Y = lista d'attesa quando pieno", + 'after' => 'max_per_booking' + ]) + ->update(); + } +} diff --git a/public/apiwp/book.php b/public/apiwp/book.php new file mode 100644 index 0000000..c64f416 --- /dev/null +++ b/public/apiwp/book.php @@ -0,0 +1,182 @@ + 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')"); + $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(); + + // TODO Step 4: email di conferma / lista d'attesa all'iscritto (usando $token). + + $msg = $status === 'confirmed' + ? 'Prenotazione confermata! Ti abbiamo inviato una email di riepilogo.' + : "Sei in lista d'attesa: ti avviseremo appena si libera un posto."; + + send_json([ + 'ok' => true, + 'status' => $status, + 'seats' => $seats, + 'amount' => $amount, + 'token' => $token, + 'message' => $msg, + ]); +} catch (Throwable $ex) { + if ($pdo->inTransaction()) { + $pdo->rollBack(); + } + error_log('[apiwp/book] ' . $ex->getMessage()); + send_json(['ok' => false, 'code' => 'server', 'error' => 'Errore durante la prenotazione. Riprova.'], 500); +} diff --git a/public/apiwp/bootstrap.php b/public/apiwp/bootstrap.php new file mode 100644 index 0000000..90ec89b --- /dev/null +++ b/public/apiwp/bootstrap.php @@ -0,0 +1,63 @@ + ../class/db-functions.php) +require_once dirname(__DIR__) . '/class/db-functions.php'; + +// URL pubblico di yogibook: usato per rendere assoluti i path delle immagini. +if (!defined('YB_PUBLIC_BASE')) { + define('YB_PUBLIC_BASE', 'https://yogibook.yogasoul.it/'); +} + +// Domini WordPress autorizzati (per la prenotazione via browser). +$YB_ALLOWED_ORIGINS = ['https://yogasoul.it', 'https://www.yogasoul.it']; + +$origin = $_SERVER['HTTP_ORIGIN'] ?? ''; +if ($origin !== '' && in_array($origin, $YB_ALLOWED_ORIGINS, true)) { + header('Access-Control-Allow-Origin: ' . $origin); + header('Vary: Origin'); + header('Access-Control-Allow-Methods: GET, POST, OPTIONS'); + header('Access-Control-Allow-Headers: Content-Type'); +} + +// Preflight CORS +if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'OPTIONS') { + http_response_code(204); + exit(); +} + +function pdo_conn(): PDO +{ + return DBHandlerSelect::getInstance()->getConnection(); +} + +/** Rende assoluto un path immagine relativo (es. assets/uploads/events/x.jpg). */ +function public_url(?string $rel): ?string +{ + if ($rel === null || $rel === '') { + return null; + } + if (preg_match('#^https?://#i', $rel)) { + return $rel; + } + return YB_PUBLIC_BASE . ltrim($rel, '/'); +} + +/** Risponde in JSON e termina. */ +function send_json($data, int $code = 200): void +{ + if (function_exists('ob_get_level') && ob_get_level()) { + ob_clean(); + } + http_response_code($code); + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + exit(); +} diff --git a/public/apiwp/events.php b/public/apiwp/events.php new file mode 100644 index 0000000..413e657 --- /dev/null +++ b/public/apiwp/events.php @@ -0,0 +1,129 @@ + 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 = 'published'", "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') + 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'], + 'description' => $r['description'], // HTML (sanificare al rendering in WP) + 'instructions' => $r['instructions'], + 'what_to_bring' => $r['what_to_bring'], + 'suitable_for' => $r['suitable_for'], + 'organizer' => $organizer !== '' ? $organizer : null, + 'starts_at' => $r['starts_at'], + 'ends_at' => $r['ends_at'], + 'address' => $r['address'], + 'lat' => $r['lat'] !== null ? (float) $r['lat'] : null, + 'lng' => $r['lng'] !== null ? (float) $r['lng'] : null, + 'cost' => (float) $r['cost'], + 'currency' => $r['currency'], + 'currency_symbol' => $symbols[$r['currency']] ?? $r['currency'], + 'color' => $r['color'], + 'cover_image' => public_url($r['cover_image']), + 'gallery' => $gallery, + 'max_seats' => $maxSeats, + 'max_per_booking' => (int) $r['max_per_booking'], + 'occupied' => $occupied, + 'remaining' => $remaining, + 'sold_out' => $remaining <= 0, + 'waitlist_enabled' => ($r['waitlist_enabled'] ?? 'N') === 'Y', + 'waiting' => (int) $r['waiting'], + ]; + } + + send_json(['ok' => true, 'count' => count($events), 'events' => $events]); +} catch (Throwable $ex) { + error_log('[api/events] ' . $ex->getMessage()); + send_json(['ok' => false, 'error' => 'Errore nel recupero degli eventi.'], 500); +} diff --git a/public/assets/uploads/events/20260917_070509_9baef25dd9e4.jpg b/public/assets/uploads/events/20260917_070509_9baef25dd9e4.jpg new file mode 100644 index 0000000..9e48fc9 Binary files /dev/null and b/public/assets/uploads/events/20260917_070509_9baef25dd9e4.jpg differ diff --git a/public/assets/uploads/events/20260917_071149_31f20e8129eb.jpg b/public/assets/uploads/events/20260917_071149_31f20e8129eb.jpg new file mode 100644 index 0000000..9e48fc9 Binary files /dev/null and b/public/assets/uploads/events/20260917_071149_31f20e8129eb.jpg differ diff --git a/public/assets/uploads/events/20260917_073200_4533f1a62c79.jpg b/public/assets/uploads/events/20260917_073200_4533f1a62c79.jpg new file mode 100644 index 0000000..2a26679 Binary files /dev/null and b/public/assets/uploads/events/20260917_073200_4533f1a62c79.jpg differ diff --git a/public/assets/uploads/events/20260917_073200_7b41af46191d.jpg b/public/assets/uploads/events/20260917_073200_7b41af46191d.jpg new file mode 100644 index 0000000..e1026b4 Binary files /dev/null and b/public/assets/uploads/events/20260917_073200_7b41af46191d.jpg differ diff --git a/public/assets/uploads/events/20260917_073200_af8526d8af96.jpg b/public/assets/uploads/events/20260917_073200_af8526d8af96.jpg new file mode 100644 index 0000000..e181cb6 Binary files /dev/null and b/public/assets/uploads/events/20260917_073200_af8526d8af96.jpg differ diff --git a/public/event_delete.php b/public/event_delete.php new file mode 100644 index 0000000..54b9084 --- /dev/null +++ b/public/event_delete.php @@ -0,0 +1,54 @@ +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) { + sendEventCancellationMail($p['email'], $p['name'], $event); + } + } + + header('Location: events.php?message=cancelled'); + exit(); +} catch (Throwable $ex) { + error_log('[event_delete] ' . $ex->getMessage()); + header('Location: events.php?message=error'); + exit(); +} diff --git a/public/event_form.php b/public/event_form.php new file mode 100644 index 0000000..9612e1c --- /dev/null +++ b/public/event_form.php @@ -0,0 +1,907 @@ + 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); + +// 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'] ?? '', +]; + +$isEdit = $id > 0; +$pageTitle = $isEdit ? 'Modifica evento' : 'Nuovo evento'; +?> + + + + + + YogiBook - <?php echo e($pageTitle); ?> + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+ +
+
+ +
+
+
+ +
+
+
+
+
+
+
+
+

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

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

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

+ + +
+ +
+
+ +

Trascina qui l'immagine oppure sfoglia

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

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

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

Rubrica delle sedi riutilizzabili.

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + +
SedeIndirizzoCoordinateAzioni
+
+ + Nessuna sede salvata. Aggiungine una dal form a sinistra. +
+
+ + + Predefinita + + —'; ?> + Modifica + + Predefinita + + +
+
+
+
+
+
+ +
+ +
+
+
+ + + + + + + + + + + \ No newline at end of file diff --git a/public/event_locations_save.php b/public/event_locations_save.php new file mode 100644 index 0000000..4c61683 --- /dev/null +++ b/public/event_locations_save.php @@ -0,0 +1,67 @@ +getConnection(); + +$id = isset($_POST['id']) ? (int) $_POST['id'] : 0; +$name = trim($_POST['name'] ?? ''); +$address = trim($_POST['address'] ?? ''); +$isDefault = (isset($_POST['is_default']) && $_POST['is_default'] === 'Y') ? 'Y' : 'N'; + +$lat = ($_POST['lat'] ?? '') !== '' ? (float) $_POST['lat'] : null; +$lng = ($_POST['lng'] ?? '') !== '' ? (float) $_POST['lng'] : null; + +if ($name === '' || $address === '') { + header('Location: event_locations.php?message=error'); + exit(); +} + +try { + if ($id > 0) { + $stmt = $pdo->prepare("UPDATE event_locations + SET name = :name, address = :address, lat = :lat, lng = :lng, + is_default = :is_default, updated_at = NOW() + WHERE id = :id"); + $stmt->execute([ + ':name' => $name, + ':address' => $address, + ':lat' => $lat, + ':lng' => $lng, + ':is_default' => $isDefault, + ':id' => $id, + ]); + $savedId = $id; + } else { + $stmt = $pdo->prepare("INSERT INTO event_locations (name, address, lat, lng, is_default, created_at, updated_at) + VALUES (:name, :address, :lat, :lng, :is_default, NOW(), NOW())"); + $stmt->execute([ + ':name' => $name, + ':address' => $address, + ':lat' => $lat, + ':lng' => $lng, + ':is_default' => $isDefault, + ]); + $savedId = (int) $pdo->lastInsertId(); + } + + // Una sola predefinita + if ($isDefault === 'Y') { + $pdo->prepare("UPDATE event_locations SET is_default = IF(id = ?, 'Y', 'N'), updated_at = NOW()")->execute([$savedId]); + } + + header('Location: event_locations.php?message=success'); + exit(); +} catch (Throwable $ex) { + error_log('[event_locations_save] ' . $ex->getMessage()); + header('Location: event_locations.php?message=error'); + exit(); +} diff --git a/public/event_participants.php b/public/event_participants.php new file mode 100644 index 0000000..c4fb0dc --- /dev/null +++ b/public/event_participants.php @@ -0,0 +1,435 @@ +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']; + $upd = $pdo->prepare("UPDATE event_bookings SET status = 'cancelled', updated_at = NOW() WHERE id = ? AND event_id = ?"); + $upd->execute([$bookingId, $eventId]); + 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 (solo prenotazioni non annullate) +$bookedSeats = 0; +$activeCount = 0; +$revenue = 0.0; +foreach ($bookings as $b) { + if ($b['status'] !== 'cancelled') { + $bookedSeats += (int) $b['seats']; + $activeCount++; + $revenue += (float) $b['amount']; + } +} +$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']; + default: + return ['In attesa', '#fff4e5', '#b26a00']; + } +} +?> + + + + + + YogiBook - Partecipanti + + + + + + + + + + + + + +
+
+ +
+ +
+ +
+
+ +
+
+
+ +
+
+
+ + + + + +
+
+
+
+
+
+
Partecipanti
+

+ + · +

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

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

+ + + + Galleria

+ +
+ + + +
+

+ + + Dove

+ +
+ +
+
+ + + +
+ +
Evento esaurito — non ci sono più posti disponibili.
+ +
+
Prenota questo evento
+
+
Persone
+
+ + 1 + +
+
+
+
Totale
+
+
+ +
+ +
+
+
+ + +
+
+ + +
+
+
+ + +
+ + + +
+
+ +
+
+
+ + + + + + + \ No newline at end of file diff --git a/public/event_save.php b/public/event_save.php new file mode 100644 index 0000000..6e40759 --- /dev/null +++ b/public/event_save.php @@ -0,0 +1,187 @@ +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'; +} + +// Campi facoltativi (testo) +$instructions = trim($_POST['instructions'] ?? ''); +$whatToBring = trim($_POST['what_to_bring'] ?? ''); +$suitableFor = trim($_POST['suitable_for'] ?? ''); + +// 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, + cover_image = :cover_image, gallery = :gallery, status = :status, 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, + cover_image, gallery, status, created_at, updated_at) + VALUES + (:user_id, :title, :slug, :description, :instructions, :what_to_bring, :suitable_for, + :organizer_name, :starts_at, :ends_at, + :address, :lat, :lng, :cost, :currency, :max_seats, :max_per_booking, :color, + :cover_image, :gallery, :status, 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, + ':color' => $color, + ':cover_image' => $coverImage !== '' ? $coverImage : null, + ':gallery' => $gallery, + ':status' => $status, + ]; + + $stmt = $pdo->prepare($sql); + $stmt->execute($params); + + header('Location: events.php?message=success'); + exit(); +} catch (Throwable $ex) { + error_log('[event_save] ' . $ex->getMessage()); + header("Location: {$redirectOnError}"); + exit(); +} diff --git a/public/event_upload_cover.php b/public/event_upload_cover.php new file mode 100644 index 0000000..b1c436d --- /dev/null +++ b/public/event_upload_cover.php @@ -0,0 +1,62 @@ + false, 'error' => $msg]); + exit(); +}; + +if ($_SERVER['REQUEST_METHOD'] !== 'POST' || !isset($_FILES['cover'])) { + $fail('Richiesta non valida.'); +} + +$file = $_FILES['cover']; +if ($file['error'] !== UPLOAD_ERR_OK) { + $fail('Errore nel caricamento (codice ' . (int) $file['error'] . ').'); +} +if ($file['size'] > 5 * 1024 * 1024) { + $fail("L'immagine supera i 5 MB."); +} + +// Validazione MIME reale (non ci si fida dell'estensione) +$allowed = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp', 'image/gif' => 'gif']; +$info = @getimagesize($file['tmp_name']); +$mime = $info['mime'] ?? ''; +if (!isset($allowed[$mime])) { + $fail('Formato non supportato. Usa JPG, PNG, WEBP o GIF.'); +} +$ext = $allowed[$mime]; + +$relDir = 'assets/uploads/events'; +$absDir = __DIR__ . '/' . $relDir; +if (!is_dir($absDir)) { + @mkdir($absDir, 0775, true); +} +if (!is_dir($absDir) || !is_writable($absDir)) { + $fail('Cartella non scrivibile: ' . $relDir . ' (crea la cartella e dai permessi di scrittura).'); +} + +try { + $rand = bin2hex(random_bytes(6)); +} catch (Throwable $e) { + $rand = uniqid('', true); +} +$name = date('Ymd_His') . '_' . $rand . '.' . $ext; +$dest = $absDir . '/' . $name; + +if (!move_uploaded_file($file['tmp_name'], $dest)) { + $fail('Impossibile salvare il file.'); +} + +echo json_encode(['ok' => true, 'path' => $relDir . '/' . $name]); +exit(); diff --git a/public/events.php b/public/events.php new file mode 100644 index 0000000..6943f5b --- /dev/null +++ b/public/events.php @@ -0,0 +1,463 @@ + 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, + 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 <> 'cancelled' + GROUP BY event_id + ) b ON b.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 'cancelled': + return ['Annullato', '#fdecea', '#c0392b']; + default: + return ['Bozza', '#eef0f3', '#6b7280']; + } +} +?> + + + + + + YogiBook - Eventi + + + + + + + + + + + + + + +
+
+ +
+ +
+ +
+
+ +
+
+
+ +
+
+
+ + + + + + + + + +
+
+
+
+
+
+
Eventi
+

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

+
+ Nuovo evento +
+ +
+ + + + + + + + + + + + + + + + + + + + + 0 && $booked >= $maxSeats; + [$stLabel, $stBg, $stColor] = statusBadge($ev['status']); + + $timeLabel = date('H:i', strtotime($ev['starts_at'])); + if (!empty($ev['ends_at'])) { + $timeLabel .= ' - ' . date('H:i', strtotime($ev['ends_at'])); + } + ?> + + + + + + + + + + + +
OrarioNomeOrganizzatorePrenotatoStatoAzioni
+
+ + Nessun evento ancora. Crea il primo con "Nuovo evento". +
+
+
+ + + + + + + (ID: ) +
+
+ + / + + + + + Anteprima + Partecipanti + Modifica + + + +
+
+
+
+
+
+ +
+ +
+
+
+ + + + + + + + + + + \ No newline at end of file diff --git a/public/include/event_mailer.php b/public/include/event_mailer.php new file mode 100644 index 0000000..f2d3ec7 --- /dev/null +++ b/public/include/event_mailer.php @@ -0,0 +1,58 @@ +isSMTP(); + // $mail->Host = getenv('MAIL_HOST'); + // $mail->SMTPAuth = true; + // $mail->Username = getenv('MAIL_USERNAME'); + // $mail->Password = getenv('MAIL_PASSWORD'); + // $mail->SMTPSecure = getenv('MAIL_ENCRYPTION'); // 'ssl' + // $mail->Port = (int) getenv('MAIL_PORT'); // 465 + // $mail->setFrom(getenv('MAIL_FROM_ADDRESS'), getenv('MAIL_FROM_NAME')); + // $mail->addAddress($toEmail, $toName); + // $mail->isHTML(true); + // $mail->Subject = 'Evento annullato: ' . $event['title']; + // $mail->Body = '...TEMPLATE HTML...'; + // return $mail->send(); + // ------------------------------------------------------------------ + + error_log(sprintf( + '[event mail] STUB annullamento -> %s (%s) per evento #%d "%s"', + $toEmail, + $toName, + (int) $event['id'], + $event['title'] ?? '' + )); + + return true; + } +} diff --git a/public/include/sidebar.php b/public/include/sidebar.php index fb428d5..176b103 100644 --- a/public/include/sidebar.php +++ b/public/include/sidebar.php @@ -70,63 +70,93 @@ hasRole('Admin')): ?> - -
  • - - - Servizi - -
  • + + +
  • - Classi + Classi + +
  • +
  • + + + Servizi
  • - Pending/Waiting + Pending/Waiting
  • - Day Off + Day Off
  • + + + + +
  • + + + Eventi + +
  • +
  • + + + Sedi + +
  • + + +
  • - Order Pending - -
  • -
  • - - - Certificati - -
  • -
  • - - - Situazione + Order Pending
  • - Sommario Ordini - -
  • -
  • - - - Opzioni Scuola + Sommario Ordini
  • + + + +
  • + + + Situazione + +
  • +
  • + + + Certificati + +
  • + + + + +
  • + + + Opzioni Scuola + +
  • + +