fixed shop

This commit is contained in:
2026-10-14 10:21:55 +02:00
parent 8b287bb81e
commit 14bd394982
15 changed files with 6312 additions and 12 deletions
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
/**
* Percorso "Primi passi" della scuola:
* setup_state JSON con i passi segnati a mano / saltati e l'ultimo avanzamento
* setup_completed_at quando l'owner ha chiuso il percorso (nasconde il promemoria)
* Le scuole già in uso (es. Seregno) vengono segnate come completate.
*/
final class AddSetupStateToOption extends AbstractMigration
{
public function up(): void
{
$this->table('option')
->addColumn('setup_state', 'text', ['null' => true, 'comment' => 'JSON percorso Primi passi'])
->addColumn('setup_completed_at', 'datetime', ['null' => true])
->update();
// Scuole che hanno già ordini o prenotazioni: percorso considerato concluso
$this->execute(
"UPDATE `option` o SET o.setup_completed_at = NOW()
WHERE EXISTS (SELECT 1 FROM orderbook ob WHERE ob.idschool = o.idschool)
OR EXISTS (SELECT 1 FROM bookingclass b WHERE b.idschool = o.idschool)"
);
}
public function down(): void
{
$this->table('option')
->removeColumn('setup_state')
->removeColumn('setup_completed_at')
->update();
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1
View File
@@ -32,6 +32,7 @@ class SchoolScope
'orderbook' => 'idorderbook',
'employee' => 'idemployee',
'dayoff' => 'iddayoff',
'expiryparameter' => 'idexpiryparameter',
'certificateuserprofile' => 'idcertificateuserprofile',
'events' => 'id',
'event_bookings' => 'id',
+303
View File
@@ -0,0 +1,303 @@
<?php
/**
* SchoolSetup
* Percorso "Primi passi" per l'owner di una nuova scuola.
* Ogni passo si considera fatto guardando i dati reali della scuola;
* pochi passi (da verificare a occhio) si segnano a mano. I passi facoltativi si possono saltare.
* Lo stato manuale e l'ultimo avanzamento stanno in option.setup_state (JSON).
*/
if (!class_exists('DBHandlerSelect')) {
require_once __DIR__ . '/db-functions.php';
}
class SchoolSetup
{
const PHASES = [
'school' => ['La tua scuola', 'Come ti presenti ad allieve e insegnanti'],
'classes' => ['Classi e calendario', 'Cosa si può prenotare e quando'],
'sell' => ['Vendita', 'Cosa vendi e come ti pagano'],
'test' => ['Prova finale', 'Fai un giro completo come farà un\'allieva'],
];
/** Stato manuale salvato: ['done' => [key => data], 'skip' => [key => data], 'progress' => 'x/y'] */
public static function state(int $idschool): array
{
$raw = (string) SchoolSettings::get('setup_state', '', $idschool);
$s = json_decode($raw, true);
return is_array($s) ? $s + ['done' => [], 'skip' => []] : ['done' => [], 'skip' => []];
}
public static function saveState(int $idschool, array $state): void
{
SchoolSettings::save(['setup_state' => json_encode($state, JSON_UNESCAPED_UNICODE)], $idschool);
}
public static function completed(int $idschool): bool
{
return (string) SchoolSettings::get('setup_completed_at', '', $idschool) !== '';
}
/** Elenco dei passi con stato calcolato */
public static function steps(int $idschool, int $userId): array
{
$pdo = DBHandlerSelect::getInstance()->getConnection();
$one = function (string $sql, array $args) use ($pdo) {
$st = $pdo->prepare($sql);
$st->execute($args);
return $st->fetchColumn();
};
$state = self::state($idschool);
$st = $pdo->prepare("SELECT * FROM schools WHERE idschool = ?");
$st->execute([$idschool]);
$sc = $st->fetch(PDO::FETCH_ASSOC) ?: [];
$filled = fn($k) => trim((string) ($sc[$k] ?? '')) !== '';
$teachers = (int) $one("SELECT COUNT(*) FROM employee WHERE idschool = ? AND active = 'Y'", [$idschool]);
$classes = (int) $one("SELECT COUNT(*) FROM service WHERE idschool = ?", [$idschool]);
$future = (int) $one("SELECT COUNT(*) FROM serviceschedule WHERE idschool = ? AND dateschedule > NOW()", [$idschool]);
$dayoffs = (int) $one("SELECT COUNT(*) FROM dayoff WHERE idschool = ? AND dayoffdate >= CURDATE()", [$idschool]);
$rules = (int) $one("SELECT COUNT(*) FROM expiryparameter WHERE idschool = ?", [$idschool]);
$products = (int) $one(
"SELECT COUNT(*) FROM products p WHERE p.idschool = ? AND p.active = 'Y'
AND EXISTS (SELECT 1 FROM product_variants v WHERE v.idproduct = p.idproduct AND v.active = 'Y')",
[$idschool]
);
$bankOk = SchoolSettings::get('bank_enabled', 'N', $idschool) === 'Y' && trim((string) SchoolSettings::get('bank_iban', '', $idschool)) !== '';
$cardOk = SchoolSettings::get('stripe_charges_enabled', 'N', $idschool) === 'Y';
$shopOn = SchoolSettings::get('shop_enabled', 'N', $idschool) === 'Y';
$testOrder = (int) $one("SELECT COUNT(*) FROM orderbook WHERE idschool = ? AND iduser = ? AND idproduct IS NOT NULL", [$idschool, $userId]);
$testDone = (int) $one("SELECT COUNT(*) FROM orderbook WHERE idschool = ? AND iduser = ? AND idproduct IS NOT NULL AND payment_status IN ('paid', 'cancelled')", [$idschool, $userId]);
$students = (int) $one("SELECT COUNT(*) FROM school_user WHERE idschool = ? AND school_role = 'student' AND active = 'Y' AND user_id <> ?", [$idschool, $userId]);
$sub = null;
if (is_file(__DIR__ . '/PlatformBilling.php')) {
require_once __DIR__ . '/PlatformBilling.php';
try {
$row = PlatformBilling::school($idschool);
$sub = $row ? PlatformBilling::state($row) : null;
} catch (Throwable $ex) {
$sub = null;
}
}
$steps = [
// ---------------- La scuola
[
'key' => 'profile',
'phase' => 'school',
'required' => true,
'title' => 'Dati della scuola',
'text' => 'Nome, email, indirizzo e città: compaiono nelle email alle allieve, nella pagina pubblica e nelle ricevute.',
'url' => 'optionsschool.php?tab=profile',
'cta' => 'Compila i dati',
'done' => $filled('name') && $filled('email') && $filled('address') && $filled('city'),
'detail' => $filled('email') ? '' : 'Manca l\'email della scuola: serve come mittente delle email.'
],
[
'key' => 'public',
'phase' => 'school',
'required' => false,
'title' => 'Logo e presentazione',
'text' => 'Il logo appare in alto nell\'app; slogan e descrizione nella pagina della scuola.',
'url' => 'optionsschool.php?tab=public',
'cta' => 'Carica il logo',
'done' => $filled('logo') && ($filled('short_description') || $filled('description'))
],
[
'key' => 'look',
'phase' => 'school',
'required' => false,
'manual' => true,
'title' => 'Colori dell\'app',
'text' => 'Scegli i colori della tua scuola. Se non li tocchi resta il tema YogiBook.',
'url' => 'optionsschool.php?tab=theme',
'cta' => 'Scegli i colori',
'done' => false
],
[
'key' => 'teachers',
'phase' => 'school',
'required' => true,
'title' => 'Insegnanti',
'text' => 'Aggiungi chi insegna: servono per assegnare le classi. Puoi inserire anche te stessa.',
'url' => 'insegnanti.php',
'cta' => 'Aggiungi insegnanti',
'done' => $teachers > 0,
'detail' => $teachers ? $teachers . ' attivi' : ''
],
// ---------------- Classi e calendario
[
'key' => 'classes',
'phase' => 'classes',
'required' => true,
'title' => 'Classi',
'text' => 'Crea le categorie (es. Hatha, Aerial) e le classi settimanali con giorno, orario, durata e posti.',
'url' => 'admin-services.php',
'cta' => 'Crea le classi',
'done' => $classes > 0,
'detail' => $classes ? $classes . ' classi' : ''
],
[
'key' => 'calendar',
'phase' => 'classes',
'required' => true,
'title' => 'Calendario delle lezioni',
'text' => 'Genera le lezioni in calendario per le prossime settimane: sono quelle che le allieve prenotano.',
'url' => 'admin-services.php',
'cta' => 'Programma le lezioni',
'done' => $future > 0,
'detail' => $future ? $future . ' lezioni in calendario' : ''
],
[
'key' => 'dayoff',
'phase' => 'classes',
'required' => false,
'title' => 'Giorni di chiusura',
'text' => 'Festività e chiusure: in quei giorni non si prenota e i pacchetti non scalano lezioni.',
'url' => 'dayoff.php',
'cta' => 'Aggiungi chiusure',
'done' => $dayoffs > 0
],
[
'key' => 'booking',
'phase' => 'classes',
'required' => true,
'manual' => true,
'title' => 'Regole di prenotazione',
'text' => 'Lista d\'attesa, insegnante visibile, ore minime per annullare, avviso per le allieve. Controllale e segna il passo come fatto.',
'url' => 'optionsschool.php?tab=booking',
'cta' => 'Controlla le regole',
'done' => false
],
[
'key' => 'packages',
'phase' => 'classes',
'required' => true,
'title' => 'Scadenza e recuperi dei pacchetti',
'text' => 'Per ogni numero di lezioni: dopo quante lezioni scade il pacchetto e quanti recuperi sono ammessi.',
'url' => 'package_rules.php',
'cta' => 'Imposta le regole',
'done' => $rules > 0,
'detail' => $rules ? $rules . ' regole' : ''
],
// ---------------- Vendita
[
'key' => 'products',
'phase' => 'sell',
'required' => true,
'title' => 'Prodotti',
'text' => 'Cosa vendi: pacchetti a classe fissa, carnet, lezione di prova, tessera, buoni regalo. Con foto, prezzi e opzioni.',
'url' => 'products.php',
'cta' => 'Crea i prodotti',
'done' => $products > 0,
'detail' => $products ? $products . ' in vendita' : ''
],
[
'key' => 'payments',
'phase' => 'sell',
'required' => true,
'title' => 'Pagamenti',
'text' => 'Attiva il bonifico (intestatario e IBAN) e, quando disponibile, la carta. Imposta anche le condizioni di acquisto.',
'url' => 'optionsschool.php?tab=shop',
'cta' => 'Configura i pagamenti',
'done' => $bankOk || $cardOk,
'detail' => trim(($bankOk ? 'Bonifico' : '') . ($bankOk && $cardOk ? ' · ' : '') . ($cardOk ? 'Carta' : ''))
],
[
'key' => 'shop',
'phase' => 'sell',
'required' => true,
'title' => 'Apri il negozio',
'text' => 'Accendi "Negozio attivo": da quel momento le allieve possono acquistare dall\'app.',
'url' => 'optionsschool.php?tab=shop',
'cta' => 'Attiva il negozio',
'done' => $shopOn
],
[
'key' => 'subscription',
'phase' => 'sell',
'required' => false,
'title' => 'Abbonamento YogiBook',
'text' => 'Controlla il tuo piano e la scadenza della prova.',
'url' => 'subscription.php',
'cta' => 'Vedi l\'abbonamento',
'done' => $sub !== null && in_array($sub['code'], ['active', 'expiring', 'exempt'], true),
'detail' => $sub ? $sub['label'] . ($sub['ends_on'] ? ' fino al ' . date('d/m/Y', strtotime($sub['ends_on'])) : '') : ''
],
// ---------------- Prova finale
[
'key' => 'test_buy',
'phase' => 'test',
'required' => true,
'title' => 'Acquista come un\'allieva',
'text' => 'Dal selettore Vista in alto nel menu passa a "Allievo", apri il Negozio e compra un prodotto con bonifico. Controlla riepilogo, causale e pagina di conferma.',
'url' => 'shop.php',
'cta' => 'Apri il negozio',
'done' => $testOrder > 0
],
[
'key' => 'test_confirm',
'phase' => 'test',
'required' => true,
'title' => 'Gestisci l\'ordine di prova',
'text' => 'Torna alla vista "Scuola", apri Ordini: segna il pagamento come ricevuto (e propagalo se è a classe fissa) oppure annullalo.',
'url' => 'school_orders.php',
'cta' => 'Apri gli ordini',
'done' => $testDone > 0
],
[
'key' => 'test_book',
'phase' => 'test',
'required' => false,
'manual' => true,
'title' => 'Prenota e annulla una lezione',
'text' => 'Sempre come allieva, prenota una lezione dal pannello Prenotazioni e poi annullala: verifichi posti e regole di cancellazione.',
'url' => 'userpanel.php',
'cta' => 'Apri le prenotazioni',
'done' => false
],
[
'key' => 'invite',
'phase' => 'test',
'required' => false,
'manual' => true,
'title' => 'Invita le tue allieve',
'text' => 'Comunica alle allieve l\'indirizzo di YogiBook per registrarsi e scegli tu quando aprire il negozio a tutte.',
'url' => '',
'cta' => '',
'done' => $students > 0,
'detail' => $students ? $students . ' allieve iscritte' : ''
],
];
foreach ($steps as &$s) {
$s += ['manual' => false, 'detail' => ''];
$s['auto'] = $s['done'];
$s['done'] = $s['done'] || isset($state['done'][$s['key']]);
$s['skipped'] = !$s['done'] && !$s['required'] && isset($state['skip'][$s['key']]);
}
unset($s);
return $steps;
}
/** [fatti (o saltati), totale, obbligatori mancanti] */
public static function progress(array $steps): array
{
$done = 0;
$missing = 0;
foreach ($steps as $s) {
if ($s['done'] || $s['skipped']) {
$done++;
} elseif ($s['required']) {
$missing++;
}
}
return [$done, count($steps), $missing];
}
}
+976
View File
@@ -0,0 +1,976 @@
<?php
/**
* ShopService
* Logica del negozio della scuola in un unico posto: catalogo, classi valide,
* posti liberi, situazione dell'allieva. Usata da:
* - shop.php (negozio dell'allieva nella webapp)
* - in seguito: checkout, API pubblica per WordPress (apiwp), app mobile
*
* Tutti i metodi ricevono $idschool esplicito: funziona anche senza sessione.
* Posti occupati = prenotazioni 'booked' + 'pending' della lezione (come BookingRules).
*/
if (!class_exists('DBHandlerSelect')) {
require_once __DIR__ . '/db-functions.php';
}
class ShopService
{
/** Giorni di calendario considerati per i posti delle classi fisse */
const LOOKAHEAD_DAYS = 120;
/** Lezioni singole/prova acquistabili nei prossimi N giorni */
const SINGLE_DAYS = 21;
/** Massimo di lezioni future lette per ogni classe */
const MAX_OCCURRENCES = 30;
const GIORNI = ['domenica', 'lunedì', 'martedì', 'mercoledì', 'giovedì', 'venerdì', 'sabato'];
const MESI = [1 => 'gennaio', 'febbraio', 'marzo', 'aprile', 'maggio', 'giugno', 'luglio', 'agosto', 'settembre', 'ottobre', 'novembre', 'dicembre'];
/**
* Catalogo completo, pronto per json_encode (lo stesso formato servirà all'API WordPress).
*
* @param int $idschool
* @param int|null $userId allieva loggata (per prova già usata, tessera, pacchetti attivi)
* @param bool $onlineOnly true = solo prodotti "acquistabili online"
*/
public static function catalog(int $idschool, ?int $userId = null, bool $onlineOnly = true): array
{
$pdo = self::pdo();
// ---- Prodotti e opzioni
$st = $pdo->prepare(
"SELECT * FROM products WHERE idschool = ? AND active = 'Y'" . ($onlineOnly ? " AND visible_online = 'Y'" : "") . "
ORDER BY FIELD(kind, 'lessons', 'membership', 'voucher'), sort_order, title"
);
$st->execute([$idschool]);
$rows = $st->fetchAll(PDO::FETCH_ASSOC);
$variantsBy = [];
$st = $pdo->prepare("SELECT * FROM product_variants WHERE idschool = ? AND active = 'Y' ORDER BY sort_order, idvariant");
$st->execute([$idschool]);
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $v) {
$variantsBy[(int) $v['idproduct']][] = $v;
}
$linksBy = [];
$st = $pdo->prepare("SELECT idproduct, idservice, idservicecategory FROM product_services WHERE idschool = ?");
$st->execute([$idschool]);
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $l) {
$linksBy[(int) $l['idproduct']][] = $l;
}
$galleryBy = [];
$st = $pdo->prepare("SELECT idproduct, filename FROM product_images WHERE idschool = ? ORDER BY sort_order, idimage");
$st->execute([$idschool]);
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $g) {
$galleryBy[(int) $g['idproduct']][] = $g['filename'];
}
$variantLabel = [];
$st = $pdo->prepare("SELECT v.idvariant, v.label, p.title FROM product_variants v JOIN products p ON p.idproduct = v.idproduct WHERE v.idschool = ?");
$st->execute([$idschool]);
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) {
$variantLabel[(int) $r['idvariant']] = $r['title'] . ' · ' . $r['label'];
}
// ---- Classi della scuola
$showTeacher = SchoolSettings::get('show_teacher', 'N', $idschool) === 'Y';
$st = $pdo->prepare(
"SELECT s.idservice, s.servicename, s.day, s.time, s.category, s.maxcapacity, s.colorclass, s.classduration,
TRIM(CONCAT(COALESCE(em.nameemployee, ''), ' ', COALESCE(em.surnameemployee, ''))) AS teacher
FROM service s
LEFT JOIN employee em ON em.idemployee = s.idteacher AND em.idschool = s.idschool
WHERE s.idschool = ?"
);
$st->execute([$idschool]);
$servicesAll = [];
$byCategory = [];
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $s) {
$servicesAll[(int) $s['idservice']] = $s;
$byCategory[(int) $s['category']][] = (int) $s['idservice'];
}
$st = $pdo->prepare("SELECT idservicecategory, namecategory FROM servicecategory WHERE idschool = ? ORDER BY namecategory");
$st->execute([$idschool]);
$catNames = [];
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $c) {
$catNames[(int) $c['idservicecategory']] = $c['namecategory'];
}
// ---- Situazione dell'allieva
$user = self::userContext($idschool, $userId);
// ---- Costruzione prodotti
$products = [];
$usedSvcs = [];
$usedCats = [];
foreach ($rows as $p) {
$pid = (int) $p['idproduct'];
if (empty($variantsBy[$pid])) {
continue; // nessuna opzione attiva = non acquistabile
}
$svc = [];
foreach ($linksBy[$pid] ?? [] as $l) {
if (!empty($l['idservice']) && isset($servicesAll[(int) $l['idservice']])) {
$svc[] = (int) $l['idservice'];
}
if (!empty($l['idservicecategory'])) {
$svc = array_merge($svc, $byCategory[(int) $l['idservicecategory']] ?? []);
}
}
$svc = array_values(array_unique($svc));
if ($p['kind'] === 'lessons' && !$svc) {
continue; // pacchetto senza classi: non vendibile
}
$cats = [];
foreach ($svc as $sid) {
$cid = (int) $servicesAll[$sid]['category'];
if (isset($catNames[$cid])) {
$cats[$cid] = true;
}
}
$cats = array_keys($cats);
if ($p['kind'] === 'lessons') {
$usedSvcs = array_merge($usedSvcs, $svc);
$usedCats = array_merge($usedCats, $cats);
}
$variants = [];
foreach ($variantsBy[$pid] as $v) {
$variants[] = [
'id' => (int) $v['idvariant'],
'label' => $v['label'],
'lessons' => $v['lessons'] !== null ? (int) $v['lessons'] : null,
'price' => (float) $v['price'],
'compare' => $v['compare_price'] !== null ? (float) $v['compare_price'] : null,
'note' => $v['price_note'],
'vmode' => $v['validity_mode'],
'vvalue' => $v['validity_value'] !== null ? (int) $v['validity_value'] : null,
'vunit' => $v['validity_unit'],
'vstart' => $v['validity_start'],
'target' => $v['target_idvariant'] !== null ? (int) $v['target_idvariant'] : null,
'targetLabel' => $v['target_idvariant'] !== null ? ($variantLabel[(int) $v['target_idvariant']] ?? '') : '',
];
}
$mode = $p['kind'] === 'lessons' ? $p['booking_mode'] : $p['kind'];
$products[] = [
'id' => $pid,
'slug' => $p['slug'],
'kind' => $p['kind'],
'mode' => $mode, // fixed | free | single | membership | voucher
'group' => self::group($p), // per i filtri del catalogo
'slots' => (int) $p['fixed_slots'],
'weekly' => $p['weekly_limit'] !== null ? (int) $p['weekly_limit'] : null,
'maxres' => $p['max_reschedule'] !== null ? (int) $p['max_reschedule'] : null,
'once' => $p['once_per_student'] === 'Y',
'title' => $p['title'],
'subtitle' => (string) $p['subtitle'],
'badge' => (string) $p['badge'],
'cover' => (string) $p['cover_image'],
'gallery' => $galleryBy[$pid] ?? [],
'description' => (string) $p['description'], // HTML già ripulito al salvataggio
'suitable' => (string) $p['suitable_for'],
'requirements' => (string) $p['requirements'],
'pros' => self::lines($p['pros']),
'cons' => self::lines($p['cons']),
'services' => $svc,
'cats' => $cats,
'variants' => $variants,
'blocked' => ($p['once_per_student'] === 'Y' && in_array($pid, $user['usedProducts'], true)) ? 'used' : null,
];
}
// ---- Calendario e posti delle classi usate dai pacchetti
$services = self::servicesWithOccurrences($idschool, array_values(array_unique($usedSvcs)), $servicesAll, $showTeacher, $userId);
// ---- Discipline (categorie) presenti nel catalogo
$categories = [];
foreach (array_unique($usedCats) as $cid) {
$categories[] = ['id' => $cid, 'name' => $catNames[$cid]];
}
usort($categories, fn($a, $b) => strcasecmp($a['name'], $b['name']));
// ---- Date utili (tessere)
$seasonStart = max(1, min(12, (int) SchoolSettings::get('membership_season_start', 9, $idschool)));
$today = new DateTimeImmutable('today');
$y = (int) $today->format('Y');
$endYear = ((int) $today->format('n') >= $seasonStart) ? $y + 1 : $y;
$seasonEnd = (new DateTimeImmutable(sprintf('%04d-%02d-01', $endYear, $seasonStart)))->modify('-1 day');
// Prodotto tessera da proporre se serve
$user['membershipProduct'] = null;
foreach ($products as $p) {
if ($p['kind'] === 'membership') {
$user['membershipProduct'] = $p['id'];
break;
}
}
unset($user['usedProducts']);
return [
'today' => $today->format('Y-m-d'),
'yearEnd' => $y . '-12-31',
'seasonEnd' => $seasonEnd->format('Y-m-d'),
'singleDays' => self::SINGLE_DAYS,
'products' => $products,
'services' => (object) $services,
'categories' => $categories,
'user' => $user,
];
}
/** Gruppo del catalogo: single | fixed | free | membership | voucher */
public static function group(array $p): string
{
return $p['kind'] === 'lessons' ? (string) $p['booking_mode'] : (string) $p['kind'];
}
/**
* Situazione dell'allieva nella scuola: prodotti "una volta" già acquistati,
* tessera valida, pacchetti attivi.
*/
public static function userContext(int $idschool, ?int $userId): array
{
$required = SchoolSettings::get('membership_required', 'N', $idschool) === 'Y';
$ctx = [
'logged' => (bool) $userId,
'membershipRequired' => $required,
'hasMembership' => false,
'membershipUntil' => null,
'packages' => [],
'usedProducts' => [],
];
if (!$userId) {
return $ctx;
}
$pdo = self::pdo();
$st = $pdo->prepare(
"SELECT DISTINCT idproduct FROM orderbook
WHERE iduser = ? AND idschool = ? AND idproduct IS NOT NULL
AND (payment_status IS NULL OR payment_status NOT IN ('cancelled', 'refunded'))"
);
$st->execute([$userId, $idschool]);
$ctx['usedProducts'] = array_map('intval', $st->fetchAll(PDO::FETCH_COLUMN));
$st = $pdo->prepare(
"SELECT MAX(valid_to) FROM memberships
WHERE iduser = ? AND idschool = ? AND status = 'active' AND valid_from <= CURDATE() AND valid_to >= CURDATE()"
);
$st->execute([$userId, $idschool]);
$until = $st->fetchColumn();
$ctx['hasMembership'] = (bool) $until;
$ctx['membershipUntil'] = $until ?: null;
// Pacchetti attivi: non scaduti e con lezioni rimaste
$st = $pdo->prepare(
"SELECT ob.idorderbook, ob.product_name, ob.nticket, ob.quantityclass, ob.expireon, ob.payment_status,
s.servicename,
(SELECT COUNT(*) FROM bookingclass b
WHERE b.idorder = ob.idorderbook AND b.idschool = ob.idschool
AND b.status <> 'cancelled' AND b.is_gift = 'N') AS used
FROM orderbook ob
LEFT JOIN service s ON s.idservice = ob.idservice
WHERE ob.iduser = ? AND ob.idschool = ?
AND (ob.expireon IS NULL OR ob.expireon >= CURDATE())
AND (ob.payment_status IS NULL OR ob.payment_status NOT IN ('cancelled', 'refunded'))
ORDER BY ob.expireon IS NULL, ob.expireon"
);
$st->execute([$userId, $idschool]);
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $o) {
$tot = (int) $o['nticket'] > 0 ? (int) $o['nticket'] : (int) $o['quantityclass'];
$left = $tot > 0 ? max(0, $tot - (int) $o['used']) : null;
if ($left === 0) {
continue;
}
$ctx['packages'][] = [
'title' => (string) ($o['product_name'] ?: $o['servicename'] ?: 'Pacchetto'),
'left' => $left,
'expire' => $o['expireon'],
'pending' => $o['payment_status'] === 'pending',
];
}
return $ctx;
}
/**
* Lezioni future delle classi indicate, con posti liberi.
* @return array idservice => [name, day, teacher, cap, cat, occ => [[id, date, time, label, free, mine]]]
*/
public static function servicesWithOccurrences(int $idschool, array $ids, array $servicesAll, bool $showTeacher, ?int $userId = null): array
{
$out = [];
foreach ($ids as $sid) {
if (!isset($servicesAll[$sid])) {
continue;
}
$s = $servicesAll[$sid];
$out[$sid] = [
'name' => $s['servicename'],
'day' => trim($s['day'] . ' ' . $s['time']),
'teacher' => $showTeacher ? trim((string) $s['teacher']) : '',
'cap' => ($s['maxcapacity'] !== null && (int) $s['maxcapacity'] > 0) ? (int) $s['maxcapacity'] : null,
'cat' => (int) $s['category'],
'color' => preg_match('/^#[0-9a-fA-F]{6}$/', (string) ($s['colorclass'] ?? '')) ? strtolower($s['colorclass']) : null,
'minutes' => (int) round((float) ($s['classduration'] ?? 0) * ((float) ($s['classduration'] ?? 0) <= 5 ? 60 : 1)),
'occ' => [],
];
}
if (!$out) {
return $out;
}
$ph = implode(',', array_fill(0, count($out), '?'));
$st = self::pdo()->prepare(
"SELECT ss.idserviceschedule, ss.idservice, ss.dateschedule,
(SELECT COUNT(*) FROM bookingclass b
WHERE b.idserviceschedule = ss.idserviceschedule AND b.idschool = ss.idschool
AND b.status IN ('booked', 'pending')) AS taken
" . ($userId ? ", (SELECT COUNT(*) FROM bookingclass b2
WHERE b2.idserviceschedule = ss.idserviceschedule AND b2.idschool = ss.idschool
AND b2.iduser = ? AND b2.status <> 'cancelled') AS mine" : ", 0 AS mine") . "
FROM serviceschedule ss
WHERE ss.idschool = ?
AND ss.idservice IN ($ph)
AND ss.dateschedule > NOW()
AND ss.dateschedule < CURDATE() + INTERVAL " . (int) self::LOOKAHEAD_DAYS . " DAY
AND NOT EXISTS (SELECT 1 FROM dayoff d WHERE d.idschool = ss.idschool AND d.dayoffdate = DATE(ss.dateschedule))
ORDER BY ss.dateschedule"
);
$args = $userId ? [$userId, $idschool] : [$idschool];
$st->execute(array_merge($args, array_keys($out)));
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $o) {
$sid = (int) $o['idservice'];
if (count($out[$sid]['occ']) >= self::MAX_OCCURRENCES) {
continue;
}
$ts = strtotime($o['dateschedule']);
$cap = $out[$sid]['cap'];
$out[$sid]['occ'][] = [
'id' => (int) $o['idserviceschedule'],
'date' => date('Y-m-d', $ts),
'time' => date('H:i', $ts),
'label' => self::dateLabel($ts),
'free' => $cap === null ? null : max(0, $cap - (int) $o['taken']),
'mine' => (int) $o['mine'] > 0,
];
}
return $out;
}
/** "Martedì 13 ottobre · 18:00" */
public static function dateLabel(int $ts): string
{
return ucfirst(self::GIORNI[(int) date('w', $ts)]) . ' ' . date('j', $ts) . ' ' . self::MESI[(int) date('n', $ts)] . ' · ' . date('H:i', $ts);
}
public static function lines(?string $t): array
{
return array_values(array_filter(array_map('trim', preg_split('/\r\n|\r|\n/', (string) $t))));
}
/* ==================================================================
* CHECKOUT
* ================================================================ */
/** Metodi di pagamento attivi per la scuola */
public static function paymentMethods(int $idschool): array
{
$bank = SchoolSettings::get('bank_enabled', 'N', $idschool) === 'Y'
&& trim((string) SchoolSettings::get('bank_holder', '', $idschool)) !== ''
&& trim((string) SchoolSettings::get('bank_iban', '', $idschool)) !== '';
// La carta arriverà con il collegamento Stripe Connect della scuola
$card = SchoolSettings::get('stripe_charges_enabled', 'N', $idschool) === 'Y'
&& trim((string) SchoolSettings::get('stripe_account_id', '', $idschool)) !== '';
return ['bank' => $bank, 'card' => $card];
}
/**
* Ricontrolla lato server la scelta arrivata dal negozio.
* $in: idvariant, services (array di idservice), idschedule, recipient_name, recipient_email, recipient_message
* @return array product, variant, services, schedule, recipient
* @throws RuntimeException con messaggio per l'allieva
*/
public static function selection(int $idschool, int $userId, array $in): array
{
$pdo = self::pdo();
$st = $pdo->prepare(
"SELECT v.*, p.kind, p.title, p.booking_mode, p.fixed_slots, p.weekly_limit, p.max_reschedule,
p.once_per_student, p.active AS p_active, p.visible_online, p.slug
FROM product_variants v
JOIN products p ON p.idproduct = v.idproduct AND p.idschool = v.idschool
WHERE v.idvariant = ? AND v.idschool = ?"
);
$st->execute([(int) ($in['idvariant'] ?? 0), $idschool]);
$row = $st->fetch(PDO::FETCH_ASSOC);
if (!$row || $row['active'] !== 'Y' || $row['p_active'] !== 'Y' || $row['visible_online'] !== 'Y') {
throw new RuntimeException('Questo prodotto non è più disponibile.');
}
$pid = (int) $row['idproduct'];
$mode = $row['kind'] === 'lessons' ? $row['booking_mode'] : $row['kind'];
if ($row['once_per_student'] === 'Y') {
$st = $pdo->prepare(
"SELECT 1 FROM orderbook WHERE iduser = ? AND idschool = ? AND idproduct = ?
AND (payment_status IS NULL OR payment_status NOT IN ('cancelled', 'refunded')) LIMIT 1"
);
$st->execute([$userId, $idschool, $pid]);
if ($st->fetchColumn()) {
throw new RuntimeException('Hai già utilizzato questo prodotto: si può acquistare una sola volta.');
}
}
$allowed = self::productServiceIds($idschool, $pid);
$sel = [
'product' => $row,
'mode' => $mode,
'variant' => $row,
'services' => [],
'schedule' => null,
'recipient' => null,
];
if ($mode === 'fixed') {
$slots = (int) $row['fixed_slots'] === 2 ? 2 : 1;
$ids = array_values(array_unique(array_filter(array_map('intval', (array) ($in['services'] ?? [])))));
if (count($ids) !== $slots) {
throw new RuntimeException($slots === 2 ? 'Scegli le tue due classi.' : 'Scegli la tua classe.');
}
$weeks = $row['lessons'] ? (int) ceil((int) $row['lessons'] / $slots) : 8;
foreach ($ids as $sid) {
if (!in_array($sid, $allowed, true)) {
throw new RuntimeException('Una delle classi scelte non è inclusa in questo prodotto.');
}
$free = self::fixedFree($idschool, $sid, $weeks);
if ($free === -1) {
throw new RuntimeException('Una delle classi scelte non ha lezioni in calendario.');
}
if ($free === 0) {
throw new RuntimeException('Una delle classi scelte è al completo. Torna indietro e scegline un\'altra.');
}
}
$sel['services'] = $ids;
}
if ($mode === 'single') {
$st = $pdo->prepare(
"SELECT ss.*, s.servicename, s.maxcapacity FROM serviceschedule ss
JOIN service s ON s.idservice = ss.idservice
WHERE ss.idserviceschedule = ? AND ss.idschool = ? AND ss.dateschedule > NOW()"
);
$st->execute([(int) ($in['idschedule'] ?? 0), $idschool]);
$sch = $st->fetch(PDO::FETCH_ASSOC);
if (!$sch || !in_array((int) $sch['idservice'], $allowed, true)) {
throw new RuntimeException('La lezione scelta non è più disponibile.');
}
$st = $pdo->prepare("SELECT 1 FROM bookingclass WHERE idserviceschedule = ? AND iduser = ? AND idschool = ? AND status <> 'cancelled' LIMIT 1");
$st->execute([(int) $sch['idserviceschedule'], $userId, $idschool]);
if ($st->fetchColumn()) {
throw new RuntimeException('Sei già iscritta a questa lezione.');
}
if ((int) $sch['maxcapacity'] > 0 && self::occupied($idschool, (int) $sch['idserviceschedule']) >= (int) $sch['maxcapacity']) {
throw new RuntimeException('La lezione scelta è al completo. Torna indietro e scegli un\'altra data.');
}
$sel['schedule'] = $sch;
}
if ($mode === 'voucher') {
$name = trim((string) ($in['recipient_name'] ?? ''));
$email = trim((string) ($in['recipient_email'] ?? ''));
if ($name === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new RuntimeException('Indica nome ed email di chi riceve il buono.');
}
if (empty($row['target_idvariant'])) {
throw new RuntimeException('Questo buono non è configurato correttamente. Contatta la scuola.');
}
$sel['recipient'] = [
'name' => mb_substr($name, 0, 191),
'email' => mb_substr($email, 0, 191),
'message' => mb_substr(trim((string) ($in['recipient_message'] ?? '')), 0, 500),
];
}
return $sel;
}
/** idservice validi per un prodotto (classi singole + categorie intere) */
public static function productServiceIds(int $idschool, int $idproduct): array
{
$st = self::pdo()->prepare(
"SELECT DISTINCT s.idservice
FROM product_services ps
JOIN service s ON s.idschool = ps.idschool
AND (s.idservice = ps.idservice OR (ps.idservicecategory IS NOT NULL AND s.category = ps.idservicecategory))
WHERE ps.idproduct = ? AND ps.idschool = ?"
);
$st->execute([$idproduct, $idschool]);
return array_map('intval', $st->fetchAll(PDO::FETCH_COLUMN));
}
public static function occupied(int $idschool, int $idschedule): int
{
$st = self::pdo()->prepare(
"SELECT COUNT(*) FROM bookingclass WHERE idserviceschedule = ? AND idschool = ? AND status IN ('booked', 'pending')"
);
$st->execute([$idschedule, $idschool]);
return (int) $st->fetchColumn();
}
/**
* Posti liberi minimi sulle prossime $weeks lezioni di una classe.
* null = senza limite, -1 = nessuna lezione in calendario
*/
public static function fixedFree(int $idschool, int $idservice, int $weeks): ?int
{
$pdo = self::pdo();
$st = $pdo->prepare("SELECT maxcapacity FROM service WHERE idservice = ? AND idschool = ?");
$st->execute([$idservice, $idschool]);
$cap = (int) $st->fetchColumn();
$st = $pdo->prepare(
"SELECT (SELECT COUNT(*) FROM bookingclass b WHERE b.idserviceschedule = ss.idserviceschedule
AND b.idschool = ss.idschool AND b.status IN ('booked', 'pending')) AS taken
FROM serviceschedule ss
WHERE ss.idservice = ? AND ss.idschool = ? AND ss.dateschedule > NOW()
AND NOT EXISTS (SELECT 1 FROM dayoff d WHERE d.idschool = ss.idschool AND d.dayoffdate = DATE(ss.dateschedule))
ORDER BY ss.dateschedule
LIMIT " . max(1, $weeks)
);
$st->execute([$idservice, $idschool]);
$taken = array_map('intval', $st->fetchAll(PDO::FETCH_COLUMN));
if (!$taken) {
return -1;
}
return $cap > 0 ? max(0, $cap - max($taken)) : null;
}
/**
* Verifica un codice sconto per l'opzione scelta (l'utente è sempre quello della sessione).
* @return array idcoupon, code, discount
* @throws RuntimeException
*/
public static function checkCoupon(int $idschool, int $userId, string $code, array $variant): array
{
$code = strtoupper(trim($code));
$price = (float) $variant['price'];
$pdo = self::pdo();
$st = $pdo->prepare("SELECT * FROM coupons WHERE idschool = ? AND code = ? AND active = 'Y' LIMIT 1");
$st->execute([$idschool, $code]);
$c = $st->fetch(PDO::FETCH_ASSOC);
$today = date('Y-m-d');
if (!$c || ($c['valid_from'] && $today < $c['valid_from']) || ($c['valid_to'] && $today > $c['valid_to'])) {
throw new RuntimeException('Codice non valido o scaduto.');
}
$st = $pdo->prepare("SELECT idproduct, idvariant FROM coupon_products WHERE idcoupon = ? AND idschool = ?");
$st->execute([(int) $c['idcoupon'], $idschool]);
$limits = $st->fetchAll(PDO::FETCH_ASSOC);
if ($limits) {
$ok = false;
foreach ($limits as $l) {
if (
(int) $l['idvariant'] === (int) $variant['idvariant']
|| ((int) $l['idproduct'] === (int) $variant['idproduct'] && empty($l['idvariant']))
) {
$ok = true;
}
}
if (!$ok) {
throw new RuntimeException('Questo codice non vale su questo prodotto.');
}
}
if ($c['min_amount'] !== null && $price < (float) $c['min_amount']) {
throw new RuntimeException('Codice valido per acquisti da ' . number_format((float) $c['min_amount'], 2, ',', '.') . ' €.');
}
if ($c['max_uses'] !== null) {
$st = $pdo->prepare("SELECT COUNT(*) FROM coupon_redemptions WHERE idcoupon = ? AND idschool = ?");
$st->execute([(int) $c['idcoupon'], $idschool]);
if ((int) $st->fetchColumn() >= (int) $c['max_uses']) {
throw new RuntimeException('Codice esaurito.');
}
}
if ($c['max_uses_per_user'] !== null) {
$st = $pdo->prepare("SELECT COUNT(*) FROM coupon_redemptions WHERE idcoupon = ? AND idschool = ? AND iduser = ?");
$st->execute([(int) $c['idcoupon'], $idschool, $userId]);
if ((int) $st->fetchColumn() >= (int) $c['max_uses_per_user']) {
throw new RuntimeException('Hai già usato questo codice.');
}
}
if ($c['new_students_only'] === 'Y') {
$st = $pdo->prepare("SELECT COUNT(*) FROM orderbook WHERE iduser = ? AND idschool = ?");
$st->execute([$userId, $idschool]);
if ((int) $st->fetchColumn() > 0) {
throw new RuntimeException('Codice riservato alle nuove allieve.');
}
}
$value = (float) $c['discount_value'];
$discount = $c['discount_type'] === 'fixed' ? min($value, $price) : round($price * min($value, 100) / 100, 2);
return ['idcoupon' => (int) $c['idcoupon'], 'code' => $c['code'], 'discount' => $discount];
}
/** Scadenza della tessera per un'opzione (Y-m-d o null) */
public static function membershipEnd(int $idschool, array $variant, ?string $from = null): ?string
{
$from = $from ?? date('Y-m-d');
if ($variant['validity_mode'] === 'calendar_year') {
return substr($from, 0, 4) . '-12-31';
}
if ($variant['validity_mode'] === 'season') {
$start = max(1, min(12, (int) SchoolSettings::get('membership_season_start', 9, $idschool)));
$y = (int) substr($from, 0, 4);
$endYear = ((int) substr($from, 5, 2) >= $start) ? $y + 1 : $y;
return (new DateTimeImmutable(sprintf('%04d-%02d-01', $endYear, $start)))->modify('-1 day')->format('Y-m-d');
}
if (!empty($variant['validity_value'])) {
return self::addPeriod($from, (int) $variant['validity_value'], (string) $variant['validity_unit'])->modify('-1 day')->format('Y-m-d');
}
return null;
}
public static function addPeriod(string $from, int $n, string $unit): DateTimeImmutable
{
$d = new DateTimeImmutable($from);
if ($unit === 'days') {
return $d->modify('+' . $n . ' days');
}
if ($unit === 'weeks') {
return $d->modify('+' . ($n * 7) . ' days');
}
// mesi senza sforare (31 gennaio + 1 mese = fine febbraio)
$y = (int) $d->format('Y');
$m = (int) $d->format('n') + $n;
$y += intdiv($m - 1, 12);
$m = (($m - 1) % 12) + 1;
$last = (int) (new DateTimeImmutable(sprintf('%04d-%02d-01', $y, $m)))->format('t');
return $d->setDate($y, $m, min((int) $d->format('j'), $last));
}
/**
* Crea l'ordine (orderbook) e tutto ciò che ne deriva, in un'unica transazione.
* - classe fissa: orderbook_services (le prenotazioni le crea la scuola alla propagazione)
* - lezione singola: prenotazione subito (bonifico = 'pending', carta = 'booked')
* - buono regalo: gift_vouchers in attesa del pagamento
* - tessera: la riga in memberships nasce alla conferma del pagamento
* @param string $method 'bank' | 'stripe'
* @return int idorderbook
*/
public static function createOrder(int $idschool, int $userId, array $sel, string $method, ?array $coupon): int
{
$pdo = self::pdo();
$p = $sel['product'];
$v = $sel['variant'];
$mode = $sel['mode'];
$paid = $method === 'stripe';
$st = $pdo->prepare("SELECT first_name, last_name, email FROM auth_users WHERE id = ?");
$st->execute([$userId]);
$u = $st->fetch(PDO::FETCH_ASSOC) ?: ['first_name' => '', 'last_name' => '', 'email' => ''];
$price = round((float) $v['price'], 2);
$discount = $coupon ? min(round((float) $coupon['discount'], 2), $price) : 0.0;
$total = round($price - $discount, 2);
$lessons = $v['lessons'] !== null ? (int) $v['lessons'] : null;
$today = date('Y-m-d');
// Scadenza
$expire = null;
if ($mode === 'free' && $v['validity_mode'] === 'duration' && $v['validity_value'] && $v['validity_start'] === 'purchase') {
$expire = self::addPeriod($today, (int) $v['validity_value'], (string) $v['validity_unit'])->format('Y-m-d');
} elseif ($mode === 'single' && $sel['schedule']) {
$expire = substr($sel['schedule']['dateschedule'], 0, 10);
} elseif ($mode === 'membership') {
$expire = self::membershipEnd($idschool, $v, $today);
}
// Recuperi: dal prodotto, altrimenti dai parametri della scuola per quel numero di lezioni
$maxres = $p['max_reschedule'] !== null ? (int) $p['max_reschedule'] : null;
if ($maxres === null && $lessons && in_array($mode, ['fixed', 'free'], true)) {
$st = $pdo->prepare("SELECT reschedulenumber FROM expiryparameter WHERE quantityclass = ? AND idschool = ? LIMIT 1");
$st->execute([$lessons, $idschool]);
$r = $st->fetchColumn();
$maxres = $r !== false ? (int) $r : null;
}
if ($mode === 'single') {
$maxres = 0;
}
$idservice = null;
if ($mode === 'fixed') {
$idservice = $sel['services'][0];
} elseif ($mode === 'single') {
$idservice = (int) $sel['schedule']['idservice'];
}
$snapshot = [
'mode' => $mode,
'fixed_slots' => (int) $p['fixed_slots'],
'weekly_limit' => $p['weekly_limit'] !== null ? (int) $p['weekly_limit'] : null,
'max_reschedule' => $maxres,
'lessons' => $lessons,
'validity_mode' => $v['validity_mode'],
'validity_value' => $v['validity_value'] !== null ? (int) $v['validity_value'] : null,
'validity_unit' => $v['validity_unit'],
'validity_start' => $v['validity_start'],
'services' => $mode === 'free' || $mode === 'single' ? self::productServiceIds($idschool, (int) $p['idproduct']) : $sel['services'],
'price' => $price,
];
$pdo->beginTransaction();
try {
// Lezione singola: blocca la riga della lezione e ricontrolla il posto
if ($mode === 'single') {
$st = $pdo->prepare("SELECT ss.idserviceschedule, s.maxcapacity FROM serviceschedule ss JOIN service s ON s.idservice = ss.idservice WHERE ss.idserviceschedule = ? AND ss.idschool = ? FOR UPDATE");
$st->execute([(int) $sel['schedule']['idserviceschedule'], $idschool]);
$lock = $st->fetch(PDO::FETCH_ASSOC);
if (!$lock || ((int) $lock['maxcapacity'] > 0 && self::occupied($idschool, (int) $lock['idserviceschedule']) >= (int) $lock['maxcapacity'])) {
throw new RuntimeException('Nel frattempo la lezione si è riempita. Scegli un\'altra data.');
}
}
$pdo->prepare(
"INSERT INTO orderbook
(order_date_created, order_billing_email, order_total, product_name, line_total, quantity,
idservice, is_openflexy, iduser, nticket, expireon, status, quantityclass,
first_name, last_name, maxreschedule, reprogrammed, idschool,
idproduct, idvariant, booking_mode, weekly_limit, rules_snapshot, source, payment_status,
amount, discount_amount, idcoupon)
VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
)->execute([
$today,
$u['email'],
number_format($total, 2, '.', ''),
mb_substr($p['title'] . ' · ' . $v['label'], 0, 200),
number_format($total, 2, '.', ''),
$idservice,
$mode === 'free' ? 'Y' : 'N',
$userId,
$lessons,
$expire,
$paid ? ($mode === 'fixed' ? 'pending' : 'completed') : 'on-hold',
$lessons,
$u['first_name'],
$u['last_name'],
$maxres,
$idschool,
(int) $p['idproduct'],
(int) $v['idvariant'],
in_array($mode, ['fixed', 'free', 'single'], true) ? $mode : null,
$p['weekly_limit'] !== null ? (int) $p['weekly_limit'] : null,
json_encode($snapshot, JSON_UNESCAPED_UNICODE),
$method,
$paid ? 'paid' : 'pending',
$total,
$discount > 0 ? $discount : null,
$coupon ? (int) $coupon['idcoupon'] : null,
]);
$idorder = (int) $pdo->lastInsertId();
if ($mode === 'fixed') {
$ins = $pdo->prepare("INSERT INTO orderbook_services (idorderbook, idschool, idservice, slot) VALUES (?, ?, ?, ?)");
foreach ($sel['services'] as $i => $sid) {
$ins->execute([$idorder, $idschool, $sid, $i + 1]);
}
}
if ($mode === 'single') {
$pdo->prepare(
"INSERT INTO bookingclass (idserviceschedule, bookingstart, status, idservice, iduser, idorder, cancellation_token, idschool)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
)->execute([
(int) $sel['schedule']['idserviceschedule'],
$sel['schedule']['dateschedule'],
$paid ? 'booked' : 'pending',
(int) $sel['schedule']['idservice'],
$userId,
$idorder,
bin2hex(random_bytes(16)),
$idschool,
]);
}
if ($mode === 'voucher') {
$codeSt = $pdo->prepare("SELECT 1 FROM gift_vouchers WHERE idschool = ? AND code = ?");
do {
$code = 'YB-' . self::randomCode(4) . '-' . self::randomCode(4);
$codeSt->execute([$idschool, $code]);
} while ($codeSt->fetchColumn());
$vExpire = $v['validity_value'] ? self::addPeriod($today, (int) $v['validity_value'], (string) $v['validity_unit'])->format('Y-m-d') : null;
$pdo->prepare(
"INSERT INTO gift_vouchers (idschool, code, idvariant, buyer_iduser, buyer_name, buyer_email, recipient_name, recipient_email,
message, source, amount, expires_at, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
)->execute([
$idschool,
$code,
(int) $v['target_idvariant'],
$userId,
trim($u['first_name'] . ' ' . $u['last_name']),
$u['email'],
$sel['recipient']['name'],
$sel['recipient']['email'],
$sel['recipient']['message'] ?: null,
$method,
$total,
$vExpire,
$paid ? 'active' : 'pending',
]);
// collega il buono all'ordine (per attivarlo alla conferma del pagamento)
$pdo->prepare("UPDATE orderbook SET cod = ? WHERE idorderbook = ?")->execute([$code, $idorder]);
}
if ($mode === 'membership' && $paid) {
$pdo->prepare("INSERT INTO memberships (idschool, iduser, idorderbook, valid_from, valid_to, status) VALUES (?, ?, ?, ?, ?, 'active')")
->execute([$idschool, $userId, $idorder, $today, $expire ?? '2099-12-31']);
}
if ($coupon) {
$pdo->prepare("INSERT INTO coupon_redemptions (idcoupon, idschool, iduser, idorderbook, discount_amount) VALUES (?, ?, ?, ?, ?)")
->execute([(int) $coupon['idcoupon'], $idschool, $userId, $idorder, $discount]);
}
$pdo->commit();
return $idorder;
} catch (Throwable $ex) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $ex;
}
}
/** Dati del bonifico per un ordine: intestatario, IBAN, banca, causale, scadenza */
public static function bankInfo(int $idschool, array $order): array
{
$tpl = trim((string) SchoolSettings::get('bank_reference', '', $idschool)) ?: 'Ordine {ordine} - {nome}';
$iban = strtoupper(preg_replace('/\s+/', '', (string) SchoolSettings::get('bank_iban', '', $idschool)));
$days = max(1, (int) SchoolSettings::get('bank_due_days', 5, $idschool));
$created = $order['order_date_created'] ?: date('Y-m-d');
return [
'holder' => (string) SchoolSettings::get('bank_holder', '', $idschool),
'iban' => trim(chunk_split($iban, 4, ' ')),
'bank' => (string) SchoolSettings::get('bank_name', '', $idschool),
'reference' => mb_substr(strtr($tpl, ['{ordine}' => (string) $order['idorderbook'], '{nome}' => trim($order['first_name'] . ' ' . $order['last_name'])]), 0, 140),
'due' => date('Y-m-d', strtotime($created . ' +' . $days . ' days')),
];
}
/**
* La scuola conferma il pagamento (bonifico ricevuto) di un ordine in attesa.
* - ordine pagato
* - lezione singola: la prenotazione passa da 'pending' a 'booked'
* - tessera: nasce la riga in memberships
* - buono regalo: diventa attivo
* Idempotente: false se l'ordine non è (più) in attesa.
*/
public static function markPaid(int $idschool, int $idorder): bool
{
$pdo = self::pdo();
$pdo->beginTransaction();
try {
$st = $pdo->prepare(
"SELECT ob.*, p.kind FROM orderbook ob LEFT JOIN products p ON p.idproduct = ob.idproduct
WHERE ob.idorderbook = ? AND ob.idschool = ? FOR UPDATE"
);
$st->execute([$idorder, $idschool]);
$o = $st->fetch(PDO::FETCH_ASSOC);
if (!$o || $o['payment_status'] !== 'pending') {
$pdo->rollBack();
return false;
}
// Classe fissa: 'pending' = da propagare (stesso significato degli ordini storici)
$pdo->prepare("UPDATE orderbook SET payment_status = 'paid', status = ? WHERE idorderbook = ? AND idschool = ?")
->execute([$o['booking_mode'] === 'fixed' ? 'pending' : 'completed', $idorder, $idschool]);
if ($o['booking_mode'] === 'single') {
$pdo->prepare("UPDATE bookingclass SET status = 'booked' WHERE idorder = ? AND idschool = ? AND status = 'pending'")
->execute([$idorder, $idschool]);
}
if ($o['kind'] === 'membership') {
$pdo->prepare("INSERT INTO memberships (idschool, iduser, idorderbook, valid_from, valid_to, status) VALUES (?, ?, ?, CURDATE(), ?, 'active')")
->execute([$idschool, (int) $o['iduser'], $idorder, $o['expireon'] ?: '2099-12-31']);
}
if (!empty($o['cod'])) {
$pdo->prepare("UPDATE gift_vouchers SET status = 'active' WHERE idschool = ? AND code = ? AND status = 'pending'")
->execute([$idschool, $o['cod']]);
}
$pdo->commit();
return true;
} catch (Throwable $ex) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $ex;
}
}
/**
* Annulla un ordine in attesa di pagamento (dall'allieva o dalla scuola):
* libera il posto della lezione singola, annulla il buono e l'uso del coupon.
* $userId: se indicato, solo se l'ordine è suo.
*/
public static function cancelPending(int $idschool, int $idorder, ?int $userId = null): bool
{
$pdo = self::pdo();
$pdo->beginTransaction();
try {
$sql = "SELECT * FROM orderbook WHERE idorderbook = ? AND idschool = ?" . ($userId ? " AND iduser = ?" : "") . " FOR UPDATE";
$args = $userId ? [$idorder, $idschool, $userId] : [$idorder, $idschool];
$st = $pdo->prepare($sql);
$st->execute($args);
$o = $st->fetch(PDO::FETCH_ASSOC);
if (!$o || $o['payment_status'] !== 'pending') {
$pdo->rollBack();
return false;
}
$pdo->prepare("UPDATE orderbook SET payment_status = 'cancelled', status = 'cancelled' WHERE idorderbook = ? AND idschool = ?")
->execute([$idorder, $idschool]);
$pdo->prepare("UPDATE bookingclass SET status = 'cancelled' WHERE idorder = ? AND idschool = ? AND status <> 'cancelled'")
->execute([$idorder, $idschool]);
$pdo->prepare("DELETE FROM coupon_redemptions WHERE idorderbook = ? AND idschool = ?")->execute([$idorder, $idschool]);
if (!empty($o['cod'])) {
$pdo->prepare("UPDATE gift_vouchers SET status = 'cancelled' WHERE idschool = ? AND code = ? AND status = 'pending'")
->execute([$idschool, $o['cod']]);
}
$pdo->commit();
return true;
} catch (Throwable $ex) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $ex;
}
}
/** Ordini su cui la scuola deve agire: da pagare + da propagare (contatore in sidebar) */
public static function pendingCount(int $idschool): int
{
$st = self::pdo()->prepare(
"SELECT COUNT(*) FROM orderbook
WHERE idschool = ?
AND (payment_status = 'pending'
OR (status = 'pending' AND (payment_status IS NULL OR payment_status = 'paid')))"
);
$st->execute([$idschool]);
return (int) $st->fetchColumn();
}
private static function randomCode(int $len): string
{
$chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
$out = '';
for ($i = 0; $i < $len; $i++) {
$out .= $chars[random_int(0, strlen($chars) - 1)];
}
return $out;
}
private static function pdo(): PDO
{
return DBHandlerSelect::getInstance()->getConnection();
}
}
+54 -10
View File
@@ -57,19 +57,45 @@ if ($__mode === 'school' && $__isAdmin && !empty($__sc['idschool'])) {
}
}
// Percorso "Primi passi" non ancora concluso: promemoria con l'avanzamento salvato
$__setup = null;
if ($__mode === 'school' && $__isAdmin && !empty($__sc['idschool'])) {
try {
if ((string) SchoolSettings::get('setup_completed_at', '') === '') {
$__ss = json_decode((string) SchoolSettings::get('setup_state', ''), true);
$__setup = is_array($__ss) && !empty($__ss['progress']) ? (string) $__ss['progress'] : '';
}
} catch (Throwable $__ex) {
error_log('[sidebar] primi passi: ' . $__ex->getMessage());
}
}
// Ordini su cui agire: da pagare + da propagare (contatore nel menu della scuola)
$__toPay = 0;
if ($__mode === 'school' && $__isAdmin && !empty($__sc['idschool'])) {
try {
require_once dirname(__DIR__) . '/class/ShopService.php';
$__toPay = ShopService::pendingCount((int) $__sc['idschool']);
} catch (Throwable $__ex) {
error_log('[sidebar] ordini: ' . $__ex->getMessage());
}
}
if (!function_exists('sb_item')) {
/** Voce singola. $also = altre pagine che la accendono (es. il form di modifica). */
function sb_item(string $href, string $icon, string $label, string $current, array $also = []): void
function sb_item(string $href, string $icon, string $label, string $current, array $also = [], int $badge = 0): void
{
$on = ($href === $current || in_array($current, $also, true));
echo '<li class="' . ($on ? 'mm-active' : '') . '"><a href="' . $href . '" class="' . ($on ? 'active' : '') . '">'
. '<i class="' . $icon . ' icon nav-icon"></i>'
. '<span class="menu-item">' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . '</span></a></li>';
. '<span class="menu-item">' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . '</span>'
. ($badge > 0 ? '<span class="badge rounded-pill bg-warning text-dark ms-1 sb-badge" title="Da pagare o da propagare">' . $badge . '</span>' : '')
. '</a></li>';
}
/**
* Gruppo richiudibile (metisMenu). Si apre da solo se contiene la pagina corrente.
* $items = [ [href, etichetta, [pagine collegate]], ... ] (le voci null vengono saltate)
* $items = [ [href, etichetta, [pagine collegate], contatore], ... ] (le voci null vengono saltate)
*/
function sb_group(string $icon, string $label, array $items, string $current): void
{
@@ -78,7 +104,9 @@ if (!function_exists('sb_item')) {
return;
}
$open = false;
$badge = 0;
foreach ($items as $it) {
$badge += (int) ($it[3] ?? 0);
if ($it[0] === $current || in_array($current, $it[2] ?? [], true)) {
$open = true;
}
@@ -86,12 +114,16 @@ if (!function_exists('sb_item')) {
echo '<li class="' . ($open ? 'mm-active' : '') . '">'
. '<a href="javascript: void(0);" class="has-arrow" aria-expanded="' . ($open ? 'true' : 'false') . '">'
. '<i class="' . $icon . ' icon nav-icon"></i>'
. '<span class="menu-item">' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . '</span></a>'
. '<span class="menu-item">' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . '</span>'
. ($badge > 0 ? '<span class="badge rounded-pill bg-warning text-dark ms-1 sb-badge">' . $badge . '</span>' : '')
. '</a>'
. '<ul class="sub-menu" aria-expanded="' . ($open ? 'true' : 'false') . '">';
foreach ($items as $it) {
$on = ($it[0] === $current || in_array($current, $it[2] ?? [], true));
echo '<li class="' . ($on ? 'mm-active' : '') . '"><a href="' . $it[0] . '" class="' . ($on ? 'active' : '') . '">'
. htmlspecialchars($it[1], ENT_QUOTES, 'UTF-8') . '</a></li>';
. htmlspecialchars($it[1], ENT_QUOTES, 'UTF-8')
. (!empty($it[3]) ? ' <span class="badge rounded-pill bg-warning text-dark ms-1">' . (int) $it[3] . '</span>' : '')
. '</a></li>';
}
echo '</ul></li>';
}
@@ -210,6 +242,10 @@ if (!function_exists('sb_item')) {
border-color: #cfe5e6;
}
body[data-sidebar-size="sm"] .sb-badge {
display: none;
}
.sub-alert:hover {
filter: brightness(.97);
}
@@ -270,6 +306,14 @@ if (!function_exists('sb_item')) {
</div>
<?php endif; ?>
<?php if ($__setup !== null && $__page !== 'school_setup.php') : ?>
<!-- Promemoria Primi passi -->
<a href="school_setup.php" class="sub-alert info">
<i class="bx bx-rocket"></i>
<span><b>Primi passi<?php echo $__setup !== '' ? ' · ' . htmlspecialchars($__setup, ENT_QUOTES, 'UTF-8') : ''; ?></b>Configura e prova la tua scuola</span>
</a>
<?php endif; ?>
<?php if ($__sub && $__page !== 'subscription.php') : ?>
<!-- Avviso abbonamento -->
<a href="subscription.php" class="sub-alert <?php echo $__sub[0]; ?>">
@@ -312,10 +356,7 @@ if (!function_exists('sb_item')) {
['statistiche.php', 'Statistiche'],
], $__page); ?>
<?php sb_group('bx bx-receipt', 'Ordini', [
['orderbooklist.php', 'Da propagare'],
['ordersummary.php', 'Sommario ordini'],
], $__page); ?>
<?php sb_item('school_orders.php', 'bx bx-receipt', 'Ordini', $__page, ['orderbooklist.php', 'ordersummary.php'], $__toPay); ?>
<?php sb_group('bx bx-store-alt', 'Negozio', [
['products.php', 'Prodotti', ['product_form.php']],
@@ -330,7 +371,9 @@ if (!function_exists('sb_item')) {
], $__page); ?>
<?php sb_group('bx bx-cog', 'Impostazioni', [
['school_setup.php', 'Primi passi'],
['optionsschool.php', 'Opzioni scuola'],
['package_rules.php', 'Regole pacchetti'],
['insegnanti.php', 'Insegnanti'],
['subscription.php', 'Abbonamento YogiBook'],
], $__page); ?>
@@ -347,7 +390,8 @@ if (!function_exists('sb_item')) {
<li class="menu-title">Le mie lezioni</li>
<?php sb_item('studentdashboard.php', 'bx bx-home-heart', 'Home', $__page); ?>
<?php sb_item('userpanel.php', 'bx bx-calendar-event', 'Prenotazioni', $__page); ?>
<?php sb_item('orders.php', 'bx bx-detail', 'I miei pacchetti', $__page); ?>
<?php sb_item('orders.php', 'bx bx-detail', 'I miei pacchetti', $__page, ['shop_order.php']); ?>
<?php sb_item('shop.php', 'bx bx-store-alt', 'Negozio', $__page, ['shop_checkout.php']); ?>
<?php endif; ?>
<!-- Account personale: sempre presente -->
-2
View File
@@ -9,8 +9,6 @@
SchoolContext::requireSchoolAdmin();
$pdo = DBHandlerSelect::getInstance()->getConnection();
if (!function_exists('e')) {
if (!function_exists('e')) {
function e($value): string
{
+305
View File
@@ -0,0 +1,305 @@
<?php
require_once('include/headscript.php');
require_once('include/csrf.php');
SchoolContext::requireSchoolAdmin();
/**
* SCUOLA - Regole dei pacchetti a numero di lezioni (tabella expiryparameter).
* Per ogni numero di lezioni: dopo quante lezioni in calendario scade il pacchetto
* e quanti recuperi sono consentiti. Usate da calcolo scadenza / propagazione,
* ordini manuali e WooCommerce, e dai prodotti con "Recuperi" lasciato vuoto.
*/
$pdo = DBHandlerSelect::getInstance()->getConnection();
function e($value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
function back(string $msg, string $type = 'msg'): void
{
header('Location: package_rules.php?' . $type . '=' . urlencode($msg));
exit();
}
/** Valori consigliati per una scuola che parte da zero */
const PR_DEFAULTS = [[4, 5, 1], [6, 8, 2], [8, 10, 2], [10, 12, 2], [12, 15, 3]];
/* ---------------------------------------------------------------- AZIONI */
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
yb_csrf_check();
$action = (string) ($_POST['action'] ?? '');
$id = (int) ($_POST['id'] ?? 0);
$int = function (string $k, int $min, int $max): ?int {
$v = trim((string) ($_POST[$k] ?? ''));
return (ctype_digit($v) && (int) $v >= $min && (int) $v <= $max) ? (int) $v : null;
};
try {
if ($action === 'save') {
$qty = $int('quantityclass', 1, 200);
$exp = $int('expirylessons', 1, 400);
$res = $int('reschedulenumber', 0, 99);
if ($qty === null || $exp === null || $res === null) {
back('Controlla i valori: numeri interi, lezioni almeno 1.', 'err');
}
if ($exp < $qty) {
back('La scadenza (' . $exp . ' lezioni) non può essere minore delle lezioni del pacchetto (' . $qty . ').', 'err');
}
$st = $pdo->prepare("SELECT 1 FROM expiryparameter WHERE idschool = ? AND quantityclass = ? AND idexpiryparameter <> ? LIMIT 1");
$st->execute([(int) $idschool, $qty, $id]);
if ($st->fetchColumn()) {
back('Esiste già una regola per i pacchetti da ' . $qty . ' lezioni.', 'err');
}
if ($id > 0) {
$st = $pdo->prepare("UPDATE expiryparameter SET quantityclass = ?, expirylessons = ?, reschedulenumber = ? WHERE idexpiryparameter = ? AND idschool = ?");
$st->execute([$qty, $exp, $res, $id, (int) $idschool]);
back('Regola per ' . $qty . ' lezioni aggiornata.');
}
$pdo->prepare("INSERT INTO expiryparameter (quantityclass, expirylessons, reschedulenumber, idschool) VALUES (?, ?, ?, ?)")
->execute([$qty, $exp, $res, (int) $idschool]);
back('Regola per ' . $qty . ' lezioni aggiunta.');
}
if ($action === 'delete' && $id > 0) {
$pdo->prepare("DELETE FROM expiryparameter WHERE idexpiryparameter = ? AND idschool = ?")->execute([$id, (int) $idschool]);
back('Regola eliminata.');
}
if ($action === 'defaults') {
$st = $pdo->prepare("SELECT COUNT(*) FROM expiryparameter WHERE idschool = ?");
$st->execute([(int) $idschool]);
if ((int) $st->fetchColumn() > 0) {
back('Ci sono già delle regole: i valori consigliati si caricano solo su una tabella vuota.', 'err');
}
$ins = $pdo->prepare("INSERT INTO expiryparameter (quantityclass, expirylessons, reschedulenumber, idschool) VALUES (?, ?, ?, ?)");
foreach (PR_DEFAULTS as [$q, $x, $r]) {
$ins->execute([$q, $x, $r, (int) $idschool]);
}
back('Valori consigliati caricati: modificali come preferisci.');
}
} catch (PDOException $ex) {
error_log('[package_rules] ' . $ex->getMessage());
back('Errore database, operazione non eseguita.', 'err');
}
back('Azione non valida.', 'err');
}
/* ------------------------------------------------------------------ DATI */
$st = $pdo->prepare("SELECT * FROM expiryparameter WHERE idschool = ? ORDER BY quantityclass, idexpiryparameter");
$st->execute([(int) $idschool]);
$rules = $st->fetchAll(PDO::FETCH_ASSOC);
// Quanti ordini usano ogni numero di lezioni (per sapere cosa si tocca)
$st = $pdo->prepare("SELECT quantityclass, COUNT(*) AS n FROM orderbook WHERE idschool = ? AND quantityclass IS NOT NULL GROUP BY quantityclass");
$st->execute([(int) $idschool]);
$usage = [];
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $u) {
$usage[(int) $u['quantityclass']] = (int) $u['n'];
}
$missing = array_diff(array_keys($usage), array_map(fn($r) => (int) $r['quantityclass'], $rules));
sort($missing);
?>
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8" />
<title>YogiBook - Regole pacchetti</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 href="assets/css/yb-billing.css" rel="stylesheet" type="text/css" />
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<style>
.pr-row input {
max-width: 110px;
padding: 7px 10px;
}
.pr-row .warn-cell {
color: #b03a2e;
font-weight: 700;
font-size: 12px;
}
.pr-new {
background: #F4FAFA;
}
.pr-example {
font-size: 13px;
color: #4b5563;
}
</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">Regole pacchetti</h4>
</div>
</div>
<div class="d-flex">
<?php include('include/schoolswitcher.php'); ?><?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-xl-9">
<?php if (!empty($_GET['msg'])) : ?><div class="alert alert-success"><?php echo e($_GET['msg']); ?></div><?php endif; ?>
<?php if (!empty($_GET['err'])) : ?><div class="alert alert-danger"><?php echo e($_GET['err']); ?></div><?php endif; ?>
<div class="card admin-card">
<div class="card-body">
<div class="section-title"><span class="disc"></span>Scadenza e recuperi dei pacchetti</div>
<p class="section-lead">
Per ogni pacchetto a numero di lezioni: dopo quante lezioni in calendario della sua classe scade, e quanti recuperi sono consentiti.
Valgono per la propagazione degli ordini, per gli ordini inseriti a mano e per i prodotti del negozio con "Recuperi" lasciato vuoto.
</p>
<div class="info-box info">
<i class="bx bx-bulb"></i>
<div class="pr-example">
<strong>Esempio:</strong> pacchetto da <strong>8</strong> lezioni, scade dopo <strong>10</strong> lezioni, <strong>2</strong> recuperi.
Se la classe è il martedì, il pacchetto vale per le prossime 10 lezioni del martedì (2 settimane di margine per le assenze) e l'allieva può spostare fino a 2 lezioni.
I giorni di chiusura non contano.
</div>
</div>
<?php if ($missing) : ?>
<div class="info-box warn">
<i class="bx bx-error"></i>
<div>Ci sono ordini con un numero di lezioni senza regola: <strong><?php echo e(implode(', ', $missing)); ?></strong>. Per questi la scadenza non si calcola da sola: aggiungi una regola o inseriscila a mano alla propagazione.</div>
</div>
<?php endif; ?>
<div class="table-responsive">
<table class="table yb-table align-middle">
<thead>
<tr>
<th>Lezioni del pacchetto</th>
<th>Scade dopo (lezioni)</th>
<th>Recuperi consentiti</th>
<th>Margine</th>
<th>Ordini</th>
<th class="text-end">Azioni</th>
</tr>
</thead>
<tbody>
<?php if (!$rules) : ?>
<tr>
<td colspan="6">
<div class="empty-state">
<i class="bx bx-list-ol"></i>
Nessuna regola ancora.
<form method="post" action="package_rules.php" class="mt-3">
<?php echo yb_csrf_field(); ?>
<input type="hidden" name="action" value="defaults">
<button type="submit" class="btn btn-yb"><i class="bx bx-magic-wand"></i> Carica i valori consigliati</button>
</form>
<div class="hint">4 → 5 · 6 → 8 · 8 → 10 · 10 → 12 · 12 → 15, poi li modifichi.</div>
</div>
</td>
</tr>
<?php endif; ?>
<?php foreach ($rules as $r) :
$rid = (int) $r['idexpiryparameter'];
$q = (int) $r['quantityclass'];
$x = (int) $r['expirylessons'];
$bad = $x < $q; ?>
<tr class="pr-row">
<td>
<form method="post" action="package_rules.php" id="f-<?php echo $rid; ?>">
<?php echo yb_csrf_field(); ?>
<input type="hidden" name="action" value="save">
<input type="hidden" name="id" value="<?php echo $rid; ?>">
</form>
<input form="f-<?php echo $rid; ?>" type="number" min="1" max="200" name="quantityclass" class="form-control" value="<?php echo $q; ?>" aria-label="Lezioni del pacchetto">
</td>
<td><input form="f-<?php echo $rid; ?>" type="number" min="1" max="400" name="expirylessons" class="form-control" value="<?php echo $x; ?>" aria-label="Scade dopo"></td>
<td><input form="f-<?php echo $rid; ?>" type="number" min="0" max="99" name="reschedulenumber" class="form-control" value="<?php echo (int) $r['reschedulenumber']; ?>" aria-label="Recuperi consentiti"></td>
<td>
<?php if ($bad) : ?>
<span class="warn-cell"><i class="bx bx-error"></i> scade prima di finire le lezioni</span>
<?php else : ?>
<?php echo $x - $q; ?> <?php echo $x - $q === 1 ? 'lezione' : 'lezioni'; ?>
<?php endif; ?>
</td>
<td><?php echo $usage[$q] ?? 0; ?></td>
<td class="text-end">
<button type="submit" form="f-<?php echo $rid; ?>" class="btn-act ok"><i class="bx bx-save"></i> Salva</button>
<form method="post" action="package_rules.php" class="d-inline confirm-form" data-msg="Eliminare la regola per <?php echo $q; ?> lezioni?" data-text="Gli ordini già propagati non cambiano. I nuovi ordini da <?php echo $q; ?> lezioni non avranno più scadenza automatica." data-ok="Elimina" data-color="#e74c3c">
<?php echo yb_csrf_field(); ?>
<input type="hidden" name="action" value="delete">
<input type="hidden" name="id" value="<?php echo $rid; ?>">
<button type="submit" class="btn-act danger" aria-label="Elimina regola"><i class="bx bx-trash"></i></button>
</form>
</td>
</tr>
<?php endforeach; ?>
<!-- Nuova regola -->
<tr class="pr-row pr-new">
<td>
<form method="post" action="package_rules.php" id="f-new">
<?php echo yb_csrf_field(); ?>
<input type="hidden" name="action" value="save">
<input type="hidden" name="id" value="0">
</form>
<input form="f-new" type="number" min="1" max="200" name="quantityclass" class="form-control" placeholder="es. 16" required aria-label="Lezioni del nuovo pacchetto">
</td>
<td><input form="f-new" type="number" min="1" max="400" name="expirylessons" class="form-control" placeholder="es. 20" required aria-label="Scade dopo"></td>
<td><input form="f-new" type="number" min="0" max="99" name="reschedulenumber" class="form-control" placeholder="es. 3" required aria-label="Recuperi consentiti"></td>
<td colspan="2" class="hint">Nuova regola</td>
<td class="text-end"><button type="submit" form="f-new" class="btn-act ok"><i class="bx bx-plus"></i> Aggiungi</button></td>
</tr>
</tbody>
</table>
</div>
<div class="hint">Le modifiche valgono per i prossimi calcoli: gli ordini già propagati mantengono scadenza e recuperi che hanno (li cambi da Ordini).</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 src="assets/js/yb-confirm.js"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+510
View File
@@ -0,0 +1,510 @@
<?php
require_once('include/headscript.php');
require_once('include/csrf.php');
require_once(__DIR__ . '/class/SchoolSetup.php');
SchoolContext::requireSchoolAdmin();
/**
* SCUOLA - Primi passi: percorso guidato per configurare e provare la scuola.
* I passi si completano da soli quando i dati ci sono; alcuni si segnano a mano,
* i facoltativi si possono saltare. Alla fine l'owner chiude il percorso.
*/
function e($value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
$sid = (int) $idschool;
/* ---------------------------------------------------------------- AZIONI */
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
yb_csrf_check();
$action = (string) ($_POST['action'] ?? '');
$key = preg_replace('/[^a-z_]/', '', (string) ($_POST['key'] ?? ''));
$state = SchoolSetup::state($sid);
$keys = array_column(SchoolSetup::steps($sid, (int) $iduserlogin), null, 'key');
if (isset($keys[$key])) {
if ($action === 'mark') {
$state['done'][$key] = date('Y-m-d');
unset($state['skip'][$key]);
} elseif ($action === 'unmark') {
unset($state['done'][$key]);
} elseif ($action === 'skip' && !$keys[$key]['required']) {
$state['skip'][$key] = date('Y-m-d');
} elseif ($action === 'unskip') {
unset($state['skip'][$key]);
}
SchoolSetup::saveState($sid, $state);
}
if ($action === 'complete') {
[,, $missing] = SchoolSetup::progress(SchoolSetup::steps($sid, (int) $iduserlogin));
if ($missing === 0) {
SchoolSettings::save(['setup_completed_at' => date('Y-m-d H:i:s')], $sid);
header('Location: school_setup.php?done=1');
exit();
}
}
if ($action === 'reopen') {
SchoolSettings::save(['setup_completed_at' => null], $sid);
}
header('Location: school_setup.php' . ($key !== '' ? '#step-' . $key : ''));
exit();
}
/* ------------------------------------------------------------------ DATI */
$steps = SchoolSetup::steps($sid, (int) $iduserlogin);
[$done, $total, $missing] = SchoolSetup::progress($steps);
$pct = $total ? (int) round($done / $total * 100) : 0;
$completedAt = (string) SchoolSettings::get('setup_completed_at', '');
// Avanzamento in cache per il promemoria in sidebar (scritto solo se cambia)
$state = SchoolSetup::state($sid);
if (($state['progress'] ?? '') !== $done . '/' . $total) {
$state['progress'] = $done . '/' . $total;
SchoolSetup::saveState($sid, $state);
}
$next = null;
foreach ($steps as $s) {
if (!$s['done'] && !$s['skipped']) {
$next = $s;
break;
}
}
$byPhase = [];
foreach ($steps as $i => $s) {
$s['n'] = $i + 1;
$byPhase[$s['phase']][] = $s;
}
?>
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8" />
<title>YogiBook - Primi passi</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 href="assets/css/yb-billing.css" rel="stylesheet" type="text/css" />
<style>
.su-wrap {
max-width: 920px;
}
.su-hero {
display: flex;
gap: 24px;
align-items: center;
flex-wrap: wrap;
}
.su-ring {
--p: 0;
width: 112px;
height: 112px;
border-radius: 50%;
flex-shrink: 0;
display: grid;
place-items: center;
background: conic-gradient(#2F7F84 calc(var(--p) * 1%), #DBF6F9 0);
position: relative;
}
.su-ring::before {
content: "";
position: absolute;
inset: 11px;
border-radius: 50%;
background: #fff;
}
.su-ring b {
position: relative;
font-size: 26px;
font-weight: 800;
color: #1E4A4F;
line-height: 1;
text-align: center;
}
.su-ring small {
display: block;
font-size: 11px;
font-weight: 700;
color: #6b7280;
margin-top: 3px;
}
.su-hero h1 {
font-size: 26px;
font-weight: 800;
color: #1E4A4F;
margin: 0 0 6px;
}
.su-hero p {
margin: 0;
color: #4b5563;
font-size: 15px;
line-height: 1.5;
}
.su-next {
border: 2px solid #6FBABE;
background: #F4FAFA;
border-radius: 16px;
padding: 18px 20px;
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
}
.su-next .k {
font-size: 12px;
font-weight: 800;
letter-spacing: .06em;
text-transform: uppercase;
color: #2F7F84;
}
.su-next .t {
font-size: 19px;
font-weight: 800;
color: #1E4A4F;
margin: 2px 0 4px;
}
.su-next .d {
font-size: 14px;
color: #374151;
max-width: 560px;
line-height: 1.45;
}
.su-phase {
margin-top: 26px;
}
.su-phase-head {
display: flex;
align-items: baseline;
gap: 10px;
margin-bottom: 10px;
}
.su-phase-head h2 {
font-size: 15px;
font-weight: 800;
letter-spacing: .04em;
text-transform: uppercase;
color: #1E4A4F;
margin: 0;
}
.su-phase-head span {
color: #6b7280;
font-size: 13px;
}
.su-step {
display: flex;
gap: 14px;
align-items: flex-start;
background: #fff;
border: 1px solid #e6ecec;
border-radius: 14px;
padding: 14px 16px;
margin-bottom: 10px;
scroll-margin-top: 90px;
}
.su-step.is-next {
border-color: #6FBABE;
box-shadow: 0 0 0 3px rgba(111, 186, 190, .18);
}
.su-dot {
width: 34px;
height: 34px;
border-radius: 50%;
flex-shrink: 0;
display: grid;
place-items: center;
font-weight: 800;
font-size: 14px;
background: #eef3f3;
color: #4b5563;
}
.su-step.done .su-dot {
background: #2F7F84;
color: #fff;
font-size: 20px;
box-shadow: 5px 0 0 #DBF6F9;
}
.su-step.skipped .su-dot {
background: #f3f4f6;
color: #9ca3af;
}
.su-body {
flex: 1;
min-width: 0;
}
.su-title {
font-weight: 700;
color: #1f2937;
font-size: 15px;
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.su-step.done .su-title {
color: #1E4A4F;
}
.su-step.skipped .su-title {
color: #9ca3af;
text-decoration: line-through;
}
.su-tag {
font-size: 11px;
font-weight: 700;
padding: 1px 8px;
border-radius: 10px;
background: #eef0f3;
color: #4b5563;
}
.su-text {
font-size: 13.5px;
color: #4b5563;
line-height: 1.45;
margin-top: 3px;
}
.su-step.done .su-text {
display: none;
}
.su-detail {
font-size: 12.5px;
color: #2F7F84;
font-weight: 600;
margin-top: 3px;
}
.su-detail.warn {
color: #92560b;
}
.su-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
justify-content: flex-end;
align-items: center;
}
.su-actions form {
margin: 0;
}
.su-link {
border: none;
background: none;
color: #6b7280;
font-size: 13px;
text-decoration: underline;
padding: 6px 4px;
min-height: 36px;
}
.su-link:hover {
color: #1E4A4F;
}
.su-finish {
text-align: center;
padding: 26px 16px;
}
.su-finish i {
font-size: 46px;
color: #2F7F84;
}
@media (max-width: 640px) {
.su-step {
flex-wrap: wrap;
}
.su-actions {
width: 100%;
justify-content: flex-start;
padding-left: 48px;
}
}
</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">Primi passi</h4>
</div>
</div>
<div class="d-flex">
<?php include('include/schoolswitcher.php'); ?><?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="su-wrap">
<?php if (isset($_GET['done'])) : ?>
<div class="alert alert-success">Configurazione completata. Il promemoria in alto nel menu non comparirà più: puoi tornare qui da Impostazioni → Primi passi.</div>
<?php endif; ?>
<div class="card admin-card">
<div class="card-body">
<div class="su-hero">
<div class="su-ring" style="--p: <?php echo $pct; ?>;" role="img" aria-label="<?php echo $done; ?> passi su <?php echo $total; ?>">
<b><?php echo $done; ?>/<?php echo $total; ?><small>passi</small></b>
</div>
<div style="flex:1;min-width:240px;">
<h1><?php echo $completedAt ? 'La tua scuola è pronta' : 'Prepariamo la tua scuola'; ?></h1>
<p>
<?php if ($completedAt) : ?>
Percorso chiuso il <?php echo e(date('d/m/Y', strtotime($completedAt))); ?>. Puoi sempre ricontrollare i passi qui sotto.
<?php else : ?>
Segui i passi in ordine: ogni volta che inserisci i dati il passo si spunta da solo.
Alla fine fai una prova come allieva, così sai esattamente cosa vedranno.
<?php endif; ?>
</p>
</div>
</div>
<?php if ($next && !$completedAt) : ?>
<div class="su-next mt-4">
<div>
<div class="k">Prossimo passo · <?php echo e(SchoolSetup::PHASES[$next['phase']][0]); ?></div>
<div class="t"><?php echo e($next['title']); ?></div>
<div class="d"><?php echo e($next['text']); ?></div>
</div>
<?php if ($next['url'] !== '') : ?>
<a href="<?php echo e($next['url']); ?>" class="btn btn-yb" style="min-height:46px;display:inline-flex;align-items:center;gap:6px;"><?php echo e($next['cta']); ?> <i class="bx bx-right-arrow-alt"></i></a>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
</div>
<?php foreach (SchoolSetup::PHASES as $pk => [$pTitle, $pLead]) :
if (empty($byPhase[$pk])) continue; ?>
<section class="su-phase" aria-labelledby="ph-<?php echo $pk; ?>">
<div class="su-phase-head">
<h2 id="ph-<?php echo $pk; ?>"><?php echo e($pTitle); ?></h2>
<span><?php echo e($pLead); ?></span>
</div>
<?php foreach ($byPhase[$pk] as $s) :
$isNext = $next && $next['key'] === $s['key'] && !$completedAt;
$cls = $s['done'] ? 'done' : ($s['skipped'] ? 'skipped' : ''); ?>
<div class="su-step <?php echo $cls; ?> <?php echo $isNext ? 'is-next' : ''; ?>" id="step-<?php echo e($s['key']); ?>">
<div class="su-dot" aria-hidden="true"><?php echo $s['done'] ? '<i class="bx bx-check"></i>' : ($s['skipped'] ? '–' : $s['n']); ?></div>
<div class="su-body">
<div class="su-title">
<?php echo e($s['title']); ?>
<?php if (!$s['required']) : ?><span class="su-tag">facoltativo</span><?php endif; ?>
<?php if ($s['done'] && $s['manual'] && !$s['auto']) : ?><span class="su-tag">segnato a mano</span><?php endif; ?>
</div>
<div class="su-text"><?php echo e($s['text']); ?></div>
<?php if ($s['detail'] !== '') : ?>
<div class="su-detail <?php echo $s['done'] ? '' : 'warn'; ?>"><?php echo e($s['detail']); ?></div>
<?php endif; ?>
</div>
<div class="su-actions">
<?php if ($s['url'] !== '') : ?>
<a href="<?php echo e($s['url']); ?>" class="btn-act <?php echo !$s['done'] && !$s['skipped'] ? 'ok' : ''; ?>"><?php echo $s['done'] ? 'Rivedi' : e($s['cta']); ?></a>
<?php endif; ?>
<?php if (!$s['done'] && !$s['skipped'] && $s['manual']) : ?>
<form method="post" action="school_setup.php"><?php echo yb_csrf_field(); ?><input type="hidden" name="action" value="mark"><input type="hidden" name="key" value="<?php echo e($s['key']); ?>"><button type="submit" class="btn-act"><i class="bx bx-check"></i> Fatto</button></form>
<?php endif; ?>
<?php if ($s['done'] && !$s['auto']) : ?>
<form method="post" action="school_setup.php"><?php echo yb_csrf_field(); ?><input type="hidden" name="action" value="unmark"><input type="hidden" name="key" value="<?php echo e($s['key']); ?>"><button type="submit" class="su-link">Non ancora</button></form>
<?php endif; ?>
<?php if (!$s['done'] && !$s['required'] && !$s['skipped']) : ?>
<form method="post" action="school_setup.php"><?php echo yb_csrf_field(); ?><input type="hidden" name="action" value="skip"><input type="hidden" name="key" value="<?php echo e($s['key']); ?>"><button type="submit" class="su-link">Salta</button></form>
<?php endif; ?>
<?php if ($s['skipped']) : ?>
<form method="post" action="school_setup.php"><?php echo yb_csrf_field(); ?><input type="hidden" name="action" value="unskip"><input type="hidden" name="key" value="<?php echo e($s['key']); ?>"><button type="submit" class="su-link">Ripristina</button></form>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
</section>
<?php endforeach; ?>
<div class="card admin-card mt-4">
<div class="card-body su-finish">
<?php if ($completedAt) : ?>
<i class="bx bx-party"></i>
<h2 class="mt-2" style="font-size:20px;font-weight:800;color:#1E4A4F;">Configurazione completata</h2>
<form method="post" action="school_setup.php" class="mt-2"><?php echo yb_csrf_field(); ?><input type="hidden" name="action" value="reopen"><button type="submit" class="su-link">Riapri il percorso e mostra di nuovo il promemoria</button></form>
<?php elseif ($missing === 0) : ?>
<i class="bx bx-rocket"></i>
<h2 class="mt-2" style="font-size:20px;font-weight:800;color:#1E4A4F;">Tutto pronto!</h2>
<p class="text-muted">Hai completato tutti i passi necessari. Chiudi il percorso: il promemoria nel menu sparirà.</p>
<form method="post" action="school_setup.php"><?php echo yb_csrf_field(); ?><input type="hidden" name="action" value="complete"><button type="submit" class="btn btn-yb" style="min-height:48px;padding:0 26px;">Ho finito</button></form>
<?php else : ?>
<p class="mb-0 text-muted">Mancano <strong><?php echo $missing; ?></strong> <?php echo $missing === 1 ? 'passo necessario' : 'passi necessari'; ?>. I passi facoltativi puoi farli dopo o saltarli.</p>
<?php endif; ?>
</div>
</div>
</div>
</div>
<?php include('include/footer.php'); ?>
</div>
</div>
</div>
<script src="assets/libs/bootstrap/js/bootstrap.bundle.min.js"></script>
<script src="assets/libs/metismenujs/metismenujs.min.js"></script>
<script src="assets/libs/simplebar/simplebar.min.js"></script>
<script src="assets/libs/eva-icons/eva.min.js"></script>
<script src="assets/js/app.js"></script>
</body>
</html>
+128
View File
@@ -0,0 +1,128 @@
<?php
require_once('include/headscript.php');
require_once('include/csrf.php');
require_once(__DIR__ . '/class/ShopService.php');
/**
* NEGOZIO della scuola (allieva loggata): catalogo, scheda prodotto, scelte.
* Il pulsante finale invia le scelte a shop_checkout.php (riepilogo, coupon, pagamento).
*
* Indirizzi: shop.php catalogo
* shop.php#/slug scheda prodotto (condivisibile)
*/
$pdo = DBHandlerSelect::getInstance()->getConnection();
function e($value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
$shopOn = SchoolSettings::get('shop_enabled', 'N') === 'Y';
$shopUrl = (string) SchoolSettings::get('shop_url', '');
// Negozio spento: l'allieva vede il link esterno (se c'è); lo staff vede comunque il catalogo
$showCatalog = $shopOn || $isstaff;
$data = $showCatalog ? ShopService::catalog((int) $idschool, (int) $iduserlogin, true) : null;
if ($data) {
$data['schoolName'] = (string) $schoolname;
}
$jsonFlags = JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP;
?>
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8" />
<title>Negozio - <?php echo e($schoolname); ?></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 href="assets/css/yb-shop.css" rel="stylesheet" type="text/css" />
</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">Negozio</h4>
</div>
</div>
<div class="d-flex">
<?php include('include/schoolswitcher.php'); ?><?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 (!$showCatalog) : ?>
<div class="ys-wrap">
<div class="ys-empty">
<i class="bx bx-store-alt"></i>
<h2>Il negozio non è ancora aperto</h2>
<?php if ($shopUrl !== '') : ?>
<p>Per acquistare pacchetti e abbonamenti usa il negozio online della scuola.</p>
<a class="ys-btn" href="<?php echo e($shopUrl); ?>" target="_blank" rel="noopener">Vai al negozio <i class="bx bx-link-external"></i></a>
<?php else : ?>
<p>Per acquistare chiedi in segreteria.</p>
<?php endif; ?>
</div>
</div>
<?php else : ?>
<?php if (!$shopOn) : ?>
<div class="ys-wrap">
<div class="ys-staffnote"><i class="bx bx-show"></i> Il negozio non è ancora attivo per le allieve: lo vedi perché fai parte dello staff. Si attiva da Opzioni Scuola → Negozio.</div>
</div>
<?php endif; ?>
<div class="ys-wrap" id="shop" aria-live="polite"></div>
<form id="checkout-form" method="post" action="shop_checkout.php" hidden>
<?php echo yb_csrf_field(); ?>
<input type="hidden" name="idvariant" id="f-variant">
<input type="hidden" name="services" id="f-services">
<input type="hidden" name="idschedule" id="f-schedule">
<input type="hidden" name="recipient_name" id="f-rname">
<input type="hidden" name="recipient_email" id="f-remail">
<input type="hidden" name="recipient_message" id="f-rmsg">
</form>
<?php endif; ?>
</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>
<?php if ($showCatalog) : ?>
<script>
window.YB_SHOP = <?php echo json_encode($data, $jsonFlags); ?>;
</script>
<script src="assets/js/yb-shop.js"></script>
<?php endif; ?>
</body>
</html>
+389
View File
@@ -0,0 +1,389 @@
<?php
require_once('include/headscript.php');
require_once('include/csrf.php');
require_once(__DIR__ . '/class/ShopService.php');
/**
* NEGOZIO - riepilogo e conferma dell'acquisto.
* Riceve le scelte da shop.php (POST), le ricontrolla sul server, applica il coupon,
* fa scegliere il pagamento e crea l'ordine (ShopService::createOrder).
* Ogni passo è un POST con CSRF; la conferma usa un token monouso contro il doppio invio.
*/
$pdo = DBHandlerSelect::getInstance()->getConnection();
function e($value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
function euro($v): string
{
return number_format((float) $v, 2, ',', '.') . ' €';
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: shop.php');
exit();
}
yb_csrf_check();
$shopOn = SchoolSettings::get('shop_enabled', 'N') === 'Y';
if (!$shopOn && !$isstaff) {
header('Location: shop.php');
exit();
}
if (!SchoolScope::isMember((int) $iduserlogin)) {
header('Location: shop.php');
exit();
}
$in = [
'idvariant' => (int) ($_POST['idvariant'] ?? 0),
'services' => array_values(array_filter(array_map('intval', explode(',', (string) ($_POST['services'] ?? ''))))),
'idschedule' => (int) ($_POST['idschedule'] ?? 0),
'recipient_name' => (string) ($_POST['recipient_name'] ?? ''),
'recipient_email' => (string) ($_POST['recipient_email'] ?? ''),
'recipient_message' => (string) ($_POST['recipient_message'] ?? ''),
];
$action = (string) ($_POST['action'] ?? 'review');
$couponCode = strtoupper(trim((string) ($_POST['coupon_code'] ?? '')));
$method = (string) ($_POST['method'] ?? '');
$error = '';
$couponMsg = '';
$sel = null;
$coupon = null;
try {
$sel = ShopService::selection((int) $idschool, (int) $iduserlogin, $in);
} catch (RuntimeException $ex) {
$error = $ex->getMessage();
} catch (PDOException $ex) {
error_log('[shop_checkout] selection: ' . $ex->getMessage());
$error = 'Si è verificato un errore. Riprova tra poco.';
}
if ($sel) {
if ($action === 'remove_coupon') {
$couponCode = '';
}
if ($couponCode !== '') {
try {
$coupon = ShopService::checkCoupon((int) $idschool, (int) $iduserlogin, $couponCode, $sel['variant']);
} catch (RuntimeException $ex) {
$couponMsg = $ex->getMessage();
$couponCode = '';
}
}
$methods = ShopService::paymentMethods((int) $idschool);
if ($action === 'confirm') {
$token = (string) ($_POST['checkout_token'] ?? '');
if ($token === '' || !hash_equals((string) ($_SESSION['yb_checkout_token'] ?? ''), $token)) {
$error = 'Questo ordine è già stato inviato. Controlla in "I miei pacchetti".';
$sel = null;
} elseif (($_POST['terms'] ?? '') !== 'Y') {
$couponMsg = $couponMsg ?: '';
$formError = 'Per continuare accetta le condizioni di acquisto.';
} elseif (!(($method === 'bank' && $methods['bank']) || ($method === 'stripe' && $methods['card']))) {
$formError = 'Scegli come vuoi pagare.';
} elseif ($method === 'stripe') {
$formError = 'Il pagamento con carta sarà disponibile a breve. Scegli il bonifico.';
} else {
unset($_SESSION['yb_checkout_token']);
try {
$idorder = ShopService::createOrder((int) $idschool, (int) $iduserlogin, $sel, $method, $coupon);
header('Location: shop_order.php?id=' . $idorder . '&new=1');
exit();
} catch (RuntimeException $ex) {
$formError = $ex->getMessage();
} catch (PDOException $ex) {
error_log('[shop_checkout] createOrder: ' . $ex->getMessage());
$formError = 'Non è stato possibile registrare l\'ordine. Riprova.';
}
}
}
}
// Token monouso per la conferma
$_SESSION['yb_checkout_token'] = bin2hex(random_bytes(16));
/* ----------------------------------------------- dati per il riepilogo */
$lines = [];
$termsUrl = (string) SchoolSettings::get('shop_terms_url', '');
$termsText = (string) SchoolSettings::get('shop_terms_text', '');
$membershipWarn = false;
if ($sel) {
$v = $sel['variant'];
$mode = $sel['mode'];
$price = (float) $v['price'];
$discount = $coupon ? min((float) $coupon['discount'], $price) : 0.0;
$total = round($price - $discount, 2);
$lessons = $v['lessons'] !== null ? (int) $v['lessons'] : null;
$unit = ['days' => ['giorno', 'giorni'], 'weeks' => ['settimana', 'settimane'], 'months' => ['mese', 'mesi']][$v['validity_unit']] ?? ['', ''];
$validity = $v['validity_value'] ? $v['validity_value'] . ' ' . ((int) $v['validity_value'] === 1 ? $unit[0] : $unit[1]) : '';
if ($mode === 'fixed') {
$st = $pdo->prepare("SELECT idservice, servicename, day, time FROM service WHERE idschool = ? AND idservice IN (" . implode(',', array_fill(0, count($sel['services']), '?')) . ")");
$st->execute(array_merge([(int) $idschool], $sel['services']));
$names = [];
foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $s) {
$names[(int) $s['idservice']] = trim($s['servicename'] . ' · ' . $s['day'] . ' ' . $s['time']);
}
$lines[] = [count($sel['services']) > 1 ? 'Le tue classi' : 'La tua classe', implode(' + ', array_map(fn($sid) => $names[$sid] ?? '', $sel['services']))];
$lines[] = ['Lezioni', $lessons ?: 'Illimitate'];
if ($validity) {
$lines[] = ['Validità', $validity . ($v['validity_start'] === 'purchase' ? ' dall\'acquisto' : ' dalla prima lezione')];
}
$lines[] = ['Prima lezione', 'Te la conferma la scuola'];
} elseif ($mode === 'free') {
$lines[] = ['Lezioni', $lessons ?: 'Illimitate'];
if ($validity) {
$lines[] = ['Validità', $validity . ($v['validity_start'] === 'purchase' ? ' dall\'acquisto' : ' dalla prima lezione')];
}
if ($sel['product']['weekly_limit']) {
$lines[] = ['Limite', 'Max ' . (int) $sel['product']['weekly_limit'] . ' lezioni a settimana'];
}
$lines[] = ['Prenotazioni', 'Le fai tu dal calendario'];
} elseif ($mode === 'single') {
$lines[] = ['Lezione', $sel['schedule']['servicename']];
$lines[] = ['Quando', ShopService::dateLabel(strtotime($sel['schedule']['dateschedule']))];
} elseif ($mode === 'membership') {
$end = ShopService::membershipEnd((int) $idschool, $v);
$lines[] = ['Validità', $end ? 'Fino al ' . date('d/m/Y', strtotime($end)) : 'Senza scadenza'];
} elseif ($mode === 'voucher') {
$lines[] = ['Per', $sel['recipient']['name']];
$lines[] = ['Invio a', $sel['recipient']['email']];
if ($validity) {
$lines[] = ['Scadenza del buono', $validity];
}
}
// Tessera obbligatoria mancante (avviso, non blocca)
if ($sel['product']['kind'] === 'lessons' && SchoolSettings::get('membership_required', 'N') === 'Y') {
$st = $pdo->prepare("SELECT 1 FROM memberships WHERE iduser = ? AND idschool = ? AND status = 'active' AND valid_to >= CURDATE() LIMIT 1");
$st->execute([(int) $iduserlogin, (int) $idschool]);
$membershipWarn = !$st->fetchColumn();
}
if ($method === '' || !(($method === 'bank' && $methods['bank']) || ($method === 'stripe' && $methods['card']))) {
$method = $methods['card'] ? 'stripe' : ($methods['bank'] ? 'bank' : '');
}
}
/** Campi nascosti che riportano la scelta a ogni invio */
function keepFields(array $in, string $coupon): string
{
$h = '';
$h .= '<input type="hidden" name="idvariant" value="' . (int) $in['idvariant'] . '">';
$h .= '<input type="hidden" name="services" value="' . e(implode(',', $in['services'])) . '">';
$h .= '<input type="hidden" name="idschedule" value="' . (int) $in['idschedule'] . '">';
foreach (['recipient_name', 'recipient_email', 'recipient_message'] as $k) {
$h .= '<input type="hidden" name="' . $k . '" value="' . e($in[$k]) . '">';
}
$h .= '<input type="hidden" name="coupon_code" value="' . e($coupon) . '">';
return $h;
}
?>
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8" />
<title>Riepilogo ordine - <?php echo e($schoolname); ?></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 href="assets/css/yb-shop.css" rel="stylesheet" type="text/css" />
</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">Riepilogo ordine</h4>
</div>
</div>
<div class="d-flex">
<?php include('include/schoolswitcher.php'); ?><?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="ys-wrap ys-checkout">
<?php if (!$sel) : ?>
<div class="ys-empty">
<i class="bx bx-error-circle" style="color:#d9a066;"></i>
<h2>Non possiamo completare l'ordine</h2>
<p><?php echo e($error); ?></p>
<a class="ys-btn" href="shop.php"><i class="bx bx-chevron-left"></i> Torna al negozio</a>
</div>
<?php else : ?>
<a class="ys-back" href="shop.php#/<?php echo e(rawurlencode($sel['product']['slug'])); ?>"><i class="bx bx-chevron-left"></i> Modifica la scelta</a>
<h1 class="ys-h1" style="margin-bottom:18px;">Riepilogo</h1>
<?php if (!empty($formError)) : ?>
<div class="ys-alert" style="background:#FDECEA;border-color:#f2c9c4;color:#8f2f24;"><i class="bx bx-error"></i>
<div><?php echo e($formError); ?></div>
</div>
<?php endif; ?>
<?php if ($membershipWarn) : ?>
<div class="ys-alert"><i class="bx bx-id-card"></i>
<div>Per frequentare le lezioni serve anche la <strong>tessera associativa</strong> valida. Puoi acquistarla dal <a href="shop.php">negozio</a> dopo questo ordine.</div>
</div>
<?php endif; ?>
<div class="ys-co-grid">
<div class="ys-co-main">
<!-- Prodotto -->
<div class="ys-panel">
<div class="ys-co-prod">
<div>
<div class="ys-opt-title"><?php echo e($sel['product']['title']); ?></div>
<div class="ys-muted"><?php echo e($v['label']); ?></div>
</div>
<div class="ys-opt-title"><?php echo e(euro($price)); ?></div>
</div>
<dl class="ys-kv">
<?php foreach ($lines as [$k, $val]) : ?>
<dt><?php echo e($k); ?></dt>
<dd><?php echo e($val); ?></dd>
<?php endforeach; ?>
</dl>
</div>
<!-- Coupon -->
<div class="ys-panel">
<form method="post" action="shop_checkout.php" class="ys-coupon">
<?php echo yb_csrf_field(); ?>
<?php echo keepFields($in, ''); ?>
<input type="hidden" name="method" value="<?php echo e($method); ?>">
<label class="ys-label" for="coupon" style="margin:0;">Hai un codice sconto?</label>
<?php if ($coupon) : ?>
<div class="ys-co-ok">
<span><i class="bx bx-check-circle"></i> <?php echo e($coupon['code']); ?> · −<?php echo e(euro($discount)); ?></span>
<input type="hidden" name="coupon_code" value="">
<button type="submit" name="action" value="remove_coupon" class="ys-linkbtn">Rimuovi</button>
</div>
<?php else : ?>
<div class="d-flex gap-2">
<input type="text" id="coupon" name="coupon_code" class="ys-input" style="text-transform:uppercase;" maxlength="40" autocomplete="off" placeholder="Codice">
<button type="submit" name="action" value="coupon" class="ys-btn ys-btn-light">Applica</button>
</div>
<?php if ($couponMsg) : ?><div class="ys-co-err"><?php echo e($couponMsg); ?></div><?php endif; ?>
<?php endif; ?>
</form>
</div>
</div>
<!-- Pagamento -->
<form method="post" action="shop_checkout.php" class="ys-co-side" id="confirm-form">
<?php echo yb_csrf_field(); ?>
<?php echo keepFields($in, $coupon ? $coupon['code'] : ''); ?>
<input type="hidden" name="checkout_token" value="<?php echo e($_SESSION['yb_checkout_token']); ?>">
<div class="ys-panel">
<span class="ys-label" style="margin:0;">Come vuoi pagare?</span>
<?php if (!$methods['bank'] && !$methods['card']) : ?>
<div class="ys-muted">Il pagamento online non è ancora attivo. Contatta la segreteria per completare l'acquisto.</div>
<?php endif; ?>
<?php if ($methods['card']) : ?>
<label class="ys-opt <?php echo $method === 'stripe' ? 'sel' : ''; ?>"><input type="radio" name="method" value="stripe" class="ys-radio-input" <?php echo $method === 'stripe' ? 'checked' : ''; ?>><span class="ys-radio"></span><span class="ys-opt-main"><span class="ys-opt-title" style="font-size:15px;">Carta di credito</span><span class="ys-muted">Pagamento immediato e sicuro.</span></span></label>
<?php endif; ?>
<?php if ($methods['bank']) : ?>
<label class="ys-opt <?php echo $method === 'bank' ? 'sel' : ''; ?>"><input type="radio" name="method" value="bank" class="ys-radio-input" <?php echo $method === 'bank' ? 'checked' : ''; ?>><span class="ys-radio"></span><span class="ys-opt-main"><span class="ys-opt-title" style="font-size:15px;">Bonifico bancario</span><span class="ys-muted"><?php echo $sel['mode'] === 'single' ? 'Il posto resta prenotato in attesa del pagamento.' : 'Il pacchetto si attiva quando la scuola riceve il pagamento.'; ?></span></span></label>
<?php endif; ?>
<div class="ys-co-totals">
<div><span>Subtotale</span><span><?php echo e(euro($price)); ?></span></div>
<?php if ($discount > 0) : ?><div class="ok"><span>Sconto <?php echo e($coupon['code']); ?></span><span>− <?php echo e(euro($discount)); ?></span></div><?php endif; ?>
<div class="tot"><span>Totale</span><span><?php echo e(euro($total)); ?></span></div>
</div>
<label class="ys-terms" for="terms">
<input type="checkbox" id="terms" name="terms" value="Y">
<span>Ho letto e accetto le
<?php if ($termsUrl !== '') : ?><a href="<?php echo e($termsUrl); ?>" target="_blank" rel="noopener">condizioni di acquisto</a>
<?php elseif ($termsText !== '') : ?><a href="#" id="terms-open">condizioni di acquisto</a>
<?php else : ?>condizioni di acquisto
<?php endif; ?> della scuola.</span>
</label>
<?php if ($termsUrl === '' && $termsText !== '') : ?>
<div class="ys-terms-text" id="terms-text" hidden><?php echo nl2br(e($termsText)); ?></div>
<?php endif; ?>
<button type="submit" name="action" value="confirm" class="ys-btn" id="confirm-btn" style="width:100%;justify-content:center;" <?php echo (!$methods['bank'] && !$methods['card']) ? 'disabled' : ''; ?>>
<?php echo $method === 'stripe' ? 'Paga ' . e(euro($total)) : 'Conferma ordine · ' . e(euro($total)); ?>
</button>
</div>
</form>
</div>
<?php endif; ?>
</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>
(function() {
var form = document.getElementById('confirm-form');
if (!form) return;
var total = <?php echo json_encode(isset($total) ? euro($total) : ''); ?>;
var btn = document.getElementById('confirm-btn');
form.addEventListener('change', function(ev) {
if (ev.target.name !== 'method') return;
form.querySelectorAll('label.ys-opt').forEach(function(l) {
l.classList.toggle('sel', l.querySelector('input').checked);
});
btn.textContent = ev.target.value === 'stripe' ? 'Paga ' + total : 'Conferma ordine · ' + total;
});
form.addEventListener('submit', function(ev) {
if (!document.getElementById('terms').checked) {
ev.preventDefault();
document.querySelector('.ys-terms').classList.add('need');
return;
}
setTimeout(function() {
btn.disabled = true;
btn.textContent = 'Attendi…';
}, 0);
});
var open = document.getElementById('terms-open');
if (open) open.addEventListener('click', function(ev) {
ev.preventDefault();
var t = document.getElementById('terms-text');
t.hidden = !t.hidden;
});
})();
</script>
</body>
</html>
+285
View File
@@ -0,0 +1,285 @@
<?php
require_once('include/headscript.php');
require_once('include/csrf.php');
require_once(__DIR__ . '/class/ShopService.php');
/**
* NEGOZIO - dettaglio di un ordine dell'allieva (anche pagina di conferma dopo l'acquisto).
* shop_order.php?id=123 dettaglio
* shop_order.php?id=123&new=1 conferma appena creato
* Solo ordini dell'utente della sessione nella scuola corrente.
* L'allieva può annullare un ordine con bonifico finché è in attesa di pagamento.
*/
$pdo = DBHandlerSelect::getInstance()->getConnection();
function e($value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
function euro($v): string
{
return number_format((float) $v, 2, ',', '.') . ' €';
}
$id = (int) ($_GET['id'] ?? $_POST['id'] ?? 0);
$st = $pdo->prepare("SELECT ob.*, p.title AS ptitle, p.kind FROM orderbook ob LEFT JOIN products p ON p.idproduct = ob.idproduct WHERE ob.idorderbook = ? AND ob.iduser = ? AND ob.idschool = ?");
$st->execute([$id, (int) $iduserlogin, (int) $idschool]);
$o = $st->fetch(PDO::FETCH_ASSOC);
if (!$o) {
header('Location: shop.php');
exit();
}
/* ------------------------------------------- annullo (solo bonifico in attesa) */
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'cancel') {
yb_csrf_check();
try {
ShopService::cancelPending((int) $idschool, $id, (int) $iduserlogin);
} catch (PDOException $ex) {
error_log('[shop_order] cancel: ' . $ex->getMessage());
header('Location: shop_order.php?id=' . $id . '&err=1');
exit();
}
header('Location: shop_order.php?id=' . $id . '&cancelled=1');
exit();
}
/* ------------------------------------------------------------------ DATI */
$rules = json_decode((string) $o['rules_snapshot'], true) ?: [];
$mode = $o['booking_mode'] ?: ($o['kind'] ?? '');
$classes = [];
if ($mode === 'fixed') {
$st = $pdo->prepare("SELECT s.servicename, s.day, s.time FROM orderbook_services os JOIN service s ON s.idservice = os.idservice WHERE os.idorderbook = ? AND os.idschool = ? ORDER BY os.slot");
$st->execute([$id, (int) $idschool]);
$classes = $st->fetchAll(PDO::FETCH_ASSOC);
}
$lesson = null;
if ($mode === 'single') {
$st = $pdo->prepare("SELECT b.bookingstart, b.status, s.servicename FROM bookingclass b JOIN service s ON s.idservice = b.idservice WHERE b.idorder = ? AND b.idschool = ? ORDER BY b.idbookingclass LIMIT 1");
$st->execute([$id, (int) $idschool]);
$lesson = $st->fetch(PDO::FETCH_ASSOC) ?: null;
}
$voucher = null;
if (!empty($o['cod'])) {
$st = $pdo->prepare("SELECT * FROM gift_vouchers WHERE idschool = ? AND code = ?");
$st->execute([(int) $idschool, $o['cod']]);
$voucher = $st->fetch(PDO::FETCH_ASSOC) ?: null;
}
$pending = $o['payment_status'] === 'pending';
$cancelled = in_array($o['payment_status'], ['cancelled', 'refunded'], true);
$bank = ($o['source'] === 'bank' && $pending) ? ShopService::bankInfo((int) $idschool, $o) : null;
$isNew = isset($_GET['new']);
$statusLabel = $cancelled ? ['Annullato', 'muted'] : ($pending ? ['In attesa di pagamento', 'warn'] : ['Pagato', 'ok']);
$next = '';
if (!$cancelled) {
if ($mode === 'fixed') {
$next = $pending
? 'Quando la scuola riceve il bonifico ti conferma la data della prima lezione; da lì le lezioni vengono prenotate in automatico ogni settimana.'
: 'La scuola ti conferma la data della prima lezione; da lì le lezioni vengono prenotate in automatico ogni settimana.';
} elseif ($mode === 'free') {
$next = $pending
? 'Appena la scuola riceve il bonifico il pacchetto si attiva e puoi prenotare le lezioni dal calendario.'
: 'Il pacchetto è attivo: prenota le lezioni dal calendario quando vuoi.';
} elseif ($mode === 'single' && $lesson) {
$next = 'Il tuo posto è ' . ($pending ? 'riservato in attesa del pagamento' : 'prenotato') . ': ' . $lesson['servicename'] . ', ' . mb_strtolower(ShopService::dateLabel(strtotime($lesson['bookingstart']))) . '.';
} elseif ($o['kind'] === 'membership') {
$next = $pending ? 'La tessera si attiva quando la scuola riceve il pagamento.' : 'La tessera è attiva' . ($o['expireon'] ? ' fino al ' . date('d/m/Y', strtotime($o['expireon'])) : '') . '.';
} elseif ($o['kind'] === 'voucher' && $voucher) {
$next = $pending
? 'Il buono per ' . $voucher['recipient_name'] . ' si attiva quando la scuola riceve il pagamento.'
: 'Il buono per ' . $voucher['recipient_name'] . ' è attivo. Codice: ' . $voucher['code'] . '.';
}
}
?>
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8" />
<title>Ordine n. <?php echo (int) $id; ?> - <?php echo e($schoolname); ?></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 href="assets/css/yb-shop.css" rel="stylesheet" type="text/css" />
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
</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">Ordine n. <?php echo (int) $id; ?></h4>
</div>
</div>
<div class="d-flex">
<?php include('include/schoolswitcher.php'); ?><?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="ys-wrap ys-checkout" style="max-width:760px;">
<?php if (isset($_GET['cancelled'])) : ?>
<div class="ys-alert"><i class="bx bx-info-circle"></i>
<div>Ordine annullato.</div>
</div>
<?php elseif (isset($_GET['err'])) : ?>
<div class="ys-alert" style="background:#FDECEA;border-color:#f2c9c4;color:#8f2f24;"><i class="bx bx-error"></i>
<div>Non è stato possibile annullare l'ordine. Riprova o contatta la segreteria.</div>
</div>
<?php endif; ?>
<?php if ($isNew && !$cancelled) : ?>
<div class="ys-done">
<span class="ys-done-ico"><i class="bx bx-check"></i></span>
<h1 class="ys-h1"><?php echo $pending ? 'Ordine registrato' : 'Acquisto completato'; ?></h1>
<p class="ys-lead"><?php echo $bank ? 'Ultimo passo: fai il bonifico con i dati qui sotto.' : 'Grazie! Trovi tutto anche in "I miei pacchetti".'; ?></p>
</div>
<?php endif; ?>
<?php if ($bank) : ?>
<div class="ys-panel ys-bank">
<div class="ys-label" style="margin:0;"><i class="bx bx-building-house"></i> Dati per il bonifico</div>
<dl class="ys-kv">
<dt>Intestatario</dt>
<dd><?php echo e($bank['holder']); ?></dd>
<dt>IBAN</dt>
<dd><span class="ys-mono" id="iban"><?php echo e($bank['iban']); ?></span> <button type="button" class="ys-linkbtn" data-copy="iban">Copia</button></dd>
<?php if ($bank['bank'] !== '') : ?><dt>Banca</dt>
<dd><?php echo e($bank['bank']); ?></dd><?php endif; ?>
<dt>Importo</dt>
<dd><strong><?php echo e(euro($o['amount'])); ?></strong></dd>
<dt>Causale</dt>
<dd><strong id="ref"><?php echo e($bank['reference']); ?></strong> <button type="button" class="ys-linkbtn" data-copy="ref">Copia</button></dd>
<dt>Entro il</dt>
<dd><?php echo e(date('d/m/Y', strtotime($bank['due']))); ?></dd>
</dl>
<div class="ys-muted">Usa esattamente questa causale: così la scuola riconosce subito il tuo pagamento.</div>
</div>
<?php endif; ?>
<div class="ys-panel">
<div class="ys-co-prod">
<div>
<div class="ys-opt-title"><?php echo e($o['product_name']); ?></div>
<div class="ys-muted">Ordine n. <?php echo (int) $id; ?> del <?php echo e(date('d/m/Y', strtotime($o['order_date_created']))); ?></div>
</div>
<span class="ys-status <?php echo $statusLabel[1]; ?>"><?php echo e($statusLabel[0]); ?></span>
</div>
<dl class="ys-kv">
<?php foreach ($classes as $c) : ?>
<dt>Classe</dt>
<dd><?php echo e(trim($c['servicename'] . ' · ' . $c['day'] . ' ' . $c['time'])); ?></dd>
<?php endforeach; ?>
<?php if ($lesson) : ?>
<dt>Lezione</dt>
<dd><?php echo e($lesson['servicename'] . ' · ' . ShopService::dateLabel(strtotime($lesson['bookingstart']))); ?></dd>
<?php endif; ?>
<?php if ($o['nticket']) : ?><dt>Lezioni</dt>
<dd><?php echo (int) $o['nticket']; ?></dd><?php endif; ?>
<?php if ($o['expireon'] && $mode !== 'single') : ?><dt>Valido fino al</dt>
<dd><?php echo e(date('d/m/Y', strtotime($o['expireon']))); ?></dd><?php endif; ?>
<?php if ($voucher) : ?><dt>Buono per</dt>
<dd><?php echo e($voucher['recipient_name'] . ' · ' . $voucher['recipient_email']); ?></dd><?php endif; ?>
<?php if ((float) $o['discount_amount'] > 0) : ?><dt>Sconto</dt>
<dd>− <?php echo e(euro($o['discount_amount'])); ?></dd><?php endif; ?>
<dt>Totale</dt>
<dd><strong><?php echo e(euro($o['amount'])); ?></strong> · <?php echo $o['source'] === 'bank' ? 'bonifico' : ($o['source'] === 'stripe' ? 'carta' : e($o['source'])); ?></dd>
</dl>
<?php if ($next !== '') : ?>
<div class="ys-note"><i class="bx bx-info-circle" style="color:#2F7F84;font-size:18px;"></i><span><?php echo e($next); ?></span></div>
<?php endif; ?>
</div>
<div class="d-flex gap-2 flex-wrap justify-content-between align-items-center mt-3">
<div class="d-flex gap-2 flex-wrap">
<a class="ys-btn" href="orders.php">I miei pacchetti</a>
<a class="ys-btn ys-btn-light" href="shop.php">Torna al negozio</a>
</div>
<?php if ($bank) : ?>
<form method="post" action="shop_order.php?id=<?php echo (int) $id; ?>" id="cancel-form">
<?php echo yb_csrf_field(); ?>
<input type="hidden" name="action" value="cancel">
<button type="submit" class="ys-linkbtn" style="color:#b03a2e;">Annulla ordine</button>
</form>
<?php endif; ?>
</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.querySelectorAll('[data-copy]').forEach(function(btn) {
btn.addEventListener('click', function() {
var text = document.getElementById(btn.getAttribute('data-copy')).textContent.trim();
var done = function() {
btn.textContent = 'Copiato';
setTimeout(function() {
btn.textContent = 'Copia';
}, 1500);
};
if (navigator.clipboard) navigator.clipboard.writeText(text).then(done, function() {
window.prompt('Copia:', text);
});
else window.prompt('Copia:', text);
});
});
var cf = document.getElementById('cancel-form');
if (cf) cf.addEventListener('submit', function(ev) {
if (cf.dataset.ok) return;
ev.preventDefault();
Swal.fire({
title: 'Annullare l\'ordine?',
text: 'Fallo solo se non hai ancora fatto il bonifico.<?php echo $mode === 'single' ? ' Il posto nella lezione verrà liberato.' : ''; ?>',
icon: 'warning',
showCancelButton: true,
reverseButtons: true,
confirmButtonText: 'Annulla ordine',
cancelButtonText: 'Chiudi',
confirmButtonColor: '#c0392b'
}).then(function(r) {
if (r.isConfirmed) {
cf.dataset.ok = '1';
cf.submit();
}
});
});
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB