Pakiety tematyczne

This commit is contained in:
dm
2025-12-16 20:10:08 +01:00
parent ff05d0dee9
commit 4f0f171bdc
16 changed files with 1320 additions and 146 deletions

View File

@@ -1,4 +1,17 @@
sections:
- title: Pakiety tematyczne
image:
button:
text: "Poznaj ofertę pakietów tematycznych →"
url: "/internet-telewizja/pakiety-tematyczne"
title: "Poznaj ofertę pakietów tematycznych"
content: |
Dolore no invidunt ipsum justo. Et et dolor gubergren ipsum.
Ipsum luptatum magna dolore nonumy tempor stet volutpat ut nobis nonumy invidunt labore autem consequat nulla dolor amet vel.
Doming ea dolor lorem justo sed velit takimata nobis clita ad ipsum. Sed esse erat est at ipsum dolore ut sadipscing diam voluptua sea ut.
Dolores ad eos invidunt ut blandit tempor lorem sed est ipsum elit eos diam erat sed amet. Voluptua voluptua ea amet duis molestie tempor amet aliquyam et takimata stet ea accusam soluta eum aliquyam diam accumsan. Labore odio et sed ut possim takimata nonumy sadipscing feugiat option facilisi invidunt vulputate sadipscing accusam. Facilisis diam clita dolor sed eirmod dolor dolor. Diam no kasd laoreet blandit gubergren.
Aliquyam ea nulla euismod sanctus sed eirmod exerci invidunt dolores nonumy.
- title: "Dekoder Arris 4302 HD"
image: "arris4302.webp"
button:

View File

@@ -1,120 +1,204 @@
tytul: Dodatkowe pakiety TV
opis: "Rozszerz ofertę telewizyjną o dodatkowe pakiety."
cena_opis: "zł/mies."
opis: |
Rozszerz ofertę telewizyjną o dodatkowe pakiety.
Lorem ipsum dolor sit amet nulla. Elitr eum sanctus diam rebum accusam est ex.
Hendrerit erat commodo lorem gubergren vulputate dolor labore amet eros justo lorem no sea.
Facer mazim eos nonumy rebum dolor euismod. Sed est in sed odio. Vero illum vero aliquyam nonumy duis.
Labore et rebum elitr amet sanctus in aliquyam dignissim lorem accusam et rebum tempor kasd.
cena_opis: zł/mies.
dodatki:
- id: canal_seriale_filmy
nazwa: "Canal+ Seriale i Filmy"
nazwa: CANAL+ Seriale i Filmy
tid: 49
typ: checkbox
opis: "Pakiet filmowo-serialowy Canal+."
opis: Pakiet filmowo-serialowy Canal+.
cena:
- pakiety: [Smart, Optimum, Platinum, Podstawowy, Korzystny, Bogaty]
- pakiety:
- Smart
- Optimum
- Platinum
- Podstawowy
- Korzystny
- Bogaty
12m: 24.99
bezterminowo: 28.99
- id: canal_super_sport
nazwa: "Canal+ Super Sport"
nazwa: CANAL+ Super Sport
typ: checkbox
opis: "Pakiet sportowy Canal+."
opis: Pakiet sportowy Canal+.
cena:
- pakiety: [Smart, Optimum, Platinum, Podstawowy, Korzystny, Bogaty]
- pakiety:
- Smart
- Optimum
- Platinum
- Podstawowy
- Korzystny
- Bogaty
12m: 64.99
bezterminowo: 68.99
tid: 48
- id: cinemax
nazwa: "Cinemax"
nazwa: Cinemax
typ: checkbox
opis: "Kanały Cinemax."
opis: Kanały Cinemax.
cena:
# SGT (PLUS): 10 / 15
- pakiety: [Podstawowy, Korzystny, Bogaty]
12m: 10.00
bezterminowo: 15.00
# EVIO: jedna cena 14.90
- pakiety: [Smart, Optimum, Platinum]
# 12m: 14.90
bezterminowo: 14.90
- pakiety:
- Podstawowy
- Korzystny
- Bogaty
12m: 10
bezterminowo: 15
- pakiety:
- Smart
- Optimum
- Platinum
bezterminowo: 14.9
tid: 18
- id: eleven
nazwa: "Eleven"
nazwa: Eleven
typ: checkbox
opis: "Kanały Eleven Sports."
opis: Kanały Eleven Sports.
cena:
- pakiety: [Podstawowy, Korzystny, Bogaty]
12m: 15.00
bezterminowo: 25.00
- pakiety:
- Podstawowy
- Korzystny
- Bogaty
12m: 15
bezterminowo: 25
tid: 61
- id: filmbox
nazwa: "Filmbox"
nazwa: FilmBox+
typ: checkbox
opis: "Kanały FilmBox."
opis: Kanały FilmBox.
cena:
- pakiety: [Podstawowy, Korzystny, Bogaty]
12m: 10.00
bezterminowo: 15.00
- pakiety:
- Podstawowy
- Korzystny
- Bogaty
12m: 10
bezterminowo: 15
tid: 19
- id: hbo_max_podstawowy
nazwa: "HBO + Max Podstawowy"
nazwa: HBO + Max Podstawowy
typ: checkbox
opis: |
W ramach Pakietu Podstawowego HBO Max możesz oglądać filmy i seriale w jakości FullHD na dwóch urządzeniach jednocześnie.
Pakiet Podstawowy HBO Max to również dostęp do bogatej Biblioteki TVN oraz możliwość śledzenia kanału live TVN.
Treści dostępne w Pakiecie Podstawowym wyświetlane są wraz z reklamami.
cena:
- pakiety: [Smart, Optimum, Platinum, Podstawowy, Korzystny, Bogaty]
- pakiety:
- Smart
- Optimum
- Platinum
- Podstawowy
- Korzystny
- Bogaty
12m: 27.99
bezterminowo: 29.99
tid: 20
- id: hbo_max_standardowy
nazwa: "HBO + Max Standardowy"
nazwa: HBO + Max Standardowy
typ: checkbox
opis: "HBO + Max (wariant standardowy)."
opis: HBO + Max (wariant standardowy).
cena:
- pakiety: [Smart, Optimum, Platinum, Podstawowy, Korzystny, Bogaty]
- pakiety:
- Smart
- Optimum
- Platinum
- Podstawowy
- Korzystny
- Bogaty
12m: 36.99
bezterminowo: 39.99
tid: 96
- id: hbo_max_premium
nazwa: "HBO + Max Premium"
nazwa: HBO + Max Premium
typ: checkbox
opis: "HBO + Max (wariant premium)."
opis: HBO + Max (wariant premium).
cena:
- pakiety: [Smart, Optimum, Platinum, Podstawowy, Korzystny, Bogaty]
- pakiety:
- Smart
- Optimum
- Platinum
- Podstawowy
- Korzystny
- Bogaty
12m: 44.99
bezterminowo: 49.99
tid: 97
- id: wiecej_sportu_plus
nazwa: "Więcej Sportu Plus"
nazwa: Więcej Sportu Plus
typ: checkbox
opis: "Dodatkowy pakiet sportowy."
opis: Dodatkowy pakiet sportowy.
cena:
- pakiety: [Podstawowy, Korzystny, Bogaty]
12m: 15.00
bezterminowo: 25.00
- pakiety:
- Podstawowy
- Korzystny
- Bogaty
12m: 15
bezterminowo: 25
tid: 79
- id: wiecej_erotyki
nazwa: "Więcej Erotyki"
nazwa: Więcej Erotyki
typ: checkbox
opis: "Pakiet kanałów erotycznych."
opis: Pakiet kanałów erotycznych.
cena:
- pakiety: [Podstawowy, Korzystny, Bogaty]
12m: 15.00
bezterminowo: 25.00
- pakiety:
- Podstawowy
- Korzystny
- Bogaty
12m: 15
bezterminowo: 25
tid: 80
- id: disney_standard
nazwa: "Disney+ Standard"
nazwa: Disney+ Standard
typ: checkbox
opis: |
Odkryj hity filmowe, nowe seriale i produkcje oryginalne ze świata Disneya, Pixara, Gwiezdnych wojen, Marvela, a także produkcje Hulu, National Geographic i FX
cena:
- pakiety: [Smart, Optimum, Platinum, Podstawowy, Korzystny, Bogaty]
bezterminowo: 34.99
Historie na całe życie czekają. Odkryj hity filmowe, nowe seriale i produkcje oryginalne ze świata Disneya, Pixara, Gwiezdnych wojen, Marvela, a także produkcje Hulu, National Geographic i FX.
Oglądaj, kiedy chcesz.
Ochrona rodzicielska - Zadbaj o bezpieczeństwo dzięki intuicyjnej kontroli rodzicielskiej. treściami
Jednoczesne oglądanie - Oglądaj na czterech ekranach jednocześnie, na obsługiwanych urządzeniach.
Rozrywka bez granic - Tysiące godzin seriali, filmów i produkcji oryginalnych.
Wygodne oglądanie - Możliwość oglądania jak chcesz, kiedy chcesz.
- Oglądaj na 2 urządzeniach jednocześnie
- Full HD
- Bez reklam
- Pobieranie offline
cena:
- pakiety:
- Smart
- Optimum
- Platinum
- Podstawowy
- Korzystny
- Bogaty
bezterminowo: 34.99
- id: disney_premium
nazwa: "Disney+ Premium"
nazwa: Disney+ Premium
typ: checkbox
opis: |
Odkryj hity filmowe, nowe seriale i produkcje oryginalne ze świata Disneya, Pixara, Gwiezdnych wojen, Marvela, a także produkcje Hulu, National Geographic i FX
Historie na całe życie czekają. Odkryj hity filmowe, nowe seriale i produkcje oryginalne ze świata Disneya, Pixara, Gwiezdnych wojen, Marvela, a także produkcje Hulu, National Geographic i FX.
Oglądaj, kiedy chcesz.
Ochrona rodzicielska - Zadbaj o bezpieczeństwo dzięki intuicyjnej kontroli rodzicielskiej. treściami
Jednoczesne oglądanie - Oglądaj na czterech ekranach jednocześnie, na obsługiwanych urządzeniach.
Rozrywka bez granic - Tysiące godzin seriali, filmów i produkcji oryginalnych.
Wygodne oglądanie - Możliwość oglądania jak chcesz, kiedy chcesz.
- Oglądaj na 4 urządzeniach jednocześnie
- 4K UHD / HDR / Dolby Atmos*
- Bez reklam
- Pobieranie offline
cena:
- pakiety: [Smart, Optimum, Platinum, Podstawowy, Korzystny, Bogaty]
- pakiety:
- Smart
- Optimum
- Platinum
- Podstawowy
- Korzystny
- Bogaty
bezterminowo: 59.99

