Pakiety tematyczne
This commit is contained in:
111
src/islands/jambox/AddonChannelsModal.jsx
Normal file
111
src/islands/jambox/AddonChannelsModal.jsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useMemo, useState } from "preact/hooks";
|
||||
|
||||
function cleanPkgName(v) {
|
||||
const s = String(v || "").trim();
|
||||
if (!s) return null;
|
||||
if (s.length > 64) return null;
|
||||
return s;
|
||||
}
|
||||
|
||||
function getNearestSectionEl(el) {
|
||||
return el?.closest?.("[data-addon-section]") ?? null;
|
||||
}
|
||||
|
||||
export default function AddonChannelsGrid(props) {
|
||||
const packageName = cleanPkgName(props?.packageName);
|
||||
const fallbackImage = String(props?.fallbackImage || "").trim();
|
||||
const title = String(props?.title || "").trim();
|
||||
const aboveFold = props?.aboveFold === true;
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
const [items, setItems] = useState([]);
|
||||
|
||||
const rootRef = useMemo(() => ({ current: null }), []);
|
||||
|
||||
const channelsWithLogo = useMemo(() => {
|
||||
return (items || []).filter((x) => String(x?.logo_url || "").trim());
|
||||
}, [items]);
|
||||
|
||||
async function load() {
|
||||
if (!packageName) return;
|
||||
setLoading(true);
|
||||
setErr("");
|
||||
try {
|
||||
const url = `/api/jambox/jambox-channels-package?package=${encodeURIComponent(
|
||||
packageName,
|
||||
)}`;
|
||||
const res = await fetch(url);
|
||||
const json = await res.json().catch(() => null);
|
||||
if (!res.ok || !json?.ok) throw new Error(json?.error || "FETCH_ERROR");
|
||||
setItems(Array.isArray(json.data) ? json.data : []);
|
||||
} catch (e) {
|
||||
setErr(String(e?.message || e));
|
||||
setItems([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [packageName]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = rootRef.current;
|
||||
const section = getNearestSectionEl(el);
|
||||
if (!section) return;
|
||||
|
||||
const hasIcons = channelsWithLogo.length > 0;
|
||||
const hasFallback = !!fallbackImage;
|
||||
const hasMedia = hasIcons || hasFallback;
|
||||
|
||||
section.setAttribute("data-has-media", hasMedia ? "1" : "0");
|
||||
}, [channelsWithLogo.length, fallbackImage]);
|
||||
|
||||
const hasIcons = channelsWithLogo.length > 0;
|
||||
const visible = hasIcons ? channelsWithLogo.slice(0, 60) : [];
|
||||
|
||||
return (
|
||||
<div ref={(el) => (rootRef.current = el)}>
|
||||
{hasIcons ? (
|
||||
<div class="f-channels-grid" aria-label={title || packageName || "Kanały"}>
|
||||
{visible.map((ch, idx) => {
|
||||
const logo = String(ch?.logo_url || "").trim();
|
||||
const name = String(ch?.name || "").trim();
|
||||
|
||||
return (
|
||||
<div class="f-channel-item" title={name}>
|
||||
{logo ? (
|
||||
<img
|
||||
class="f-channel-logo"
|
||||
src={logo}
|
||||
alt=""
|
||||
loading={aboveFold && idx < 12 ? "eager" : "lazy"}
|
||||
decoding="async"
|
||||
/>
|
||||
) : (
|
||||
<div class="f-channel-logo-placeholder" />
|
||||
)}
|
||||
<div class="f-channel-label">{name}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : fallbackImage ? (
|
||||
<img
|
||||
class="f-addon-fallback-image"
|
||||
src={fallbackImage}
|
||||
alt={title || packageName || ""}
|
||||
loading={aboveFold ? "eager" : "lazy"}
|
||||
decoding="async"
|
||||
/>
|
||||
) : (
|
||||
<div class="sr-only">
|
||||
{loading ? "Ładowanie kanałów" : err ? `Błąd: ${err}` : "Brak kanałów"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -59,6 +59,7 @@ function normalizeAddons(addons) {
|
||||
krok: a.krok != null ? Number(a.krok) : 1,
|
||||
opis: a.opis ? String(a.opis) : "",
|
||||
cena: a.cena ?? 0,
|
||||
tid: String(a.tid),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -335,7 +336,16 @@ export default function JamboxAddonsModal({
|
||||
|
||||
<div class="f-addon-main">
|
||||
<div class="f-addon-name">{a.nazwa}</div>
|
||||
{a.opis && <div class="f-addon-desc">{a.opis}</div>}
|
||||
{/* {a.opis && <div class="f-addon-desc">{a.opis}</div>} */}
|
||||
<a
|
||||
href={`/internet-telewizja/pakiety-tematyczne#${a.tid}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={`Więcej informacji o pakiecie ${a.nazwa ?? ""} (otwiera się w nowej karcie)`}
|
||||
title={`Więcej o pakiecie ${a.nazwa ?? ""}`}
|
||||
>
|
||||
Przejdź do szczegółowch informacji o pakiecie tematycznnym
|
||||
</a>
|
||||
|
||||
{termPricing && (
|
||||
<div class="mt-2 flex flex-wrap gap-3 text-sm" onClick={(e) => e.stopPropagation()}>
|
||||
@@ -587,7 +597,7 @@ export default function JamboxAddonsModal({
|
||||
</SectionAccordion>
|
||||
</div>
|
||||
|
||||
{/* ✅ DEKODER (sekcja) */}
|
||||
{/* DEKODER */}
|
||||
<div class="f-modal-section">
|
||||
<SectionAccordion
|
||||
title="Wybór dekodera"
|
||||
@@ -637,7 +647,7 @@ export default function JamboxAddonsModal({
|
||||
</SectionAccordion>
|
||||
</div>
|
||||
|
||||
{/* ✅ TV ADDONS (sekcja) */}
|
||||
{/* TV ADDONS */}
|
||||
<div class="f-modal-section">
|
||||
<SectionAccordion
|
||||
title="Pakiety dodatkowe TV"
|
||||
@@ -657,7 +667,7 @@ export default function JamboxAddonsModal({
|
||||
</SectionAccordion>
|
||||
</div>
|
||||
|
||||
{/* ✅ TELEFON (sekcja) */}
|
||||
{/* TELEFON */}
|
||||
<div class="f-modal-section">
|
||||
<SectionAccordion
|
||||
title="Usługa telefoniczna"
|
||||
@@ -744,7 +754,7 @@ export default function JamboxAddonsModal({
|
||||
</SectionAccordion>
|
||||
</div>
|
||||
|
||||
{/* ✅ DODATKI (sekcja) */}
|
||||
{/* DODATKI */}
|
||||
<div class="f-modal-section">
|
||||
<SectionAccordion
|
||||
title="Dodatkowe usługi"
|
||||
@@ -764,7 +774,7 @@ export default function JamboxAddonsModal({
|
||||
</SectionAccordion>
|
||||
</div>
|
||||
|
||||
{/* ✅ PODSUMOWANIE (sekcja) */}
|
||||
{/* PODSUMOWANIE */}
|
||||
<div class="f-modal-section">
|
||||
<SectionAccordion
|
||||
title="Podsumowanie miesięczne"
|
||||
@@ -818,15 +828,6 @@ export default function JamboxAddonsModal({
|
||||
</SectionAccordion>
|
||||
</div>
|
||||
|
||||
{/* ✅ pływająca suma jak w internecie */}
|
||||
{/* <div class="f-floating-total" onClick={(e) => e.stopPropagation()}>
|
||||
<div class="f-floating-total-inner">
|
||||
<span class="f-floating-total-label">Suma</span>
|
||||
<span class="f-floating-total-value">
|
||||
{money(totalMonthly)} {cenaOpis}
|
||||
</span>
|
||||
</div>
|
||||
</div> */}
|
||||
<div
|
||||
ref={floating.ref}
|
||||
class="f-floating-total"
|
||||
|
||||
@@ -8,7 +8,7 @@ export default function JamboxChannelsSearch() {
|
||||
const [err, setErr] = useState("");
|
||||
|
||||
// ✅ koszyk kanałów
|
||||
const [wanted, setWanted] = useState([]); // [{ name, logo_url, packages:[{id,name}] }]
|
||||
const [wanted, setWanted] = useState([]); // [{ name, logo_url, packages:[{id,name}], thematic_packages:[{tid,name}] }]
|
||||
|
||||
const abortRef = useRef(null);
|
||||
|
||||
@@ -34,10 +34,13 @@ export default function JamboxChannelsSearch() {
|
||||
params.set("q", qq);
|
||||
params.set("limit", "80");
|
||||
|
||||
const res = await fetch(`/api/jambox/jambox-channels-search?${params.toString()}`, {
|
||||
signal: ac.signal,
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
const res = await fetch(
|
||||
`/api/jambox/jambox-channels-search?${params.toString()}`,
|
||||
{
|
||||
signal: ac.signal,
|
||||
headers: { Accept: "application/json" },
|
||||
}
|
||||
);
|
||||
|
||||
const json = await res.json();
|
||||
if (!res.ok || !json.ok) throw new Error(json?.error || "API_ERROR");
|
||||
@@ -83,12 +86,16 @@ export default function JamboxChannelsSearch() {
|
||||
// ✅ koszyk: dodaj/usuń kanał
|
||||
// ==========================
|
||||
const isWanted = (c) =>
|
||||
wanted.some((w) => String(w.name || "").toLowerCase() === String(c.name || "").toLowerCase());
|
||||
wanted.some(
|
||||
(w) =>
|
||||
String(w.name || "").toLowerCase() === String(c.name || "").toLowerCase()
|
||||
);
|
||||
|
||||
function addWanted(c) {
|
||||
setWanted((prev) => {
|
||||
const exists = prev.some(
|
||||
(w) => String(w.name || "").toLowerCase() === String(c.name || "").toLowerCase()
|
||||
(w) =>
|
||||
String(w.name || "").toLowerCase() === String(c.name || "").toLowerCase()
|
||||
);
|
||||
if (exists) return prev;
|
||||
|
||||
@@ -98,6 +105,9 @@ export default function JamboxChannelsSearch() {
|
||||
name: c.name,
|
||||
logo_url: c.logo_url || "",
|
||||
packages: Array.isArray(c.packages) ? c.packages : [],
|
||||
thematic_packages: Array.isArray(c.thematic_packages)
|
||||
? c.thematic_packages
|
||||
: [],
|
||||
},
|
||||
];
|
||||
});
|
||||
@@ -105,7 +115,10 @@ export default function JamboxChannelsSearch() {
|
||||
|
||||
function removeWantedByName(name) {
|
||||
setWanted((prev) =>
|
||||
prev.filter((w) => String(w.name || "").toLowerCase() !== String(name || "").toLowerCase())
|
||||
prev.filter(
|
||||
(w) =>
|
||||
String(w.name || "").toLowerCase() !== String(name || "").toLowerCase()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -114,12 +127,14 @@ export default function JamboxChannelsSearch() {
|
||||
}
|
||||
|
||||
// =========================================
|
||||
// ✅ pakiety, które zawierają WSZYSTKIE kanały
|
||||
// ✅ sugestie pakietów dla koszyka
|
||||
// - GŁÓWNE: exact/ranked (z count)
|
||||
// - TEMATYCZNE: dodatki do dokupienia (bez liczenia)
|
||||
// =========================================
|
||||
const packageSuggestions = useMemo(() => {
|
||||
if (!wanted.length) return { exact: [], ranked: [] };
|
||||
if (!wanted.length) return { exact: [], ranked: [], thematic: [] };
|
||||
|
||||
// mapa pakietu -> { id,name,count }
|
||||
// ======= GŁÓWNE =======
|
||||
const counts = new Map(); // key = packageName
|
||||
for (const ch of wanted) {
|
||||
const pkgs = Array.isArray(ch.packages) ? ch.packages : [];
|
||||
@@ -134,25 +149,41 @@ export default function JamboxChannelsSearch() {
|
||||
|
||||
const all = Array.from(counts.values());
|
||||
|
||||
// ✅ exact: pakiety z count == wanted.length (czyli zawierają wszystkie)
|
||||
const exact = all
|
||||
.filter((p) => p.count === wanted.length)
|
||||
.sort((a, b) => a.name.localeCompare(b.name, "pl"));
|
||||
|
||||
// ✅ ranked: najlepsze dopasowania gdy exact puste (malejąco count)
|
||||
const ranked = all
|
||||
.filter((p) => p.count < wanted.length)
|
||||
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name, "pl"))
|
||||
.slice(0, 12);
|
||||
|
||||
return { exact, ranked };
|
||||
// ======= TEMATYCZNE (dodatki) =======
|
||||
const thematicMap = new Map(); // key = tid
|
||||
for (const ch of wanted) {
|
||||
const tp = Array.isArray(ch.thematic_packages)
|
||||
? ch.thematic_packages
|
||||
: [];
|
||||
for (const p of tp) {
|
||||
const tid = String(p?.tid ?? "").trim();
|
||||
const name = String(p?.name ?? "").trim();
|
||||
if (!tid || !name) continue;
|
||||
if (!thematicMap.has(tid)) thematicMap.set(tid, { tid, name });
|
||||
}
|
||||
}
|
||||
|
||||
const thematic = Array.from(thematicMap.values()).sort((a, b) =>
|
||||
a.name.localeCompare(b.name, "pl")
|
||||
);
|
||||
|
||||
return { exact, ranked, thematic };
|
||||
}, [wanted]);
|
||||
|
||||
return (
|
||||
<div class="f-chsearch">
|
||||
<h1 class="f-section-title">Wyszukiwanie kanałów w pakietach telewizji</h1>
|
||||
|
||||
{/* ✅ SEK CJA "CHCIAŁBYM MIEĆ TE KANAŁY" */}
|
||||
{/* ✅ SEKCJA "CHCIAŁBYM MIEĆ TE KANAŁY" */}
|
||||
<div class="f-chsearch__wanted">
|
||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div class="text-lg font-semibold">Chciałbym mieć te kanały</div>
|
||||
@@ -166,7 +197,8 @@ export default function JamboxChannelsSearch() {
|
||||
|
||||
{wanted.length === 0 ? (
|
||||
<div class="opacity-80 mt-2">
|
||||
Dodaj kanały z listy wyników — pokażę pakiety, które zawierają wszystkie wybrane kanały.
|
||||
Dodaj kanały z listy wyników — pokażę pakiety, które zawierają
|
||||
wszystkie wybrane kanały.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -174,7 +206,12 @@ export default function JamboxChannelsSearch() {
|
||||
{wanted.map((w) => (
|
||||
<div class="f-chsearch__wanted-chip" key={w.name}>
|
||||
{w.logo_url ? (
|
||||
<img src={w.logo_url} alt={w.name} class="f-chsearch__wanted-logo" loading="lazy" />
|
||||
<img
|
||||
src={w.logo_url}
|
||||
alt={w.name}
|
||||
class="f-chsearch__wanted-logo"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : null}
|
||||
<span class="f-chsearch__wanted-name">{w.name}</span>
|
||||
<button
|
||||
@@ -191,29 +228,29 @@ export default function JamboxChannelsSearch() {
|
||||
|
||||
{/* ✅ SUGESTIE PAKIETÓW */}
|
||||
<div class="f-chsearch__wanted-packages">
|
||||
<div class="font-semibold">Pakiety pasujące do wybranych kanałów:</div>
|
||||
<div class="font-semibold">
|
||||
Pakiety pasujące do wybranych kanałów:
|
||||
</div>
|
||||
|
||||
{packageSuggestions.exact.length > 0 ? (
|
||||
<div class="flex flex-wrap gap-2 mt-2">
|
||||
{packageSuggestions.exact.map((p) => (
|
||||
<button
|
||||
type="button"
|
||||
key={p.name}
|
||||
class="f-chsearch-pkg"
|
||||
onClick={() => scrollToPackage(p.name)}
|
||||
title="Kliknij, aby przewinąć do pakietu"
|
||||
>
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div class="opacity-80 mt-2">
|
||||
Nie ma jednego pakietu zawierającego wszystkie wybrane kanały.
|
||||
Poniżej najlepsze dopasowania:
|
||||
{/* ======= GŁÓWNE (jak było) ======= */}
|
||||
<div class="mt-2">
|
||||
<div class="opacity-80">Pakiety główne:</div>
|
||||
|
||||
{packageSuggestions.exact.length > 0 ? (
|
||||
<div class="flex flex-wrap gap-2 mt-2">
|
||||
{packageSuggestions.exact.map((p) => (
|
||||
<button
|
||||
type="button"
|
||||
key={p.name}
|
||||
class="f-chsearch-pkg"
|
||||
onClick={() => scrollToPackage(p.name)}
|
||||
title="Kliknij, aby przewinąć do pakietu"
|
||||
>
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
) : (
|
||||
<div class="flex flex-wrap gap-2 mt-2">
|
||||
{packageSuggestions.ranked.map((p) => (
|
||||
<button
|
||||
@@ -227,7 +264,33 @@ export default function JamboxChannelsSearch() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ======= TEMATYCZNE — dodatki (bez liczenia) ======= */}
|
||||
{packageSuggestions.thematic.length > 0 && (
|
||||
<div class="mt-4">
|
||||
<div class="opacity-80">
|
||||
Pakiety tematyczne do dokupienia:
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 mt-2">
|
||||
{packageSuggestions.thematic.map((p) => (
|
||||
<a
|
||||
key={p.tid}
|
||||
class="f-chsearch-pkg"
|
||||
href={`/internet-telewizja/pakiety-tematyczne#${encodeURIComponent(
|
||||
p.tid
|
||||
)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Otwórz w nowej karcie"
|
||||
>
|
||||
{p.name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
@@ -268,11 +331,20 @@ export default function JamboxChannelsSearch() {
|
||||
const selected = isWanted(c);
|
||||
|
||||
return (
|
||||
<div class="f-chsearch__row" role="listitem" key={`${c.name}-${c.logo_url || ""}`}>
|
||||
<div
|
||||
class="f-chsearch__row"
|
||||
role="listitem"
|
||||
key={`${c.name}-${c.logo_url || ""}`}
|
||||
>
|
||||
{/* kolumna 1 */}
|
||||
<div class="f-chsearch__left">
|
||||
{c.logo_url && (
|
||||
<img src={c.logo_url} alt={c.name} class="f-chsearch__logo" loading="lazy" />
|
||||
<img
|
||||
src={c.logo_url}
|
||||
alt={c.name}
|
||||
class="f-chsearch__logo"
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div class="f-chsearch__channel-name">{c.name}</div>
|
||||
@@ -280,11 +352,19 @@ export default function JamboxChannelsSearch() {
|
||||
{/* ✅ przycisk dodaj/usuń */}
|
||||
<div class="mt-2">
|
||||
{!selected ? (
|
||||
<button type="button" class="btn btn-outline" onClick={() => addWanted(c)}>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline"
|
||||
onClick={() => addWanted(c)}
|
||||
>
|
||||
Dodaj do “Chciałbym mieć”
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" class="btn btn-primary" onClick={() => removeWantedByName(c.name)}>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
onClick={() => removeWantedByName(c.name)}
|
||||
>
|
||||
Usuń z listy
|
||||
</button>
|
||||
)}
|
||||
@@ -295,7 +375,9 @@ export default function JamboxChannelsSearch() {
|
||||
<div class="f-chsearch__right">
|
||||
<div
|
||||
class="f-chsearch__desc f-chsearch__desc--html"
|
||||
dangerouslySetInnerHTML={{ __html: c.description || "<em>—</em>" }}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: c.description || "<em>—</em>",
|
||||
}}
|
||||
/>
|
||||
|
||||
{Array.isArray(c.packages) && c.packages.length > 0 && (
|
||||
@@ -303,13 +385,40 @@ export default function JamboxChannelsSearch() {
|
||||
Dostępny w pakietach:
|
||||
{c.packages.map((p) => (
|
||||
<span key={p.id}>
|
||||
<button type="button" class="f-chsearch-pkg" onClick={() => scrollToPackage(p.name)}>
|
||||
<button
|
||||
type="button"
|
||||
class="f-chsearch-pkg"
|
||||
onClick={() => scrollToPackage(p.name)}
|
||||
title="Kliknij, aby przewinąć do pakietu"
|
||||
>
|
||||
{p.name}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{Array.isArray(c.thematic_packages) &&
|
||||
c.thematic_packages.length > 0 && (
|
||||
<div class="f-chsearch__packages">
|
||||
Dostępny w pakietach tematycznych:
|
||||
{c.thematic_packages.map((p) => (
|
||||
<span key={`${p.tid}-${p.name}`}>
|
||||
<a
|
||||
class="f-chsearch-pkg"
|
||||
href={`/internet-telewizja/pakiety-tematyczne#${encodeURIComponent(
|
||||
p.tid
|
||||
)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Otwórz w nowej karcie"
|
||||
>
|
||||
{p.name}
|
||||
</a>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user