Catalogo prodotti: pagina admin Prodotti (elenco, scheda, opzioni, immagini) + helper CSRF
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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? */
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
@@ -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]);
|
||||
@@ -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 |
Reference in New Issue
Block a user