Dorabiamy funkcjonalnosci w TV

This commit is contained in:
dm
2025-12-12 14:58:13 +01:00
parent 0f17daee17
commit ee51e0816d
29 changed files with 1975 additions and 455 deletions

View File

@@ -1,18 +1,18 @@
sections:
- title: Dodatkowe możliwości naszej telewizji"
image: "ekosystem-kyanit.webp"
content: |
- **Catchup** — na wybranych kanałach możesz obejrzeć audycję z ostatnich 7 dni. [Więcej →](#catchup "Przeczytaj o usłudze CatchUp")
# - title: Dodatkowe możliwości naszej telewizji"
# image: "ekosystem-kyanit.webp"
# content: |
# - **Catchup** — na wybranych kanałach możesz obejrzeć audycję z ostatnich 7 dni. [Więcej →](#catchup "Przeczytaj o usłudze CatchUp")
- **Nagrywanie** — nagraj interesującą Cię audycję i obejrzyj ją kiedy chcesz. [Więcej →](#nagrywarka "Przeczytaj o nagrywaniu audycji")
# - **Nagrywanie** — nagraj interesującą Cię audycję i obejrzyj ją kiedy chcesz. [Więcej →](#nagrywarka "Przeczytaj o nagrywaniu audycji")
- **StartOver** — obejrzyj od początku audycję, która już się rozpoczęła (do 3h wstecz). [Więcej →](#startover "Przeczytaj o usłudze StartOver")
# - **StartOver** — obejrzyj od początku audycję, która już się rozpoczęła (do 3h wstecz). [Więcej →](#startover "Przeczytaj o usłudze StartOver")
- **Nagrywanie serii** — zaplanuj nagrywanie kolejnych odcinków ulubionego serialu. [Więcej →](#nagrywanie_cykliczne "Przeczytaj o nagrywaniu cyklicznym")
# - **Nagrywanie serii** — zaplanuj nagrywanie kolejnych odcinków ulubionego serialu. [Więcej →](#nagrywanie_cykliczne "Przeczytaj o nagrywaniu cyklicznym")
- **Pauzowanie** — zatrzymuj i cofaj audycje.
# - **Pauzowanie** — zatrzymuj i cofaj audycje.
- **Wyszukiwarka tekstowa** — wyszukaj dowolną frazę audycji i zaplanuj nagranie.
# - **Wyszukiwarka tekstowa** — wyszukaj dowolną frazę audycji i zaplanuj nagranie.
- title: "Dekoder telewizyjny"
image: "VIP4302.png"
@@ -33,26 +33,26 @@ sections:
- Przedni panel zawiera m.in. diodę LED i odbiornik podczerwieni
- Wymiary modelu (szer/dł/wys): 130 x 130 x 26 mm
- type: "iframe-channels"
title: Sprawdź listę kanałów w interesującym Cię pakiecie
content: ""
# - type: "iframe-channels"
# title: Sprawdź listę kanałów w interesującym Cię pakiecie
# content: ""
iframe_sets:
- id: "canal_smart"
name: "SMART"
p: 86
- id: "canal_optimum"
name: "OPTIMUM"
p: 87
- id: "canal_platinum"
name: "PLATINUM"
p: 88
- id: "canal_podstawowy"
name: "PODSTAWOWY"
p: 75
- id: "canal_korzystny"
name: "KORZYSTNY"
p: 76
- id: "canal_bogaty"
name: "BOGATY"
p: 77
# iframe_sets:
# - id: "canal_smart"
# name: "SMART"
# p: 86
# - id: "canal_optimum"
# name: "OPTIMUM"
# p: 87
# - id: "canal_platinum"
# name: "PLATINUM"
# p: 88
# - id: "canal_podstawowy"
# name: "PODSTAWOWY"
# p: 75
# - id: "canal_korzystny"
# name: "KORZYSTNY"
# p: 76
# - id: "canal_bogaty"
# name: "BOGATY"
# p: 77

Binary file not shown.

View File

@@ -141,13 +141,11 @@ function OfferCard({ plan, contractLabel, onConfigureAddons }) {
);
})}
{/* Umowa już tylko z przełącznika */}
<li class="f-card-row">
<span class="f-card-label">Umowa</span>
<span class="f-card-value">{effectiveContract}</span>
</li>
{/* Aktywacja z pola plan.price_installation */}
<li class="f-card-row">
<span class="f-card-label">Aktywacja</span>
<span class="f-card-value">

View File

@@ -1,193 +0,0 @@
import { useEffect, useMemo, useState } from "preact/hooks";
import "../../styles/offers/offers-table.css"; //
const BUILDING_MAP = {
jednorodzinny: 1,
wielorodzinny: 2,
};
const CONTRACT_MAP = {
"24m": 1,
bezterminowa: 2,
};
export default function JamboxBasePackages({
source = "PLUS",
title,
selected = {},
}) {
const [packages, setPackages] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
// wyliczamy kody na podstawie tego samego selected, którego używasz w OffersCards
const buildingCode = useMemo(() => {
const val = selected?.budynek;
return BUILDING_MAP[val] ?? 1; // domyślnie jednorodzinny
}, [selected?.budynek]);
const contractCode = useMemo(() => {
const val = selected?.umowa;
return CONTRACT_MAP[val] ?? 1; // domyślnie 24m
}, [selected?.umowa]);
useEffect(() => {
let cancelled = false;
async function load() {
setLoading(true);
setError("");
try {
const params = new URLSearchParams();
if (source && source !== "ALL") {
params.set("source", source);
}
if (buildingCode) {
params.set("building", String(buildingCode));
}
if (contractCode) {
params.set("contract", String(contractCode));
}
const query = params.toString();
const url = `/api/jambox/base-packages${query ? `?${query}` : ""}`;
const res = await fetch(url);
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;
};
}, [source, buildingCode, contractCode]);
const effectiveTitle =
title ||
(source === "PLUS"
? "Pakiety podstawowe JAMBOX PLUS"
: source === "EVIO"
? "Pakiety podstawowe JAMBOX EVIO"
: "Pakiety podstawowe JAMBOX");
if (loading) {
return (
<section class="f-offers">
<h2 class="f-offers-title">{effectiveTitle}</h2>
<p>Ładowanie pakietów...</p>
</section>
);
}
if (error) {
return (
<section class="f-offers">
<h2 class="f-offers-title">{effectiveTitle}</h2>
<p class="text-red-600">{error}</p>
</section>
);
}
if (!packages.length) {
return (
<section class="f-offers">
<h2 class="f-offers-title">{effectiveTitle}</h2>
<p>Brak pakietów do wyświetlenia.</p>
</section>
);
}
return (
<section class="f-offers">
{effectiveTitle && <h2 class="f-offers-title">{effectiveTitle}</h2>}
<div class={`f-offers-grid f-count-${packages.length}`}>
{packages.map((pkg) => (
<JamboxPackageCard key={`${pkg.source}-${pkg.tid}`} pkg={pkg} />
))}
</div>
</section>
);
}
function JamboxPackageCard({ pkg }) {
const updatedDate = pkg.updated_at
? new Date(pkg.updated_at).toLocaleDateString("pl-PL")
: "-";
const hasPrice = pkg.price_monthly != null;
const hasInstall = pkg.price_installation != null;
return (
<div class="f-card">
<div class="f-card-header">
<div class="f-card-name">{pkg.name}</div>
{/* TU zamiast JAMBOX PLUS/EVIO pokazujemy cenę */}
<div class="f-card-price">
{hasPrice
? `${pkg.price_monthly} zł/mies.`
: pkg.source === "PLUS"
? "JAMBOX PLUS"
: "JAMBOX EVIO"}
</div>
</div>
<ul class="f-card-features">
{/* ID / slug jako techniczne info */}
<li class="f-card-row">
<span class="f-card-label">ID pakietu</span>
<span class="f-card-value">{pkg.tid}</span>
</li>
<li class="f-card-row">
<span class="f-card-label">Slug</span>
<span class="f-card-value">{pkg.slug || "—"}</span>
</li>
{/* nowa linia: cena instalacji z bazy */}
<li class="f-card-row">
<span class="f-card-label">Aktywacja</span>
<span class="f-card-value">
{hasInstall ? `${pkg.price_installation}` : "—"}
</span>
</li>
{/* opcjonalnie informacja o źródle pakietu */}
<li class="f-card-row">
<span class="f-card-label">Platforma</span>
<span class="f-card-value">
{pkg.source === "PLUS" ? "JAMBOX PLUS" : "JAMBOX EVIO"}
</span>
</li>
<li class="f-card-row">
<span class="f-card-label">Ostatnia aktualizacja</span>
<span class="f-card-value">{updatedDate}</span>
</li>
</ul>
</div>
);
}

View File

@@ -2,7 +2,7 @@ import { useState } from "preact/hooks";
import OffersSwitches from "./Offers/OffersSwitches.jsx";
import OffersCards from "./Offers/OffersCards.jsx"; // <-- WAŻNE!!
import OffersExtraServices from "./Offers/OffersExtraServices.jsx";
// import OffersExtraServices from "./Offers/OffersExtraServices.jsx";
export default function OffersIsland({ data }) {
const switches = data.przelaczniki ?? [];

View File

@@ -23,7 +23,7 @@ export default function RangeForm() {
let timeoutStreet = null;
async function fetchCitySuggestions(q) {
const res = await fetch(`/api/cities-autocomplete?q=${encodeURIComponent(q)}`);
const res = await fetch(`/api/range/cities-autocomplete?q=${encodeURIComponent(q)}`);
setCitySuggest(await res.json());
setHighlightIndex(-1);
}
@@ -102,7 +102,7 @@ export default function RangeForm() {
return;
}
const res = await fetch(`/api/has-streets?city=${encodeURIComponent(currentCity)}`);
const res = await fetch(`/api/range/has-streets?city=${encodeURIComponent(currentCity)}`);
const { hasStreets } = await res.json();
if (!hasStreets) {
@@ -126,7 +126,7 @@ export default function RangeForm() {
async function fetchStreetSuggestions(q, c) {
const res = await fetch(
`/api/streets-autocomplete?city=${encodeURIComponent(c)}&q=${encodeURIComponent(q)}`
`/api/range/streets-autocomplete?city=${encodeURIComponent(c)}&q=${encodeURIComponent(q)}`
);
setStreetSuggest(await res.json());
setStreetHighlightIndex(-1);
@@ -207,7 +207,7 @@ export default function RangeForm() {
setLoading(true);
const res = await fetch("/api/search", {
const res = await fetch("/api/range/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ city, street, number }),

View File

@@ -0,0 +1,410 @@
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([]);
const [tvAddons, setTvAddons] = useState([]);
const [selectedTvAddonTids, setSelectedTvAddonTids] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [selectedPhoneId, setSelectedPhoneId] = useState(null);
const [selectedAddonIds, setSelectedAddonIds] = useState([]);
// akordeony
const [openPhoneId, setOpenPhoneId] = useState(null);
const [baseOpen, setBaseOpen] = useState(true);
const formatFeatureValue = (val) => {
if (val === true || val === "true") return "✓";
if (val === false || val === "false" || val == null) return "✕";
return val;
};
const handlePhoneSelect = (id) => {
if (id === null) {
setSelectedPhoneId(null);
setOpenPhoneId(null);
return;
}
setSelectedPhoneId(id);
setOpenPhoneId((prev) => (prev === id ? null : id));
};
const toggleAddon = (addonId) => {
setSelectedAddonIds((prev) =>
prev.includes(addonId)
? prev.filter((x) => x !== addonId)
: [...prev, addonId]
);
};
// reset po otwarciu / zmianie pakietu
useEffect(() => {
if (!isOpen) return;
setSelectedPhoneId(null);
setSelectedAddonIds([]);
setOpenPhoneId(null);
setBaseOpen(true);
setError("");
setSelectedTvAddonTids([]);
}, [isOpen, pkg?.id]);
// load danych
useEffect(() => {
if (!isOpen || !pkg?.id) 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 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 : [];
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 : [];
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);
}
}
loadData();
return () => {
cancelled = true;
};
}, [isOpen, pkg?.id]);
if (!isOpen || !pkg) return null;
const basePrice = Number(pkg.price_monthly || 0);
const phonePrice = useMemo(() => {
if (!selectedPhoneId) return 0;
const p = phonePlans.find((x) => x.id === selectedPhoneId);
return Number(p?.price_monthly || 0);
}, [selectedPhoneId, phonePlans]);
// UWAGA: 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]);
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]);
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]
);
};
return (
<div class="fuz-modal-overlay" onClick={onClose}>
<button
class="fuz-modal-close"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
>
</button>
<div
class="fuz-modal-panel fuz-modal-panel--compact"
onClick={(e) => e.stopPropagation()}
>
<div class="fuz-modal-inner">
<h2 class="fuz-modal-title">Konfiguracja usług dodatkowych</h2>
{/* PAKIET JAMBOX jako akordeon (jak internet w Twoim modalu) */}
<div class="fuz-modal-section">
<div class={`fuz-accordion-item ${baseOpen ? "is-open" : ""}`}>
<button
type="button"
class="fuz-accordion-header"
onClick={() => setBaseOpen((prev) => !prev)}
>
<span class="fuz-modal-phone-name">{pkg.name}</span>
<span class="fuz-modal-phone-price">
{basePrice ? `${basePrice.toFixed(2)} zł/mies.` : "—"}
</span>
</button>
{baseOpen && pkg.features && pkg.features.length > 0 && (
<div class="fuz-accordion-body">
<ul class="f-card-features">
{pkg.features.map((f, idx) => (
<li class="f-card-row" key={idx}>
<span class="f-card-label">{f.label}</span>
<span class="f-card-value">
{formatFeatureValue(f.value)}
</span>
</li>
))}
</ul>
</div>
)}
</div>
</div>
{loading && <p>Ładowanie danych...</p>}
{error && <p class="text-red-600">{error}</p>}
{!loading && !error && (
<>
<div class="fuz-modal-section">
<h3>Pakiety dodatkowe TV</h3>
{tvAddons.length === 0 ? (
<p>Brak pakietów dodatkowych TV dla tego pakietu.</p>
) : (
<div class="fuz-addon-list">
{tvAddons.map((a) => {
const tid = Number(a.tid);
const checked = selectedTvAddonTids.includes(tid);
const priceNum = Number(a.price || 0);
return (
<label class="fuz-addon-item" key={`tv-${tid}`}>
<div class="fuz-addon-checkbox">
<input
type="checkbox"
checked={checked}
onChange={() => toggleTvAddon(tid)}
/>
</div>
<div class="fuz-addon-main">
<div class="fuz-addon-name">{a.name}</div>
{/* jeśli chcesz pokazać typ/kind */}
<div class="fuz-addon-desc">{a.description}</div>
</div>
<div class="fuz-addon-price">
{Number.isFinite(priceNum) ? `${priceNum.toFixed(2)} zł/mies.` : "—"}
</div>
</label>
);
})}
</div>
)}
</div>
{/* TELEFON (identycznie jak w InternetAddonsModal) */}
<div class="fuz-modal-section">
<h3>Usługa telefoniczna</h3>
{phonePlans.length === 0 ? (
<p>Brak dostępnych pakietów telefonicznych.</p>
) : (
<div class="fuz-modal-phone-list fuz-accordion">
{/* OPCJA: brak telefonu */}
<div class="fuz-accordion-item fuz-accordion-item--no-phone">
<button
type="button"
class="fuz-accordion-header"
onClick={() => handlePhoneSelect(null)}
>
<span class="fuz-accordion-header-left">
<input
type="radio"
name="phone-plan"
checked={selectedPhoneId === null}
onChange={(e) => {
e.stopPropagation();
handlePhoneSelect(null);
}}
onClick={(e) => e.stopPropagation()}
/>
<span class="fuz-modal-phone-name">
Nie potrzebuję telefonu
</span>
</span>
<span class="fuz-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={`fuz-accordion-item ${isOpen ? "is-open" : ""}`}
key={p.id}
>
<button
type="button"
class="fuz-accordion-header"
onClick={() => handlePhoneSelect(p.id)}
>
<span class="fuz-accordion-header-left">
<input
type="radio"
name="phone-plan"
checked={isSelected}
onChange={(e) => {
e.stopPropagation();
handlePhoneSelect(p.id);
}}
onClick={(e) => e.stopPropagation()}
/>
<span class="fuz-modal-phone-name">{p.name}</span>
</span>
<span class="fuz-modal-phone-price">
{Number(p.price_monthly || 0).toFixed(2)} /mies.
</span>
</button>
{isOpen && (
<div class="fuz-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>
)}
</div>
);
})}
</div>
)}
</div>
{/* DODATKI JAMBOX (checkbox, cena z jambox_package_addon_options.price) */}
<div class="fuz-modal-section">
<h3>Dodatkowe usługi</h3>
{addons.length === 0 ? (
<p>Brak usług dodatkowych dla tego pakietu.</p>
) : (
<div class="fuz-addon-list">
{addons.map((a) => {
const addonId = a.id ?? a.addon_id; // defensywnie
const checked = selectedAddonIds.includes(addonId);
return (
<label class="fuz-addon-item" key={addonId}>
<div class="fuz-addon-checkbox">
<input
type="checkbox"
checked={checked}
onChange={() => toggleAddon(addonId)}
/>
</div>
<div class="fuz-addon-main">
<div class="fuz-addon-name">{a.name}</div>
{a.description && (
<div class="fuz-addon-desc">{a.description}</div>
)}
</div>
<div class="fuz-addon-price">
{Number(a.price || 0).toFixed(2)} /mies.
</div>
</label>
);
})}
</div>
)}
</div>
{/* PODSUMOWANIE */}
<div class="fuz-modal-section fuz-summary">
<h3>Podsumowanie miesięczne</h3>
<div class="fuz-summary-list">
<div class="fuz-summary-row">
<span>Pakiet</span>
<span>
{basePrice ? `${basePrice.toFixed(2)} zł/mies.` : "—"}
</span>
</div>
<div class="fuz-summary-row">
<span>Pakiety TV</span>
<span>
{tvAddonsPrice ? `${tvAddonsPrice.toFixed(2)} zł/mies.` : "—"}
</span>
</div>
<div class="fuz-summary-row">
<span>Telefon</span>
<span>{phonePrice ? `${phonePrice.toFixed(2)} zł/mies.` : "—"}</span>
</div>
<div class="fuz-summary-row">
<span>Dodatki</span>
<span>{addonsPrice ? `${addonsPrice.toFixed(2)} zł/mies.` : "—"}</span>
</div>
<div class="fuz-summary-total">
<span>Łącznie</span>
<span>{totalMonthly.toFixed(2)} /mies.</span>
</div>
</div>
</div>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,154 @@
// src/islands/JamboxChannelsModal.jsx
import { useEffect, useState } from "preact/hooks";
import "../../styles/modal.css";
import "../../styles/offers/offers-table.css";
export default function JamboxChannelsModal({ isOpen, onClose, pkg }) {
const [channels, setChannels] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [query, setQuery] = useState("");
const q = query.trim().toLowerCase();
const filtered = !q
? channels
: channels.filter((ch) =>
(ch.name || "").toLowerCase().includes(q)
);
useEffect(() => {
if (!isOpen || !pkg?.id) return;
let cancelled = false;
async function loadChannels() {
setLoading(true);
setError("");
setChannels([]);
setQuery("");
try {
const params = new URLSearchParams({ packageId: String(pkg.id) });
const res = await fetch(`/api/jambox/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 : []);
}
} catch (err) {
console.error("❌ Błąd pobierania listy kanałów:", err);
if (!cancelled) {
setError("Nie udało się załadować listy kanałów.");
}
} finally {
if (!cancelled) setLoading(false);
}
}
loadChannels();
return () => {
cancelled = true;
};
}, [isOpen, pkg?.id]);
if (!isOpen || !pkg) return null;
return (
<div class="fuz-modal-overlay" onClick={onClose}>
<button
class="fuz-modal-close"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
>
</button>
<div
class="fuz-modal-panel fuz-modal-panel--channels"
onClick={(e) => e.stopPropagation()}
>
<div class="">
<h2 class="fuz-modal-title">Kanały w pakiecie {pkg.name}</h2>
<div class="jmb-search">
<input
class="jmb-search-input"
type="search"
value={query}
onInput={(e) => setQuery(e.currentTarget.value)}
placeholder="Szukaj kanału po nazwie…"
aria-label="Szukaj kanału po nazwie"
/>
{query && (
<button class="jmb-search-clear" type="button" onClick={() => setQuery("")}>
Wyczyść
</button>
)}
</div>
{!loading && !error && (
<div class="jmb-search-meta">
Wyniki: <strong>{filtered.length}</strong> / {channels.length}
</div>
)}
{loading && <p>Ładowanie kanałów...</p>}
{error && <p class="text-red-600">{error}</p>}
{!loading && !error && (
<>
{filtered.length === 0 ? (
<p>Brak kanałów spełniających kryteria.</p>
) : (
<div class="">
<div class="f-section-channel">
{filtered.map((ch) => (
<div class="jmb-channel-card" key={ch.number}>
<div class="jmb-channel-inner">
{/* FRONT */}
<div class="jmb-channel-face jmb-channel-front">
{ch.logo_url && (
<img
src={ch.logo_url}
alt={ch.name}
class="jmb-channel-logo"
loading="lazy"
/>
)}
<div class="jmb-channel-name">{ch.name}</div>
<div class="jmb-channel-number">kanał {ch.number}</div>
</div>
{/* BACK */}
<div class="jmb-channel-face jmb-channel-back">
<div class="jmb-channel-back-title">{ch.name}</div>
<div
class="jmb-channel-desc"
dangerouslySetInnerHTML={{
__html: ch.description || "<em>Brak opisu kanału.</em>",
}}
/>
</div>
</div>
</div>
))}
</div>
</div>
)}
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,187 @@
// src/islands/JamboxBasePackages.jsx
import { useEffect, useState } from "preact/hooks";
import "../../styles/offers/offers-table.css";
import JamboxChannelsModal from "./JamboxChannelsModal.jsx";
import JamboxAddonsModal from "./JamboxAddonsModal.jsx";
export default function JamboxBasePackages({ source = "ALL" }) {
const [selected, setSelected] = useState({});
const [packages, setPackages] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [channelsModalOpen, setChannelsModalOpen] = useState(false);
const [addonsModalOpen, setAddonsModalOpen] = useState(false);
const [activePackage, setActivePackage] = useState(null);
useEffect(() => {
if (typeof window !== "undefined" && window.fuzSwitchState) {
const { selected: sel } = window.fuzSwitchState;
if (sel) setSelected(sel);
}
function handler(e) {
const detail = e.detail || {};
if (detail.selected) setSelected(detail.selected);
}
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>
<JamboxChannelsModal
isOpen={channelsModalOpen}
onClose={() => setChannelsModalOpen(false)}
pkg={activePackage}
/>
<JamboxAddonsModal
isOpen={addonsModalOpen}
onClose={() => setAddonsModalOpen(false)}
pkg={activePackage}
/>
</section>
);
}
function JamboxPackageCard({ pkg, onShowChannels, onConfigureAddons }) {
const basePrice = pkg.price_monthly;
const installPrice = pkg.price_installation;
const featureRows = pkg.features || [];
const hasPrice = basePrice != null;
return (
<div class="f-card">
<div class="f-card-header">
<div class="f-card-name">{pkg.name}</div>
<div class="f-card-price">
{hasPrice
? `${basePrice} zł/mies.`
: pkg.source === "PLUS"
? "JAMBOX PLUS"
: "JAMBOX EVIO"}
</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>
</ul>
<button type="button" class="btn btn-primary mt-2" onClick={onShowChannels}>
Pokaż listę kanałów
</button>
<button
type="button"
class="btn btn-primary mt-2"
onClick={onConfigureAddons}
>
Skonfiguruj usługi dodatkowe
</button>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from "preact/hooks";
import "../styles/offers/offers-table.css";
import "../../styles/offers/offers-table.css";
export default function PhoneDbOffersCards({
title = "Telefonia stacjonarna FUZ",

View File

@@ -1,13 +0,0 @@
import type { APIRoute } from "astro";
import { getDb } from "./db";
type CityRow = { city: string };
export const GET: APIRoute = async () => {
const db = getDb();
const rows = db.prepare("SELECT DISTINCT city FROM ranges ORDER BY city").all() as CityRow[];
return new Response(JSON.stringify(rows.map((x) => x.city)), {
headers: { "Content-Type": "application/json" },
});
};

View File

@@ -3,7 +3,7 @@ import Database from "better-sqlite3";
const DB_PATH =
process.env.FUZ_DB_PATH || "./src/data/ServicesRange.db";
let db: Database.Database;
let db;
export function getDb() {
if (!db) {

View File

@@ -0,0 +1,54 @@
import Database from "better-sqlite3";
const DB_PATH = process.env.FUZ_DB_PATH || "./src/data/ServicesRange.db";
export async function GET({ url }) {
const db = new Database(DB_PATH, { readonly: true });
try {
const packageId = Number(url.searchParams.get("packageId") || 0);
if (!packageId) {
return new Response(
JSON.stringify({ ok: false, error: "MISSING_PACKAGE_ID" }),
{ status: 400, headers: { "Content-Type": "application/json; charset=utf-8" } }
);
}
const rows = db
.prepare(
`
SELECT
a.id AS id,
a.name AS name,
a.type AS type,
CAST(o.price AS REAL) AS price
FROM jambox_package_addon_options o
JOIN internet_addons a
ON a.id = o.addon_id
WHERE o.package_id = ?
ORDER BY a.type, a.name
`
)
.all(packageId);
return new Response(
JSON.stringify({
ok: true,
count: rows.length,
data: rows,
}),
{
status: 200,
headers: { "Content-Type": "application/json; charset=utf-8" },
}
);
} catch (err) {
console.error("❌ Błąd w /api/jambox/addons:", err);
return new Response(
JSON.stringify({ ok: false, error: err.message || "DB_ERROR" }),
{ status: 500, headers: { "Content-Type": "application/json; charset=utf-8" } }
);
} finally {
db.close();
}
}

View File

@@ -1,143 +1,117 @@
// src/pages/api/jambox/base-packages.js
//import { getDb } from "../db.js";
import Database from "better-sqlite3";
const DB_PATH =
process.env.FUZ_DB_PATH || "./src/data/ServicesRange.db";
process.env.FUZ_DB_PATH || "./src/data/ServicesRange.db";
function getDb() {
return new Database(DB_PATH, { readonly: true });
}
/**
* GET /api/jambox/base-packages
* ?source=PLUS|EVIO
* &building=1|2 (1=jednorodzinny, 2=wielorodzinny)
* &contract=1|2 (1=24m, 2=bezterminowa)
* GET /api/jambox/base-packages?source=PLUS|EVIO|ALL&building=1|2&contract=1|2
*/
export function GET({ url }) {
const sourceParam = url.searchParams.get("source");
const source = sourceParam ? sourceParam.toUpperCase() : null;
const sourceParam = url.searchParams.get("source") || "PLUS";
const buildingParam = url.searchParams.get("building");
const contractParam = url.searchParams.get("contract");
const building = buildingParam ? Number(buildingParam) : null;
const contract = contractParam ? Number(contractParam) : null;
const building = buildingParam ? Number(buildingParam) : 1;
const contract = contractParam ? Number(contractParam) : 1;
const db = getDb();
try {
let rows = [];
const hasVariant =
Number.isInteger(building) && Number.isInteger(contract);
const rows = db
.prepare(
`
SELECT
p.id AS package_id,
p.source AS package_source,
p.tid AS package_tid,
p.name AS package_name,
p.slug AS package_slug,
p.sort_order AS package_sort_order,
p.updated_at AS package_updated_at,
if (source === "PLUS" || source === "EVIO") {
if (hasVariant) {
// pakiety + ceny dla danego budynku/umowy
const stmt = db.prepare(
`
SELECT
p.id,
p.source,
p.tid,
p.name,
p.slug,
p.sort_order,
p.updated_at,
pr.price_monthly,
pr.price_installation,
pr.currency
FROM jambox_base_packages p
LEFT JOIN jambox_base_package_prices pr
ON pr.package_id = p.id
AND pr.building_type = ?
AND pr.contract_type = ?
WHERE p.source = ?
ORDER BY p.sort_order ASC, p.name ASC;
`.trim()
);
rows = stmt.all(building, contract, source);
} else {
// tylko pakiety, bez cen
const stmt = db.prepare(
`
SELECT
p.id,
p.source,
p.tid,
p.name,
p.slug,
p.sort_order,
p.updated_at
FROM jambox_base_packages p
WHERE p.source = ?
ORDER BY p.sort_order ASC, p.name ASC;
`.trim()
);
rows = stmt.all(source);
pr.price_monthly AS price_monthly,
pr.price_installation AS price_installation,
f.id AS feature_id,
f.label AS feature_label,
fv.value AS feature_value
FROM jambox_base_packages p
LEFT JOIN jambox_base_package_prices pr
ON pr.package_id = p.id
AND pr.building_type = ?
AND pr.contract_type = ?
LEFT JOIN jambox_package_feature_values fv
ON fv.package_id = p.id
LEFT JOIN internet_features f
ON f.id = fv.feature_id
WHERE (? = 'ALL' OR p.source = ?)
ORDER BY p.sort_order ASC, p.id ASC, f.id ASC;
`.trim()
)
.all(building, contract, sourceParam, sourceParam);
// grupowanie jak w /api/internet/plans
const byPackage = new Map();
for (const row of rows) {
if (!byPackage.has(row.package_id)) {
byPackage.set(row.package_id, {
id: row.package_id,
source: row.package_source,
tid: row.package_tid,
name: row.package_name,
slug: row.package_slug,
sort_order: row.package_sort_order,
updated_at: row.package_updated_at,
price_monthly: row.price_monthly,
price_installation: row.price_installation,
features: [],
});
}
} else {
// bez filtra source (raczej nie użyjesz, ale niech będzie poprawnie)
if (hasVariant) {
const stmt = db.prepare(
`
SELECT
p.id,
p.source,
p.tid,
p.name,
p.slug,
p.sort_order,
p.updated_at,
pr.price_monthly,
pr.price_installation,
pr.currency
FROM jambox_base_packages p
LEFT JOIN jambox_base_package_prices pr
ON pr.package_id = p.id
AND pr.building_type = ?
AND pr.contract_type = ?
ORDER BY p.source ASC, p.sort_order ASC, p.name ASC;
`.trim()
);
rows = stmt.all(building, contract);
} else {
const stmt = db.prepare(
`
SELECT
p.id,
p.source,
p.tid,
p.name,
p.slug,
p.sort_order,
p.updated_at
FROM jambox_base_packages p
ORDER BY p.source ASC, p.sort_order ASC, p.name ASC;
`.trim()
);
rows = stmt.all();
if (row.feature_id) {
byPackage.get(row.package_id).features.push({
id: row.feature_id,
label: row.feature_label,
value: row.feature_value,
});
}
}
const data = Array.from(byPackage.values());
return new Response(
JSON.stringify({
ok: true,
source: source ?? "ALL",
source: sourceParam,
building,
contract,
count: rows.length,
data: rows,
count: data.length,
data,
}),
{
status: 200,
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "public, max-age=60",
"Cache-Control": "public, max-age=30",
},
}
);
} catch (err) {
console.error("❌ Błąd odczytu z bazy jambox_base_packages:", err);
console.error("❌ Błąd w /api/jambox/base-packages:", err);
return new Response(
JSON.stringify({ ok: false, error: "DB_ERROR" }),
{
@@ -147,7 +121,5 @@ export function GET({ url }) {
},
}
);
} finally {
db.close();
}
}

View File

@@ -0,0 +1,64 @@
// src/pages/api/jambox/channels.js
//import { getDb } from "../db.js";
import Database from "better-sqlite3";
const DB_PATH =
process.env.FUZ_DB_PATH || "./src/data/ServicesRange.db";
function getDb() {
return new Database(DB_PATH, { readonly: true });
}
export function GET({ url }) {
const packageIdParam = url.searchParams.get("packageId");
const packageId = Number(packageIdParam);
if (!packageId) {
return new Response(
JSON.stringify({ ok: false, error: "INVALID_PACKAGE_ID" }),
{
status: 400,
headers: { "Content-Type": "application/json; charset=utf-8" },
}
);
}
const db = getDb();
try {
const rows = db
.prepare(
`
SELECT
number,
name,
description,
logo_url,
guaranteed
FROM jambox_package_channels
WHERE package_id = ?
ORDER BY number ASC;
`.trim()
)
.all(packageId);
return new Response(
JSON.stringify({ ok: true, data: rows }),
{
status: 200,
headers: { "Content-Type": "application/json; charset=utf-8" },
}
);
} catch (err) {
console.error("❌ Błąd w /api/jambox/channels:", err);
return new Response(
JSON.stringify({ ok: false, error: "DB_ERROR" }),
{
status: 500,
headers: { "Content-Type": "application/json; charset=utf-8" },
}
);
}
}

View File

@@ -0,0 +1,51 @@
import Database from "better-sqlite3";
const DB_PATH = process.env.FUZ_DB_PATH || "./src/data/ServicesRange.db";
export async function GET({ url }) {
const db = new Database(DB_PATH, { readonly: true });
try {
const packageId = Number(url.searchParams.get("packageId") || 0);
if (!packageId) {
return new Response(
JSON.stringify({ ok: false, error: "MISSING_PACKAGE_ID" }),
{ status: 400, headers: { "Content-Type": "application/json; charset=utf-8" } }
);
}
const rows = db
.prepare(
`
SELECT
a.tid AS tid,
a.name AS name,
a.kind AS kind,
a.is_active AS is_active,
CAST(p.price AS REAL) AS price,
p.currency AS currency,
a.description AS description
FROM jambox_tv_addon_prices p
JOIN jambox_tv_addons a
ON a.tid = p.addon_tid
WHERE p.package_id = ?
AND a.is_active = 1
ORDER BY a.kind, a.name
`
)
.all(packageId);
return new Response(
JSON.stringify({ ok: true, count: rows.length, data: rows }),
{ status: 200, headers: { "Content-Type": "application/json; charset=utf-8" } }
);
} catch (err) {
console.error("❌ Błąd w /api/jambox/tv-addons:", err);
return new Response(
JSON.stringify({ ok: false, error: err.message || "DB_ERROR" }),
{ status: 500, headers: { "Content-Type": "application/json; charset=utf-8" } }
);
} finally {
db.close();
}
}

View File

@@ -1,7 +1,6 @@
import Database from "better-sqlite3";
const DB_PATH =
process.env.FUZ_DB_PATH || "./src/data/ServicesRange.db";
const DB_PATH = process.env.FUZ_DB_PATH || "./src/data/ServicesRange.db";
export async function GET() {
const db = new Database(DB_PATH, { readonly: true });
@@ -69,7 +68,7 @@ export async function GET() {
}
);
} catch (err) {
console.error("Błąd w /api/phone/plans:", err);
console.error("Błąd w /api/phone/plans:", err);
return new Response(
JSON.stringify({
ok: false,

View File

@@ -0,0 +1,15 @@
import { getDb } from "../db.js";
export async function GET() {
const db = getDb();
const rows = db
.prepare("SELECT DISTINCT city FROM ranges ORDER BY city")
.all();
const cities = rows.map((row) => row.city);
return new Response(JSON.stringify(cities), {
headers: { "Content-Type": "application/json; charset=utf-8" },
});
}

View File

@@ -1,21 +1,21 @@
import type { APIRoute } from "astro";
import { getDb } from "./db";
import { getDb } from "../db.js";
function normalize(s: string) {
function normalize(s) {
return s.normalize("NFD").replace(/\p{Diacritic}/gu, "").toLowerCase();
}
type CityRow = { city: string };
export const GET: APIRoute = async ({ request }) => {
export async function GET({ request }) {
const q = new URL(request.url).searchParams.get("q")?.trim() || "";
if (q.length < 2)
return new Response("[]", { headers: { "Content-Type": "application/json" } });
if (q.length < 2) {
return new Response("[]", {
headers: { "Content-Type": "application/json; charset=utf-8" },
});
}
const db = getDb();
const rows = db.prepare(`SELECT DISTINCT city FROM ranges`).all() as CityRow[];
const rows = db.prepare(`SELECT DISTINCT city FROM ranges`).all();
const nq = normalize(q);
@@ -25,6 +25,6 @@ export const GET: APIRoute = async ({ request }) => {
.map((r) => r.city);
return new Response(JSON.stringify(result), {
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json; charset=utf-8" },
});
};
}

View File

@@ -1,13 +1,13 @@
import type { APIRoute } from "astro";
import { getDb } from "./db";
import { getDb } from "../db.js";
export const GET: APIRoute = async ({ request }) => {
export async function GET({ request }) {
const city = new URL(request.url).searchParams.get("city")?.trim() || "";
if (!city)
if (!city) {
return new Response(JSON.stringify({ hasStreets: false }), {
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json; charset=utf-8" },
});
}
const db = getDb();
@@ -18,10 +18,12 @@ export const GET: APIRoute = async ({ request }) => {
WHERE LOWER(city) = LOWER(?)
AND TRIM(street) <> ''`
)
.get(city) as { cnt: number };
.get(city);
return new Response(
JSON.stringify({ hasStreets: row.cnt > 0 }),
{ headers: { "Content-Type": "application/json" } }
{
headers: { "Content-Type": "application/json; charset=utf-8" },
}
);
};
}

View File

@@ -1,7 +1,7 @@
import type { APIRoute } from "astro";
import { getDb } from "./db";
// src/pages/api/range/check-address.js (przykładowa nazwa)
import { getDb } from "../db.js";
export const POST: APIRoute = async ({ request }) => {
export async function POST({ request }) {
const { city, street, number } = await request.json();
const db = getDb();
@@ -17,12 +17,13 @@ export const POST: APIRoute = async ({ request }) => {
)
.get(city, street || "", number || "");
if (!row)
if (!row) {
return new Response(JSON.stringify({ ok: false }), {
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json; charset=utf-8" },
});
}
return new Response(JSON.stringify({ ok: true, result: row }), {
headers: { "Content-Type": "application/json" },
headers: { "Content-Type": "application/json; charset=utf-8" },
});
};
}

View File

@@ -0,0 +1,40 @@
import { getDb } from "../db.js";
function normalize(s) {
return s.normalize("NFD").replace(/\p{Diacritic}/gu, "").toLowerCase();
}
export async function GET({ request }) {
const url = new URL(request.url);
const city = url.searchParams.get("city")?.trim() || "";
const q = url.searchParams.get("q")?.trim() || "";
if (!city || q.length < 1) {
return new Response("[]", {
headers: { "Content-Type": "application/json; charset=utf-8" },
});
}
const db = getDb();
const rows = db
.prepare(
`SELECT DISTINCT street
FROM ranges
WHERE LOWER(city) = LOWER(?)
AND TRIM(street) <> ''`
)
.all(city);
const pattern = normalize(q);
const filtered = rows
.map((r) => r.street)
.filter((street) => normalize(street).includes(pattern))
.slice(0, 20);
return new Response(JSON.stringify(filtered), {
headers: { "Content-Type": "application/json; charset=utf-8" },
});
}

View File

@@ -1,38 +0,0 @@
import type { APIRoute } from "astro";
import { getDb } from "./db";
function normalize(s: string) {
return s.normalize("NFD").replace(/\p{Diacritic}/gu, "").toLowerCase();
}
type StreetRow = { street: string };
export const GET: APIRoute = async ({ request }) => {
const url = new URL(request.url);
const city = url.searchParams.get("city")?.trim() || "";
const q = url.searchParams.get("q")?.trim() || "";
if (!city || q.length < 1)
return new Response("[]", { headers: { "Content-Type": "application/json" } });
const db = getDb();
const rows = db.prepare(
`SELECT DISTINCT street
FROM ranges
WHERE LOWER(city) = LOWER(?)
AND TRIM(street) <> ''`
).all(city) as StreetRow[];
const pattern = normalize(q);
const filtered = rows
.map((s) => s.street)
.filter((s) => normalize(s).includes(pattern))
.slice(0, 20);
return new Response(JSON.stringify(filtered), {
headers: { "Content-Type": "application/json" },
});
};

View File

@@ -1,12 +1,13 @@
---
import DefaultLayout from "../../layouts/DefaultLayout.astro";
import OffersSwitches from "../../islands/Offers/OffersSwitches.jsx";
import OffersJamboxCards from "../../islands/jambox/OffersJamboxCards.jsx";
import Hero from "../../components/hero/Hero.astro";
import SectionRenderer from "../../components/sections/SectionRenderer.astro";
import Markdown from "../../islands/Markdown.jsx";
import Modal from "../../islands/Modal.jsx";
import OffersIsland from "../../islands/OffersIsland.jsx";
import JamboxMozliwosci from "../../components/sections/SectionJamboxMozliwosci.astro";
import JamboxBasePackages from "../../islands/Offers/JamboxBasePackages.jsx";
import yaml from "js-yaml";
import fs from "fs";
@@ -21,7 +22,7 @@ const page = yaml.load(
fs.readFileSync("./src/content/internet-telewizja/page.yaml", "utf8"),
);
const modalData = yaml.load(
fs.readFileSync("./src/content/internet-telewizja/modal.yaml", "utf8")
fs.readFileSync("./src/content/internet-telewizja/modal.yaml", "utf8"),
);
type Paragraph = {
@@ -37,31 +38,30 @@ const rest = page.paragraphs.slice(1);
---
<DefaultLayout seo={seo}>
<Hero {...hero} />
<!-- <Hero {...hero} /> -->
<section class="f-section">
<!-- <section class="f-section">
<div class="f-section-grid-single md:grid-cols-1">
{page.title.map((line: any) => <h1 class="f-section-title">{line}</h1>)}
{first.title && <h3>{first.title}</h3>}
<Markdown text={first.content} />
</div>
</section>
</section> -->
<section class="f-section">
<div class="f-section-grid-single md:grid-cols-1 max-w-6xl mx-auto">
<section class="f-section">
<div class="f-section-grid-single md:grid-cols-1">
<h1 class="f-section-title">Telewizja z interentem</h1>
<div class="fuz-markdown max-w-none">
<p>Wybierz rodzaj budynku i czas trwania umowy</p>
</div>
<OffersSwitches client:load />
<OffersJamboxCards client:load />
<JamboxBasePackages
client:load
source=""
title=""/>
<OffersIsland client:load data={data} />
<!-- <OffersIsland client:load data={data} /> -->
</div>
</section>
{
<!-- {
rest.map((p: Paragraph) => (
<section class="f-section">
<div class="f-section-grid-single md:grid-cols-1">
@@ -70,13 +70,11 @@ const rest = page.paragraphs.slice(1);
</div>
</section>
))
}
} -->
<SectionRenderer src="./src/content/internet-telewizja/section.yaml" />
<JamboxMozliwosci />
<Modal client:load modalData={modalData} />
<!-- <JamboxMozliwosci /> -->
<!-- <Modal client:load modalData={modalData} /> -->
</DefaultLayout>

View File

@@ -1,7 +1,7 @@
---
import DefaultLayout from "../../layouts/DefaultLayout.astro";
import SectionRenderer from "../../components/sections/SectionRenderer.astro";
import OffersPhoneCards from "../../islands/OffersPhoneCards.jsx";
import OffersPhoneCards from "../../islands/phone/OffersPhoneCards.jsx";
import yaml from "js-yaml";
import fs from "fs";
@@ -19,17 +19,5 @@ const seo = yaml.load(
</div>
</section>
<!-- <OffersIsland client:load data={data} /> -->
<!-- {
rest.map((p: Paragraph) => (
<section class="f-section">
<div class="f-section-grid-single md:grid-cols-1">
{p.title && <h3 class="f-section-title">{p.title}</h3>}
<Markdown text={p.content.replace(/\n/g, "\n\n")} />
</div>
</section>
))
} -->
<SectionRenderer src="./src/content/telefon/section.yaml" />
</DefaultLayout>

View File

@@ -0,0 +1,280 @@
// scripts/importJamboxChannelLists.js
import Database from "better-sqlite3";
import { XMLParser } from "fast-xml-parser";
const DB_PATH =
process.env.FUZ_DB_PATH || "./src/data/ServicesRange.db";
// źródła: URL + docelowy pakiet (source + slug)
const FEEDS = [
{
url: "https://www.jambox.pl/xml/listakanalow-smart.xml",
source: "EVIO",
slug: "smart",
},
{
url: "https://www.jambox.pl/xml/listakanalow-optimum.xml",
source: "EVIO",
slug: "optimum",
},
{
url: "https://www.jambox.pl/xml/listakanalow-platinum.xml",
source: "EVIO",
slug: "platinum",
},
{
url: "https://www.jambox.pl/xml/listakanalow-pluspodstawowy.xml",
source: "PLUS",
slug: "podstawowy",
},
{
url: "https://www.jambox.pl/xml/listakanalow-pluskorzystny.xml",
source: "PLUS",
slug: "korzystny",
},
{
url: "https://www.jambox.pl/xml/listakanalow-plusbogaty.xml",
source: "PLUS",
slug: "bogaty",
},
];
/**
* Helper: pobranie XML-a
*/
async function fetchXml(url) {
const res = await fetch(url);
if (!res.ok) {
throw new Error(
`Błąd pobierania XML z ${url}: ${res.status} ${res.statusText}`
);
}
return await res.text();
}
/**
* Parser XML -> tablica node'ów z <xml><node>...</node></xml>
*/
function parseNodes(xmlText) {
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: "@_",
});
const json = parser.parse(xmlText);
let nodes = json.xml?.node ?? json.node ?? [];
if (!nodes) return [];
if (!Array.isArray(nodes)) nodes = [nodes];
return nodes;
}
/**
* Prosta dekodacja encji HTML (&ndash; &nbsp; etc.)
* nie bawimy się w pełen parser HTML, tylko najczęstsze kody
*/
function decodeHtmlEntities(str) {
if (!str) return "";
return String(str)
.replace(/&ndash;/g, "")
.replace(/&nbsp;/g, " ")
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">");
}
/**
* Z node'a zrobimy obiekt kanału
*/
function mapChannelNode(node) {
const number = Number(node.nr);
const name = node.nazwa_kanalu ?? node.nazwa ?? null;
if (!number || !name) {
console.warn("⚠ Pomijam node bez numeru / nazwy:", node);
return null;
}
// opis chcemy HTML, ale z poprawionymi encjami
let descriptionHtml = "";
if (typeof node.opis === "string") {
descriptionHtml = node.opis;
} else if (node.opis && node.opis.p) {
// <opis><p>...</p></opis>
if (typeof node.opis.p === "string") {
descriptionHtml = `<p>${node.opis.p}</p>`;
} else if (Array.isArray(node.opis.p)) {
// kilka <p>...</p>
descriptionHtml = node.opis.p
.map((p) =>
typeof p === "string" ? `<p>${p}</p>` : ""
)
.join("");
}
}
descriptionHtml = decodeHtmlEntities(descriptionHtml.trim());
// logo interesuje nas przede wszystkim src
let logoUrl = null;
const logoNode = node.field_logo_fid;
if (typeof logoNode === "string") {
// próbujemy wyciągnąć src="..."
const m = logoNode.match(/src="([^"]+)"/);
if (m) logoUrl = m[1];
} else if (logoNode && logoNode.img) {
// fast-xml-parser: <field_logo_fid><img ... /></field_logo_fid>
const img = logoNode.img;
if (img["@_src"]) {
logoUrl = img["@_src"];
}
}
// gwarantowany
const guaranteed = String(node.gwarantowany || "")
.toLowerCase()
.includes("gwarantowany")
? 1
: 0;
return {
number,
name: String(name).trim(),
description: descriptionHtml || null,
logo_url: logoUrl || null,
guaranteed,
};
}
/**
* Upewniamy się, że tabela docelowa istnieje
*/
function ensureSchema(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS jambox_package_channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
package_id INTEGER NOT NULL,
number INTEGER NOT NULL,
name TEXT NOT NULL,
description TEXT,
logo_url TEXT,
guaranteed INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL,
UNIQUE (package_id, number),
FOREIGN KEY (package_id)
REFERENCES jambox_base_packages (id) ON DELETE CASCADE
);
`);
}
/**
* Znajdowanie id pakietu po (source, slug)
*/
function getPackageId(db, source, slug) {
const row = db
.prepare(
`
SELECT id
FROM jambox_base_packages
WHERE source = ?
AND slug = ?;
`
)
.get(source, slug);
return row?.id ?? null;
}
/**
* Upsert kanałów dla danego pakietu
*/
function upsertChannelsForPackage(db, packageId, channels) {
const now = new Date().toISOString();
const stmt = db.prepare(`
INSERT INTO jambox_package_channels
(package_id, number, name, description, logo_url, guaranteed, updated_at)
VALUES
(@package_id, @number, @name, @description, @logo_url, @guaranteed, @updated_at)
ON CONFLICT(package_id, number) DO UPDATE SET
name = excluded.name,
description = excluded.description,
logo_url = excluded.logo_url,
guaranteed = excluded.guaranteed,
updated_at = excluded.updated_at;
`);
const tx = db.transaction((rows) => {
let count = 0;
for (const ch of rows) {
stmt.run({
package_id: packageId,
number: ch.number,
name: ch.name,
description: ch.description,
logo_url: ch.logo_url,
guaranteed: ch.guaranteed,
updated_at: now,
});
count++;
}
console.log(` → zapisano/zmieniono ${count} kanałów dla package_id=${packageId}`);
});
tx(channels);
}
async function main() {
console.log(`Używam bazy: ${DB_PATH}`);
const db = new Database(DB_PATH);
ensureSchema(db);
for (const feed of FEEDS) {
console.log(
`\n=== Przetwarzam ${feed.url} (source=${feed.source}, slug=${feed.slug}) ===`
);
const packageId = getPackageId(db, feed.source, feed.slug);
if (!packageId) {
console.warn(
`⚠ Brak pakietu w jambox_base_packages dla source=${feed.source}, slug="${feed.slug}". Pomijam ten feed.`
);
continue;
}
try {
const xml = await fetchXml(feed.url);
const nodes = parseNodes(xml);
console.log(`📦 Znaleziono ${nodes.length} node'ów (kanałów) w XML-u.`);
const channels = [];
for (const node of nodes) {
const mapped = mapChannelNode(node);
if (!mapped) continue;
channels.push(mapped);
}
console.log(
`🧮 Po zmapowaniu pozostaje ${channels.length} poprawnych kanałów.`
);
upsertChannelsForPackage(db, packageId, channels);
} catch (err) {
console.error(`❌ Błąd przy przetwarzaniu ${feed.url}:`, err);
}
}
db.close();
console.log("\n🎉 Import list kanałów JAMBOX zakończony.");
}
main().catch((err) => {
console.error("❌ Krytyczny błąd:", err);
process.exit(1);
});

View File

@@ -0,0 +1,127 @@
import Database from "better-sqlite3";
import { XMLParser } from "fast-xml-parser";
const DB_PATH = process.env.FUZ_DB_PATH || "./src/data/ServicesRange.db";
const SOURCES = [
{
kind: "premium",
url: "https://www.jambox.pl/xml/slownik-pakietypremium.xml",
},
{
kind: "tematyczne_plus",
url: "https://www.jambox.pl/xml/slownik-pakietytematyczneplus.xml",
},
];
async function fetchXml(url) {
const res = await fetch(url);
if (!res.ok) {
throw new Error(
`Błąd pobierania XML z ${url}: ${res.status} ${res.statusText}`
);
}
return await res.text();
}
function parseNodesFromXml(xmlText) {
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: "@_",
});
const json = parser.parse(xmlText);
let nodes = json.xml?.node ?? json.node ?? [];
if (!nodes) return [];
if (!Array.isArray(nodes)) nodes = [nodes];
return nodes;
}
function ensureSchema(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS jambox_tv_addons (
tid INTEGER PRIMARY KEY,
name TEXT NOT NULL,
kind TEXT NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1,
updated_at TEXT NOT NULL
);
`);
}
function upsertTvAddons(db, kind, nodes) {
const now = new Date().toISOString();
const upsert = db.prepare(`
INSERT INTO jambox_tv_addons (tid, name, kind, is_active, updated_at)
VALUES (@tid, @name, @kind, 1, @updated_at)
ON CONFLICT(tid) DO UPDATE SET
name = excluded.name,
kind = excluded.kind,
is_active = excluded.is_active,
updated_at = excluded.updated_at;
`);
const tx = db.transaction((rows) => {
let ok = 0;
for (const node of rows) {
const name = node.name ?? node.nazwa ?? node["#text"] ?? "";
const tidRaw = node.tid ?? node.id ?? null;
if (!tidRaw || !name) {
console.warn(`⚠ Pomijam node bez tid/name (kind=${kind}):`, node);
continue;
}
const tid = Number(tidRaw);
if (!Number.isFinite(tid) || tid <= 0) {
console.warn(`⚠ Pomijam node z niepoprawnym tid (kind=${kind}):`, node);
continue;
}
upsert.run({
tid,
name: String(name).trim(),
kind,
updated_at: now,
});
ok++;
}
console.log(`${kind}: zapisano/zmieniono ${ok} rekordów.`);
});
tx(nodes);
}
async function main() {
console.log(`Używam bazy: ${DB_PATH}`);
const db = new Database(DB_PATH);
ensureSchema(db);
for (const { kind, url } of SOURCES) {
console.log(`\n=== Przetwarzam ${kind} (${url}) ===`);
try {
const xml = await fetchXml(url);
const nodes = parseNodesFromXml(xml);
console.log(`📦 Znaleziono ${nodes.length} node'ów (kind=${kind})`);
upsertTvAddons(db, kind, nodes);
} catch (err) {
console.error(`❌ Błąd przy imporcie ${kind}:`, err);
}
}
db.close();
console.log("\n🎉 Import słowników pakietów dodatkowych zakończony.");
}
main().catch((err) => {
console.error("❌ Krytyczny błąd:", err);
process.exit(1);
});

