Files
fuz-site/src/islands/jambox/JamboxAddonsModal.jsx
2025-12-16 11:38:38 +01:00

638 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from "preact/hooks";
import "../../styles/modal.css";
import "../../styles/addons.css";
function formatFeatureValue(val) {
if (val === true || val === "true") return "✓";
if (val === false || val === "false" || val == null) return "✕";
return val;
}
function money(amount) {
const n = Number(amount || 0);
return n.toFixed(2).replace(".", ",");
}
/** telefon z YAML (phone/cards.yaml -> cards[]) => { id, name, price_monthly, features[] } */
function mapPhoneYamlToPlans(phoneCards) {
const list = Array.isArray(phoneCards) ? phoneCards : [];
return list
.filter((c) => c?.widoczny !== false)
.map((c, idx) => ({
id: String(c?.id ?? c?.nazwa ?? idx),
name: c?.nazwa ?? "—",
price_monthly: Number(c?.cena?.wartosc ?? 0),
features: (Array.isArray(c?.parametry) ? c.parametry : []).map((p) => ({
label: p.label,
value: p.value,
})),
}));
}
/** dekodery z YAML */
function normalizeDecoders(list) {
const arr = Array.isArray(list) ? list : [];
return arr
.filter((d) => d?.id && d?.nazwa)
.map((d) => ({
id: String(d.id),
nazwa: String(d.nazwa),
cena: Number(d.cena ?? 0),
}));
}
/** dodatki z YAML (tv-addons.yaml / addons.yaml) */
function normalizeAddons(addons) {
const list = Array.isArray(addons) ? addons : [];
return list
.filter((a) => a?.id && a?.nazwa)
.map((a) => ({
id: String(a.id),
nazwa: String(a.nazwa),
typ: String(a.typ ?? a.type ?? "checkbox"),
ilosc: !!a.ilosc,
min: a.min != null ? Number(a.min) : 0,
max: a.max != null ? Number(a.max) : 10,
krok: a.krok != null ? Number(a.krok) : 1,
opis: a.opis ? String(a.opis) : "",
// addons.yaml -> number albo {default, by_name}
// tv-addons.yaml -> [{pakiety, 12m, bezterminowo}]
cena: a.cena ?? 0,
}));
}
function normKey(s) {
return String(s || "")
.trim()
.toLowerCase()
.replace(/\s+/g, " ");
}
/** TV: wybór wariantu ceny po pkg.name, albo fallback "*" */
function pickTvVariant(addon, pkgName) {
const c = addon?.cena;
if (!Array.isArray(c)) return null;
const wanted = normKey(pkgName);
// 1) po nazwie pakietu
for (const row of c) {
const pk = row?.pakiety;
if (!Array.isArray(pk)) continue;
if (pk.some((p) => normKey(p) === wanted)) return row;
}
// 2) fallback "*"
for (const row of c) {
const pk = row?.pakiety;
if (!Array.isArray(pk)) continue;
if (pk.some((p) => String(p).trim() === "*")) return row;
}
return null;
}
/** TV: czy addon w ogóle dostępny dla pakietu */
function isTvAddonAvailableForPkg(addon, pkg) {
if (!pkg) return false;
const v = pickTvVariant(addon, String(pkg?.name ?? ""));
return !!v;
}
/** TV: czy ma dwie ceny (12m/bezterminowo) */
function hasTvTermPricing(addon, pkg) {
const c = addon?.cena;
if (!Array.isArray(c)) return false;
// sprawdzamy wariant dla konkretnego pakietu (bo może się różnić)
const v = pickTvVariant(addon, String(pkg?.name ?? ""));
if (!v || typeof v !== "object") return false;
// ✅ radio tylko jeśli są OBIE ceny
return v["12m"] != null && v.bezterminowo != null;
}
/**
* ✅ cena jednostkowa:
* - addons.yaml: number / string / legacy {default, by_name}
* - tv-addons.yaml: tablica wariantów
*/
function getAddonUnitPrice(addon, pkg, term /* "12m"|"bezterminowo"|null */) {
const c = addon?.cena;
// addons.yaml: liczba / liczba jako string
if (typeof c === "number") return c;
if (typeof c === "string" && c.trim() !== "" && !Number.isNaN(Number(c))) return Number(c);
// tv-addons.yaml: tablica wariantów [{pakiety, 12m, bezterminowo}]
if (Array.isArray(c)) {
const v = pickTvVariant(addon, String(pkg?.name ?? ""));
if (!v) return 0;
const t = term || "12m";
if (v[t] != null) return Number(v[t]) || 0;
// fallback
if (v.bezterminowo != null) return Number(v.bezterminowo) || 0;
if (v["12m"] != null) return Number(v["12m"]) || 0;
return 0;
}
// ✅ LEGACY: addons.yaml może mieć cenę zależną od pakietu:
// cena: { default: 19.9, by_name: { "Smart": 15.0, ... } }
if (c && typeof c === "object") {
const name = String(pkg?.name ?? "");
const wanted = normKey(name);
const byName = c.by_name || c.byName || c.by_nazwa || c.byNazwa;
if (byName && typeof byName === "object" && name) {
for (const k of Object.keys(byName)) {
if (normKey(k) === wanted) return Number(byName[k]) || 0;
}
}
if (c.default != null) return Number(c.default) || 0;
}
return 0;
}
export default function JamboxAddonsModal({
isOpen,
onClose,
pkg,
// ✅ YAML
phoneCards = [],
tvAddons = [],
addons = [],
decoders = [],
cenaOpis = "zł/mies.",
}) {
const phonePlans = useMemo(() => mapPhoneYamlToPlans(phoneCards), [phoneCards]);
const tvAddonsList = useMemo(() => normalizeAddons(tvAddons), [tvAddons]);
const addonsList = useMemo(() => normalizeAddons(addons), [addons]);
const decodersList = useMemo(() => normalizeDecoders(decoders), [decoders]);
// ✅ TV: pokazujemy tylko dostępne dla pkg.name
const tvAddonsVisible = useMemo(() => {
if (!pkg) return [];
return tvAddonsList.filter((a) => isTvAddonAvailableForPkg(a, pkg));
}, [tvAddonsList, pkg]);
// wybory
const [selectedPhoneId, setSelectedPhoneId] = useState(null);
const [openPhoneId, setOpenPhoneId] = useState(null);
// dekoder (radio)
const [selectedDecoderId, setSelectedDecoderId] = useState(null);
// checkbox/quantity: { [id]: qty }
const [selectedQty, setSelectedQty] = useState({});
// ✅ TV: term per dodatek (12m / bezterminowo)
const [tvTerm, setTvTerm] = useState({}); // { [id]: "12m" | "bezterminowo" }
// akordeon pakietu bazowego
const [baseOpen, setBaseOpen] = useState(true);
// reset po otwarciu / zmianie pakietu
useEffect(() => {
if (!isOpen) return;
setSelectedPhoneId(null);
setOpenPhoneId(null);
setSelectedDecoderId(null);
setSelectedQty({});
setTvTerm({});
setBaseOpen(true);
const d0 =
(Array.isArray(decodersList) && decodersList.find((d) => Number(d.cena) === 0)) ||
(Array.isArray(decodersList) ? decodersList[0] : null);
setSelectedDecoderId(d0 ? String(d0.id) : null);
}, [isOpen, pkg?.id, decodersList]);
if (!isOpen || !pkg) return null;
const basePrice = Number(pkg.price_monthly || 0);
const phonePrice = useMemo(() => {
if (!selectedPhoneId) return 0;
const p = phonePlans.find((x) => String(x.id) === String(selectedPhoneId));
return Number(p?.price_monthly || 0);
}, [selectedPhoneId, phonePlans]);
const decoderPrice = useMemo(() => {
if (!selectedDecoderId) return 0;
const d = decodersList.find((x) => String(x.id) === String(selectedDecoderId));
return Number(d?.cena || 0);
}, [selectedDecoderId, decodersList]);
// ✅ TV: suma liczona tylko po widocznych (czyli dostępnych)
const tvAddonsPrice = useMemo(() => {
return tvAddonsVisible.reduce((sum, a) => {
const qty = Number(selectedQty[a.id] || 0);
if (qty <= 0) return sum;
const termPricing = hasTvTermPricing(a, pkg);
const term = tvTerm[a.id] || "12m";
const unit = getAddonUnitPrice(a, pkg, termPricing ? term : null);
return sum + qty * unit;
}, 0);
}, [selectedQty, tvAddonsVisible, tvTerm, pkg]);
// zwykłe dodatki (addons.yaml) stara logika (multiroom itp.)
const addonsOnlyPrice = useMemo(() => {
return addonsList.reduce((sum, a) => {
const qty = Number(selectedQty[a.id] || 0);
const unit = getAddonUnitPrice(a, pkg, null);
return sum + qty * unit;
}, 0);
}, [selectedQty, addonsList, pkg]);
const addonsPrice = tvAddonsPrice + addonsOnlyPrice;
const totalMonthly = basePrice + phonePrice + decoderPrice + addonsPrice;
const handlePhoneSelect = (id) => {
if (id === null) {
setSelectedPhoneId(null);
setOpenPhoneId(null);
return;
}
setSelectedPhoneId(id);
setOpenPhoneId((prev) => (String(prev) === String(id) ? null : id));
};
const toggleCheckboxAddon = (id) => {
setSelectedQty((prev) => {
const next = { ...prev };
next[id] = (next[id] || 0) > 0 ? 0 : 1;
return next;
});
};
const setQtyAddon = (id, qty, min, max) => {
const safe = Math.max(min, Math.min(max, qty));
setSelectedQty((prev) => ({ ...prev, [id]: safe }));
};
const renderAddonRow = (a, isTv = false) => {
const qty = Number(selectedQty[a.id] || 0);
const isQty = a.typ === "quantity" || a.ilosc === true;
// TV: term i cena
const termPricing = isTv && hasTvTermPricing(a, pkg);
const term = tvTerm[a.id] || "12m";
const unit = getAddonUnitPrice(a, pkg, termPricing ? term : null);
if (!isQty) {
return (
<label class="f-addon-item" key={(isTv ? "tv-" : "a-") + a.id}>
<div class="f-addon-checkbox">
<input
type="checkbox"
checked={qty > 0}
onChange={() => toggleCheckboxAddon(a.id)}
/>
</div>
<div class="f-addon-main">
<div class="f-addon-name">{a.nazwa}</div>
{a.opis && <div class="f-addon-desc">{a.opis}</div>}
{termPricing && (
<div class="mt-2 flex flex-wrap gap-3 text-sm" onClick={(e) => e.stopPropagation()}>
<label class="inline-flex items-center gap-2">
<input
type="radio"
name={`term-${a.id}`}
checked={(tvTerm[a.id] || "12m") === "12m"}
onChange={() => setTvTerm((p) => ({ ...p, [a.id]: "12m" }))}
/>
<span>12 miesięcy</span>
</label>
<label class="inline-flex items-center gap-2">
<input
type="radio"
name={`term-${a.id}`}
checked={(tvTerm[a.id] || "12m") === "bezterminowo"}
onChange={() => setTvTerm((p) => ({ ...p, [a.id]: "bezterminowo" }))}
/>
<span>Bezterminowo</span>
</label>
</div>
)}
</div>
<div class="f-addon-price">
{money(unit)} {cenaOpis}
</div>
</label>
);
}
const min = Number.isFinite(a.min) ? a.min : 0;
const max = Number.isFinite(a.max) ? a.max : 10;
const step = Number.isFinite(a.krok) ? a.krok : 1;
const lineTotal = qty * unit;
return (
<div class="f-addon-item f-addon-item--qty" key={(isTv ? "tvq-" : "aq-") + a.id}>
<div class="f-addon-checkbox" aria-hidden="true"></div>
<div class="f-addon-main">
<div class="f-addon-name">{a.nazwa}</div>
{a.opis && <div class="f-addon-desc">{a.opis}</div>}
</div>
<div class="f-addon-qty" onClick={(e) => e.stopPropagation()}>
<button
type="button"
class="btn btn-outline"
onClick={() => setQtyAddon(a.id, qty - step, min, max)}
disabled={qty <= min}
>
</button>
<span class="f-addon-qty-value">{qty}</span>
<button
type="button"
class="btn btn-outline"
onClick={() => setQtyAddon(a.id, qty + step, min, max)}
disabled={qty >= max}
>
+
</button>
</div>
<div class="f-addon-price">
<div>
{money(unit)} {cenaOpis}
</div>
<div class="f-addon-price-total">{qty > 0 ? `${money(lineTotal)} ${cenaOpis}` : "—"}</div>
</div>
</div>
);
};
return (
<div class="f-modal-overlay" onClick={onClose}>
<button
class="f-modal-close"
type="button"
aria-label="Zamknij"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
>
</button>
<div class="f-modal-panel f-modal-panel--compact" onClick={(e) => e.stopPropagation()}>
<div class="f-modal-inner">
<h2 class="f-modal-title">Konfiguracja usług dodatkowych</h2>
{/* PAKIET jako akordeon */}
<div class="f-modal-section">
<div class={`f-accordion-item ${baseOpen ? "is-open" : ""}`}>
<button
type="button"
class="f-accordion-header"
onClick={() => setBaseOpen((prev) => !prev)}
>
<span class="f-modal-phone-name">{pkg.name}</span>
<span class="f-modal-phone-price">
{money(basePrice)} {cenaOpis}
</span>
</button>
{baseOpen && pkg.features && pkg.features.length > 0 && (
<div class="f-accordion-body">
<ul class="f-card-features">
{pkg.features.map((f, idx) => (
<li class="f-card-row" key={idx}>
<span class="f-card-label">{f.label}</span>
<span class="f-card-value">{formatFeatureValue(f.value)}</span>
</li>
))}
</ul>
</div>
)}
</div>
</div>
{/* ✅ DEKODER (radio) — NAD TV ADDONS */}
<div class="f-modal-section">
<h3>Wybór dekodera</h3>
{decodersList.length === 0 ? (
<p>Brak dostępnych dekoderów.</p>
) : (
<div class="f-modal-phone-list f-accordion">
{decodersList.map((d) => {
const isSelected = String(selectedDecoderId) === String(d.id);
return (
<div class={`f-accordion-item ${isSelected ? "is-open" : ""}`} key={d.id}>
<button
type="button"
class="f-accordion-header"
onClick={() => setSelectedDecoderId(String(d.id))}
>
<span class="f-accordion-header-left">
<input
type="radio"
name="decoder"
checked={isSelected}
onChange={(e) => {
e.stopPropagation();
setSelectedDecoderId(String(d.id));
}}
onClick={(e) => e.stopPropagation()}
/>
<span class="f-modal-phone-name">{d.nazwa}</span>
</span>
<span class="f-modal-phone-price">
{money(d.cena)} {cenaOpis}
</span>
</button>
</div>
);
})}
</div>
)}
</div>
{/* TV ADDONS */}
<div class="f-modal-section">
<h3>Pakiety dodatkowe TV</h3>
{tvAddonsVisible.length === 0 ? (
<p>Brak pakietów dodatkowych TV.</p>
) : (
<div class="f-addon-list">{tvAddonsVisible.map((a) => renderAddonRow(a, true))}</div>
)}
</div>
{/* TELEFON */}
<div class="f-modal-section">
<h3>Usługa telefoniczna</h3>
{phonePlans.length === 0 ? (
<p>Brak dostępnych pakietów telefonicznych.</p>
) : (
<div class="f-modal-phone-list f-accordion">
<div class="f-accordion-item f-accordion-item--no-phone">
<button
type="button"
class="f-accordion-header"
onClick={() => handlePhoneSelect(null)}
>
<span class="f-accordion-header-left">
<input
type="radio"
name="phone-plan"
checked={selectedPhoneId === null}
onChange={(e) => {
e.stopPropagation();
handlePhoneSelect(null);
}}
onClick={(e) => e.stopPropagation()}
/>
<span class="f-modal-phone-name">Nie potrzebuję telefonu</span>
</span>
<span class="f-modal-phone-price">0,00 {cenaOpis}</span>
</button>
</div>
{phonePlans.map((p) => {
const isSelected = String(selectedPhoneId) === String(p.id);
const isOpen = String(openPhoneId) === String(p.id);
return (
<div class={`f-accordion-item ${isOpen ? "is-open" : ""}`} key={p.id}>
<button
type="button"
class="f-accordion-header"
onClick={() => handlePhoneSelect(p.id)}
>
<span class="f-accordion-header-left">
<input
type="radio"
name="phone-plan"
checked={isSelected}
onChange={(e) => {
e.stopPropagation();
handlePhoneSelect(p.id);
}}
onClick={(e) => e.stopPropagation()}
/>
<span class="f-modal-phone-name">{p.name}</span>
</span>
<span class="f-modal-phone-price">
{money(p.price_monthly)} {cenaOpis}
</span>
</button>
{isOpen && (
<div class="f-accordion-body">
{p.features?.length > 0 && (
<ul class="f-card-features">
{p.features
.filter(
(f) => !String(f.label || "").toLowerCase().includes("aktyw"),
)
.map((f, idx) => (
<li class="f-card-row" key={idx}>
<span class="f-card-label">{f.label}</span>
<span class="f-card-value">{formatFeatureValue(f.value)}</span>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
})}
</div>
)}
</div>
{/* DODATKI (addons.yaml) */}
<div class="f-modal-section">
<h3>Dodatkowe usługi</h3>
{addonsList.length === 0 ? (
<p>Brak usług dodatkowych.</p>
) : (
<div class="f-addon-list">{addonsList.map((a) => renderAddonRow(a, false))}</div>
)}
</div>
{/* PODSUMOWANIE */}
<div class="f-modal-section f-summary">
<h3>Podsumowanie miesięczne</h3>
<div class="f-summary-list">
<div class="f-summary-row">
<span>Pakiet</span>
<span>
{money(basePrice)} {cenaOpis}
</span>
</div>
<div class="f-summary-row">
<span>Telefon</span>
<span>{phonePrice ? `${money(phonePrice)} ${cenaOpis}` : "—"}</span>
</div>
<div class="f-summary-row">
<span>Dekoder</span>
<span>{decoderPrice ? `${money(decoderPrice)} ${cenaOpis}` : "—"}</span>
</div>
<div class="f-summary-row">
<span>Dodatki TV</span>
<span>{tvAddonsPrice ? `${money(tvAddonsPrice)} ${cenaOpis}` : "—"}</span>
</div>
<div class="f-summary-row">
<span>Dodatki</span>
<span>{addonsOnlyPrice ? `${money(addonsOnlyPrice)} ${cenaOpis}` : "—"}</span>
</div>
<div class="f-summary-total">
<span>Łącznie</span>
<span>
{money(totalMonthly)} {cenaOpis}
</span>
</div>
</div>
</div>
<div class="f-floating-total" onClick={(e) => e.stopPropagation()}>
<div class="f-floating-total-inner">
<span class="f-floating-total-label">Suma</span>
<span class="f-floating-total-value">
{money(totalMonthly)} {cenaOpis}
</span>
</div>
</div>
</div>
</div>
</div>
);
}