Binary file not shown.

Binary file not shown.

Binary file not shown.

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

View File

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

View File

@@ -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:&nbsp;
{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:&nbsp;
{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>
);

View File

@@ -0,0 +1,347 @@
import path from "node:path";
import fs from "node:fs/promises";
import yaml from "js-yaml";
import { XMLParser } from "fast-xml-parser";
import Database from "better-sqlite3";
const CHANNELS_URL = "https://www.jambox.pl/xml/listakanalow.xml";
const DB_PATH =
process.env.FUZ_DB_PATH ||
path.join(process.cwd(), "src", "data", "ServicesRange.db");
function isAuthorized(request) {
const expected = import.meta.env.JAMBOX_ADMIN_TOKEN;
if (!expected) return false;
const token = request.headers.get("x-admin-token");
return token === expected;
}
function getDb() {
const db = new Database(DB_PATH);
db.pragma("journal_mode = WAL");
return db;
}
async function fetchXml(url) {
const res = await fetch(url, {
headers: { accept: "application/xml,text/xml,*/*" },
});
if (!res.ok) throw new Error(`XML HTTP ${res.status} for ${url}`);
return await res.text();
}
function parseNodes(xmlText) {
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: "@_",
trimValues: true,
});
const json = parser.parse(xmlText);
const nodes = json?.xml?.node ?? json?.node ?? [];
return Array.isArray(nodes) ? nodes : [nodes];
}
function toInt(v) {
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
function parseCsvIds(v) {
const s = String(v ?? "").trim();
if (!s) return [];
return s
.split(",")
.map((x) => toInt(x.trim()))
.filter((n) => n != null);
}
function decodeEntities(input) {
if (!input) return "";
let s = String(input)
.replace(/&nbsp;/g, " ")
.replace(/&ndash;/g, "")
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&#39;/g, "'")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">");
s = s.replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d)));
s = s.replace(/&#x([0-9a-fA-F]+);/g, (_, h) =>
String.fromCodePoint(parseInt(h, 16)),
);
return s;
}
function htmlToMarkdown(input) {
if (!input) return "";
let html = "";
if (typeof input === "string") html = input;
else if (input?.p) {
if (typeof input.p === "string") html = `<p>${input.p}</p>`;
else if (Array.isArray(input.p))
html = input.p.map((p) => `<p>${p}</p>`).join("");
} else html = String(input);
let s = decodeEntities(html);
s = s
.replace(/<\s*(ul|ol)[^>]*>/gi, "\n__LIST_START__\n")
.replace(/<\/\s*(ul|ol)\s*>/gi, "\n__LIST_END__\n")
.replace(/<\s*li[^>]*>/gi, "__LI__")
.replace(/<\/\s*li\s*>/gi, "\n")
.replace(/<\s*br\s*\/?\s*>/gi, "\n")
.replace(/<\/\s*(p|div)\s*>/gi, "\n")
.replace(/<\s*(p|div)[^>]*>/gi, "")
.replace(/<[^>]+>/g, "")
.replace(/\r/g, "")
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
const lines = s.split("\n").map((x) => x.trim());
const out = [];
let inList = false;
for (const line of lines) {
if (!line) {
if (!inList) out.push("");
continue;
}
if (line === "__LIST_START__") {
inList = true;
continue;
}
if (line === "__LIST_END__") {
inList = false;
out.push("");
continue;
}
if (inList && line.startsWith("__LI__")) {
out.push(`- ${line.replace("__LI__", "").trim()}`);
continue;
}
out.push(line);
}
return out.join("\n").trim();
}
function extractLogoUrl(node) {
const logo = node?.field_logo_fid;
if (!logo) return null;
// w listakanalow.xml jest zwykle bezpośredni URL
if (typeof logo === "string") {
const s = logo.trim();
if (!s) return null;
const m = s.match(/src="([^"]+)"/);
return m?.[1] ?? s;
}
if (logo?.img?.["@_src"]) return logo.img["@_src"];
return null;
}
async function downloadLogoAsBase64(url) {
try {
const res = await fetch(url);
if (!res.ok) return null;
const ct = res.headers.get("content-type") || "image/png";
const buf = Buffer.from(await res.arrayBuffer());
if (!buf.length) return null;
return `data:${ct};base64,${buf.toString("base64")}`;
} catch {
return null;
}
}
function ensureTidColumn(db) {
const cols = db
.prepare("PRAGMA table_info(jambox_channels)")
.all()
.map((r) => String(r.name || "").toLowerCase());
if (!cols.includes("tid")) {
db.exec("ALTER TABLE jambox_channels ADD COLUMN tid INTEGER;");
}
}
export async function POST({ request }) {
if (!isAuthorized(request)) {
return new Response(JSON.stringify({ ok: false, error: "Unauthorized" }), {
status: 401,
headers: { "content-type": "application/json; charset=utf-8" },
});
}
const body = await request.json().catch(() => ({}));
const dryRun = body?.dryRun === true;
const YAML_PATH =
import.meta.env.JAMBOX_TV_ADDONS_YAML_PATH ||
path.join(
process.cwd(),
"src",
"content",
"internet-telewizja",
"tv-addons.yaml",
);
const db = getDb();
// tabela + unikalność do ON CONFLICT
db.exec(`
CREATE TABLE IF NOT EXISTS jambox_channels (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nazwa TEXT NOT NULL,
pckg_name TEXT NOT NULL,
image TEXT,
opis TEXT,
pckg_addons INTEGER NOT NULL DEFAULT (0)
);
CREATE UNIQUE INDEX IF NOT EXISTS ux_jambox_channels_name_pkg
ON jambox_channels(nazwa, pckg_name);
`);
// ✅ dorób kolumnę tid (dla addonów)
ensureTidColumn(db);
const delAddons = db.prepare(
`DELETE FROM jambox_channels WHERE pckg_addons = 1`,
);
// ✅ zapisujemy pckg_addons=1 i tid
const upsert = db.prepare(`
INSERT INTO jambox_channels (nazwa, pckg_name, image, opis, pckg_addons, tid)
VALUES (@nazwa, @pckg_name, @image, @opis, 1, @tid)
ON CONFLICT(nazwa, pckg_name) DO UPDATE SET
image = COALESCE(excluded.image, jambox_channels.image),
opis = COALESCE(excluded.opis, jambox_channels.opis),
pckg_addons = 1,
tid = COALESCE(excluded.tid, jambox_channels.tid)
`);
const logoCache = new Map();
const rows = [];
try {
// 1) YAML -> tid dodatku
const rawYaml = await fs.readFile(YAML_PATH, "utf8");
const doc = yaml.load(rawYaml);
if (!doc || !Array.isArray(doc.dodatki)) {
return new Response(
JSON.stringify({ ok: false, error: "YAML: brak doc.dodatki" }),
{
status: 400,
headers: { "content-type": "application/json; charset=utf-8" },
},
);
}
// tid -> nazwa pakietu (z YAML)
const addonTidToName = new Map();
for (const a of doc.dodatki) {
const tid = toInt(a?.tid);
const name = String(a?.nazwa ?? "").trim();
if (tid != null && name) addonTidToName.set(tid, name);
}
const addonTids = new Set(addonTidToName.keys());
// 2) XML kanałów
const xml = await fetchXml(CHANNELS_URL);
const nodes = parseNodes(xml);
for (const node of nodes) {
const channelName = String(
node?.nazwa_kanalu ?? node?.nazwa ?? "",
).trim();
if (!channelName) continue;
const pkgIds = parseCsvIds(node?.pakiety_id);
if (!pkgIds.length) continue;
// interesują nas tylko te pakiety_id, które są w YAML dodatków (tid)
const matchedAddonTids = pkgIds.filter((tid) => addonTids.has(tid));
if (!matchedAddonTids.length) continue;
const opis = htmlToMarkdown(node?.opis) || null;
const key = channelName.toLowerCase();
let img = logoCache.get(key);
if (img === undefined) {
const logoUrl = extractLogoUrl(node);
img = logoUrl ? await downloadLogoAsBase64(logoUrl) : null;
logoCache.set(key, img);
}
for (const tid of matchedAddonTids) {
const pckgName = addonTidToName.get(tid);
if (!pckgName) continue;
rows.push({
nazwa: channelName,
pckg_name: pckgName,
image: img,
opis,
tid, // ✅ kluczowe: zapis tid do bazy
});
}
}
// 3) REFRESH: usuń wszystkie addonowe i wrzuć nowe
if (!dryRun) {
const trx = db.transaction((data) => {
const info = delAddons.run();
for (const r of data) upsert.run(r);
return info.changes;
});
const deleted = trx(rows);
return new Response(
JSON.stringify({
ok: true,
dryRun,
deleted_addon_rows: deleted,
inserted_rows: rows.length,
db: DB_PATH,
yaml: YAML_PATH,
uniqueAddonsUsed: addonTidToName.size,
tidsUsed: addonTids.size,
}),
{ headers: { "content-type": "application/json; charset=utf-8" } },
);
}
return new Response(
JSON.stringify({
ok: true,
dryRun: true,
would_delete_addon_rows: "(unknown in dryRun)",
would_insert_rows: rows.length,
db: DB_PATH,
yaml: YAML_PATH,
uniqueAddonsUsed: addonTidToName.size,
tidsUsed: addonTids.size,
}),
{ headers: { "content-type": "application/json; charset=utf-8" } },
);
} catch (e) {
console.error("import addon jambox_channels:", e);
return new Response(
JSON.stringify({ ok: false, error: String(e?.message || e) }),
{
status: 500,
headers: { "content-type": "application/json; charset=utf-8" },
},
);
} finally {
try {
db.close();
} catch {}
}
}

