Rezygnacja z bazy, przeniesienie danych do plików yamla
This commit is contained in:
@@ -1,28 +1,109 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { useEffect, useMemo, useState } from "preact/hooks";
|
||||
import "../../styles/modal.css";
|
||||
import "../../styles/offers/offers-table.css";
|
||||
|
||||
export default function InternetAddonsModal({ isOpen, onClose, plan }) {
|
||||
const [phonePlans, setPhonePlans] = useState([]);
|
||||
const [addons, setAddons] = useState([]);
|
||||
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),
|
||||
}));
|
||||
}
|
||||
|
||||
export default function InternetAddonsModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
plan,
|
||||
|
||||
// ✅ nowe: z YAML
|
||||
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 [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const [selectedPhoneId, setSelectedPhoneId] = useState(null);
|
||||
const [selectedAddons, setSelectedAddons] = useState([]);
|
||||
|
||||
// który pakiet telefoniczny jest rozwinięty
|
||||
// zamiast selectedAddons (DB) -> mapka ilości
|
||||
// { public_ip: 1, ip_v4_extra: 3 }
|
||||
const [selectedQty, setSelectedQty] = useState({});
|
||||
|
||||
// akordeony
|
||||
const [openPhoneId, setOpenPhoneId] = useState(null);
|
||||
|
||||
// czy akordeon internetu (fiber) jest rozwinięty
|
||||
const [baseOpen, setBaseOpen] = useState(true);
|
||||
|
||||
const formatFeatureValue = (val) => {
|
||||
if (val === true || val === "true") return "✓";
|
||||
if (val === false || val === "false" || val == null) return "✕";
|
||||
return val;
|
||||
};
|
||||
// reset wyborów po otwarciu / zmianie planu
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setError("");
|
||||
setSelectedPhoneId(null);
|
||||
setSelectedQty({});
|
||||
setOpenPhoneId(null);
|
||||
setBaseOpen(true);
|
||||
}, [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) {
|
||||
@@ -32,98 +113,22 @@ export default function InternetAddonsModal({ isOpen, onClose, plan }) {
|
||||
}
|
||||
|
||||
setSelectedPhoneId(id);
|
||||
setOpenPhoneId((prev) => (prev === id ? null : id));
|
||||
setOpenPhoneId((prev) => (String(prev) === String(id) ? null : id));
|
||||
};
|
||||
|
||||
// reset wyborów po otwarciu nowego planu
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setSelectedPhoneId(null);
|
||||
setSelectedAddons([]);
|
||||
setOpenPhoneId(null);
|
||||
setBaseOpen(true);
|
||||
}, [isOpen, plan]);
|
||||
|
||||
// ładowanie danych
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
async function loadData() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
// telefon
|
||||
const phoneRes = await fetch("/api/phone/plans");
|
||||
if (!phoneRes.ok) throw new Error(`HTTP ${phoneRes.status} (phone)`);
|
||||
|
||||
const phoneJson = await phoneRes.json();
|
||||
const phoneData = Array.isArray(phoneJson.data) ? phoneJson.data : [];
|
||||
|
||||
// dodatki
|
||||
const addonsRes = await fetch("/api/internet/addons");
|
||||
if (!addonsRes.ok) throw new Error(`HTTP ${addonsRes.status} (addons)`);
|
||||
|
||||
const addonsJson = await addonsRes.json();
|
||||
const addonsData = Array.isArray(addonsJson.data) ? addonsJson.data : [];
|
||||
|
||||
if (!cancelled) {
|
||||
setPhonePlans(phoneData);
|
||||
setAddons(addonsData);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("❌ Błąd ładowania danych do InternetAddonsModal:", err);
|
||||
if (!cancelled) {
|
||||
setError("Nie udało się załadować danych dodatkowych usług.");
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadData();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen || !plan) return null;
|
||||
|
||||
const basePrice = plan.price_monthly || 0;
|
||||
|
||||
const phonePrice = (() => {
|
||||
if (!selectedPhoneId) return 0;
|
||||
const p = phonePlans.find((p) => p.id === selectedPhoneId);
|
||||
return p?.price_monthly || 0;
|
||||
})();
|
||||
|
||||
const addonsPrice = selectedAddons.reduce((sum, sel) => {
|
||||
const addon = addons.find((a) => a.id === sel.addonId);
|
||||
if (!addon) return sum;
|
||||
const opt = addon.options.find((o) => o.id === sel.optionId);
|
||||
if (!opt) return sum;
|
||||
return sum + (opt.price || 0);
|
||||
}, 0);
|
||||
|
||||
const totalMonthly = basePrice + phonePrice + addonsPrice;
|
||||
|
||||
const handleAddonToggle = (addonId, optionId) => {
|
||||
setSelectedAddons((prev) => {
|
||||
const exists = prev.some(
|
||||
(x) => x.addonId === addonId && x.optionId === optionId
|
||||
);
|
||||
if (exists) {
|
||||
return prev.filter(
|
||||
(x) => !(x.addonId === addonId && x.optionId === optionId)
|
||||
);
|
||||
} else {
|
||||
return [...prev, { addonId, optionId }];
|
||||
}
|
||||
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
|
||||
@@ -155,7 +160,7 @@ export default function InternetAddonsModal({ isOpen, onClose, plan }) {
|
||||
>
|
||||
<span class="f-modal-phone-name">{plan.name}</span>
|
||||
<span class="f-modal-phone-price">
|
||||
{basePrice.toFixed(2)} zł/mies.
|
||||
{money(basePrice)} {cenaOpis}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -176,188 +181,233 @@ export default function InternetAddonsModal({ isOpen, onClose, plan }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && <p>Ładowanie danych...</p>}
|
||||
{error && <p class="text-red-600">{error}</p>}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{/* Telefon */}
|
||||
<div class="f-modal-section">
|
||||
<h3>Usługa telefoniczna</h3>
|
||||
{/* 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">
|
||||
{/* brak telefonu */}
|
||||
<div class="f-accordion-item f-accordion-item--no-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);
|
||||
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(null)}
|
||||
onClick={() => handlePhoneSelect(p.id)}
|
||||
>
|
||||
<span class="f-accordion-header-left">
|
||||
<input
|
||||
type="radio"
|
||||
name="phone-plan"
|
||||
checked={selectedPhoneId === null}
|
||||
checked={isSelected}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePhoneSelect(null);
|
||||
handlePhoneSelect(p.id);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span class="f-modal-phone-name">
|
||||
Nie potrzebuję telefonu
|
||||
</span>
|
||||
<span class="f-modal-phone-name">{p.name}</span>
|
||||
</span>
|
||||
|
||||
<span class="f-modal-phone-price">
|
||||
{money(p.price_monthly)} {cenaOpis}
|
||||
</span>
|
||||
<span class="f-modal-phone-price">0,00 zł/mies.</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* lista pakietów telefonu */}
|
||||
{phonePlans.map((p) => {
|
||||
const isSelected = selectedPhoneId === p.id;
|
||||
const isOpen = openPhoneId === 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">
|
||||
{p.price_monthly.toFixed(2)} zł/mies.
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div class="f-accordion-body">
|
||||
{p.features && 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">{f.value}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div class="f-accordion-body">
|
||||
{p.features && 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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dodatki internetowe */}
|
||||
<div class="f-modal-section">
|
||||
<h3>Dodatkowe usługi</h3>
|
||||
|
||||
{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}>
|
||||
{/* slot na checkbox (dla wyrównania kolumn) */}
|
||||
<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>
|
||||
|
||||
{/* licznik ilości bliżej prawej */}
|
||||
<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>
|
||||
|
||||
{/* cena po prawej + suma pod spodem */}
|
||||
<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>
|
||||
)}
|
||||
</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>Internet</span>
|
||||
<span>{money(basePrice)} {cenaOpis}</span>
|
||||
</div>
|
||||
|
||||
{/* Dodatki internetowe */}
|
||||
<div class="f-modal-section">
|
||||
<h3>Dodatkowe usługi</h3>
|
||||
|
||||
{addons.length === 0 ? (
|
||||
<p>Brak dodatkowych usług.</p>
|
||||
) : (
|
||||
<div class="f-addon-list">
|
||||
{addons.map((addon) =>
|
||||
addon.options.map((opt) => {
|
||||
const checked = selectedAddons.some(
|
||||
(x) => x.addonId === addon.id && x.optionId === opt.id
|
||||
);
|
||||
|
||||
return (
|
||||
<label class="f-addon-item" key={`${addon.id}-${opt.id}`}>
|
||||
<div class="f-addon-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => handleAddonToggle(addon.id, opt.id)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="f-addon-main">
|
||||
<div class="f-addon-name">{addon.name}</div>
|
||||
{addon.description && (
|
||||
<div class="f-addon-desc">{addon.description}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="f-addon-price">
|
||||
{opt.price.toFixed(2)} {opt.currency}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div class="f-summary-row">
|
||||
<span>Telefon</span>
|
||||
<span>{phonePrice ? `${money(phonePrice)} ${cenaOpis}` : "—"}</span>
|
||||
</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>Internet</span>
|
||||
<span>{basePrice.toFixed(2)} zł/mies.</span>
|
||||
</div>
|
||||
|
||||
<div class="f-summary-row">
|
||||
<span>Telefon</span>
|
||||
<span>
|
||||
{phonePrice ? `${phonePrice.toFixed(2)} zł/mies.` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="f-summary-row">
|
||||
<span>Dodatki</span>
|
||||
<span>
|
||||
{addonsPrice ? `${addonsPrice.toFixed(2)} zł/mies.` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="f-summary-total">
|
||||
<span>Łącznie</span>
|
||||
<span>{totalMonthly.toFixed(2)} zł/mies.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="f-summary-row">
|
||||
<span>Dodatki</span>
|
||||
<span>{addonsPrice ? `${money(addonsPrice)} ${cenaOpis}` : "—"}</span>
|
||||
</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">{totalMonthly.toFixed(2)} zł/mies.</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>
|
||||
|
||||
@@ -1,16 +1,75 @@
|
||||
//InternetCards.jsx
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import Markdown from "../Markdown.jsx";
|
||||
import OffersSwitches from "../OffersSwitches.jsx";
|
||||
import InternetAddonsModal from "./InternetAddonsModal.jsx";
|
||||
import "../../styles/offers/offers-table.css";
|
||||
|
||||
export default function InternetDbOffersCards({
|
||||
function formatMoney(amount, currency = "PLN") {
|
||||
if (typeof amount !== "number" || Number.isNaN(amount)) return "";
|
||||
try {
|
||||
return new Intl.NumberFormat("pl-PL", {
|
||||
style: "currency",
|
||||
currency,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
} catch {
|
||||
return String(amount);
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ mapper: InternetCard(YAML) + match + labels -> plan (dla modala)
|
||||
function mapCardToPlan(card, match, labels, waluta) {
|
||||
const baseParams = Array.isArray(card?.parametry) ? card.parametry : [];
|
||||
|
||||
const features = baseParams.map((p) => ({
|
||||
label: p.label,
|
||||
value: p.value,
|
||||
}));
|
||||
|
||||
// na końcu jak parametry:
|
||||
features.push({ label: "Umowa", value: labels?.umowa || "—" });
|
||||
features.push({
|
||||
label: "Aktywacja",
|
||||
value: typeof match?.aktywacja === "number" ? formatMoney(match.aktywacja, waluta) : "—",
|
||||
});
|
||||
|
||||
return {
|
||||
name: card?.nazwa || "—",
|
||||
price_monthly: typeof match?.miesiecznie === "number" ? match.miesiecznie : 0,
|
||||
price_installation: typeof match?.aktywacja === "number" ? match.aktywacja : 0,
|
||||
features,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* title?: string,
|
||||
* description?: string,
|
||||
* cards?: any[],
|
||||
* waluta?: string,
|
||||
* cenaOpis?: string,
|
||||
* phoneCards?: any[],
|
||||
* addons?: any[],
|
||||
* addonsCenaOpis?: string
|
||||
* }} props
|
||||
*/
|
||||
export default function InternetCards({
|
||||
title = "",
|
||||
description = "",
|
||||
cards = [],
|
||||
waluta = "PLN",
|
||||
cenaOpis = "zł/mies.",
|
||||
phoneCards = [],
|
||||
addons = [],
|
||||
addonsCenaOpis = "zł/mies.",
|
||||
}) {
|
||||
const visibleCards = Array.isArray(cards) ? cards : [];
|
||||
|
||||
// switch state (z /api/switches)
|
||||
const [selected, setSelected] = useState({});
|
||||
const [labels, setLabels] = useState({});
|
||||
const [plans, setPlans] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// modal
|
||||
const [addonsModalOpen, setAddonsModalOpen] = useState(false);
|
||||
const [activePlan, setActivePlan] = useState(null);
|
||||
|
||||
@@ -22,75 +81,40 @@ export default function InternetDbOffersCards({
|
||||
}
|
||||
|
||||
function handler(e) {
|
||||
const detail = e.detail || {};
|
||||
if (detail.selected) {
|
||||
setSelected(detail.selected);
|
||||
}
|
||||
if (detail.labels) {
|
||||
setLabels(detail.labels);
|
||||
}
|
||||
const detail = e?.detail || {};
|
||||
if (detail.selected) setSelected(detail.selected);
|
||||
if (detail.labels) setLabels(detail.labels);
|
||||
}
|
||||
|
||||
window.addEventListener("fuz:switch-change", handler);
|
||||
return () => window.removeEventListener("fuz:switch-change", handler);
|
||||
}, []);
|
||||
|
||||
|
||||
const buildingCode = Number(selected.budynek) || 1;
|
||||
const contractCode = Number(selected.umowa) || 1;
|
||||
|
||||
useEffect(() => {
|
||||
if (!buildingCode || !contractCode) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
building: String(buildingCode),
|
||||
contract: String(contractCode),
|
||||
});
|
||||
|
||||
const res = await fetch(`/api/internet/plans?${params.toString()}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
|
||||
const json = await res.json();
|
||||
if (!cancelled) {
|
||||
setPlans(Array.isArray(json.data) ? json.data : []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Błąd pobierania planów internetu:", err);
|
||||
if (!cancelled) setError("Nie udało się załadować ofert.");
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [buildingCode, contractCode]);
|
||||
|
||||
const contractLabel = labels.umowa || "";
|
||||
|
||||
return (
|
||||
<section class="f-offers">
|
||||
{title && <h1 class="f-section-header">{title}</h1>}
|
||||
|
||||
{loading && <p>Ładowanie ofert...</p>}
|
||||
{error && <p class="text-red-600">{error}</p>}
|
||||
{description && (
|
||||
<div class="mb-4">
|
||||
<Markdown text={description} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && (
|
||||
<div class={`f-offers-grid f-count-${plans.length || 1}`}>
|
||||
{plans.map((plan) => (
|
||||
<OffersSwitches />
|
||||
|
||||
{visibleCards.length === 0 ? (
|
||||
<p class="opacity-80">Brak dostępnych pakietów.</p>
|
||||
) : (
|
||||
<div class={`f-offers-grid f-count-${visibleCards.length || 1}`}>
|
||||
{visibleCards.map((card) => (
|
||||
<OfferCard
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
contractLabel={contractLabel}
|
||||
onConfigureAddons={() => {
|
||||
key={card.nazwa}
|
||||
card={card}
|
||||
selected={selected}
|
||||
labels={labels}
|
||||
waluta={waluta}
|
||||
cenaOpis={cenaOpis}
|
||||
onConfigureAddons={(plan) => {
|
||||
setActivePlan(plan);
|
||||
setAddonsModalOpen(true);
|
||||
}}
|
||||
@@ -103,62 +127,77 @@ export default function InternetDbOffersCards({
|
||||
isOpen={addonsModalOpen}
|
||||
onClose={() => setAddonsModalOpen(false)}
|
||||
plan={activePlan}
|
||||
phoneCards={phoneCards}
|
||||
addons={addons}
|
||||
cenaOpis={addonsCenaOpis || cenaOpis}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function OfferCard({ plan, contractLabel, onConfigureAddons }) {
|
||||
const basePrice = plan.price_monthly;
|
||||
const installPrice = plan.price_installation;
|
||||
function OfferCard({ card, selected, labels, waluta, cenaOpis, onConfigureAddons }) {
|
||||
const baseParams = Array.isArray(card?.parametry) ? card.parametry : [];
|
||||
const ceny = Array.isArray(card?.ceny) ? card.ceny : [];
|
||||
|
||||
const featureRows = plan.features || [];
|
||||
const budynek = selected?.budynek;
|
||||
const umowa = selected?.umowa;
|
||||
|
||||
const effectiveContract = contractLabel || "—";
|
||||
const match = ceny.find(
|
||||
(c) => String(c?.budynek) === String(budynek) && String(c?.umowa) === String(umowa),
|
||||
);
|
||||
|
||||
const mies = match?.miesiecznie;
|
||||
const akt = match?.aktywacja;
|
||||
|
||||
// na końcu jako parametry
|
||||
const params = [
|
||||
...baseParams,
|
||||
{ klucz: "umowa", label: "Umowa", value: labels?.umowa || "—" },
|
||||
{
|
||||
klucz: "aktywacja",
|
||||
label: "Aktywacja",
|
||||
value: typeof akt === "number" ? formatMoney(akt, waluta) : "—",
|
||||
},
|
||||
];
|
||||
|
||||
const canConfigureAddons = !!match;
|
||||
|
||||
return (
|
||||
<div class={`f-card ${plan.popular ? "f-card-popular" : ""}`}>
|
||||
<div class={`f-card ${card.popularny ? "f-card-popular" : ""}`}>
|
||||
{card.popularny && <div class="f-card-badge">Najczęściej wybierany</div>}
|
||||
|
||||
<div class="f-card-header">
|
||||
<div class="f-card-name">{plan.name}</div>
|
||||
<div class="f-card-name">{card.nazwa}</div>
|
||||
|
||||
<div class="f-card-price">
|
||||
{basePrice != null ? `${basePrice} zł/mies.` : "—"}
|
||||
{typeof mies === "number" ? (
|
||||
<>
|
||||
{formatMoney(mies, waluta)} <span class="opacity-80">{cenaOpis}</span>
|
||||
</>
|
||||
) : (
|
||||
<span class="opacity-70">Wybierz opcje</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="f-card-features">
|
||||
{featureRows.map((f) => {
|
||||
let val = f.value;
|
||||
let display;
|
||||
|
||||
if (val === true || val === "true") display = "✓";
|
||||
else if (val === false || val === "false" || val == null) display = "✕";
|
||||
else display = val;
|
||||
|
||||
return (
|
||||
<li class="f-card-row">
|
||||
<span class="f-card-label">{f.label}</span>
|
||||
<span class="f-card-value">{display}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
<li class="f-card-row">
|
||||
<span class="f-card-label">Umowa</span>
|
||||
<span class="f-card-value">{effectiveContract}</span>
|
||||
</li>
|
||||
|
||||
<li class="f-card-row">
|
||||
<span class="f-card-label">Aktywacja</span>
|
||||
<span class="f-card-value">
|
||||
{installPrice != null ? `${installPrice} zł` : "—"}
|
||||
</span>
|
||||
</li>
|
||||
{params.map((p) => (
|
||||
<li class="f-card-row" key={p.klucz || p.label}>
|
||||
<span class="f-card-label">{p.label}</span>
|
||||
<span class="f-card-value">{p.value}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary mt-4"
|
||||
onClick={onConfigureAddons}
|
||||
disabled={!canConfigureAddons}
|
||||
onClick={() => {
|
||||
const plan = mapCardToPlan(card, match, labels, waluta);
|
||||
onConfigureAddons(plan);
|
||||
}}
|
||||
title={!canConfigureAddons ? "Wybierz typ budynku i umowę" : ""}
|
||||
>
|
||||
Skonfiguruj usługi dodatkowe
|
||||
</button>
|
||||
|
||||
@@ -2,28 +2,261 @@ import { useEffect, useMemo, useState } from "preact/hooks";
|
||||
import "../../styles/modal.css";
|
||||
import "../../styles/offers/offers-table.css";
|
||||
|
||||
export default function JamboxAddonsModal({ isOpen, onClose, pkg }) {
|
||||
const [phonePlans, setPhonePlans] = useState([]);
|
||||
const [addons, setAddons] = useState([]);
|
||||
function formatFeatureValue(val) {
|
||||
if (val === true || val === "true") return "✓";
|
||||
if (val === false || val === "false" || val == null) return "✕";
|
||||
return val;
|
||||
}
|
||||
|
||||
const [tvAddons, setTvAddons] = useState([]);
|
||||
const [selectedTvAddonTids, setSelectedTvAddonTids] = useState([]);
|
||||
function money(amount) {
|
||||
const n = Number(amount || 0);
|
||||
return n.toFixed(2).replace(".", ",");
|
||||
}
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
/** 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 [selectedAddonIds, setSelectedAddonIds] = useState([]);
|
||||
|
||||
// akordeony
|
||||
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);
|
||||
|
||||
const formatFeatureValue = (val) => {
|
||||
if (val === true || val === "true") return "✓";
|
||||
if (val === false || val === "false" || val == null) return "✕";
|
||||
return val;
|
||||
};
|
||||
// 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) {
|
||||
@@ -32,105 +265,121 @@ export default function JamboxAddonsModal({ isOpen, onClose, pkg }) {
|
||||
return;
|
||||
}
|
||||
setSelectedPhoneId(id);
|
||||
setOpenPhoneId((prev) => (prev === id ? null : id));
|
||||
setOpenPhoneId((prev) => (String(prev) === String(id) ? null : id));
|
||||
};
|
||||
|
||||
const toggleAddon = (addonId) => {
|
||||
setSelectedAddonIds((prev) =>
|
||||
prev.includes(addonId) ? prev.filter((x) => x !== addonId) : [...prev, addonId]
|
||||
);
|
||||
const toggleCheckboxAddon = (id) => {
|
||||
setSelectedQty((prev) => {
|
||||
const next = { ...prev };
|
||||
next[id] = (next[id] || 0) > 0 ? 0 : 1;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// reset po otwarciu / zmianie pakietu
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setSelectedPhoneId(null);
|
||||
setSelectedAddonIds([]);
|
||||
setOpenPhoneId(null);
|
||||
setBaseOpen(true);
|
||||
setError("");
|
||||
setSelectedTvAddonTids([]);
|
||||
}, [isOpen, pkg?.id]);
|
||||
const setQtyAddon = (id, qty, min, max) => {
|
||||
const safe = Math.max(min, Math.min(max, qty));
|
||||
setSelectedQty((prev) => ({ ...prev, [id]: safe }));
|
||||
};
|
||||
|
||||
// load danych
|
||||
useEffect(() => {
|
||||
if (!isOpen || !pkg?.id) return;
|
||||
const renderAddonRow = (a, isTv = false) => {
|
||||
const qty = Number(selectedQty[a.id] || 0);
|
||||
const isQty = a.typ === "quantity" || a.ilosc === true;
|
||||
|
||||
let cancelled = false;
|
||||
// TV: term i cena
|
||||
const termPricing = isTv && hasTvTermPricing(a, pkg);
|
||||
const term = tvTerm[a.id] || "12m";
|
||||
const unit = getAddonUnitPrice(a, pkg, termPricing ? term : null);
|
||||
|
||||
async function loadData() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
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>
|
||||
|
||||
try {
|
||||
// telefon
|
||||
const phoneRes = await fetch("/api/phone/plans");
|
||||
if (!phoneRes.ok) throw new Error(`HTTP ${phoneRes.status} (phone)`);
|
||||
const phoneJson = await phoneRes.json();
|
||||
const phoneData = Array.isArray(phoneJson.data) ? phoneJson.data : [];
|
||||
<div class="f-addon-main">
|
||||
<div class="f-addon-name">{a.nazwa}</div>
|
||||
{a.opis && <div class="f-addon-desc">{a.opis}</div>}
|
||||
|
||||
// dodatki JAMBOX (dla pakietu)
|
||||
const addonsRes = await fetch(`/api/jambox/addons?packageId=${pkg.id}`);
|
||||
if (!addonsRes.ok) throw new Error(`HTTP ${addonsRes.status} (addons)`);
|
||||
const addonsJson = await addonsRes.json();
|
||||
const addonsData = Array.isArray(addonsJson.data) ? addonsJson.data : [];
|
||||
{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>
|
||||
|
||||
// pakiety TV
|
||||
const tvRes = await fetch(`/api/jambox/tv-addons?packageId=${pkg.id}`);
|
||||
if (!tvRes.ok) throw new Error(`HTTP ${tvRes.status} (tv-addons)`);
|
||||
const tvJson = await tvRes.json();
|
||||
const tvData = Array.isArray(tvJson.data) ? tvJson.data : [];
|
||||
<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>
|
||||
|
||||
if (!cancelled) {
|
||||
setPhonePlans(phoneData);
|
||||
setAddons(addonsData);
|
||||
setTvAddons(tvData);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("❌ Błąd ładowania danych do JamboxAddonsModal:", err);
|
||||
if (!cancelled) setError("Nie udało się załadować danych dodatkowych usług.");
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
<div class="f-addon-price">
|
||||
{money(unit)} {cenaOpis}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
loadData();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isOpen, pkg?.id]);
|
||||
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;
|
||||
|
||||
if (!isOpen || !pkg) return null;
|
||||
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>
|
||||
|
||||
const basePrice = Number(pkg.price_monthly || 0);
|
||||
<div class="f-addon-main">
|
||||
<div class="f-addon-name">{a.nazwa}</div>
|
||||
{a.opis && <div class="f-addon-desc">{a.opis}</div>}
|
||||
</div>
|
||||
|
||||
const phonePrice = useMemo(() => {
|
||||
if (!selectedPhoneId) return 0;
|
||||
const p = phonePlans.find((x) => x.id === selectedPhoneId);
|
||||
return Number(p?.price_monthly || 0);
|
||||
}, [selectedPhoneId, phonePlans]);
|
||||
<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>
|
||||
|
||||
// backend może zwrócić { id, price } albo { addon_id, price }
|
||||
const addonsPrice = useMemo(() => {
|
||||
return selectedAddonIds.reduce((sum, addonId) => {
|
||||
const a = addons.find((x) => (x.id ?? x.addon_id) === addonId);
|
||||
return sum + Number(a?.price || 0);
|
||||
}, 0);
|
||||
}, [selectedAddonIds, addons]);
|
||||
<span class="f-addon-qty-value">{qty}</span>
|
||||
|
||||
const tvAddonsPrice = useMemo(() => {
|
||||
return selectedTvAddonTids.reduce((sum, tid) => {
|
||||
const a = tvAddons.find((x) => Number(x.tid) === Number(tid));
|
||||
return sum + Number(a?.price || 0);
|
||||
}, 0);
|
||||
}, [selectedTvAddonTids, tvAddons]);
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline"
|
||||
onClick={() => setQtyAddon(a.id, qty + step, min, max)}
|
||||
disabled={qty >= max}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
const totalMonthly = basePrice + phonePrice + addonsPrice + tvAddonsPrice;
|
||||
|
||||
const toggleTvAddon = (tid) => {
|
||||
const t = Number(tid);
|
||||
setSelectedTvAddonTids((prev) =>
|
||||
prev.includes(t) ? prev.filter((x) => x !== t) : [...prev, t]
|
||||
<div class="f-addon-price">
|
||||
<div>
|
||||
{money(unit)} {cenaOpis}
|
||||
</div>
|
||||
<div class="f-addon-price-total">{qty > 0 ? `${money(lineTotal)} ${cenaOpis}` : "—"}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -152,7 +401,7 @@ export default function JamboxAddonsModal({ isOpen, onClose, pkg }) {
|
||||
<div class="f-modal-inner">
|
||||
<h2 class="f-modal-title">Konfiguracja usług dodatkowych</h2>
|
||||
|
||||
{/* PAKIET JAMBOX jako akordeon */}
|
||||
{/* PAKIET jako akordeon */}
|
||||
<div class="f-modal-section">
|
||||
<div class={`f-accordion-item ${baseOpen ? "is-open" : ""}`}>
|
||||
<button
|
||||
@@ -162,7 +411,7 @@ export default function JamboxAddonsModal({ isOpen, onClose, pkg }) {
|
||||
>
|
||||
<span class="f-modal-phone-name">{pkg.name}</span>
|
||||
<span class="f-modal-phone-price">
|
||||
{basePrice ? `${basePrice.toFixed(2)} zł/mies.` : "—"}
|
||||
{money(basePrice)} {cenaOpis}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -181,214 +430,206 @@ export default function JamboxAddonsModal({ isOpen, onClose, pkg }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && <p>Ładowanie danych...</p>}
|
||||
{error && <p class="text-red-600">{error}</p>}
|
||||
{/* ✅ DEKODER (radio) — NAD TV ADDONS */}
|
||||
<div class="f-modal-section">
|
||||
<h3>Wybór dekodera</h3>
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{/* TV ADDONS */}
|
||||
<div class="f-modal-section">
|
||||
<h3>Pakiety dodatkowe TV</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);
|
||||
|
||||
{tvAddons.length === 0 ? (
|
||||
<p>Brak pakietów dodatkowych TV dla tego pakietu.</p>
|
||||
) : (
|
||||
<div class="f-addon-list">
|
||||
{tvAddons.map((a) => {
|
||||
const tid = Number(a.tid);
|
||||
const checked = selectedTvAddonTids.includes(tid);
|
||||
const priceNum = Number(a.price || 0);
|
||||
|
||||
return (
|
||||
<label class="f-addon-item" key={`tv-${tid}`}>
|
||||
<div class="f-addon-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleTvAddon(tid)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="f-addon-main">
|
||||
<div class="f-addon-name">{a.name}</div>
|
||||
<div class="f-addon-desc">{a.description}</div>
|
||||
</div>
|
||||
|
||||
<div class="f-addon-price">
|
||||
{Number.isFinite(priceNum) ? `${priceNum.toFixed(2)} zł/mies.` : "—"}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</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">
|
||||
{/* brak telefonu */}
|
||||
<div class="f-accordion-item f-accordion-item--no-phone">
|
||||
return (
|
||||
<div class={`f-accordion-item ${isSelected ? "is-open" : ""}`} key={d.id}>
|
||||
<button
|
||||
type="button"
|
||||
class="f-accordion-header"
|
||||
onClick={() => handlePhoneSelect(null)}
|
||||
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={selectedPhoneId === null}
|
||||
checked={isSelected}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePhoneSelect(null);
|
||||
handlePhoneSelect(p.id);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span class="f-modal-phone-name">Nie potrzebuję telefonu</span>
|
||||
<span class="f-modal-phone-name">{p.name}</span>
|
||||
</span>
|
||||
|
||||
<span class="f-modal-phone-price">
|
||||
{money(p.price_monthly)} {cenaOpis}
|
||||
</span>
|
||||
<span class="f-modal-phone-price">0,00 zł/mies.</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* pakiety telefonu */}
|
||||
{phonePlans.map((p) => {
|
||||
const isSelected = selectedPhoneId === p.id;
|
||||
const isOpen = openPhoneId === 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">
|
||||
{Number(p.price_monthly || 0).toFixed(2)} zł/mies.
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div class="f-accordion-body">
|
||||
{p.features && 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">{f.value}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
{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>
|
||||
)}
|
||||
</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>
|
||||
|
||||
{/* DODATKI JAMBOX */}
|
||||
<div class="f-modal-section">
|
||||
<h3>Dodatkowe usługi</h3>
|
||||
|
||||
{addons.length === 0 ? (
|
||||
<p>Brak usług dodatkowych dla tego pakietu.</p>
|
||||
) : (
|
||||
<div class="f-addon-list">
|
||||
{addons.map((a) => {
|
||||
const addonId = a.id ?? a.addon_id;
|
||||
const checked = selectedAddonIds.includes(addonId);
|
||||
const priceNum = Number(a.price || 0);
|
||||
|
||||
return (
|
||||
<label class="f-addon-item" key={addonId}>
|
||||
<div class="f-addon-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleAddon(addonId)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="f-addon-main">
|
||||
<div class="f-addon-name">{a.name}</div>
|
||||
{a.description && <div class="f-addon-desc">{a.description}</div>}
|
||||
</div>
|
||||
|
||||
<div class="f-addon-price">{priceNum.toFixed(2)} zł/mies.</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div class="f-summary-row">
|
||||
<span>Telefon</span>
|
||||
<span>{phonePrice ? `${money(phonePrice)} ${cenaOpis}` : "—"}</span>
|
||||
</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>{basePrice ? `${basePrice.toFixed(2)} zł/mies.` : "—"}</span>
|
||||
</div>
|
||||
|
||||
<div class="f-summary-row">
|
||||
<span>Pakiety TV</span>
|
||||
<span>{tvAddonsPrice ? `${tvAddonsPrice.toFixed(2)} zł/mies.` : "—"}</span>
|
||||
</div>
|
||||
|
||||
<div class="f-summary-row">
|
||||
<span>Telefon</span>
|
||||
<span>{phonePrice ? `${phonePrice.toFixed(2)} zł/mies.` : "—"}</span>
|
||||
</div>
|
||||
|
||||
<div class="f-summary-row">
|
||||
<span>Dodatki</span>
|
||||
<span>{addonsPrice ? `${addonsPrice.toFixed(2)} zł/mies.` : "—"}</span>
|
||||
</div>
|
||||
|
||||
<div class="f-summary-total">
|
||||
<span>Łącznie</span>
|
||||
<span>{totalMonthly.toFixed(2)} zł/mies.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="f-summary-row">
|
||||
<span>Dekoder</span>
|
||||
<span>{decoderPrice ? `${money(decoderPrice)} ${cenaOpis}` : "—"}</span>
|
||||
</div>
|
||||
|
||||
{/* FLOATING TOTAL (dymek jak czat) */}
|
||||
<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">{totalMonthly.toFixed(2)} zł/mies.</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>
|
||||
|
||||
@@ -1,184 +1,252 @@
|
||||
// src/islands/JamboxCards.jsx
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import "../../styles/offers/offers-table.css";
|
||||
|
||||
import OffersSwitches from "../OffersSwitches.jsx";
|
||||
import JamboxChannelsModal from "./JamboxChannelsModal.jsx";
|
||||
import JamboxAddonsModal from "./JamboxAddonsModal.jsx";
|
||||
import Markdown from "../Markdown.jsx";
|
||||
|
||||
export default function JamboxBasePackages({ source = "ALL" }) {
|
||||
function formatMoney(amount, currency = "PLN") {
|
||||
if (typeof amount !== "number" || Number.isNaN(amount)) return "";
|
||||
try {
|
||||
return new Intl.NumberFormat("pl-PL", {
|
||||
style: "currency",
|
||||
currency,
|
||||
maximumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
} catch {
|
||||
return String(amount);
|
||||
}
|
||||
}
|
||||
|
||||
function toFeatureRows(params) {
|
||||
const list = Array.isArray(params) ? params : [];
|
||||
return list.map((p) => ({ label: p.label, value: p.value }));
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {{ label: string, value: any, klucz?: string }} Param
|
||||
* @typedef {{ id?: any, tid?: any, source?: string, nazwa?: string, slug?: string, ceny?: any[], parametry?: any[] }} Card
|
||||
* @typedef {{ id?: any, nazwa?: string }} PhoneCard
|
||||
* @typedef {{ id?: any, nazwa?: string }} Addon
|
||||
* @typedef {{ id?: any, nazwa?: string }} Decoder
|
||||
*
|
||||
* @typedef {{
|
||||
* nazwa: string;
|
||||
* opis?: string;
|
||||
* image?: string;
|
||||
* pakiety?: string[];
|
||||
* }} ChannelYaml
|
||||
*
|
||||
* @param {{
|
||||
* title?: string,
|
||||
* description?: string,
|
||||
* cards?: Card[],
|
||||
* internetWspolne?: Param[],
|
||||
* waluta?: string,
|
||||
* cenaOpis?: string,
|
||||
*
|
||||
* phoneCards?: PhoneCard[],
|
||||
* tvAddons?: any[],
|
||||
* addons?: Addon[],
|
||||
* decoders?: Decoder[],
|
||||
*
|
||||
* addonsCenaOpis?: string,
|
||||
*
|
||||
* // ✅ NOWE
|
||||
* channels?: ChannelYaml[]
|
||||
* }} props
|
||||
*/
|
||||
export default function JamboxCards({
|
||||
title = "",
|
||||
description = "",
|
||||
cards = [],
|
||||
internetWspolne = [],
|
||||
waluta = "PLN",
|
||||
cenaOpis = "zł/mies.",
|
||||
|
||||
phoneCards = [],
|
||||
tvAddons = [],
|
||||
addons = [],
|
||||
decoders = [],
|
||||
channels = [],
|
||||
}) {
|
||||
const visibleCards = Array.isArray(cards) ? cards : [];
|
||||
const wsp = Array.isArray(internetWspolne) ? internetWspolne : [];
|
||||
|
||||
// stan switchera (window.fuzSwitchState + event)
|
||||
const [selected, setSelected] = useState({});
|
||||
const [packages, setPackages] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [labels, setLabels] = useState({});
|
||||
|
||||
// modale
|
||||
const [channelsModalOpen, setChannelsModalOpen] = useState(false);
|
||||
const [addonsModalOpen, setAddonsModalOpen] = useState(false);
|
||||
|
||||
const [activePackage, setActivePackage] = useState(null);
|
||||
const [activePkg, setActivePkg] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined" && window.fuzSwitchState) {
|
||||
const { selected: sel } = window.fuzSwitchState;
|
||||
const { selected: sel, labels: labs } = window.fuzSwitchState;
|
||||
if (sel) setSelected(sel);
|
||||
if (labs) setLabels(labs);
|
||||
}
|
||||
|
||||
function handler(e) {
|
||||
const detail = e.detail || {};
|
||||
const handler = (e) => {
|
||||
const detail = e?.detail || {};
|
||||
if (detail.selected) setSelected(detail.selected);
|
||||
}
|
||||
if (detail.labels) setLabels(detail.labels);
|
||||
};
|
||||
|
||||
window.addEventListener("fuz:switch-change", handler);
|
||||
return () => window.removeEventListener("fuz:switch-change", handler);
|
||||
}, []);
|
||||
|
||||
const buildingCode = Number(selected.budynek) || 1;
|
||||
const contractCode = Number(selected.umowa) || 1;
|
||||
|
||||
useEffect(() => {
|
||||
if (!buildingCode || !contractCode) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set("source", String(source || "ALL"));
|
||||
params.set("building", String(buildingCode));
|
||||
params.set("contract", String(contractCode));
|
||||
|
||||
const res = await fetch(`/api/jambox/base-packages?${params.toString()}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
|
||||
const json = await res.json();
|
||||
if (!cancelled) setPackages(Array.isArray(json.data) ? json.data : []);
|
||||
} catch (err) {
|
||||
console.error("Błąd pobierania pakietów JAMBOX:", err);
|
||||
if (!cancelled) setError("Nie udało się załadować pakietów JAMBOX.");
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [buildingCode, contractCode, source]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section class="f-offers">
|
||||
<p>Ładowanie pakietów...</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<section class="f-offers">
|
||||
<p class="text-red-600">{error}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!packages.length) {
|
||||
return (
|
||||
<section class="f-offers">
|
||||
<p>Brak pakietów do wyświetlenia.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section class="f-offers">
|
||||
<div class={`f-offers-grid f-count-${packages.length || 1}`}>
|
||||
{packages.map((pkg) => (
|
||||
<JamboxPackageCard
|
||||
key={pkg.id}
|
||||
pkg={pkg}
|
||||
onShowChannels={() => {
|
||||
setActivePackage(pkg);
|
||||
setChannelsModalOpen(true);
|
||||
}}
|
||||
onConfigureAddons={() => {
|
||||
setActivePackage(pkg);
|
||||
setAddonsModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{title && <h1 class="f-section-header">{title}</h1>}
|
||||
|
||||
{description && (
|
||||
<div class="mb-4">
|
||||
<Markdown text={description} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<OffersSwitches />
|
||||
|
||||
{visibleCards.length === 0 ? (
|
||||
<p class="opacity-80">Brak pakietów do wyświetlenia.</p>
|
||||
) : (
|
||||
<div class={`f-offers-grid f-count-${visibleCards.length || 1}`}>
|
||||
{visibleCards.map((card) => (
|
||||
<JamboxPackageCard
|
||||
key={card.id || card.nazwa}
|
||||
card={card}
|
||||
wsp={wsp}
|
||||
selected={selected}
|
||||
labels={labels}
|
||||
waluta={waluta}
|
||||
cenaOpis={cenaOpis}
|
||||
onShowChannels={(pkg) => {
|
||||
setActivePkg(pkg);
|
||||
setChannelsModalOpen(true);
|
||||
}}
|
||||
onConfigureAddons={(pkg) => {
|
||||
setActivePkg(pkg);
|
||||
setAddonsModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<JamboxChannelsModal
|
||||
isOpen={channelsModalOpen}
|
||||
onClose={() => setChannelsModalOpen(false)}
|
||||
pkg={activePackage}
|
||||
pkg={activePkg}
|
||||
allChannels={channels}
|
||||
/>
|
||||
|
||||
<JamboxAddonsModal
|
||||
isOpen={addonsModalOpen}
|
||||
onClose={() => setAddonsModalOpen(false)}
|
||||
pkg={activePackage}
|
||||
pkg={activePkg}
|
||||
phoneCards={phoneCards}
|
||||
tvAddons={tvAddons}
|
||||
addons={addons}
|
||||
decoders={decoders}
|
||||
cenaOpis={cenaOpis}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function JamboxPackageCard({ pkg, onShowChannels, onConfigureAddons }) {
|
||||
const basePrice = pkg.price_monthly;
|
||||
const installPrice = pkg.price_installation;
|
||||
function JamboxPackageCard({
|
||||
card,
|
||||
wsp,
|
||||
selected,
|
||||
labels,
|
||||
waluta,
|
||||
cenaOpis,
|
||||
onShowChannels,
|
||||
onConfigureAddons,
|
||||
}) {
|
||||
const baseParams = Array.isArray(card?.parametry) ? card.parametry : [];
|
||||
const ceny = Array.isArray(card?.ceny) ? card.ceny : [];
|
||||
|
||||
const featureRows = pkg.features || [];
|
||||
const hasPrice = basePrice != null;
|
||||
const budynek = selected?.budynek;
|
||||
const umowa = selected?.umowa;
|
||||
|
||||
const match = ceny.find(
|
||||
(c) => String(c?.budynek) === String(budynek) && String(c?.umowa) === String(umowa),
|
||||
);
|
||||
|
||||
const basePrice = match?.miesiecznie;
|
||||
const installPrice = match?.aktywacja;
|
||||
|
||||
const dynamicParams = [
|
||||
{ klucz: "umowa", label: "Umowa", value: labels?.umowa || "—" },
|
||||
{
|
||||
klucz: "aktywacja",
|
||||
label: "Aktywacja",
|
||||
value: typeof installPrice === "number" ? formatMoney(installPrice, waluta) : "—",
|
||||
},
|
||||
];
|
||||
|
||||
const mergedParams = [...(Array.isArray(wsp) ? wsp : []), ...baseParams, ...dynamicParams];
|
||||
|
||||
const pkgForModals = {
|
||||
id: card?.id,
|
||||
tid: card?.tid,
|
||||
source: card?.source,
|
||||
name: card?.nazwa,
|
||||
slug: card?.slug,
|
||||
price_monthly: typeof basePrice === "number" ? basePrice : null,
|
||||
price_installation: typeof installPrice === "number" ? installPrice : null,
|
||||
features: toFeatureRows(mergedParams),
|
||||
};
|
||||
|
||||
const hasPrice = typeof basePrice === "number";
|
||||
|
||||
return (
|
||||
<div class="f-card" id={`pkg-${pkg.id}`} data-pkgid={pkg.id}>
|
||||
<div class="f-card" id={`pkg-${card?.nazwa}`} data-pkg={card?.nazwa} >
|
||||
<div class="f-card-header">
|
||||
<div class="f-card-name">{pkg.name}</div>
|
||||
<div class="f-card-name">{card.nazwa}</div>
|
||||
|
||||
<div class="f-card-price">
|
||||
{hasPrice
|
||||
? `${basePrice} zł/mies.`
|
||||
: pkg.source === "PLUS"
|
||||
? "JAMBOX PLUS"
|
||||
: "JAMBOX EVIO"}
|
||||
{hasPrice ? (
|
||||
<>
|
||||
{formatMoney(basePrice, waluta)} <span class="opacity-80">{cenaOpis}</span>
|
||||
</>
|
||||
) : (
|
||||
<span class="opacity-70">Wybierz opcje</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="f-card-features">
|
||||
{featureRows.map((f, idx) => {
|
||||
let val = f.value;
|
||||
let display;
|
||||
|
||||
if (val === true || val === "true") display = "✓";
|
||||
else if (val === false || val === "false" || val == null) display = "✕";
|
||||
else display = val;
|
||||
|
||||
return (
|
||||
<li class="f-card-row" key={idx}>
|
||||
<span class="f-card-label">{f.label}</span>
|
||||
<span class="f-card-value">{display}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
||||
<li class="f-card-row">
|
||||
<span class="f-card-label">Aktywacja</span>
|
||||
<span class="f-card-value">
|
||||
{installPrice != null ? `${installPrice} zł` : "—"}
|
||||
</span>
|
||||
</li>
|
||||
{mergedParams.map((p, idx) => (
|
||||
<li class="f-card-row" key={p.klucz || p.label || idx}>
|
||||
<span class="f-card-label">{p.label}</span>
|
||||
<span class="f-card-value">{p.value}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<button type="button" class="btn btn-primary mt-2" onClick={onShowChannels}>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary mt-2"
|
||||
disabled={!hasPrice}
|
||||
onClick={() => onShowChannels(pkgForModals)}
|
||||
title={!hasPrice ? "Wybierz typ budynku i umowę" : ""}
|
||||
>
|
||||
Pokaż listę kanałów
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary mt-2"
|
||||
onClick={onConfigureAddons}
|
||||
disabled={!hasPrice}
|
||||
onClick={() => onConfigureAddons(pkgForModals)}
|
||||
title={!hasPrice ? "Wybierz typ budynku i umowę" : ""}
|
||||
>
|
||||
Skonfiguruj usługi dodatkowe
|
||||
</button>
|
||||
|
||||
@@ -22,7 +22,7 @@ export default function JamboxChannelsModal({ isOpen, onClose, pkg }) {
|
||||
}, [loading, error, filtered.length, channels.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !pkg?.id) return;
|
||||
if (!isOpen || !pkg?.name) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
@@ -33,12 +33,27 @@ export default function JamboxChannelsModal({ isOpen, onClose, pkg }) {
|
||||
setQuery("");
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({ packageId: String(pkg.id) });
|
||||
const res = await fetch(`/api/jambox/channels?${params.toString()}`);
|
||||
// ✅ NOWE API: po nazwie pakietu
|
||||
const params = new URLSearchParams({ package: String(pkg.name) });
|
||||
const res = await fetch(`/api/jambox/package-channels?${params.toString()}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
|
||||
const json = await res.json();
|
||||
if (!cancelled) setChannels(Array.isArray(json.data) ? json.data : []);
|
||||
if (!json?.ok) throw new Error(json?.error || "API_ERROR");
|
||||
|
||||
const list = Array.isArray(json.data) ? json.data : [];
|
||||
|
||||
// ✅ Normalizacja do UI (żeby reszta modala się nie sypała)
|
||||
// - number: nie ma w DB, więc dajemy null/"—"
|
||||
const normalized = list.map((ch, i) => ({
|
||||
name: ch?.name ?? "",
|
||||
description: ch?.description ?? "",
|
||||
logo_url: ch?.logo_url ?? "",
|
||||
number: ch?.number ?? "—",
|
||||
_key: `${ch?.name ?? "?"}-${i}`,
|
||||
}));
|
||||
|
||||
if (!cancelled) setChannels(normalized);
|
||||
} catch (err) {
|
||||
console.error("❌ Błąd pobierania listy kanałów:", err);
|
||||
if (!cancelled) setError("Nie udało się załadować listy kanałów.");
|
||||
@@ -51,7 +66,7 @@ export default function JamboxChannelsModal({ isOpen, onClose, pkg }) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isOpen, pkg?.id]);
|
||||
}, [isOpen, pkg?.name]);
|
||||
|
||||
if (!isOpen || !pkg) return null;
|
||||
|
||||
@@ -103,7 +118,6 @@ export default function JamboxChannelsModal({ isOpen, onClose, pkg }) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ✅ tu musi być __meta */}
|
||||
<div class="f-chsearch__meta">{meta}</div>
|
||||
</div>
|
||||
|
||||
@@ -119,7 +133,7 @@ export default function JamboxChannelsModal({ isOpen, onClose, pkg }) {
|
||||
{filtered.map((ch) => (
|
||||
<div
|
||||
class="jmb-channel-card"
|
||||
key={ch.number}
|
||||
key={ch._key}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
@@ -144,7 +158,8 @@ export default function JamboxChannelsModal({ isOpen, onClose, pkg }) {
|
||||
/>
|
||||
)}
|
||||
<div class="jmb-channel-name">{ch.name}</div>
|
||||
<div class="jmb-channel-number">kanał {ch.number}</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div class="jmb-channel-face jmb-channel-back">
|
||||
|
||||
@@ -58,23 +58,28 @@ export default function JamboxChannelsSearch() {
|
||||
|
||||
const meta = useMemo(() => {
|
||||
const qq = q.trim();
|
||||
if (qq.length === 0) return "";
|
||||
if (qq.length === 0) return "";
|
||||
// "Zacznij pisać, aby wyszukać"
|
||||
if (loading) return "Szukam…";
|
||||
if (err) return err;
|
||||
return `Znaleziono: ${items.length}`;
|
||||
}, [q, loading, err, items]);
|
||||
|
||||
function scrollToPackage(packageId) {
|
||||
const el = document.getElementById(`pkg-${packageId}`);
|
||||
if (!el) return;
|
||||
function scrollToPackage(packageName) {
|
||||
const key = String(packageName || "").trim();
|
||||
if (!key) return;
|
||||
|
||||
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
|
||||
el.classList.add("is-target");
|
||||
window.setTimeout(() => el.classList.remove("is-target"), 5400);
|
||||
const el = document.getElementById(`pkg-${key}`);
|
||||
if (!el) {
|
||||
console.warn("❌ Nie znaleziono pakietu w DOM:", `pkg-${key}`);
|
||||
return;
|
||||
}
|
||||
|
||||
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
el.classList.add("is-target");
|
||||
window.setTimeout(() => el.classList.remove("is-target"), 5400);
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="f-chsearch">
|
||||
<h1 class="f-section-title">Wyszukiwanie kanałów w pakietach telewizji</h1>
|
||||
@@ -137,20 +142,18 @@ export default function JamboxChannelsSearch() {
|
||||
|
||||
{Array.isArray(c.packages) && c.packages.length > 0 && (
|
||||
<div class="f-chsearch__packages">
|
||||
Dostępny w:
|
||||
Dostępny w pakietach:
|
||||
{c.packages.map((p, i) => (
|
||||
<span key={p.id}>
|
||||
<button
|
||||
type="button"
|
||||
class="f-chsearch__pkg"
|
||||
onClick={() => scrollToPackage(p.id)}
|
||||
class="f-chsearch-pkg"
|
||||
onClick={() => scrollToPackage(p.name)}
|
||||
>
|
||||
{p.name}
|
||||
</button>
|
||||
<span class="f-chsearch__pkgnum">
|
||||
{" "} (kanał {p.number})
|
||||
</span>
|
||||
{i < c.packages.length - 1 ? ", " : ""}
|
||||
|
||||
{/* {i < c.packages.length - 1 ? ", " : ""} */}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -159,7 +162,7 @@ export default function JamboxChannelsSearch() {
|
||||
</div>
|
||||
))}
|
||||
|
||||
{q.trim().length >= 2 && !loading && items.length === 0 && (
|
||||
{q.trim().length >= 1 && !loading && items.length === 0 && (
|
||||
<div class="f-chsearch-empty">
|
||||
Brak wyników dla: <strong>{q}</strong>
|
||||
</div>
|
||||
|
||||
200
src/islands/jambox/JamboxMozliwosciSearch.jsx
Normal file
200
src/islands/jambox/JamboxMozliwosciSearch.jsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import { useMemo, useState } from "preact/hooks";
|
||||
import { marked } from "marked";
|
||||
import "../../styles/channels-search.css";
|
||||
|
||||
function norm(s) {
|
||||
return String(s || "")
|
||||
.toLowerCase()
|
||||
.replace(/\u00a0/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function escapeRegExp(s) {
|
||||
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/** Podświetlenie w czystym tekście (np. title) */
|
||||
function highlightText(text, q) {
|
||||
const qq = (q || "").trim();
|
||||
if (!qq) return text;
|
||||
|
||||
const re = new RegExp(escapeRegExp(qq), "ig");
|
||||
const parts = String(text || "").split(re);
|
||||
|
||||
if (parts.length === 1) return text;
|
||||
|
||||
// split() gubi match — więc budujemy przez exec na oryginale
|
||||
const matches = String(text || "").match(re) || [];
|
||||
|
||||
const out = [];
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
out.push(parts[i]);
|
||||
if (i < matches.length) out.push(<mark class="f-hl">{matches[i]}</mark>);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Podświetlenie wewnątrz HTML (po markdown), omijamy PRE/CODE */
|
||||
function highlightHtml(html, q) {
|
||||
const qq = (q || "").trim();
|
||||
if (!qq) return html;
|
||||
|
||||
const re = new RegExp(escapeRegExp(qq), "ig");
|
||||
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
const root = doc.body;
|
||||
|
||||
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
|
||||
|
||||
const toSkip = (node) => {
|
||||
const p = node.parentElement;
|
||||
if (!p) return true;
|
||||
const tag = p.tagName;
|
||||
return tag === "SCRIPT" || tag === "STYLE" || tag === "CODE" || tag === "PRE";
|
||||
};
|
||||
|
||||
const nodes = [];
|
||||
let n;
|
||||
while ((n = walker.nextNode())) nodes.push(n);
|
||||
|
||||
for (const textNode of nodes) {
|
||||
if (toSkip(textNode)) continue;
|
||||
|
||||
const txt = textNode.nodeValue || "";
|
||||
if (!re.test(txt)) continue;
|
||||
|
||||
// reset RegExp state (bo test() z /g/ potrafi przesuwać lastIndex)
|
||||
re.lastIndex = 0;
|
||||
|
||||
const frag = doc.createDocumentFragment();
|
||||
let last = 0;
|
||||
let m;
|
||||
|
||||
while ((m = re.exec(txt))) {
|
||||
const start = m.index;
|
||||
const end = start + m[0].length;
|
||||
|
||||
if (start > last) frag.appendChild(doc.createTextNode(txt.slice(last, start)));
|
||||
|
||||
const mark = doc.createElement("mark");
|
||||
mark.className = "f-hl";
|
||||
mark.textContent = txt.slice(start, end);
|
||||
frag.appendChild(mark);
|
||||
|
||||
last = end;
|
||||
}
|
||||
|
||||
if (last < txt.length) frag.appendChild(doc.createTextNode(txt.slice(last)));
|
||||
|
||||
textNode.parentNode?.replaceChild(frag, textNode);
|
||||
}
|
||||
|
||||
return root.innerHTML;
|
||||
}
|
||||
|
||||
function HighlightedMarkdown({ text, q }) {
|
||||
const html = useMemo(() => {
|
||||
// markdown -> html
|
||||
const raw = marked.parse(String(text || ""), {
|
||||
gfm: true,
|
||||
breaks: true,
|
||||
headerIds: false,
|
||||
mangle: false,
|
||||
});
|
||||
|
||||
// highlight w HTML
|
||||
return highlightHtml(raw, q);
|
||||
}, [text, q]);
|
||||
|
||||
return (
|
||||
<div
|
||||
class="fuz-markdown"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function JamboxMozliwosciSearch({ items = [] }) {
|
||||
const [q, setQ] = useState("");
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const qq = norm(q);
|
||||
if (qq.length === 0) return items;
|
||||
return items.filter((it) => norm(`${it.title}\n${it.content}`).includes(qq));
|
||||
}, [items, q]);
|
||||
|
||||
const meta = useMemo(() => {
|
||||
const qq = q.trim();
|
||||
if (qq.length === 0) return "";
|
||||
return `Znaleziono: ${filtered.length} sekcje`;
|
||||
}, [q, filtered]);
|
||||
|
||||
return (
|
||||
<div class="f-chsearch">
|
||||
<div class="f-chsearch__top">
|
||||
<div class="f-chsearch__inputwrap">
|
||||
<input
|
||||
class="f-chsearch__input"
|
||||
type="search"
|
||||
value={q}
|
||||
onInput={(e) => setQ(e.currentTarget.value)}
|
||||
placeholder="Szukaj funkcji po nazwie lub opisie…"
|
||||
aria-label="Szukaj funkcji po nazwie lub opisie"
|
||||
/>
|
||||
|
||||
{q && (
|
||||
<button
|
||||
type="button"
|
||||
class="f-chsearch__clear"
|
||||
aria-label="Wyczyść wyszukiwanie"
|
||||
onClick={() => setQ("")}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="f-chsearch-meta">{meta}</div>
|
||||
</div>
|
||||
|
||||
{filtered.map((it, index) => {
|
||||
const reverse = index % 2 === 1;
|
||||
const imageUrl = it.image || "";
|
||||
const hasImage = !!imageUrl;
|
||||
|
||||
return (
|
||||
<section class="f-section" id={it.id} key={it.id}>
|
||||
<div class={`f-section-grid ${hasImage ? "md:grid-cols-2" : "md:grid-cols-1"}`}>
|
||||
{hasImage && (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={it.title}
|
||||
class={`f-section-image ${reverse ? "md:order-1" : "md:order-2"}`}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div class={`${hasImage ? (reverse ? "md:order-2" : "md:order-1") : ""}`}>
|
||||
<h2 class="f-section-title">{highlightText(it.title, q)}</h2>
|
||||
|
||||
<HighlightedMarkdown text={it.content} q={q} />
|
||||
|
||||
<div class="f-section-nav">
|
||||
<a href="#top" class="btn btn-outline">Do góry ↑</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
|
||||
{q.length > 0 && filtered.length === 0 && (
|
||||
<div class="f-chsearch-empty">
|
||||
Brak wyników dla: <strong>{q}</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +1,39 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import Markdown from "../../islands/Markdown.jsx";
|
||||
import "../../styles/offers/offers-table.css";
|
||||
|
||||
/**
|
||||
* @typedef {{ klucz: string, label: string, value: (string|number) }} PhoneParam
|
||||
* @typedef {{
|
||||
* nazwa: string,
|
||||
* widoczny?: boolean,
|
||||
* popularny?: boolean,
|
||||
* cena?: { wartosc: number, opis?: string },
|
||||
* parametry?: PhoneParam[]
|
||||
* }} PhoneCard
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {{ title?: string, description?: string, cards?: PhoneCard[] }} props
|
||||
*/
|
||||
export default function PhoneDbOffersCards({
|
||||
title = "Telefonia stacjonarna FUZ",
|
||||
title = "",
|
||||
description = "",
|
||||
cards = [],
|
||||
}) {
|
||||
const [plans, setPlans] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/phone/plans");
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
|
||||
const json = await res.json();
|
||||
if (!cancelled) {
|
||||
setPlans(Array.isArray(json.data) ? json.data : []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Błąd pobierania planów telefonii:", err);
|
||||
if (!cancelled) {
|
||||
setError("Nie udało się załadować pakietów telefonicznych.");
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
const visibleCards = Array.isArray(cards) ? cards : [];
|
||||
|
||||
return (
|
||||
<section class="f-offers">
|
||||
{loading && <p>Ładowanie pakietów telefonicznych...</p>}
|
||||
{error && <p class="text-red-600">{error}</p>}
|
||||
|
||||
{!loading && !error && (
|
||||
<div class={`f-offers-grid f-count-${plans.length || 1}`}>
|
||||
{plans.map((plan) => (
|
||||
<PhoneOfferCard key={plan.id} plan={plan} />
|
||||
{title && <h2 class="f-section-header">{title}</h2>}
|
||||
<div>
|
||||
<Markdown text={description} />
|
||||
</div>
|
||||
{visibleCards.length === 0 ? (
|
||||
<p class="opacity-80">Brak dostępnych pakietów.</p>
|
||||
) : (
|
||||
<div class={`f-offers-grid f-count-${visibleCards.length || 1}`}>
|
||||
{visibleCards.map((card) => (
|
||||
<PhoneOfferCard key={card.nazwa} card={card} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -54,21 +41,28 @@ export default function PhoneDbOffersCards({
|
||||
);
|
||||
}
|
||||
|
||||
function PhoneOfferCard({ plan }) {
|
||||
function PhoneOfferCard({ card }) {
|
||||
const price = card?.cena?.wartosc ?? "";
|
||||
const priceDesc = card?.cena?.opis ?? "zł/mies.";
|
||||
|
||||
const params = Array.isArray(card?.parametry) ? card.parametry : [];
|
||||
|
||||
return (
|
||||
<div class={`f-card ${plan.popular ? "f-card-popular" : ""}`}>
|
||||
{plan.popular && <div class="f-card-badge">Najczęściej wybierany</div>}
|
||||
<div class={`f-card ${card.popularny ? "f-card-popular" : ""}`}>
|
||||
{card.popularny && <div class="f-card-badge">Najczęściej wybierany</div>}
|
||||
|
||||
<div class="f-card-header">
|
||||
<div class="f-card-name">{plan.name}</div>
|
||||
<div class="f-card-price">{plan.price_monthly} zł/mies.</div>
|
||||
<div class="f-card-name">{card.nazwa}</div>
|
||||
<div class="f-card-price">
|
||||
{price.toFixed(2)} {priceDesc}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="f-card-features">
|
||||
{plan.features.map((f) => (
|
||||
<li class="f-card-row">
|
||||
<span class="f-card-label">{f.label}</span>
|
||||
<span class="f-card-value">{f.value}</span>
|
||||
{params.map((p) => (
|
||||
<li class="f-card-row" key={p.klucz || p.label}>
|
||||
<span class="f-card-label">{p.label}</span>
|
||||
<span class="f-card-value">{p.value}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
Reference in New Issue
Block a user