Dorabiamy funkcjonalnosci w TV
This commit is contained in:
@@ -1,18 +1,18 @@
|
|||||||
sections:
|
sections:
|
||||||
- title: Dodatkowe możliwości naszej telewizji"
|
# - title: Dodatkowe możliwości naszej telewizji"
|
||||||
image: "ekosystem-kyanit.webp"
|
# image: "ekosystem-kyanit.webp"
|
||||||
content: |
|
# content: |
|
||||||
- **Catchup** — na wybranych kanałach możesz obejrzeć audycję z ostatnich 7 dni. [Więcej →](#catchup "Przeczytaj o usłudze CatchUp")
|
# - **Catchup** — na wybranych kanałach możesz obejrzeć audycję z ostatnich 7 dni. [Więcej →](#catchup "Przeczytaj o usłudze CatchUp")
|
||||||
|
|
||||||
- **Nagrywanie** — nagraj interesującą Cię audycję i obejrzyj ją kiedy chcesz. [Więcej →](#nagrywarka "Przeczytaj o nagrywaniu audycji")
|
# - **Nagrywanie** — nagraj interesującą Cię audycję i obejrzyj ją kiedy chcesz. [Więcej →](#nagrywarka "Przeczytaj o nagrywaniu audycji")
|
||||||
|
|
||||||
- **StartOver** — obejrzyj od początku audycję, która już się rozpoczęła (do 3h wstecz). [Więcej →](#startover "Przeczytaj o usłudze StartOver")
|
# - **StartOver** — obejrzyj od początku audycję, która już się rozpoczęła (do 3h wstecz). [Więcej →](#startover "Przeczytaj o usłudze StartOver")
|
||||||
|
|
||||||
- **Nagrywanie serii** — zaplanuj nagrywanie kolejnych odcinków ulubionego serialu. [Więcej →](#nagrywanie_cykliczne "Przeczytaj o nagrywaniu cyklicznym")
|
# - **Nagrywanie serii** — zaplanuj nagrywanie kolejnych odcinków ulubionego serialu. [Więcej →](#nagrywanie_cykliczne "Przeczytaj o nagrywaniu cyklicznym")
|
||||||
|
|
||||||
- **Pauzowanie** — zatrzymuj i cofaj audycje.
|
# - **Pauzowanie** — zatrzymuj i cofaj audycje.
|
||||||
|
|
||||||
- **Wyszukiwarka tekstowa** — wyszukaj dowolną frazę audycji i zaplanuj nagranie.
|
# - **Wyszukiwarka tekstowa** — wyszukaj dowolną frazę audycji i zaplanuj nagranie.
|
||||||
|
|
||||||
- title: "Dekoder telewizyjny"
|
- title: "Dekoder telewizyjny"
|
||||||
image: "VIP4302.png"
|
image: "VIP4302.png"
|
||||||
@@ -33,26 +33,26 @@ sections:
|
|||||||
- Przedni panel zawiera m.in. diodę LED i odbiornik podczerwieni
|
- Przedni panel zawiera m.in. diodę LED i odbiornik podczerwieni
|
||||||
- Wymiary modelu (szer/dł/wys): 130 x 130 x 26 mm
|
- Wymiary modelu (szer/dł/wys): 130 x 130 x 26 mm
|
||||||
|
|
||||||
- type: "iframe-channels"
|
# - type: "iframe-channels"
|
||||||
title: Sprawdź listę kanałów w interesującym Cię pakiecie
|
# title: Sprawdź listę kanałów w interesującym Cię pakiecie
|
||||||
content: ""
|
# content: ""
|
||||||
|
|
||||||
iframe_sets:
|
# iframe_sets:
|
||||||
- id: "canal_smart"
|
# - id: "canal_smart"
|
||||||
name: "SMART"
|
# name: "SMART"
|
||||||
p: 86
|
# p: 86
|
||||||
- id: "canal_optimum"
|
# - id: "canal_optimum"
|
||||||
name: "OPTIMUM"
|
# name: "OPTIMUM"
|
||||||
p: 87
|
# p: 87
|
||||||
- id: "canal_platinum"
|
# - id: "canal_platinum"
|
||||||
name: "PLATINUM"
|
# name: "PLATINUM"
|
||||||
p: 88
|
# p: 88
|
||||||
- id: "canal_podstawowy"
|
# - id: "canal_podstawowy"
|
||||||
name: "PODSTAWOWY"
|
# name: "PODSTAWOWY"
|
||||||
p: 75
|
# p: 75
|
||||||
- id: "canal_korzystny"
|
# - id: "canal_korzystny"
|
||||||
name: "KORZYSTNY"
|
# name: "KORZYSTNY"
|
||||||
p: 76
|
# p: 76
|
||||||
- id: "canal_bogaty"
|
# - id: "canal_bogaty"
|
||||||
name: "BOGATY"
|
# name: "BOGATY"
|
||||||
p: 77
|
# p: 77
|
||||||
|
|||||||
Binary file not shown.
@@ -141,13 +141,11 @@ function OfferCard({ plan, contractLabel, onConfigureAddons }) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{/* Umowa – już tylko z przełącznika */}
|
|
||||||
<li class="f-card-row">
|
<li class="f-card-row">
|
||||||
<span class="f-card-label">Umowa</span>
|
<span class="f-card-label">Umowa</span>
|
||||||
<span class="f-card-value">{effectiveContract}</span>
|
<span class="f-card-value">{effectiveContract}</span>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
{/* Aktywacja – z pola plan.price_installation */}
|
|
||||||
<li class="f-card-row">
|
<li class="f-card-row">
|
||||||
<span class="f-card-label">Aktywacja</span>
|
<span class="f-card-label">Aktywacja</span>
|
||||||
<span class="f-card-value">
|
<span class="f-card-value">
|
||||||
|
|||||||
@@ -1,193 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from "preact/hooks";
|
|
||||||
import "../../styles/offers/offers-table.css"; //
|
|
||||||
|
|
||||||
const BUILDING_MAP = {
|
|
||||||
jednorodzinny: 1,
|
|
||||||
wielorodzinny: 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
const CONTRACT_MAP = {
|
|
||||||
"24m": 1,
|
|
||||||
bezterminowa: 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function JamboxBasePackages({
|
|
||||||
source = "PLUS",
|
|
||||||
title,
|
|
||||||
selected = {},
|
|
||||||
}) {
|
|
||||||
const [packages, setPackages] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
|
|
||||||
// wyliczamy kody na podstawie tego samego selected, którego używasz w OffersCards
|
|
||||||
const buildingCode = useMemo(() => {
|
|
||||||
const val = selected?.budynek;
|
|
||||||
return BUILDING_MAP[val] ?? 1; // domyślnie jednorodzinny
|
|
||||||
}, [selected?.budynek]);
|
|
||||||
|
|
||||||
const contractCode = useMemo(() => {
|
|
||||||
const val = selected?.umowa;
|
|
||||||
return CONTRACT_MAP[val] ?? 1; // domyślnie 24m
|
|
||||||
}, [selected?.umowa]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
setLoading(true);
|
|
||||||
setError("");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
|
|
||||||
if (source && source !== "ALL") {
|
|
||||||
params.set("source", source);
|
|
||||||
}
|
|
||||||
if (buildingCode) {
|
|
||||||
params.set("building", String(buildingCode));
|
|
||||||
}
|
|
||||||
if (contractCode) {
|
|
||||||
params.set("contract", String(contractCode));
|
|
||||||
}
|
|
||||||
|
|
||||||
const query = params.toString();
|
|
||||||
const url = `/api/jambox/base-packages${query ? `?${query}` : ""}`;
|
|
||||||
|
|
||||||
const res = await fetch(url);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`HTTP ${res.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = await res.json();
|
|
||||||
|
|
||||||
if (!cancelled) {
|
|
||||||
setPackages(Array.isArray(json.data) ? json.data : []);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Błąd pobierania pakietów JAMBOX:", err);
|
|
||||||
if (!cancelled) {
|
|
||||||
setError("Nie udało się załadować pakietów JAMBOX.");
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
load();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [source, buildingCode, contractCode]);
|
|
||||||
|
|
||||||
const effectiveTitle =
|
|
||||||
title ||
|
|
||||||
(source === "PLUS"
|
|
||||||
? "Pakiety podstawowe JAMBOX PLUS"
|
|
||||||
: source === "EVIO"
|
|
||||||
? "Pakiety podstawowe JAMBOX EVIO"
|
|
||||||
: "Pakiety podstawowe JAMBOX");
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<section class="f-offers">
|
|
||||||
<h2 class="f-offers-title">{effectiveTitle}</h2>
|
|
||||||
<p>Ładowanie pakietów...</p>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<section class="f-offers">
|
|
||||||
<h2 class="f-offers-title">{effectiveTitle}</h2>
|
|
||||||
<p class="text-red-600">{error}</p>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!packages.length) {
|
|
||||||
return (
|
|
||||||
<section class="f-offers">
|
|
||||||
<h2 class="f-offers-title">{effectiveTitle}</h2>
|
|
||||||
<p>Brak pakietów do wyświetlenia.</p>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section class="f-offers">
|
|
||||||
{effectiveTitle && <h2 class="f-offers-title">{effectiveTitle}</h2>}
|
|
||||||
|
|
||||||
<div class={`f-offers-grid f-count-${packages.length}`}>
|
|
||||||
{packages.map((pkg) => (
|
|
||||||
<JamboxPackageCard key={`${pkg.source}-${pkg.tid}`} pkg={pkg} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function JamboxPackageCard({ pkg }) {
|
|
||||||
const updatedDate = pkg.updated_at
|
|
||||||
? new Date(pkg.updated_at).toLocaleDateString("pl-PL")
|
|
||||||
: "-";
|
|
||||||
|
|
||||||
const hasPrice = pkg.price_monthly != null;
|
|
||||||
const hasInstall = pkg.price_installation != null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div class="f-card">
|
|
||||||
<div class="f-card-header">
|
|
||||||
<div class="f-card-name">{pkg.name}</div>
|
|
||||||
|
|
||||||
{/* TU zamiast JAMBOX PLUS/EVIO pokazujemy cenę */}
|
|
||||||
<div class="f-card-price">
|
|
||||||
{hasPrice
|
|
||||||
? `${pkg.price_monthly} zł/mies.`
|
|
||||||
: pkg.source === "PLUS"
|
|
||||||
? "JAMBOX PLUS"
|
|
||||||
: "JAMBOX EVIO"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ul class="f-card-features">
|
|
||||||
{/* ID / slug jako techniczne info */}
|
|
||||||
<li class="f-card-row">
|
|
||||||
<span class="f-card-label">ID pakietu</span>
|
|
||||||
<span class="f-card-value">{pkg.tid}</span>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li class="f-card-row">
|
|
||||||
<span class="f-card-label">Slug</span>
|
|
||||||
<span class="f-card-value">{pkg.slug || "—"}</span>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
{/* nowa linia: cena instalacji z bazy */}
|
|
||||||
<li class="f-card-row">
|
|
||||||
<span class="f-card-label">Aktywacja</span>
|
|
||||||
<span class="f-card-value">
|
|
||||||
{hasInstall ? `${pkg.price_installation} zł` : "—"}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
{/* opcjonalnie informacja o źródle pakietu */}
|
|
||||||
<li class="f-card-row">
|
|
||||||
<span class="f-card-label">Platforma</span>
|
|
||||||
<span class="f-card-value">
|
|
||||||
{pkg.source === "PLUS" ? "JAMBOX PLUS" : "JAMBOX EVIO"}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li class="f-card-row">
|
|
||||||
<span class="f-card-label">Ostatnia aktualizacja</span>
|
|
||||||
<span class="f-card-value">{updatedDate}</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@ import { useState } from "preact/hooks";
|
|||||||
|
|
||||||
import OffersSwitches from "./Offers/OffersSwitches.jsx";
|
import OffersSwitches from "./Offers/OffersSwitches.jsx";
|
||||||
import OffersCards from "./Offers/OffersCards.jsx"; // <-- WAŻNE!!
|
import OffersCards from "./Offers/OffersCards.jsx"; // <-- WAŻNE!!
|
||||||
import OffersExtraServices from "./Offers/OffersExtraServices.jsx";
|
// import OffersExtraServices from "./Offers/OffersExtraServices.jsx";
|
||||||
|
|
||||||
export default function OffersIsland({ data }) {
|
export default function OffersIsland({ data }) {
|
||||||
const switches = data.przelaczniki ?? [];
|
const switches = data.przelaczniki ?? [];
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export default function RangeForm() {
|
|||||||
let timeoutStreet = null;
|
let timeoutStreet = null;
|
||||||
|
|
||||||
async function fetchCitySuggestions(q) {
|
async function fetchCitySuggestions(q) {
|
||||||
const res = await fetch(`/api/cities-autocomplete?q=${encodeURIComponent(q)}`);
|
const res = await fetch(`/api/range/cities-autocomplete?q=${encodeURIComponent(q)}`);
|
||||||
setCitySuggest(await res.json());
|
setCitySuggest(await res.json());
|
||||||
setHighlightIndex(-1);
|
setHighlightIndex(-1);
|
||||||
}
|
}
|
||||||
@@ -102,7 +102,7 @@ export default function RangeForm() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch(`/api/has-streets?city=${encodeURIComponent(currentCity)}`);
|
const res = await fetch(`/api/range/has-streets?city=${encodeURIComponent(currentCity)}`);
|
||||||
const { hasStreets } = await res.json();
|
const { hasStreets } = await res.json();
|
||||||
|
|
||||||
if (!hasStreets) {
|
if (!hasStreets) {
|
||||||
@@ -126,7 +126,7 @@ export default function RangeForm() {
|
|||||||
|
|
||||||
async function fetchStreetSuggestions(q, c) {
|
async function fetchStreetSuggestions(q, c) {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/api/streets-autocomplete?city=${encodeURIComponent(c)}&q=${encodeURIComponent(q)}`
|
`/api/range/streets-autocomplete?city=${encodeURIComponent(c)}&q=${encodeURIComponent(q)}`
|
||||||
);
|
);
|
||||||
setStreetSuggest(await res.json());
|
setStreetSuggest(await res.json());
|
||||||
setStreetHighlightIndex(-1);
|
setStreetHighlightIndex(-1);
|
||||||
@@ -207,7 +207,7 @@ export default function RangeForm() {
|
|||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
const res = await fetch("/api/search", {
|
const res = await fetch("/api/range/search", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ city, street, number }),
|
body: JSON.stringify({ city, street, number }),
|
||||||
|
|||||||
410
src/islands/jambox/JamboxAddonsModal.jsx
Normal file
410
src/islands/jambox/JamboxAddonsModal.jsx
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "preact/hooks";
|
||||||
|
import "../../styles/modal.css";
|
||||||
|
import "../../styles/offers/offers-table.css";
|
||||||
|
|
||||||
|
export default function JamboxAddonsModal({ isOpen, onClose, pkg }) {
|
||||||
|
const [phonePlans, setPhonePlans] = useState([]);
|
||||||
|
const [addons, setAddons] = useState([]);
|
||||||
|
|
||||||
|
const [tvAddons, setTvAddons] = useState([]);
|
||||||
|
const [selectedTvAddonTids, setSelectedTvAddonTids] = useState([]);
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const [selectedPhoneId, setSelectedPhoneId] = useState(null);
|
||||||
|
const [selectedAddonIds, setSelectedAddonIds] = useState([]);
|
||||||
|
|
||||||
|
// akordeony
|
||||||
|
const [openPhoneId, setOpenPhoneId] = useState(null);
|
||||||
|
const [baseOpen, setBaseOpen] = useState(true);
|
||||||
|
|
||||||
|
const formatFeatureValue = (val) => {
|
||||||
|
if (val === true || val === "true") return "✓";
|
||||||
|
if (val === false || val === "false" || val == null) return "✕";
|
||||||
|
return val;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePhoneSelect = (id) => {
|
||||||
|
if (id === null) {
|
||||||
|
setSelectedPhoneId(null);
|
||||||
|
setOpenPhoneId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedPhoneId(id);
|
||||||
|
setOpenPhoneId((prev) => (prev === id ? null : id));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAddon = (addonId) => {
|
||||||
|
setSelectedAddonIds((prev) =>
|
||||||
|
prev.includes(addonId)
|
||||||
|
? prev.filter((x) => x !== addonId)
|
||||||
|
: [...prev, addonId]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// reset po otwarciu / zmianie pakietu
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
setSelectedPhoneId(null);
|
||||||
|
setSelectedAddonIds([]);
|
||||||
|
setOpenPhoneId(null);
|
||||||
|
setBaseOpen(true);
|
||||||
|
setError("");
|
||||||
|
setSelectedTvAddonTids([]);
|
||||||
|
}, [isOpen, pkg?.id]);
|
||||||
|
|
||||||
|
// load danych
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || !pkg?.id) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// telefon
|
||||||
|
const phoneRes = await fetch("/api/phone/plans");
|
||||||
|
if (!phoneRes.ok) throw new Error(`HTTP ${phoneRes.status} (phone)`);
|
||||||
|
const phoneJson = await phoneRes.json();
|
||||||
|
const phoneData = Array.isArray(phoneJson.data) ? phoneJson.data : [];
|
||||||
|
|
||||||
|
// dodatki JAMBOX (dla pakietu)
|
||||||
|
const addonsRes = await fetch(`/api/jambox/addons?packageId=${pkg.id}`);
|
||||||
|
if (!addonsRes.ok) throw new Error(`HTTP ${addonsRes.status} (addons)`);
|
||||||
|
const addonsJson = await addonsRes.json();
|
||||||
|
const addonsData = Array.isArray(addonsJson.data) ? addonsJson.data : [];
|
||||||
|
|
||||||
|
const tvRes = await fetch(`/api/jambox/tv-addons?packageId=${pkg.id}`);
|
||||||
|
if (!tvRes.ok) throw new Error(`HTTP ${tvRes.status} (tv-addons)`);
|
||||||
|
|
||||||
|
const tvJson = await tvRes.json();
|
||||||
|
const tvData = Array.isArray(tvJson.data) ? tvJson.data : [];
|
||||||
|
|
||||||
|
if (!cancelled) {
|
||||||
|
setPhonePlans(phoneData);
|
||||||
|
setAddons(addonsData);
|
||||||
|
setTvAddons(tvData);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ Błąd ładowania danych do JamboxAddonsModal:", err);
|
||||||
|
if (!cancelled) setError("Nie udało się załadować danych dodatkowych usług.");
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [isOpen, pkg?.id]);
|
||||||
|
|
||||||
|
if (!isOpen || !pkg) return null;
|
||||||
|
|
||||||
|
const basePrice = Number(pkg.price_monthly || 0);
|
||||||
|
|
||||||
|
const phonePrice = useMemo(() => {
|
||||||
|
if (!selectedPhoneId) return 0;
|
||||||
|
const p = phonePlans.find((x) => x.id === selectedPhoneId);
|
||||||
|
return Number(p?.price_monthly || 0);
|
||||||
|
}, [selectedPhoneId, phonePlans]);
|
||||||
|
|
||||||
|
// UWAGA: backend może zwrócić { id, price } albo { addon_id, price }
|
||||||
|
const addonsPrice = useMemo(() => {
|
||||||
|
return selectedAddonIds.reduce((sum, addonId) => {
|
||||||
|
const a = addons.find((x) => (x.id ?? x.addon_id) === addonId);
|
||||||
|
return sum + Number(a?.price || 0);
|
||||||
|
}, 0);
|
||||||
|
}, [selectedAddonIds, addons]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const tvAddonsPrice = useMemo(() => {
|
||||||
|
return selectedTvAddonTids.reduce((sum, tid) => {
|
||||||
|
const a = tvAddons.find((x) => Number(x.tid) === Number(tid));
|
||||||
|
return sum + Number(a?.price || 0);
|
||||||
|
}, 0);
|
||||||
|
}, [selectedTvAddonTids, tvAddons]);
|
||||||
|
|
||||||
|
const totalMonthly = basePrice + phonePrice + addonsPrice + tvAddonsPrice;
|
||||||
|
|
||||||
|
const toggleTvAddon = (tid) => {
|
||||||
|
const t = Number(tid);
|
||||||
|
setSelectedTvAddonTids((prev) =>
|
||||||
|
prev.includes(t) ? prev.filter((x) => x !== t) : [...prev, t]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="fuz-modal-overlay" onClick={onClose}>
|
||||||
|
<button
|
||||||
|
class="fuz-modal-close"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="fuz-modal-panel fuz-modal-panel--compact"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div class="fuz-modal-inner">
|
||||||
|
<h2 class="fuz-modal-title">Konfiguracja usług dodatkowych</h2>
|
||||||
|
|
||||||
|
{/* PAKIET JAMBOX jako akordeon (jak internet w Twoim modalu) */}
|
||||||
|
<div class="fuz-modal-section">
|
||||||
|
<div class={`fuz-accordion-item ${baseOpen ? "is-open" : ""}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="fuz-accordion-header"
|
||||||
|
onClick={() => setBaseOpen((prev) => !prev)}
|
||||||
|
>
|
||||||
|
<span class="fuz-modal-phone-name">{pkg.name}</span>
|
||||||
|
<span class="fuz-modal-phone-price">
|
||||||
|
{basePrice ? `${basePrice.toFixed(2)} zł/mies.` : "—"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{baseOpen && pkg.features && pkg.features.length > 0 && (
|
||||||
|
<div class="fuz-accordion-body">
|
||||||
|
<ul class="f-card-features">
|
||||||
|
{pkg.features.map((f, idx) => (
|
||||||
|
<li class="f-card-row" key={idx}>
|
||||||
|
<span class="f-card-label">{f.label}</span>
|
||||||
|
<span class="f-card-value">
|
||||||
|
{formatFeatureValue(f.value)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <p>Ładowanie danych...</p>}
|
||||||
|
{error && <p class="text-red-600">{error}</p>}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<>
|
||||||
|
|
||||||
|
<div class="fuz-modal-section">
|
||||||
|
<h3>Pakiety dodatkowe TV</h3>
|
||||||
|
|
||||||
|
{tvAddons.length === 0 ? (
|
||||||
|
<p>Brak pakietów dodatkowych TV dla tego pakietu.</p>
|
||||||
|
) : (
|
||||||
|
<div class="fuz-addon-list">
|
||||||
|
{tvAddons.map((a) => {
|
||||||
|
const tid = Number(a.tid);
|
||||||
|
const checked = selectedTvAddonTids.includes(tid);
|
||||||
|
const priceNum = Number(a.price || 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label class="fuz-addon-item" key={`tv-${tid}`}>
|
||||||
|
<div class="fuz-addon-checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => toggleTvAddon(tid)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fuz-addon-main">
|
||||||
|
<div class="fuz-addon-name">{a.name}</div>
|
||||||
|
{/* jeśli chcesz pokazać typ/kind */}
|
||||||
|
<div class="fuz-addon-desc">{a.description}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fuz-addon-price">
|
||||||
|
{Number.isFinite(priceNum) ? `${priceNum.toFixed(2)} zł/mies.` : "—"}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TELEFON (identycznie jak w InternetAddonsModal) */}
|
||||||
|
<div class="fuz-modal-section">
|
||||||
|
<h3>Usługa telefoniczna</h3>
|
||||||
|
|
||||||
|
{phonePlans.length === 0 ? (
|
||||||
|
<p>Brak dostępnych pakietów telefonicznych.</p>
|
||||||
|
) : (
|
||||||
|
<div class="fuz-modal-phone-list fuz-accordion">
|
||||||
|
{/* OPCJA: brak telefonu */}
|
||||||
|
<div class="fuz-accordion-item fuz-accordion-item--no-phone">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="fuz-accordion-header"
|
||||||
|
onClick={() => handlePhoneSelect(null)}
|
||||||
|
>
|
||||||
|
<span class="fuz-accordion-header-left">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="phone-plan"
|
||||||
|
checked={selectedPhoneId === null}
|
||||||
|
onChange={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handlePhoneSelect(null);
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<span class="fuz-modal-phone-name">
|
||||||
|
Nie potrzebuję telefonu
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span class="fuz-modal-phone-price">0,00 zł/mies.</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PAKIETY TELEFONU */}
|
||||||
|
{phonePlans.map((p) => {
|
||||||
|
const isSelected = selectedPhoneId === p.id;
|
||||||
|
const isOpen = openPhoneId === p.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={`fuz-accordion-item ${isOpen ? "is-open" : ""}`}
|
||||||
|
key={p.id}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="fuz-accordion-header"
|
||||||
|
onClick={() => handlePhoneSelect(p.id)}
|
||||||
|
>
|
||||||
|
<span class="fuz-accordion-header-left">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="phone-plan"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handlePhoneSelect(p.id);
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<span class="fuz-modal-phone-name">{p.name}</span>
|
||||||
|
</span>
|
||||||
|
<span class="fuz-modal-phone-price">
|
||||||
|
{Number(p.price_monthly || 0).toFixed(2)} zł/mies.
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div class="fuz-accordion-body">
|
||||||
|
{p.features && p.features.length > 0 && (
|
||||||
|
<ul class="f-card-features">
|
||||||
|
{p.features
|
||||||
|
.filter(
|
||||||
|
(f) =>
|
||||||
|
!String(f.label || "")
|
||||||
|
.toLowerCase()
|
||||||
|
.includes("aktyw")
|
||||||
|
)
|
||||||
|
.map((f, idx) => (
|
||||||
|
<li class="f-card-row" key={idx}>
|
||||||
|
<span class="f-card-label">{f.label}</span>
|
||||||
|
<span class="f-card-value">{f.value}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* DODATKI JAMBOX (checkbox, cena z jambox_package_addon_options.price) */}
|
||||||
|
<div class="fuz-modal-section">
|
||||||
|
<h3>Dodatkowe usługi</h3>
|
||||||
|
|
||||||
|
{addons.length === 0 ? (
|
||||||
|
<p>Brak usług dodatkowych dla tego pakietu.</p>
|
||||||
|
) : (
|
||||||
|
<div class="fuz-addon-list">
|
||||||
|
{addons.map((a) => {
|
||||||
|
const addonId = a.id ?? a.addon_id; // defensywnie
|
||||||
|
const checked = selectedAddonIds.includes(addonId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label class="fuz-addon-item" key={addonId}>
|
||||||
|
<div class="fuz-addon-checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => toggleAddon(addonId)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fuz-addon-main">
|
||||||
|
<div class="fuz-addon-name">{a.name}</div>
|
||||||
|
{a.description && (
|
||||||
|
<div class="fuz-addon-desc">{a.description}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fuz-addon-price">
|
||||||
|
{Number(a.price || 0).toFixed(2)} zł/mies.
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PODSUMOWANIE */}
|
||||||
|
<div class="fuz-modal-section fuz-summary">
|
||||||
|
<h3>Podsumowanie miesięczne</h3>
|
||||||
|
|
||||||
|
<div class="fuz-summary-list">
|
||||||
|
<div class="fuz-summary-row">
|
||||||
|
<span>Pakiet</span>
|
||||||
|
<span>
|
||||||
|
{basePrice ? `${basePrice.toFixed(2)} zł/mies.` : "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fuz-summary-row">
|
||||||
|
<span>Pakiety TV</span>
|
||||||
|
<span>
|
||||||
|
{tvAddonsPrice ? `${tvAddonsPrice.toFixed(2)} zł/mies.` : "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fuz-summary-row">
|
||||||
|
<span>Telefon</span>
|
||||||
|
<span>{phonePrice ? `${phonePrice.toFixed(2)} zł/mies.` : "—"}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fuz-summary-row">
|
||||||
|
<span>Dodatki</span>
|
||||||
|
<span>{addonsPrice ? `${addonsPrice.toFixed(2)} zł/mies.` : "—"}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fuz-summary-total">
|
||||||
|
<span>Łącznie</span>
|
||||||
|
<span>{totalMonthly.toFixed(2)} zł/mies.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
src/islands/jambox/JamboxChannelsModal.jsx
Normal file
154
src/islands/jambox/JamboxChannelsModal.jsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
// src/islands/JamboxChannelsModal.jsx
|
||||||
|
import { useEffect, useState } from "preact/hooks";
|
||||||
|
import "../../styles/modal.css";
|
||||||
|
import "../../styles/offers/offers-table.css";
|
||||||
|
|
||||||
|
export default function JamboxChannelsModal({ isOpen, onClose, pkg }) {
|
||||||
|
const [channels, setChannels] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
|
||||||
|
const filtered = !q
|
||||||
|
? channels
|
||||||
|
: channels.filter((ch) =>
|
||||||
|
(ch.name || "").toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || !pkg?.id) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function loadChannels() {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
setChannels([]);
|
||||||
|
setQuery("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ packageId: String(pkg.id) });
|
||||||
|
const res = await fetch(`/api/jambox/channels?${params.toString()}`);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
if (!cancelled) {
|
||||||
|
setChannels(Array.isArray(json.data) ? json.data : []);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ Błąd pobierania listy kanałów:", err);
|
||||||
|
if (!cancelled) {
|
||||||
|
setError("Nie udało się załadować listy kanałów.");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadChannels();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [isOpen, pkg?.id]);
|
||||||
|
|
||||||
|
if (!isOpen || !pkg) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="fuz-modal-overlay" onClick={onClose}>
|
||||||
|
<button
|
||||||
|
class="fuz-modal-close"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="fuz-modal-panel fuz-modal-panel--channels"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div class="">
|
||||||
|
<h2 class="fuz-modal-title">Kanały w pakiecie {pkg.name}</h2>
|
||||||
|
|
||||||
|
<div class="jmb-search">
|
||||||
|
<input
|
||||||
|
class="jmb-search-input"
|
||||||
|
type="search"
|
||||||
|
value={query}
|
||||||
|
onInput={(e) => setQuery(e.currentTarget.value)}
|
||||||
|
placeholder="Szukaj kanału po nazwie…"
|
||||||
|
aria-label="Szukaj kanału po nazwie"
|
||||||
|
/>
|
||||||
|
{query && (
|
||||||
|
<button class="jmb-search-clear" type="button" onClick={() => setQuery("")}>
|
||||||
|
Wyczyść
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<div class="jmb-search-meta">
|
||||||
|
Wyniki: <strong>{filtered.length}</strong> / {channels.length}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && <p>Ładowanie kanałów...</p>}
|
||||||
|
{error && <p class="text-red-600">{error}</p>}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<>
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<p>Brak kanałów spełniających kryteria.</p>
|
||||||
|
) : (
|
||||||
|
<div class="">
|
||||||
|
<div class="f-section-channel">
|
||||||
|
{filtered.map((ch) => (
|
||||||
|
<div class="jmb-channel-card" key={ch.number}>
|
||||||
|
<div class="jmb-channel-inner">
|
||||||
|
{/* FRONT */}
|
||||||
|
<div class="jmb-channel-face jmb-channel-front">
|
||||||
|
{ch.logo_url && (
|
||||||
|
<img
|
||||||
|
src={ch.logo_url}
|
||||||
|
alt={ch.name}
|
||||||
|
class="jmb-channel-logo"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div class="jmb-channel-name">{ch.name}</div>
|
||||||
|
<div class="jmb-channel-number">kanał {ch.number}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* BACK */}
|
||||||
|
<div class="jmb-channel-face jmb-channel-back">
|
||||||
|
<div class="jmb-channel-back-title">{ch.name}</div>
|
||||||
|
<div
|
||||||
|
class="jmb-channel-desc"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: ch.description || "<em>Brak opisu kanału.</em>",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
187
src/islands/jambox/OffersJamboxCards.jsx
Normal file
187
src/islands/jambox/OffersJamboxCards.jsx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
// src/islands/JamboxBasePackages.jsx
|
||||||
|
import { useEffect, useState } from "preact/hooks";
|
||||||
|
import "../../styles/offers/offers-table.css";
|
||||||
|
import JamboxChannelsModal from "./JamboxChannelsModal.jsx";
|
||||||
|
import JamboxAddonsModal from "./JamboxAddonsModal.jsx";
|
||||||
|
|
||||||
|
export default function JamboxBasePackages({ source = "ALL" }) {
|
||||||
|
const [selected, setSelected] = useState({});
|
||||||
|
const [packages, setPackages] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const [channelsModalOpen, setChannelsModalOpen] = useState(false);
|
||||||
|
const [addonsModalOpen, setAddonsModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const [activePackage, setActivePackage] = useState(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== "undefined" && window.fuzSwitchState) {
|
||||||
|
const { selected: sel } = window.fuzSwitchState;
|
||||||
|
if (sel) setSelected(sel);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handler(e) {
|
||||||
|
const detail = e.detail || {};
|
||||||
|
if (detail.selected) setSelected(detail.selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("fuz:switch-change", handler);
|
||||||
|
return () => window.removeEventListener("fuz:switch-change", handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const buildingCode = Number(selected.budynek) || 1;
|
||||||
|
const contractCode = Number(selected.umowa) || 1;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!buildingCode || !contractCode) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("source", String(source || "ALL"));
|
||||||
|
params.set("building", String(buildingCode));
|
||||||
|
params.set("contract", String(contractCode));
|
||||||
|
|
||||||
|
const res = await fetch(`/api/jambox/base-packages?${params.toString()}`);
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
|
||||||
|
const json = await res.json();
|
||||||
|
if (!cancelled) setPackages(Array.isArray(json.data) ? json.data : []);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Błąd pobierania pakietów JAMBOX:", err);
|
||||||
|
if (!cancelled) setError("Nie udało się załadować pakietów JAMBOX.");
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [buildingCode, contractCode, source]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<section class="f-offers">
|
||||||
|
<p>Ładowanie pakietów...</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<section class="f-offers">
|
||||||
|
<p class="text-red-600">{error}</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!packages.length) {
|
||||||
|
return (
|
||||||
|
<section class="f-offers">
|
||||||
|
<p>Brak pakietów do wyświetlenia.</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section class="f-offers">
|
||||||
|
<div class={`f-offers-grid f-count-${packages.length || 1}`}>
|
||||||
|
{packages.map((pkg) => (
|
||||||
|
<JamboxPackageCard
|
||||||
|
key={pkg.id}
|
||||||
|
pkg={pkg}
|
||||||
|
onShowChannels={() => {
|
||||||
|
setActivePackage(pkg);
|
||||||
|
setChannelsModalOpen(true);
|
||||||
|
}}
|
||||||
|
onConfigureAddons={() => {
|
||||||
|
setActivePackage(pkg);
|
||||||
|
setAddonsModalOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<JamboxChannelsModal
|
||||||
|
isOpen={channelsModalOpen}
|
||||||
|
onClose={() => setChannelsModalOpen(false)}
|
||||||
|
pkg={activePackage}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<JamboxAddonsModal
|
||||||
|
isOpen={addonsModalOpen}
|
||||||
|
onClose={() => setAddonsModalOpen(false)}
|
||||||
|
pkg={activePackage}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function JamboxPackageCard({ pkg, onShowChannels, onConfigureAddons }) {
|
||||||
|
const basePrice = pkg.price_monthly;
|
||||||
|
const installPrice = pkg.price_installation;
|
||||||
|
|
||||||
|
const featureRows = pkg.features || [];
|
||||||
|
const hasPrice = basePrice != null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="f-card">
|
||||||
|
<div class="f-card-header">
|
||||||
|
<div class="f-card-name">{pkg.name}</div>
|
||||||
|
|
||||||
|
<div class="f-card-price">
|
||||||
|
{hasPrice
|
||||||
|
? `${basePrice} zł/mies.`
|
||||||
|
: pkg.source === "PLUS"
|
||||||
|
? "JAMBOX PLUS"
|
||||||
|
: "JAMBOX EVIO"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="f-card-features">
|
||||||
|
{featureRows.map((f, idx) => {
|
||||||
|
let val = f.value;
|
||||||
|
let display;
|
||||||
|
|
||||||
|
if (val === true || val === "true") display = "✓";
|
||||||
|
else if (val === false || val === "false" || val == null) display = "✕";
|
||||||
|
else display = val;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li class="f-card-row" key={idx}>
|
||||||
|
<span class="f-card-label">{f.label}</span>
|
||||||
|
<span class="f-card-value">{display}</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<li class="f-card-row">
|
||||||
|
<span class="f-card-label">Aktywacja</span>
|
||||||
|
<span class="f-card-value">
|
||||||
|
{installPrice != null ? `${installPrice} zł` : "—"}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<button type="button" class="btn btn-primary mt-2" onClick={onShowChannels}>
|
||||||
|
Pokaż listę kanałów
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary mt-2"
|
||||||
|
onClick={onConfigureAddons}
|
||||||
|
>
|
||||||
|
Skonfiguruj usługi dodatkowe
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from "preact/hooks";
|
import { useEffect, useState } from "preact/hooks";
|
||||||
import "../styles/offers/offers-table.css";
|
import "../../styles/offers/offers-table.css";
|
||||||
|
|
||||||
export default function PhoneDbOffersCards({
|
export default function PhoneDbOffersCards({
|
||||||
title = "Telefonia stacjonarna FUZ",
|
title = "Telefonia stacjonarna FUZ",
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
import type { APIRoute } from "astro";
|
|
||||||
import { getDb } from "./db";
|
|
||||||
|
|
||||||
type CityRow = { city: string };
|
|
||||||
|
|
||||||
export const GET: APIRoute = async () => {
|
|
||||||
const db = getDb();
|
|
||||||
const rows = db.prepare("SELECT DISTINCT city FROM ranges ORDER BY city").all() as CityRow[];
|
|
||||||
|
|
||||||
return new Response(JSON.stringify(rows.map((x) => x.city)), {
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -3,7 +3,7 @@ import Database from "better-sqlite3";
|
|||||||
const DB_PATH =
|
const DB_PATH =
|
||||||
process.env.FUZ_DB_PATH || "./src/data/ServicesRange.db";
|
process.env.FUZ_DB_PATH || "./src/data/ServicesRange.db";
|
||||||
|
|
||||||
let db: Database.Database;
|
let db;
|
||||||
|
|
||||||
export function getDb() {
|
export function getDb() {
|
||||||
if (!db) {
|
if (!db) {
|
||||||
54
src/pages/api/jambox/addons.js
Normal file
54
src/pages/api/jambox/addons.js
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import Database from "better-sqlite3";
|
||||||
|
|
||||||
|
const DB_PATH = process.env.FUZ_DB_PATH || "./src/data/ServicesRange.db";
|
||||||
|
|
||||||
|
export async function GET({ url }) {
|
||||||
|
const db = new Database(DB_PATH, { readonly: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const packageId = Number(url.searchParams.get("packageId") || 0);
|
||||||
|
if (!packageId) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ ok: false, error: "MISSING_PACKAGE_ID" }),
|
||||||
|
{ status: 400, headers: { "Content-Type": "application/json; charset=utf-8" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
a.id AS id,
|
||||||
|
a.name AS name,
|
||||||
|
a.type AS type,
|
||||||
|
CAST(o.price AS REAL) AS price
|
||||||
|
FROM jambox_package_addon_options o
|
||||||
|
JOIN internet_addons a
|
||||||
|
ON a.id = o.addon_id
|
||||||
|
WHERE o.package_id = ?
|
||||||
|
ORDER BY a.type, a.name
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.all(packageId);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
count: rows.length,
|
||||||
|
data: rows,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ Błąd w /api/jambox/addons:", err);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ ok: false, error: err.message || "DB_ERROR" }),
|
||||||
|
{ status: 500, headers: { "Content-Type": "application/json; charset=utf-8" } }
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,143 +1,117 @@
|
|||||||
// src/pages/api/jambox/base-packages.js
|
// src/pages/api/jambox/base-packages.js
|
||||||
|
//import { getDb } from "../db.js";
|
||||||
|
|
||||||
import Database from "better-sqlite3";
|
import Database from "better-sqlite3";
|
||||||
|
|
||||||
const DB_PATH =
|
const DB_PATH =
|
||||||
process.env.FUZ_DB_PATH || "./src/data/ServicesRange.db";
|
process.env.FUZ_DB_PATH || "./src/data/ServicesRange.db";
|
||||||
|
|
||||||
function getDb() {
|
function getDb() {
|
||||||
return new Database(DB_PATH, { readonly: true });
|
return new Database(DB_PATH, { readonly: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/jambox/base-packages
|
* GET /api/jambox/base-packages?source=PLUS|EVIO|ALL&building=1|2&contract=1|2
|
||||||
* ?source=PLUS|EVIO
|
|
||||||
* &building=1|2 (1=jednorodzinny, 2=wielorodzinny)
|
|
||||||
* &contract=1|2 (1=24m, 2=bezterminowa)
|
|
||||||
*/
|
*/
|
||||||
export function GET({ url }) {
|
export function GET({ url }) {
|
||||||
const sourceParam = url.searchParams.get("source");
|
const sourceParam = url.searchParams.get("source") || "PLUS";
|
||||||
const source = sourceParam ? sourceParam.toUpperCase() : null;
|
|
||||||
|
|
||||||
const buildingParam = url.searchParams.get("building");
|
const buildingParam = url.searchParams.get("building");
|
||||||
const contractParam = url.searchParams.get("contract");
|
const contractParam = url.searchParams.get("contract");
|
||||||
const building = buildingParam ? Number(buildingParam) : null;
|
|
||||||
const contract = contractParam ? Number(contractParam) : null;
|
const building = buildingParam ? Number(buildingParam) : 1;
|
||||||
|
const contract = contractParam ? Number(contractParam) : 1;
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let rows = [];
|
const rows = db
|
||||||
const hasVariant =
|
.prepare(
|
||||||
Number.isInteger(building) && Number.isInteger(contract);
|
`
|
||||||
|
SELECT
|
||||||
|
p.id AS package_id,
|
||||||
|
p.source AS package_source,
|
||||||
|
p.tid AS package_tid,
|
||||||
|
p.name AS package_name,
|
||||||
|
p.slug AS package_slug,
|
||||||
|
p.sort_order AS package_sort_order,
|
||||||
|
p.updated_at AS package_updated_at,
|
||||||
|
|
||||||
if (source === "PLUS" || source === "EVIO") {
|
pr.price_monthly AS price_monthly,
|
||||||
if (hasVariant) {
|
pr.price_installation AS price_installation,
|
||||||
// pakiety + ceny dla danego budynku/umowy
|
|
||||||
const stmt = db.prepare(
|
f.id AS feature_id,
|
||||||
`
|
f.label AS feature_label,
|
||||||
SELECT
|
fv.value AS feature_value
|
||||||
p.id,
|
|
||||||
p.source,
|
FROM jambox_base_packages p
|
||||||
p.tid,
|
|
||||||
p.name,
|
LEFT JOIN jambox_base_package_prices pr
|
||||||
p.slug,
|
ON pr.package_id = p.id
|
||||||
p.sort_order,
|
AND pr.building_type = ?
|
||||||
p.updated_at,
|
AND pr.contract_type = ?
|
||||||
pr.price_monthly,
|
|
||||||
pr.price_installation,
|
LEFT JOIN jambox_package_feature_values fv
|
||||||
pr.currency
|
ON fv.package_id = p.id
|
||||||
FROM jambox_base_packages p
|
|
||||||
LEFT JOIN jambox_base_package_prices pr
|
LEFT JOIN internet_features f
|
||||||
ON pr.package_id = p.id
|
ON f.id = fv.feature_id
|
||||||
AND pr.building_type = ?
|
|
||||||
AND pr.contract_type = ?
|
WHERE (? = 'ALL' OR p.source = ?)
|
||||||
WHERE p.source = ?
|
ORDER BY p.sort_order ASC, p.id ASC, f.id ASC;
|
||||||
ORDER BY p.sort_order ASC, p.name ASC;
|
`.trim()
|
||||||
`.trim()
|
)
|
||||||
);
|
.all(building, contract, sourceParam, sourceParam);
|
||||||
rows = stmt.all(building, contract, source);
|
|
||||||
} else {
|
// grupowanie jak w /api/internet/plans
|
||||||
// tylko pakiety, bez cen
|
const byPackage = new Map();
|
||||||
const stmt = db.prepare(
|
|
||||||
`
|
for (const row of rows) {
|
||||||
SELECT
|
if (!byPackage.has(row.package_id)) {
|
||||||
p.id,
|
byPackage.set(row.package_id, {
|
||||||
p.source,
|
id: row.package_id,
|
||||||
p.tid,
|
source: row.package_source,
|
||||||
p.name,
|
tid: row.package_tid,
|
||||||
p.slug,
|
name: row.package_name,
|
||||||
p.sort_order,
|
slug: row.package_slug,
|
||||||
p.updated_at
|
sort_order: row.package_sort_order,
|
||||||
FROM jambox_base_packages p
|
updated_at: row.package_updated_at,
|
||||||
WHERE p.source = ?
|
price_monthly: row.price_monthly,
|
||||||
ORDER BY p.sort_order ASC, p.name ASC;
|
price_installation: row.price_installation,
|
||||||
`.trim()
|
features: [],
|
||||||
);
|
});
|
||||||
rows = stmt.all(source);
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// bez filtra source (raczej nie użyjesz, ale niech będzie poprawnie)
|
if (row.feature_id) {
|
||||||
if (hasVariant) {
|
byPackage.get(row.package_id).features.push({
|
||||||
const stmt = db.prepare(
|
id: row.feature_id,
|
||||||
`
|
label: row.feature_label,
|
||||||
SELECT
|
value: row.feature_value,
|
||||||
p.id,
|
});
|
||||||
p.source,
|
|
||||||
p.tid,
|
|
||||||
p.name,
|
|
||||||
p.slug,
|
|
||||||
p.sort_order,
|
|
||||||
p.updated_at,
|
|
||||||
pr.price_monthly,
|
|
||||||
pr.price_installation,
|
|
||||||
pr.currency
|
|
||||||
FROM jambox_base_packages p
|
|
||||||
LEFT JOIN jambox_base_package_prices pr
|
|
||||||
ON pr.package_id = p.id
|
|
||||||
AND pr.building_type = ?
|
|
||||||
AND pr.contract_type = ?
|
|
||||||
ORDER BY p.source ASC, p.sort_order ASC, p.name ASC;
|
|
||||||
`.trim()
|
|
||||||
);
|
|
||||||
rows = stmt.all(building, contract);
|
|
||||||
} else {
|
|
||||||
const stmt = db.prepare(
|
|
||||||
`
|
|
||||||
SELECT
|
|
||||||
p.id,
|
|
||||||
p.source,
|
|
||||||
p.tid,
|
|
||||||
p.name,
|
|
||||||
p.slug,
|
|
||||||
p.sort_order,
|
|
||||||
p.updated_at
|
|
||||||
FROM jambox_base_packages p
|
|
||||||
ORDER BY p.source ASC, p.sort_order ASC, p.name ASC;
|
|
||||||
`.trim()
|
|
||||||
);
|
|
||||||
rows = stmt.all();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const data = Array.from(byPackage.values());
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
ok: true,
|
ok: true,
|
||||||
source: source ?? "ALL",
|
source: sourceParam,
|
||||||
building,
|
building,
|
||||||
contract,
|
contract,
|
||||||
count: rows.length,
|
count: data.length,
|
||||||
data: rows,
|
data,
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json; charset=utf-8",
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
"Cache-Control": "public, max-age=60",
|
"Cache-Control": "public, max-age=30",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("❌ Błąd odczytu z bazy jambox_base_packages:", err);
|
console.error("❌ Błąd w /api/jambox/base-packages:", err);
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ ok: false, error: "DB_ERROR" }),
|
JSON.stringify({ ok: false, error: "DB_ERROR" }),
|
||||||
{
|
{
|
||||||
@@ -147,7 +121,5 @@ export function GET({ url }) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
} finally {
|
|
||||||
db.close();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
64
src/pages/api/jambox/channels.js
Normal file
64
src/pages/api/jambox/channels.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
// src/pages/api/jambox/channels.js
|
||||||
|
//import { getDb } from "../db.js";
|
||||||
|
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
|
||||||
|
const DB_PATH =
|
||||||
|
process.env.FUZ_DB_PATH || "./src/data/ServicesRange.db";
|
||||||
|
|
||||||
|
function getDb() {
|
||||||
|
return new Database(DB_PATH, { readonly: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function GET({ url }) {
|
||||||
|
const packageIdParam = url.searchParams.get("packageId");
|
||||||
|
const packageId = Number(packageIdParam);
|
||||||
|
|
||||||
|
if (!packageId) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ ok: false, error: "INVALID_PACKAGE_ID" }),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
number,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
logo_url,
|
||||||
|
guaranteed
|
||||||
|
FROM jambox_package_channels
|
||||||
|
WHERE package_id = ?
|
||||||
|
ORDER BY number ASC;
|
||||||
|
`.trim()
|
||||||
|
)
|
||||||
|
.all(packageId);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ ok: true, data: rows }),
|
||||||
|
{
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ Błąd w /api/jambox/channels:", err);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ ok: false, error: "DB_ERROR" }),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
src/pages/api/jambox/tv-addons.js
Normal file
51
src/pages/api/jambox/tv-addons.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import Database from "better-sqlite3";
|
||||||
|
|
||||||
|
const DB_PATH = process.env.FUZ_DB_PATH || "./src/data/ServicesRange.db";
|
||||||
|
|
||||||
|
export async function GET({ url }) {
|
||||||
|
const db = new Database(DB_PATH, { readonly: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const packageId = Number(url.searchParams.get("packageId") || 0);
|
||||||
|
if (!packageId) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ ok: false, error: "MISSING_PACKAGE_ID" }),
|
||||||
|
{ status: 400, headers: { "Content-Type": "application/json; charset=utf-8" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
a.tid AS tid,
|
||||||
|
a.name AS name,
|
||||||
|
a.kind AS kind,
|
||||||
|
a.is_active AS is_active,
|
||||||
|
CAST(p.price AS REAL) AS price,
|
||||||
|
p.currency AS currency,
|
||||||
|
a.description AS description
|
||||||
|
FROM jambox_tv_addon_prices p
|
||||||
|
JOIN jambox_tv_addons a
|
||||||
|
ON a.tid = p.addon_tid
|
||||||
|
WHERE p.package_id = ?
|
||||||
|
AND a.is_active = 1
|
||||||
|
ORDER BY a.kind, a.name
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.all(packageId);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ ok: true, count: rows.length, data: rows }),
|
||||||
|
{ status: 200, headers: { "Content-Type": "application/json; charset=utf-8" } }
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ Błąd w /api/jambox/tv-addons:", err);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ ok: false, error: err.message || "DB_ERROR" }),
|
||||||
|
{ status: 500, headers: { "Content-Type": "application/json; charset=utf-8" } }
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import Database from "better-sqlite3";
|
import Database from "better-sqlite3";
|
||||||
|
|
||||||
const DB_PATH =
|
const DB_PATH = process.env.FUZ_DB_PATH || "./src/data/ServicesRange.db";
|
||||||
process.env.FUZ_DB_PATH || "./src/data/ServicesRange.db";
|
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const db = new Database(DB_PATH, { readonly: true });
|
const db = new Database(DB_PATH, { readonly: true });
|
||||||
@@ -69,7 +68,7 @@ export async function GET() {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("❌ Błąd w /api/phone/plans:", err);
|
console.error("Błąd w /api/phone/plans:", err);
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
ok: false,
|
ok: false,
|
||||||
|
|||||||
15
src/pages/api/range/all-cities.js
Normal file
15
src/pages/api/range/all-cities.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { getDb } from "../db.js";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const rows = db
|
||||||
|
.prepare("SELECT DISTINCT city FROM ranges ORDER BY city")
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const cities = rows.map((row) => row.city);
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(cities), {
|
||||||
|
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,21 +1,21 @@
|
|||||||
import type { APIRoute } from "astro";
|
import { getDb } from "../db.js";
|
||||||
import { getDb } from "./db";
|
|
||||||
|
|
||||||
function normalize(s: string) {
|
function normalize(s) {
|
||||||
return s.normalize("NFD").replace(/\p{Diacritic}/gu, "").toLowerCase();
|
return s.normalize("NFD").replace(/\p{Diacritic}/gu, "").toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
type CityRow = { city: string };
|
export async function GET({ request }) {
|
||||||
|
|
||||||
export const GET: APIRoute = async ({ request }) => {
|
|
||||||
const q = new URL(request.url).searchParams.get("q")?.trim() || "";
|
const q = new URL(request.url).searchParams.get("q")?.trim() || "";
|
||||||
|
|
||||||
if (q.length < 2)
|
if (q.length < 2) {
|
||||||
return new Response("[]", { headers: { "Content-Type": "application/json" } });
|
return new Response("[]", {
|
||||||
|
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
const rows = db.prepare(`SELECT DISTINCT city FROM ranges`).all() as CityRow[];
|
const rows = db.prepare(`SELECT DISTINCT city FROM ranges`).all();
|
||||||
|
|
||||||
const nq = normalize(q);
|
const nq = normalize(q);
|
||||||
|
|
||||||
@@ -25,6 +25,6 @@ export const GET: APIRoute = async ({ request }) => {
|
|||||||
.map((r) => r.city);
|
.map((r) => r.city);
|
||||||
|
|
||||||
return new Response(JSON.stringify(result), {
|
return new Response(JSON.stringify(result), {
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import type { APIRoute } from "astro";
|
import { getDb } from "../db.js";
|
||||||
import { getDb } from "./db";
|
|
||||||
|
|
||||||
export const GET: APIRoute = async ({ request }) => {
|
export async function GET({ request }) {
|
||||||
const city = new URL(request.url).searchParams.get("city")?.trim() || "";
|
const city = new URL(request.url).searchParams.get("city")?.trim() || "";
|
||||||
|
|
||||||
if (!city)
|
if (!city) {
|
||||||
return new Response(JSON.stringify({ hasStreets: false }), {
|
return new Response(JSON.stringify({ hasStreets: false }), {
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
@@ -18,10 +18,12 @@ export const GET: APIRoute = async ({ request }) => {
|
|||||||
WHERE LOWER(city) = LOWER(?)
|
WHERE LOWER(city) = LOWER(?)
|
||||||
AND TRIM(street) <> ''`
|
AND TRIM(street) <> ''`
|
||||||
)
|
)
|
||||||
.get(city) as { cnt: number };
|
.get(city);
|
||||||
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ hasStreets: row.cnt > 0 }),
|
JSON.stringify({ hasStreets: row.cnt > 0 }),
|
||||||
{ headers: { "Content-Type": "application/json" } }
|
{
|
||||||
|
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||||
|
}
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { APIRoute } from "astro";
|
// src/pages/api/range/check-address.js (przykładowa nazwa)
|
||||||
import { getDb } from "./db";
|
import { getDb } from "../db.js";
|
||||||
|
|
||||||
export const POST: APIRoute = async ({ request }) => {
|
export async function POST({ request }) {
|
||||||
const { city, street, number } = await request.json();
|
const { city, street, number } = await request.json();
|
||||||
|
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
@@ -17,12 +17,13 @@ export const POST: APIRoute = async ({ request }) => {
|
|||||||
)
|
)
|
||||||
.get(city, street || "", number || "");
|
.get(city, street || "", number || "");
|
||||||
|
|
||||||
if (!row)
|
if (!row) {
|
||||||
return new Response(JSON.stringify({ ok: false }), {
|
return new Response(JSON.stringify({ ok: false }), {
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return new Response(JSON.stringify({ ok: true, result: row }), {
|
return new Response(JSON.stringify({ ok: true, result: row }), {
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
40
src/pages/api/range/streets-autocomplete.js
Normal file
40
src/pages/api/range/streets-autocomplete.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { getDb } from "../db.js";
|
||||||
|
|
||||||
|
function normalize(s) {
|
||||||
|
return s.normalize("NFD").replace(/\p{Diacritic}/gu, "").toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET({ request }) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
const city = url.searchParams.get("city")?.trim() || "";
|
||||||
|
const q = url.searchParams.get("q")?.trim() || "";
|
||||||
|
|
||||||
|
if (!city || q.length < 1) {
|
||||||
|
return new Response("[]", {
|
||||||
|
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT DISTINCT street
|
||||||
|
FROM ranges
|
||||||
|
WHERE LOWER(city) = LOWER(?)
|
||||||
|
AND TRIM(street) <> ''`
|
||||||
|
)
|
||||||
|
.all(city);
|
||||||
|
|
||||||
|
const pattern = normalize(q);
|
||||||
|
|
||||||
|
const filtered = rows
|
||||||
|
.map((r) => r.street)
|
||||||
|
.filter((street) => normalize(street).includes(pattern))
|
||||||
|
.slice(0, 20);
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(filtered), {
|
||||||
|
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import type { APIRoute } from "astro";
|
|
||||||
import { getDb } from "./db";
|
|
||||||
|
|
||||||
function normalize(s: string) {
|
|
||||||
return s.normalize("NFD").replace(/\p{Diacritic}/gu, "").toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
type StreetRow = { street: string };
|
|
||||||
|
|
||||||
export const GET: APIRoute = async ({ request }) => {
|
|
||||||
const url = new URL(request.url);
|
|
||||||
|
|
||||||
const city = url.searchParams.get("city")?.trim() || "";
|
|
||||||
const q = url.searchParams.get("q")?.trim() || "";
|
|
||||||
|
|
||||||
if (!city || q.length < 1)
|
|
||||||
return new Response("[]", { headers: { "Content-Type": "application/json" } });
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
const rows = db.prepare(
|
|
||||||
`SELECT DISTINCT street
|
|
||||||
FROM ranges
|
|
||||||
WHERE LOWER(city) = LOWER(?)
|
|
||||||
AND TRIM(street) <> ''`
|
|
||||||
).all(city) as StreetRow[];
|
|
||||||
|
|
||||||
const pattern = normalize(q);
|
|
||||||
|
|
||||||
const filtered = rows
|
|
||||||
.map((s) => s.street)
|
|
||||||
.filter((s) => normalize(s).includes(pattern))
|
|
||||||
.slice(0, 20);
|
|
||||||
|
|
||||||
return new Response(JSON.stringify(filtered), {
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
});
|
|
||||||
};
|
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
---
|
---
|
||||||
import DefaultLayout from "../../layouts/DefaultLayout.astro";
|
import DefaultLayout from "../../layouts/DefaultLayout.astro";
|
||||||
|
import OffersSwitches from "../../islands/Offers/OffersSwitches.jsx";
|
||||||
|
import OffersJamboxCards from "../../islands/jambox/OffersJamboxCards.jsx";
|
||||||
|
|
||||||
import Hero from "../../components/hero/Hero.astro";
|
import Hero from "../../components/hero/Hero.astro";
|
||||||
import SectionRenderer from "../../components/sections/SectionRenderer.astro";
|
import SectionRenderer from "../../components/sections/SectionRenderer.astro";
|
||||||
import Markdown from "../../islands/Markdown.jsx";
|
import Markdown from "../../islands/Markdown.jsx";
|
||||||
import Modal from "../../islands/Modal.jsx";
|
import Modal from "../../islands/Modal.jsx";
|
||||||
import OffersIsland from "../../islands/OffersIsland.jsx";
|
|
||||||
import JamboxMozliwosci from "../../components/sections/SectionJamboxMozliwosci.astro";
|
import JamboxMozliwosci from "../../components/sections/SectionJamboxMozliwosci.astro";
|
||||||
import JamboxBasePackages from "../../islands/Offers/JamboxBasePackages.jsx";
|
|
||||||
|
|
||||||
import yaml from "js-yaml";
|
import yaml from "js-yaml";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
@@ -21,7 +22,7 @@ const page = yaml.load(
|
|||||||
fs.readFileSync("./src/content/internet-telewizja/page.yaml", "utf8"),
|
fs.readFileSync("./src/content/internet-telewizja/page.yaml", "utf8"),
|
||||||
);
|
);
|
||||||
const modalData = yaml.load(
|
const modalData = yaml.load(
|
||||||
fs.readFileSync("./src/content/internet-telewizja/modal.yaml", "utf8")
|
fs.readFileSync("./src/content/internet-telewizja/modal.yaml", "utf8"),
|
||||||
);
|
);
|
||||||
|
|
||||||
type Paragraph = {
|
type Paragraph = {
|
||||||
@@ -37,31 +38,30 @@ const rest = page.paragraphs.slice(1);
|
|||||||
---
|
---
|
||||||
|
|
||||||
<DefaultLayout seo={seo}>
|
<DefaultLayout seo={seo}>
|
||||||
<Hero {...hero} />
|
<!-- <Hero {...hero} /> -->
|
||||||
|
|
||||||
<section class="f-section">
|
<!-- <section class="f-section">
|
||||||
<div class="f-section-grid-single md:grid-cols-1">
|
<div class="f-section-grid-single md:grid-cols-1">
|
||||||
{page.title.map((line: any) => <h1 class="f-section-title">{line}</h1>)}
|
{page.title.map((line: any) => <h1 class="f-section-title">{line}</h1>)}
|
||||||
{first.title && <h3>{first.title}</h3>}
|
{first.title && <h3>{first.title}</h3>}
|
||||||
<Markdown text={first.content} />
|
<Markdown text={first.content} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section> -->
|
||||||
|
|
||||||
<section class="f-section">
|
<section class="f-section">
|
||||||
<div class="f-section-grid-single md:grid-cols-1 max-w-6xl mx-auto">
|
<div class="f-section-grid-single md:grid-cols-1">
|
||||||
|
<h1 class="f-section-title">Telewizja z interentem</h1>
|
||||||
<JamboxBasePackages
|
<div class="fuz-markdown max-w-none">
|
||||||
client:load
|
<p>Wybierz rodzaj budynku i czas trwania umowy</p>
|
||||||
source=""
|
</div>
|
||||||
title=""/>
|
<OffersSwitches client:load />
|
||||||
|
<OffersJamboxCards client:load />
|
||||||
<OffersIsland client:load data={data} />
|
|
||||||
|
<!-- <OffersIsland client:load data={data} /> -->
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- {
|
||||||
|
|
||||||
{
|
|
||||||
rest.map((p: Paragraph) => (
|
rest.map((p: Paragraph) => (
|
||||||
<section class="f-section">
|
<section class="f-section">
|
||||||
<div class="f-section-grid-single md:grid-cols-1">
|
<div class="f-section-grid-single md:grid-cols-1">
|
||||||
@@ -70,13 +70,11 @@ const rest = page.paragraphs.slice(1);
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
))
|
))
|
||||||
}
|
} -->
|
||||||
|
|
||||||
<SectionRenderer src="./src/content/internet-telewizja/section.yaml" />
|
<SectionRenderer src="./src/content/internet-telewizja/section.yaml" />
|
||||||
|
|
||||||
<JamboxMozliwosci />
|
<!-- <JamboxMozliwosci /> -->
|
||||||
|
|
||||||
|
|
||||||
<Modal client:load modalData={modalData} />
|
|
||||||
|
|
||||||
|
<!-- <Modal client:load modalData={modalData} /> -->
|
||||||
</DefaultLayout>
|
</DefaultLayout>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
import DefaultLayout from "../../layouts/DefaultLayout.astro";
|
import DefaultLayout from "../../layouts/DefaultLayout.astro";
|
||||||
import SectionRenderer from "../../components/sections/SectionRenderer.astro";
|
import SectionRenderer from "../../components/sections/SectionRenderer.astro";
|
||||||
import OffersPhoneCards from "../../islands/OffersPhoneCards.jsx";
|
import OffersPhoneCards from "../../islands/phone/OffersPhoneCards.jsx";
|
||||||
|
|
||||||
import yaml from "js-yaml";
|
import yaml from "js-yaml";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
@@ -19,17 +19,5 @@ const seo = yaml.load(
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- <OffersIsland client:load data={data} /> -->
|
|
||||||
<!-- {
|
|
||||||
rest.map((p: Paragraph) => (
|
|
||||||
<section class="f-section">
|
|
||||||
<div class="f-section-grid-single md:grid-cols-1">
|
|
||||||
{p.title && <h3 class="f-section-title">{p.title}</h3>}
|
|
||||||
<Markdown text={p.content.replace(/\n/g, "\n\n")} />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
))
|
|
||||||
} -->
|
|
||||||
|
|
||||||
<SectionRenderer src="./src/content/telefon/section.yaml" />
|
<SectionRenderer src="./src/content/telefon/section.yaml" />
|
||||||
</DefaultLayout>
|
</DefaultLayout>
|
||||||
|
|||||||
280
src/scripts/importJamboxChannelLists.js
Normal file
280
src/scripts/importJamboxChannelLists.js
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
// scripts/importJamboxChannelLists.js
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
import { XMLParser } from "fast-xml-parser";
|
||||||
|
|
||||||
|
const DB_PATH =
|
||||||
|
process.env.FUZ_DB_PATH || "./src/data/ServicesRange.db";
|
||||||
|
|
||||||
|
// źródła: URL + docelowy pakiet (source + slug)
|
||||||
|
const FEEDS = [
|
||||||
|
{
|
||||||
|
url: "https://www.jambox.pl/xml/listakanalow-smart.xml",
|
||||||
|
source: "EVIO",
|
||||||
|
slug: "smart",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://www.jambox.pl/xml/listakanalow-optimum.xml",
|
||||||
|
source: "EVIO",
|
||||||
|
slug: "optimum",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://www.jambox.pl/xml/listakanalow-platinum.xml",
|
||||||
|
source: "EVIO",
|
||||||
|
slug: "platinum",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://www.jambox.pl/xml/listakanalow-pluspodstawowy.xml",
|
||||||
|
source: "PLUS",
|
||||||
|
slug: "podstawowy",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://www.jambox.pl/xml/listakanalow-pluskorzystny.xml",
|
||||||
|
source: "PLUS",
|
||||||
|
slug: "korzystny",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: "https://www.jambox.pl/xml/listakanalow-plusbogaty.xml",
|
||||||
|
source: "PLUS",
|
||||||
|
slug: "bogaty",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: pobranie XML-a
|
||||||
|
*/
|
||||||
|
async function fetchXml(url) {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Błąd pobierania XML z ${url}: ${res.status} ${res.statusText}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return await res.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parser XML -> tablica node'ów z <xml><node>...</node></xml>
|
||||||
|
*/
|
||||||
|
function parseNodes(xmlText) {
|
||||||
|
const parser = new XMLParser({
|
||||||
|
ignoreAttributes: false,
|
||||||
|
attributeNamePrefix: "@_",
|
||||||
|
});
|
||||||
|
|
||||||
|
const json = parser.parse(xmlText);
|
||||||
|
let nodes = json.xml?.node ?? json.node ?? [];
|
||||||
|
|
||||||
|
if (!nodes) return [];
|
||||||
|
if (!Array.isArray(nodes)) nodes = [nodes];
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prosta dekodacja encji HTML (– etc.)
|
||||||
|
* – nie bawimy się w pełen parser HTML, tylko najczęstsze kody
|
||||||
|
*/
|
||||||
|
function decodeHtmlEntities(str) {
|
||||||
|
if (!str) return "";
|
||||||
|
return String(str)
|
||||||
|
.replace(/–/g, "–")
|
||||||
|
.replace(/ /g, " ")
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, "'")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Z node'a zrobimy obiekt kanału
|
||||||
|
*/
|
||||||
|
function mapChannelNode(node) {
|
||||||
|
const number = Number(node.nr);
|
||||||
|
const name = node.nazwa_kanalu ?? node.nazwa ?? null;
|
||||||
|
|
||||||
|
if (!number || !name) {
|
||||||
|
console.warn("⚠ Pomijam node bez numeru / nazwy:", node);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// opis – chcemy HTML, ale z poprawionymi encjami
|
||||||
|
let descriptionHtml = "";
|
||||||
|
|
||||||
|
if (typeof node.opis === "string") {
|
||||||
|
descriptionHtml = node.opis;
|
||||||
|
} else if (node.opis && node.opis.p) {
|
||||||
|
// <opis><p>...</p></opis>
|
||||||
|
if (typeof node.opis.p === "string") {
|
||||||
|
descriptionHtml = `<p>${node.opis.p}</p>`;
|
||||||
|
} else if (Array.isArray(node.opis.p)) {
|
||||||
|
// kilka <p>...</p>
|
||||||
|
descriptionHtml = node.opis.p
|
||||||
|
.map((p) =>
|
||||||
|
typeof p === "string" ? `<p>${p}</p>` : ""
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
descriptionHtml = decodeHtmlEntities(descriptionHtml.trim());
|
||||||
|
|
||||||
|
// logo – interesuje nas przede wszystkim src
|
||||||
|
let logoUrl = null;
|
||||||
|
const logoNode = node.field_logo_fid;
|
||||||
|
|
||||||
|
if (typeof logoNode === "string") {
|
||||||
|
// próbujemy wyciągnąć src="..."
|
||||||
|
const m = logoNode.match(/src="([^"]+)"/);
|
||||||
|
if (m) logoUrl = m[1];
|
||||||
|
} else if (logoNode && logoNode.img) {
|
||||||
|
// fast-xml-parser: <field_logo_fid><img ... /></field_logo_fid>
|
||||||
|
const img = logoNode.img;
|
||||||
|
if (img["@_src"]) {
|
||||||
|
logoUrl = img["@_src"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// gwarantowany
|
||||||
|
const guaranteed = String(node.gwarantowany || "")
|
||||||
|
.toLowerCase()
|
||||||
|
.includes("gwarantowany")
|
||||||
|
? 1
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
number,
|
||||||
|
name: String(name).trim(),
|
||||||
|
description: descriptionHtml || null,
|
||||||
|
logo_url: logoUrl || null,
|
||||||
|
guaranteed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upewniamy się, że tabela docelowa istnieje
|
||||||
|
*/
|
||||||
|
function ensureSchema(db) {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS jambox_package_channels (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
package_id INTEGER NOT NULL,
|
||||||
|
number INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
logo_url TEXT,
|
||||||
|
guaranteed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
UNIQUE (package_id, number),
|
||||||
|
FOREIGN KEY (package_id)
|
||||||
|
REFERENCES jambox_base_packages (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Znajdowanie id pakietu po (source, slug)
|
||||||
|
*/
|
||||||
|
function getPackageId(db, source, slug) {
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT id
|
||||||
|
FROM jambox_base_packages
|
||||||
|
WHERE source = ?
|
||||||
|
AND slug = ?;
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.get(source, slug);
|
||||||
|
|
||||||
|
return row?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert kanałów dla danego pakietu
|
||||||
|
*/
|
||||||
|
function upsertChannelsForPackage(db, packageId, channels) {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
INSERT INTO jambox_package_channels
|
||||||
|
(package_id, number, name, description, logo_url, guaranteed, updated_at)
|
||||||
|
VALUES
|
||||||
|
(@package_id, @number, @name, @description, @logo_url, @guaranteed, @updated_at)
|
||||||
|
ON CONFLICT(package_id, number) DO UPDATE SET
|
||||||
|
name = excluded.name,
|
||||||
|
description = excluded.description,
|
||||||
|
logo_url = excluded.logo_url,
|
||||||
|
guaranteed = excluded.guaranteed,
|
||||||
|
updated_at = excluded.updated_at;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const tx = db.transaction((rows) => {
|
||||||
|
let count = 0;
|
||||||
|
for (const ch of rows) {
|
||||||
|
stmt.run({
|
||||||
|
package_id: packageId,
|
||||||
|
number: ch.number,
|
||||||
|
name: ch.name,
|
||||||
|
description: ch.description,
|
||||||
|
logo_url: ch.logo_url,
|
||||||
|
guaranteed: ch.guaranteed,
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
console.log(` → zapisano/zmieniono ${count} kanałów dla package_id=${packageId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
tx(channels);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`Używam bazy: ${DB_PATH}`);
|
||||||
|
const db = new Database(DB_PATH);
|
||||||
|
|
||||||
|
ensureSchema(db);
|
||||||
|
|
||||||
|
for (const feed of FEEDS) {
|
||||||
|
console.log(
|
||||||
|
`\n=== Przetwarzam ${feed.url} (source=${feed.source}, slug=${feed.slug}) ===`
|
||||||
|
);
|
||||||
|
|
||||||
|
const packageId = getPackageId(db, feed.source, feed.slug);
|
||||||
|
if (!packageId) {
|
||||||
|
console.warn(
|
||||||
|
`⚠ Brak pakietu w jambox_base_packages dla source=${feed.source}, slug="${feed.slug}". Pomijam ten feed.`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const xml = await fetchXml(feed.url);
|
||||||
|
const nodes = parseNodes(xml);
|
||||||
|
|
||||||
|
console.log(`📦 Znaleziono ${nodes.length} node'ów (kanałów) w XML-u.`);
|
||||||
|
|
||||||
|
const channels = [];
|
||||||
|
for (const node of nodes) {
|
||||||
|
const mapped = mapChannelNode(node);
|
||||||
|
if (!mapped) continue;
|
||||||
|
channels.push(mapped);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`🧮 Po zmapowaniu pozostaje ${channels.length} poprawnych kanałów.`
|
||||||
|
);
|
||||||
|
|
||||||
|
upsertChannelsForPackage(db, packageId, channels);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`❌ Błąd przy przetwarzaniu ${feed.url}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db.close();
|
||||||
|
console.log("\n🎉 Import list kanałów JAMBOX zakończony.");
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("❌ Krytyczny błąd:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
127
src/scripts/importJamboxTvAddons.js
Normal file
127
src/scripts/importJamboxTvAddons.js
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import Database from "better-sqlite3";
|
||||||
|
import { XMLParser } from "fast-xml-parser";
|
||||||
|
|
||||||
|
const DB_PATH = process.env.FUZ_DB_PATH || "./src/data/ServicesRange.db";
|
||||||
|
|
||||||
|
const SOURCES = [
|
||||||
|
{
|
||||||
|
kind: "premium",
|
||||||
|
url: "https://www.jambox.pl/xml/slownik-pakietypremium.xml",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: "tematyczne_plus",
|
||||||
|
url: "https://www.jambox.pl/xml/slownik-pakietytematyczneplus.xml",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function fetchXml(url) {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Błąd pobierania XML z ${url}: ${res.status} ${res.statusText}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return await res.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNodesFromXml(xmlText) {
|
||||||
|
const parser = new XMLParser({
|
||||||
|
ignoreAttributes: false,
|
||||||
|
attributeNamePrefix: "@_",
|
||||||
|
});
|
||||||
|
|
||||||
|
const json = parser.parse(xmlText);
|
||||||
|
let nodes = json.xml?.node ?? json.node ?? [];
|
||||||
|
|
||||||
|
if (!nodes) return [];
|
||||||
|
if (!Array.isArray(nodes)) nodes = [nodes];
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureSchema(db) {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS jambox_tv_addons (
|
||||||
|
tid INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
kind TEXT NOT NULL,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function upsertTvAddons(db, kind, nodes) {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
const upsert = db.prepare(`
|
||||||
|
INSERT INTO jambox_tv_addons (tid, name, kind, is_active, updated_at)
|
||||||
|
VALUES (@tid, @name, @kind, 1, @updated_at)
|
||||||
|
ON CONFLICT(tid) DO UPDATE SET
|
||||||
|
name = excluded.name,
|
||||||
|
kind = excluded.kind,
|
||||||
|
is_active = excluded.is_active,
|
||||||
|
updated_at = excluded.updated_at;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const tx = db.transaction((rows) => {
|
||||||
|
let ok = 0;
|
||||||
|
|
||||||
|
for (const node of rows) {
|
||||||
|
const name = node.name ?? node.nazwa ?? node["#text"] ?? "";
|
||||||
|
const tidRaw = node.tid ?? node.id ?? null;
|
||||||
|
|
||||||
|
if (!tidRaw || !name) {
|
||||||
|
console.warn(`⚠ Pomijam node bez tid/name (kind=${kind}):`, node);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tid = Number(tidRaw);
|
||||||
|
if (!Number.isFinite(tid) || tid <= 0) {
|
||||||
|
console.warn(`⚠ Pomijam node z niepoprawnym tid (kind=${kind}):`, node);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
upsert.run({
|
||||||
|
tid,
|
||||||
|
name: String(name).trim(),
|
||||||
|
kind,
|
||||||
|
updated_at: now,
|
||||||
|
});
|
||||||
|
|
||||||
|
ok++;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`✅ ${kind}: zapisano/zmieniono ${ok} rekordów.`);
|
||||||
|
});
|
||||||
|
|
||||||
|
tx(nodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`Używam bazy: ${DB_PATH}`);
|
||||||
|
const db = new Database(DB_PATH);
|
||||||
|
|
||||||
|
ensureSchema(db);
|
||||||
|
|
||||||
|
for (const { kind, url } of SOURCES) {
|
||||||
|
console.log(`\n=== Przetwarzam ${kind} (${url}) ===`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const xml = await fetchXml(url);
|
||||||
|
const nodes = parseNodesFromXml(xml);
|
||||||
|
|
||||||
|
console.log(`📦 Znaleziono ${nodes.length} node'ów (kind=${kind})`);
|
||||||
|
upsertTvAddons(db, kind, nodes);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`❌ Błąd przy imporcie ${kind}:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db.close();
|
||||||
|
console.log("\n🎉 Import słowników pakietów dodatkowych zakończony.");
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("❌ Krytyczny błąd:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
239
src/scripts/updateJamboxChannels.js
Normal file
239
src/scripts/updateJamboxChannels.js
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
// scripts/updateJamboxChannels.js
|
||||||
|
import Database from "better-sqlite3";
|
||||||
|
import { XMLParser } from "fast-xml-parser";
|
||||||
|
|
||||||
|
const DB_PATH =
|
||||||
|
process.env.FUZ_DB_PATH || "./src/data/ServicesRange.db";
|
||||||
|
|
||||||
|
const JAMBOX_NUMBERS_URL = "https://www.jambox.pl/xml/jamboxwliczbach.xml";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prosty helper do pobrania XML-a
|
||||||
|
*/
|
||||||
|
async function fetchXml(url) {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Błąd pobierania XML z ${url}: ${res.status} ${res.statusText}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return await res.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parsowanie XML -> pojedynczy node z licznikami
|
||||||
|
*/
|
||||||
|
function parseCountsNode(xmlText) {
|
||||||
|
const parser = new XMLParser({
|
||||||
|
ignoreAttributes: false,
|
||||||
|
attributeNamePrefix: "@_",
|
||||||
|
});
|
||||||
|
|
||||||
|
const json = parser.parse(xmlText);
|
||||||
|
|
||||||
|
// dokładnie jak w podglądzie: <xml><node>...</node></xml>
|
||||||
|
const node = json.xml?.node ?? json.node ?? null;
|
||||||
|
|
||||||
|
if (!node) {
|
||||||
|
throw new Error("Nie znaleziono <node> w jamboxwliczbach.xml");
|
||||||
|
}
|
||||||
|
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInt(node, key) {
|
||||||
|
const raw = node[key];
|
||||||
|
const n = Number(raw);
|
||||||
|
if (!Number.isFinite(n)) {
|
||||||
|
console.warn(`⚠ Pole ${key} ma dziwną wartość:`, raw);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pobierz ID cech "Liczba Kanałów" i "Liczba Kanałów HD"
|
||||||
|
* żeby nie polegać na tym, że to zawsze 7 i 8.
|
||||||
|
*/
|
||||||
|
function getChannelFeatureIds(db) {
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT id, label
|
||||||
|
FROM internet_features
|
||||||
|
WHERE label IN ('Liczba Kanałów', 'Liczba Kanałów HD');
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
let channelsId = null;
|
||||||
|
let channelsHdId = null;
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (row.label === "Liczba Kanałów") channelsId = row.id;
|
||||||
|
if (row.label === "Liczba Kanałów HD") channelsHdId = row.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!channelsId || !channelsHdId) {
|
||||||
|
throw new Error(
|
||||||
|
`Brak wymaganych cech w internet_features (Liczba Kanałów / Liczba Kanałów HD).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`ℹ Feature IDs: "Liczba Kanałów" = ${channelsId}, "Liczba Kanałów HD" = ${channelsHdId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return { channelsId, channelsHdId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zwraca mapę (source, slug) -> package_id z jambox_base_packages
|
||||||
|
*/
|
||||||
|
function getPackageIdLookup(db) {
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`
|
||||||
|
SELECT id, source, slug
|
||||||
|
FROM jambox_base_packages;
|
||||||
|
`
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const map = new Map();
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!row.source || !row.slug) continue;
|
||||||
|
const key = `${row.source}::${row.slug}`;
|
||||||
|
map.set(key, row.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aktualizacja rekordów w jambox_package_feature_values
|
||||||
|
* dla "Liczba Kanałów" i "Liczba Kanałów HD"
|
||||||
|
*/
|
||||||
|
function updateChannelFeatureValues(db, packages, featureIds, packageIdMap) {
|
||||||
|
const { channelsId, channelsHdId } = featureIds;
|
||||||
|
|
||||||
|
const upsertStmt = db.prepare(`
|
||||||
|
INSERT INTO jambox_package_feature_values (package_id, feature_id, value)
|
||||||
|
VALUES (@package_id, @feature_id, @value)
|
||||||
|
ON CONFLICT(package_id, feature_id) DO UPDATE SET
|
||||||
|
value = excluded.value;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const tx = db.transaction((rows) => {
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
for (const pkg of rows) {
|
||||||
|
const key = `${pkg.source}::${pkg.slug}`;
|
||||||
|
const packageId = packageIdMap.get(key);
|
||||||
|
|
||||||
|
if (!packageId) {
|
||||||
|
console.warn(
|
||||||
|
`⚠ Brak pakietu w jambox_base_packages dla: source=${pkg.source}, slug="${pkg.slug}"`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Liczba Kanałów
|
||||||
|
upsertStmt.run({
|
||||||
|
package_id: packageId,
|
||||||
|
feature_id: channelsId,
|
||||||
|
value: String(pkg.canals),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Liczba Kanałów HD
|
||||||
|
upsertStmt.run({
|
||||||
|
package_id: packageId,
|
||||||
|
feature_id: channelsHdId,
|
||||||
|
value: String(pkg.canalshd),
|
||||||
|
});
|
||||||
|
|
||||||
|
total += 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`✅ Zaktualizowano / wstawiono ${total} wartości cech w jambox_package_feature_values.`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
tx(packages);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log(`Używam bazy: ${DB_PATH}`);
|
||||||
|
const db = new Database(DB_PATH); // tu normalne R/W połączenie
|
||||||
|
|
||||||
|
console.log(`\n=== Pobieram JAMBOX "w liczbach" ===`);
|
||||||
|
const xml = await fetchXml(JAMBOX_NUMBERS_URL);
|
||||||
|
const node = parseCountsNode(xml);
|
||||||
|
|
||||||
|
// 🔗 Ręczne mapowanie z ich XML-a na nasze pakiety (source + slug)
|
||||||
|
const packages = [
|
||||||
|
// EVIO
|
||||||
|
{
|
||||||
|
source: "EVIO",
|
||||||
|
slug: "smart",
|
||||||
|
canals: getInt(node, "ilosc_kanalow_eviosmart"),
|
||||||
|
canalshd: getInt(node, "ilosc_kanalow_hd_eviosmart"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: "EVIO",
|
||||||
|
slug: "optimum",
|
||||||
|
canals: getInt(node, "ilosc_kanalow_eviooptimum"),
|
||||||
|
canalshd: getInt(node, "ilosc_kanalow_hd_eviooptimum"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: "EVIO",
|
||||||
|
slug: "platinum",
|
||||||
|
canals: getInt(node, "ilosc_kanalow_evioplatinum"),
|
||||||
|
canalshd: getInt(node, "ilosc_kanalow_hd_evioplatinum"),
|
||||||
|
},
|
||||||
|
|
||||||
|
// PLUS
|
||||||
|
{
|
||||||
|
source: "PLUS",
|
||||||
|
slug: "podstawowy",
|
||||||
|
canals: getInt(node, "ilosc_kanalow_podstawowy"),
|
||||||
|
canalshd: getInt(node, "ilosc_kanalow_hd_podstawowy"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: "PLUS",
|
||||||
|
slug: "korzystny",
|
||||||
|
canals: getInt(node, "ilosc_kanalow_korzystny"),
|
||||||
|
canalshd: getInt(node, "ilosc_kanalow_hd_korzystny"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
source: "PLUS",
|
||||||
|
slug: "bogaty",
|
||||||
|
canals: getInt(node, "ilosc_kanalow_bogaty"),
|
||||||
|
canalshd: getInt(node, "ilosc_kanalow_hd_bogaty"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
console.log("🧮 Pakiety do aktualizacji:");
|
||||||
|
packages.forEach((p) => {
|
||||||
|
console.log(
|
||||||
|
` - ${p.source} / ${p.slug}: ${p.canals} kanałów, ${p.canalshd} HD`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 🔹 Pobierz ID cech i mapę pakietów
|
||||||
|
const featureIds = getChannelFeatureIds(db);
|
||||||
|
const packageIdMap = getPackageIdLookup(db);
|
||||||
|
|
||||||
|
// 🔹 Aktualizacja tabeli jambox_package_feature_values
|
||||||
|
updateChannelFeatureValues(db, packages, featureIds, packageIdMap);
|
||||||
|
|
||||||
|
db.close();
|
||||||
|
console.log("\n🎉 Aktualizacja liczby kanałów zakończona.");
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error("❌ Krytyczny błąd:", err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -139,3 +139,188 @@
|
|||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
obszar ze scrollem wewnątrz modala
|
||||||
|
.jmb-channels-scroll {
|
||||||
|
margin-top: 1rem;
|
||||||
|
padding-right: 0.25rem;
|
||||||
|
max-height: 72vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
} */
|
||||||
|
|
||||||
|
/* dużo kart w wierszu */
|
||||||
|
.jmb-channels-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||||
|
gap: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* pojedyncza karta kanału */
|
||||||
|
.jmb-channel-card {
|
||||||
|
background: var(--fuz-bg);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 0.75rem 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||||
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.15);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* logo kanału */
|
||||||
|
.jmb-channel-logo {
|
||||||
|
max-width: 90px;
|
||||||
|
max-height: 60px;
|
||||||
|
margin: 0 auto 0.5rem auto;
|
||||||
|
display: block;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* podpisy pod logo */
|
||||||
|
.jmb-channel-name {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jmb-channel-number {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* badge "gwarantowany" */
|
||||||
|
.jmb-channel-tag {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: rgba(34, 197, 94, 0.08);
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/* === FIX: stabilny grid + flip (nie rozjeżdża kart) === */
|
||||||
|
|
||||||
|
.jmb-channels-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||||
|
gap: 0.9rem;
|
||||||
|
align-items: stretch; /* ważne: równe wysokości w wierszu */
|
||||||
|
}
|
||||||
|
|
||||||
|
.jmb-channel-card {
|
||||||
|
position: relative;
|
||||||
|
perspective: 1000px;
|
||||||
|
|
||||||
|
/* zostawiamy Twój wygląd */
|
||||||
|
background: var(--fuz-bg);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||||
|
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.15);
|
||||||
|
|
||||||
|
/* kluczowe: karta ma “ramę” */
|
||||||
|
min-height: 170px; /* dopasuj: 160–200 */
|
||||||
|
height: 100%;
|
||||||
|
padding: 0; /* padding przenosimy na face */
|
||||||
|
overflow: hidden; /* żeby back nie wystawał */
|
||||||
|
}
|
||||||
|
|
||||||
|
.jmb-channel-inner {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
transition: transform 0.6s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
.jmb-channel-card:hover .jmb-channel-inner {
|
||||||
|
transform: rotateY(180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* front + back */
|
||||||
|
.jmb-channel-face {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
padding: 0.75rem 0.5rem; /* to był Twój padding z card */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
backface-visibility: hidden;
|
||||||
|
-webkit-backface-visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jmb-channel-front {
|
||||||
|
transform: rotateY(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jmb-channel-back {
|
||||||
|
transform: rotateY(180deg);
|
||||||
|
text-align: left;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jmb-channel-back-title {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jmb-channel-desc {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
opacity: 0.9;
|
||||||
|
overflow: auto; /* długi opis nie rozwala karty */
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.jmb-search {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
margin: 0.75rem 0 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jmb-search-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||||
|
background: var(--fuz-bg);
|
||||||
|
color: var(--fuz-text);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jmb-search-input:focus {
|
||||||
|
border-color: rgba(59, 130, 246, 0.55);
|
||||||
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jmb-search-clear {
|
||||||
|
padding: 0.55rem 0.7rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jmb-search-meta {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
opacity: 0.75;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user