View File

@@ -0,0 +1,291 @@
import path from "node:path";
import fs from "node:fs/promises";
import yaml from "js-yaml";
import { XMLParser } from "fast-xml-parser";
const PLUS_THEMATIC_URL =
"https://www.jambox.pl/xml/slownik-pakietytematyczneplus.xml";
const PREMIUM_URL = "https://www.jambox.pl/xml/slownik-pakietypremium.xml";
/**
* .env: JAMBOX_ADMIN_TOKEN="..."
*/
function isAuthorized(request) {
const expected = import.meta.env.JAMBOX_ADMIN_TOKEN;
if (!expected) return false;
const token = request.headers.get("x-admin-token");
return token === expected;
}
async function fetchXml(url) {
const res = await fetch(url);
if (!res.ok) {
throw new Error(
`Błąd pobierania XML: ${res.status} ${res.statusText} (${url})`,
);
}
return await res.text();
}
/**
* XML:
* <xml>
* <node><name>...</name><tid>...</tid></node>
* <node>...</node>
* </xml>
*/
function parseNodes(xmlText) {
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: "@_",
});
const json = parser.parse(xmlText);
const root = json.xml ?? json;
let nodes = root?.node ?? [];
if (!nodes) return [];
if (!Array.isArray(nodes)) nodes = [nodes];
return nodes;
}
function toInt(v) {
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
function normalizeName(name) {
let s = String(name || "").trim();
// często XML ma "Pakiet ..." a u Ciebie w YAML jest bez "Pakiet"
s = s.replace(/^pakiet\s+/i, "");
// "+Max" -> "+ Max"
s = s.replace(/\+([A-Za-zĄĆĘŁŃÓŚŹŻ])/g, "+ $1");
// pojedyncze spacje
s = s.replace(/\s+/g, " ").trim();
return s;
}
function stripDiacritics(s) {
return String(s || "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "");
}
function slugifyId(name) {
const s = stripDiacritics(String(name || "").toLowerCase())
.replace(/\+/g, " plus ")
.replace(/&/g, " and ")
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "")
.replace(/_+/g, "_");
return s || "pakiet";
}
function buildXmlMap(nodesA, nodesB) {
const byTid = new Map();
const byNormName = new Map();
function add(nodes, source) {
for (const n of nodes || []) {
const tid = toInt(n?.tid);
const rawName = String(n?.name ?? "").trim();
const name = normalizeName(rawName);
if (!tid || !name) continue;
if (!byTid.has(tid)) byTid.set(tid, { tid, name, rawName, source });
const key = stripDiacritics(name).toLowerCase();
if (!byNormName.has(key)) byNormName.set(key, { tid, name, rawName, source });
}
}
add(nodesA, "thematic_plus");
add(nodesB, "premium");
return { byTid, byNormName };
}
export async function POST({ request }) {
try {
if (!isAuthorized(request)) {
return new Response(JSON.stringify({ ok: false, error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
const body = await request.json().catch(() => ({}));
const dryRun = body?.dryRun === true;
// domyślnie: tylko aktualizacja (bez dopisywania nowych)
const addMissing = body?.addMissing === true;
const updateNames = body?.updateNames !== false; // default true
const YAML_PATH =
import.meta.env.JAMBOX_TV_ADDONS_YAML_PATH ||
path.join(
process.cwd(),
"src",
"content",
"internet-telewizja",
"tv-addons.yaml",
);
// 1) XML
const [xmlA, xmlB] = await Promise.all([
fetchXml(PLUS_THEMATIC_URL),
fetchXml(PREMIUM_URL),
]);
const nodesA = parseNodes(xmlA);
const nodesB = parseNodes(xmlB);
const { byTid, byNormName } = buildXmlMap(nodesA, nodesB);
// 2) YAML
const rawYaml = await fs.readFile(YAML_PATH, "utf8");
const doc = yaml.load(rawYaml);
if (!doc || !Array.isArray(doc.dodatki)) {
return new Response(
JSON.stringify({ ok: false, error: "YAML: brak doc.dodatki" }),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
const changedItems = [];
const unchangedItems = [];
const skippedItems = [];
const addedItems = [];
// indeksy istniejących (dla addMissing)
const existingByTid = new Map();
const existingByNormName = new Map();
for (const a of doc.dodatki) {
const tid = toInt(a?.tid);
const nm = normalizeName(String(a?.nazwa ?? ""));
if (tid != null) existingByTid.set(tid, a);
if (nm) existingByNormName.set(stripDiacritics(nm).toLowerCase(), a);
}
// 2a) aktualizacje
for (const addon of doc.dodatki) {
const id = addon?.id ?? null;
const before = {
tid: toInt(addon?.tid),
nazwa: String(addon?.nazwa ?? ""),
};
let match = null;
// najpierw po tid (najpewniejsze)
if (before.tid != null && byTid.has(before.tid)) {
match = byTid.get(before.tid);
} else {
// potem po nazwie
const key = stripDiacritics(normalizeName(before.nazwa)).toLowerCase();
if (key && byNormName.has(key)) match = byNormName.get(key);
}
if (!match) {
skippedItems.push({ id, reason: "brak dopasowania w XML", before });
continue;
}
const after = {
tid: match.tid,
nazwa: updateNames ? match.name : before.nazwa,
};
const willChange =
before.tid !== after.tid || before.nazwa !== after.nazwa;
if (!willChange) {
unchangedItems.push({ id, tid: before.tid, nazwa: before.nazwa });
continue;
}
if (!dryRun) {
addon.tid = after.tid;
if (updateNames) addon.nazwa = after.nazwa;
}
changedItems.push({
id,
before,
after,
matchedFrom: match.source,
});
}
// 2b) opcjonalnie dopisz brakujące pakiety z XML do YAML
if (addMissing) {
for (const [tid, it] of byTid.entries()) {
if (existingByTid.has(tid)) continue;
const key = stripDiacritics(it.name).toLowerCase();
if (existingByNormName.has(key)) continue;
const newId = slugifyId(it.name);
const newItem = {
id: newId,
nazwa: it.name,
tid: tid,
typ: "checkbox",
opis: "",
cena: [],
};
if (!dryRun) doc.dodatki.push(newItem);
addedItems.push({ id: newId, tid, nazwa: it.name, source: it.source });
}
}
if (!dryRun && (changedItems.length > 0 || addedItems.length > 0)) {
const newYaml = yaml.dump(doc, {
lineWidth: -1,
noRefs: true,
sortKeys: false,
});
await fs.writeFile(YAML_PATH, newYaml, "utf8");
}
return new Response(
JSON.stringify({
ok: true,
dryRun,
yamlPath: YAML_PATH,
options: { addMissing, updateNames },
xmlCounts: {
thematic_plus: nodesA.length,
premium: nodesB.length,
uniqueByTid: byTid.size,
},
changed: changedItems.length,
added: addedItems.length,
unchanged: unchangedItems.length,
skipped: skippedItems.length,
changedItems,
addedItems,
skippedItems,
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
} catch (err) {
console.error("update-tv-addons error:", err);
return new Response(
JSON.stringify({ ok: false, error: String(err?.message ?? err) }),
{ status: 500, headers: { "Content-Type": "application/json" } },
);
}
}

View File

@@ -23,6 +23,18 @@ function slugifyPkg(name) {
.replace(/(^-|-$)/g, "");
}
function uniqByKey(list, keyFn) {
const seen = new Set();
const out = [];
for (const it of list || []) {
const k = keyFn(it);
if (!k || seen.has(k)) continue;
seen.add(k);
out.push(it);
}
return out;
}
export function GET({ url }) {
const q = (url.searchParams.get("q") || "").trim();
const limit = clamp(Number(url.searchParams.get("limit") || 50), 1, 200);
@@ -44,10 +56,25 @@ export function GET({ url }) {
.prepare(
`
SELECT
nazwa AS name,
MAX(image) AS logo_url,
MAX(opis) AS description,
GROUP_CONCAT(pckg_name, '||') AS packages_blob
nazwa AS name,
MAX(image) AS logo_url,
MAX(opis) AS description,
-- ✅ pakiety główne (pckg_addons = 0)
GROUP_CONCAT(
CASE WHEN IFNULL(pckg_addons, 0) = 0 THEN pckg_name END,
'||'
) AS packages_blob,
-- ✅ pakiety tematyczne (pckg_addons = 1) + tid
GROUP_CONCAT(
CASE
WHEN IFNULL(pckg_addons, 0) = 1 AND tid IS NOT NULL
THEN CAST(tid AS TEXT) || '::' || pckg_name
END,
'||'
) AS thematic_blob
FROM jambox_channels
WHERE nazwa LIKE ? ESCAPE '\\'
GROUP BY nazwa
@@ -58,23 +85,40 @@ export function GET({ url }) {
.all(like, limit);
const data = rows.map((r) => {
// ===== główne =====
const pkgsRaw = String(r.packages_blob || "")
.split("||")
.map((x) => x.trim())
.filter(Boolean);
const packages = uniq(pkgsRaw)
.map((p) => ({
id: slugifyPkg(p),
name: p,
}))
.map((p) => ({ id: slugifyPkg(p), name: p }))
.sort((a, b) => a.name.localeCompare(b.name, "pl"));
// ===== tematyczne =====
const thematicRaw = String(r.thematic_blob || "")
.split("||")
.map((x) => x.trim())
.filter(Boolean);
const thematic_packages = uniqByKey(
thematicRaw
.map((x) => {
const [tid, ...rest] = x.split("::");
const name = rest.join("::").trim();
const t = String(tid || "").trim();
return t && name ? { tid: t, name } : null;
})
.filter(Boolean),
(p) => `${p.tid}::${p.name}`
).sort((a, b) => a.name.localeCompare(b.name, "pl"));
return {
name: r.name,
logo_url: r.logo_url || "",
description: r.description || "",
packages,
thematic_packages, // ✅ NOWE
};
});

View File

@@ -0,0 +1,90 @@
---
import DefaultLayout from "../../layouts/DefaultLayout.astro";
import yaml from "js-yaml";
import fs from "node:fs";
import Markdown from "../../islands/Markdown.jsx";
import AddonChannelsGrid from "../../islands/jambox/AddonChannelsModal.jsx";
import "../../styles/jambox-tematyczne.css";
/** Typy minimalne */
type AddonPriceRow = {
pakiety?: string[] | any;
"12m"?: number | string;
bezterminowo?: number | string;
};
type TvAddon = {
id?: string;
nazwa?: string;
tid?: number;
typ?: string;
opis?: string;
image?: string;
cena?: AddonPriceRow[];
};
type TvAddonsDoc = {
tytul?: string;
opis?: string;
cena_opis?: string;
dodatki?: TvAddon[];
};
const doc = yaml.load(
fs.readFileSync("./src/content/internet-telewizja/tv-addons.yaml", "utf8"),
) as TvAddonsDoc;
const pageTitle = doc?.tytul ?? "Dodatkowe pakiety TV";
const pageDesc = doc?.opis ?? "";
const addons: TvAddon[] = Array.isArray(doc?.dodatki) ? doc.dodatki : [];
---
<DefaultLayout title={pageTitle} description={pageDesc}>
<section class="f-section">
<div class="f-section-grid-single">
<h1 class="f-section-title">{pageTitle}</h1>
{pageDesc && <Markdown text={pageDesc} />}
</div>
</section>
{addons.map((addon: TvAddon, index: number) => {
const isAboveFold = index === 0;
const hasYamlImage = !!String(addon?.image ?? "").trim();
const pkgName = String(addon?.nazwa ?? "").trim();
const assumeHasMedia = pkgName ? true : hasYamlImage;
const anchorId = addon?.tid != null ? String(addon.tid) : undefined;
return (
<section class="f-section">
<div
class={`f-section-grid f-addon-grid f-addon-section ${
assumeHasMedia ? "md:grid-cols-2" : "md:grid-cols-1"
}`}
data-addon-section
data-has-media={assumeHasMedia ? "1" : "0"}
>
{/* TEKST — lewa, od góry */}
<div class="f-addon-text" id={anchorId}>
{pkgName && <h2 class="f-section-title">{pkgName}</h2>}
{addon?.opis && <Markdown text={addon.opis} />}
</div>
{/* MEDIA — prawa */}
<div class="f-addon-media">
{pkgName ? (
<AddonChannelsGrid
client:idle
packageName={pkgName}
fallbackImage={String(addon?.image ?? "")}
aboveFold={isAboveFold}
title={pkgName}
/>
) : null}
</div>
</div>
</section>
);
})}
</DefaultLayout>

View File

@@ -0,0 +1,87 @@
/* ===========================
TV ADDONS — LAYOUT FIXES
=========================== */
/* f-section-grid ma u Ciebie items-center (centrowanie w pionie),
więc dla addonów wymuszamy start (od góry). */
.f-addon-section {
align-items: start; /* override dla grid items */
}
/* Gdy island stwierdzi brak ikon i brak fallback image:
- 1 kolumna
- ukryj pusty blok mediów
UWAGA: NIE dodawaj margin:0, bo zabijesz mx-auto z f-section-grid */
[data-addon-section][data-has-media="0"] {
grid-template-columns: 1fr !important;
}
[data-addon-section][data-has-media="0"] .f-addon-media {
display: none;
}
/* ===========================
KANAŁY / MEDIA — STYLE
(wyniesione z island)
=========================== */
.f-channels-grid{
display:grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: .65rem;
padding: .75rem;
border-radius: 1.25rem;
border: 1px solid var(--f-border-color);
background: color-mix(in oklab, var(--f-background) 92%, black 8%);
}
@media (min-width: 520px){
.f-channels-grid{ grid-template-columns: repeat(5, minmax(0,1fr)); }
}
@media (min-width: 768px){
.f-channels-grid{ grid-template-columns: repeat(4, minmax(0,1fr)); }
}
@media (min-width: 1024px){
.f-channels-grid{ grid-template-columns: repeat(5, minmax(0,1fr)); }
}
.f-channel-item{
display:flex;
flex-direction:column;
align-items:center;
text-align:center;
gap:.35rem;
min-width:0;
}
.f-channel-logo{
/* width: 86px;
height: 86px; */
object-fit: contain;
/* border-radius: 1rem; */
padding: .25rem;
/* border: 1px solid color-mix(in oklab, var(--f-border-color) 85%, transparent 15%); */
/* background: color-mix(in oklab, var(--f-background) 95%, black 5%); */
}
.f-channel-logo-placeholder{
width:56px;height:56px;border-radius:1rem;
/* border:1px dashed var(--f-border-color); */
opacity:.6;
}
.f-channel-label{
font-size: .78rem;
line-height: 1.0rem;
opacity:.85;
max-width: 9rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.f-addon-fallback-image{
width:100%;
height: 280px;
object-fit: cover;
border-radius: 1.25rem;
border: 1px solid var(--f-border-color);
}

View File

@@ -14,22 +14,23 @@
}
/* Mała karta mapy (inne sekcje) */
.fuz-map--card {
/* .fuz-map--card {
@apply w-full h-[350px] overflow-hidden mt-8;
}
} */
/*
.map-range-container {
@apply sticky top-[67px] z-[999] flex justify-center w-full pointer-events-auto;
/* position: absolute;
position: absolute;
top: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 9000;
display: flex;
justify-content: center; */
justify-content: center;
}
*/
/* Pulsująca obwódka budynku */
.pulse-marker {

View File

@@ -7,7 +7,7 @@
}
.f-section {
@apply pt-1 pb-1 mx-2;
@apply pt-1 pb-1 mx-2 my-6;;
}
.f-section-center {

View File

@@ -43,10 +43,6 @@
--card-ring: hsla(217 91% 60% / 0.45);
--card-shadow-deep: hsla(221 47% 11% / 0.18);
/*
*/
--surface-shadow-dark: var(--brand-hue) 50% 3%;
--shadow-strength-dark: .8;
@@ -69,7 +65,7 @@
/* --- Background and Text --- */
--f-background: var(--surface3-light);
--f-text: var(--text1-light);
--f-text: var(--text2-light);
--f-header: var(--text1-light);
--f-header-items: (var(--text1-light));
/*--- Navbar --- */
@@ -130,7 +126,7 @@
/* --- Background and Text --- */
--f-background: var(--surface1-dark);
--f-text: var(--text1-dark);
--f-text: var(--text2-dark);
--f-header: var(--text1-dark);
--f-header-items: (var(--text1-dark));
/*--- Navbar --- */