Kolejne zmiany,
This commit is contained in:
@@ -148,7 +148,7 @@ export default function InternetAddonsModal({
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div class="f-modal-inner">
|
||||
<h2 class="f-modal-title">Konfiguracja usług dodatkowych</h2>
|
||||
<h2 class="f-modal-title">{plan.name} — konfiguracja usług</h2>
|
||||
|
||||
{/* INTERNET (fiber) jako akordeon */}
|
||||
<div class="f-modal-section">
|
||||
|
||||
449
src/islands/Internet/InternetAddonsModalCompact.jsx
Normal file
449
src/islands/Internet/InternetAddonsModalCompact.jsx
Normal file
@@ -0,0 +1,449 @@
|
||||
import { useEffect, useMemo, useState } from "preact/hooks";
|
||||
import "../../styles/modal.css";
|
||||
import "../../styles/offers/offers-table.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(".", ",");
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapuje YAML telefonu (cards.yaml) na format używany w modalu:
|
||||
* { id, name, price_monthly, features: [{label, value}] }
|
||||
*/
|
||||
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,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Dodatki z YAML:
|
||||
* { id, nazwa, typ, ilosc, min, max, krok, opis, cena }
|
||||
*/
|
||||
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 || "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) : "",
|
||||
cena: Number(a.cena ?? 0),
|
||||
}));
|
||||
}
|
||||
|
||||
function SectionAccordion({ title, right, open, onToggle, children }) {
|
||||
return (
|
||||
<div class={`f-accordion-item f-section-acc ${open ? "is-open" : ""}`}>
|
||||
<button type="button" class="f-accordion-header" onClick={onToggle}>
|
||||
<span class="f-accordion-header-left">
|
||||
<span class="f-modal-phone-name">{title}</span>
|
||||
</span>
|
||||
<span class="f-accordion-header-right">
|
||||
{right}
|
||||
<span class="f-acc-chevron" aria-hidden="true">
|
||||
{open ? "▲" : "▼"}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open && <div class="f-accordion-body">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function InternetAddonsModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
plan,
|
||||
|
||||
phoneCards = [], // telefon/cards.yaml -> cards[]
|
||||
addons = [], // internet-swiatlowodowy/addons.yaml -> dodatki[]
|
||||
cenaOpis = "zł/mies.",
|
||||
}) {
|
||||
const phonePlans = useMemo(() => mapPhoneYamlToPlans(phoneCards), [phoneCards]);
|
||||
const addonsList = useMemo(() => normalizeAddons(addons), [addons]);
|
||||
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const [selectedPhoneId, setSelectedPhoneId] = useState(null);
|
||||
const [selectedQty, setSelectedQty] = useState({});
|
||||
|
||||
// ✅ sekcje jako akordeony (jedna otwarta naraz)
|
||||
const [openSections, setOpenSections] = useState({
|
||||
internet: true,
|
||||
phone: false,
|
||||
addons: false,
|
||||
summary: false,
|
||||
});
|
||||
|
||||
const toggleSection = (key) => {
|
||||
setOpenSections((prev) => {
|
||||
const nextOpen = !prev[key];
|
||||
return {
|
||||
internet: false,
|
||||
phone: false,
|
||||
addons: false,
|
||||
summary: false,
|
||||
[key]: nextOpen,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// reset wyborów po otwarciu / zmianie planu
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setError("");
|
||||
setSelectedPhoneId(null);
|
||||
setSelectedQty({});
|
||||
setOpenSections({ internet: true, phone: false, addons: false, summary: false });
|
||||
}, [isOpen, plan]);
|
||||
|
||||
if (!isOpen || !plan) return null;
|
||||
|
||||
const basePrice = Number(plan.price_monthly || 0);
|
||||
|
||||
const phonePrice = (() => {
|
||||
if (!selectedPhoneId) return 0;
|
||||
const p = phonePlans.find((p) => String(p.id) === String(selectedPhoneId));
|
||||
return Number(p?.price_monthly || 0);
|
||||
})();
|
||||
|
||||
const addonsPrice = addonsList.reduce((sum, a) => {
|
||||
const qty = Number(selectedQty[a.id] || 0);
|
||||
return sum + qty * Number(a.cena || 0);
|
||||
}, 0);
|
||||
|
||||
const totalMonthly = basePrice + phonePrice + addonsPrice;
|
||||
|
||||
const handlePhoneSelect = (id) => {
|
||||
if (id === null) {
|
||||
setSelectedPhoneId(null);
|
||||
return;
|
||||
}
|
||||
setSelectedPhoneId(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 }));
|
||||
};
|
||||
|
||||
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">{plan.name} — konfiguracja usług</h2>
|
||||
|
||||
{error && <p class="text-red-600">{error}</p>}
|
||||
|
||||
{/* ✅ INTERNET */}
|
||||
<div class="f-modal-section">
|
||||
<SectionAccordion
|
||||
title={plan.name}
|
||||
right={<span class="f-modal-phone-price">{money(basePrice)} {cenaOpis}</span>}
|
||||
open={openSections.internet}
|
||||
onToggle={() => toggleSection("internet")}
|
||||
>
|
||||
{plan.features?.length ? (
|
||||
<ul class="f-card-features">
|
||||
{plan.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>
|
||||
) : (
|
||||
<p class="opacity-80">Brak szczegółów.</p>
|
||||
)}
|
||||
</SectionAccordion>
|
||||
</div>
|
||||
|
||||
{/* ✅ TELEFON */}
|
||||
<div class="f-modal-section">
|
||||
<SectionAccordion
|
||||
title="Usługa telefoniczna"
|
||||
right={
|
||||
<span class="f-modal-phone-price">
|
||||
{phonePrice ? `${money(phonePrice)} ${cenaOpis}` : "—"}
|
||||
</span>
|
||||
}
|
||||
open={openSections.phone}
|
||||
onToggle={() => toggleSection("phone")}
|
||||
>
|
||||
{phonePlans.length === 0 ? (
|
||||
<p>Brak dostępnych pakietów telefonicznych.</p>
|
||||
) : (
|
||||
<div class="f-modal-phone-list f-accordion">
|
||||
{/* brak telefonu */}
|
||||
<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);
|
||||
return (
|
||||
<div class="f-accordion-item" 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>
|
||||
|
||||
{/* pokazuj parametry tylko dla wybranego (czytelniej) */}
|
||||
{isSelected && p.features?.length > 0 && (
|
||||
<div class="f-accordion-body">
|
||||
<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>
|
||||
)}
|
||||
</SectionAccordion>
|
||||
</div>
|
||||
|
||||
{/* ✅ DODATKI */}
|
||||
<div class="f-modal-section">
|
||||
<SectionAccordion
|
||||
title="Dodatkowe usługi"
|
||||
right={
|
||||
<span class="f-modal-phone-price">
|
||||
{addonsPrice ? `${money(addonsPrice)} ${cenaOpis}` : "—"}
|
||||
</span>
|
||||
}
|
||||
open={openSections.addons}
|
||||
onToggle={() => toggleSection("addons")}
|
||||
>
|
||||
{addonsList.length === 0 ? (
|
||||
<p>Brak dodatkowych usług.</p>
|
||||
) : (
|
||||
<div class="f-addon-list">
|
||||
{addonsList.map((a) => {
|
||||
const qty = Number(selectedQty[a.id] || 0);
|
||||
const isQty = a.typ === "quantity" || a.ilosc === true;
|
||||
|
||||
if (!isQty) {
|
||||
const checked = qty > 0;
|
||||
return (
|
||||
<label class="f-addon-item" key={a.id}>
|
||||
<div class="f-addon-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
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>}
|
||||
</div>
|
||||
|
||||
<div class="f-addon-price">
|
||||
{money(a.cena)} {cenaOpis}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// quantity
|
||||
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 * Number(a.cena || 0);
|
||||
|
||||
return (
|
||||
<div class="f-addon-item f-addon-item--qty" key={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(a.cena)} {cenaOpis}
|
||||
</div>
|
||||
<div class="f-addon-price-total">
|
||||
{qty > 0 ? `${money(lineTotal)} ${cenaOpis}` : "—"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</SectionAccordion>
|
||||
</div>
|
||||
|
||||
{/* ✅ PODSUMOWANIE */}
|
||||
<div class="f-modal-section">
|
||||
<SectionAccordion
|
||||
title="Podsumowanie miesięczne"
|
||||
right={<span class="f-modal-phone-price">{money(totalMonthly)} {cenaOpis}</span>}
|
||||
open={openSections.summary}
|
||||
onToggle={() => toggleSection("summary")}
|
||||
>
|
||||
<div class="f-summary">
|
||||
<div class="f-summary-list">
|
||||
<div class="f-summary-row">
|
||||
<span>Internet</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>Dodatki</span>
|
||||
<span>{addonsPrice ? `${money(addonsPrice)} ${cenaOpis}` : "—"}</span>
|
||||
</div>
|
||||
|
||||
<div class="f-summary-total">
|
||||
<span>Łącznie</span>
|
||||
<span>{money(totalMonthly)} {cenaOpis}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionAccordion>
|
||||
</div>
|
||||
|
||||
{/* ✅ zawsze widoczne w rogu */}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -30,7 +30,8 @@ function mapCardToPlan(card, match, labels, waluta) {
|
||||
features.push({ label: "Umowa", value: labels?.umowa || "—" });
|
||||
features.push({
|
||||
label: "Aktywacja",
|
||||
value: typeof match?.aktywacja === "number" ? formatMoney(match.aktywacja, waluta) : "—",
|
||||
value:
|
||||
typeof match?.aktywacja === "number" ? formatMoney(match.aktywacja, waluta) : "—",
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -50,7 +51,8 @@ function mapCardToPlan(card, match, labels, waluta) {
|
||||
* cenaOpis?: string,
|
||||
* phoneCards?: any[],
|
||||
* addons?: any[],
|
||||
* addonsCenaOpis?: string
|
||||
* addonsCenaOpis?: string,
|
||||
* switches?: any[] // ✅ NOWE: przełączniki z YAML
|
||||
* }} props
|
||||
*/
|
||||
export default function InternetCards({
|
||||
@@ -62,10 +64,11 @@ export default function InternetCards({
|
||||
phoneCards = [],
|
||||
addons = [],
|
||||
addonsCenaOpis = "zł/mies.",
|
||||
switches = [], // ✅ NOWE
|
||||
}) {
|
||||
const visibleCards = Array.isArray(cards) ? cards : [];
|
||||
|
||||
// switch state (z /api/switches)
|
||||
// switch state (teraz idzie z OffersSwitches na podstawie YAML)
|
||||
const [selected, setSelected] = useState({});
|
||||
const [labels, setLabels] = useState({});
|
||||
|
||||
@@ -100,7 +103,8 @@ export default function InternetCards({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<OffersSwitches />
|
||||
{/* ✅ TERAZ switcher dostaje dane z YAML */}
|
||||
<OffersSwitches switches={switches} />
|
||||
|
||||
{visibleCards.length === 0 ? (
|
||||
<p class="opacity-80">Brak dostępnych pakietów.</p>
|
||||
|
||||
@@ -1,170 +1,79 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { useEffect, useMemo, useState } from "preact/hooks";
|
||||
|
||||
function buildLabels(switches, selected) {
|
||||
const out = {};
|
||||
for (const sw of switches || []) {
|
||||
const currentId = selected[sw.id];
|
||||
const opt = sw.opcje?.find((op) => String(op.id) === String(currentId));
|
||||
const currentId = selected?.[sw.id];
|
||||
const opt = sw?.opcje?.find((op) => String(op.id) === String(currentId));
|
||||
if (opt) out[sw.id] = opt.nazwa;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export default function OffersSwitches(props) {
|
||||
const { switches, selected, onSwitch } = props || {};
|
||||
|
||||
const isControlled =
|
||||
Array.isArray(switches) &&
|
||||
switches.length > 0 &&
|
||||
typeof onSwitch === "function";
|
||||
|
||||
const [autoSwitches, setAutoSwitches] = useState([]);
|
||||
const [autoSelected, setAutoSelected] = useState({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (isControlled) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/switches");
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
|
||||
const json = await res.json();
|
||||
const sws = Array.isArray(json.data) ? json.data : [];
|
||||
if (cancelled) return;
|
||||
|
||||
const initial = {};
|
||||
for (const sw of sws) {
|
||||
if (sw.domyslny != null) initial[sw.id] = sw.domyslny;
|
||||
else if (sw.opcje?.length) initial[sw.id] = sw.opcje[0].id;
|
||||
}
|
||||
|
||||
const labels = buildLabels(sws, initial);
|
||||
|
||||
setAutoSwitches(sws);
|
||||
setAutoSelected(initial);
|
||||
|
||||
window.fuzSwitchState = {
|
||||
selected: initial,
|
||||
labels,
|
||||
};
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("fuz:switch-change", {
|
||||
detail: {
|
||||
id: null,
|
||||
value: null,
|
||||
selected: initial,
|
||||
labels,
|
||||
},
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Błąd pobierania przełączników:", err);
|
||||
if (!cancelled) setError("Nie udało się załadować przełączników.");
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
export default function OffersSwitches({ switches = [] }) {
|
||||
const initialSelected = useMemo(() => {
|
||||
const init = {};
|
||||
for (const sw of switches) {
|
||||
if (!sw?.id) continue;
|
||||
if (sw.domyslny != null) init[sw.id] = sw.domyslny;
|
||||
else if (sw.opcje?.length) init[sw.id] = sw.opcje[0].id;
|
||||
}
|
||||
return init;
|
||||
}, [switches]);
|
||||
|
||||
load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isControlled]);
|
||||
|
||||
const effectiveSwitches = isControlled ? switches : autoSwitches;
|
||||
const effectiveSelected = isControlled ? selected || {} : autoSelected;
|
||||
|
||||
const handleClick = (id, value) => {
|
||||
if (isControlled) {
|
||||
onSwitch(id, value);
|
||||
} else {
|
||||
setAutoSelected((prev) => {
|
||||
const next = { ...prev, [id]: value };
|
||||
const labels = buildLabels(autoSwitches, next);
|
||||
|
||||
window.fuzSwitchState = {
|
||||
selected: next,
|
||||
labels,
|
||||
};
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("fuz:switch-change", {
|
||||
detail: {
|
||||
id,
|
||||
value,
|
||||
selected: next,
|
||||
labels,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
const [selected, setSelected] = useState(initialSelected);
|
||||
|
||||
// gdy switches się zmienią (np. hot reload) – zresetuj sensownie
|
||||
useEffect(() => {
|
||||
if (!isControlled) return;
|
||||
if (!Array.isArray(switches) || !switches.length) return;
|
||||
setSelected(initialSelected);
|
||||
}, [initialSelected]);
|
||||
|
||||
const safeSelected = selected || {};
|
||||
const labels = buildLabels(switches, safeSelected);
|
||||
// globalny stan + event (tak jak masz teraz)
|
||||
useEffect(() => {
|
||||
const labels = buildLabels(switches, selected);
|
||||
|
||||
window.fuzSwitchState = {
|
||||
selected: safeSelected,
|
||||
labels,
|
||||
};
|
||||
window.fuzSwitchState = { selected, labels };
|
||||
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("fuz:switch-change", {
|
||||
detail: {
|
||||
id: null,
|
||||
value: null,
|
||||
selected: safeSelected,
|
||||
labels,
|
||||
},
|
||||
detail: { id: null, value: null, selected, labels },
|
||||
}),
|
||||
);
|
||||
}, [isControlled, switches, selected]);
|
||||
}, [switches, selected]);
|
||||
|
||||
if (!isControlled && loading) {
|
||||
return (
|
||||
<div class="f-switches-wrapper">
|
||||
<p>Ładowanie opcji przełączników...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const handleClick = (id, value) => {
|
||||
setSelected((prev) => {
|
||||
const next = { ...prev, [id]: value };
|
||||
const labels = buildLabels(switches, next);
|
||||
|
||||
if (!isControlled && error) {
|
||||
return (
|
||||
<div class="f-switches-wrapper">
|
||||
<p class="text-red-600">{error}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
window.fuzSwitchState = { selected: next, labels };
|
||||
|
||||
if (!effectiveSwitches.length) return null;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("fuz:switch-change", {
|
||||
detail: { id, value, selected: next, labels },
|
||||
}),
|
||||
);
|
||||
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
if (!Array.isArray(switches) || !switches.length) return null;
|
||||
|
||||
return (
|
||||
<div class="f-switches-wrapper">
|
||||
{effectiveSwitches.map((sw) => (
|
||||
<div class="f-switch-group">
|
||||
{sw.opcje.map((op) => (
|
||||
{switches.map((sw) => (
|
||||
<div class="f-switch-group" key={sw.id}>
|
||||
{/* (opcjonalnie) etykieta */}
|
||||
{/* <div class="f-switch-label">{sw.etykieta}</div> */}
|
||||
|
||||
{sw.opcje?.map((op) => (
|
||||
<button
|
||||
key={`${sw.id}:${op.id}`}
|
||||
type="button"
|
||||
class={`f-switch ${String(effectiveSelected[sw.id]) === String(op.id)
|
||||
? "active"
|
||||
: ""
|
||||
}`}
|
||||
class={`f-switch ${
|
||||
String(selected?.[sw.id]) === String(op.id) ? "active" : ""
|
||||
}`}
|
||||
onClick={() => handleClick(sw.id, op.id)}
|
||||
title={sw.title}
|
||||
>
|
||||
|
||||
704
src/islands/jambox/JamboxAddonsModalCompact.jsx
Normal file
704
src/islands/jambox/JamboxAddonsModalCompact.jsx
Normal file
@@ -0,0 +1,704 @@
|
||||
import { useEffect, useMemo, useState } from "preact/hooks";
|
||||
import "../../styles/modal.css";
|
||||
import "../../styles/offers/offers-table.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),
|
||||
opis: d.opis ? String(d.opis) : "",
|
||||
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) : "",
|
||||
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);
|
||||
|
||||
for (const row of c) {
|
||||
const pk = row?.pakiety;
|
||||
if (!Array.isArray(pk)) continue;
|
||||
if (pk.some((p) => normKey(p) === wanted)) return row;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function isTvAddonAvailableForPkg(addon, pkg) {
|
||||
if (!pkg) return false;
|
||||
const v = pickTvVariant(addon, String(pkg?.name ?? ""));
|
||||
return !!v;
|
||||
}
|
||||
|
||||
function hasTvTermPricing(addon, pkg) {
|
||||
const c = addon?.cena;
|
||||
if (!Array.isArray(c)) return false;
|
||||
|
||||
const v = pickTvVariant(addon, String(pkg?.name ?? ""));
|
||||
if (!v || typeof v !== "object") return false;
|
||||
|
||||
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;
|
||||
|
||||
if (typeof c === "number") return c;
|
||||
if (typeof c === "string" && c.trim() !== "" && !Number.isNaN(Number(c))) return Number(c);
|
||||
|
||||
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;
|
||||
|
||||
if (v.bezterminowo != null) return Number(v.bezterminowo) || 0;
|
||||
if (v["12m"] != null) return Number(v["12m"]) || 0;
|
||||
return 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;
|
||||
}
|
||||
|
||||
/** ✅ Sekcja-akordeon (jak w internet modal) */
|
||||
function SectionAccordion({ title, right, open, onToggle, children }) {
|
||||
return (
|
||||
<div class={`f-accordion-item f-section-acc ${open ? "is-open" : ""}`}>
|
||||
<button type="button" class="f-accordion-header" onClick={onToggle}>
|
||||
<span class="f-accordion-header-left">
|
||||
<span class="f-modal-phone-name">{title}</span>
|
||||
</span>
|
||||
<span class="f-accordion-header-right">
|
||||
{right}
|
||||
<span class="f-acc-chevron" aria-hidden="true">
|
||||
{open ? "▲" : "▼"}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open && <div class="f-accordion-body">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function JamboxAddonsModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
pkg,
|
||||
|
||||
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]);
|
||||
|
||||
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);
|
||||
|
||||
const [selectedDecoderId, setSelectedDecoderId] = useState(null);
|
||||
|
||||
const [selectedQty, setSelectedQty] = useState({});
|
||||
|
||||
const [tvTerm, setTvTerm] = useState({}); // { [id]: "12m" | "bezterminowo" }
|
||||
|
||||
// ✅ sekcje (jedna otwarta naraz)
|
||||
const [openSections, setOpenSections] = useState({
|
||||
base: true,
|
||||
decoder: false,
|
||||
tv: false,
|
||||
phone: false,
|
||||
addons: false,
|
||||
summary: false,
|
||||
});
|
||||
|
||||
const toggleSection = (key) => {
|
||||
setOpenSections((prev) => {
|
||||
const nextOpen = !prev[key];
|
||||
return {
|
||||
base: false,
|
||||
decoder: false,
|
||||
tv: false,
|
||||
phone: false,
|
||||
addons: false,
|
||||
summary: false,
|
||||
[key]: nextOpen,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// reset po otwarciu / zmianie pakietu
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
setSelectedPhoneId(null);
|
||||
setOpenPhoneId(null);
|
||||
setSelectedDecoderId(null);
|
||||
setSelectedQty({});
|
||||
setTvTerm({});
|
||||
|
||||
setOpenSections({
|
||||
base: true,
|
||||
decoder: false,
|
||||
tv: false,
|
||||
phone: false,
|
||||
addons: false,
|
||||
summary: false,
|
||||
});
|
||||
|
||||
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]);
|
||||
|
||||
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]);
|
||||
|
||||
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;
|
||||
|
||||
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">{pkg.name} — konfiguracja usług</h2>
|
||||
|
||||
{/* ✅ PAKIET (sekcja) */}
|
||||
<div class="f-modal-section">
|
||||
<SectionAccordion
|
||||
title={pkg.name}
|
||||
right={<span class="f-modal-phone-price">{money(basePrice)} {cenaOpis}</span>}
|
||||
open={openSections.base}
|
||||
onToggle={() => toggleSection("base")}
|
||||
>
|
||||
{pkg.features?.length ? (
|
||||
<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>
|
||||
) : (
|
||||
<p class="opacity-80">Brak szczegółów pakietu.</p>
|
||||
)}
|
||||
</SectionAccordion>
|
||||
</div>
|
||||
|
||||
{/* ✅ DEKODER (sekcja) */}
|
||||
<div class="f-modal-section">
|
||||
<SectionAccordion
|
||||
title="Wybór dekodera"
|
||||
right={
|
||||
<span class="f-modal-phone-price">
|
||||
{decoderPrice ? `${money(decoderPrice)} ${cenaOpis}` : "—"}
|
||||
</span>
|
||||
}
|
||||
open={openSections.decoder}
|
||||
onToggle={() => toggleSection("decoder")}
|
||||
>
|
||||
|
||||
{decodersList.length === 0 ? (
|
||||
<p>Brak dostępnych dekoderów.</p>
|
||||
) : (
|
||||
<div class="f-radio-list">
|
||||
{decodersList.map((d) => {
|
||||
const isSelected = String(selectedDecoderId) === String(d.id);
|
||||
|
||||
return (
|
||||
<label class={`f-radio-item ${isSelected ? "is-selected" : ""}`} key={d.id}>
|
||||
<div class="f-radio-check">
|
||||
<input
|
||||
type="radio"
|
||||
name="decoder"
|
||||
checked={isSelected}
|
||||
onChange={() => setSelectedDecoderId(String(d.id))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="f-radio-main">
|
||||
<div class="f-radio-name">{d.nazwa}</div>
|
||||
{d.opis && <div class="f-addon-desc">{d.opis}</div>}
|
||||
</div>
|
||||
|
||||
<div class="f-radio-price">
|
||||
{money(d.cena)} {cenaOpis}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
</SectionAccordion>
|
||||
</div>
|
||||
|
||||
{/* ✅ TV ADDONS (sekcja) */}
|
||||
<div class="f-modal-section">
|
||||
<SectionAccordion
|
||||
title="Pakiety dodatkowe TV"
|
||||
right={
|
||||
<span class="f-modal-phone-price">
|
||||
{tvAddonsPrice ? `${money(tvAddonsPrice)} ${cenaOpis}` : "—"}
|
||||
</span>
|
||||
}
|
||||
open={openSections.tv}
|
||||
onToggle={() => toggleSection("tv")}
|
||||
>
|
||||
{tvAddonsVisible.length === 0 ? (
|
||||
<p>Brak pakietów dodatkowych TV.</p>
|
||||
) : (
|
||||
<div class="f-addon-list">{tvAddonsVisible.map((a) => renderAddonRow(a, true))}</div>
|
||||
)}
|
||||
</SectionAccordion>
|
||||
</div>
|
||||
|
||||
{/* ✅ TELEFON (sekcja) */}
|
||||
<div class="f-modal-section">
|
||||
<SectionAccordion
|
||||
title="Usługa telefoniczna"
|
||||
right={
|
||||
<span class="f-modal-phone-price">
|
||||
{phonePrice ? `${money(phonePrice)} ${cenaOpis}` : "—"}
|
||||
</span>
|
||||
}
|
||||
open={openSections.phone}
|
||||
onToggle={() => toggleSection("phone")}
|
||||
>
|
||||
{phonePlans.length === 0 ? (
|
||||
<p>Brak dostępnych pakietów telefonicznych.</p>
|
||||
) : (
|
||||
<div class="f-radio-list">
|
||||
{/* brak telefonu */}
|
||||
<label class={`f-radio-item ${selectedPhoneId === null ? "is-selected" : ""}`}>
|
||||
<div class="f-radio-check">
|
||||
<input
|
||||
type="radio"
|
||||
name="phone-plan"
|
||||
checked={selectedPhoneId === null}
|
||||
onChange={() => handlePhoneSelect(null)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="f-radio-main">
|
||||
<div class="f-radio-name">Nie potrzebuję telefonu</div>
|
||||
</div>
|
||||
|
||||
<div class="f-radio-price">0,00 {cenaOpis}</div>
|
||||
</label>
|
||||
|
||||
{/* pakiety */}
|
||||
{phonePlans.map((p) => {
|
||||
const isSelected = String(selectedPhoneId) === String(p.id);
|
||||
|
||||
return (
|
||||
<div class="f-radio-block" key={p.id}>
|
||||
<label class={`f-radio-item ${isSelected ? "is-selected" : ""}`}>
|
||||
<div class="f-radio-check">
|
||||
<input
|
||||
type="radio"
|
||||
name="phone-plan"
|
||||
checked={isSelected}
|
||||
onChange={() => handlePhoneSelect(p.id)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="f-radio-main">
|
||||
<div class="f-radio-name">{p.name}</div>
|
||||
</div>
|
||||
|
||||
<div class="f-radio-price">
|
||||
{money(p.price_monthly)} {cenaOpis}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{/* ✅ detale ZAWSZE widoczne */}
|
||||
{p.features?.length > 0 && (
|
||||
<div class="f-radio-details">
|
||||
<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>
|
||||
|
||||
)}
|
||||
|
||||
</SectionAccordion>
|
||||
</div>
|
||||
|
||||
{/* ✅ DODATKI (sekcja) */}
|
||||
<div class="f-modal-section">
|
||||
<SectionAccordion
|
||||
title="Dodatkowe usługi"
|
||||
right={
|
||||
<span class="f-modal-phone-price">
|
||||
{addonsOnlyPrice ? `${money(addonsOnlyPrice)} ${cenaOpis}` : "—"}
|
||||
</span>
|
||||
}
|
||||
open={openSections.addons}
|
||||
onToggle={() => toggleSection("addons")}
|
||||
>
|
||||
{addonsList.length === 0 ? (
|
||||
<p>Brak usług dodatkowych.</p>
|
||||
) : (
|
||||
<div class="f-addon-list">{addonsList.map((a) => renderAddonRow(a, false))}</div>
|
||||
)}
|
||||
</SectionAccordion>
|
||||
</div>
|
||||
|
||||
{/* ✅ PODSUMOWANIE (sekcja) */}
|
||||
<div class="f-modal-section">
|
||||
<SectionAccordion
|
||||
title="Podsumowanie miesięczne"
|
||||
right={<span class="f-modal-phone-price">{money(totalMonthly)} {cenaOpis}</span>}
|
||||
open={openSections.summary}
|
||||
onToggle={() => toggleSection("summary")}
|
||||
>
|
||||
<div class="f-summary">
|
||||
<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>Dodatkowe usługi</span>
|
||||
<span>{addonsOnlyPrice ? `${money(addonsOnlyPrice)} ${cenaOpis}` : "—"}</span>
|
||||
</div>
|
||||
|
||||
<div class="f-summary-total">
|
||||
<span>Łącznie</span>
|
||||
<span>{money(totalMonthly)} {cenaOpis}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SectionAccordion>
|
||||
</div>
|
||||
|
||||
{/* ✅ pływająca suma jak w internecie */}
|
||||
{/* <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 class="f-floating-total" onClick={(e) => e.stopPropagation()}>
|
||||
<div class="f-floating-total-circle" role="status" aria-label="Cena miesięczna">
|
||||
<span class="f-floating-total-unit">
|
||||
Razem
|
||||
</span>
|
||||
<span class="f-floating-total-amount">
|
||||
{money(totalMonthly)}
|
||||
</span>
|
||||
<span class="f-floating-total-unit">
|
||||
{cenaOpis}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import "../../styles/offers/offers-table.css";
|
||||
|
||||
import OffersSwitches from "../OffersSwitches.jsx";
|
||||
import JamboxChannelsModal from "./JamboxChannelsModal.jsx";
|
||||
import JamboxAddonsModal from "./JamboxAddonsModal.jsx";
|
||||
import JamboxAddonsModal from "./JamboxAddonsModalCompact.jsx";
|
||||
import Markdown from "../Markdown.jsx";
|
||||
|
||||
function formatMoney(amount, currency = "PLN") {
|
||||
@@ -50,10 +50,8 @@ function toFeatureRows(params) {
|
||||
* tvAddons?: any[],
|
||||
* addons?: Addon[],
|
||||
* decoders?: Decoder[],
|
||||
*
|
||||
* addonsCenaOpis?: string,
|
||||
*
|
||||
* // ✅ NOWE
|
||||
* channels?: ChannelYaml[],
|
||||
* channels?: ChannelYaml[]
|
||||
* }} props
|
||||
*/
|
||||
@@ -70,6 +68,7 @@ export default function JamboxCards({
|
||||
addons = [],
|
||||
decoders = [],
|
||||
channels = [],
|
||||
switches = [],
|
||||
}) {
|
||||
const visibleCards = Array.isArray(cards) ? cards : [];
|
||||
const wsp = Array.isArray(internetWspolne) ? internetWspolne : [];
|
||||
@@ -110,7 +109,7 @@ export default function JamboxCards({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<OffersSwitches />
|
||||
<OffersSwitches switches={switches} />
|
||||
|
||||
{visibleCards.length === 0 ? (
|
||||
<p class="opacity-80">Brak pakietów do wyświetlenia.</p>
|
||||
|
||||
@@ -35,7 +35,7 @@ export default function JamboxChannelsModal({ isOpen, onClose, pkg }) {
|
||||
try {
|
||||
// ✅ NOWE API: po nazwie pakietu
|
||||
const params = new URLSearchParams({ package: String(pkg.name) });
|
||||
const res = await fetch(`/api/jambox/package-channels?${params.toString()}`);
|
||||
const res = await fetch(`/api/jambox/jambox-channels-package?${params.toString()}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
|
||||
const json = await res.json();
|
||||
|
||||
@@ -32,7 +32,7 @@ export default function JamboxChannelsSearch() {
|
||||
params.set("limit", "80");
|
||||
|
||||
const res = await fetch(
|
||||
`/api/jambox/channels-search?${params.toString()}`,
|
||||
`/api/jambox/jambox-channels-search?${params.toString()}`,
|
||||
{
|
||||
signal: ac.signal,
|
||||
headers: { Accept: "application/json" },
|
||||
@@ -45,7 +45,7 @@ export default function JamboxChannelsSearch() {
|
||||
setItems(Array.isArray(json.data) ? json.data : []);
|
||||
} catch (e) {
|
||||
if (e?.name !== "AbortError") {
|
||||
console.error("❌ channels search:", e);
|
||||
console.error("jambox-channels-search:", e);
|
||||
setErr("Błąd wyszukiwania.");
|
||||
}
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user