View File

@@ -0,0 +1,239 @@
// scripts/updateJamboxChannels.js
import Database from "better-sqlite3";
import { XMLParser } from "fast-xml-parser";
const DB_PATH =
process.env.FUZ_DB_PATH || "./src/data/ServicesRange.db";
const JAMBOX_NUMBERS_URL = "https://www.jambox.pl/xml/jamboxwliczbach.xml";
/**
* Prosty helper do pobrania XML-a
*/
async function fetchXml(url) {
const res = await fetch(url);
if (!res.ok) {
throw new Error(
`Błąd pobierania XML z ${url}: ${res.status} ${res.statusText}`
);
}
return await res.text();
}
/**
* Parsowanie XML -> pojedynczy node z licznikami
*/
function parseCountsNode(xmlText) {
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: "@_",
});
const json = parser.parse(xmlText);
// dokładnie jak w podglądzie: <xml><node>...</node></xml>
const node = json.xml?.node ?? json.node ?? null;
if (!node) {
throw new Error("Nie znaleziono <node> w jamboxwliczbach.xml");
}
return node;
}
function getInt(node, key) {
const raw = node[key];
const n = Number(raw);
if (!Number.isFinite(n)) {
console.warn(`⚠ Pole ${key} ma dziwną wartość:`, raw);
return 0;
}
return n;
}
/**
* Pobierz ID cech "Liczba Kanałów" i "Liczba Kanałów HD"
* żeby nie polegać na tym, że to zawsze 7 i 8.
*/
function getChannelFeatureIds(db) {
const rows = db
.prepare(
`
SELECT id, label
FROM internet_features
WHERE label IN ('Liczba Kanałów', 'Liczba Kanałów HD');
`
)
.all();
let channelsId = null;
let channelsHdId = null;
for (const row of rows) {
if (row.label === "Liczba Kanałów") channelsId = row.id;
if (row.label === "Liczba Kanałów HD") channelsHdId = row.id;
}
if (!channelsId || !channelsHdId) {
throw new Error(
`Brak wymaganych cech w internet_features (Liczba Kanałów / Liczba Kanałów HD).`
);
}
console.log(
` Feature IDs: "Liczba Kanałów" = ${channelsId}, "Liczba Kanałów HD" = ${channelsHdId}`
);
return { channelsId, channelsHdId };
}
/**
* Zwraca mapę (source, slug) -> package_id z jambox_base_packages
*/
function getPackageIdLookup(db) {
const rows = db
.prepare(
`
SELECT id, source, slug
FROM jambox_base_packages;
`
)
.all();
const map = new Map();
for (const row of rows) {
if (!row.source || !row.slug) continue;
const key = `${row.source}::${row.slug}`;
map.set(key, row.id);
}
return map;
}
/**
* Aktualizacja rekordów w jambox_package_feature_values
* dla "Liczba Kanałów" i "Liczba Kanałów HD"
*/
function updateChannelFeatureValues(db, packages, featureIds, packageIdMap) {
const { channelsId, channelsHdId } = featureIds;
const upsertStmt = db.prepare(`
INSERT INTO jambox_package_feature_values (package_id, feature_id, value)
VALUES (@package_id, @feature_id, @value)
ON CONFLICT(package_id, feature_id) DO UPDATE SET
value = excluded.value;
`);
const tx = db.transaction((rows) => {
let total = 0;
for (const pkg of rows) {
const key = `${pkg.source}::${pkg.slug}`;
const packageId = packageIdMap.get(key);
if (!packageId) {
console.warn(
`⚠ Brak pakietu w jambox_base_packages dla: source=${pkg.source}, slug="${pkg.slug}"`
);
continue;
}
// Liczba Kanałów
upsertStmt.run({
package_id: packageId,
feature_id: channelsId,
value: String(pkg.canals),
});
// Liczba Kanałów HD
upsertStmt.run({
package_id: packageId,
feature_id: channelsHdId,
value: String(pkg.canalshd),
});
total += 2;
}
console.log(
`✅ Zaktualizowano / wstawiono ${total} wartości cech w jambox_package_feature_values.`
);
});
tx(packages);
}
async function main() {
console.log(`Używam bazy: ${DB_PATH}`);
const db = new Database(DB_PATH); // tu normalne R/W połączenie
console.log(`\n=== Pobieram JAMBOX "w liczbach" ===`);
const xml = await fetchXml(JAMBOX_NUMBERS_URL);
const node = parseCountsNode(xml);
// 🔗 Ręczne mapowanie z ich XML-a na nasze pakiety (source + slug)
const packages = [
// EVIO
{
source: "EVIO",
slug: "smart",
canals: getInt(node, "ilosc_kanalow_eviosmart"),
canalshd: getInt(node, "ilosc_kanalow_hd_eviosmart"),
},
{
source: "EVIO",
slug: "optimum",
canals: getInt(node, "ilosc_kanalow_eviooptimum"),
canalshd: getInt(node, "ilosc_kanalow_hd_eviooptimum"),
},
{
source: "EVIO",
slug: "platinum",
canals: getInt(node, "ilosc_kanalow_evioplatinum"),
canalshd: getInt(node, "ilosc_kanalow_hd_evioplatinum"),
},
// PLUS
{
source: "PLUS",
slug: "podstawowy",
canals: getInt(node, "ilosc_kanalow_podstawowy"),
canalshd: getInt(node, "ilosc_kanalow_hd_podstawowy"),
},
{
source: "PLUS",
slug: "korzystny",
canals: getInt(node, "ilosc_kanalow_korzystny"),
canalshd: getInt(node, "ilosc_kanalow_hd_korzystny"),
},
{
source: "PLUS",
slug: "bogaty",
canals: getInt(node, "ilosc_kanalow_bogaty"),
canalshd: getInt(node, "ilosc_kanalow_hd_bogaty"),
},
];
console.log("🧮 Pakiety do aktualizacji:");
packages.forEach((p) => {
console.log(
` - ${p.source} / ${p.slug}: ${p.canals} kanałów, ${p.canalshd} HD`
);
});
// 🔹 Pobierz ID cech i mapę pakietów
const featureIds = getChannelFeatureIds(db);
const packageIdMap = getPackageIdLookup(db);
// 🔹 Aktualizacja tabeli jambox_package_feature_values
updateChannelFeatureValues(db, packages, featureIds, packageIdMap);
db.close();
console.log("\n🎉 Aktualizacja liczby kanałów zakończona.");
}
main().catch((err) => {
console.error("❌ Krytyczny błąd:", err);
process.exit(1);
});

