Catalogo prodotti: pagina admin Prodotti (elenco, scheda, opzioni, immagini) + helper CSRF

This commit is contained in:
2026-10-08 15:07:54 +02:00
parent 914de83657
commit a8ecf86b7b
12 changed files with 2555 additions and 0 deletions
@@ -0,0 +1,316 @@
<?php
declare(strict_types=1);
use Phinx\Db\Adapter\MysqlAdapter;
use Phinx\Migration\AbstractMigration;
/**
* Catalogo prodotti YogiBook:
* prodotti, varianti, classi valide, immagini, classi scelte nei pacchetti fissi,
* tessere, buoni regalo, coupon. Estende orderbook e option.
*/
final class CreateProductCatalog extends AbstractMigration
{
private const OPT = [
'id' => 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();
}
}
+10
View File
@@ -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? */
+40
View File
@@ -0,0 +1,40 @@
<?php
/**
* Protezione CSRF minima per i form POST e le chiamate fetch.
*
* Uso nei form: <?php echo yb_csrf_field(); ?>
* Uso in fetch: header 'X-CSRF-TOKEN': '<?php echo e(yb_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 '<input type="hidden" name="_csrf" value="' . htmlspecialchars(yb_csrf_token(), ENT_QUOTES, 'UTF-8') . '">';
}
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.');
}
}
+73
View File
@@ -0,0 +1,73 @@
<?php
require_once('include/headscript.php');
require_once('include/csrf.php');
SchoolContext::requireSchoolAdmin();
/**
* Catalogo prodotti - azioni dall'elenco (solo POST + CSRF):
* action=toggle -> 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();
File diff suppressed because it is too large Load Diff
+404
View File
@@ -0,0 +1,404 @@
<?php
require_once('include/headscript.php');
require_once('include/csrf.php');
SchoolContext::requireSchoolAdmin();
/**
* Catalogo prodotti - salvataggio (insert/update) di prodotto, classi valide, opzioni e galleria.
* Le opzioni rimosse vengono eliminate solo se mai usate; altrimenti vengono disattivate.
*/
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
header('Location: products.php');
exit();
}
yb_csrf_check();
$pdo = DBHandlerSelect::getInstance()->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, '<p><br><strong><b><em><i><u><s><h2><h3><ul><ol><li><a><span><blockquote>');
$html = preg_replace('/\s+on[a-z]+\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)/i', '', $html);
$html = preg_replace('/(href|src)\s*=\s*("|\')\s*(javascript|data|vbscript):[^"\']*\2/i', '$1="#"', $html);
return $html;
}
$imgPrefix = 'uploads/schools/' . (int) $idschool . '/products/';
function validImage(string $path, string $prefix): bool
{
return strpos($path, $prefix) === 0
&& strpos($path, '..') === false
&& preg_match('#^[A-Za-z0-9/_\-]+\.(jpe?g|png|webp|gif)$#', $path) === 1;
}
// ---------------------------------------------------------------------
// Prodotto esistente: appartenenza, tipo bloccato se già venduto
// ---------------------------------------------------------------------
$oldKind = null;
$nsold = 0;
if ($id > 0) {
$st = $pdo->prepare("SELECT kind FROM products WHERE idproduct = ? AND idschool = ?");
$st->execute([$id, $idschool]);
$oldKind = $st->fetchColumn();
if ($oldKind === false) {
header('Location: products.php?message=error');
exit();
}
$st = $pdo->prepare("SELECT COUNT(*) FROM orderbook WHERE idproduct = ? AND idschool = ?");
$st->execute([$id, $idschool]);
$nsold = (int) $st->fetchColumn();
}
$kind = in_array($_POST['kind'] ?? '', ['lessons', 'membership', 'voucher'], true) ? $_POST['kind'] : 'lessons';
if ($oldKind !== null && $nsold > 0) {
$kind = $oldKind;
}
// ---------------------------------------------------------------------
// Presentazione
// ---------------------------------------------------------------------
$title = trim((string) ($_POST['title'] ?? ''));
if ($title === '' || mb_strlen($title) > 191) {
back('invalid', $id);
}
$data = [
'kind' => $kind,
'title' => $title,
'subtitle' => textOrNull($_POST['subtitle'] ?? '', 255),
'badge' => textOrNull($_POST['badge'] ?? '', 50),
'description' => cleanHtml((string) ($_POST['description'] ?? '')),
'suitable_for' => textOrNull($_POST['suitable_for'] ?? ''),
'pros' => textOrNull($_POST['pros'] ?? ''),
'cons' => textOrNull($_POST['cons'] ?? ''),
'requirements' => textOrNull($_POST['requirements'] ?? ''),
'visible_online' => yn($_POST['visible_online'] ?? 'N'),
'active' => yn($_POST['active'] ?? 'N'),
'sort_order' => (int) ($_POST['sort_order'] ?? 0),
];
$cover = trim((string) ($_POST['cover_image'] ?? ''));
$data['cover_image'] = ($cover !== '' && validImage($cover, $imgPrefix)) ? $cover : null;
$gallery = json_decode((string) ($_POST['gallery'] ?? '[]'), true);
$gallery = is_array($gallery) ? $gallery : [];
$gallery = array_values(array_unique(array_filter($gallery, function ($p) use ($imgPrefix) {
return is_string($p) && validImage($p, $imgPrefix);
})));
$gallery = array_slice($gallery, 0, 10);
// Slug unico per scuola
$base = slugify((string) ($_POST['slug'] ?? '')) ?: slugify($title);
$base = substr($base !== '' ? $base : 'prodotto', 0, 180);
$slug = $base;
$n = 2;
$chk = $pdo->prepare("SELECT 1 FROM products WHERE idschool = ? AND slug = ? AND idproduct <> ? LIMIT 1");
while (true) {
$chk->execute([$idschool, $slug, $id]);
if (!$chk->fetchColumn()) {
break;
}
$slug = $base . '-' . $n++;
}
$data['slug'] = $slug;
// ---------------------------------------------------------------------
// Regole (solo lezioni)
// ---------------------------------------------------------------------
if ($kind === 'lessons') {
$mode = in_array($_POST['booking_mode'] ?? '', ['fixed', 'free', 'single'], true) ? $_POST['booking_mode'] : 'free';
$data['booking_mode'] = $mode;
$data['fixed_slots'] = ($mode === 'fixed' && (int) ($_POST['fixed_slots'] ?? 1) === 2) ? 2 : 1;
$weekly = $mode === 'free' ? intOrNull($_POST['weekly_limit'] ?? '') : null;
$data['weekly_limit'] = $weekly ?: null;
$data['max_reschedule'] = $mode === 'single' ? null : intOrNull($_POST['max_reschedule'] ?? '');
$data['once_per_student'] = yn($_POST['once_per_student'] ?? 'N');
} else {
$data['booking_mode'] = 'free';
$data['fixed_slots'] = 1;
$data['weekly_limit'] = null;
$data['max_reschedule'] = null;
$data['once_per_student'] = 'N';
}
// ---------------------------------------------------------------------
// Classi valide (solo lezioni): solo ID della scuola
// ---------------------------------------------------------------------
$services = [];
$categories = [];
if ($kind === 'lessons') {
$inSvc = array_values(array_unique(array_filter(array_map('intval', (array) ($_POST['services'] ?? [])))));
$inCat = array_values(array_unique(array_filter(array_map('intval', (array) ($_POST['categories'] ?? [])))));
if ($inSvc) {
$ph = implode(',', array_fill(0, count($inSvc), '?'));
$st = $pdo->prepare("SELECT idservice FROM service WHERE idschool = ? AND idservice IN ($ph)");
$st->execute(array_merge([$idschool], $inSvc));
$services = array_map('intval', $st->fetchAll(PDO::FETCH_COLUMN));
}
if ($inCat) {
$ph = implode(',', array_fill(0, count($inCat), '?'));
$st = $pdo->prepare("SELECT idservicecategory FROM servicecategory WHERE idschool = ? AND idservicecategory IN ($ph)");
$st->execute(array_merge([$idschool], $inCat));
$categories = array_map('intval', $st->fetchAll(PDO::FETCH_COLUMN));
}
if (!$services && !$categories) {
back('noclasses', $id);
}
}
// ---------------------------------------------------------------------
// Opzioni
// ---------------------------------------------------------------------
$existingIds = [];
if ($id > 0) {
$st = $pdo->prepare("SELECT idvariant FROM product_variants WHERE idproduct = ? AND idschool = ?");
$st->execute([$id, $idschool]);
$existingIds = array_map('intval', $st->fetchAll(PDO::FETCH_COLUMN));
}
$validTargets = [];
if ($kind === 'voucher') {
$st = $pdo->prepare(
"SELECT v.idvariant FROM product_variants v
JOIN products p ON p.idproduct = v.idproduct
WHERE v.idschool = ? AND p.kind = 'lessons' AND p.idproduct <> ?"
);
$st->execute([$idschool, $id]);
$validTargets = array_map('intval', $st->fetchAll(PDO::FETCH_COLUMN));
}
$variants = [];
foreach ((array) ($_POST['variants'] ?? []) as $r) {
if (!is_array($r)) {
continue;
}
$label = trim((string) ($r['label'] ?? ''));
if ($label === '') {
continue;
}
$vid = (int) ($r['idvariant'] ?? 0);
if (!in_array($vid, $existingIds, true)) {
$vid = 0;
}
$lessons = $kind === 'lessons' ? intOrNull($r['lessons'] ?? '') : null;
$vmode = ($kind === 'membership' && in_array($r['validity_mode'] ?? '', ['duration', 'calendar_year', 'season'], true))
? $r['validity_mode'] : 'duration';
$vval = null;
$vunit = null;
if ($vmode === 'duration') {
$vval = intOrNull($r['validity_value'] ?? '');
$vunit = in_array($r['validity_unit'] ?? '', ['days', 'weeks', 'months'], true) ? $r['validity_unit'] : 'weeks';
if (!$vval) {
$vmode = 'none';
$vval = null;
$vunit = null;
}
}
$price = money($r['price'] ?? '') ?? 0.0;
$compare = money($r['compare_price'] ?? '');
if ($compare !== null && $compare <= $price) {
$compare = null;
}
$target = null;
if ($kind === 'voucher') {
$target = (int) ($r['target_idvariant'] ?? 0);
if (!in_array($target, $validTargets, true)) {
back('notarget', $id);
}
}
$variants[] = [
'idvariant' => $vid,
'label' => mb_substr($label, 0, 100),
'lessons' => $lessons ?: null,
'validity_mode' => $vmode,
'validity_value' => $vval,
'validity_unit' => $vunit,
'validity_start' => ($kind === 'lessons' && ($r['validity_start'] ?? '') !== 'purchase') ? 'first_lesson' : 'purchase',
'price' => $price,
'compare_price' => $compare,
'price_note' => textOrNull($r['price_note'] ?? '', 100),
'target_idvariant' => $target,
'wc_product_id' => intOrNull($r['wc_product_id'] ?? '') ?: null,
'wc_variation_id' => intOrNull($r['wc_variation_id'] ?? '') ?: null,
'active' => yn($r['active'] ?? 'N'),
];
}
if (!$variants) {
back('novariants', $id);
}
// ---------------------------------------------------------------------
// Salvataggio
// ---------------------------------------------------------------------
$kept = false;
try {
$pdo->beginTransaction();
$cols = array_keys($data);
if ($id > 0) {
$set = implode(', ', array_map(function ($c) {
return "`$c` = ?";
}, $cols));
$pdo->prepare("UPDATE products SET $set WHERE idproduct = ? AND idschool = ?")
->execute(array_merge(array_values($data), [$id, $idschool]));
} else {
$colList = '`idschool`, ' . implode(', ', array_map(function ($c) {
return "`$c`";
}, $cols));
$ph = implode(', ', array_fill(0, count($cols) + 1, '?'));
$pdo->prepare("INSERT INTO products ($colList) VALUES ($ph)")
->execute(array_merge([$idschool], array_values($data)));
$id = (int) $pdo->lastInsertId();
}
// Classi valide
$pdo->prepare("DELETE FROM product_services WHERE idproduct = ? AND idschool = ?")->execute([$id, $idschool]);
$ins = $pdo->prepare("INSERT INTO product_services (idproduct, idschool, idservice, idservicecategory) VALUES (?, ?, ?, ?)");
foreach ($categories as $cid) {
$ins->execute([$id, $idschool, null, $cid]);
}
foreach ($services as $sid) {
$ins->execute([$id, $idschool, $sid, null]);
}
// Opzioni
$vCols = [
'label',
'lessons',
'validity_mode',
'validity_value',
'validity_unit',
'validity_start',
'price',
'compare_price',
'price_note',
'target_idvariant',
'wc_product_id',
'wc_variation_id',
'active',
'sort_order'
];
$upd = $pdo->prepare(
"UPDATE product_variants SET " . implode(', ', array_map(function ($c) {
return "`$c` = ?";
}, $vCols)) .
" WHERE idvariant = ? AND idproduct = ? AND idschool = ?"
);
$insV = $pdo->prepare(
"INSERT INTO product_variants (idproduct, idschool, " . implode(', ', array_map(function ($c) {
return "`$c`";
}, $vCols)) . ")
VALUES (?, ?, " . implode(', ', array_fill(0, count($vCols), '?')) . ")"
);
$keepIds = [];
foreach ($variants as $pos => $v) {
$v['sort_order'] = $pos * 10;
$values = [];
foreach ($vCols as $c) {
$values[] = $v[$c];
}
if ($v['idvariant'] > 0) {
$upd->execute(array_merge($values, [$v['idvariant'], $id, $idschool]));
$keepIds[] = $v['idvariant'];
} else {
$insV->execute(array_merge([$id, $idschool], $values));
}
}
// Opzioni rimosse: elimina se mai usate, altrimenti disattiva
$used = $pdo->prepare(
"SELECT (SELECT COUNT(*) FROM orderbook WHERE idvariant = ? AND idschool = ?)
+ (SELECT COUNT(*) FROM gift_vouchers WHERE idvariant = ? AND idschool = ?)
+ (SELECT COUNT(*) FROM product_variants WHERE target_idvariant = ? AND idschool = ?)"
);
$del = $pdo->prepare("DELETE FROM product_variants WHERE idvariant = ? AND idproduct = ? AND idschool = ?");
$off = $pdo->prepare("UPDATE product_variants SET active = 'N' WHERE idvariant = ? AND idproduct = ? AND idschool = ?");
foreach (array_diff($existingIds, $keepIds) as $vid) {
$used->execute([$vid, $idschool, $vid, $idschool, $vid, $idschool]);
if ((int) $used->fetchColumn() > 0) {
$off->execute([$vid, $id, $idschool]);
$kept = true;
} else {
$del->execute([$vid, $id, $idschool]);
}
}
// Galleria
$pdo->prepare("DELETE FROM product_images WHERE idproduct = ? AND idschool = ?")->execute([$id, $idschool]);
$insI = $pdo->prepare("INSERT INTO product_images (idproduct, idschool, filename, sort_order) VALUES (?, ?, ?, ?)");
foreach ($gallery as $pos => $path) {
$insI->execute([$id, $idschool, $path, $pos]);
}
$pdo->commit();
} catch (PDOException $ex) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
error_log('[product_save] ' . $ex->getMessage());
back('error', $id);
}
header('Location: products.php?message=' . ($kept ? 'saved_kept' : 'saved'));
exit();
+58
View File
@@ -0,0 +1,58 @@
<?php
ob_start();
require_once('include/headscript.php');
require_once('include/csrf.php');
/**
* Catalogo prodotti - upload immagine (copertina o galleria).
* Salva in uploads/schools/{idschool}/products/ e restituisce il percorso relativo.
*/
function jsonOut(array $payload, int $code = 200): void
{
if (ob_get_length() !== false) {
ob_end_clean();
}
http_response_code($code);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($payload, JSON_UNESCAPED_UNICODE);
exit;
}
if (!SchoolContext::isSchoolAdmin()) {
jsonOut(['ok' => false, 'error' => 'Accesso non autorizzato.'], 403);
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonOut(['ok' => false, 'error' => 'Metodo non consentito.'], 405);
}
if (!yb_csrf_valid()) {
jsonOut(['ok' => false, 'error' => 'Sessione scaduta: ricarica la pagina.'], 403);
}
$f = $_FILES['image'] ?? null;
if (!is_array($f) || ($f['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || !is_uploaded_file($f['tmp_name'])) {
jsonOut(['ok' => false, 'error' => 'Nessun file ricevuto.'], 400);
}
if ($f['size'] > 5 * 1024 * 1024) {
jsonOut(['ok' => false, 'error' => "L'immagine supera i 5 MB."], 400);
}
$mime = (new finfo(FILEINFO_MIME_TYPE))->file($f['tmp_name']);
$exts = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp', 'image/gif' => 'gif'];
if (!isset($exts[$mime]) || @getimagesize($f['tmp_name']) === false) {
jsonOut(['ok' => false, 'error' => 'Formato non valido: usa JPG, PNG, WEBP o GIF.'], 400);
}
$rel = 'uploads/schools/' . (int) $idschool . '/products/';
$dir = __DIR__ . '/' . $rel;
if (!is_dir($dir) && !mkdir($dir, 0755, true) && !is_dir($dir)) {
error_log('[product_upload] impossibile creare ' . $dir);
jsonOut(['ok' => false, 'error' => 'Caricamento non riuscito.'], 500);
}
$name = bin2hex(random_bytes(12)) . '.' . $exts[$mime];
if (!move_uploaded_file($f['tmp_name'], $dir . $name)) {
error_log('[product_upload] move_uploaded_file fallito');
jsonOut(['ok' => false, 'error' => 'Caricamento non riuscito.'], 500);
}
jsonOut(['ok' => true, 'path' => $rel . $name]);
+464
View File
@@ -0,0 +1,464 @@
<?php
require_once('include/headscript.php');
require_once('include/csrf.php');
SchoolContext::requireSchoolAdmin();
/**
* Catalogo prodotti - elenco della scuola corrente.
* Azioni: Modifica (product_form.php), Attiva/Disattiva ed Elimina (POST + CSRF a product_action.php).
*/
$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, ',', '.') . ' €';
}
$message = $_GET['message'] ?? '';
$st = $pdo->prepare(
"SELECT p.*,
(SELECT COUNT(*) FROM product_variants v WHERE v.idproduct = p.idproduct AND v.active = 'Y') AS nvariants,
(SELECT MIN(v.price) FROM product_variants v WHERE v.idproduct = p.idproduct AND v.active = 'Y') AS price_from,
(SELECT COUNT(*) FROM product_services ps WHERE ps.idproduct = p.idproduct) AS nclasses,
(SELECT COUNT(*) FROM orderbook o WHERE o.idproduct = p.idproduct AND o.idschool = p.idschool) AS nsold
FROM products p
WHERE p.idschool = ?
ORDER BY FIELD(p.kind, 'lessons', 'membership', 'voucher'), p.sort_order, p.title"
);
$st->execute([$idschool]);
$products = $st->fetchAll(PDO::FETCH_ASSOC);
$kindLabels = [
'lessons' => ['Lezioni', 'bx-calendar-check'],
'membership' => ['Tessere', 'bx-id-card'],
'voucher' => ['Buoni regalo', 'bx-gift'],
];
function rulesSummary(array $p): string
{
if ($p['kind'] === 'membership') {
return 'Tessera / quota associativa';
}
if ($p['kind'] === 'voucher') {
return 'Regala un pacchetto';
}
switch ($p['booking_mode']) {
case 'fixed':
$s = (int) $p['fixed_slots'] === 2 ? 'Classi fisse · 2 a settimana' : 'Classe fissa · 1 a settimana';
break;
case 'single':
$s = 'Lezione scelta all\'acquisto';
break;
default:
$s = 'Libera';
if (!empty($p['weekly_limit'])) {
$s .= ' · max ' . (int) $p['weekly_limit'] . '/sett.';
}
}
if ($p['once_per_student'] === 'Y') {
$s .= ' · una volta per allieva';
}
return $s;
}
?>
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8" />
<title>YogiBook - Prodotti</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta content="YogiBook" name="description" />
<link rel="shortcut icon" href="assets/images/favicon.ico">
<link href="assets/css/bootstrap.min.css" id="bootstrap-style" rel="stylesheet" type="text/css" />
<link href="assets/css/icons.min.css" rel="stylesheet" type="text/css" />
<link href="assets/css/app.min.css" id="app-style" rel="stylesheet" type="text/css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<style>
.admin-card {
border: none;
border-radius: 14px;
box-shadow: 0 4px 20px rgba(0, 0, 0, .06);
}
.admin-card .card-body {
padding: 26px 30px;
}
.section-title {
font-size: 15px;
font-weight: 700;
letter-spacing: .04em;
text-transform: uppercase;
color: #6b7280;
margin-bottom: 4px;
}
.section-lead {
color: #9ca3af;
font-size: 14px;
margin-bottom: 0;
}
.list-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.btn-new {
background: #2F7F84;
border: none;
color: #fff;
border-radius: 8px;
padding: 9px 18px;
font-weight: 600;
font-size: 14px;
white-space: nowrap;
}
.btn-new:hover {
background: #1E4A4F;
color: #fff;
}
.prod-table thead th {
font-size: 12px;
text-transform: uppercase;
letter-spacing: .03em;
color: #6b7280;
border-bottom: 2px solid #eef0f3;
white-space: nowrap;
}
.prod-table td {
vertical-align: middle;
}
.kind-group td {
background: #F4FAFA;
font-weight: 700;
color: #1E4A4F;
font-size: 13px;
}
.kind-group i {
color: #2F7F84;
margin-right: 6px;
}
.prod-name {
display: flex;
align-items: center;
gap: 12px;
}
.prod-thumb {
width: 46px;
height: 46px;
border-radius: 10px;
object-fit: cover;
flex-shrink: 0;
}
.prod-thumb.ph {
background: #DBF6F9;
display: flex;
align-items: center;
justify-content: center;
color: #2F7F84;
font-size: 20px;
}
.prod-title {
font-weight: 600;
color: #1f2937;
}
.prod-sub {
color: #9ca3af;
font-size: 12px;
}
.badge-tag {
display: inline-block;
background: #F8EFE4;
color: #8a6a3f;
font-size: 11px;
font-weight: 700;
padding: 1px 8px;
border-radius: 10px;
margin-left: 6px;
}
.pill {
display: inline-block;
font-size: 12px;
font-weight: 700;
padding: 3px 10px;
border-radius: 20px;
white-space: nowrap;
}
.pill.on {
background: #e7f7ee;
color: #1a7f52;
}
.pill.off {
background: #eef0f3;
color: #6b7280;
}
.pill.web {
background: #DBF6F9;
color: #1E4A4F;
}
.btn-act {
border: 1px solid #e2e4e9;
background: #fff;
border-radius: 8px;
padding: 6px 12px;
font-size: 13px;
font-weight: 600;
color: #374151;
cursor: pointer;
white-space: nowrap;
text-decoration: none;
display: inline-block;
}
.btn-act:hover {
background: #f3f4f6;
color: #111827;
}
.btn-act.danger {
color: #c0392b;
border-color: #f2c9c4;
}
.btn-act.danger:hover {
background: #fdecea;
}
.empty-state {
text-align: center;
padding: 44px 10px;
color: #9ca3af;
}
.empty-state i {
font-size: 34px;
margin-bottom: 10px;
display: block;
color: #cfe3e4;
}
</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">Prodotti</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 ($message === 'saved') : ?>
<div class="alert alert-success">Prodotto salvato.</div>
<?php elseif ($message === 'saved_kept') : ?>
<div class="alert alert-info">Prodotto salvato. Le opzioni già vendute non sono state eliminate ma disattivate, per non perdere lo storico.</div>
<?php elseif ($message === 'toggled') : ?>
<div class="alert alert-success">Stato del prodotto aggiornato.</div>
<?php elseif ($message === 'deleted') : ?>
<div class="alert alert-warning">Prodotto eliminato.</div>
<?php elseif ($message === 'inuse') : ?>
<div class="alert alert-warning">Questo prodotto è già stato venduto o è usato in un buono regalo: non si può eliminare. Puoi disattivarlo.</div>
<?php elseif ($message === 'error') : ?>
<div class="alert alert-danger">Si è verificato un errore. Riprova.</div>
<?php endif; ?>
<div class="card admin-card">
<div class="card-body">
<div class="list-head">
<div>
<div class="section-title">Prodotti</div>
<p class="section-lead">Quello che la scuola vende: pacchetti di lezioni, carnet, flexi, lezioni singole e di prova, tessere e buoni regalo.</p>
</div>
<a href="product_form.php" class="btn btn-new"><i class="bx bx-plus align-middle"></i> Nuovo prodotto</a>
</div>
<div class="table-responsive mt-3">
<table class="table prod-table align-middle">
<thead>
<tr>
<th>Prodotto</th>
<th>Regole</th>
<th>Opzioni</th>
<th>Classi</th>
<th>Venduti</th>
<th>Stato</th>
<th class="text-end">Azioni</th>
</tr>
</thead>
<tbody>
<?php if (empty($products)) : ?>
<tr>
<td colspan="7">
<div class="empty-state">
<i class="bx bx-purchase-tag-alt"></i>
Nessun prodotto ancora. Crea il primo con "Nuovo prodotto".
</div>
</td>
</tr>
<?php else :
$currentKind = null;
foreach ($products as $p) :
if ($p['kind'] !== $currentKind) :
$currentKind = $p['kind'];
[$kLabel, $kIcon] = $kindLabels[$currentKind] ?? [$currentKind, 'bx-box']; ?>
<tr class="kind-group">
<td colspan="7"><i class="bx <?php echo e($kIcon); ?>"></i><?php echo e($kLabel); ?></td>
</tr>
<?php endif; ?>
<tr>
<td>
<div class="prod-name">
<?php if (!empty($p['cover_image'])) : ?>
<img class="prod-thumb" src="<?php echo e($p['cover_image']); ?>" alt="">
<?php else : ?>
<span class="prod-thumb ph"><i class="bx bx-image"></i></span>
<?php endif; ?>
<div>
<div class="prod-title">
<?php echo e($p['title']); ?>
<?php if (!empty($p['badge'])) : ?><span class="badge-tag"><?php echo e($p['badge']); ?></span><?php endif; ?>
</div>
<?php if (!empty($p['subtitle'])) : ?><div class="prod-sub"><?php echo e($p['subtitle']); ?></div><?php endif; ?>
</div>
</div>
</td>
<td><?php echo e(rulesSummary($p)); ?></td>
<td>
<?php echo (int) $p['nvariants']; ?>
<?php if ($p['price_from'] !== null) : ?>
<span class="prod-sub">· da <?php echo e(euro($p['price_from'])); ?></span>
<?php endif; ?>
</td>
<td><?php echo $p['kind'] === 'lessons' ? (int) $p['nclasses'] : '—'; ?></td>
<td><?php echo (int) $p['nsold']; ?></td>
<td>
<span class="pill <?php echo $p['active'] === 'Y' ? 'on' : 'off'; ?>"><?php echo $p['active'] === 'Y' ? 'Attivo' : 'Disattivo'; ?></span>
<?php if ($p['visible_online'] === 'Y') : ?><span class="pill web" title="Visibile nell'acquisto online">Online</span><?php endif; ?>
</td>
<td class="text-end">
<a href="product_form.php?id=<?php echo (int) $p['idproduct']; ?>" class="btn-act"><i class="bx bx-edit"></i> Modifica</a>
<button type="button" class="btn-act btn-toggle" data-id="<?php echo (int) $p['idproduct']; ?>">
<i class="bx <?php echo $p['active'] === 'Y' ? 'bx-hide' : 'bx-show'; ?>"></i>
<?php echo $p['active'] === 'Y' ? 'Disattiva' : 'Attiva'; ?>
</button>
<?php if ((int) $p['nsold'] === 0) : ?>
<button type="button" class="btn-act danger btn-delete" data-id="<?php echo (int) $p['idproduct']; ?>" data-title="<?php echo e($p['title']); ?>"><i class="bx bx-trash"></i> Elimina</button>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
<form id="action-form" action="product_action.php" method="POST" style="display:none;">
<?php echo yb_csrf_field(); ?>
<input type="hidden" name="id" id="action-id">
<input type="hidden" name="action" id="action-name">
</form>
</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 sendAction(id, action) {
document.getElementById('action-id').value = id;
document.getElementById('action-name').value = action;
document.getElementById('action-form').submit();
}
document.querySelectorAll('.btn-toggle').forEach(function(btn) {
btn.addEventListener('click', function() {
sendAction(btn.getAttribute('data-id'), 'toggle');
});
});
document.querySelectorAll('.btn-delete').forEach(function(btn) {
btn.addEventListener('click', function() {
var title = document.createElement('div');
title.textContent = btn.getAttribute('data-title');
Swal.fire({
title: 'Eliminare il prodotto?',
html: 'Stai per eliminare <strong>' + title.innerHTML + '</strong> con tutte le sue opzioni.',
icon: 'warning',
showCancelButton: true,
confirmButtonText: 'Elimina',
cancelButtonText: 'Chiudi',
confirmButtonColor: '#e74c3c',
reverseButtons: true
}).then(function(r) {
if (r.isConfirmed) sendAction(btn.getAttribute('data-id'), 'delete');
});
});
});
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB