Pakiety tematyczne
This commit is contained in:
@@ -1,4 +1,17 @@
|
|||||||
sections:
|
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"
|
- title: "Dekoder Arris 4302 HD"
|
||||||
image: "arris4302.webp"
|
image: "arris4302.webp"
|
||||||
button:
|
button:
|
||||||
|
|||||||
@@ -1,120 +1,204 @@
|
|||||||
tytul: Dodatkowe pakiety TV
|
tytul: Dodatkowe pakiety TV
|
||||||
opis: "Rozszerz ofertę telewizyjną o dodatkowe pakiety."
|
opis: |
|
||||||
cena_opis: "zł/mies."
|
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:
|
dodatki:
|
||||||
- id: canal_seriale_filmy
|
- id: canal_seriale_filmy
|
||||||
nazwa: "Canal+ Seriale i Filmy"
|
nazwa: CANAL+ Seriale i Filmy
|
||||||
|
tid: 49
|
||||||
typ: checkbox
|
typ: checkbox
|
||||||
opis: "Pakiet filmowo-serialowy Canal+."
|
opis: Pakiet filmowo-serialowy Canal+.
|
||||||
cena:
|
cena:
|
||||||
- pakiety: [Smart, Optimum, Platinum, Podstawowy, Korzystny, Bogaty]
|
- pakiety:
|
||||||
|
- Smart
|
||||||
|
- Optimum
|
||||||
|
- Platinum
|
||||||
|
- Podstawowy
|
||||||
|
- Korzystny
|
||||||
|
- Bogaty
|
||||||
12m: 24.99
|
12m: 24.99
|
||||||
bezterminowo: 28.99
|
bezterminowo: 28.99
|
||||||
|
|
||||||
- id: canal_super_sport
|
- id: canal_super_sport
|
||||||
nazwa: "Canal+ Super Sport"
|
nazwa: CANAL+ Super Sport
|
||||||
typ: checkbox
|
typ: checkbox
|
||||||
opis: "Pakiet sportowy Canal+."
|
opis: Pakiet sportowy Canal+.
|
||||||
cena:
|
cena:
|
||||||
- pakiety: [Smart, Optimum, Platinum, Podstawowy, Korzystny, Bogaty]
|
- pakiety:
|
||||||
|
- Smart
|
||||||
|
- Optimum
|
||||||
|
- Platinum
|
||||||
|
- Podstawowy
|
||||||
|
- Korzystny
|
||||||
|
- Bogaty
|
||||||
12m: 64.99
|
12m: 64.99
|
||||||
bezterminowo: 68.99
|
bezterminowo: 68.99
|
||||||
|
tid: 48
|
||||||
- id: cinemax
|
- id: cinemax
|
||||||
nazwa: "Cinemax"
|
nazwa: Cinemax
|
||||||
typ: checkbox
|
typ: checkbox
|
||||||
opis: "Kanały Cinemax."
|
opis: Kanały Cinemax.
|
||||||
cena:
|
cena:
|
||||||
# SGT (PLUS): 10 / 15
|
- pakiety:
|
||||||
- pakiety: [Podstawowy, Korzystny, Bogaty]
|
- Podstawowy
|
||||||
12m: 10.00
|
- Korzystny
|
||||||
bezterminowo: 15.00
|
- Bogaty
|
||||||
# EVIO: jedna cena 14.90
|
12m: 10
|
||||||
- pakiety: [Smart, Optimum, Platinum]
|
bezterminowo: 15
|
||||||
# 12m: 14.90
|
- pakiety:
|
||||||
bezterminowo: 14.90
|
- Smart
|
||||||
|
- Optimum
|
||||||
|
- Platinum
|
||||||
|
bezterminowo: 14.9
|
||||||
|
tid: 18
|
||||||
- id: eleven
|
- id: eleven
|
||||||
nazwa: "Eleven"
|
nazwa: Eleven
|
||||||
typ: checkbox
|
typ: checkbox
|
||||||
opis: "Kanały Eleven Sports."
|
opis: Kanały Eleven Sports.
|
||||||
cena:
|
cena:
|
||||||
- pakiety: [Podstawowy, Korzystny, Bogaty]
|
- pakiety:
|
||||||
12m: 15.00
|
- Podstawowy
|
||||||
bezterminowo: 25.00
|
- Korzystny
|
||||||
|
- Bogaty
|
||||||
|
12m: 15
|
||||||
|
bezterminowo: 25
|
||||||
|
tid: 61
|
||||||
- id: filmbox
|
- id: filmbox
|
||||||
nazwa: "Filmbox"
|
nazwa: FilmBox+
|
||||||
typ: checkbox
|
typ: checkbox
|
||||||
opis: "Kanały FilmBox."
|
opis: Kanały FilmBox.
|
||||||
cena:
|
cena:
|
||||||
- pakiety: [Podstawowy, Korzystny, Bogaty]
|
- pakiety:
|
||||||
12m: 10.00
|
- Podstawowy
|
||||||
bezterminowo: 15.00
|
- Korzystny
|
||||||
|
- Bogaty
|
||||||
|
12m: 10
|
||||||
|
bezterminowo: 15
|
||||||
|
tid: 19
|
||||||
- id: hbo_max_podstawowy
|
- id: hbo_max_podstawowy
|
||||||
nazwa: "HBO + Max Podstawowy"
|
nazwa: HBO + Max Podstawowy
|
||||||
typ: checkbox
|
typ: checkbox
|
||||||
opis: |
|
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.
|
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.
|
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.
|
Treści dostępne w Pakiecie Podstawowym wyświetlane są wraz z reklamami.
|
||||||
cena:
|
cena:
|
||||||
- pakiety: [Smart, Optimum, Platinum, Podstawowy, Korzystny, Bogaty]
|
- pakiety:
|
||||||
|
- Smart
|
||||||
|
- Optimum
|
||||||
|
- Platinum
|
||||||
|
- Podstawowy
|
||||||
|
- Korzystny
|
||||||
|
- Bogaty
|
||||||
12m: 27.99
|
12m: 27.99
|
||||||
bezterminowo: 29.99
|
bezterminowo: 29.99
|
||||||
|
tid: 20
|
||||||
- id: hbo_max_standardowy
|
- id: hbo_max_standardowy
|
||||||
nazwa: "HBO + Max Standardowy"
|
nazwa: HBO + Max Standardowy
|
||||||
typ: checkbox
|
typ: checkbox
|
||||||
opis: "HBO + Max (wariant standardowy)."
|
opis: HBO + Max (wariant standardowy).
|
||||||
cena:
|
cena:
|
||||||
- pakiety: [Smart, Optimum, Platinum, Podstawowy, Korzystny, Bogaty]
|
- pakiety:
|
||||||
|
- Smart
|
||||||
|
- Optimum
|
||||||
|
- Platinum
|
||||||
|
- Podstawowy
|
||||||
|
- Korzystny
|
||||||
|
- Bogaty
|
||||||
12m: 36.99
|
12m: 36.99
|
||||||
bezterminowo: 39.99
|
bezterminowo: 39.99
|
||||||
|
tid: 96
|
||||||
- id: hbo_max_premium
|
- id: hbo_max_premium
|
||||||
nazwa: "HBO + Max Premium"
|
nazwa: HBO + Max Premium
|
||||||
typ: checkbox
|
typ: checkbox
|
||||||
opis: "HBO + Max (wariant premium)."
|
opis: HBO + Max (wariant premium).
|
||||||
cena:
|
cena:
|
||||||
- pakiety: [Smart, Optimum, Platinum, Podstawowy, Korzystny, Bogaty]
|
- pakiety:
|
||||||
|
- Smart
|
||||||
|
- Optimum
|
||||||
|
- Platinum
|
||||||
|
- Podstawowy
|
||||||
|
- Korzystny
|
||||||
|
- Bogaty
|
||||||
12m: 44.99
|
12m: 44.99
|
||||||
bezterminowo: 49.99
|
bezterminowo: 49.99
|
||||||
|
tid: 97
|
||||||
- id: wiecej_sportu_plus
|
- id: wiecej_sportu_plus
|
||||||
nazwa: "Więcej Sportu Plus"
|
nazwa: Więcej Sportu Plus
|
||||||
typ: checkbox
|
typ: checkbox
|
||||||
opis: "Dodatkowy pakiet sportowy."
|
opis: Dodatkowy pakiet sportowy.
|
||||||
cena:
|
cena:
|
||||||
- pakiety: [Podstawowy, Korzystny, Bogaty]
|
- pakiety:
|
||||||
12m: 15.00
|
- Podstawowy
|
||||||
bezterminowo: 25.00
|
- Korzystny
|
||||||
|
- Bogaty
|
||||||
|
12m: 15
|
||||||
|
bezterminowo: 25
|
||||||
|
tid: 79
|
||||||
- id: wiecej_erotyki
|
- id: wiecej_erotyki
|
||||||
nazwa: "Więcej Erotyki"
|
nazwa: Więcej Erotyki
|
||||||
typ: checkbox
|
typ: checkbox
|
||||||
opis: "Pakiet kanałów erotycznych."
|
opis: Pakiet kanałów erotycznych.
|
||||||
cena:
|
cena:
|
||||||
- pakiety: [Podstawowy, Korzystny, Bogaty]
|
- pakiety:
|
||||||
12m: 15.00
|
- Podstawowy
|
||||||
bezterminowo: 25.00
|
- Korzystny
|
||||||
|
- Bogaty
|
||||||
|
12m: 15
|
||||||
|
bezterminowo: 25
|
||||||
|
tid: 80
|
||||||
- id: disney_standard
|
- id: disney_standard
|
||||||
nazwa: "Disney+ Standard"
|
nazwa: Disney+ Standard
|
||||||
typ: checkbox
|
typ: checkbox
|
||||||
opis: |
|
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.
|
||||||
cena:
|
Oglądaj, kiedy chcesz.
|
||||||
- pakiety: [Smart, Optimum, Platinum, Podstawowy, Korzystny, Bogaty]
|
|
||||||
bezterminowo: 34.99
|
|
||||||
|
|
||||||
|
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
|
- id: disney_premium
|
||||||
nazwa: "Disney+ Premium"
|
nazwa: Disney+ Premium
|
||||||
typ: checkbox
|
typ: checkbox
|
||||||
opis: |
|
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:
|
cena:
|
||||||
- pakiety: [Smart, Optimum, Platinum, Podstawowy, Korzystny, Bogaty]
|
- pakiety:
|
||||||
|
- Smart
|
||||||
|
- Optimum
|
||||||
|
- Platinum
|
||||||
|
- Podstawowy
|
||||||
|
- Korzystny
|
||||||
|
- Bogaty
|
||||||
bezterminowo: 59.99
|
bezterminowo: 59.99
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
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,
|
krok: a.krok != null ? Number(a.krok) : 1,
|
||||||
opis: a.opis ? String(a.opis) : "",
|
opis: a.opis ? String(a.opis) : "",
|
||||||
cena: a.cena ?? 0,
|
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-main">
|
||||||
<div class="f-addon-name">{a.nazwa}</div>
|
<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 && (
|
{termPricing && (
|
||||||
<div class="mt-2 flex flex-wrap gap-3 text-sm" onClick={(e) => e.stopPropagation()}>
|
<div class="mt-2 flex flex-wrap gap-3 text-sm" onClick={(e) => e.stopPropagation()}>
|
||||||
@@ -587,7 +597,7 @@ export default function JamboxAddonsModal({
|
|||||||
</SectionAccordion>
|
</SectionAccordion>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ✅ DEKODER (sekcja) */}
|
{/* DEKODER */}
|
||||||
<div class="f-modal-section">
|
<div class="f-modal-section">
|
||||||
<SectionAccordion
|
<SectionAccordion
|
||||||
title="Wybór dekodera"
|
title="Wybór dekodera"
|
||||||
@@ -637,7 +647,7 @@ export default function JamboxAddonsModal({
|
|||||||
</SectionAccordion>
|
</SectionAccordion>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ✅ TV ADDONS (sekcja) */}
|
{/* TV ADDONS */}
|
||||||
<div class="f-modal-section">
|
<div class="f-modal-section">
|
||||||
<SectionAccordion
|
<SectionAccordion
|
||||||
title="Pakiety dodatkowe TV"
|
title="Pakiety dodatkowe TV"
|
||||||
@@ -657,7 +667,7 @@ export default function JamboxAddonsModal({
|
|||||||
</SectionAccordion>
|
</SectionAccordion>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ✅ TELEFON (sekcja) */}
|
{/* TELEFON */}
|
||||||
<div class="f-modal-section">
|
<div class="f-modal-section">
|
||||||
<SectionAccordion
|
<SectionAccordion
|
||||||
title="Usługa telefoniczna"
|
title="Usługa telefoniczna"
|
||||||
@@ -744,7 +754,7 @@ export default function JamboxAddonsModal({
|
|||||||
</SectionAccordion>
|
</SectionAccordion>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ✅ DODATKI (sekcja) */}
|
{/* DODATKI */}
|
||||||
<div class="f-modal-section">
|
<div class="f-modal-section">
|
||||||
<SectionAccordion
|
<SectionAccordion
|
||||||
title="Dodatkowe usługi"
|
title="Dodatkowe usługi"
|
||||||
@@ -764,7 +774,7 @@ export default function JamboxAddonsModal({
|
|||||||
</SectionAccordion>
|
</SectionAccordion>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ✅ PODSUMOWANIE (sekcja) */}
|
{/* PODSUMOWANIE */}
|
||||||
<div class="f-modal-section">
|
<div class="f-modal-section">
|
||||||
<SectionAccordion
|
<SectionAccordion
|
||||||
title="Podsumowanie miesięczne"
|
title="Podsumowanie miesięczne"
|
||||||
@@ -818,15 +828,6 @@ export default function JamboxAddonsModal({
|
|||||||
</SectionAccordion>
|
</SectionAccordion>
|
||||||
</div>
|
</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
|
<div
|
||||||
ref={floating.ref}
|
ref={floating.ref}
|
||||||
class="f-floating-total"
|
class="f-floating-total"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export default function JamboxChannelsSearch() {
|
|||||||
const [err, setErr] = useState("");
|
const [err, setErr] = useState("");
|
||||||
|
|
||||||
// ✅ koszyk kanałów
|
// ✅ 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);
|
const abortRef = useRef(null);
|
||||||
|
|
||||||
@@ -34,10 +34,13 @@ export default function JamboxChannelsSearch() {
|
|||||||
params.set("q", qq);
|
params.set("q", qq);
|
||||||
params.set("limit", "80");
|
params.set("limit", "80");
|
||||||
|
|
||||||
const res = await fetch(`/api/jambox/jambox-channels-search?${params.toString()}`, {
|
const res = await fetch(
|
||||||
signal: ac.signal,
|
`/api/jambox/jambox-channels-search?${params.toString()}`,
|
||||||
headers: { Accept: "application/json" },
|
{
|
||||||
});
|
signal: ac.signal,
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
if (!res.ok || !json.ok) throw new Error(json?.error || "API_ERROR");
|
if (!res.ok || !json.ok) throw new Error(json?.error || "API_ERROR");
|
||||||
@@ -83,12 +86,16 @@ export default function JamboxChannelsSearch() {
|
|||||||
// ✅ koszyk: dodaj/usuń kanał
|
// ✅ koszyk: dodaj/usuń kanał
|
||||||
// ==========================
|
// ==========================
|
||||||
const isWanted = (c) =>
|
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) {
|
function addWanted(c) {
|
||||||
setWanted((prev) => {
|
setWanted((prev) => {
|
||||||
const exists = prev.some(
|
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;
|
if (exists) return prev;
|
||||||
|
|
||||||
@@ -98,6 +105,9 @@ export default function JamboxChannelsSearch() {
|
|||||||
name: c.name,
|
name: c.name,
|
||||||
logo_url: c.logo_url || "",
|
logo_url: c.logo_url || "",
|
||||||
packages: Array.isArray(c.packages) ? c.packages : [],
|
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) {
|
function removeWantedByName(name) {
|
||||||
setWanted((prev) =>
|
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(() => {
|
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
|
const counts = new Map(); // key = packageName
|
||||||
for (const ch of wanted) {
|
for (const ch of wanted) {
|
||||||
const pkgs = Array.isArray(ch.packages) ? ch.packages : [];
|
const pkgs = Array.isArray(ch.packages) ? ch.packages : [];
|
||||||
@@ -134,25 +149,41 @@ export default function JamboxChannelsSearch() {
|
|||||||
|
|
||||||
const all = Array.from(counts.values());
|
const all = Array.from(counts.values());
|
||||||
|
|
||||||
// ✅ exact: pakiety z count == wanted.length (czyli zawierają wszystkie)
|
|
||||||
const exact = all
|
const exact = all
|
||||||
.filter((p) => p.count === wanted.length)
|
.filter((p) => p.count === wanted.length)
|
||||||
.sort((a, b) => a.name.localeCompare(b.name, "pl"));
|
.sort((a, b) => a.name.localeCompare(b.name, "pl"));
|
||||||
|
|
||||||
// ✅ ranked: najlepsze dopasowania gdy exact puste (malejąco count)
|
|
||||||
const ranked = all
|
const ranked = all
|
||||||
.filter((p) => p.count < wanted.length)
|
.filter((p) => p.count < wanted.length)
|
||||||
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name, "pl"))
|
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name, "pl"))
|
||||||
.slice(0, 12);
|
.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]);
|
}, [wanted]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="f-chsearch">
|
<div class="f-chsearch">
|
||||||
<h1 class="f-section-title">Wyszukiwanie kanałów w pakietach telewizji</h1>
|
<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="f-chsearch__wanted">
|
||||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||||
<div class="text-lg font-semibold">Chciałbym mieć te kanały</div>
|
<div class="text-lg font-semibold">Chciałbym mieć te kanały</div>
|
||||||
@@ -166,7 +197,8 @@ export default function JamboxChannelsSearch() {
|
|||||||
|
|
||||||
{wanted.length === 0 ? (
|
{wanted.length === 0 ? (
|
||||||
<div class="opacity-80 mt-2">
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -174,7 +206,12 @@ export default function JamboxChannelsSearch() {
|
|||||||
{wanted.map((w) => (
|
{wanted.map((w) => (
|
||||||
<div class="f-chsearch__wanted-chip" key={w.name}>
|
<div class="f-chsearch__wanted-chip" key={w.name}>
|
||||||
{w.logo_url ? (
|
{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}
|
) : null}
|
||||||
<span class="f-chsearch__wanted-name">{w.name}</span>
|
<span class="f-chsearch__wanted-name">{w.name}</span>
|
||||||
<button
|
<button
|
||||||
@@ -191,29 +228,29 @@ export default function JamboxChannelsSearch() {
|
|||||||
|
|
||||||
{/* ✅ SUGESTIE PAKIETÓW */}
|
{/* ✅ SUGESTIE PAKIETÓW */}
|
||||||
<div class="f-chsearch__wanted-packages">
|
<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 ? (
|
{/* ======= GŁÓWNE (jak było) ======= */}
|
||||||
<div class="flex flex-wrap gap-2 mt-2">
|
<div class="mt-2">
|
||||||
{packageSuggestions.exact.map((p) => (
|
<div class="opacity-80">Pakiety główne:</div>
|
||||||
<button
|
|
||||||
type="button"
|
{packageSuggestions.exact.length > 0 ? (
|
||||||
key={p.name}
|
<div class="flex flex-wrap gap-2 mt-2">
|
||||||
class="f-chsearch-pkg"
|
{packageSuggestions.exact.map((p) => (
|
||||||
onClick={() => scrollToPackage(p.name)}
|
<button
|
||||||
title="Kliknij, aby przewinąć do pakietu"
|
type="button"
|
||||||
>
|
key={p.name}
|
||||||
{p.name}
|
class="f-chsearch-pkg"
|
||||||
</button>
|
onClick={() => scrollToPackage(p.name)}
|
||||||
))}
|
title="Kliknij, aby przewinąć do pakietu"
|
||||||
</div>
|
>
|
||||||
) : (
|
{p.name}
|
||||||
<>
|
</button>
|
||||||
<div class="opacity-80 mt-2">
|
))}
|
||||||
Nie ma jednego pakietu zawierającego wszystkie wybrane kanały.
|
|
||||||
Poniżej najlepsze dopasowania:
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
<div class="flex flex-wrap gap-2 mt-2">
|
<div class="flex flex-wrap gap-2 mt-2">
|
||||||
{packageSuggestions.ranked.map((p) => (
|
{packageSuggestions.ranked.map((p) => (
|
||||||
<button
|
<button
|
||||||
@@ -227,7 +264,33 @@ export default function JamboxChannelsSearch() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</>
|
</>
|
||||||
@@ -268,11 +331,20 @@ export default function JamboxChannelsSearch() {
|
|||||||
const selected = isWanted(c);
|
const selected = isWanted(c);
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* kolumna 1 */}
|
||||||
<div class="f-chsearch__left">
|
<div class="f-chsearch__left">
|
||||||
{c.logo_url && (
|
{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>
|
<div class="f-chsearch__channel-name">{c.name}</div>
|
||||||
@@ -280,11 +352,19 @@ export default function JamboxChannelsSearch() {
|
|||||||
{/* ✅ przycisk dodaj/usuń */}
|
{/* ✅ przycisk dodaj/usuń */}
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
{!selected ? (
|
{!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ć”
|
Dodaj do “Chciałbym mieć”
|
||||||
</button>
|
</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
|
Usuń z listy
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -295,7 +375,9 @@ export default function JamboxChannelsSearch() {
|
|||||||
<div class="f-chsearch__right">
|
<div class="f-chsearch__right">
|
||||||
<div
|
<div
|
||||||
class="f-chsearch__desc f-chsearch__desc--html"
|
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 && (
|
{Array.isArray(c.packages) && c.packages.length > 0 && (
|
||||||
@@ -303,13 +385,40 @@ export default function JamboxChannelsSearch() {
|
|||||||
Dostępny w pakietach:
|
Dostępny w pakietach:
|
||||||
{c.packages.map((p) => (
|
{c.packages.map((p) => (
|
||||||
<span key={p.id}>
|
<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}
|
{p.name}
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
347
src/pages/api/jambox/import-jambox-tematyczne-channels.js
Normal file
347
src/pages/api/jambox/import-jambox-tematyczne-channels.js
Normal 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(/ /g, " ")
|
||||||
|
.replace(/–/g, "–")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/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 {}
|
||||||
|
}
|
||||||
|
}
|
||||||
291
src/pages/api/jambox/import-jambox-tematyczne.js
Normal file
291
src/pages/api/jambox/import-jambox-tematyczne.js
Normal 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" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,18 @@ function slugifyPkg(name) {
|
|||||||
.replace(/(^-|-$)/g, "");
|
.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 }) {
|
export function GET({ url }) {
|
||||||
const q = (url.searchParams.get("q") || "").trim();
|
const q = (url.searchParams.get("q") || "").trim();
|
||||||
const limit = clamp(Number(url.searchParams.get("limit") || 50), 1, 200);
|
const limit = clamp(Number(url.searchParams.get("limit") || 50), 1, 200);
|
||||||
@@ -44,10 +56,25 @@ export function GET({ url }) {
|
|||||||
.prepare(
|
.prepare(
|
||||||
`
|
`
|
||||||
SELECT
|
SELECT
|
||||||
nazwa AS name,
|
nazwa AS name,
|
||||||
MAX(image) AS logo_url,
|
MAX(image) AS logo_url,
|
||||||
MAX(opis) AS description,
|
MAX(opis) AS description,
|
||||||
GROUP_CONCAT(pckg_name, '||') AS packages_blob
|
|
||||||
|
-- ✅ 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
|
FROM jambox_channels
|
||||||
WHERE nazwa LIKE ? ESCAPE '\\'
|
WHERE nazwa LIKE ? ESCAPE '\\'
|
||||||
GROUP BY nazwa
|
GROUP BY nazwa
|
||||||
@@ -58,23 +85,40 @@ export function GET({ url }) {
|
|||||||
.all(like, limit);
|
.all(like, limit);
|
||||||
|
|
||||||
const data = rows.map((r) => {
|
const data = rows.map((r) => {
|
||||||
|
// ===== główne =====
|
||||||
const pkgsRaw = String(r.packages_blob || "")
|
const pkgsRaw = String(r.packages_blob || "")
|
||||||
.split("||")
|
.split("||")
|
||||||
.map((x) => x.trim())
|
.map((x) => x.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
const packages = uniq(pkgsRaw)
|
const packages = uniq(pkgsRaw)
|
||||||
.map((p) => ({
|
.map((p) => ({ id: slugifyPkg(p), name: p }))
|
||||||
id: slugifyPkg(p),
|
|
||||||
name: p,
|
|
||||||
}))
|
|
||||||
.sort((a, b) => a.name.localeCompare(b.name, "pl"));
|
.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 {
|
return {
|
||||||
name: r.name,
|
name: r.name,
|
||||||
logo_url: r.logo_url || "",
|
logo_url: r.logo_url || "",
|
||||||
description: r.description || "",
|
description: r.description || "",
|
||||||
packages,
|
packages,
|
||||||
|
thematic_packages, // ✅ NOWE
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
90
src/pages/internet-telewizja/pakiety-tematyczne.astro
Normal file
90
src/pages/internet-telewizja/pakiety-tematyczne.astro
Normal 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>
|
||||||
87
src/styles/jambox-tematyczne.css
Normal file
87
src/styles/jambox-tematyczne.css
Normal 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);
|
||||||
|
}
|
||||||
@@ -14,22 +14,23 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Mała karta mapy (inne sekcje) */
|
/* Mała karta mapy (inne sekcje) */
|
||||||
.fuz-map--card {
|
/* .fuz-map--card {
|
||||||
@apply w-full h-[350px] overflow-hidden mt-8;
|
@apply w-full h-[350px] overflow-hidden mt-8;
|
||||||
}
|
} */
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
.map-range-container {
|
.map-range-container {
|
||||||
@apply sticky top-[67px] z-[999] flex justify-center w-full pointer-events-auto;
|
@apply sticky top-[67px] z-[999] flex justify-center w-full pointer-events-auto;
|
||||||
/* position: absolute;
|
position: absolute;
|
||||||
top: 20px;
|
top: 20px;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
z-index: 9000;
|
z-index: 9000;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center; */
|
justify-content: center;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
/* Pulsująca obwódka budynku */
|
/* Pulsująca obwódka budynku */
|
||||||
.pulse-marker {
|
.pulse-marker {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.f-section {
|
.f-section {
|
||||||
@apply pt-1 pb-1 mx-2;
|
@apply pt-1 pb-1 mx-2 my-6;;
|
||||||
}
|
}
|
||||||
|
|
||||||
.f-section-center {
|
.f-section-center {
|
||||||
|
|||||||
@@ -43,10 +43,6 @@
|
|||||||
|
|
||||||
--card-ring: hsla(217 91% 60% / 0.45);
|
--card-ring: hsla(217 91% 60% / 0.45);
|
||||||
--card-shadow-deep: hsla(221 47% 11% / 0.18);
|
--card-shadow-deep: hsla(221 47% 11% / 0.18);
|
||||||
/*
|
|
||||||
|
|
||||||
|
|
||||||
*/
|
|
||||||
|
|
||||||
--surface-shadow-dark: var(--brand-hue) 50% 3%;
|
--surface-shadow-dark: var(--brand-hue) 50% 3%;
|
||||||
--shadow-strength-dark: .8;
|
--shadow-strength-dark: .8;
|
||||||
@@ -69,7 +65,7 @@
|
|||||||
|
|
||||||
/* --- Background and Text --- */
|
/* --- Background and Text --- */
|
||||||
--f-background: var(--surface3-light);
|
--f-background: var(--surface3-light);
|
||||||
--f-text: var(--text1-light);
|
--f-text: var(--text2-light);
|
||||||
--f-header: var(--text1-light);
|
--f-header: var(--text1-light);
|
||||||
--f-header-items: (var(--text1-light));
|
--f-header-items: (var(--text1-light));
|
||||||
/*--- Navbar --- */
|
/*--- Navbar --- */
|
||||||
@@ -130,7 +126,7 @@
|
|||||||
|
|
||||||
/* --- Background and Text --- */
|
/* --- Background and Text --- */
|
||||||
--f-background: var(--surface1-dark);
|
--f-background: var(--surface1-dark);
|
||||||
--f-text: var(--text1-dark);
|
--f-text: var(--text2-dark);
|
||||||
--f-header: var(--text1-dark);
|
--f-header: var(--text1-dark);
|
||||||
--f-header-items: (var(--text1-dark));
|
--f-header-items: (var(--text1-dark));
|
||||||
/*--- Navbar --- */
|
/*--- Navbar --- */
|
||||||
|
|||||||
Reference in New Issue
Block a user