Rezygnacja z bazy, przeniesienie danych do plików yamla

This commit is contained in:
dm
2025-12-15 06:30:39 +01:00
parent 00d6a57d74
commit 0b6bbbdce7
55 changed files with 3558 additions and 1545 deletions

View File

@@ -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)} /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 /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)} /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)} /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)} /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)} /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>

View File

@@ -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}` : "—"}
</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>

View File

@@ -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 /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)} /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)} /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)} /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)} /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>

View File

@@ -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}` : "—"}
</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>

View File

@@ -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">

View File

@@ -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:&nbsp;
Dostępny w pakietach:&nbsp;
{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>

View 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>
);
}

View File

@@ -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} /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>