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 += + '
' + + '' + + '
'; + } + return h; + } + + function catSelect() { + if (!DATA.categories || DATA.categories.length < 2) return ""; + return ( + '" + ); + } + + function productCard(p, fromSvc) { + var min = Math.min.apply( + null, + p.variants.map(function (v) { + return v.price; + }), + ); + return ( + '
" + + '' + + esc(p.title) + + "" + + (p.subtitle + ? '' + esc(p.subtitle) + "" + : "") + + '' + + esc(ruleLine(p)) + + "" + + '' + + (p.variants.length > 1 ? "da " : "") + + euro(min) + + "" + ); + } + + function renderCatalog() { + var h = renderTop("products"); + var present = GROUPS.filter(function (g) { + return DATA.products.some(function (p) { + return p.group === g[0]; + }); + }); + h += + '"; + + 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 += + '"; + }); + 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(" · ") + + "
" + + '
' + + '

' + + (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 = + '
'; + h += cur + ? '' + : '"; + 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 += + '"; + }); + h += "
"; + + var blocked = p.blocked === "used"; + h += buyBar( + v, + blocked + ? '' + : '', + ); + 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 = + '
'; + 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 += + '"; + }); + 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 += + '"; + }); + } + if (p.mode === "voucher") { + h += + '

A chi lo regali?

Il buono arriva via email con un codice da usare nel negozio della scuola.

' + + '
' + + '
' + + '
Metti la tua se preferisci consegnarlo di persona.
' + + '
"; + } + h += "
"; + h += buyBar( + v, + '', + ); + 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 '
  • ' . '' - . '' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . '
  • '; + . '' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . '' + . ($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 '
  • ' . '' . '' - . '' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . '' + . '' . htmlspecialchars($label, ENT_QUOTES, 'UTF-8') . '' + . ($badge > 0 ? '' . $badge . '' : '') + . '' . '
  • '; } @@ -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')) {
    + + + + + Primi passiConfigura e prova la tua scuola + + + @@ -312,10 +356,7 @@ if (!function_exists('sb_item')) { ['statistiche.php', 'Statistiche'], ], $__page); ?> - + @@ -347,7 +390,8 @@ if (!function_exists('sb_item')) { - + + diff --git a/public/ordersummary.php b/public/ordersummary.php index e315bc5..fab6680 100644 --- a/public/ordersummary.php +++ b/public/ordersummary.php @@ -9,8 +9,6 @@ SchoolContext::requireSchoolAdmin(); $pdo = DBHandlerSelect::getInstance()->getConnection(); -if (!function_exists('e')) { - if (!function_exists('e')) { function e($value): string { diff --git a/public/package_rules.php b/public/package_rules.php new file mode 100644 index 0000000..861e592 --- /dev/null +++ b/public/package_rules.php @@ -0,0 +1,305 @@ +getConnection(); + +function e($value): string +{ + return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); +} + +function back(string $msg, string $type = 'msg'): void +{ + header('Location: package_rules.php?' . $type . '=' . urlencode($msg)); + exit(); +} + +/** Valori consigliati per una scuola che parte da zero */ +const PR_DEFAULTS = [[4, 5, 1], [6, 8, 2], [8, 10, 2], [10, 12, 2], [12, 15, 3]]; + +/* ---------------------------------------------------------------- AZIONI */ +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + yb_csrf_check(); + $action = (string) ($_POST['action'] ?? ''); + $id = (int) ($_POST['id'] ?? 0); + + $int = function (string $k, int $min, int $max): ?int { + $v = trim((string) ($_POST[$k] ?? '')); + return (ctype_digit($v) && (int) $v >= $min && (int) $v <= $max) ? (int) $v : null; + }; + + try { + if ($action === 'save') { + $qty = $int('quantityclass', 1, 200); + $exp = $int('expirylessons', 1, 400); + $res = $int('reschedulenumber', 0, 99); + if ($qty === null || $exp === null || $res === null) { + back('Controlla i valori: numeri interi, lezioni almeno 1.', 'err'); + } + if ($exp < $qty) { + back('La scadenza (' . $exp . ' lezioni) non può essere minore delle lezioni del pacchetto (' . $qty . ').', 'err'); + } + $st = $pdo->prepare("SELECT 1 FROM expiryparameter WHERE idschool = ? AND quantityclass = ? AND idexpiryparameter <> ? LIMIT 1"); + $st->execute([(int) $idschool, $qty, $id]); + if ($st->fetchColumn()) { + back('Esiste già una regola per i pacchetti da ' . $qty . ' lezioni.', 'err'); + } + if ($id > 0) { + $st = $pdo->prepare("UPDATE expiryparameter SET quantityclass = ?, expirylessons = ?, reschedulenumber = ? WHERE idexpiryparameter = ? AND idschool = ?"); + $st->execute([$qty, $exp, $res, $id, (int) $idschool]); + back('Regola per ' . $qty . ' lezioni aggiornata.'); + } + $pdo->prepare("INSERT INTO expiryparameter (quantityclass, expirylessons, reschedulenumber, idschool) VALUES (?, ?, ?, ?)") + ->execute([$qty, $exp, $res, (int) $idschool]); + back('Regola per ' . $qty . ' lezioni aggiunta.'); + } + + if ($action === 'delete' && $id > 0) { + $pdo->prepare("DELETE FROM expiryparameter WHERE idexpiryparameter = ? AND idschool = ?")->execute([$id, (int) $idschool]); + back('Regola eliminata.'); + } + + if ($action === 'defaults') { + $st = $pdo->prepare("SELECT COUNT(*) FROM expiryparameter WHERE idschool = ?"); + $st->execute([(int) $idschool]); + if ((int) $st->fetchColumn() > 0) { + back('Ci sono già delle regole: i valori consigliati si caricano solo su una tabella vuota.', 'err'); + } + $ins = $pdo->prepare("INSERT INTO expiryparameter (quantityclass, expirylessons, reschedulenumber, idschool) VALUES (?, ?, ?, ?)"); + foreach (PR_DEFAULTS as [$q, $x, $r]) { + $ins->execute([$q, $x, $r, (int) $idschool]); + } + back('Valori consigliati caricati: modificali come preferisci.'); + } + } catch (PDOException $ex) { + error_log('[package_rules] ' . $ex->getMessage()); + back('Errore database, operazione non eseguita.', 'err'); + } + back('Azione non valida.', 'err'); +} + +/* ------------------------------------------------------------------ DATI */ +$st = $pdo->prepare("SELECT * FROM expiryparameter WHERE idschool = ? ORDER BY quantityclass, idexpiryparameter"); +$st->execute([(int) $idschool]); +$rules = $st->fetchAll(PDO::FETCH_ASSOC); + +// Quanti ordini usano ogni numero di lezioni (per sapere cosa si tocca) +$st = $pdo->prepare("SELECT quantityclass, COUNT(*) AS n FROM orderbook WHERE idschool = ? AND quantityclass IS NOT NULL GROUP BY quantityclass"); +$st->execute([(int) $idschool]); +$usage = []; +foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $u) { + $usage[(int) $u['quantityclass']] = (int) $u['n']; +} +$missing = array_diff(array_keys($usage), array_map(fn($r) => (int) $r['quantityclass'], $rules)); +sort($missing); +?> + + + + + + YogiBook - Regole pacchetti + + + + + + + + + + + +
    +
    + +
    + +
    + +
    +
    + +
    +
    +
    + +
    +
    +
    +
    +
    + +
    +
    + +
    +
    +
    Scadenza e recuperi dei pacchetti
    +

    + Per ogni pacchetto a numero di lezioni: dopo quante lezioni in calendario della sua classe scade, e quanti recuperi sono consentiti. + Valgono per la propagazione degli ordini, per gli ordini inseriti a mano e per i prodotti del negozio con "Recuperi" lasciato vuoto. +

    + +
    + +
    + Esempio: pacchetto da 8 lezioni, scade dopo 10 lezioni, 2 recuperi. + Se la classe è il martedì, il pacchetto vale per le prossime 10 lezioni del martedì (2 settimane di margine per le assenze) e l'allieva può spostare fino a 2 lezioni. + I giorni di chiusura non contano. +
    +
    + + +
    + +
    Ci sono ordini con un numero di lezioni senza regola: . Per questi la scadenza non si calcola da sola: aggiungi una regola o inseriscila a mano alla propagazione.
    +
    + + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Lezioni del pacchettoScade dopo (lezioni)Recuperi consentitiMargineOrdiniAzioni
    +
    + + Nessuna regola ancora. +
    + + + +
    +
    4 → 5 · 6 → 8 · 8 → 10 · 10 → 12 · 12 → 15, poi li modifichi.
    +
    +
    +
    + + + +
    + +
    + + scade prima di finire le lezioni + + + + + +
    + + + + +
    +
    +
    + + + +
    + +
    Nuova regola
    +
    +
    Le modifiche valgono per i prossimi calcoli: gli ordini già propagati mantengono scadenza e recuperi che hanno (li cambi da Ordini).
    +
    +
    + +
    +
    +
    + +
    +
    +
    + + + + + + + + + + \ No newline at end of file diff --git a/public/school_orders.php b/public/school_orders.php new file mode 100644 index 0000000..e110364 --- /dev/null +++ b/public/school_orders.php @@ -0,0 +1,1123 @@ +getConnection(); + +function e($value): string +{ + return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); +} + +function back(string $msg, string $type = 'msg'): void +{ + $f = preg_replace('/[^a-z]/', '', (string) ($_GET['f'] ?? '')); + header('Location: school_orders.php?' . http_build_query(array_filter(['f' => $f, $type => $msg]))); + exit(); +} + +/* ------------------------------------------------- AZIONI (pagamento, annullo) */ +if ($_SERVER['REQUEST_METHOD'] === 'POST') { + yb_csrf_check(); + $id = (int) ($_POST['id'] ?? 0); + if (!SchoolScope::belongs('orderbook', $id)) { + back('Ordine non trovato.', 'err'); + } + try { + $action = (string) ($_POST['action'] ?? ''); + if ($action === 'paid') { + ShopService::markPaid((int) $idschool, $id) + ? back('Pagamento registrato per l\'ordine n. ' . $id . '.') + : back('L\'ordine n. ' . $id . ' non è più in attesa di pagamento.', 'err'); + } + if ($action === 'cancel') { + ShopService::cancelPending((int) $idschool, $id) + ? back('Ordine n. ' . $id . ' annullato.') + : back('Si possono annullare solo gli ordini in attesa di pagamento.', 'err'); + } + } catch (PDOException $ex) { + error_log('[school_orders] ' . $ex->getMessage()); + back('Errore database, operazione non eseguita.', 'err'); + } + back('Azione non valida.', 'err'); +} + +/* ------------------------------------------------------------------ DATI */ +$st = $pdo->prepare( + "SELECT ob.idorderbook, ob.order_id, ob.order_date_created, ob.first_name, ob.last_name, ob.order_billing_email, + ob.product_name, ob.cod, ob.nticket, ob.quantityclass, ob.expireon, ob.status, ob.maxreschedule, ob.reprogrammed, + ob.is_openflexy, ob.booking_mode, ob.source, ob.payment_status, ob.amount, ob.discount_amount, ob.order_total, + p.kind, s.servicename, s.day, s.time, + (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, + (SELECT COUNT(*) FROM bookingclass b2 WHERE b2.idorder = ob.idorderbook AND b2.idschool = ob.idschool) AS anybooking + FROM orderbook ob + LEFT JOIN products p ON p.idproduct = ob.idproduct + LEFT JOIN service s ON s.idservice = ob.idservice AND s.idschool = ob.idschool + WHERE ob.idschool = ? + ORDER BY ob.idorderbook DESC + LIMIT 3000" +); +$st->execute([(int) $idschool]); +$raw = $st->fetchAll(PDO::FETCH_ASSOC); + +$ids = array_map(fn($o) => (int) $o['idorderbook'], $raw); +$classesBy = []; +$lessonBy = []; +$voucherBy = []; +if ($ids) { + $ph = implode(',', array_fill(0, count($ids), '?')); + $st = $pdo->prepare("SELECT os.idorderbook, s.servicename, s.day, s.time FROM orderbook_services os JOIN service s ON s.idservice = os.idservice WHERE os.idschool = ? AND os.idorderbook IN ($ph) ORDER BY os.slot"); + $st->execute(array_merge([(int) $idschool], $ids)); + foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) { + $classesBy[(int) $r['idorderbook']][] = trim($r['servicename'] . ' ' . $r['day'] . ' ' . $r['time']); + } + $st = $pdo->prepare("SELECT b.idorder, b.bookingstart, b.status FROM bookingclass b JOIN orderbook ob ON ob.idorderbook = b.idorder WHERE b.idschool = ? AND ob.booking_mode = 'single' AND b.idorder IN ($ph)"); + $st->execute(array_merge([(int) $idschool], $ids)); + foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) { + $lessonBy[(int) $r['idorder']] = $r; + } + $st = $pdo->prepare("SELECT ob.idorderbook, g.code, g.recipient_name FROM gift_vouchers g JOIN orderbook ob ON ob.cod = g.code COLLATE utf8mb4_general_ci AND ob.idschool = g.idschool WHERE g.idschool = ? AND ob.idorderbook IN ($ph)"); + $st->execute(array_merge([(int) $idschool], $ids)); + foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $r) { + $voucherBy[(int) $r['idorderbook']] = $r; + } +} + +$today = date('Y-m-d'); +$dueDays = max(1, (int) SchoolSettings::get('bank_due_days', 5)); +$sources = ['bank' => 'Bonifico', 'stripe' => 'Carta', 'manual' => 'Segreteria', 'voucher' => 'Buono regalo', 'woocommerce' => 'Sito']; + +$rows = []; +foreach ($raw as $o) { + $oid = (int) $o['idorderbook']; + $canc = in_array($o['payment_status'], ['cancelled', 'refunded'], true) || $o['status'] === 'cancelled'; + $pay = $o['payment_status'] === 'pending'; + if ($canc) { + $state = 'cancelled'; + } elseif ($pay) { + $state = 'topay'; + } elseif ($o['status'] === 'pending') { + $state = 'prop'; + } elseif ($o['expireon'] && $o['expireon'] < $today) { + $state = 'expired'; + } else { + $state = 'active'; + } + $details = $classesBy[$oid] ?? []; + if (!$details && $o['servicename'] && $o['booking_mode'] !== 'single') { + $details[] = trim($o['servicename'] . ' ' . $o['day'] . ' ' . $o['time']); + } + if (isset($lessonBy[$oid])) { + $details[] = ShopService::dateLabel(strtotime($lessonBy[$oid]['bookingstart'])) . ' (' . $lessonBy[$oid]['status'] . ')'; + } + if (isset($voucherBy[$oid])) { + $details[] = 'Buono per ' . $voucherBy[$oid]['recipient_name'] . ' · ' . $voucherBy[$oid]['code']; + } + $due = $pay ? date('Y-m-d', strtotime(($o['order_date_created'] ?: $today) . ' +' . $dueDays . ' days')) : null; + $amount = $o['amount'] !== null ? (float) $o['amount'] : (is_numeric(str_replace(',', '.', (string) $o['order_total'])) ? (float) str_replace(',', '.', (string) $o['order_total']) : null); + + $rows[] = [ + 'id' => $oid, + 'wc' => $o['order_id'], + 'date' => $o['order_date_created'], + 'name' => trim($o['first_name'] . ' ' . $o['last_name']), + 'email' => (string) $o['order_billing_email'], + 'product' => (string) ($o['product_name'] ?: ($o['servicename'] ?: $o['cod'])), + 'details' => $details, + 'used' => (int) $o['used'], + 'tot' => (int) $o['nticket'] > 0 ? (int) $o['nticket'] : (int) $o['quantityclass'], + 'expire' => $o['expireon'], + 'maxres' => $o['maxreschedule'] !== null ? (int) $o['maxreschedule'] : null, + 'reprog' => (int) $o['reprogrammed'], + 'amount' => $amount, + 'discount' => $o['discount_amount'] !== null ? (float) $o['discount_amount'] : null, + 'source' => $o['source'] ? ($sources[$o['source']] ?? $o['source']) : 'Storico', + 'state' => $state, + 'due' => $due, + 'flex' => $o['is_openflexy'] === 'Y', + 'single' => $o['booking_mode'] === 'single', + 'kind' => (string) $o['kind'], + 'bookings' => (int) $o['anybooking'], + ]; +} + +// Classi per il nuovo ordine +$st = $pdo->prepare("SELECT idservice, servicename, day, time FROM service WHERE idschool = ? ORDER BY servicename, day, time"); +$st->execute([(int) $idschool]); +$services = $st->fetchAll(PDO::FETCH_ASSOC); + +$f = preg_replace('/[^a-z]/', '', (string) ($_GET['f'] ?? '')); +$jsonFlags = JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP; +?> + + + + + + YogiBook - Ordini + + + + + + + + + + + +
    +
    + +
    + +
    + +
    +
    + +
    +
    +
    + +
    +
    +
    + +
    +
    + +
    +
    +
    +
    +
    Ordini
    +

    Tutti gli ordini della scuola. Clicca su scadenza o recuperi per modificarli.

    +
    + +
    + +
    +
    + +
    + + + + + + + + + + + + + + + +
    N.AllievaProdottoLezioniScadenzaRecuperiImportoStatoAzioni
    +
    +
    +
    + + + + + + + +
    + +
    +
    +
    + + + + + + + + + + \ No newline at end of file diff --git a/public/school_setup.php b/public/school_setup.php new file mode 100644 index 0000000..00b27bb --- /dev/null +++ b/public/school_setup.php @@ -0,0 +1,510 @@ + date('Y-m-d H:i:s')], $sid); + header('Location: school_setup.php?done=1'); + exit(); + } + } + if ($action === 'reopen') { + SchoolSettings::save(['setup_completed_at' => null], $sid); + } + header('Location: school_setup.php' . ($key !== '' ? '#step-' . $key : '')); + exit(); +} + +/* ------------------------------------------------------------------ DATI */ +$steps = SchoolSetup::steps($sid, (int) $iduserlogin); +[$done, $total, $missing] = SchoolSetup::progress($steps); +$pct = $total ? (int) round($done / $total * 100) : 0; +$completedAt = (string) SchoolSettings::get('setup_completed_at', ''); + +// Avanzamento in cache per il promemoria in sidebar (scritto solo se cambia) +$state = SchoolSetup::state($sid); +if (($state['progress'] ?? '') !== $done . '/' . $total) { + $state['progress'] = $done . '/' . $total; + SchoolSetup::saveState($sid, $state); +} + +$next = null; +foreach ($steps as $s) { + if (!$s['done'] && !$s['skipped']) { + $next = $s; + break; + } +} +$byPhase = []; +foreach ($steps as $i => $s) { + $s['n'] = $i + 1; + $byPhase[$s['phase']][] = $s; +} +?> + + + + + + YogiBook - Primi passi + + + + + + + + + + +
    +
    + +
    + +
    + +
    +
    + +
    +
    +
    + +
    + +
    +
    + + + + + + + + + \ No newline at end of file diff --git a/public/shop.php b/public/shop.php new file mode 100644 index 0000000..a2c6bea --- /dev/null +++ b/public/shop.php @@ -0,0 +1,128 @@ +getConnection(); + +function e($value): string +{ + return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); +} + +$shopOn = SchoolSettings::get('shop_enabled', 'N') === 'Y'; +$shopUrl = (string) SchoolSettings::get('shop_url', ''); + +// Negozio spento: l'allieva vede il link esterno (se c'è); lo staff vede comunque il catalogo +$showCatalog = $shopOn || $isstaff; +$data = $showCatalog ? ShopService::catalog((int) $idschool, (int) $iduserlogin, true) : null; +if ($data) { + $data['schoolName'] = (string) $schoolname; +} +$jsonFlags = JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP; +?> + + + + + + Negozio - <?php echo e($schoolname); ?> + + + + + + + + + +
    +
    + +
    + +
    + +
    +
    + +
    +
    +
    + +
    +
    +
    + + +
    +
    + +

    Il negozio non è ancora aperto

    + +

    Per acquistare pacchetti e abbonamenti usa il negozio online della scuola.

    + Vai al negozio + +

    Per acquistare chiedi in segreteria.

    + +
    +
    + + +
    +
    Il negozio non è ancora attivo per le allieve: lo vedi perché fai parte dello staff. Si attiva da Opzioni Scuola → Negozio.
    +
    + +
    + + + +
    + +
    +
    +
    + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/shop_checkout.php b/public/shop_checkout.php new file mode 100644 index 0000000..7fff27f --- /dev/null +++ b/public/shop_checkout.php @@ -0,0 +1,389 @@ +getConnection(); + +function e($value): string +{ + return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); +} + +function euro($v): string +{ + return number_format((float) $v, 2, ',', '.') . ' €'; +} + +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + header('Location: shop.php'); + exit(); +} +yb_csrf_check(); + +$shopOn = SchoolSettings::get('shop_enabled', 'N') === 'Y'; +if (!$shopOn && !$isstaff) { + header('Location: shop.php'); + exit(); +} +if (!SchoolScope::isMember((int) $iduserlogin)) { + header('Location: shop.php'); + exit(); +} + +$in = [ + 'idvariant' => (int) ($_POST['idvariant'] ?? 0), + 'services' => array_values(array_filter(array_map('intval', explode(',', (string) ($_POST['services'] ?? ''))))), + 'idschedule' => (int) ($_POST['idschedule'] ?? 0), + 'recipient_name' => (string) ($_POST['recipient_name'] ?? ''), + 'recipient_email' => (string) ($_POST['recipient_email'] ?? ''), + 'recipient_message' => (string) ($_POST['recipient_message'] ?? ''), +]; +$action = (string) ($_POST['action'] ?? 'review'); +$couponCode = strtoupper(trim((string) ($_POST['coupon_code'] ?? ''))); +$method = (string) ($_POST['method'] ?? ''); +$error = ''; +$couponMsg = ''; +$sel = null; +$coupon = null; + +try { + $sel = ShopService::selection((int) $idschool, (int) $iduserlogin, $in); +} catch (RuntimeException $ex) { + $error = $ex->getMessage(); +} catch (PDOException $ex) { + error_log('[shop_checkout] selection: ' . $ex->getMessage()); + $error = 'Si è verificato un errore. Riprova tra poco.'; +} + +if ($sel) { + if ($action === 'remove_coupon') { + $couponCode = ''; + } + if ($couponCode !== '') { + try { + $coupon = ShopService::checkCoupon((int) $idschool, (int) $iduserlogin, $couponCode, $sel['variant']); + } catch (RuntimeException $ex) { + $couponMsg = $ex->getMessage(); + $couponCode = ''; + } + } + + $methods = ShopService::paymentMethods((int) $idschool); + + if ($action === 'confirm') { + $token = (string) ($_POST['checkout_token'] ?? ''); + if ($token === '' || !hash_equals((string) ($_SESSION['yb_checkout_token'] ?? ''), $token)) { + $error = 'Questo ordine è già stato inviato. Controlla in "I miei pacchetti".'; + $sel = null; + } elseif (($_POST['terms'] ?? '') !== 'Y') { + $couponMsg = $couponMsg ?: ''; + $formError = 'Per continuare accetta le condizioni di acquisto.'; + } elseif (!(($method === 'bank' && $methods['bank']) || ($method === 'stripe' && $methods['card']))) { + $formError = 'Scegli come vuoi pagare.'; + } elseif ($method === 'stripe') { + $formError = 'Il pagamento con carta sarà disponibile a breve. Scegli il bonifico.'; + } else { + unset($_SESSION['yb_checkout_token']); + try { + $idorder = ShopService::createOrder((int) $idschool, (int) $iduserlogin, $sel, $method, $coupon); + header('Location: shop_order.php?id=' . $idorder . '&new=1'); + exit(); + } catch (RuntimeException $ex) { + $formError = $ex->getMessage(); + } catch (PDOException $ex) { + error_log('[shop_checkout] createOrder: ' . $ex->getMessage()); + $formError = 'Non è stato possibile registrare l\'ordine. Riprova.'; + } + } + } +} + +// Token monouso per la conferma +$_SESSION['yb_checkout_token'] = bin2hex(random_bytes(16)); + +/* ----------------------------------------------- dati per il riepilogo */ +$lines = []; +$termsUrl = (string) SchoolSettings::get('shop_terms_url', ''); +$termsText = (string) SchoolSettings::get('shop_terms_text', ''); +$membershipWarn = false; +if ($sel) { + $v = $sel['variant']; + $mode = $sel['mode']; + $price = (float) $v['price']; + $discount = $coupon ? min((float) $coupon['discount'], $price) : 0.0; + $total = round($price - $discount, 2); + $lessons = $v['lessons'] !== null ? (int) $v['lessons'] : null; + $unit = ['days' => ['giorno', 'giorni'], 'weeks' => ['settimana', 'settimane'], 'months' => ['mese', 'mesi']][$v['validity_unit']] ?? ['', '']; + $validity = $v['validity_value'] ? $v['validity_value'] . ' ' . ((int) $v['validity_value'] === 1 ? $unit[0] : $unit[1]) : ''; + + if ($mode === 'fixed') { + $st = $pdo->prepare("SELECT idservice, servicename, day, time FROM service WHERE idschool = ? AND idservice IN (" . implode(',', array_fill(0, count($sel['services']), '?')) . ")"); + $st->execute(array_merge([(int) $idschool], $sel['services'])); + $names = []; + foreach ($st->fetchAll(PDO::FETCH_ASSOC) as $s) { + $names[(int) $s['idservice']] = trim($s['servicename'] . ' · ' . $s['day'] . ' ' . $s['time']); + } + $lines[] = [count($sel['services']) > 1 ? 'Le tue classi' : 'La tua classe', implode(' + ', array_map(fn($sid) => $names[$sid] ?? '', $sel['services']))]; + $lines[] = ['Lezioni', $lessons ?: 'Illimitate']; + if ($validity) { + $lines[] = ['Validità', $validity . ($v['validity_start'] === 'purchase' ? ' dall\'acquisto' : ' dalla prima lezione')]; + } + $lines[] = ['Prima lezione', 'Te la conferma la scuola']; + } elseif ($mode === 'free') { + $lines[] = ['Lezioni', $lessons ?: 'Illimitate']; + if ($validity) { + $lines[] = ['Validità', $validity . ($v['validity_start'] === 'purchase' ? ' dall\'acquisto' : ' dalla prima lezione')]; + } + if ($sel['product']['weekly_limit']) { + $lines[] = ['Limite', 'Max ' . (int) $sel['product']['weekly_limit'] . ' lezioni a settimana']; + } + $lines[] = ['Prenotazioni', 'Le fai tu dal calendario']; + } elseif ($mode === 'single') { + $lines[] = ['Lezione', $sel['schedule']['servicename']]; + $lines[] = ['Quando', ShopService::dateLabel(strtotime($sel['schedule']['dateschedule']))]; + } elseif ($mode === 'membership') { + $end = ShopService::membershipEnd((int) $idschool, $v); + $lines[] = ['Validità', $end ? 'Fino al ' . date('d/m/Y', strtotime($end)) : 'Senza scadenza']; + } elseif ($mode === 'voucher') { + $lines[] = ['Per', $sel['recipient']['name']]; + $lines[] = ['Invio a', $sel['recipient']['email']]; + if ($validity) { + $lines[] = ['Scadenza del buono', $validity]; + } + } + + // Tessera obbligatoria mancante (avviso, non blocca) + if ($sel['product']['kind'] === 'lessons' && SchoolSettings::get('membership_required', 'N') === 'Y') { + $st = $pdo->prepare("SELECT 1 FROM memberships WHERE iduser = ? AND idschool = ? AND status = 'active' AND valid_to >= CURDATE() LIMIT 1"); + $st->execute([(int) $iduserlogin, (int) $idschool]); + $membershipWarn = !$st->fetchColumn(); + } + if ($method === '' || !(($method === 'bank' && $methods['bank']) || ($method === 'stripe' && $methods['card']))) { + $method = $methods['card'] ? 'stripe' : ($methods['bank'] ? 'bank' : ''); + } +} + +/** Campi nascosti che riportano la scelta a ogni invio */ +function keepFields(array $in, string $coupon): string +{ + $h = ''; + $h .= ''; + $h .= ''; + $h .= ''; + foreach (['recipient_name', 'recipient_email', 'recipient_message'] as $k) { + $h .= ''; + } + $h .= ''; + return $h; +} +?> + + + + + + Riepilogo ordine - <?php echo e($schoolname); ?> + + + + + + + + + +
    +
    + +
    + +
    + +
    +
    + +
    +
    +
    + +
    +
    +
    +
    + + +
    + +

    Non possiamo completare l'ordine

    +

    + Torna al negozio +
    + + Modifica la scelta +

    Riepilogo

    + + +
    +
    +
    + + +
    +
    Per frequentare le lezioni serve anche la tessera associativa valida. Puoi acquistarla dal negozio dopo questo ordine.
    +
    + + +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    + +
    +
    + + +
    +
    + + + + + +
    + · − + + +
    + +
    + + +
    +
    + +
    +
    +
    + + +
    + + + +
    + Come vuoi pagare? + +
    Il pagamento online non è ancora attivo. Contatta la segreteria per completare l'acquisto.
    + + + + + + + + +
    +
    Subtotale
    + 0) : ?>
    Sconto −
    +
    Totale
    +
    + + + + + + + +
    +
    +
    + + +
    +
    + +
    +
    +
    + + + + + + + + + + \ No newline at end of file diff --git a/public/shop_order.php b/public/shop_order.php new file mode 100644 index 0000000..2c51434 --- /dev/null +++ b/public/shop_order.php @@ -0,0 +1,285 @@ +getConnection(); + +function e($value): string +{ + return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); +} + +function euro($v): string +{ + return number_format((float) $v, 2, ',', '.') . ' €'; +} + +$id = (int) ($_GET['id'] ?? $_POST['id'] ?? 0); +$st = $pdo->prepare("SELECT ob.*, p.title AS ptitle, p.kind FROM orderbook ob LEFT JOIN products p ON p.idproduct = ob.idproduct WHERE ob.idorderbook = ? AND ob.iduser = ? AND ob.idschool = ?"); +$st->execute([$id, (int) $iduserlogin, (int) $idschool]); +$o = $st->fetch(PDO::FETCH_ASSOC); +if (!$o) { + header('Location: shop.php'); + exit(); +} + +/* ------------------------------------------- annullo (solo bonifico in attesa) */ +if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'cancel') { + yb_csrf_check(); + try { + ShopService::cancelPending((int) $idschool, $id, (int) $iduserlogin); + } catch (PDOException $ex) { + error_log('[shop_order] cancel: ' . $ex->getMessage()); + header('Location: shop_order.php?id=' . $id . '&err=1'); + exit(); + } + header('Location: shop_order.php?id=' . $id . '&cancelled=1'); + exit(); +} + +/* ------------------------------------------------------------------ DATI */ +$rules = json_decode((string) $o['rules_snapshot'], true) ?: []; +$mode = $o['booking_mode'] ?: ($o['kind'] ?? ''); + +$classes = []; +if ($mode === 'fixed') { + $st = $pdo->prepare("SELECT s.servicename, s.day, s.time FROM orderbook_services os JOIN service s ON s.idservice = os.idservice WHERE os.idorderbook = ? AND os.idschool = ? ORDER BY os.slot"); + $st->execute([$id, (int) $idschool]); + $classes = $st->fetchAll(PDO::FETCH_ASSOC); +} +$lesson = null; +if ($mode === 'single') { + $st = $pdo->prepare("SELECT b.bookingstart, b.status, s.servicename FROM bookingclass b JOIN service s ON s.idservice = b.idservice WHERE b.idorder = ? AND b.idschool = ? ORDER BY b.idbookingclass LIMIT 1"); + $st->execute([$id, (int) $idschool]); + $lesson = $st->fetch(PDO::FETCH_ASSOC) ?: null; +} +$voucher = null; +if (!empty($o['cod'])) { + $st = $pdo->prepare("SELECT * FROM gift_vouchers WHERE idschool = ? AND code = ?"); + $st->execute([(int) $idschool, $o['cod']]); + $voucher = $st->fetch(PDO::FETCH_ASSOC) ?: null; +} + +$pending = $o['payment_status'] === 'pending'; +$cancelled = in_array($o['payment_status'], ['cancelled', 'refunded'], true); +$bank = ($o['source'] === 'bank' && $pending) ? ShopService::bankInfo((int) $idschool, $o) : null; +$isNew = isset($_GET['new']); + +$statusLabel = $cancelled ? ['Annullato', 'muted'] : ($pending ? ['In attesa di pagamento', 'warn'] : ['Pagato', 'ok']); + +$next = ''; +if (!$cancelled) { + if ($mode === 'fixed') { + $next = $pending + ? 'Quando la scuola riceve il bonifico ti conferma la data della prima lezione; da lì le lezioni vengono prenotate in automatico ogni settimana.' + : 'La scuola ti conferma la data della prima lezione; da lì le lezioni vengono prenotate in automatico ogni settimana.'; + } elseif ($mode === 'free') { + $next = $pending + ? 'Appena la scuola riceve il bonifico il pacchetto si attiva e puoi prenotare le lezioni dal calendario.' + : 'Il pacchetto è attivo: prenota le lezioni dal calendario quando vuoi.'; + } elseif ($mode === 'single' && $lesson) { + $next = 'Il tuo posto è ' . ($pending ? 'riservato in attesa del pagamento' : 'prenotato') . ': ' . $lesson['servicename'] . ', ' . mb_strtolower(ShopService::dateLabel(strtotime($lesson['bookingstart']))) . '.'; + } elseif ($o['kind'] === 'membership') { + $next = $pending ? 'La tessera si attiva quando la scuola riceve il pagamento.' : 'La tessera è attiva' . ($o['expireon'] ? ' fino al ' . date('d/m/Y', strtotime($o['expireon'])) : '') . '.'; + } elseif ($o['kind'] === 'voucher' && $voucher) { + $next = $pending + ? 'Il buono per ' . $voucher['recipient_name'] . ' si attiva quando la scuola riceve il pagamento.' + : 'Il buono per ' . $voucher['recipient_name'] . ' è attivo. Codice: ' . $voucher['code'] . '.'; + } +} +?> + + + + + + Ordine n. <?php echo (int) $id; ?> - <?php echo e($schoolname); ?> + + + + + + + + + + +
    +
    + +
    + +
    + +
    +
    + +
    +
    +
    + +
    +
    +
    +
    + + +
    +
    Ordine annullato.
    +
    + +
    +
    Non è stato possibile annullare l'ordine. Riprova o contatta la segreteria.
    +
    + + + +
    + +

    +

    +
    + + + +
    +
    Dati per il bonifico
    +
    +
    Intestatario
    +
    +
    IBAN
    +
    +
    Banca
    +
    +
    Importo
    +
    +
    Causale
    +
    +
    Entro il
    +
    +
    +
    Usa esattamente questa causale: così la scuola riconosce subito il tuo pagamento.
    +
    + + +
    +
    +
    +
    +
    Ordine n. del
    +
    + +
    +
    + +
    Classe
    +
    + + +
    Lezione
    +
    + +
    Lezioni
    +
    +
    Valido fino al
    +
    +
    Buono per
    +
    + 0) : ?>
    Sconto
    +
    −
    +
    Totale
    +
    ·
    +
    + +
    + +
    + +
    + + +
    + + + +
    + +
    + +
    +
    + +
    +
    +
    + + + + + + + + + + \ No newline at end of file diff --git a/public/uploads/schools/1/logo-40be84a1d014d11e.png b/public/uploads/schools/1/logo-40be84a1d014d11e.png new file mode 100644 index 0000000..f487704 Binary files /dev/null and b/public/uploads/schools/1/logo-40be84a1d014d11e.png differ