From a8ecf86b7b9c749cd58e15cdfd9f254c77ff4b66 Mon Sep 17 00:00:00 2001 From: solocla Date: Thu, 8 Oct 2026 15:07:54 +0200 Subject: [PATCH] Catalogo prodotti: pagina admin Prodotti (elenco, scheda, opzioni, immagini) + helper CSRF --- .../20261008124927_create_product_catalog.php | 316 +++++ public/class/SchoolScope.php | 10 + public/include/csrf.php | 40 + public/product_action.php | 73 + public/product_form.php | 1190 +++++++++++++++++ public/product_save.php | 404 ++++++ public/product_upload.php | 58 + public/products.php | 464 +++++++ .../1/products/2b6091e45a00ed79517af1c0.png | Bin 0 -> 1243417 bytes .../1/products/2c6a8c403655ce2955078d05.jpg | Bin 0 -> 164231 bytes .../1/products/7f659bdb69c1016992e478c7.png | Bin 0 -> 1065875 bytes .../1/products/e36587a154b1525b029b6b64.jpg | Bin 0 -> 333076 bytes 12 files changed, 2555 insertions(+) create mode 100644 db/migrations/20261008124927_create_product_catalog.php create mode 100644 public/include/csrf.php create mode 100644 public/product_action.php create mode 100644 public/product_form.php create mode 100644 public/product_save.php create mode 100644 public/product_upload.php create mode 100644 public/products.php create mode 100644 public/uploads/schools/1/products/2b6091e45a00ed79517af1c0.png create mode 100644 public/uploads/schools/1/products/2c6a8c403655ce2955078d05.jpg create mode 100644 public/uploads/schools/1/products/7f659bdb69c1016992e478c7.png create mode 100644 public/uploads/schools/1/products/e36587a154b1525b029b6b64.jpg diff --git a/db/migrations/20261008124927_create_product_catalog.php b/db/migrations/20261008124927_create_product_catalog.php new file mode 100644 index 0000000..c23a7f3 --- /dev/null +++ b/db/migrations/20261008124927_create_product_catalog.php @@ -0,0 +1,316 @@ + false, + 'engine' => 'InnoDB', + 'encoding' => 'utf8mb4', + 'collation' => 'utf8mb4_unicode_ci', + ]; + + private const CASCADE = ['delete' => 'CASCADE', 'update' => 'NO_ACTION']; + private const SETNULL = ['delete' => 'SET_NULL', 'update' => 'NO_ACTION']; + private const RESTRICT = ['delete' => 'RESTRICT', 'update' => 'NO_ACTION']; + + public function up(): void + { + // ------------------------------------------------------------------ + // products: la scheda di vendita + le regole + // ------------------------------------------------------------------ + $this->table('products', self::OPT + ['primary_key' => ['idproduct']]) + ->addColumn('idproduct', 'integer', ['signed' => false, 'identity' => true]) + ->addColumn('idschool', 'integer', ['signed' => false]) + ->addColumn('kind', 'string', ['limit' => 20, 'default' => 'lessons', 'comment' => 'lessons|membership|voucher']) + // presentazione + ->addColumn('title', 'string', ['limit' => 191]) + ->addColumn('subtitle', 'string', ['limit' => 255, 'null' => true]) + ->addColumn('slug', 'string', ['limit' => 191]) + ->addColumn('description', 'text', ['limit' => MysqlAdapter::TEXT_LONG, 'null' => true, 'comment' => 'HTML editor']) + ->addColumn('suitable_for', 'text', ['null' => true, 'comment' => 'per chi e']) + ->addColumn('pros', 'text', ['null' => true, 'comment' => 'vantaggi, uno per riga']) + ->addColumn('cons', 'text', ['null' => true, 'comment' => 'da considerare, uno per riga']) + ->addColumn('requirements', 'text', ['null' => true, 'comment' => 'cosa serve / cosa portare']) + ->addColumn('cover_image', 'string', ['limit' => 191, 'null' => true]) + ->addColumn('badge', 'string', ['limit' => 50, 'null' => true, 'comment' => 'es. Il piu scelto']) + // regole + ->addColumn('booking_mode', 'string', ['limit' => 10, 'default' => 'free', 'comment' => 'fixed|free|single']) + ->addColumn('fixed_slots', 'integer', ['default' => 1, 'comment' => 'classi fisse a settimana (fixed)']) + ->addColumn('weekly_limit', 'integer', ['null' => true, 'comment' => 'max lezioni a settimana, NULL = nessuno']) + ->addColumn('max_reschedule', 'integer', ['null' => true, 'comment' => 'recuperi, NULL = default scuola']) + ->addColumn('once_per_student', 'string', ['limit' => 1, 'default' => 'N', 'comment' => 'Y = es. lezione di prova']) + // visibilita + ->addColumn('visible_online', 'string', ['limit' => 1, 'default' => 'Y']) + ->addColumn('active', 'string', ['limit' => 1, 'default' => 'Y']) + ->addColumn('sort_order', 'integer', ['default' => 0]) + ->addColumn('created_at', 'timestamp', ['default' => 'CURRENT_TIMESTAMP']) + ->addColumn('updated_at', 'timestamp', ['null' => true, 'update' => 'CURRENT_TIMESTAMP']) + ->addIndex(['idschool', 'slug'], ['unique' => true, 'name' => 'products_school_slug_unique']) + ->addIndex(['idschool', 'active'], ['name' => 'products_school_active']) + ->addForeignKey('idschool', 'schools', 'idschool', self::CASCADE) + ->create(); + + // ------------------------------------------------------------------ + // product_variants: le opzioni acquistabili (prezzo, lezioni, durata) + // ------------------------------------------------------------------ + $this->table('product_variants', self::OPT + ['primary_key' => ['idvariant']]) + ->addColumn('idvariant', 'integer', ['signed' => false, 'identity' => true]) + ->addColumn('idproduct', 'integer', ['signed' => false]) + ->addColumn('idschool', 'integer', ['signed' => false]) + ->addColumn('label', 'string', ['limit' => 100, 'comment' => 'es. 8 lezioni']) + ->addColumn('lessons', 'integer', ['null' => true, 'comment' => 'NULL = illimitate']) + ->addColumn('validity_mode', 'string', ['limit' => 20, 'default' => 'duration', 'comment' => 'duration|calendar_year|season|none']) + ->addColumn('validity_value', 'integer', ['null' => true]) + ->addColumn('validity_unit', 'string', ['limit' => 10, 'null' => true, 'comment' => 'days|weeks|months']) + ->addColumn('validity_start', 'string', ['limit' => 20, 'default' => 'first_lesson', 'comment' => 'purchase|first_lesson']) + ->addColumn('price', 'decimal', ['precision' => 8, 'scale' => 2, 'default' => 0]) + ->addColumn('compare_price', 'decimal', ['precision' => 8, 'scale' => 2, 'null' => true, 'comment' => 'prezzo barrato']) + ->addColumn('price_note', 'string', ['limit' => 100, 'null' => true, 'comment' => 'es. circa 9 euro a lezione']) + ->addColumn('target_idvariant', 'integer', ['signed' => false, 'null' => true, 'comment' => 'buono regalo: variante regalata']) + ->addColumn('wc_product_id', 'integer', ['null' => true]) + ->addColumn('wc_variation_id', 'integer', ['null' => true]) + ->addColumn('active', 'string', ['limit' => 1, 'default' => 'Y']) + ->addColumn('sort_order', 'integer', ['default' => 0]) + ->addColumn('created_at', 'timestamp', ['default' => 'CURRENT_TIMESTAMP']) + ->addColumn('updated_at', 'timestamp', ['null' => true, 'update' => 'CURRENT_TIMESTAMP']) + ->addIndex(['idschool'], ['name' => 'product_variants_idschool']) + ->addIndex(['wc_product_id', 'wc_variation_id'], ['name' => 'product_variants_wc']) + ->addForeignKey('idproduct', 'products', 'idproduct', self::CASCADE) + ->addForeignKey('idschool', 'schools', 'idschool', self::CASCADE) + ->create(); + + $this->table('product_variants') + ->addForeignKey('target_idvariant', 'product_variants', 'idvariant', self::SETNULL) + ->update(); + + // ------------------------------------------------------------------ + // product_services: su quali classi / categorie vale il prodotto + // ------------------------------------------------------------------ + $this->table('product_services', self::OPT + ['primary_key' => ['idproductservice']]) + ->addColumn('idproductservice', 'integer', ['signed' => false, 'identity' => true]) + ->addColumn('idproduct', 'integer', ['signed' => false]) + ->addColumn('idschool', 'integer', ['signed' => false]) + ->addColumn('idservice', 'integer', ['null' => true]) + ->addColumn('idservicecategory', 'integer', ['null' => true]) + ->addIndex(['idschool'], ['name' => 'product_services_idschool']) + ->addIndex(['idservice'], ['name' => 'product_services_idservice']) + ->addForeignKey('idproduct', 'products', 'idproduct', self::CASCADE) + ->addForeignKey('idservice', 'service', 'idservice', self::CASCADE) + ->addForeignKey('idservicecategory', 'servicecategory', 'idservicecategory', self::CASCADE) + ->create(); + + // ------------------------------------------------------------------ + // product_images: galleria del prodotto + // ------------------------------------------------------------------ + $this->table('product_images', self::OPT + ['primary_key' => ['idimage']]) + ->addColumn('idimage', 'integer', ['signed' => false, 'identity' => true]) + ->addColumn('idproduct', 'integer', ['signed' => false]) + ->addColumn('idschool', 'integer', ['signed' => false]) + ->addColumn('filename', 'string', ['limit' => 191]) + ->addColumn('sort_order', 'integer', ['default' => 0]) + ->addColumn('created_at', 'timestamp', ['default' => 'CURRENT_TIMESTAMP']) + ->addIndex(['idschool'], ['name' => 'product_images_idschool']) + ->addForeignKey('idproduct', 'products', 'idproduct', self::CASCADE) + ->create(); + + // ------------------------------------------------------------------ + // orderbook_services: le 1-2 classi scelte nei pacchetti fissi + // (nessuna FK verso service, come bookingclass -> serviceschedule) + // ------------------------------------------------------------------ + $this->table('orderbook_services', self::OPT + ['primary_key' => ['idorderbookservice']]) + ->addColumn('idorderbookservice', 'integer', ['signed' => false, 'identity' => true]) + ->addColumn('idorderbook', 'integer') + ->addColumn('idschool', 'integer', ['signed' => false]) + ->addColumn('idservice', 'integer') + ->addColumn('slot', 'integer', ['default' => 1, 'comment' => '1 = prima classe, 2 = seconda (bisettimanale)']) + ->addIndex(['idorderbook', 'slot'], ['unique' => true, 'name' => 'orderbook_services_order_slot']) + ->addIndex(['idschool', 'idservice'], ['name' => 'orderbook_services_school_service']) + ->addForeignKey('idorderbook', 'orderbook', 'idorderbook', self::CASCADE) + ->create(); + + // ------------------------------------------------------------------ + // memberships: tessere / quota associativa + // ------------------------------------------------------------------ + $this->table('memberships', self::OPT + ['primary_key' => ['idmembership']]) + ->addColumn('idmembership', 'integer', ['signed' => false, 'identity' => true]) + ->addColumn('idschool', 'integer', ['signed' => false]) + ->addColumn('iduser', 'integer', ['signed' => false]) + ->addColumn('idorderbook', 'integer', ['null' => true, 'comment' => 'pacchetto di origine']) + ->addColumn('card_number', 'string', ['limit' => 50, 'null' => true]) + ->addColumn('valid_from', 'date') + ->addColumn('valid_to', 'date') + ->addColumn('status', 'string', ['limit' => 20, 'default' => 'active', 'comment' => 'active|cancelled']) + ->addColumn('notes', 'text', ['null' => true]) + ->addColumn('created_at', 'timestamp', ['default' => 'CURRENT_TIMESTAMP']) + ->addIndex(['idschool', 'iduser', 'valid_to'], ['name' => 'memberships_school_user_valid']) + ->addForeignKey('idschool', 'schools', 'idschool', self::CASCADE) + ->addForeignKey('iduser', 'auth_users', 'id', self::CASCADE) + ->addForeignKey('idorderbook', 'orderbook', 'idorderbook', self::SETNULL) + ->create(); + + // ------------------------------------------------------------------ + // gift_vouchers: buoni regalo (regalano una variante precisa) + // ------------------------------------------------------------------ + $this->table('gift_vouchers', self::OPT + ['primary_key' => ['idvoucher']]) + ->addColumn('idvoucher', 'integer', ['signed' => false, 'identity' => true]) + ->addColumn('idschool', 'integer', ['signed' => false]) + ->addColumn('code', 'string', ['limit' => 32]) + ->addColumn('idvariant', 'integer', ['signed' => false, 'comment' => 'variante regalata']) + ->addColumn('buyer_iduser', 'integer', ['signed' => false, 'null' => true]) + ->addColumn('buyer_name', 'string', ['limit' => 191, 'null' => true]) + ->addColumn('buyer_email', 'string', ['limit' => 191, 'null' => true]) + ->addColumn('recipient_name', 'string', ['limit' => 191, 'null' => true]) + ->addColumn('recipient_email', 'string', ['limit' => 191, 'null' => true]) + ->addColumn('message', 'text', ['null' => true]) + ->addColumn('source', 'string', ['limit' => 20, 'null' => true, 'comment' => 'manual|stripe|bank|woocommerce']) + ->addColumn('amount', 'decimal', ['precision' => 8, 'scale' => 2, 'null' => true]) + ->addColumn('expires_at', 'date', ['null' => true]) + ->addColumn('status', 'string', ['limit' => 20, 'default' => 'active', 'comment' => 'active|redeemed|cancelled|expired']) + ->addColumn('redeemed_by', 'integer', ['signed' => false, 'null' => true]) + ->addColumn('redeemed_at', 'datetime', ['null' => true]) + ->addColumn('redeemed_idorderbook', 'integer', ['null' => true]) + ->addColumn('created_at', 'timestamp', ['default' => 'CURRENT_TIMESTAMP']) + ->addIndex(['idschool', 'code'], ['unique' => true, 'name' => 'gift_vouchers_school_code']) + ->addForeignKey('idschool', 'schools', 'idschool', self::CASCADE) + ->addForeignKey('idvariant', 'product_variants', 'idvariant', self::RESTRICT) + ->addForeignKey('buyer_iduser', 'auth_users', 'id', self::SETNULL) + ->addForeignKey('redeemed_by', 'auth_users', 'id', self::SETNULL) + ->addForeignKey('redeemed_idorderbook', 'orderbook', 'idorderbook', self::SETNULL) + ->create(); + + // ------------------------------------------------------------------ + // coupons + // ------------------------------------------------------------------ + $this->table('coupons', self::OPT + ['primary_key' => ['idcoupon']]) + ->addColumn('idcoupon', 'integer', ['signed' => false, 'identity' => true]) + ->addColumn('idschool', 'integer', ['signed' => false]) + ->addColumn('code', 'string', ['limit' => 40]) + ->addColumn('description', 'string', ['limit' => 191, 'null' => true]) + ->addColumn('discount_type', 'string', ['limit' => 10, 'default' => 'percent', 'comment' => 'percent|fixed']) + ->addColumn('discount_value', 'decimal', ['precision' => 8, 'scale' => 2, 'default' => 0]) + ->addColumn('valid_from', 'date', ['null' => true]) + ->addColumn('valid_to', 'date', ['null' => true]) + ->addColumn('max_uses', 'integer', ['null' => true, 'comment' => 'NULL = illimitati']) + ->addColumn('max_uses_per_user', 'integer', ['null' => true, 'default' => 1]) + ->addColumn('min_amount', 'decimal', ['precision' => 8, 'scale' => 2, 'null' => true]) + ->addColumn('new_students_only', 'string', ['limit' => 1, 'default' => 'N']) + ->addColumn('active', 'string', ['limit' => 1, 'default' => 'Y']) + ->addColumn('created_at', 'timestamp', ['default' => 'CURRENT_TIMESTAMP']) + ->addColumn('updated_at', 'timestamp', ['null' => true, 'update' => 'CURRENT_TIMESTAMP']) + ->addIndex(['idschool', 'code'], ['unique' => true, 'name' => 'coupons_school_code']) + ->addForeignKey('idschool', 'schools', 'idschool', self::CASCADE) + ->create(); + + $this->table('coupon_products', self::OPT + ['primary_key' => ['idcouponproduct']]) + ->addColumn('idcouponproduct', 'integer', ['signed' => false, 'identity' => true]) + ->addColumn('idcoupon', 'integer', ['signed' => false]) + ->addColumn('idschool', 'integer', ['signed' => false]) + ->addColumn('idproduct', 'integer', ['signed' => false, 'null' => true]) + ->addColumn('idvariant', 'integer', ['signed' => false, 'null' => true]) + ->addIndex(['idschool'], ['name' => 'coupon_products_idschool']) + ->addForeignKey('idcoupon', 'coupons', 'idcoupon', self::CASCADE) + ->addForeignKey('idproduct', 'products', 'idproduct', self::CASCADE) + ->addForeignKey('idvariant', 'product_variants', 'idvariant', self::CASCADE) + ->create(); + + $this->table('coupon_redemptions', self::OPT + ['primary_key' => ['idredemption']]) + ->addColumn('idredemption', 'integer', ['signed' => false, 'identity' => true]) + ->addColumn('idcoupon', 'integer', ['signed' => false]) + ->addColumn('idschool', 'integer', ['signed' => false]) + ->addColumn('iduser', 'integer', ['signed' => false, 'null' => true]) + ->addColumn('idorderbook', 'integer', ['null' => true]) + ->addColumn('discount_amount', 'decimal', ['precision' => 8, 'scale' => 2, 'default' => 0]) + ->addColumn('redeemed_at', 'timestamp', ['default' => 'CURRENT_TIMESTAMP']) + ->addIndex(['idschool'], ['name' => 'coupon_redemptions_idschool']) + ->addIndex(['idcoupon', 'iduser'], ['name' => 'coupon_redemptions_coupon_user']) + ->addForeignKey('idcoupon', 'coupons', 'idcoupon', self::RESTRICT) + ->addForeignKey('iduser', 'auth_users', 'id', self::SETNULL) + ->addForeignKey('idorderbook', 'orderbook', 'idorderbook', self::SETNULL) + ->create(); + + // ------------------------------------------------------------------ + // orderbook: collegamento al prodotto + copia delle regole + pagamento + // ------------------------------------------------------------------ + $this->table('orderbook') + ->addColumn('idproduct', 'integer', ['signed' => false, 'null' => true]) + ->addColumn('idvariant', 'integer', ['signed' => false, 'null' => true]) + ->addColumn('booking_mode', 'string', ['limit' => 10, 'null' => true, 'comment' => 'fixed|free|single (copia)']) + ->addColumn('weekly_limit', 'integer', ['null' => true, 'comment' => 'copia dal prodotto']) + ->addColumn('rules_snapshot', 'text', ['null' => true, 'comment' => 'JSON: regole al momento dell acquisto']) + ->addColumn('source', 'string', ['limit' => 20, 'null' => true, 'comment' => 'woocommerce|manual|stripe|bank|voucher; NULL = storico']) + ->addColumn('payment_status', 'string', ['limit' => 20, 'null' => true, 'comment' => 'paid|pending|refunded']) + ->addColumn('amount', 'decimal', ['precision' => 8, 'scale' => 2, 'null' => true]) + ->addColumn('discount_amount', 'decimal', ['precision' => 8, 'scale' => 2, 'null' => true]) + ->addColumn('idcoupon', 'integer', ['signed' => false, 'null' => true]) + ->addIndex(['idschool', 'idproduct'], ['name' => 'orderbook_school_product']) + ->addForeignKey('idproduct', 'products', 'idproduct', self::SETNULL) + ->addForeignKey('idvariant', 'product_variants', 'idvariant', self::SETNULL) + ->addForeignKey('idcoupon', 'coupons', 'idcoupon', self::SETNULL) + ->update(); + + // ------------------------------------------------------------------ + // option: impostazioni tessera per scuola + // ------------------------------------------------------------------ + $this->table('option') + ->addColumn('membership_required', 'string', ['limit' => 1, 'default' => 'N', 'comment' => 'Y = tessera valida obbligatoria per prenotare']) + ->addColumn('membership_season_start', 'integer', ['default' => 9, 'comment' => 'mese di inizio anno sportivo (1-12)']) + ->update(); + } + + public function down(): void + { + $this->table('option') + ->removeColumn('membership_required') + ->removeColumn('membership_season_start') + ->update(); + + $this->table('orderbook') + ->dropForeignKey('idproduct') + ->dropForeignKey('idvariant') + ->dropForeignKey('idcoupon') + ->update(); + + $this->table('orderbook') + ->removeIndexByName('orderbook_school_product') + ->removeColumn('idproduct') + ->removeColumn('idvariant') + ->removeColumn('booking_mode') + ->removeColumn('weekly_limit') + ->removeColumn('rules_snapshot') + ->removeColumn('source') + ->removeColumn('payment_status') + ->removeColumn('amount') + ->removeColumn('discount_amount') + ->removeColumn('idcoupon') + ->update(); + + foreach ( + [ + 'coupon_redemptions', + 'coupon_products', + 'coupons', + 'gift_vouchers', + 'memberships', + 'orderbook_services', + 'product_images', + 'product_services', + ] as $t + ) { + $this->table($t)->drop()->save(); + } + + $this->table('product_variants')->dropForeignKey('target_idvariant')->update(); + $this->table('product_variants')->drop()->save(); + $this->table('products')->drop()->save(); + } +} diff --git a/public/class/SchoolScope.php b/public/class/SchoolScope.php index ad4f03b..beffb89 100644 --- a/public/class/SchoolScope.php +++ b/public/class/SchoolScope.php @@ -37,6 +37,16 @@ class SchoolScope 'event_bookings' => 'id', 'event_locations' => 'id', 'school_images' => 'idimage', + 'products' => 'idproduct', + 'product_variants' => 'idvariant', + 'product_services' => 'idproductservice', + 'product_images' => 'idimage', + 'orderbook_services' => 'idorderbookservice', + 'memberships' => 'idmembership', + 'gift_vouchers' => 'idvoucher', + 'coupons' => 'idcoupon', + 'coupon_products' => 'idcouponproduct', + 'coupon_redemptions' => 'idredemption', ]; /** Il record $id della tabella appartiene alla scuola corrente? */ diff --git a/public/include/csrf.php b/public/include/csrf.php new file mode 100644 index 0000000..149100e --- /dev/null +++ b/public/include/csrf.php @@ -0,0 +1,40 @@ + + * Uso in fetch: header 'X-CSRF-TOKEN': '' + * Uso nei salvataggi: yb_csrf_check(); (blocca con 403) + * if (!yb_csrf_valid()) (per gli endpoint JSON) + */ + +function yb_csrf_token(): string +{ + if (session_status() !== PHP_SESSION_ACTIVE) { + session_start(); + } + if (empty($_SESSION['_csrf'])) { + $_SESSION['_csrf'] = bin2hex(random_bytes(32)); + } + return $_SESSION['_csrf']; +} + +function yb_csrf_field(): string +{ + return ''; +} + +function yb_csrf_valid(): bool +{ + $sent = $_POST['_csrf'] ?? ($_SERVER['HTTP_X_CSRF_TOKEN'] ?? ''); + return is_string($sent) && $sent !== '' && hash_equals(yb_csrf_token(), $sent); +} + +function yb_csrf_check(): void +{ + if (!yb_csrf_valid()) { + http_response_code(403); + exit('Sessione scaduta: ricarica la pagina e riprova.'); + } +} diff --git a/public/product_action.php b/public/product_action.php new file mode 100644 index 0000000..d275923 --- /dev/null +++ b/public/product_action.php @@ -0,0 +1,73 @@ + attiva/disattiva + * action=delete -> elimina, solo se mai venduto e non usato in buoni regalo + */ +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + header('Location: products.php'); + exit(); +} +yb_csrf_check(); + +$pdo = DBHandlerSelect::getInstance()->getConnection(); +$id = (int) ($_POST['id'] ?? 0); +$action = (string) ($_POST['action'] ?? ''); + +if (!SchoolScope::belongs('products', $id)) { + header('Location: products.php?message=error'); + exit(); +} + +try { + if ($action === 'toggle') { + $pdo->prepare("UPDATE products SET active = IF(active = 'Y', 'N', 'Y') WHERE idproduct = ? AND idschool = ?") + ->execute([$id, $idschool]); + header('Location: products.php?message=toggled'); + exit(); + } + + if ($action === 'delete') { + $st = $pdo->prepare( + "SELECT (SELECT COUNT(*) FROM orderbook WHERE idproduct = ? AND idschool = ?) + + (SELECT COUNT(*) FROM gift_vouchers g JOIN product_variants v ON v.idvariant = g.idvariant + WHERE v.idproduct = ? AND v.idschool = ?) + + (SELECT COUNT(*) FROM product_variants t JOIN product_variants v ON v.idvariant = t.target_idvariant + WHERE v.idproduct = ? AND t.idproduct <> ? AND v.idschool = ?)" + ); + $st->execute([$id, $idschool, $id, $idschool, $id, $id, $idschool]); + if ((int) $st->fetchColumn() > 0) { + header('Location: products.php?message=inuse'); + exit(); + } + + // File da rimuovere dopo la cancellazione + $files = []; + $st = $pdo->prepare("SELECT cover_image FROM products WHERE idproduct = ? AND idschool = ?"); + $st->execute([$id, $idschool]); + $files[] = (string) $st->fetchColumn(); + $st = $pdo->prepare("SELECT filename FROM product_images WHERE idproduct = ? AND idschool = ?"); + $st->execute([$id, $idschool]); + $files = array_merge($files, $st->fetchAll(PDO::FETCH_COLUMN)); + + $pdo->prepare("DELETE FROM products WHERE idproduct = ? AND idschool = ?")->execute([$id, $idschool]); + + $prefix = 'uploads/schools/' . (int) $idschool . '/products/'; + foreach (array_unique(array_filter($files)) as $f) { + if (strpos($f, $prefix) === 0 && strpos($f, '..') === false && is_file(__DIR__ . '/' . $f)) { + @unlink(__DIR__ . '/' . $f); + } + } + header('Location: products.php?message=deleted'); + exit(); + } +} catch (PDOException $ex) { + error_log('[product_action] ' . $ex->getMessage()); +} + +header('Location: products.php?message=error'); +exit(); diff --git a/public/product_form.php b/public/product_form.php new file mode 100644 index 0000000..2b198bd --- /dev/null +++ b/public/product_form.php @@ -0,0 +1,1190 @@ + nuovo prodotto + * product_form.php?id=123 -> modifica (solo se della scuola) + * Invia a product_save.php. Immagini caricate con product_upload.php. + */ +$pdo = DBHandlerSelect::getInstance()->getConnection(); + +function e($value): string +{ + return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); +} + +$id = isset($_GET['id']) ? (int) $_GET['id'] : 0; +$message = $_GET['message'] ?? ''; +$p = null; + +if ($id > 0) { + $st = $pdo->prepare("SELECT * FROM products WHERE idproduct = ? AND idschool = ?"); + $st->execute([$id, $idschool]); + $p = $st->fetch(PDO::FETCH_ASSOC); + if (!$p) { + header('Location: products.php?message=error'); + exit(); + } +} + +$variants = []; +$selServices = []; +$selCategories = []; +$gallery = []; +$nsold = 0; + +if ($p) { + $st = $pdo->prepare("SELECT * FROM product_variants WHERE idproduct = ? AND idschool = ? ORDER BY sort_order, idvariant"); + $st->execute([$id, $idschool]); + $variants = $st->fetchAll(PDO::FETCH_ASSOC); + + $st = $pdo->prepare("SELECT idservice, idservicecategory FROM product_services WHERE idproduct = ? AND idschool = ?"); + $st->execute([$id, $idschool]); + foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) { + if (!empty($r['idservice'])) { + $selServices[] = (int) $r['idservice']; + } + if (!empty($r['idservicecategory'])) { + $selCategories[] = (int) $r['idservicecategory']; + } + } + + $st = $pdo->prepare("SELECT filename FROM product_images WHERE idproduct = ? AND idschool = ? ORDER BY sort_order, idimage"); + $st->execute([$id, $idschool]); + $gallery = $st->fetchAll(PDO::FETCH_COLUMN); + + $st = $pdo->prepare("SELECT COUNT(*) FROM orderbook WHERE idproduct = ? AND idschool = ?"); + $st->execute([$id, $idschool]); + $nsold = (int) $st->fetchColumn(); +} + +// Classi della scuola, raggruppate per categoria +$st = $pdo->prepare("SELECT idservicecategory, namecategory FROM servicecategory WHERE idschool = ? ORDER BY namecategory"); +$st->execute([$idschool]); +$categories = $st->fetchAll(PDO::FETCH_ASSOC); + +$st = $pdo->prepare("SELECT idservice, servicename, day, time, category FROM service WHERE idschool = ? ORDER BY servicename, day, time"); +$st->execute([$idschool]); +$servicesByCat = []; +foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $s) { + $servicesByCat[(int) $s['category']][] = $s; +} + +// Varianti regalabili (per i buoni regalo): varianti attive dei prodotti "lezioni" +$st = $pdo->prepare( + "SELECT v.idvariant, v.label, v.price, pr.title + FROM product_variants v + JOIN products pr ON pr.idproduct = v.idproduct + WHERE v.idschool = ? AND pr.kind = 'lessons' AND v.active = 'Y' AND pr.idproduct <> ? + ORDER BY pr.title, v.sort_order" +); +$st->execute([$idschool, $id]); +$targetOptions = []; +foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $t) { + $targetOptions[] = [ + 'id' => (int) $t['idvariant'], + 'label' => $t['title'] . ' — ' . $t['label'] . ' (' . number_format((float) $t['price'], 2, ',', '.') . ' €)', + ]; +} + +$val = [ + 'kind' => $p['kind'] ?? 'lessons', + 'title' => $p['title'] ?? '', + 'subtitle' => $p['subtitle'] ?? '', + 'slug' => $p['slug'] ?? '', + 'badge' => $p['badge'] ?? '', + 'description' => $p['description'] ?? '', + 'suitable_for' => $p['suitable_for'] ?? '', + 'pros' => $p['pros'] ?? '', + 'cons' => $p['cons'] ?? '', + 'requirements' => $p['requirements'] ?? '', + 'cover_image' => $p['cover_image'] ?? '', + 'booking_mode' => $p['booking_mode'] ?? 'fixed', + 'fixed_slots' => (int) ($p['fixed_slots'] ?? 1), + 'weekly_limit' => $p['weekly_limit'] ?? '', + 'max_reschedule' => $p['max_reschedule'] ?? '', + 'once_per_student' => ($p['once_per_student'] ?? 'N') === 'Y', + 'visible_online' => ($p['visible_online'] ?? 'Y') === 'Y', + 'active' => ($p['active'] ?? 'Y') === 'Y', + 'sort_order' => $p['sort_order'] ?? 0, +]; + +$jsVariants = []; +foreach ($variants as $v) { + $jsVariants[] = [ + 'idvariant' => (int) $v['idvariant'], + 'label' => $v['label'], + 'lessons' => $v['lessons'], + 'validity_mode' => $v['validity_mode'], + 'validity_value' => $v['validity_value'], + 'validity_unit' => $v['validity_unit'], + 'validity_start' => $v['validity_start'], + 'price' => $v['price'], + 'compare_price' => $v['compare_price'], + 'price_note' => $v['price_note'], + 'target_idvariant' => $v['target_idvariant'], + 'wc_product_id' => $v['wc_product_id'], + 'wc_variation_id' => $v['wc_variation_id'], + 'active' => $v['active'], + ]; +} + +$jsonFlags = JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP; +$isEdit = $id > 0; +$kindLocked = $nsold > 0; +$pageTitle = $isEdit ? 'Modifica prodotto' : 'Nuovo prodotto'; + +$errors = [ + 'invalid' => 'Controlla i campi obbligatori (titolo).', + 'novariants' => 'Aggiungi almeno un\'opzione con nome e prezzo.', + 'noclasses' => 'Scegli almeno una classe o una categoria su cui vale il prodotto.', + 'notarget' => 'Per ogni opzione del buono regalo scegli il pacchetto regalato.', + 'error' => 'Si è verificato un errore durante il salvataggio. Riprova.', +]; +?> + + + + + + YogiBook - <?php echo e($pageTitle); ?> + + + + + + + + + + + + + + +
+
+ +
+ +
+ +
+
+ +
+
+
+ +
+
+
+
+
+ + +
+ + +
+ + + + +
+
+
Tipo di prodotto
+

+ Cosa stai vendendo? +
Il prodotto è già stato venduto: il tipo non si può più cambiare. +

+
+ ['bx-calendar-check', 'Lezioni', 'Pacchetti mono/bisettimanali, carnet, flexi, lezione singola o di prova'], + 'membership' => ['bx-id-card', 'Tessera', 'Quota associativa / tesseramento annuale'], + 'voucher' => ['bx-gift', 'Buono regalo', 'Regala un pacchetto preciso con un codice'], + ] as $k => [$icon, $lbl, $desc] + ) : ?> + + +
+
+
+ + +
+
+
Presentazione
+

