diff --git a/db/migrations/20261010162855_add_setup_state_to_option.php b/db/migrations/20261010162855_add_setup_state_to_option.php
new file mode 100644
index 0000000..078d9a3
--- /dev/null
+++ b/db/migrations/20261010162855_add_setup_state_to_option.php
@@ -0,0 +1,37 @@
+table('option')
+ ->addColumn('setup_state', 'text', ['null' => true, 'comment' => 'JSON percorso Primi passi'])
+ ->addColumn('setup_completed_at', 'datetime', ['null' => true])
+ ->update();
+
+ // Scuole che hanno già ordini o prenotazioni: percorso considerato concluso
+ $this->execute(
+ "UPDATE `option` o SET o.setup_completed_at = NOW()
+ WHERE EXISTS (SELECT 1 FROM orderbook ob WHERE ob.idschool = o.idschool)
+ OR EXISTS (SELECT 1 FROM bookingclass b WHERE b.idschool = o.idschool)"
+ );
+ }
+
+ public function down(): void
+ {
+ $this->table('option')
+ ->removeColumn('setup_state')
+ ->removeColumn('setup_completed_at')
+ ->update();
+ }
+}
diff --git a/public/assets/css/yb-shop.css b/public/assets/css/yb-shop.css
new file mode 100644
index 0000000..d74c1af
--- /dev/null
+++ b/public/assets/css/yb-shop.css
@@ -0,0 +1,1015 @@
+/* YogiBook - negozio della scuola (webapp; riutilizzabile dal plugin WordPress) */
+.ys-wrap {
+ --ys-ink: #1e4a4f;
+ --ys-teal: #2f7f84;
+ --ys-aqua: #dbf6f9;
+ --ys-cream: #f8efe4;
+ --ys-sand: #e2ceb1;
+ --ys-text: #24363a;
+ --ys-muted: #5b6b6d;
+ --ys-line: #dce7e8;
+ max-width: 1120px;
+ margin: 0 auto;
+ color: var(--ys-text);
+ font-size: 15px;
+}
+.ys-wrap * {
+ box-sizing: border-box;
+}
+.ys-wrap button {
+ font-family: inherit;
+}
+.ys-wrap a {
+ color: var(--ys-teal);
+}
+
+.ys-staffnote {
+ background: #fff8ec;
+ border: 1px solid #eed9b7;
+ color: #6b4b1e;
+ border-radius: 12px;
+ padding: 10px 14px;
+ font-size: 13px;
+ margin-bottom: 14px;
+ display: flex;
+ gap: 8px;
+ align-items: center;
+}
+.ys-empty {
+ text-align: center;
+ background: #fff;
+ border-radius: 20px;
+ padding: 48px 20px;
+ box-shadow: 0 4px 20px rgba(30, 74, 79, 0.06);
+}
+.ys-empty i {
+ font-size: 42px;
+ color: #9fcfd2;
+}
+.ys-empty h2 {
+ font-size: 22px;
+ color: var(--ys-ink);
+ margin: 10px 0 6px;
+ font-weight: 700;
+}
+.ys-empty p {
+ color: var(--ys-muted);
+}
+
+/* Intestazione catalogo */
+.ys-head {
+ display: flex;
+ justify-content: space-between;
+ gap: 18px;
+ flex-wrap: wrap;
+ align-items: flex-end;
+ margin-bottom: 18px;
+}
+.ys-h1 {
+ margin: 0;
+ font-size: 28px;
+ line-height: 1.15;
+ font-weight: 700;
+ color: var(--ys-ink);
+}
+.ys-lead {
+ margin: 6px 0 0;
+ color: var(--ys-muted);
+ font-size: 15px;
+}
+.ys-mine {
+ background: #fff;
+ border: 1px solid var(--ys-line);
+ border-radius: 16px;
+ padding: 12px 16px;
+ min-width: 260px;
+}
+.ys-mine b {
+ display: block;
+ font-size: 12px;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--ys-teal);
+ margin-bottom: 4px;
+}
+.ys-mine div {
+ font-size: 14px;
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+}
+.ys-mine span {
+ color: var(--ys-muted);
+ white-space: nowrap;
+}
+.ys-alert {
+ display: flex;
+ gap: 10px;
+ align-items: flex-start;
+ border-radius: 14px;
+ padding: 12px 16px;
+ font-size: 14px;
+ line-height: 1.45;
+ margin-bottom: 16px;
+ background: var(--ys-cream);
+ border: 1px solid #ead9bf;
+ color: #6b4b1e;
+}
+.ys-alert i {
+ font-size: 20px;
+ flex-shrink: 0;
+}
+
+/* Filtri */
+.ys-filters {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+ align-items: center;
+ margin-bottom: 18px;
+}
+.ys-pill {
+ border: 1px solid var(--ys-line);
+ background: #fff;
+ color: var(--ys-text);
+ border-radius: 999px;
+ padding: 8px 16px;
+ font-weight: 600;
+ font-size: 14px;
+ min-height: 40px;
+ cursor: pointer;
+}
+.ys-pill:hover {
+ border-color: #9fcfd2;
+}
+.ys-pill.on {
+ background: var(--ys-ink);
+ border-color: var(--ys-ink);
+ color: #fff;
+}
+.ys-select {
+ border: 1px solid var(--ys-line);
+ border-radius: 999px;
+ padding: 8px 14px;
+ font-size: 14px;
+ min-height: 40px;
+ background: #fff;
+ color: var(--ys-text);
+ margin-left: auto;
+}
+
+/* Griglia prodotti */
+.ys-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+ gap: 18px;
+}
+.ys-card {
+ display: flex;
+ flex-direction: column;
+ text-align: left;
+ background: #fff;
+ border: 1px solid var(--ys-line);
+ border-radius: 20px;
+ overflow: hidden;
+ padding: 0;
+ cursor: pointer;
+ box-shadow: 0 2px 12px rgba(30, 74, 79, 0.05);
+ transition:
+ transform 0.15s,
+ box-shadow 0.15s;
+}
+.ys-card:hover {
+ transform: translateY(-2px);
+ box-shadow: 0 8px 24px rgba(30, 74, 79, 0.1);
+}
+.ys-card:focus-visible {
+ outline: 3px solid #6fbabe;
+ outline-offset: 2px;
+}
+.ys-card-img {
+ height: 150px;
+ background: var(--ys-aqua) center / cover no-repeat;
+ position: relative;
+ display: flex;
+ align-items: flex-end;
+ padding: 14px;
+}
+.ys-card-img .mark {
+ font-size: 30px;
+ font-weight: 800;
+ color: var(--ys-ink);
+ opacity: 0.85;
+}
+.ys-card-img .ys-badge {
+ position: absolute;
+ top: 12px;
+ left: 12px;
+}
+.ys-card-body {
+ padding: 14px 16px 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ flex: 1;
+}
+.ys-card-title {
+ font-size: 17px;
+ font-weight: 700;
+ color: var(--ys-ink);
+ line-height: 1.25;
+}
+.ys-card-sub {
+ font-size: 13px;
+ color: var(--ys-muted);
+ line-height: 1.4;
+}
+.ys-card-rule {
+ font-size: 12px;
+ color: var(--ys-teal);
+ font-weight: 600;
+}
+.ys-card-foot {
+ margin-top: auto;
+ padding-top: 8px;
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+ gap: 8px;
+}
+.ys-price-from {
+ font-size: 18px;
+ font-weight: 800;
+ color: var(--ys-ink);
+}
+.ys-price-from small {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--ys-muted);
+}
+.ys-badge {
+ display: inline-block;
+ background: #fff;
+ color: #7a5a2e;
+ font-size: 11px;
+ font-weight: 800;
+ padding: 3px 10px;
+ border-radius: 10px;
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
+}
+.ys-badge.muted {
+ background: #eef0f3;
+ color: #4b5563;
+ box-shadow: none;
+}
+.ys-noresult {
+ text-align: center;
+ color: var(--ys-muted);
+ padding: 40px 10px;
+}
+
+/* Scheda prodotto */
+.ys-back {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ border: none;
+ background: none;
+ color: var(--ys-teal);
+ font-weight: 700;
+ font-size: 14px;
+ padding: 8px 0;
+ min-height: 44px;
+ cursor: pointer;
+}
+.ys-product {
+ display: grid;
+ grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr);
+ gap: 32px;
+ align-items: start;
+}
+.ys-gallery-main {
+ aspect-ratio: 4 / 3;
+ border-radius: 22px;
+ background: var(--ys-aqua) center / cover no-repeat;
+ display: flex;
+ align-items: flex-end;
+ padding: 22px;
+}
+.ys-gallery-main .mark {
+ font-size: 54px;
+ font-weight: 800;
+ color: var(--ys-ink);
+ opacity: 0.8;
+}
+.ys-thumbs {
+ display: flex;
+ gap: 8px;
+ margin-top: 10px;
+ flex-wrap: wrap;
+}
+.ys-thumb {
+ width: 64px;
+ height: 64px;
+ border-radius: 12px;
+ border: 2px solid transparent;
+ padding: 0;
+ background: #eee center / cover no-repeat;
+ cursor: pointer;
+}
+.ys-thumb.on {
+ border-color: var(--ys-teal);
+}
+.ys-info {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+.ys-title {
+ margin: 0;
+ font-size: 28px;
+ line-height: 1.2;
+ font-weight: 700;
+ color: var(--ys-ink);
+}
+.ys-subtitle {
+ margin: 0;
+ font-size: 16px;
+ color: var(--ys-muted);
+}
+.ys-desc {
+ line-height: 1.6;
+}
+.ys-desc p {
+ margin: 0 0 8px;
+}
+.ys-h3 {
+ margin: 0 0 8px;
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--ys-ink);
+}
+.ys-li {
+ display: flex;
+ gap: 10px;
+ align-items: flex-start;
+ line-height: 1.45;
+ font-size: 14px;
+ margin-bottom: 6px;
+}
+.ys-check {
+ width: 22px;
+ height: 22px;
+ border-radius: 50%;
+ background: var(--ys-aqua);
+ color: var(--ys-teal);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ font-size: 15px;
+}
+.ys-dot {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: #c9a876;
+ margin-top: 7px;
+ flex-shrink: 0;
+}
+.ys-cons {
+ background: #fff;
+ border: 1px solid #e8dcc9;
+ border-radius: 14px;
+ padding: 12px 14px;
+}
+.ys-chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+.ys-chip {
+ font-size: 13px;
+ background: #fff;
+ border: 1px solid var(--ys-line);
+ border-radius: 20px;
+ padding: 5px 11px;
+}
+.ys-info2 {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+}
+.ys-info2 div {
+ background: #fff;
+ border-radius: 14px;
+ padding: 12px;
+ font-size: 13px;
+ line-height: 1.45;
+}
+.ys-info2 b {
+ display: block;
+ font-size: 12px;
+ color: var(--ys-teal);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ margin-bottom: 4px;
+}
+.ys-muted {
+ font-size: 13px;
+ color: var(--ys-muted);
+ line-height: 1.4;
+}
+
+/* Opzioni e scelte */
+.ys-options {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+.ys-opt {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ width: 100%;
+ text-align: left;
+ background: #fff;
+ border: 1px solid #d5e3e4;
+ border-radius: 16px;
+ padding: 14px;
+ min-height: 64px;
+ cursor: pointer;
+}
+.ys-opt.sel {
+ background: #f4fafa;
+ border: 2px solid var(--ys-teal);
+ padding: 13px;
+}
+.ys-opt:disabled {
+ opacity: 0.55;
+ cursor: not-allowed;
+}
+.ys-radio {
+ width: 22px;
+ height: 22px;
+ border-radius: 50%;
+ border: 2px solid #a9bfc1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+.ys-opt.sel .ys-radio {
+ border-color: var(--ys-teal);
+}
+.ys-opt.sel .ys-radio::after {
+ content: "";
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ background: var(--ys-teal);
+}
+.ys-box {
+ width: 24px;
+ height: 24px;
+ border-radius: 7px;
+ border: 2px solid #a9bfc1;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ color: #fff;
+ font-size: 16px;
+}
+.ys-opt.sel .ys-box {
+ background: var(--ys-teal);
+ border-color: var(--ys-teal);
+}
+.ys-opt-main {
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ flex: 1;
+ min-width: 0;
+}
+.ys-opt-title {
+ font-weight: 700;
+ font-size: 16px;
+ color: var(--ys-ink);
+}
+.ys-opt-note {
+ font-size: 12px;
+ color: var(--ys-teal);
+ font-weight: 600;
+}
+.ys-opt-price {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ flex-shrink: 0;
+}
+.ys-opt-price s {
+ font-size: 13px;
+ color: #6b7a7c;
+}
+.ys-opt-price b {
+ font-size: 19px;
+ color: var(--ys-ink);
+}
+.ys-seats {
+ font-size: 12px;
+ font-weight: 700;
+ padding: 3px 8px;
+ border-radius: 10px;
+ flex-shrink: 0;
+ background: #e9f7f0;
+ color: #1a6b47;
+ white-space: nowrap;
+}
+.ys-seats.low {
+ background: #fbede3;
+ color: #9a4a14;
+}
+.ys-seats.full {
+ background: #fdecea;
+ color: #a33a1f;
+}
+.ys-seats.none,
+.ys-seats.mine {
+ background: #eef0f3;
+ color: #4b5563;
+}
+.ys-pillcount {
+ align-self: flex-start;
+ font-size: 13px;
+ font-weight: 700;
+ color: var(--ys-teal);
+ background: var(--ys-aqua);
+ padding: 4px 10px;
+ border-radius: 10px;
+}
+.ys-note {
+ display: flex;
+ gap: 10px;
+ background: #fff;
+ border: 1px dashed #b9d7d9;
+ border-radius: 14px;
+ padding: 12px 14px;
+ font-size: 13px;
+ line-height: 1.45;
+}
+.ys-panel {
+ background: #fff;
+ border-radius: 18px;
+ padding: 18px;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+.ys-label {
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--ys-ink);
+ margin-bottom: 6px;
+ display: block;
+}
+.ys-input {
+ width: 100%;
+ border: 1px solid #cfdedf;
+ border-radius: 12px;
+ padding: 12px;
+ font-size: 15px;
+ color: var(--ys-text);
+ background: #fff;
+}
+.ys-input:focus {
+ outline: none;
+ border-color: var(--ys-teal);
+ box-shadow: 0 0 0 3px rgba(47, 127, 132, 0.15);
+}
+.ys-choose {
+ max-width: 680px;
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+/* Barra acquisto */
+.ys-buy {
+ position: sticky;
+ bottom: 0;
+ z-index: 4;
+ margin-top: 22px;
+ background: #fff;
+ border: 1px solid #e3eced;
+ border-radius: 18px;
+ padding: 12px 16px;
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ box-shadow: 0 -4px 20px rgba(30, 74, 79, 0.06);
+}
+.ys-buy .lbl {
+ font-size: 12px;
+ color: var(--ys-muted);
+}
+.ys-buy .amt {
+ font-size: 24px;
+ font-weight: 800;
+ color: var(--ys-ink);
+ line-height: 1.1;
+}
+.ys-btn {
+ border: none;
+ border-radius: 14px;
+ min-height: 50px;
+ padding: 0 24px;
+ font-size: 16px;
+ font-weight: 700;
+ background: var(--ys-teal);
+ color: #fff;
+ cursor: pointer;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ text-decoration: none;
+}
+.ys-btn:hover {
+ background: var(--ys-ink);
+ color: #fff;
+}
+.ys-btn:disabled {
+ background: #c9d6d7;
+ cursor: not-allowed;
+}
+
+/* Schede Prodotti / Orario */
+.ys-tabs {
+ display: inline-flex;
+ gap: 4px;
+ background: #e9f1f1;
+ border-radius: 12px;
+ padding: 4px;
+ margin-bottom: 16px;
+}
+.ys-tab {
+ border: none;
+ background: transparent;
+ border-radius: 9px;
+ padding: 9px 16px;
+ font-weight: 700;
+ font-size: 14px;
+ color: var(--ys-muted);
+ cursor: pointer;
+ min-height: 40px;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+.ys-tab.on {
+ background: #fff;
+ color: var(--ys-ink);
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
+}
+
+/* Orario settimanale */
+.ys-week {
+ display: grid;
+ grid-template-columns: repeat(var(--ys-cols, 5), minmax(0, 1fr));
+ gap: 10px;
+ align-items: start;
+}
+.ys-day {
+ background: #fff;
+ border-radius: 16px;
+ padding: 10px;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ min-height: 120px;
+ border: 1px solid var(--ys-line);
+}
+.ys-day-name {
+ font-size: 13px;
+ font-weight: 800;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--ys-ink);
+ text-align: center;
+ padding: 4px 0 6px;
+ border-bottom: 1px solid #eef3f3;
+}
+.ys-day-empty {
+ text-align: center;
+ color: #b7c4c5;
+ padding: 10px 0;
+}
+.ys-slot {
+ --ys-c: #6fbabe;
+ position: relative;
+ text-align: left;
+ border: 1px solid var(--ys-line);
+ border-left: 5px solid var(--ys-c);
+ background: #fff;
+ border-radius: 12px;
+ padding: 9px 10px 10px;
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ cursor: pointer;
+ width: 100%;
+}
+.ys-slot:hover {
+ background: #f4fafa;
+}
+.ys-slot.sel {
+ background: #f4fafa;
+ border-color: var(--ys-teal);
+ border-left-color: var(--ys-c);
+ box-shadow: 0 0 0 2px var(--ys-teal);
+}
+.ys-slot .t {
+ font-size: 12px;
+ font-weight: 800;
+ color: var(--ys-teal);
+}
+.ys-slot .n {
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--ys-ink);
+ line-height: 1.25;
+}
+.ys-slot .m {
+ font-size: 12px;
+ color: var(--ys-muted);
+}
+.ys-slot .ys-seats {
+ align-self: flex-start;
+ margin-top: 4px;
+ font-size: 11px;
+ padding: 2px 7px;
+}
+.ys-calpanel {
+ margin-top: 18px;
+ background: #fff;
+ border: 1px solid var(--ys-line);
+ border-radius: 20px;
+ padding: 18px;
+ scroll-margin-top: 90px;
+}
+.ys-calpanel-head {
+ --ys-c: #6fbabe;
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ align-items: flex-start;
+ border-left: 5px solid var(--ys-c);
+ padding-left: 12px;
+ margin-bottom: 12px;
+}
+@media (max-width: 860px) {
+ .ys-week {
+ grid-template-columns: minmax(0, 1fr);
+ }
+ .ys-day {
+ min-height: 0;
+ }
+ .ys-day-name {
+ text-align: left;
+ }
+ .ys-day:has(.ys-day-empty) {
+ display: none;
+ }
+}
+
+@media (max-width: 860px) {
+ .ys-product {
+ grid-template-columns: minmax(0, 1fr);
+ gap: 18px;
+ }
+ .ys-select {
+ margin-left: 0;
+ }
+}
+@media (max-width: 520px) {
+ .ys-h1,
+ .ys-title {
+ font-size: 24px;
+ }
+ .ys-info2 {
+ grid-template-columns: minmax(0, 1fr);
+ }
+ .ys-buy {
+ border-radius: 0;
+ margin-left: -12px;
+ margin-right: -12px;
+ }
+}
+
+/* ===== Checkout e ordine ===== */
+.ys-co-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1.4fr) minmax(300px, 1fr);
+ gap: 18px;
+ align-items: start;
+}
+.ys-co-main {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+.ys-co-side {
+ position: sticky;
+ top: 90px;
+ margin: 0;
+}
+.ys-co-prod {
+ display: flex;
+ justify-content: space-between;
+ gap: 14px;
+ align-items: flex-start;
+ padding-bottom: 12px;
+ border-bottom: 1px solid #e3eced;
+}
+.ys-kv {
+ display: grid;
+ grid-template-columns: minmax(110px, auto) 1fr;
+ gap: 8px 16px;
+ font-size: 14px;
+ margin: 0;
+}
+.ys-kv dt {
+ color: var(--ys-muted);
+ font-weight: 600;
+}
+.ys-kv dd {
+ margin: 0;
+ color: var(--ys-text);
+ word-break: break-word;
+}
+.ys-coupon {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ margin: 0;
+}
+.ys-co-ok {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 8px;
+ background: #e9f7f0;
+ color: #1a6b47;
+ border-radius: 12px;
+ padding: 8px 12px;
+ font-weight: 600;
+ font-size: 14px;
+}
+.ys-co-err {
+ font-size: 13px;
+ font-weight: 600;
+ color: #a33a1f;
+}
+.ys-linkbtn {
+ border: none;
+ background: none;
+ color: var(--ys-teal);
+ font-weight: 700;
+ text-decoration: underline;
+ padding: 6px 4px;
+ min-height: 36px;
+ cursor: pointer;
+ font-size: 14px;
+}
+.ys-btn-light {
+ background: #fff;
+ color: var(--ys-teal);
+ border: 1px solid var(--ys-teal);
+}
+.ys-btn-light:hover {
+ background: #f4fafa;
+ color: var(--ys-ink);
+}
+label.ys-opt {
+ margin: 0;
+}
+.ys-radio-input {
+ position: absolute;
+ opacity: 0;
+ pointer-events: none;
+}
+.ys-co-totals {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ font-size: 14px;
+ border-top: 1px solid #e3eced;
+ padding-top: 12px;
+}
+.ys-co-totals div {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+}
+.ys-co-totals .ok {
+ color: #1a6b47;
+}
+.ys-co-totals .tot {
+ font-size: 22px;
+ font-weight: 800;
+ color: var(--ys-ink);
+ padding-top: 4px;
+}
+.ys-terms {
+ display: flex;
+ gap: 10px;
+ align-items: flex-start;
+ font-size: 13px;
+ line-height: 1.45;
+ cursor: pointer;
+ margin: 0;
+ padding: 6px;
+ border-radius: 10px;
+}
+.ys-terms input {
+ width: 20px;
+ height: 20px;
+ margin: 0;
+ accent-color: var(--ys-teal);
+ flex-shrink: 0;
+}
+.ys-terms.need {
+ background: #fdecea;
+ color: #8f2f24;
+}
+.ys-terms-text {
+ max-height: 220px;
+ overflow-y: auto;
+ font-size: 13px;
+ line-height: 1.5;
+ background: #fafbfc;
+ border: 1px solid #eef0f3;
+ border-radius: 10px;
+ padding: 10px 12px;
+ color: var(--ys-text);
+}
+.ys-done {
+ text-align: center;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8px;
+ padding: 10px 0 22px;
+}
+.ys-done-ico {
+ width: 72px;
+ height: 72px;
+ border-radius: 50%;
+ background: var(--ys-aqua);
+ color: var(--ys-teal);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 38px;
+ box-shadow: 10px 0 0 var(--ys-cream);
+}
+.ys-bank {
+ border: 2px dashed #9fcfd2;
+ margin-bottom: 16px;
+}
+.ys-mono {
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ letter-spacing: 0.03em;
+}
+.ys-status {
+ display: inline-block;
+ font-size: 12px;
+ font-weight: 700;
+ padding: 4px 10px;
+ border-radius: 20px;
+ white-space: nowrap;
+}
+.ys-status.ok {
+ background: #e7f7ee;
+ color: #1a7f52;
+}
+.ys-status.warn {
+ background: #fff4e0;
+ color: #92560b;
+}
+.ys-status.muted {
+ background: #eef0f3;
+ color: #4b5563;
+}
+.ys-checkout .ys-panel {
+ margin-bottom: 0;
+ border: 1px solid var(--ys-line);
+}
+@media (max-width: 860px) {
+ .ys-co-grid {
+ grid-template-columns: minmax(0, 1fr);
+ }
+ .ys-co-side {
+ position: static;
+ }
+}
diff --git a/public/assets/js/yb-shop.js b/public/assets/js/yb-shop.js
new file mode 100644
index 0000000..fc38971
--- /dev/null
+++ b/public/assets/js/yb-shop.js
@@ -0,0 +1,1186 @@
+/*!
+ * YogiBook - negozio della scuola.
+ * Catalogo, scheda prodotto e scelte (classe fissa, data, destinatario del buono).
+ * Pensato per essere riusato anche dal plugin WordPress:
+ * YBShop.mount(elemento, dati, { onCheckout: function (scelta) { ... } })
+ * "dati" è l'oggetto prodotto da ShopService::catalog().
+ * Indirizzi: #/slug (scheda prodotto), #/slug/scegli (passo delle scelte).
+ */
+(function () {
+ "use strict";
+
+ var MESI = [
+ "gennaio",
+ "febbraio",
+ "marzo",
+ "aprile",
+ "maggio",
+ "giugno",
+ "luglio",
+ "agosto",
+ "settembre",
+ "ottobre",
+ "novembre",
+ "dicembre",
+ ];
+ var TONES = {
+ single: "#DBF6F9",
+ fixed: "#DBF6F9",
+ free: "#F8EFE4",
+ membership: "#F8EFE4",
+ voucher: "#E2CEB1",
+ };
+ var GROUPS = [
+ ["single", "Per iniziare"],
+ ["fixed", "Classe fissa"],
+ ["free", "Flessibili"],
+ ["membership", "Tessera"],
+ ["voucher", "Regali"],
+ ];
+
+ function esc(s) {
+ return String(s === null || s === undefined ? "" : s).replace(
+ /[&<>"']/g,
+ function (c) {
+ return {
+ "&": "&",
+ "<": "<",
+ ">": ">",
+ '"': """,
+ "'": "'",
+ }[c];
+ },
+ );
+ }
+ function euro(n) {
+ return (
+ Number(n).toLocaleString("it-IT", {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ }) + " €"
+ );
+ }
+ function fmtDate(iso) {
+ var p = String(iso).split("-");
+ return (
+ parseInt(p[2], 10) + " " + MESI[parseInt(p[1], 10) - 1] + " " + p[0]
+ );
+ }
+ function addDays(iso, n) {
+ var p = iso.split("-"),
+ d = new Date(+p[0], +p[1] - 1, +p[2] + n);
+ return (
+ d.getFullYear() +
+ "-" +
+ String(d.getMonth() + 1).padStart(2, "0") +
+ "-" +
+ String(d.getDate()).padStart(2, "0")
+ );
+ }
+ function unitLabel(n, unit) {
+ var l = {
+ days: ["giorno", "giorni"],
+ weeks: ["settimana", "settimane"],
+ months: ["mese", "mesi"],
+ }[unit] || ["", ""];
+ return n + " " + (n === 1 ? l[0] : l[1]);
+ }
+
+ function mount(root, DATA, opts) {
+ opts = opts || {};
+ // cls = classe scelta nell'orario; prefSvc = classe da preselezionare nella scheda (classe fissa)
+ var S = {
+ filter: "",
+ cat: 0,
+ pid: null,
+ vid: null,
+ picked: [],
+ occ: null,
+ rName: "",
+ rEmail: "",
+ rMsg: "",
+ img: 0,
+ cls: 0,
+ prefSvc: 0,
+ from: "",
+ };
+ var lastView = null;
+
+ /* ---------------- dati ---------------- */
+ function byId(id) {
+ for (var i = 0; i < DATA.products.length; i++)
+ if (DATA.products[i].id === id) return DATA.products[i];
+ return null;
+ }
+ function bySlug(slug) {
+ for (var i = 0; i < DATA.products.length; i++)
+ if (DATA.products[i].slug === slug) return DATA.products[i];
+ return null;
+ }
+ function prod() {
+ return byId(S.pid);
+ }
+ function variant(p) {
+ p = p || prod();
+ if (!p) return null;
+ for (var i = 0; i < p.variants.length; i++)
+ if (p.variants[i].id === S.vid) return p.variants[i];
+ return null;
+ }
+ function svc(id) {
+ return DATA.services[id] || null;
+ }
+ function needsChoose(p) {
+ return (
+ !!p &&
+ (p.mode === "fixed" ||
+ p.mode === "single" ||
+ p.mode === "voucher")
+ );
+ }
+
+ function mark(p) {
+ if (p.mode === "fixed") return p.slots === 2 ? "2×" : "1×";
+ if (p.mode === "single") return p.once ? "Prova" : "1";
+ if (p.mode === "membership") return "Tessera";
+ if (p.mode === "voucher") return "Regalo";
+ var v = p.variants[0];
+ return v.lessons ? String(v.lessons) : "∞";
+ }
+ function ruleLine(p) {
+ var lessons = p.variants
+ .map(function (v) {
+ return v.lessons;
+ })
+ .filter(Boolean);
+ var range = lessons.length
+ ? (Math.min.apply(null, lessons) ===
+ Math.max.apply(null, lessons)
+ ? Math.min.apply(null, lessons)
+ : Math.min.apply(null, lessons) +
+ "–" +
+ Math.max.apply(null, lessons)) + " lezioni"
+ : "";
+ if (p.mode === "fixed")
+ return (
+ (p.slots === 2
+ ? "2 classi fisse a settimana"
+ : "1 classe fissa a settimana") +
+ (range ? " · " + range : "")
+ );
+ if (p.mode === "free")
+ return (
+ "Prenoti tu" +
+ (p.weekly ? " · max " + p.weekly + "/sett." : "") +
+ (range ? " · " + range : "")
+ );
+ if (p.mode === "single") return "Scegli giorno e ora";
+ if (p.mode === "membership") return "Quota associativa";
+ return "Da regalare con un codice";
+ }
+ function validityText(p, v) {
+ if (p.mode === "membership") {
+ if (v.vmode === "calendar_year")
+ return "Valida fino al " + fmtDate(DATA.yearEnd);
+ if (v.vmode === "season")
+ return "Valida fino al " + fmtDate(DATA.seasonEnd);
+ if (v.vmode === "duration" && v.vvalue)
+ return (
+ "Valida " +
+ unitLabel(v.vvalue, v.vunit) +
+ " dall'acquisto"
+ );
+ return "Senza scadenza";
+ }
+ if (p.mode === "voucher")
+ return v.vvalue
+ ? "Il buono scade dopo " + unitLabel(v.vvalue, v.vunit)
+ : "Il buono non scade";
+ if (p.mode === "single") return "Valida per la lezione che scegli";
+ if (v.vmode === "none" || !v.vvalue) return "Senza scadenza";
+ return (
+ "Da usare entro " +
+ unitLabel(v.vvalue, v.vunit) +
+ (v.vstart === "purchase"
+ ? " dall'acquisto"
+ : " dalla prima lezione")
+ );
+ }
+
+ /* ---------------- posti ---------------- */
+ // Classe fissa: minimo dei posti liberi sulle prossime N lezioni coperte dal pacchetto
+ function fixedSeats(sid, p, v) {
+ var s = svc(sid);
+ var n = v && v.lessons ? Math.ceil(v.lessons / (p.slots || 1)) : 8;
+ var occ = s ? s.occ.slice(0, n) : [];
+ if (!occ.length)
+ return {
+ cls: "none",
+ text: "Nessuna lezione in calendario",
+ ok: false,
+ };
+ if (s.cap === null)
+ return { cls: "", text: "Posti disponibili", ok: true };
+ var min = Math.min.apply(
+ null,
+ occ.map(function (o) {
+ return o.free;
+ }),
+ );
+ if (min <= 0)
+ return { cls: "full", text: "Al completo", ok: false };
+ return {
+ cls: min <= 2 ? "low" : "",
+ text: min === 1 ? "Ultimo posto" : min + " posti liberi",
+ ok: true,
+ };
+ }
+ function seatsBadge(o) {
+ if (o.mine)
+ return { cls: "mine", text: "Sei già iscritta", ok: false };
+ if (o.free === null)
+ return { cls: "", text: "Posti disponibili", ok: true };
+ if (o.free <= 0)
+ return { cls: "full", text: "Al completo", ok: false };
+ return {
+ cls: o.free <= 2 ? "low" : "",
+ text: o.free === 1 ? "Ultimo posto" : o.free + " posti",
+ ok: true,
+ };
+ }
+ function singleOccurrences(p) {
+ var limit = addDays(DATA.today, DATA.singleDays || 21),
+ list = [];
+ p.services.forEach(function (sid) {
+ var s = svc(sid);
+ if (!s) return;
+ s.occ.forEach(function (o) {
+ if (o.date <= limit) list.push({ sid: sid, o: o, s: s });
+ });
+ });
+ list.sort(function (a, b) {
+ return (a.o.date + a.o.time).localeCompare(b.o.date + b.o.time);
+ });
+ return list.slice(0, 40);
+ }
+ function chooseValid(p) {
+ if (!p) return false;
+ if (p.mode === "fixed") return S.picked.length === (p.slots || 1);
+ if (p.mode === "single") return !!S.occ;
+ if (p.mode === "voucher")
+ return (
+ S.rName.trim() !== "" &&
+ /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(S.rEmail.trim())
+ );
+ return true;
+ }
+
+ /* ---------------- render ---------------- */
+ function cover(p, cls) {
+ var img = p.cover || (p.gallery && p.gallery[0]) || "";
+ return img
+ ? '
"
+ : '
' +
+ esc(mark(p)) +
+ " ";
+ }
+
+ var DAYS = [
+ "Domenica",
+ "Lunedì",
+ "Martedì",
+ "Mercoledì",
+ "Giovedì",
+ "Venerdì",
+ "Sabato",
+ ];
+ var DAY_ORDER = [1, 2, 3, 4, 5, 6, 0];
+ var CAT_COLORS = [
+ "#6FBABE",
+ "#E2A86B",
+ "#9C8FD6",
+ "#7FB77E",
+ "#E58F9E",
+ "#5A9BD5",
+ "#C9A876",
+ ];
+
+ function catColor(s) {
+ if (s.color) return s.color;
+ var i = 0;
+ (DATA.categories || []).forEach(function (c, idx) {
+ if (c.id === s.cat) i = idx;
+ });
+ return CAT_COLORS[i % CAT_COLORS.length];
+ }
+ function endTime(start, minutes) {
+ if (!minutes) return "";
+ var p = start.split(":"),
+ t = +p[0] * 60 + +p[1] + minutes;
+ return (
+ String(Math.floor(t / 60) % 24).padStart(2, "0") +
+ ":" +
+ String(t % 60).padStart(2, "0")
+ );
+ }
+ function productsWith(sid) {
+ return DATA.products.filter(function (p) {
+ return p.kind === "lessons" && p.services.indexOf(sid) !== -1;
+ });
+ }
+
+ function renderTop(active) {
+ var h =
+ '
Scegli come praticare ' +
+ (active === "calendar"
+ ? "Parti dall'orario: scegli una classe e vedi con quali pacchetti puoi frequentarla."
+ : "Pacchetti con la tua classe fissa, ingressi flessibili, una lezione singola o un regalo.") +
+ "
";
+ var u = DATA.user || {};
+ if (u.packages && u.packages.length) {
+ h +=
+ '
I tuoi pacchetti attivi ' +
+ u.packages
+ .slice(0, 3)
+ .map(function (k) {
+ return (
+ "
" +
+ esc(k.title) +
+ " " +
+ (k.pending
+ ? "in attesa di pagamento"
+ : (k.left !== null
+ ? k.left + " lezioni"
+ : "") +
+ (k.expire
+ ? " · fino al " + fmtDate(k.expire)
+ : "")) +
+ "
"
+ );
+ })
+ .join("") +
+ "
";
+ }
+ h += "
";
+ if (u.membershipRequired && !u.hasMembership) {
+ h +=
+ '
Per frequentare le lezioni serve la
tessera associativa valida.' +
+ (u.membershipProduct
+ ? '
Acquista la tessera '
+ : " Chiedi in segreteria.") +
+ "
";
+ }
+ var hasCal = Object.keys(DATA.services).some(function (k) {
+ return DATA.services[k].occ.length > 0;
+ });
+ if (hasCal) {
+ h +=
+ '
' +
+ ' Prodotti ' +
+ ' Orario settimanale
';
+ }
+ return h;
+ }
+
+ function catSelect() {
+ if (!DATA.categories || DATA.categories.length < 2) return "";
+ return (
+ '
Tutte le discipline ' +
+ DATA.categories
+ .map(function (c) {
+ return (
+ '" +
+ esc(c.name) +
+ " "
+ );
+ })
+ .join("") +
+ ""
+ );
+ }
+
+ function productCard(p, fromSvc) {
+ var min = Math.min.apply(
+ null,
+ p.variants.map(function (v) {
+ return v.price;
+ }),
+ );
+ return (
+ '
" +
+ cover(p, "ys-card-img") +
+ (p.blocked === "used"
+ ? 'Già utilizzata '
+ : p.badge
+ ? '' + esc(p.badge) + " "
+ : "") +
+ " " +
+ '
' +
+ esc(p.title) +
+ " " +
+ (p.subtitle
+ ? '' + esc(p.subtitle) + " "
+ : "") +
+ '' +
+ esc(ruleLine(p)) +
+ " " +
+ '"
+ );
+ }
+
+ function renderCatalog() {
+ var h = renderTop("products");
+ var present = GROUPS.filter(function (g) {
+ return DATA.products.some(function (p) {
+ return p.group === g[0];
+ });
+ });
+ h +=
+ '
Tutti ' +
+ present
+ .map(function (g) {
+ return (
+ '' +
+ esc(g[1]) +
+ " "
+ );
+ })
+ .join("") +
+ catSelect() +
+ "
";
+
+ var list = DATA.products.filter(function (p) {
+ if (S.filter && p.group !== S.filter) return false;
+ if (
+ S.cat &&
+ p.kind === "lessons" &&
+ p.cats.indexOf(S.cat) === -1
+ )
+ return false;
+ if (S.cat && p.kind !== "lessons" && S.filter === "")
+ return false;
+ return true;
+ });
+ if (!DATA.products.length)
+ return (
+ h +
+ '
Al momento non ci sono prodotti in vendita.
'
+ );
+ if (!list.length)
+ return (
+ h +
+ '
Nessun prodotto con questi filtri.
'
+ );
+ return (
+ h +
+ '
' +
+ list
+ .map(function (p) {
+ return productCard(p);
+ })
+ .join("") +
+ "
"
+ );
+ }
+
+ /* Orario settimanale: le classi ricavate dalla prossima lezione in calendario */
+ function renderCalendar() {
+ var h = renderTop("calendar");
+ h +=
+ '
' +
+ catSelect() +
+ 'Tocca una classe per vedere come frequentarla
';
+
+ var cols = {};
+ DAY_ORDER.forEach(function (d) {
+ cols[d] = [];
+ });
+ Object.keys(DATA.services).forEach(function (k) {
+ var s = DATA.services[k],
+ sid = +k;
+ if (
+ !s.occ.length ||
+ (S.cat && s.cat !== S.cat) ||
+ !productsWith(sid).length
+ )
+ return;
+ var o = s.occ[0],
+ p = o.date.split("-");
+ var wd = new Date(+p[0], +p[1] - 1, +p[2]).getDay();
+ cols[wd].push({ sid: sid, s: s, o: o });
+ });
+ var days = DAY_ORDER.filter(function (d) {
+ return cols[d].length || (d >= 1 && d <= 5);
+ });
+ if (
+ !days.some(function (d) {
+ return cols[d].length;
+ })
+ ) {
+ return (
+ h +
+ '
Nessuna classe in calendario' +
+ (S.cat ? " per questa disciplina" : "") +
+ ".
"
+ );
+ }
+
+ h += '
';
+ days.forEach(function (d) {
+ cols[d].sort(function (a, b) {
+ return a.o.time.localeCompare(b.o.time);
+ });
+ h +=
+ '
' +
+ DAYS[d] +
+ "
";
+ if (!cols[d].length) h += '
—
';
+ cols[d].forEach(function (it) {
+ var b = seatsBadge(it.o),
+ sel = S.cls === it.sid,
+ col = catColor(it.s);
+ var end = endTime(it.o.time, it.s.minutes);
+ h +=
+ '
' +
+ '' +
+ esc(it.o.time) +
+ (end ? "–" + esc(end) : "") +
+ " " +
+ '' +
+ esc(it.s.name) +
+ " " +
+ (it.s.teacher
+ ? '' + esc(it.s.teacher) + " "
+ : "") +
+ '' +
+ esc(b.text) +
+ " ";
+ });
+ h += "
";
+ });
+ h += "
";
+
+ if (S.cls && DATA.services[S.cls]) {
+ var s = DATA.services[S.cls],
+ list = productsWith(S.cls);
+ h +=
+ '
' +
+ '
' +
+ esc(s.name) +
+ " · " +
+ esc(s.occ[0].label.split(" · ")[0].split(" ")[0]) +
+ " " +
+ esc(s.occ[0].time) +
+ "
" +
+ '
Prossime lezioni: ' +
+ s.occ
+ .slice(0, 3)
+ .map(function (o) {
+ return esc(o.label.replace(/^\S+\s/, ""));
+ })
+ .join(" · ") +
+ "
" +
+ '
Chiudi ' +
+ '
' +
+ (list.length === 1
+ ? "Puoi frequentarla con questo prodotto:"
+ : "Puoi frequentarla con questi prodotti:") +
+ "
" +
+ '
' +
+ list
+ .map(function (p) {
+ return productCard(p, S.cls);
+ })
+ .join("") +
+ "
";
+ }
+ return h;
+ }
+
+ function renderProduct(p, v) {
+ var imgs = [];
+ if (p.cover) imgs.push(p.cover);
+ (p.gallery || []).forEach(function (g) {
+ if (imgs.indexOf(g) === -1) imgs.push(g);
+ });
+ var cur = imgs[S.img] || imgs[0];
+
+ var h =
+ '
' +
+ (S.from === "calendar"
+ ? "Torna all'orario"
+ : "Tutti i prodotti") +
+ '';
+ h += cur
+ ? '
'
+ : '
' +
+ esc(mark(p)) +
+ "
";
+ if (imgs.length > 1) {
+ h +=
+ '
' +
+ imgs
+ .map(function (src, i) {
+ return (
+ ' '
+ );
+ })
+ .join("") +
+ "
";
+ }
+ if (p.pros.length) {
+ h +=
+ '
Vantaggi ' +
+ p.pros
+ .map(function (t) {
+ return (
+ '
' +
+ esc(t) +
+ "
"
+ );
+ })
+ .join("") +
+ "
";
+ }
+ if (p.cons.length) {
+ h +=
+ '
Da considerare ' +
+ p.cons
+ .map(function (t) {
+ return (
+ '
' +
+ esc(t) +
+ "
"
+ );
+ })
+ .join("") +
+ "
";
+ }
+ h += '
';
+ if (p.badge)
+ h +=
+ '
' +
+ esc(p.badge) +
+ " ";
+ h += '
' + esc(p.title) + " ";
+ if (p.subtitle)
+ h += '
' + esc(p.subtitle) + "
";
+ if (p.description)
+ h += '
' + p.description + "
";
+ if (p.kind === "lessons" && p.services.length) {
+ h +=
+ '
' +
+ (p.mode === "fixed"
+ ? "Classi tra cui scegliere"
+ : "Vale per queste classi") +
+ ' ' +
+ p.services
+ .map(function (sid) {
+ var s = svc(sid);
+ return s
+ ? '' +
+ esc(
+ s.name + (s.day ? " · " + s.day : ""),
+ ) +
+ " "
+ : "";
+ })
+ .join("") +
+ "
" +
+ (p.weekly
+ ? '
Puoi prenotare al massimo ' +
+ p.weekly +
+ " lezioni a settimana.
"
+ : "") +
+ (p.maxres !== null && p.mode !== "single"
+ ? '
Recuperi inclusi: ' +
+ p.maxres +
+ ".
"
+ : "") +
+ "
";
+ }
+ if (p.suitable || p.requirements) {
+ h +=
+ '
' +
+ (p.suitable
+ ? "
Per chi è " + esc(p.suitable) + "
"
+ : "") +
+ (p.requirements
+ ? "
Cosa serve " +
+ esc(p.requirements) +
+ "
"
+ : "") +
+ "
";
+ }
+ h +=
+ '
' +
+ (p.variants.length > 1 ? "Scegli l'opzione" : "Prezzo") +
+ ' ';
+ p.variants.forEach(function (x) {
+ var sel = v && x.id === v.id;
+ h +=
+ ' ' +
+ '' +
+ esc(x.label) +
+ ' ' +
+ esc(validityText(p, x)) +
+ " " +
+ (p.mode === "voucher" && x.targetLabel
+ ? 'Regala: ' +
+ esc(x.targetLabel) +
+ " "
+ : "") +
+ (x.note
+ ? '' + esc(x.note) + " "
+ : "") +
+ " " +
+ '' +
+ (x.compare ? "" + euro(x.compare) + " " : "") +
+ "" +
+ euro(x.price) +
+ " ";
+ });
+ h += "
";
+
+ var blocked = p.blocked === "used";
+ h += buyBar(
+ v,
+ blocked
+ ? '
Già utilizzata '
+ : '
' +
+ (needsChoose(p) ? "Continua" : "Acquista") +
+ ' ',
+ );
+ if (blocked)
+ h +=
+ '
Questo prodotto si può acquistare una sola volta e l\'hai già utilizzato.
';
+ return h;
+ }
+
+ function buyBar(v, button) {
+ return (
+ '
' +
+ esc(v ? v.label : "") +
+ ' ' +
+ (v ? euro(v.price) : "") +
+ "
" +
+ button +
+ "
"
+ );
+ }
+
+ function renderChoose(p, v) {
+ var h =
+ '
' +
+ esc(p.title) +
+ '';
+ if (p.mode === "fixed") {
+ var slots = p.slots || 1;
+ h +=
+ '
' +
+ (slots === 2
+ ? "Scegli le tue 2 classi"
+ : "Scegli la tua classe") +
+ ' ' +
+ (slots === 1
+ ? "Sarà il tuo posto fisso ogni settimana."
+ : "Saranno i tuoi due posti fissi ogni settimana.") +
+ "
";
+ h +=
+ '
' +
+ S.picked.length +
+ " di " +
+ slots +
+ (slots === 1 ? " scelta" : " scelte") +
+ " ";
+ p.services.forEach(function (sid) {
+ var s = svc(sid);
+ if (!s) return;
+ var seat = fixedSeats(sid, p, v),
+ sel = S.picked.indexOf(sid) !== -1;
+ h +=
+ '
" +
+ '' +
+ (sel ? ' ' : "") +
+ ' ' +
+ esc(s.day || s.name) +
+ " " +
+ '' +
+ esc(s.name + (s.teacher ? " · " + s.teacher : "")) +
+ " " +
+ (s.occ.length
+ ? 'Prossima lezione: ' +
+ esc(s.occ[0].label) +
+ " "
+ : "") +
+ " " +
+ '' +
+ esc(seat.text) +
+ " ";
+ });
+ h +=
+ '
Dopo l\'acquisto la scuola ti conferma la data della prima lezione e prenota per te tutte le altre, ogni settimana.
';
+ }
+ if (p.mode === "single") {
+ var list = singleOccurrences(p);
+ h +=
+ '
Scegli la lezione Lezioni delle prossime settimane. Il posto si prenota quando completi l\'acquisto.
';
+ if (!list.length)
+ h +=
+ '
Al momento non ci sono lezioni prenotabili per questo prodotto.
';
+ list.forEach(function (it) {
+ var b = seatsBadge(it.o),
+ sel = S.occ === it.o.id;
+ h +=
+ '
' +
+ '' +
+ esc(it.o.label) +
+ ' ' +
+ esc(
+ it.s.name +
+ (it.s.teacher ? " · " + it.s.teacher : ""),
+ ) +
+ " " +
+ '' +
+ esc(b.text) +
+ " ";
+ });
+ }
+ if (p.mode === "voucher") {
+ h +=
+ '
A chi lo regali? Il buono arriva via email con un codice da usare nel negozio della scuola.
' +
+ '
' +
+ '
Nome di chi lo riceve
' +
+ '
' +
+ '
Messaggio (facoltativo)
";
+ }
+ h += "
";
+ h += buyBar(
+ v,
+ '
Vai al pagamento ',
+ );
+ return h;
+ }
+
+ function view() {
+ var hash = decodeURIComponent(
+ (location.hash || "").replace(/^#\/?/, ""),
+ );
+ var parts = hash.split("/");
+ if (parts[0] === "orario") return { name: "calendar" };
+ var p = parts[0] ? bySlug(parts[0]) : null;
+ if (!p) return { name: "catalog" };
+ return {
+ name:
+ parts[1] === "scegli" &&
+ needsChoose(p) &&
+ p.blocked !== "used"
+ ? "choose"
+ : "product",
+ p: p,
+ };
+ }
+
+ function render() {
+ var vw = view(),
+ html;
+ if (vw.name === "catalog") {
+ S.from = "";
+ html = renderCatalog();
+ } else if (vw.name === "calendar") {
+ S.from = "calendar";
+ html = renderCalendar();
+ } else {
+ if (S.pid !== vw.p.id) {
+ S.pid = vw.p.id;
+ S.vid = vw.p.variants[0].id;
+ S.picked = [];
+ S.occ = null;
+ S.img = 0;
+ // Arrivo dall'orario: preseleziona la classe scelta
+ if (S.prefSvc && vw.p.services.indexOf(S.prefSvc) !== -1) {
+ if (
+ vw.p.mode === "fixed" &&
+ fixedSeats(S.prefSvc, vw.p, variant(vw.p)).ok
+ )
+ S.picked = [S.prefSvc];
+ if (vw.p.mode === "single" && svc(S.prefSvc)) {
+ var free = svc(S.prefSvc).occ.filter(function (o) {
+ return seatsBadge(o).ok;
+ })[0];
+ if (free) S.occ = free.id;
+ }
+ }
+ S.prefSvc = 0;
+ }
+ var v = variant(vw.p);
+ html =
+ vw.name === "choose"
+ ? renderChoose(vw.p, v)
+ : renderProduct(vw.p, v);
+ }
+ root.innerHTML = html;
+ var key = vw.name + (vw.p ? vw.p.id : "");
+ if (lastView !== key) {
+ lastView = key;
+ var top =
+ root.getBoundingClientRect().top + window.pageYOffset - 90;
+ window.scrollTo({ top: Math.max(0, top) });
+ }
+ }
+
+ function go(hash) {
+ if (location.hash === hash) render();
+ else location.hash = hash;
+ }
+
+ /* ---------------- azioni ---------------- */
+ var actions = {
+ filter: function (id) {
+ S.filter = id || "";
+ render();
+ },
+ open: function (id, el) {
+ var p = byId(+id);
+ if (!p) return;
+ S.prefSvc =
+ el && el.getAttribute("data-svc")
+ ? +el.getAttribute("data-svc")
+ : 0;
+ S.pid = null; // nuova apertura: riparte dalle scelte predefinite
+ go("#/" + encodeURIComponent(p.slug));
+ },
+ home: function () {
+ go(S.from === "calendar" ? "#/orario" : "#/");
+ },
+ tab: function (id) {
+ go(id === "calendar" ? "#/orario" : "#/");
+ },
+ cls: function (id) {
+ S.cls = S.cls === +id ? 0 : +id;
+ render();
+ var panel = document.getElementById("ys-calpanel");
+ if (panel && panel.scrollIntoView)
+ panel.scrollIntoView({
+ behavior: "smooth",
+ block: "nearest",
+ });
+ },
+ toProduct: function () {
+ var p = prod();
+ if (p) go("#/" + encodeURIComponent(p.slug));
+ },
+ img: function (i) {
+ S.img = +i;
+ render();
+ },
+ variant: function (id) {
+ S.vid = +id;
+ var p = prod(),
+ v = variant(p);
+ if (p && p.mode === "fixed")
+ S.picked = S.picked.filter(function (sid) {
+ return fixedSeats(sid, p, v).ok;
+ });
+ render();
+ },
+ next: function () {
+ var p = prod();
+ if (!p || p.blocked === "used") return;
+ if (needsChoose(p))
+ go("#/" + encodeURIComponent(p.slug) + "/scegli");
+ else actions.checkout();
+ },
+ pickClass: function (id) {
+ var p = prod(),
+ sid = +id,
+ slots = p.slots || 1,
+ arr = S.picked.slice(),
+ i = arr.indexOf(sid);
+ if (i !== -1) arr.splice(i, 1);
+ else if (slots === 1) arr = [sid];
+ else if (arr.length < slots) arr.push(sid);
+ else arr = [arr[1], sid];
+ S.picked = arr;
+ render();
+ },
+ pickOcc: function (id) {
+ S.occ = +id;
+ render();
+ },
+ checkout: function () {
+ var p = prod(),
+ v = variant(p);
+ if (!p || !v || !chooseValid(p) || p.blocked === "used") return;
+ var sel = {
+ product: p,
+ variant: v,
+ idvariant: v.id,
+ services: p.mode === "fixed" ? S.picked.slice() : [],
+ idschedule: p.mode === "single" ? S.occ : null,
+ recipient:
+ p.mode === "voucher"
+ ? {
+ name: S.rName.trim(),
+ email: S.rEmail.trim(),
+ message: S.rMsg.trim(),
+ }
+ : null,
+ };
+ if (typeof opts.onCheckout === "function") opts.onCheckout(sel);
+ },
+ };
+
+ root.addEventListener("click", function (ev) {
+ var el = ev.target.closest("[data-act]");
+ if (
+ !el ||
+ el.disabled ||
+ !root.contains(el) ||
+ el.tagName === "SELECT"
+ )
+ return;
+ var fn = actions[el.getAttribute("data-act")];
+ if (fn) fn(el.getAttribute("data-id"), el);
+ });
+ root.addEventListener("change", function (ev) {
+ if (ev.target.getAttribute("data-act") === "cat") {
+ S.cat = +ev.target.value;
+ S.cls = 0;
+ render();
+ }
+ });
+ root.addEventListener("input", function (ev) {
+ var f = ev.target.getAttribute("data-field");
+ if (!f) return;
+ S[f] = ev.target.value;
+ var btn = document.getElementById("ys-go");
+ if (btn) btn.disabled = !chooseValid(prod());
+ });
+ window.addEventListener("hashchange", render);
+ render();
+ }
+
+ window.YBShop = { mount: mount };
+
+ // Webapp: monta il negozio e invia le scelte a shop_checkout.php
+ document.addEventListener("DOMContentLoaded", function () {
+ var root = document.getElementById("shop");
+ if (!root || !window.YB_SHOP) return;
+ mount(root, window.YB_SHOP, {
+ onCheckout: function (sel) {
+ var f = document.getElementById("checkout-form");
+ document.getElementById("f-variant").value = sel.idvariant;
+ document.getElementById("f-services").value =
+ sel.services.join(",");
+ document.getElementById("f-schedule").value =
+ sel.idschedule || "";
+ document.getElementById("f-rname").value = sel.recipient
+ ? sel.recipient.name
+ : "";
+ document.getElementById("f-remail").value = sel.recipient
+ ? sel.recipient.email
+ : "";
+ document.getElementById("f-rmsg").value = sel.recipient
+ ? sel.recipient.message
+ : "";
+ f.submit();
+ },
+ });
+ });
+})();
diff --git a/public/class/SchoolScope.php b/public/class/SchoolScope.php
index beffb89..1a9d59e 100644
--- a/public/class/SchoolScope.php
+++ b/public/class/SchoolScope.php
@@ -32,6 +32,7 @@ class SchoolScope
'orderbook' => 'idorderbook',
'employee' => 'idemployee',
'dayoff' => 'iddayoff',
+ 'expiryparameter' => 'idexpiryparameter',
'certificateuserprofile' => 'idcertificateuserprofile',
'events' => 'id',
'event_bookings' => 'id',
diff --git a/public/class/SchoolSetup.php b/public/class/SchoolSetup.php
new file mode 100644
index 0000000..544ff7a
--- /dev/null
+++ b/public/class/SchoolSetup.php
@@ -0,0 +1,303 @@
+ ['La tua scuola', 'Come ti presenti ad allieve e insegnanti'],
+ 'classes' => ['Classi e calendario', 'Cosa si può prenotare e quando'],
+ 'sell' => ['Vendita', 'Cosa vendi e come ti pagano'],
+ 'test' => ['Prova finale', 'Fai un giro completo come farà un\'allieva'],
+ ];
+
+ /** Stato manuale salvato: ['done' => [key => data], 'skip' => [key => data], 'progress' => 'x/y'] */
+ public static function state(int $idschool): array
+ {
+ $raw = (string) SchoolSettings::get('setup_state', '', $idschool);
+ $s = json_decode($raw, true);
+ return is_array($s) ? $s + ['done' => [], 'skip' => []] : ['done' => [], 'skip' => []];
+ }
+
+ public static function saveState(int $idschool, array $state): void
+ {
+ SchoolSettings::save(['setup_state' => json_encode($state, JSON_UNESCAPED_UNICODE)], $idschool);
+ }
+
+ public static function completed(int $idschool): bool
+ {
+ return (string) SchoolSettings::get('setup_completed_at', '', $idschool) !== '';
+ }
+
+ /** Elenco dei passi con stato calcolato */
+ public static function steps(int $idschool, int $userId): array
+ {
+ $pdo = DBHandlerSelect::getInstance()->getConnection();
+ $one = function (string $sql, array $args) use ($pdo) {
+ $st = $pdo->prepare($sql);
+ $st->execute($args);
+ return $st->fetchColumn();
+ };
+ $state = self::state($idschool);
+
+ $st = $pdo->prepare("SELECT * FROM schools WHERE idschool = ?");
+ $st->execute([$idschool]);
+ $sc = $st->fetch(PDO::FETCH_ASSOC) ?: [];
+ $filled = fn($k) => trim((string) ($sc[$k] ?? '')) !== '';
+
+ $teachers = (int) $one("SELECT COUNT(*) FROM employee WHERE idschool = ? AND active = 'Y'", [$idschool]);
+ $classes = (int) $one("SELECT COUNT(*) FROM service WHERE idschool = ?", [$idschool]);
+ $future = (int) $one("SELECT COUNT(*) FROM serviceschedule WHERE idschool = ? AND dateschedule > NOW()", [$idschool]);
+ $dayoffs = (int) $one("SELECT COUNT(*) FROM dayoff WHERE idschool = ? AND dayoffdate >= CURDATE()", [$idschool]);
+ $rules = (int) $one("SELECT COUNT(*) FROM expiryparameter WHERE idschool = ?", [$idschool]);
+ $products = (int) $one(
+ "SELECT COUNT(*) FROM products p WHERE p.idschool = ? AND p.active = 'Y'
+ AND EXISTS (SELECT 1 FROM product_variants v WHERE v.idproduct = p.idproduct AND v.active = 'Y')",
+ [$idschool]
+ );
+ $bankOk = SchoolSettings::get('bank_enabled', 'N', $idschool) === 'Y' && trim((string) SchoolSettings::get('bank_iban', '', $idschool)) !== '';
+ $cardOk = SchoolSettings::get('stripe_charges_enabled', 'N', $idschool) === 'Y';
+ $shopOn = SchoolSettings::get('shop_enabled', 'N', $idschool) === 'Y';
+ $testOrder = (int) $one("SELECT COUNT(*) FROM orderbook WHERE idschool = ? AND iduser = ? AND idproduct IS NOT NULL", [$idschool, $userId]);
+ $testDone = (int) $one("SELECT COUNT(*) FROM orderbook WHERE idschool = ? AND iduser = ? AND idproduct IS NOT NULL AND payment_status IN ('paid', 'cancelled')", [$idschool, $userId]);
+ $students = (int) $one("SELECT COUNT(*) FROM school_user WHERE idschool = ? AND school_role = 'student' AND active = 'Y' AND user_id <> ?", [$idschool, $userId]);
+
+ $sub = null;
+ if (is_file(__DIR__ . '/PlatformBilling.php')) {
+ require_once __DIR__ . '/PlatformBilling.php';
+ try {
+ $row = PlatformBilling::school($idschool);
+ $sub = $row ? PlatformBilling::state($row) : null;
+ } catch (Throwable $ex) {
+ $sub = null;
+ }
+ }
+
+ $steps = [
+ // ---------------- La scuola
+ [
+ 'key' => 'profile',
+ 'phase' => 'school',
+ 'required' => true,
+ 'title' => 'Dati della scuola',
+ 'text' => 'Nome, email, indirizzo e città: compaiono nelle email alle allieve, nella pagina pubblica e nelle ricevute.',
+ 'url' => 'optionsschool.php?tab=profile',
+ 'cta' => 'Compila i dati',
+ 'done' => $filled('name') && $filled('email') && $filled('address') && $filled('city'),
+ 'detail' => $filled('email') ? '' : 'Manca l\'email della scuola: serve come mittente delle email.'
+ ],
+ [
+ 'key' => 'public',
+ 'phase' => 'school',
+ 'required' => false,
+ 'title' => 'Logo e presentazione',
+ 'text' => 'Il logo appare in alto nell\'app; slogan e descrizione nella pagina della scuola.',
+ 'url' => 'optionsschool.php?tab=public',
+ 'cta' => 'Carica il logo',
+ 'done' => $filled('logo') && ($filled('short_description') || $filled('description'))
+ ],
+ [
+ 'key' => 'look',
+ 'phase' => 'school',
+ 'required' => false,
+ 'manual' => true,
+ 'title' => 'Colori dell\'app',
+ 'text' => 'Scegli i colori della tua scuola. Se non li tocchi resta il tema YogiBook.',
+ 'url' => 'optionsschool.php?tab=theme',
+ 'cta' => 'Scegli i colori',
+ 'done' => false
+ ],
+ [
+ 'key' => 'teachers',
+ 'phase' => 'school',
+ 'required' => true,
+ 'title' => 'Insegnanti',
+ 'text' => 'Aggiungi chi insegna: servono per assegnare le classi. Puoi inserire anche te stessa.',
+ 'url' => 'insegnanti.php',
+ 'cta' => 'Aggiungi insegnanti',
+ 'done' => $teachers > 0,
+ 'detail' => $teachers ? $teachers . ' attivi' : ''
+ ],
+
+ // ---------------- Classi e calendario
+ [
+ 'key' => 'classes',
+ 'phase' => 'classes',
+ 'required' => true,
+ 'title' => 'Classi',
+ 'text' => 'Crea le categorie (es. Hatha, Aerial) e le classi settimanali con giorno, orario, durata e posti.',
+ 'url' => 'admin-services.php',
+ 'cta' => 'Crea le classi',
+ 'done' => $classes > 0,
+ 'detail' => $classes ? $classes . ' classi' : ''
+ ],
+ [
+ 'key' => 'calendar',
+ 'phase' => 'classes',
+ 'required' => true,
+ 'title' => 'Calendario delle lezioni',
+ 'text' => 'Genera le lezioni in calendario per le prossime settimane: sono quelle che le allieve prenotano.',
+ 'url' => 'admin-services.php',
+ 'cta' => 'Programma le lezioni',
+ 'done' => $future > 0,
+ 'detail' => $future ? $future . ' lezioni in calendario' : ''
+ ],
+ [
+ 'key' => 'dayoff',
+ 'phase' => 'classes',
+ 'required' => false,
+ 'title' => 'Giorni di chiusura',
+ 'text' => 'Festività e chiusure: in quei giorni non si prenota e i pacchetti non scalano lezioni.',
+ 'url' => 'dayoff.php',
+ 'cta' => 'Aggiungi chiusure',
+ 'done' => $dayoffs > 0
+ ],
+ [
+ 'key' => 'booking',
+ 'phase' => 'classes',
+ 'required' => true,
+ 'manual' => true,
+ 'title' => 'Regole di prenotazione',
+ 'text' => 'Lista d\'attesa, insegnante visibile, ore minime per annullare, avviso per le allieve. Controllale e segna il passo come fatto.',
+ 'url' => 'optionsschool.php?tab=booking',
+ 'cta' => 'Controlla le regole',
+ 'done' => false
+ ],
+ [
+ 'key' => 'packages',
+ 'phase' => 'classes',
+ 'required' => true,
+ 'title' => 'Scadenza e recuperi dei pacchetti',
+ 'text' => 'Per ogni numero di lezioni: dopo quante lezioni scade il pacchetto e quanti recuperi sono ammessi.',
+ 'url' => 'package_rules.php',
+ 'cta' => 'Imposta le regole',
+ 'done' => $rules > 0,
+ 'detail' => $rules ? $rules . ' regole' : ''
+ ],
+
+ // ---------------- Vendita
+ [
+ 'key' => 'products',
+ 'phase' => 'sell',
+ 'required' => true,
+ 'title' => 'Prodotti',
+ 'text' => 'Cosa vendi: pacchetti a classe fissa, carnet, lezione di prova, tessera, buoni regalo. Con foto, prezzi e opzioni.',
+ 'url' => 'products.php',
+ 'cta' => 'Crea i prodotti',
+ 'done' => $products > 0,
+ 'detail' => $products ? $products . ' in vendita' : ''
+ ],
+ [
+ 'key' => 'payments',
+ 'phase' => 'sell',
+ 'required' => true,
+ 'title' => 'Pagamenti',
+ 'text' => 'Attiva il bonifico (intestatario e IBAN) e, quando disponibile, la carta. Imposta anche le condizioni di acquisto.',
+ 'url' => 'optionsschool.php?tab=shop',
+ 'cta' => 'Configura i pagamenti',
+ 'done' => $bankOk || $cardOk,
+ 'detail' => trim(($bankOk ? 'Bonifico' : '') . ($bankOk && $cardOk ? ' · ' : '') . ($cardOk ? 'Carta' : ''))
+ ],
+ [
+ 'key' => 'shop',
+ 'phase' => 'sell',
+ 'required' => true,
+ 'title' => 'Apri il negozio',
+ 'text' => 'Accendi "Negozio attivo": da quel momento le allieve possono acquistare dall\'app.',
+ 'url' => 'optionsschool.php?tab=shop',
+ 'cta' => 'Attiva il negozio',
+ 'done' => $shopOn
+ ],
+ [
+ 'key' => 'subscription',
+ 'phase' => 'sell',
+ 'required' => false,
+ 'title' => 'Abbonamento YogiBook',
+ 'text' => 'Controlla il tuo piano e la scadenza della prova.',
+ 'url' => 'subscription.php',
+ 'cta' => 'Vedi l\'abbonamento',
+ 'done' => $sub !== null && in_array($sub['code'], ['active', 'expiring', 'exempt'], true),
+ 'detail' => $sub ? $sub['label'] . ($sub['ends_on'] ? ' fino al ' . date('d/m/Y', strtotime($sub['ends_on'])) : '') : ''
+ ],
+
+ // ---------------- Prova finale
+ [
+ 'key' => 'test_buy',
+ 'phase' => 'test',
+ 'required' => true,
+ 'title' => 'Acquista come un\'allieva',
+ 'text' => 'Dal selettore Vista in alto nel menu passa a "Allievo", apri il Negozio e compra un prodotto con bonifico. Controlla riepilogo, causale e pagina di conferma.',
+ 'url' => 'shop.php',
+ 'cta' => 'Apri il negozio',
+ 'done' => $testOrder > 0
+ ],
+ [
+ 'key' => 'test_confirm',
+ 'phase' => 'test',
+ 'required' => true,
+ 'title' => 'Gestisci l\'ordine di prova',
+ 'text' => 'Torna alla vista "Scuola", apri Ordini: segna il pagamento come ricevuto (e propagalo se è a classe fissa) oppure annullalo.',
+ 'url' => 'school_orders.php',
+ 'cta' => 'Apri gli ordini',
+ 'done' => $testDone > 0
+ ],
+ [
+ 'key' => 'test_book',
+ 'phase' => 'test',
+ 'required' => false,
+ 'manual' => true,
+ 'title' => 'Prenota e annulla una lezione',
+ 'text' => 'Sempre come allieva, prenota una lezione dal pannello Prenotazioni e poi annullala: verifichi posti e regole di cancellazione.',
+ 'url' => 'userpanel.php',
+ 'cta' => 'Apri le prenotazioni',
+ 'done' => false
+ ],
+ [
+ 'key' => 'invite',
+ 'phase' => 'test',
+ 'required' => false,
+ 'manual' => true,
+ 'title' => 'Invita le tue allieve',
+ 'text' => 'Comunica alle allieve l\'indirizzo di YogiBook per registrarsi e scegli tu quando aprire il negozio a tutte.',
+ 'url' => '',
+ 'cta' => '',
+ 'done' => $students > 0,
+ 'detail' => $students ? $students . ' allieve iscritte' : ''
+ ],
+ ];
+
+ foreach ($steps as &$s) {
+ $s += ['manual' => false, 'detail' => ''];
+ $s['auto'] = $s['done'];
+ $s['done'] = $s['done'] || isset($state['done'][$s['key']]);
+ $s['skipped'] = !$s['done'] && !$s['required'] && isset($state['skip'][$s['key']]);
+ }
+ unset($s);
+ return $steps;
+ }
+
+ /** [fatti (o saltati), totale, obbligatori mancanti] */
+ public static function progress(array $steps): array
+ {
+ $done = 0;
+ $missing = 0;
+ foreach ($steps as $s) {
+ if ($s['done'] || $s['skipped']) {
+ $done++;
+ } elseif ($s['required']) {
+ $missing++;
+ }
+ }
+ return [$done, count($steps), $missing];
+ }
+}
diff --git a/public/class/ShopService.php b/public/class/ShopService.php
new file mode 100644
index 0000000..e26b8b3
--- /dev/null
+++ b/public/class/ShopService.php
@@ -0,0 +1,976 @@
+ 'gennaio', 'febbraio', 'marzo', 'aprile', 'maggio', 'giugno', 'luglio', 'agosto', 'settembre', 'ottobre', 'novembre', 'dicembre'];
+
+ /**
+ * Catalogo completo, pronto per json_encode (lo stesso formato servirà all'API WordPress).
+ *
+ * @param int $idschool
+ * @param int|null $userId allieva loggata (per prova già usata, tessera, pacchetti attivi)
+ * @param bool $onlineOnly true = solo prodotti "acquistabili online"
+ */
+ public static function catalog(int $idschool, ?int $userId = null, bool $onlineOnly = true): array
+ {
+ $pdo = self::pdo();
+
+ // ---- Prodotti e opzioni
+ $st = $pdo->prepare(
+ "SELECT * FROM products WHERE idschool = ? AND active = 'Y'" . ($onlineOnly ? " AND visible_online = 'Y'" : "") . "
+ ORDER BY FIELD(kind, 'lessons', 'membership', 'voucher'), sort_order, title"
+ );
+ $st->execute([$idschool]);
+ $rows = $st->fetchAll(PDO::FETCH_ASSOC);
+
+ $variantsBy = [];
+ $st = $pdo->prepare("SELECT * FROM product_variants WHERE idschool = ? AND active = 'Y' ORDER BY sort_order, idvariant");
+ $st->execute([$idschool]);
+ foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $v) {
+ $variantsBy[(int) $v['idproduct']][] = $v;
+ }
+
+ $linksBy = [];
+ $st = $pdo->prepare("SELECT idproduct, idservice, idservicecategory FROM product_services WHERE idschool = ?");
+ $st->execute([$idschool]);
+ foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $l) {
+ $linksBy[(int) $l['idproduct']][] = $l;
+ }
+
+ $galleryBy = [];
+ $st = $pdo->prepare("SELECT idproduct, filename FROM product_images WHERE idschool = ? ORDER BY sort_order, idimage");
+ $st->execute([$idschool]);
+ foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $g) {
+ $galleryBy[(int) $g['idproduct']][] = $g['filename'];
+ }
+
+ $variantLabel = [];
+ $st = $pdo->prepare("SELECT v.idvariant, v.label, p.title FROM product_variants v JOIN products p ON p.idproduct = v.idproduct WHERE v.idschool = ?");
+ $st->execute([$idschool]);
+ foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) {
+ $variantLabel[(int) $r['idvariant']] = $r['title'] . ' · ' . $r['label'];
+ }
+
+ // ---- Classi della scuola
+ $showTeacher = SchoolSettings::get('show_teacher', 'N', $idschool) === 'Y';
+ $st = $pdo->prepare(
+ "SELECT s.idservice, s.servicename, s.day, s.time, s.category, s.maxcapacity, s.colorclass, s.classduration,
+ TRIM(CONCAT(COALESCE(em.nameemployee, ''), ' ', COALESCE(em.surnameemployee, ''))) AS teacher
+ FROM service s
+ LEFT JOIN employee em ON em.idemployee = s.idteacher AND em.idschool = s.idschool
+ WHERE s.idschool = ?"
+ );
+ $st->execute([$idschool]);
+ $servicesAll = [];
+ $byCategory = [];
+ foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $s) {
+ $servicesAll[(int) $s['idservice']] = $s;
+ $byCategory[(int) $s['category']][] = (int) $s['idservice'];
+ }
+
+ $st = $pdo->prepare("SELECT idservicecategory, namecategory FROM servicecategory WHERE idschool = ? ORDER BY namecategory");
+ $st->execute([$idschool]);
+ $catNames = [];
+ foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $c) {
+ $catNames[(int) $c['idservicecategory']] = $c['namecategory'];
+ }
+
+ // ---- Situazione dell'allieva
+ $user = self::userContext($idschool, $userId);
+
+ // ---- Costruzione prodotti
+ $products = [];
+ $usedSvcs = [];
+ $usedCats = [];
+ foreach ($rows as $p) {
+ $pid = (int) $p['idproduct'];
+ if (empty($variantsBy[$pid])) {
+ continue; // nessuna opzione attiva = non acquistabile
+ }
+ $svc = [];
+ foreach ($linksBy[$pid] ?? [] as $l) {
+ if (!empty($l['idservice']) && isset($servicesAll[(int) $l['idservice']])) {
+ $svc[] = (int) $l['idservice'];
+ }
+ if (!empty($l['idservicecategory'])) {
+ $svc = array_merge($svc, $byCategory[(int) $l['idservicecategory']] ?? []);
+ }
+ }
+ $svc = array_values(array_unique($svc));
+ if ($p['kind'] === 'lessons' && !$svc) {
+ continue; // pacchetto senza classi: non vendibile
+ }
+
+ $cats = [];
+ foreach ($svc as $sid) {
+ $cid = (int) $servicesAll[$sid]['category'];
+ if (isset($catNames[$cid])) {
+ $cats[$cid] = true;
+ }
+ }
+ $cats = array_keys($cats);
+ if ($p['kind'] === 'lessons') {
+ $usedSvcs = array_merge($usedSvcs, $svc);
+ $usedCats = array_merge($usedCats, $cats);
+ }
+
+ $variants = [];
+ foreach ($variantsBy[$pid] as $v) {
+ $variants[] = [
+ 'id' => (int) $v['idvariant'],
+ 'label' => $v['label'],
+ 'lessons' => $v['lessons'] !== null ? (int) $v['lessons'] : null,
+ 'price' => (float) $v['price'],
+ 'compare' => $v['compare_price'] !== null ? (float) $v['compare_price'] : null,
+ 'note' => $v['price_note'],
+ 'vmode' => $v['validity_mode'],
+ 'vvalue' => $v['validity_value'] !== null ? (int) $v['validity_value'] : null,
+ 'vunit' => $v['validity_unit'],
+ 'vstart' => $v['validity_start'],
+ 'target' => $v['target_idvariant'] !== null ? (int) $v['target_idvariant'] : null,
+ 'targetLabel' => $v['target_idvariant'] !== null ? ($variantLabel[(int) $v['target_idvariant']] ?? '') : '',
+ ];
+ }
+
+ $mode = $p['kind'] === 'lessons' ? $p['booking_mode'] : $p['kind'];
+ $products[] = [
+ 'id' => $pid,
+ 'slug' => $p['slug'],
+ 'kind' => $p['kind'],
+ 'mode' => $mode, // fixed | free | single | membership | voucher
+ 'group' => self::group($p), // per i filtri del catalogo
+ 'slots' => (int) $p['fixed_slots'],
+ 'weekly' => $p['weekly_limit'] !== null ? (int) $p['weekly_limit'] : null,
+ 'maxres' => $p['max_reschedule'] !== null ? (int) $p['max_reschedule'] : null,
+ 'once' => $p['once_per_student'] === 'Y',
+ 'title' => $p['title'],
+ 'subtitle' => (string) $p['subtitle'],
+ 'badge' => (string) $p['badge'],
+ 'cover' => (string) $p['cover_image'],
+ 'gallery' => $galleryBy[$pid] ?? [],
+ 'description' => (string) $p['description'], // HTML già ripulito al salvataggio
+ 'suitable' => (string) $p['suitable_for'],
+ 'requirements' => (string) $p['requirements'],
+ 'pros' => self::lines($p['pros']),
+ 'cons' => self::lines($p['cons']),
+ 'services' => $svc,
+ 'cats' => $cats,
+ 'variants' => $variants,
+ 'blocked' => ($p['once_per_student'] === 'Y' && in_array($pid, $user['usedProducts'], true)) ? 'used' : null,
+ ];
+ }
+
+ // ---- Calendario e posti delle classi usate dai pacchetti
+ $services = self::servicesWithOccurrences($idschool, array_values(array_unique($usedSvcs)), $servicesAll, $showTeacher, $userId);
+
+ // ---- Discipline (categorie) presenti nel catalogo
+ $categories = [];
+ foreach (array_unique($usedCats) as $cid) {
+ $categories[] = ['id' => $cid, 'name' => $catNames[$cid]];
+ }
+ usort($categories, fn($a, $b) => strcasecmp($a['name'], $b['name']));
+
+ // ---- Date utili (tessere)
+ $seasonStart = max(1, min(12, (int) SchoolSettings::get('membership_season_start', 9, $idschool)));
+ $today = new DateTimeImmutable('today');
+ $y = (int) $today->format('Y');
+ $endYear = ((int) $today->format('n') >= $seasonStart) ? $y + 1 : $y;
+ $seasonEnd = (new DateTimeImmutable(sprintf('%04d-%02d-01', $endYear, $seasonStart)))->modify('-1 day');
+
+ // Prodotto tessera da proporre se serve
+ $user['membershipProduct'] = null;
+ foreach ($products as $p) {
+ if ($p['kind'] === 'membership') {
+ $user['membershipProduct'] = $p['id'];
+ break;
+ }
+ }
+ unset($user['usedProducts']);
+
+ return [
+ 'today' => $today->format('Y-m-d'),
+ 'yearEnd' => $y . '-12-31',
+ 'seasonEnd' => $seasonEnd->format('Y-m-d'),
+ 'singleDays' => self::SINGLE_DAYS,
+ 'products' => $products,
+ 'services' => (object) $services,
+ 'categories' => $categories,
+ 'user' => $user,
+ ];
+ }
+
+ /** Gruppo del catalogo: single | fixed | free | membership | voucher */
+ public static function group(array $p): string
+ {
+ return $p['kind'] === 'lessons' ? (string) $p['booking_mode'] : (string) $p['kind'];
+ }
+
+ /**
+ * Situazione dell'allieva nella scuola: prodotti "una volta" già acquistati,
+ * tessera valida, pacchetti attivi.
+ */
+ public static function userContext(int $idschool, ?int $userId): array
+ {
+ $required = SchoolSettings::get('membership_required', 'N', $idschool) === 'Y';
+ $ctx = [
+ 'logged' => (bool) $userId,
+ 'membershipRequired' => $required,
+ 'hasMembership' => false,
+ 'membershipUntil' => null,
+ 'packages' => [],
+ 'usedProducts' => [],
+ ];
+ if (!$userId) {
+ return $ctx;
+ }
+ $pdo = self::pdo();
+
+ $st = $pdo->prepare(
+ "SELECT DISTINCT idproduct FROM orderbook
+ WHERE iduser = ? AND idschool = ? AND idproduct IS NOT NULL
+ AND (payment_status IS NULL OR payment_status NOT IN ('cancelled', 'refunded'))"
+ );
+ $st->execute([$userId, $idschool]);
+ $ctx['usedProducts'] = array_map('intval', $st->fetchAll(PDO::FETCH_COLUMN));
+
+ $st = $pdo->prepare(
+ "SELECT MAX(valid_to) FROM memberships
+ WHERE iduser = ? AND idschool = ? AND status = 'active' AND valid_from <= CURDATE() AND valid_to >= CURDATE()"
+ );
+ $st->execute([$userId, $idschool]);
+ $until = $st->fetchColumn();
+ $ctx['hasMembership'] = (bool) $until;
+ $ctx['membershipUntil'] = $until ?: null;
+
+ // Pacchetti attivi: non scaduti e con lezioni rimaste
+ $st = $pdo->prepare(
+ "SELECT ob.idorderbook, ob.product_name, ob.nticket, ob.quantityclass, ob.expireon, ob.payment_status,
+ s.servicename,
+ (SELECT COUNT(*) FROM bookingclass b
+ WHERE b.idorder = ob.idorderbook AND b.idschool = ob.idschool
+ AND b.status <> 'cancelled' AND b.is_gift = 'N') AS used
+ FROM orderbook ob
+ LEFT JOIN service s ON s.idservice = ob.idservice
+ WHERE ob.iduser = ? AND ob.idschool = ?
+ AND (ob.expireon IS NULL OR ob.expireon >= CURDATE())
+ AND (ob.payment_status IS NULL OR ob.payment_status NOT IN ('cancelled', 'refunded'))
+ ORDER BY ob.expireon IS NULL, ob.expireon"
+ );
+ $st->execute([$userId, $idschool]);
+ foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $o) {
+ $tot = (int) $o['nticket'] > 0 ? (int) $o['nticket'] : (int) $o['quantityclass'];
+ $left = $tot > 0 ? max(0, $tot - (int) $o['used']) : null;
+ if ($left === 0) {
+ continue;
+ }
+ $ctx['packages'][] = [
+ 'title' => (string) ($o['product_name'] ?: $o['servicename'] ?: 'Pacchetto'),
+ 'left' => $left,
+ 'expire' => $o['expireon'],
+ 'pending' => $o['payment_status'] === 'pending',
+ ];
+ }
+ return $ctx;
+ }
+
+ /**
+ * Lezioni future delle classi indicate, con posti liberi.
+ * @return array idservice => [name, day, teacher, cap, cat, occ => [[id, date, time, label, free, mine]]]
+ */
+ public static function servicesWithOccurrences(int $idschool, array $ids, array $servicesAll, bool $showTeacher, ?int $userId = null): array
+ {
+ $out = [];
+ foreach ($ids as $sid) {
+ if (!isset($servicesAll[$sid])) {
+ continue;
+ }
+ $s = $servicesAll[$sid];
+ $out[$sid] = [
+ 'name' => $s['servicename'],
+ 'day' => trim($s['day'] . ' ' . $s['time']),
+ 'teacher' => $showTeacher ? trim((string) $s['teacher']) : '',
+ 'cap' => ($s['maxcapacity'] !== null && (int) $s['maxcapacity'] > 0) ? (int) $s['maxcapacity'] : null,
+ 'cat' => (int) $s['category'],
+ 'color' => preg_match('/^#[0-9a-fA-F]{6}$/', (string) ($s['colorclass'] ?? '')) ? strtolower($s['colorclass']) : null,
+ 'minutes' => (int) round((float) ($s['classduration'] ?? 0) * ((float) ($s['classduration'] ?? 0) <= 5 ? 60 : 1)),
+ 'occ' => [],
+ ];
+ }
+ if (!$out) {
+ return $out;
+ }
+
+ $ph = implode(',', array_fill(0, count($out), '?'));
+ $st = self::pdo()->prepare(
+ "SELECT ss.idserviceschedule, ss.idservice, ss.dateschedule,
+ (SELECT COUNT(*) FROM bookingclass b
+ WHERE b.idserviceschedule = ss.idserviceschedule AND b.idschool = ss.idschool
+ AND b.status IN ('booked', 'pending')) AS taken
+ " . ($userId ? ", (SELECT COUNT(*) FROM bookingclass b2
+ WHERE b2.idserviceschedule = ss.idserviceschedule AND b2.idschool = ss.idschool
+ AND b2.iduser = ? AND b2.status <> 'cancelled') AS mine" : ", 0 AS mine") . "
+ FROM serviceschedule ss
+ WHERE ss.idschool = ?
+ AND ss.idservice IN ($ph)
+ AND ss.dateschedule > NOW()
+ AND ss.dateschedule < CURDATE() + INTERVAL " . (int) self::LOOKAHEAD_DAYS . " DAY
+ AND NOT EXISTS (SELECT 1 FROM dayoff d WHERE d.idschool = ss.idschool AND d.dayoffdate = DATE(ss.dateschedule))
+ ORDER BY ss.dateschedule"
+ );
+ $args = $userId ? [$userId, $idschool] : [$idschool];
+ $st->execute(array_merge($args, array_keys($out)));
+
+ foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $o) {
+ $sid = (int) $o['idservice'];
+ if (count($out[$sid]['occ']) >= self::MAX_OCCURRENCES) {
+ continue;
+ }
+ $ts = strtotime($o['dateschedule']);
+ $cap = $out[$sid]['cap'];
+ $out[$sid]['occ'][] = [
+ 'id' => (int) $o['idserviceschedule'],
+ 'date' => date('Y-m-d', $ts),
+ 'time' => date('H:i', $ts),
+ 'label' => self::dateLabel($ts),
+ 'free' => $cap === null ? null : max(0, $cap - (int) $o['taken']),
+ 'mine' => (int) $o['mine'] > 0,
+ ];
+ }
+ return $out;
+ }
+
+ /** "Martedì 13 ottobre · 18:00" */
+ public static function dateLabel(int $ts): string
+ {
+ return ucfirst(self::GIORNI[(int) date('w', $ts)]) . ' ' . date('j', $ts) . ' ' . self::MESI[(int) date('n', $ts)] . ' · ' . date('H:i', $ts);
+ }
+
+ public static function lines(?string $t): array
+ {
+ return array_values(array_filter(array_map('trim', preg_split('/\r\n|\r|\n/', (string) $t))));
+ }
+
+ /* ==================================================================
+ * CHECKOUT
+ * ================================================================ */
+
+ /** Metodi di pagamento attivi per la scuola */
+ public static function paymentMethods(int $idschool): array
+ {
+ $bank = SchoolSettings::get('bank_enabled', 'N', $idschool) === 'Y'
+ && trim((string) SchoolSettings::get('bank_holder', '', $idschool)) !== ''
+ && trim((string) SchoolSettings::get('bank_iban', '', $idschool)) !== '';
+ // La carta arriverà con il collegamento Stripe Connect della scuola
+ $card = SchoolSettings::get('stripe_charges_enabled', 'N', $idschool) === 'Y'
+ && trim((string) SchoolSettings::get('stripe_account_id', '', $idschool)) !== '';
+ return ['bank' => $bank, 'card' => $card];
+ }
+
+ /**
+ * Ricontrolla lato server la scelta arrivata dal negozio.
+ * $in: idvariant, services (array di idservice), idschedule, recipient_name, recipient_email, recipient_message
+ * @return array product, variant, services, schedule, recipient
+ * @throws RuntimeException con messaggio per l'allieva
+ */
+ public static function selection(int $idschool, int $userId, array $in): array
+ {
+ $pdo = self::pdo();
+ $st = $pdo->prepare(
+ "SELECT v.*, p.kind, p.title, p.booking_mode, p.fixed_slots, p.weekly_limit, p.max_reschedule,
+ p.once_per_student, p.active AS p_active, p.visible_online, p.slug
+ FROM product_variants v
+ JOIN products p ON p.idproduct = v.idproduct AND p.idschool = v.idschool
+ WHERE v.idvariant = ? AND v.idschool = ?"
+ );
+ $st->execute([(int) ($in['idvariant'] ?? 0), $idschool]);
+ $row = $st->fetch(PDO::FETCH_ASSOC);
+ if (!$row || $row['active'] !== 'Y' || $row['p_active'] !== 'Y' || $row['visible_online'] !== 'Y') {
+ throw new RuntimeException('Questo prodotto non è più disponibile.');
+ }
+ $pid = (int) $row['idproduct'];
+ $mode = $row['kind'] === 'lessons' ? $row['booking_mode'] : $row['kind'];
+
+ if ($row['once_per_student'] === 'Y') {
+ $st = $pdo->prepare(
+ "SELECT 1 FROM orderbook WHERE iduser = ? AND idschool = ? AND idproduct = ?
+ AND (payment_status IS NULL OR payment_status NOT IN ('cancelled', 'refunded')) LIMIT 1"
+ );
+ $st->execute([$userId, $idschool, $pid]);
+ if ($st->fetchColumn()) {
+ throw new RuntimeException('Hai già utilizzato questo prodotto: si può acquistare una sola volta.');
+ }
+ }
+
+ $allowed = self::productServiceIds($idschool, $pid);
+ $sel = [
+ 'product' => $row,
+ 'mode' => $mode,
+ 'variant' => $row,
+ 'services' => [],
+ 'schedule' => null,
+ 'recipient' => null,
+ ];
+
+ if ($mode === 'fixed') {
+ $slots = (int) $row['fixed_slots'] === 2 ? 2 : 1;
+ $ids = array_values(array_unique(array_filter(array_map('intval', (array) ($in['services'] ?? [])))));
+ if (count($ids) !== $slots) {
+ throw new RuntimeException($slots === 2 ? 'Scegli le tue due classi.' : 'Scegli la tua classe.');
+ }
+ $weeks = $row['lessons'] ? (int) ceil((int) $row['lessons'] / $slots) : 8;
+ foreach ($ids as $sid) {
+ if (!in_array($sid, $allowed, true)) {
+ throw new RuntimeException('Una delle classi scelte non è inclusa in questo prodotto.');
+ }
+ $free = self::fixedFree($idschool, $sid, $weeks);
+ if ($free === -1) {
+ throw new RuntimeException('Una delle classi scelte non ha lezioni in calendario.');
+ }
+ if ($free === 0) {
+ throw new RuntimeException('Una delle classi scelte è al completo. Torna indietro e scegline un\'altra.');
+ }
+ }
+ $sel['services'] = $ids;
+ }
+
+ if ($mode === 'single') {
+ $st = $pdo->prepare(
+ "SELECT ss.*, s.servicename, s.maxcapacity FROM serviceschedule ss
+ JOIN service s ON s.idservice = ss.idservice
+ WHERE ss.idserviceschedule = ? AND ss.idschool = ? AND ss.dateschedule > NOW()"
+ );
+ $st->execute([(int) ($in['idschedule'] ?? 0), $idschool]);
+ $sch = $st->fetch(PDO::FETCH_ASSOC);
+ if (!$sch || !in_array((int) $sch['idservice'], $allowed, true)) {
+ throw new RuntimeException('La lezione scelta non è più disponibile.');
+ }
+ $st = $pdo->prepare("SELECT 1 FROM bookingclass WHERE idserviceschedule = ? AND iduser = ? AND idschool = ? AND status <> 'cancelled' LIMIT 1");
+ $st->execute([(int) $sch['idserviceschedule'], $userId, $idschool]);
+ if ($st->fetchColumn()) {
+ throw new RuntimeException('Sei già iscritta a questa lezione.');
+ }
+ if ((int) $sch['maxcapacity'] > 0 && self::occupied($idschool, (int) $sch['idserviceschedule']) >= (int) $sch['maxcapacity']) {
+ throw new RuntimeException('La lezione scelta è al completo. Torna indietro e scegli un\'altra data.');
+ }
+ $sel['schedule'] = $sch;
+ }
+
+ if ($mode === 'voucher') {
+ $name = trim((string) ($in['recipient_name'] ?? ''));
+ $email = trim((string) ($in['recipient_email'] ?? ''));
+ if ($name === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
+ throw new RuntimeException('Indica nome ed email di chi riceve il buono.');
+ }
+ if (empty($row['target_idvariant'])) {
+ throw new RuntimeException('Questo buono non è configurato correttamente. Contatta la scuola.');
+ }
+ $sel['recipient'] = [
+ 'name' => mb_substr($name, 0, 191),
+ 'email' => mb_substr($email, 0, 191),
+ 'message' => mb_substr(trim((string) ($in['recipient_message'] ?? '')), 0, 500),
+ ];
+ }
+ return $sel;
+ }
+
+ /** idservice validi per un prodotto (classi singole + categorie intere) */
+ public static function productServiceIds(int $idschool, int $idproduct): array
+ {
+ $st = self::pdo()->prepare(
+ "SELECT DISTINCT s.idservice
+ FROM product_services ps
+ JOIN service s ON s.idschool = ps.idschool
+ AND (s.idservice = ps.idservice OR (ps.idservicecategory IS NOT NULL AND s.category = ps.idservicecategory))
+ WHERE ps.idproduct = ? AND ps.idschool = ?"
+ );
+ $st->execute([$idproduct, $idschool]);
+ return array_map('intval', $st->fetchAll(PDO::FETCH_COLUMN));
+ }
+
+ public static function occupied(int $idschool, int $idschedule): int
+ {
+ $st = self::pdo()->prepare(
+ "SELECT COUNT(*) FROM bookingclass WHERE idserviceschedule = ? AND idschool = ? AND status IN ('booked', 'pending')"
+ );
+ $st->execute([$idschedule, $idschool]);
+ return (int) $st->fetchColumn();
+ }
+
+ /**
+ * Posti liberi minimi sulle prossime $weeks lezioni di una classe.
+ * null = senza limite, -1 = nessuna lezione in calendario
+ */
+ public static function fixedFree(int $idschool, int $idservice, int $weeks): ?int
+ {
+ $pdo = self::pdo();
+ $st = $pdo->prepare("SELECT maxcapacity FROM service WHERE idservice = ? AND idschool = ?");
+ $st->execute([$idservice, $idschool]);
+ $cap = (int) $st->fetchColumn();
+
+ $st = $pdo->prepare(
+ "SELECT (SELECT COUNT(*) FROM bookingclass b WHERE b.idserviceschedule = ss.idserviceschedule
+ AND b.idschool = ss.idschool AND b.status IN ('booked', 'pending')) AS taken
+ FROM serviceschedule ss
+ WHERE ss.idservice = ? AND ss.idschool = ? AND ss.dateschedule > NOW()
+ AND NOT EXISTS (SELECT 1 FROM dayoff d WHERE d.idschool = ss.idschool AND d.dayoffdate = DATE(ss.dateschedule))
+ ORDER BY ss.dateschedule
+ LIMIT " . max(1, $weeks)
+ );
+ $st->execute([$idservice, $idschool]);
+ $taken = array_map('intval', $st->fetchAll(PDO::FETCH_COLUMN));
+ if (!$taken) {
+ return -1;
+ }
+ return $cap > 0 ? max(0, $cap - max($taken)) : null;
+ }
+
+ /**
+ * Verifica un codice sconto per l'opzione scelta (l'utente è sempre quello della sessione).
+ * @return array idcoupon, code, discount
+ * @throws RuntimeException
+ */
+ public static function checkCoupon(int $idschool, int $userId, string $code, array $variant): array
+ {
+ $code = strtoupper(trim($code));
+ $price = (float) $variant['price'];
+ $pdo = self::pdo();
+
+ $st = $pdo->prepare("SELECT * FROM coupons WHERE idschool = ? AND code = ? AND active = 'Y' LIMIT 1");
+ $st->execute([$idschool, $code]);
+ $c = $st->fetch(PDO::FETCH_ASSOC);
+ $today = date('Y-m-d');
+ if (!$c || ($c['valid_from'] && $today < $c['valid_from']) || ($c['valid_to'] && $today > $c['valid_to'])) {
+ throw new RuntimeException('Codice non valido o scaduto.');
+ }
+
+ $st = $pdo->prepare("SELECT idproduct, idvariant FROM coupon_products WHERE idcoupon = ? AND idschool = ?");
+ $st->execute([(int) $c['idcoupon'], $idschool]);
+ $limits = $st->fetchAll(PDO::FETCH_ASSOC);
+ if ($limits) {
+ $ok = false;
+ foreach ($limits as $l) {
+ if (
+ (int) $l['idvariant'] === (int) $variant['idvariant']
+ || ((int) $l['idproduct'] === (int) $variant['idproduct'] && empty($l['idvariant']))
+ ) {
+ $ok = true;
+ }
+ }
+ if (!$ok) {
+ throw new RuntimeException('Questo codice non vale su questo prodotto.');
+ }
+ }
+ if ($c['min_amount'] !== null && $price < (float) $c['min_amount']) {
+ throw new RuntimeException('Codice valido per acquisti da ' . number_format((float) $c['min_amount'], 2, ',', '.') . ' €.');
+ }
+ if ($c['max_uses'] !== null) {
+ $st = $pdo->prepare("SELECT COUNT(*) FROM coupon_redemptions WHERE idcoupon = ? AND idschool = ?");
+ $st->execute([(int) $c['idcoupon'], $idschool]);
+ if ((int) $st->fetchColumn() >= (int) $c['max_uses']) {
+ throw new RuntimeException('Codice esaurito.');
+ }
+ }
+ if ($c['max_uses_per_user'] !== null) {
+ $st = $pdo->prepare("SELECT COUNT(*) FROM coupon_redemptions WHERE idcoupon = ? AND idschool = ? AND iduser = ?");
+ $st->execute([(int) $c['idcoupon'], $idschool, $userId]);
+ if ((int) $st->fetchColumn() >= (int) $c['max_uses_per_user']) {
+ throw new RuntimeException('Hai già usato questo codice.');
+ }
+ }
+ if ($c['new_students_only'] === 'Y') {
+ $st = $pdo->prepare("SELECT COUNT(*) FROM orderbook WHERE iduser = ? AND idschool = ?");
+ $st->execute([$userId, $idschool]);
+ if ((int) $st->fetchColumn() > 0) {
+ throw new RuntimeException('Codice riservato alle nuove allieve.');
+ }
+ }
+ $value = (float) $c['discount_value'];
+ $discount = $c['discount_type'] === 'fixed' ? min($value, $price) : round($price * min($value, 100) / 100, 2);
+ return ['idcoupon' => (int) $c['idcoupon'], 'code' => $c['code'], 'discount' => $discount];
+ }
+
+ /** Scadenza della tessera per un'opzione (Y-m-d o null) */
+ public static function membershipEnd(int $idschool, array $variant, ?string $from = null): ?string
+ {
+ $from = $from ?? date('Y-m-d');
+ if ($variant['validity_mode'] === 'calendar_year') {
+ return substr($from, 0, 4) . '-12-31';
+ }
+ if ($variant['validity_mode'] === 'season') {
+ $start = max(1, min(12, (int) SchoolSettings::get('membership_season_start', 9, $idschool)));
+ $y = (int) substr($from, 0, 4);
+ $endYear = ((int) substr($from, 5, 2) >= $start) ? $y + 1 : $y;
+ return (new DateTimeImmutable(sprintf('%04d-%02d-01', $endYear, $start)))->modify('-1 day')->format('Y-m-d');
+ }
+ if (!empty($variant['validity_value'])) {
+ return self::addPeriod($from, (int) $variant['validity_value'], (string) $variant['validity_unit'])->modify('-1 day')->format('Y-m-d');
+ }
+ return null;
+ }
+
+ public static function addPeriod(string $from, int $n, string $unit): DateTimeImmutable
+ {
+ $d = new DateTimeImmutable($from);
+ if ($unit === 'days') {
+ return $d->modify('+' . $n . ' days');
+ }
+ if ($unit === 'weeks') {
+ return $d->modify('+' . ($n * 7) . ' days');
+ }
+ // mesi senza sforare (31 gennaio + 1 mese = fine febbraio)
+ $y = (int) $d->format('Y');
+ $m = (int) $d->format('n') + $n;
+ $y += intdiv($m - 1, 12);
+ $m = (($m - 1) % 12) + 1;
+ $last = (int) (new DateTimeImmutable(sprintf('%04d-%02d-01', $y, $m)))->format('t');
+ return $d->setDate($y, $m, min((int) $d->format('j'), $last));
+ }
+
+ /**
+ * Crea l'ordine (orderbook) e tutto ciò che ne deriva, in un'unica transazione.
+ * - classe fissa: orderbook_services (le prenotazioni le crea la scuola alla propagazione)
+ * - lezione singola: prenotazione subito (bonifico = 'pending', carta = 'booked')
+ * - buono regalo: gift_vouchers in attesa del pagamento
+ * - tessera: la riga in memberships nasce alla conferma del pagamento
+ * @param string $method 'bank' | 'stripe'
+ * @return int idorderbook
+ */
+ public static function createOrder(int $idschool, int $userId, array $sel, string $method, ?array $coupon): int
+ {
+ $pdo = self::pdo();
+ $p = $sel['product'];
+ $v = $sel['variant'];
+ $mode = $sel['mode'];
+ $paid = $method === 'stripe';
+
+ $st = $pdo->prepare("SELECT first_name, last_name, email FROM auth_users WHERE id = ?");
+ $st->execute([$userId]);
+ $u = $st->fetch(PDO::FETCH_ASSOC) ?: ['first_name' => '', 'last_name' => '', 'email' => ''];
+
+ $price = round((float) $v['price'], 2);
+ $discount = $coupon ? min(round((float) $coupon['discount'], 2), $price) : 0.0;
+ $total = round($price - $discount, 2);
+ $lessons = $v['lessons'] !== null ? (int) $v['lessons'] : null;
+ $today = date('Y-m-d');
+
+ // Scadenza
+ $expire = null;
+ if ($mode === 'free' && $v['validity_mode'] === 'duration' && $v['validity_value'] && $v['validity_start'] === 'purchase') {
+ $expire = self::addPeriod($today, (int) $v['validity_value'], (string) $v['validity_unit'])->format('Y-m-d');
+ } elseif ($mode === 'single' && $sel['schedule']) {
+ $expire = substr($sel['schedule']['dateschedule'], 0, 10);
+ } elseif ($mode === 'membership') {
+ $expire = self::membershipEnd($idschool, $v, $today);
+ }
+
+ // Recuperi: dal prodotto, altrimenti dai parametri della scuola per quel numero di lezioni
+ $maxres = $p['max_reschedule'] !== null ? (int) $p['max_reschedule'] : null;
+ if ($maxres === null && $lessons && in_array($mode, ['fixed', 'free'], true)) {
+ $st = $pdo->prepare("SELECT reschedulenumber FROM expiryparameter WHERE quantityclass = ? AND idschool = ? LIMIT 1");
+ $st->execute([$lessons, $idschool]);
+ $r = $st->fetchColumn();
+ $maxres = $r !== false ? (int) $r : null;
+ }
+ if ($mode === 'single') {
+ $maxres = 0;
+ }
+
+ $idservice = null;
+ if ($mode === 'fixed') {
+ $idservice = $sel['services'][0];
+ } elseif ($mode === 'single') {
+ $idservice = (int) $sel['schedule']['idservice'];
+ }
+
+ $snapshot = [
+ 'mode' => $mode,
+ 'fixed_slots' => (int) $p['fixed_slots'],
+ 'weekly_limit' => $p['weekly_limit'] !== null ? (int) $p['weekly_limit'] : null,
+ 'max_reschedule' => $maxres,
+ 'lessons' => $lessons,
+ 'validity_mode' => $v['validity_mode'],
+ 'validity_value' => $v['validity_value'] !== null ? (int) $v['validity_value'] : null,
+ 'validity_unit' => $v['validity_unit'],
+ 'validity_start' => $v['validity_start'],
+ 'services' => $mode === 'free' || $mode === 'single' ? self::productServiceIds($idschool, (int) $p['idproduct']) : $sel['services'],
+ 'price' => $price,
+ ];
+
+ $pdo->beginTransaction();
+ try {
+ // Lezione singola: blocca la riga della lezione e ricontrolla il posto
+ if ($mode === 'single') {
+ $st = $pdo->prepare("SELECT ss.idserviceschedule, s.maxcapacity FROM serviceschedule ss JOIN service s ON s.idservice = ss.idservice WHERE ss.idserviceschedule = ? AND ss.idschool = ? FOR UPDATE");
+ $st->execute([(int) $sel['schedule']['idserviceschedule'], $idschool]);
+ $lock = $st->fetch(PDO::FETCH_ASSOC);
+ if (!$lock || ((int) $lock['maxcapacity'] > 0 && self::occupied($idschool, (int) $lock['idserviceschedule']) >= (int) $lock['maxcapacity'])) {
+ throw new RuntimeException('Nel frattempo la lezione si è riempita. Scegli un\'altra data.');
+ }
+ }
+
+ $pdo->prepare(
+ "INSERT INTO orderbook
+ (order_date_created, order_billing_email, order_total, product_name, line_total, quantity,
+ idservice, is_openflexy, iduser, nticket, expireon, status, quantityclass,
+ first_name, last_name, maxreschedule, reprogrammed, idschool,
+ idproduct, idvariant, booking_mode, weekly_limit, rules_snapshot, source, payment_status,
+ amount, discount_amount, idcoupon)
+ VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
+ )->execute([
+ $today,
+ $u['email'],
+ number_format($total, 2, '.', ''),
+ mb_substr($p['title'] . ' · ' . $v['label'], 0, 200),
+ number_format($total, 2, '.', ''),
+ $idservice,
+ $mode === 'free' ? 'Y' : 'N',
+ $userId,
+ $lessons,
+ $expire,
+ $paid ? ($mode === 'fixed' ? 'pending' : 'completed') : 'on-hold',
+ $lessons,
+ $u['first_name'],
+ $u['last_name'],
+ $maxres,
+ $idschool,
+ (int) $p['idproduct'],
+ (int) $v['idvariant'],
+ in_array($mode, ['fixed', 'free', 'single'], true) ? $mode : null,
+ $p['weekly_limit'] !== null ? (int) $p['weekly_limit'] : null,
+ json_encode($snapshot, JSON_UNESCAPED_UNICODE),
+ $method,
+ $paid ? 'paid' : 'pending',
+ $total,
+ $discount > 0 ? $discount : null,
+ $coupon ? (int) $coupon['idcoupon'] : null,
+ ]);
+ $idorder = (int) $pdo->lastInsertId();
+
+ if ($mode === 'fixed') {
+ $ins = $pdo->prepare("INSERT INTO orderbook_services (idorderbook, idschool, idservice, slot) VALUES (?, ?, ?, ?)");
+ foreach ($sel['services'] as $i => $sid) {
+ $ins->execute([$idorder, $idschool, $sid, $i + 1]);
+ }
+ }
+
+ if ($mode === 'single') {
+ $pdo->prepare(
+ "INSERT INTO bookingclass (idserviceschedule, bookingstart, status, idservice, iduser, idorder, cancellation_token, idschool)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
+ )->execute([
+ (int) $sel['schedule']['idserviceschedule'],
+ $sel['schedule']['dateschedule'],
+ $paid ? 'booked' : 'pending',
+ (int) $sel['schedule']['idservice'],
+ $userId,
+ $idorder,
+ bin2hex(random_bytes(16)),
+ $idschool,
+ ]);
+ }
+
+ if ($mode === 'voucher') {
+ $codeSt = $pdo->prepare("SELECT 1 FROM gift_vouchers WHERE idschool = ? AND code = ?");
+ do {
+ $code = 'YB-' . self::randomCode(4) . '-' . self::randomCode(4);
+ $codeSt->execute([$idschool, $code]);
+ } while ($codeSt->fetchColumn());
+ $vExpire = $v['validity_value'] ? self::addPeriod($today, (int) $v['validity_value'], (string) $v['validity_unit'])->format('Y-m-d') : null;
+ $pdo->prepare(
+ "INSERT INTO gift_vouchers (idschool, code, idvariant, buyer_iduser, buyer_name, buyer_email, recipient_name, recipient_email,
+ message, source, amount, expires_at, status)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
+ )->execute([
+ $idschool,
+ $code,
+ (int) $v['target_idvariant'],
+ $userId,
+ trim($u['first_name'] . ' ' . $u['last_name']),
+ $u['email'],
+ $sel['recipient']['name'],
+ $sel['recipient']['email'],
+ $sel['recipient']['message'] ?: null,
+ $method,
+ $total,
+ $vExpire,
+ $paid ? 'active' : 'pending',
+ ]);
+ // collega il buono all'ordine (per attivarlo alla conferma del pagamento)
+ $pdo->prepare("UPDATE orderbook SET cod = ? WHERE idorderbook = ?")->execute([$code, $idorder]);
+ }
+
+ if ($mode === 'membership' && $paid) {
+ $pdo->prepare("INSERT INTO memberships (idschool, iduser, idorderbook, valid_from, valid_to, status) VALUES (?, ?, ?, ?, ?, 'active')")
+ ->execute([$idschool, $userId, $idorder, $today, $expire ?? '2099-12-31']);
+ }
+
+ if ($coupon) {
+ $pdo->prepare("INSERT INTO coupon_redemptions (idcoupon, idschool, iduser, idorderbook, discount_amount) VALUES (?, ?, ?, ?, ?)")
+ ->execute([(int) $coupon['idcoupon'], $idschool, $userId, $idorder, $discount]);
+ }
+
+ $pdo->commit();
+ return $idorder;
+ } catch (Throwable $ex) {
+ if ($pdo->inTransaction()) {
+ $pdo->rollBack();
+ }
+ throw $ex;
+ }
+ }
+
+ /** Dati del bonifico per un ordine: intestatario, IBAN, banca, causale, scadenza */
+ public static function bankInfo(int $idschool, array $order): array
+ {
+ $tpl = trim((string) SchoolSettings::get('bank_reference', '', $idschool)) ?: 'Ordine {ordine} - {nome}';
+ $iban = strtoupper(preg_replace('/\s+/', '', (string) SchoolSettings::get('bank_iban', '', $idschool)));
+ $days = max(1, (int) SchoolSettings::get('bank_due_days', 5, $idschool));
+ $created = $order['order_date_created'] ?: date('Y-m-d');
+ return [
+ 'holder' => (string) SchoolSettings::get('bank_holder', '', $idschool),
+ 'iban' => trim(chunk_split($iban, 4, ' ')),
+ 'bank' => (string) SchoolSettings::get('bank_name', '', $idschool),
+ 'reference' => mb_substr(strtr($tpl, ['{ordine}' => (string) $order['idorderbook'], '{nome}' => trim($order['first_name'] . ' ' . $order['last_name'])]), 0, 140),
+ 'due' => date('Y-m-d', strtotime($created . ' +' . $days . ' days')),
+ ];
+ }
+
+ /**
+ * La scuola conferma il pagamento (bonifico ricevuto) di un ordine in attesa.
+ * - ordine pagato
+ * - lezione singola: la prenotazione passa da 'pending' a 'booked'
+ * - tessera: nasce la riga in memberships
+ * - buono regalo: diventa attivo
+ * Idempotente: false se l'ordine non è (più) in attesa.
+ */
+ public static function markPaid(int $idschool, int $idorder): bool
+ {
+ $pdo = self::pdo();
+ $pdo->beginTransaction();
+ try {
+ $st = $pdo->prepare(
+ "SELECT ob.*, p.kind FROM orderbook ob LEFT JOIN products p ON p.idproduct = ob.idproduct
+ WHERE ob.idorderbook = ? AND ob.idschool = ? FOR UPDATE"
+ );
+ $st->execute([$idorder, $idschool]);
+ $o = $st->fetch(PDO::FETCH_ASSOC);
+ if (!$o || $o['payment_status'] !== 'pending') {
+ $pdo->rollBack();
+ return false;
+ }
+
+ // Classe fissa: 'pending' = da propagare (stesso significato degli ordini storici)
+ $pdo->prepare("UPDATE orderbook SET payment_status = 'paid', status = ? WHERE idorderbook = ? AND idschool = ?")
+ ->execute([$o['booking_mode'] === 'fixed' ? 'pending' : 'completed', $idorder, $idschool]);
+
+ if ($o['booking_mode'] === 'single') {
+ $pdo->prepare("UPDATE bookingclass SET status = 'booked' WHERE idorder = ? AND idschool = ? AND status = 'pending'")
+ ->execute([$idorder, $idschool]);
+ }
+ if ($o['kind'] === 'membership') {
+ $pdo->prepare("INSERT INTO memberships (idschool, iduser, idorderbook, valid_from, valid_to, status) VALUES (?, ?, ?, CURDATE(), ?, 'active')")
+ ->execute([$idschool, (int) $o['iduser'], $idorder, $o['expireon'] ?: '2099-12-31']);
+ }
+ if (!empty($o['cod'])) {
+ $pdo->prepare("UPDATE gift_vouchers SET status = 'active' WHERE idschool = ? AND code = ? AND status = 'pending'")
+ ->execute([$idschool, $o['cod']]);
+ }
+ $pdo->commit();
+ return true;
+ } catch (Throwable $ex) {
+ if ($pdo->inTransaction()) {
+ $pdo->rollBack();
+ }
+ throw $ex;
+ }
+ }
+
+ /**
+ * Annulla un ordine in attesa di pagamento (dall'allieva o dalla scuola):
+ * libera il posto della lezione singola, annulla il buono e l'uso del coupon.
+ * $userId: se indicato, solo se l'ordine è suo.
+ */
+ public static function cancelPending(int $idschool, int $idorder, ?int $userId = null): bool
+ {
+ $pdo = self::pdo();
+ $pdo->beginTransaction();
+ try {
+ $sql = "SELECT * FROM orderbook WHERE idorderbook = ? AND idschool = ?" . ($userId ? " AND iduser = ?" : "") . " FOR UPDATE";
+ $args = $userId ? [$idorder, $idschool, $userId] : [$idorder, $idschool];
+ $st = $pdo->prepare($sql);
+ $st->execute($args);
+ $o = $st->fetch(PDO::FETCH_ASSOC);
+ if (!$o || $o['payment_status'] !== 'pending') {
+ $pdo->rollBack();
+ return false;
+ }
+ $pdo->prepare("UPDATE orderbook SET payment_status = 'cancelled', status = 'cancelled' WHERE idorderbook = ? AND idschool = ?")
+ ->execute([$idorder, $idschool]);
+ $pdo->prepare("UPDATE bookingclass SET status = 'cancelled' WHERE idorder = ? AND idschool = ? AND status <> 'cancelled'")
+ ->execute([$idorder, $idschool]);
+ $pdo->prepare("DELETE FROM coupon_redemptions WHERE idorderbook = ? AND idschool = ?")->execute([$idorder, $idschool]);
+ if (!empty($o['cod'])) {
+ $pdo->prepare("UPDATE gift_vouchers SET status = 'cancelled' WHERE idschool = ? AND code = ? AND status = 'pending'")
+ ->execute([$idschool, $o['cod']]);
+ }
+ $pdo->commit();
+ return true;
+ } catch (Throwable $ex) {
+ if ($pdo->inTransaction()) {
+ $pdo->rollBack();
+ }
+ throw $ex;
+ }
+ }
+
+ /** Ordini su cui la scuola deve agire: da pagare + da propagare (contatore in sidebar) */
+ public static function pendingCount(int $idschool): int
+ {
+ $st = self::pdo()->prepare(
+ "SELECT COUNT(*) FROM orderbook
+ WHERE idschool = ?
+ AND (payment_status = 'pending'
+ OR (status = 'pending' AND (payment_status IS NULL OR payment_status = 'paid')))"
+ );
+ $st->execute([$idschool]);
+ return (int) $st->fetchColumn();
+ }
+
+ private static function randomCode(int $len): string
+ {
+ $chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
+ $out = '';
+ for ($i = 0; $i < $len; $i++) {
+ $out .= $chars[random_int(0, strlen($chars) - 1)];
+ }
+ return $out;
+ }
+
+ private static function pdo(): PDO
+ {
+ return DBHandlerSelect::getInstance()->getConnection();
+ }
+}
diff --git a/public/include/sidebar.php b/public/include/sidebar.php
index ed35cbd..20d43ff 100644
--- a/public/include/sidebar.php
+++ b/public/include/sidebar.php
@@ -57,19 +57,45 @@ if ($__mode === 'school' && $__isAdmin && !empty($__sc['idschool'])) {
}
}
+// Percorso "Primi passi" non ancora concluso: promemoria con l'avanzamento salvato
+$__setup = null;
+if ($__mode === 'school' && $__isAdmin && !empty($__sc['idschool'])) {
+ try {
+ if ((string) SchoolSettings::get('setup_completed_at', '') === '') {
+ $__ss = json_decode((string) SchoolSettings::get('setup_state', ''), true);
+ $__setup = is_array($__ss) && !empty($__ss['progress']) ? (string) $__ss['progress'] : '';
+ }
+ } catch (Throwable $__ex) {
+ error_log('[sidebar] primi passi: ' . $__ex->getMessage());
+ }
+}
+
+// Ordini su cui agire: da pagare + da propagare (contatore nel menu della scuola)
+$__toPay = 0;
+if ($__mode === 'school' && $__isAdmin && !empty($__sc['idschool'])) {
+ try {
+ require_once dirname(__DIR__) . '/class/ShopService.php';
+ $__toPay = ShopService::pendingCount((int) $__sc['idschool']);
+ } catch (Throwable $__ex) {
+ error_log('[sidebar] ordini: ' . $__ex->getMessage());
+ }
+}
+
if (!function_exists('sb_item')) {
/** Voce singola. $also = altre pagine che la accendono (es. il form di modifica). */
- function sb_item(string $href, string $icon, string $label, string $current, array $also = []): void
+ function sb_item(string $href, string $icon, string $label, string $current, array $also = [], int $badge = 0): void
{
$on = ($href === $current || in_array($current, $also, true));
echo '
'
. ' '
- . ' ';
+ . ''
+ . ($badge > 0 ? '
' . $badge . ' ' : '')
+ . '';
}
/**
* Gruppo richiudibile (metisMenu). Si apre da solo se contiene la pagina corrente.
- * $items = [ [href, etichetta, [pagine collegate]], ... ] (le voci null vengono saltate)
+ * $items = [ [href, etichetta, [pagine collegate], contatore], ... ] (le voci null vengono saltate)
*/
function sb_group(string $icon, string $label, array $items, string $current): void
{
@@ -78,7 +104,9 @@ if (!function_exists('sb_item')) {
return;
}
$open = false;
+ $badge = 0;
foreach ($items as $it) {
+ $badge += (int) ($it[3] ?? 0);
if ($it[0] === $current || in_array($current, $it[2] ?? [], true)) {
$open = true;
}
@@ -86,12 +114,16 @@ if (!function_exists('sb_item')) {
echo '
'
. ''
. ' '
- . ' '
+ . ''
+ . ($badge > 0 ? '' . $badge . ' ' : '')
+ . ''
. ' ';
}
echo '';
}
@@ -210,6 +242,10 @@ if (!function_exists('sb_item')) {
border-color: #cfe5e6;
}
+ body[data-sidebar-size="sm"] .sb-badge {
+ display: none;
+ }
+
.sub-alert:hover {
filter: brightness(.97);
}
@@ -270,6 +306,14 @@ if (!function_exists('sb_item')) {
+
+
+