events yogibook
This commit is contained in:
@@ -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,182 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/bootstrap.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')");
|
||||
$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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?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 = '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);
|
||||
}
|
||||
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 |
@@ -0,0 +1,54 @@
|
||||
<?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¬ify=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) {
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,907 @@
|
||||
<?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);
|
||||
|
||||
// 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';
|
||||
?>
|
||||
<!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-9 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>
|
||||
|
||||
<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);
|
||||
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 = '×';
|
||||
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>
|
||||
@@ -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();
|
||||
@@ -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-lg-5">
|
||||
<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-lg-7">
|
||||
<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>
|
||||
@@ -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();
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
<?php
|
||||
require_once('include/headscript.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'];
|
||||
$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'];
|
||||
}
|
||||
}
|
||||
?>
|
||||
<!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>
|
||||
· <?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-4 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-4 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-4 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>
|
||||
@@ -0,0 +1,995 @@
|
||||
<?php
|
||||
require_once('include/headscript.php');
|
||||
|
||||
/**
|
||||
* Anteprima "scheda evento" pubblica (prototipo del widget WordPress).
|
||||
* Versione COMPATTA + espandibile:
|
||||
* - stato compatto: data, titolo, capacità/luogo/orario, prezzo, prenotazione
|
||||
* - "Dettagli": espande la scheda completa (cover, descrizione, gallery, mappa,
|
||||
* per chi è adatto / cosa portare / istruzioni)
|
||||
* Prenotazione a passo unico: qty + "Prenota" rivela Nome/Cognome/Email + consensi
|
||||
* nella stessa card. In anteprima "Conferma" non invia: mostra un riepilogo.
|
||||
*/
|
||||
$pdo = DBHandlerSelect::getInstance()->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));
|
||||
}
|
||||
?>
|
||||
<!doctype html>
|
||||
<html lang="it">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title><?php echo e($ev['title']); ?> — Anteprima</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,400;9..144,500;9..144,600&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--accent: <?php echo $color; ?>;
|
||||
--ink: #2b2622;
|
||||
--muted: #8c837c;
|
||||
--line: #ece7e2;
|
||||
--paper: #faf8f6;
|
||||
--card: #ffffff;
|
||||
--accent-soft: color-mix(in srgb, var(--accent) 10%, white);
|
||||
--accent-tint: color-mix(in srgb, var(--accent) 16%, white);
|
||||
--accent-ink: color-mix(in srgb, var(--accent) 62%, black);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
line-height: 1.55;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.preview-bar {
|
||||
background: var(--ink);
|
||||
color: #f4efe9;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.wrap {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 26px 18px 60px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card);
|
||||
border-radius: 18px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 14px 40px rgba(43, 38, 34, 0.10);
|
||||
}
|
||||
|
||||
/* ---------- HEADER COMPATTO ---------- */
|
||||
.chead {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
padding: 18px 22px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.date-chip {
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 7px 11px 5px;
|
||||
text-align: center;
|
||||
min-width: 54px;
|
||||
}
|
||||
|
||||
.date-chip .m {
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: .12em;
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.date-chip .d {
|
||||
font-family: 'Fraunces', serif;
|
||||
font-size: 23px;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.chead .main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.title-row h1 {
|
||||
font-family: 'Fraunces', serif;
|
||||
font-weight: 500;
|
||||
font-size: 21px;
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--accent-ink);
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 2px;
|
||||
}
|
||||
|
||||
.toggle svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
transition: transform .2s;
|
||||
}
|
||||
|
||||
.toggle.open svg {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.cmeta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 20px;
|
||||
margin-top: 9px;
|
||||
}
|
||||
|
||||
.cmeta .item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: var(--muted);
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.cmeta .item svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--accent-ink);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cmeta .item strong {
|
||||
color: var(--ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.price-badge {
|
||||
flex-shrink: 0;
|
||||
align-self: flex-start;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
padding: 7px 13px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
/* ---------- DETTAGLI ESPANDIBILI ---------- */
|
||||
.details {
|
||||
display: none;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.details.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
height: 260px;
|
||||
background: linear-gradient(135deg, var(--accent-ink), var(--accent));
|
||||
}
|
||||
|
||||
.hero img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.hero .emblem {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgba(255, 255, 255, .35);
|
||||
}
|
||||
|
||||
.hero .emblem svg {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
}
|
||||
|
||||
.body {
|
||||
padding: 22px;
|
||||
}
|
||||
|
||||
.section+.section {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.section h3 {
|
||||
font-family: 'Fraunces', serif;
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
color: var(--ink);
|
||||
margin: 0 0 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.section h3 svg {
|
||||
width: 19px;
|
||||
height: 19px;
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.desc {
|
||||
font-size: 15px;
|
||||
color: #4a433d;
|
||||
}
|
||||
|
||||
.desc :where(p) {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.desc :where(img) {
|
||||
max-width: 100%;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.detail {
|
||||
background: var(--accent-soft);
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 18%, white);
|
||||
border-radius: 13px;
|
||||
padding: 14px 15px;
|
||||
}
|
||||
|
||||
.detail .h {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-ink);
|
||||
margin-bottom: 6px;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
|
||||
.detail .h svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.detail .t {
|
||||
font-size: 13.5px;
|
||||
color: #4a433d;
|
||||
}
|
||||
|
||||
.gallery {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.gallery img {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
object-fit: cover;
|
||||
border-radius: 11px;
|
||||
cursor: pointer;
|
||||
transition: transform .15s, box-shadow .15s;
|
||||
}
|
||||
|
||||
.gallery img:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 18px rgba(0, 0, 0, .15);
|
||||
}
|
||||
|
||||
.map {
|
||||
border: 0;
|
||||
width: 100%;
|
||||
height: 210px;
|
||||
border-radius: 13px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ---------- BOOKING ---------- */
|
||||
.booking {
|
||||
padding: 18px 22px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.booking-compact {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 18px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.bc-block .lbl {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .05em;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.bc-title {
|
||||
font-family: 'Fraunces', serif;
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
color: var(--ink);
|
||||
margin-right: auto;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.stepper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 11px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.stepper button {
|
||||
border: none;
|
||||
background: #fff;
|
||||
width: 42px;
|
||||
height: 44px;
|
||||
font-size: 21px;
|
||||
color: var(--accent-ink);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stepper button:disabled {
|
||||
color: #cfc8c2;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.stepper .n {
|
||||
width: 50px;
|
||||
text-align: center;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.total-box .val {
|
||||
font-family: 'Fraunces', serif;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.cta {
|
||||
border: none;
|
||||
border-radius: 11px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
transition: filter .15s;
|
||||
}
|
||||
|
||||
.cta:hover {
|
||||
filter: brightness(.94);
|
||||
}
|
||||
|
||||
.cta:disabled {
|
||||
background: #cfc8c2;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.cta.compact {
|
||||
padding: 13px 24px;
|
||||
}
|
||||
|
||||
.cta.full {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.booking-form {
|
||||
display: none;
|
||||
margin-top: 18px;
|
||||
padding-top: 18px;
|
||||
border-top: 1px dashed var(--line);
|
||||
}
|
||||
|
||||
.booking-form.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.field {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.field label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--ink);
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.field label .req {
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
.field input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 11px 13px;
|
||||
font-size: 15px;
|
||||
font-family: inherit;
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.field input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, white);
|
||||
}
|
||||
|
||||
.consent {
|
||||
font-size: 13px;
|
||||
color: #4a433d;
|
||||
display: flex;
|
||||
gap: 9px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 9px;
|
||||
}
|
||||
|
||||
.consent input {
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.consent a {
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.soldout-note {
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
.form-error {
|
||||
color: #c0392b;
|
||||
font-size: 13px;
|
||||
margin-top: 8px;
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
.booking-done {
|
||||
text-align: center;
|
||||
padding: 6px 4px;
|
||||
}
|
||||
|
||||
.booking-done .ok {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 10px;
|
||||
}
|
||||
|
||||
.booking-done h3 {
|
||||
margin: 0 0 6px;
|
||||
font-family: 'Fraunces', serif;
|
||||
}
|
||||
|
||||
.booking-done p {
|
||||
color: #4a433d;
|
||||
font-size: 14px;
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.booking-done .hintline {
|
||||
color: var(--muted);
|
||||
font-size: 12.5px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.lightbox {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(24, 19, 16, .9);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 50;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.lightbox.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.lightbox img {
|
||||
max-width: 92vw;
|
||||
max-height: 86vh;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.lightbox .close {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
right: 22px;
|
||||
color: #fff;
|
||||
font-size: 34px;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: none;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.hero {
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.title-row h1 {
|
||||
font-size: 19px;
|
||||
}
|
||||
|
||||
.field-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.booking-compact {
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.bc-title {
|
||||
width: 100%;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="preview-bar">Anteprima — la card parte compatta; “Dettagli” apre la scheda completa. Il pulsante non invia nulla.</div>
|
||||
|
||||
<div class="wrap">
|
||||
<div class="card">
|
||||
|
||||
<!-- HEADER COMPATTO -->
|
||||
<div class="chead">
|
||||
<div class="date-chip">
|
||||
<div class="m"><?php echo e($monAbbr); ?></div>
|
||||
<div class="d"><?php echo $dayNum; ?></div>
|
||||
</div>
|
||||
<div class="main">
|
||||
<div class="title-row">
|
||||
<h1><?php echo e($ev['title']); ?></h1>
|
||||
<?php if ($hasDetails) : ?>
|
||||
<button type="button" class="toggle" id="details-toggle" aria-expanded="false">
|
||||
<span id="toggle-label">Dettagli</span>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div class="cmeta">
|
||||
<div class="item">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
<span>Capacità <strong><?php echo $booked; ?>/<?php echo $maxSeats; ?></strong></span>
|
||||
</div>
|
||||
<?php if (trim((string) $ev['address']) !== '') : ?>
|
||||
<div class="item">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7">
|
||||
<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" />
|
||||
</svg>
|
||||
<span><?php echo e($ev['address']); ?></span>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<div class="item">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 7v5l3 2" />
|
||||
</svg>
|
||||
<span><?php echo e($dateLong); ?> · <?php echo e($timeLabel); ?></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="price-badge"><?php echo e($priceLabel); ?></div>
|
||||
</div>
|
||||
|
||||
<!-- DETTAGLI ESPANDIBILI -->
|
||||
<?php if ($hasDetails) : ?>
|
||||
<div class="details" id="details">
|
||||
<div class="hero">
|
||||
<?php if (!empty($ev['cover_image'])) : ?>
|
||||
<img src="<?php echo e($ev['cover_image']); ?>" alt="<?php echo e($ev['title']); ?>">
|
||||
<?php else : ?>
|
||||
<div class="emblem"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4">
|
||||
<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" />
|
||||
</svg></div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
<?php if (trim((string) $ev['description']) !== '') : ?>
|
||||
<div class="section">
|
||||
<div class="desc"><?php echo $ev['description']; ?></div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (trim((string) $ev['suitable_for']) !== '' || trim((string) $ev['what_to_bring']) !== '' || trim((string) $ev['instructions']) !== '') : ?>
|
||||
<div class="section">
|
||||
<div class="detail-grid">
|
||||
<?php if (trim((string) $ev['suitable_for']) !== '') : ?>
|
||||
<div class="detail">
|
||||
<div class="h"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7">
|
||||
<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" />
|
||||
</svg> Per chi è adatto</div>
|
||||
<div class="t"><?php echo textBlock($ev['suitable_for']); ?></div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if (trim((string) $ev['what_to_bring']) !== '') : ?>
|
||||
<div class="detail">
|
||||
<div class="h"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7">
|
||||
<path d="M6 7h12l1 13H5L6 7z" />
|
||||
<path d="M9 7a3 3 0 0 1 6 0" />
|
||||
</svg> Cosa portare</div>
|
||||
<div class="t"><?php echo textBlock($ev['what_to_bring']); ?></div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php if (trim((string) $ev['instructions']) !== '') : ?>
|
||||
<div class="detail">
|
||||
<div class="h"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 11v5" />
|
||||
<path d="M12 8h.01" />
|
||||
</svg> Istruzioni</div>
|
||||
<div class="t"><?php echo textBlock($ev['instructions']); ?></div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($gallery)) : ?>
|
||||
<div class="section">
|
||||
<h3><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7">
|
||||
<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" />
|
||||
</svg> Galleria</h3>
|
||||
<div class="gallery">
|
||||
<?php foreach ($gallery as $g) : ?>
|
||||
<img src="<?php echo e($g); ?>" alt="" data-full="<?php echo e($g); ?>">
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if ($mapQuery !== '') : ?>
|
||||
<div class="section">
|
||||
<h3><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7">
|
||||
<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" />
|
||||
</svg> Dove</h3>
|
||||
<iframe class="map" loading="lazy" src="https://www.google.com/maps?q=<?php echo urlencode($mapQuery); ?>&output=embed"></iframe>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<!-- BOOKING (compatto, passo unico) -->
|
||||
<div class="booking" id="booking">
|
||||
<?php if ($soldOut) : ?>
|
||||
<div class="soldout-note">Evento esaurito — non ci sono più posti disponibili.</div>
|
||||
<?php else : ?>
|
||||
<div class="booking-compact">
|
||||
<div class="bc-title">Prenota questo evento</div>
|
||||
<div class="bc-block">
|
||||
<div class="lbl">Persone</div>
|
||||
<div class="stepper">
|
||||
<button type="button" id="q-minus">−</button>
|
||||
<span class="n" id="q-num">1</span>
|
||||
<button type="button" id="q-plus">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bc-block total-box">
|
||||
<div class="lbl">Totale</div>
|
||||
<div class="val" id="q-total"><?php echo e($priceLabel); ?></div>
|
||||
</div>
|
||||
<button type="button" class="cta compact" id="b-open">Prenota</button>
|
||||
</div>
|
||||
|
||||
<div class="booking-form" id="booking-form">
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label>Nome <span class="req">*</span></label>
|
||||
<input type="text" id="b-name" placeholder="Il tuo nome">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Cognome <span class="req">*</span></label>
|
||||
<input type="text" id="b-surname" placeholder="Il tuo cognome">
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Email <span class="req">*</span></label>
|
||||
<input type="email" id="b-email" placeholder="latua@email.it">
|
||||
</div>
|
||||
<label class="consent">
|
||||
<input type="checkbox" id="b-consent">
|
||||
<span>Ho letto e accetto l'<a href="#" target="_blank">informativa privacy</a>. <span class="req">*</span></span>
|
||||
</label>
|
||||
<label class="consent">
|
||||
<input type="checkbox" id="b-marketing">
|
||||
<span>Desidero ricevere aggiornamenti e novità via email.</span>
|
||||
</label>
|
||||
<button type="button" class="cta full" id="b-submit">Conferma prenotazione</button>
|
||||
<div class="form-error" id="b-error"></div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="lightbox" id="lightbox">
|
||||
<button class="close" id="lb-close">×</button>
|
||||
<img src="" alt="" id="lb-img">
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var COST = <?php echo json_encode($cost); ?>;
|
||||
var SYM = <?php echo json_encode($sym); ?>;
|
||||
var MAXQTY = <?php echo (int) $maxQty; ?>;
|
||||
|
||||
function fmtMoney(v) {
|
||||
if (v <= 0) return 'Gratuito';
|
||||
return SYM + v.toLocaleString('it-IT', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2
|
||||
});
|
||||
}
|
||||
|
||||
// Toggle dettagli
|
||||
var dt = document.getElementById('details-toggle');
|
||||
if (dt) {
|
||||
dt.addEventListener('click', function() {
|
||||
var box = document.getElementById('details');
|
||||
var open = box.classList.toggle('open');
|
||||
dt.classList.toggle('open', open);
|
||||
dt.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
document.getElementById('toggle-label').textContent = open ? 'Chiudi' : 'Dettagli';
|
||||
});
|
||||
}
|
||||
|
||||
(function() {
|
||||
var qty = 1;
|
||||
var numEl = document.getElementById('q-num');
|
||||
if (!numEl) return;
|
||||
var totEl = document.getElementById('q-total');
|
||||
var minus = document.getElementById('q-minus');
|
||||
var plus = document.getElementById('q-plus');
|
||||
var submitBtn = document.getElementById('b-submit');
|
||||
|
||||
function refresh() {
|
||||
numEl.textContent = qty;
|
||||
totEl.textContent = fmtMoney(qty * COST);
|
||||
minus.disabled = qty <= 1;
|
||||
plus.disabled = qty >= MAXQTY;
|
||||
if (submitBtn) submitBtn.textContent = 'Conferma prenotazione' + (COST > 0 ? ' · ' + fmtMoney(qty * COST) : '');
|
||||
}
|
||||
minus.addEventListener('click', function() {
|
||||
if (qty > 1) {
|
||||
qty--;
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
plus.addEventListener('click', function() {
|
||||
if (qty < MAXQTY) {
|
||||
qty++;
|
||||
refresh();
|
||||
}
|
||||
});
|
||||
refresh();
|
||||
|
||||
// "Prenota" rivela i campi (stessa card, passo unico)
|
||||
document.getElementById('b-open').addEventListener('click', function() {
|
||||
document.getElementById('booking-form').classList.add('open');
|
||||
this.style.display = 'none';
|
||||
document.getElementById('b-name').focus();
|
||||
});
|
||||
|
||||
submitBtn.addEventListener('click', function() {
|
||||
var name = document.getElementById('b-name').value.trim();
|
||||
var surname = document.getElementById('b-surname').value.trim();
|
||||
var email = document.getElementById('b-email').value.trim();
|
||||
var consent = document.getElementById('b-consent').checked;
|
||||
var err = document.getElementById('b-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 (!consent) {
|
||||
err.textContent = 'Devi accettare l\'informativa privacy.';
|
||||
return;
|
||||
}
|
||||
err.textContent = '';
|
||||
|
||||
var marketing = document.getElementById('b-marketing').checked;
|
||||
document.getElementById('booking').innerHTML =
|
||||
'<div class="booking-done">' +
|
||||
' <div class="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>Anteprima prenotazione</h3>' +
|
||||
' <p><strong>' + name + ' ' + surname + '</strong> · ' + email + '</p>' +
|
||||
' <p>' + qty + ' ' + (qty === 1 ? 'persona' : 'persone') + ' · Totale <strong>' + fmtMoney(qty * COST) + '</strong></p>' +
|
||||
' <p>Marketing: ' + (marketing ? 'sì' : 'no') + '</p>' +
|
||||
' <p class="hintline">Nel widget pubblico, qui parte la prenotazione reale (POST all\'endpoint).</p>' +
|
||||
'</div>';
|
||||
});
|
||||
})();
|
||||
|
||||
// Lightbox galleria
|
||||
var lb = document.getElementById('lightbox');
|
||||
var lbImg = document.getElementById('lb-img');
|
||||
document.querySelectorAll('.gallery img').forEach(function(img) {
|
||||
img.addEventListener('click', function() {
|
||||
lbImg.src = img.getAttribute('data-full');
|
||||
lb.classList.add('open');
|
||||
});
|
||||
});
|
||||
|
||||
function closeLb() {
|
||||
lb.classList.remove('open');
|
||||
lbImg.src = '';
|
||||
}
|
||||
document.getElementById('lb-close').addEventListener('click', closeLb);
|
||||
lb.addEventListener('click', function(e) {
|
||||
if (e.target === lb) closeLb();
|
||||
});
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') closeLb();
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,187 @@
|
||||
<?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';
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
@@ -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();
|
||||
@@ -0,0 +1,463 @@
|
||||
<?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,
|
||||
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'];
|
||||
}
|
||||
}
|
||||
?>
|
||||
<!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;
|
||||
}
|
||||
|
||||
.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="event-id">(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>
|
||||
</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">
|
||||
<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'] !== '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() {
|
||||
$('.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 + '¬ify=' + result.value;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* STUB mailer del modulo Eventi.
|
||||
*
|
||||
* Per ora NON invia davvero: registra solo un log, così tutto il flusso
|
||||
* (annullamento + scelta "invia mail") è già funzionante end-to-end.
|
||||
* Nello step successivo definiamo il TEMPLATE e attiviamo l'invio reale via
|
||||
* PHPMailer, leggendo le variabili MAIL_* dal .env che hai già.
|
||||
*
|
||||
* Config disponibile nel .env:
|
||||
* MAIL_HOST=mail.yogasoul.it MAIL_PORT=465 MAIL_ENCRYPTION=ssl
|
||||
* MAIL_USERNAME=info@yogasoul.it MAIL_PASSWORD=...
|
||||
* MAIL_FROM_ADDRESS=info@yogasoul.it MAIL_FROM_NAME="YogiBook"
|
||||
*/
|
||||
|
||||
if (!function_exists('sendEventCancellationMail')) {
|
||||
/**
|
||||
* @param string $toEmail Email destinatario
|
||||
* @param string $toName Nome destinatario
|
||||
* @param array $event Riga completa della tabella events
|
||||
*/
|
||||
function sendEventCancellationMail(string $toEmail, string $toName, array $event): bool
|
||||
{
|
||||
// ------------------------------------------------------------------
|
||||
// STEP SUCCESSIVO: sostituire il log qui sotto con l'invio reale.
|
||||
// Scheletro PHPMailer (richiede: composer require phpmailer/phpmailer
|
||||
// e un modo per leggere il .env, es. vlucas/phpdotenv oppure il
|
||||
// bootstrap Laravel già presente nel progetto):
|
||||
//
|
||||
// use PHPMailer\PHPMailer\PHPMailer;
|
||||
// $mail = new PHPMailer(true);
|
||||
// $mail->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;
|
||||
}
|
||||
}
|
||||
+60
-30
@@ -70,63 +70,93 @@
|
||||
</li>
|
||||
|
||||
<?php if (Auth::user()->hasRole('Admin')): ?>
|
||||
<li class="menu-title" data-key="t-applications">Amministrazione</li>
|
||||
|
||||
<li>
|
||||
<a href="admin-services.php" class="">
|
||||
<i class="bx bx-edit-alt icon nav-icon"></i>
|
||||
<span class="menu-item" data-key="t-email">Servizi</span>
|
||||
</a>
|
||||
</li>
|
||||
<!-- Classi & Prenotazioni -->
|
||||
<li class="menu-title" data-key="t-admin-classi">Classi & Prenotazioni</li>
|
||||
|
||||
<li>
|
||||
<a href="adminpanel.php" class="">
|
||||
<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>
|
||||
</li>
|
||||
<li>
|
||||
<a href="pendingbookedclass.php" class="">
|
||||
<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>
|
||||
</li>
|
||||
<li>
|
||||
<a href="dayoff.php" class="">
|
||||
<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>
|
||||
</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>
|
||||
<!-- Ordini -->
|
||||
<li class="menu-title" data-key="t-admin-ordini">Ordini</li>
|
||||
|
||||
<li>
|
||||
<a href="orderbooklist.php" class="">
|
||||
<i class="bx bx-list-check icon nav-icon"></i>
|
||||
<span class="menu-item" data-key="t-email">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>
|
||||
<span class="menu-item">Order Pending</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="ordersummary.php" class="">
|
||||
<i class="bx bx-receipt icon nav-icon"></i>
|
||||
<span class="menu-item" data-key="t-email">Sommario Ordini</span>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="optionsschool.php" class="">
|
||||
<i class="bx bx-receipt icon nav-icon"></i>
|
||||
<span class="menu-item" data-key="t-email">Opzioni Scuola</span>
|
||||
<span class="menu-item">Sommario Ordini</span>
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<!-- Report & Certificati -->
|
||||
<li class="menu-title" data-key="t-admin-report">Report & 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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- Template (demo) -->
|
||||
<li class="menu-title" data-key="t-applications">Template</li>
|
||||
|
||||
<li>
|
||||
|
||||
Reference in New Issue
Block a user