Come appare all'allieva al momento dell'acquisto. I campi con * sono obbligatori.

+ +
+
+ + +
+
+ + +
Facoltativa, appare in evidenza sulla scheda.
+
+
+
+
+ + +
+
+ + +
Indirizzo pubblico. Vuoto = dal titolo.
+
+
+ +
+ +
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
Uno per riga: diventano un elenco con la spunta.
+
+
+ + +
Uno per riga: aiutano l'allieva a scegliere il prodotto giusto.
+
+
+ +
+
+ +
+
+ +

Trascina qui l'immagine oppure sfoglia

+ JPG, PNG, WEBP o GIF · max 5 MB +
+
+ Copertina +
+
+ + +
+ +
+
+ + + + +
+
+
+
+ + +
+
+
Come si usa
+

Le regole di prenotazione del pacchetto.

+ +
+
+ + +
+
+ + +
L'allieva sceglie la classe (o le due classi) al momento dell'acquisto; l'insegnante decide la prima lezione alla propagazione.
+
+
+ + +
+
+ + +
+
+
+ > + +
+
+
+ + +
+
+
Classi valide
+

Su quali classi si può usare il prodotto. Scegliendo un'intera categoria valgono anche le classi che aggiungerai in futuro.

+ + +

Non ci sono ancora classi in questa scuola: creale prima da Servizi.

