Compila i dati dell'evento. I campi con * sono obbligatori.
+ + +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'; +?> + + + +
+ +Compila i dati dell'evento. I campi con * sono obbligatori.
+ + +event_locations non esiste ancora. Esegui la migration Phinx CreateEventLocationsTable e ricarica.
+ Le sedi salvate compaiono nella tendina "Sede" del form evento.
+ + +Rubrica delle sedi riutilizzabili.
+ +| Sede | +Indirizzo | +Coordinate | +Azioni | +
|---|---|---|---|
|
+
+
+ Nessuna sede salvata. Aggiungine una dal form a sinistra.
+
+ |
+ |||
| + + + Predefinita + + | ++ | —'; ?> | ++ Modifica + + Predefinita + + + | +
+ + · +
+| Partecipante | +Telefono | +Posti | +Marketing | +Iscritto il | +Stato | +Azioni | +|
|---|---|---|---|---|---|---|---|
|
+
+
+ Ancora nessun iscritto a questo evento.
+
+ |
+ |||||||
|
+
+
+
+
+
+
+ ospite
+
+ |
+ + | + | + | + + Sì + + No + + | ++ | + | + + + + — + + | +
Ciao , gestisci qui i tuoi eventi: crea, modifica, vedi i partecipanti o annulla.
+| Orario | +Nome | +Organizzatore | +Prenotato | +Stato | +Azioni | +
|---|---|---|---|---|---|
|
+
+
+ Nessun evento ancora. Crea il primo con "Nuovo evento".
+
+ |
+ |||||
| + | |||||
| + |
+
+
+
+ |
+ + | + + / + + | ++ + | ++ Anteprima + Partecipanti + Modifica + + + + | +