View File

@@ -139,3 +139,188 @@
margin-right: auto;
}
}
/*
obszar ze scrollem wewnątrz modala
.jmb-channels-scroll {
margin-top: 1rem;
padding-right: 0.25rem;
max-height: 72vh;
overflow-y: auto;
} */
/* dużo kart w wierszu */
.jmb-channels-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.9rem;
}
/* pojedyncza karta kanału */
.jmb-channel-card {
background: var(--fuz-bg);
border-radius: 0.75rem;
padding: 0.75rem 0.5rem;
text-align: center;
border: 1px solid rgba(148, 163, 184, 0.25);
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.15);
display: flex;
flex-direction: column;
justify-content: space-between;
}
/* logo kanału */
.jmb-channel-logo {
max-width: 90px;
max-height: 60px;
margin: 0 auto 0.5rem auto;
display: block;
object-fit: contain;
}
/* podpisy pod logo */
.jmb-channel-name {
font-size: 0.85rem;
font-weight: 600;
margin-bottom: 0.15rem;
}
.jmb-channel-number {
font-size: 0.75rem;
color: #94a3b8;
}
/* badge "gwarantowany" */
.jmb-channel-tag {
margin-top: 0.35rem;
font-size: 0.7rem;
display: inline-block;
padding: 0.1rem 0.5rem;
border-radius: 9999px;
background: rgba(34, 197, 94, 0.08);
color: #22c55e;
}
/* === FIX: stabilny grid + flip (nie rozjeżdża kart) === */
.jmb-channels-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 0.9rem;
align-items: stretch; /* ważne: równe wysokości w wierszu */
}
.jmb-channel-card {
position: relative;
perspective: 1000px;
/* zostawiamy Twój wygląd */
background: var(--fuz-bg);
border-radius: 0.75rem;
border: 1px solid rgba(148, 163, 184, 0.25);
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.15);
/* kluczowe: karta ma “ramę” */
min-height: 170px; /* dopasuj: 160200 */
height: 100%;
padding: 0; /* padding przenosimy na face */
overflow: hidden; /* żeby back nie wystawał */
}
.jmb-channel-inner {
position: relative;
width: 100%;
height: 100%;
transform-style: preserve-3d;
transition: transform 0.6s ease;
}
@media (hover: hover) and (pointer: fine) {
.jmb-channel-card:hover .jmb-channel-inner {
transform: rotateY(180deg);
}
}
/* front + back */
.jmb-channel-face {
position: absolute;
inset: 0;
padding: 0.75rem 0.5rem; /* to był Twój padding z card */
display: flex;
flex-direction: column;
justify-content: space-between;
text-align: center;
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
.jmb-channel-front {
transform: rotateY(0deg);
}
.jmb-channel-back {
transform: rotateY(180deg);
text-align: left;
justify-content: flex-start;
gap: 0.5rem;
}
.jmb-channel-back-title {
font-size: 0.9rem;
font-weight: 700;
}
.jmb-channel-desc {
font-size: 0.85rem;
line-height: 1.35;
opacity: 0.9;
overflow: auto; /* długi opis nie rozwala karty */
}
.jmb-search {
display: flex;
gap: 0.5rem;
align-items: center;
margin: 0.75rem 0 0.25rem;
}
.jmb-search-input {
flex: 1;
padding: 0.6rem 0.75rem;
border-radius: 0.75rem;
border: 1px solid rgba(148, 163, 184, 0.35);
background: var(--fuz-bg);
color: var(--fuz-text);
outline: none;
}
.jmb-search-input:focus {
border-color: rgba(59, 130, 246, 0.55);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
.jmb-search-clear {
padding: 0.55rem 0.7rem;
border-radius: 0.75rem;
border: 1px solid rgba(148, 163, 184, 0.35);
background: transparent;
}
.jmb-search-meta {
font-size: 0.85rem;
opacity: 0.75;
margin-bottom: 0.5rem;
}