+ $cname) : + $list = $servicesByCat[$cid] ?? []; + if (empty($list) && $cname === null) { + continue; + } ?> +
+
+ + + + +
+ +
+ + + +
+ +
Nessuna classe in questa categoria.
+ +
+ +
+
+ + +
+
+
Opzioni e prezzi
+

Ogni opzione è una scelta acquistabile, es. 4, 8 o 12 lezioni, ognuna con prezzo e durata.

+

Es. "Tessera annuale". La validità può essere di N mesi, l'anno solare o l'anno sportivo (da Opzioni Scuola).

+

Ogni opzione regala un pacchetto preciso. La durata è la scadenza del buono.

+ +
+ +
+
+ + +
+
+
Visibilità
+
+
+
+ > + +
+
Disattivo = non più vendibile; i pacchetti già venduti restano validi.
+
+
+
+ > + +
+
Spento = lo vende solo la segreteria.
+
+
+ + +
Numeri più bassi appaiono prima.
+
+
+
+
+ +
+ Indietro + +
+
+ +
+
+
+ +
+
+
+ + + + + + + + + + + \ No newline at end of file diff --git a/public/product_save.php b/public/product_save.php new file mode 100644 index 0000000..9b4525a --- /dev/null +++ b/public/product_save.php @@ -0,0 +1,404 @@ +getConnection(); +$id = (int) ($_POST['id'] ?? 0); + +function back(string $msg, int $id = 0): void +{ + header('Location: ' . ($id > 0 ? 'product_form.php?id=' . $id . '&' : ($msg === 'saved' || $msg === 'saved_kept' ? 'products.php?' : 'product_form.php?')) . 'message=' . $msg); + exit(); +} + +function yn($v): string +{ + return $v === 'Y' ? 'Y' : 'N'; +} + +function intOrNull($v): ?int +{ + $v = trim((string) $v); + if ($v === '' || !is_numeric($v)) { + return null; + } + return max(0, (int) $v); +} + +function money($v): ?float +{ + $v = str_replace(',', '.', trim((string) $v)); + if ($v === '' || !is_numeric($v)) { + return null; + } + return round(max(0, (float) $v), 2); +} + +function textOrNull($v, int $max = 65000): ?string +{ + $v = trim((string) $v); + return $v === '' ? null : mb_substr($v, 0, $max); +} + +function slugify(string $s): string +{ + $t = function_exists('iconv') ? @iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $s) : $s; + if ($t === false) { + $t = $s; + } + $t = strtolower($t); + $t = preg_replace('/[^a-z0-9]+/', '-', $t); + return trim((string) $t, '-'); +} + +/** HTML dell'editor: tag ammessi, niente attributi on*, niente javascript: */ +function cleanHtml(string $html): ?string +{ + $html = trim($html); + if ($html === '') { + return null; + } + $html = strip_tags($html, '