Rezygnacja z bazy, przeniesienie danych do plików yamla

This commit is contained in:
dm
2025-12-15 06:30:39 +01:00
parent 00d6a57d74
commit 0b6bbbdce7
55 changed files with 3558 additions and 1545 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

BIN
src/assets/logo.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -1,5 +1,6 @@
---
import type { ImageMetadata } from 'astro';
import { Image, getImage } from "astro:assets";
import type { ImageMetadata } from "astro";
interface CTA {
label: string;
@@ -22,49 +23,69 @@ const {
description = "",
imageUrl = "",
textPosition = "right",
ctas = []
ctas = [],
} = Astro.props;
const imageBase = imageUrl.replace(/\.(webp|png|jpg|jpeg)$/i, '');
const imageBase = imageUrl.replace(/\.(webp|png|jpg|jpeg)$/i, "");
const images = import.meta.glob<{ default: ImageMetadata }>(
'/src/assets/hero/**/*.webp',
{ eager: true }
"/src/assets/hero/**/*.webp",
{ eager: true },
);
function findImage(folder: string): ImageMetadata | null {
const key = `/src/assets/hero/${folder}/${imageBase}-${folder}.webp`;
return images[key]?.default || null;
return images[key]?.default ?? null;
}
const mobile = findImage('mobile');
const tablet = findImage('tablet');
const desktop = findImage('desktop');
const mobile = findImage("mobile");
const tablet = findImage("tablet");
const desktop = findImage("desktop");
// Generujemy prawdziwe srcsety (a nie pojedynczy URL)
const mobileSet = mobile
? await getImage({ src: mobile, widths: [480, 640], format: "webp" })
: null;
const tabletSet = tablet
? await getImage({ src: tablet, widths: [768, 1024], format: "webp" })
: null;
// Fallback (największy, eager)
const desktopImg = desktop
? await getImage({ src: desktop, widths: [1280, 1440, 1920], format: "webp" })
: null;
---
<section class={`f-hero f-hero--${textPosition}`}>
<picture>
{mobile && (
{mobileSet && (
<source
srcset={mobile.src}
srcset={mobileSet.srcSet.attribute}
media="(max-width: 640px)"
sizes="100vw"
/>
)}
{tablet && (
{tabletSet && (
<source
srcset={tablet.src}
srcset={tabletSet.srcSet.attribute}
media="(max-width: 1024px)"
sizes="100vw"
/>
)}
{desktop && (
<img
src={desktop.src}
alt={imageBase}
<Image
src={desktop}
alt={Array.isArray(title) ? title.join(" ") : String(title || imageBase)}
class="f-hero-background"
loading="eager"
fetchpriority="high"
decoding="async"
format="webp"
widths={[1280, 1440, 1920]}
sizes="100vw"
/>
)}
</picture>
@@ -74,7 +95,7 @@ const desktop = findImage('desktop');
<div class="f-hero-container">
<div class="f-hero-content">
{Array.isArray(title) ? (
title.map(t => <h1 class="f-hero-title">{t}</h1>)
title.map((t) => <h1 class="f-hero-title">{t}</h1>)
) : (
<h1 class="f-hero-title">{title}</h1>
)}
@@ -91,7 +112,7 @@ const desktop = findImage('desktop');
{ctas.length > 0 && (
<div class="f-hero-ctas">
{ctas.map(cta => (
{ctas.map((cta) => (
<a
href={cta.href}
class={cta.primary ? "btn btn-primary" : "btn btn-primary"}

View File

@@ -1,5 +1,7 @@
---
import MobileMenu from "../../islands/MobileMenu.jsx";
import { Image } from "astro:assets";
import logo from "../../assets/logo.webp";
const links = [
{ name: "START", href: "/" },
@@ -21,7 +23,13 @@ const links = [
<nav class="f-navbar">
<div class="f-navbar-inner">
<a href="/" class="flex items-center gap-2 font-semibold">
<img src="/assets/logo.webp" alt="FUZ Logo" class="f-navbar-logo" />
<Image
src={logo}
alt="FUZ Logo"
class="f-navbar-logo"
loading="eager"
decoding="async"
/>
</a>
<div class="hidden md:flex f-navbar-links">

View File

@@ -1,51 +1,31 @@
---
import { Image } from "astro:assets";
import type { ImageMetadata } from "astro";
import Markdown from "../../islands/Markdown.jsx";
import JamboxChannelsSearch from "../../islands/jambox/JamboxChannelsSearch.jsx";
import TvChannelsSearch from "../../islands/jambox/JamboxChannelsSearch.jsx";
const props = Astro.props ?? {};
const section = props.section ?? {};
const index = Number(props.index ?? 0);
const hasImage = !!section.image;
const reverse = index % 2 === 1;
const sectionImages = import.meta.glob<{ default: ImageMetadata }>(
"/src/assets/sections/**/*.{png,jpg,jpeg,webp,avif}",
{ eager: true }
);
let sectionImage: ImageMetadata | null = null;
if (section.image) {
const path = `/src/assets/sections/${section.image}`;
const mod = sectionImages[path];
if (mod) sectionImage = mod.default;
}
---
<section class="f-section">
<div class={`f-section-grid ${hasImage ? "md:grid-cols-2" : "md:grid-cols-1"}`}>
{sectionImage && (
<Image
src={sectionImage}
alt={section.title ?? "Kanały TV"}
class={`f-section-image ${reverse ? "md:order-1" : "md:order-2"} ${section.dimmed ? "f-image-dimmed" : ""}`}
loading="lazy"
decoding="async"
format="webp"
widths={[480, 768, 1024, 1440]}
sizes="100vw"
/>
)}
<div class={`${hasImage ? (reverse ? "md:order-2" : "md:order-1") : ""}`}>
<div class="f-section-grid md:grid-cols-1">
<div>
{section.title && <h2 class="f-section-title">{section.title}</h2>}
{section.content && <Markdown text={section.content} />}
<JamboxChannelsSearch client:load />
{/* wyszukiwarka działa na API/DB */}
<TvChannelsSearch client:load />
{section.button && (
<div class="f-section-nav">
<a
href={section.button.url}
class="btn btn-primary"
title={section.button.title}
>
{section.button.text}
</a>
</div>
)}
</div>
</div>
</section>

View File

@@ -1,5 +1,6 @@
---
import { Image } from "astro:assets";
import type { ImageMetadata } from "astro";
import Markdown from "../../islands/Markdown.jsx";
const { section, index } = Astro.props;
@@ -19,6 +20,8 @@ if (section.image) {
const mod = sectionImages[path];
if (mod) sectionImage = mod.default;
}
const isAboveFold = index === 0; // możesz zmienić warunek jak chcesz
---
<section class="f-section">
@@ -31,21 +34,20 @@ if (section.image) {
src={sectionImage}
alt={section.title}
class={`f-section-image ${reverse ? "md:order-1" : "md:order-2"} ${section.dimmed ? "f-image-dimmed" : ""}`}
loading="lazy"
loading={isAboveFold ? "eager" : "lazy"}
fetchpriority={isAboveFold ? "high" : "auto"}
decoding="async"
format="webp"
widths={[480, 768, 1024, 1440]}
sizes="100vw"
sizes="(min-width: 768px) 50vw, 100vw"
/>
)
}
<div
class={`f-section-grid ${hasImage ? (reverse ? "md:order-2" : "md:order-1") : ""}`}
>
<h2 class="f-section-title">{section.title}</h2>
<div class={`${hasImage ? (reverse ? "md:order-2" : "md:order-1") : ""}`}>
{section.title && <h2 class="f-section-title">{section.title}</h2>}
<Markdown text={section.content} />
{
section.button && (
<div class="f-section-nav">

View File

@@ -0,0 +1,22 @@
tytul: "Dodatkowe usługi"
opis: "Wybierz usługi dodatkowe do internetu."
cena_opis: "zł/mies."
dodatki:
- id: "public_ip"
nazwa: "Publiczny adres IP"
typ: "checkbox"
ilosc: false
opis: "Otrzymujesz unikalny, publiczny adres IP przypisany na stałe do Twojego łącza."
cena: 18.45
# - id: "ip_v4_extra"
# nazwa: "Dodatkowy publiczny adres IP"
# typ: "quantity"
# ilosc: true
# min: 0
# max: 4
# krok: 1
# opis: "Możesz dodać kilka dodatkowych adresów IP. Cena za sztukę."
# cena: 18.45

View File

@@ -0,0 +1,133 @@
tytul: Internet światłowodowy
opis: |
Wybierz rodzaj budynku i czas trwania umowy
cena_opis: "zł/mies."
cards:
- nazwa: "FIBER 100"
widoczny: true
popularny: false
# cechy niezależne od przełączników
parametry:
- klucz: pobieranie
label: "Pobieranie"
value: "do 100 Mb/s"
- klucz: wysylanie
label: "Wysyłanie"
value: "do 50 Mb/s"
- klucz: router_wifi
label: "Router Wi-Fi"
value: "Tak"
- klucz: adres_ip
label: "Adres IP"
value: "Dynamiczny"
# ceny zależne od przełączników (building_type + contract_type)
ceny:
- budynek: 1
umowa: 1
miesiecznie: 64
aktywacja: 149
- budynek: 1
umowa: 2
miesiecznie: 84
aktywacja: 199
- budynek: 2
umowa: 1
miesiecznie: 54
aktywacja: 99
- budynek: 2
umowa: 2
miesiecznie: 74
aktywacja: 149
- nazwa: "FIBER 300"
widoczny: true
popularny: false
parametry:
- klucz: pobieranie
label: "Pobieranie"
value: "do 300 Mb/s"
- klucz: wysylanie
label: "Wysyłanie"
value: "do 150 Mb/s"
- klucz: router_wifi
label: "Router Wi-Fi"
value: "Tak"
- klucz: adres_ip
label: "Adres IP"
value: "Dynamiczny"
ceny:
- budynek: 1
umowa: 1
miesiecznie: 75
aktywacja: 149
- budynek: 1
umowa: 2
miesiecznie: 95
aktywacja: 199
- budynek: 2
umowa: 1
miesiecznie: 65
aktywacja: 99
- budynek: 2
umowa: 2
miesiecznie: 85
aktywacja: 149
- nazwa: "FIBER 600"
widoczny: true
popularny: false
parametry:
- klucz: pobieranie
label: "Pobieranie"
value: "do 600 Mb/s"
- klucz: wysylanie
label: "Wysyłanie"
value: "do 300 Mb/s"
- klucz: router_wifi
label: "Router Wi-Fi"
value: "Tak"
- klucz: adres_ip
label: "Adres IP"
value: "Dynamiczny"
ceny:
- budynek: 1
umowa: 1
miesiecznie: 85
aktywacja: 149
- budynek: 1
umowa: 2
miesiecznie: 105
aktywacja: 199
- budynek: 2
umowa: 1
miesiecznie: 75
aktywacja: 99
- budynek: 2
umowa: 2
miesiecznie: 95
aktywacja: 149

View File

@@ -2,7 +2,7 @@ sections:
- title: Router WiFi HL-4BX3V-F
image: "HL-4BX3V-F.webp"
content: |
W ramach instalacji otrzymujesz nowoczesny router marki HALNy to urządzenie stworzone z myślą o wymagających użytkownikach.
W ramach instalacji otrzymujesz nowoczesny router marki HALNy - urządzenie stworzone z myślą o wymagających użytkownikach..
Znajdziesz w nim nowoczesny standard WiFi 6, porty 2,5Gb/s oraz 1 Gb/s, wsparcie dla sieci Mesh i VoIP. Stabilność, niezawodność i pełne wykorzystanie łącza w całym Twoim domu.

View File

@@ -0,0 +1,49 @@
tytul: "Dodatkowe usługi"
opis: "Wybierz usługi dodatkowe do internetu z telewizją."
cena_opis: "zł/mies."
dekodery:
- id: arris_4302
nazwa: "Arris 4302"
cena: 0
- id: arris_5202
nazwa: "Arris 5202"
cena: 5
- id: tv_smart_4k
nazwa: "TV Smart 4K"
cena: 10
dodatki:
- id: "public_ip"
nazwa: "Publiczny adres IP"
typ: "checkbox"
ilosc: false
opis: "Otrzymujesz unikalny, publiczny adres IP przypisany na stałe do Twojego łącza."
cena: 18.45
- id: "internet_600"
nazwa: "Internet 600 Mb/s"
typ: "checkbox"
ilosc: false
opis: "Zwiększenie internetu na 600 Mb/s."
cena: 10.0
- id: "multiroom"
nazwa: "Multiroom"
typ: "quantity"
ilosc: true
min: 0
max: 4
krok: 1
opis: "Multiroom umożliwia odbiór telewizji cyfrowej maksymalnie na 4 dodatkowych telewizorach."
cena:
default: 19.90
by_name:
"Smart": 15.0
"Optimum": 15.0
"Platinum": 15.0
"Podstawowy": 19.90
"Korzystny": 19.90
"Bogaty": 19.90

View File

@@ -0,0 +1,242 @@
tytul: "Internet z telewizją"
opis: |
Wybierz rodzaj budynku i czas trwania umowy
cena_opis: "zł/mies."
grupy:
- id: "EVIO"
nazwa: "Evio"
opis: "Pakiety TV Evio"
- id: "PLUS"
nazwa: "Jambox Plus"
opis: "Pakiety TV Jambox Plus"
# wspólne parametry internetu dla wszystkich pakietów (żeby nie dublować)
internet_parametry_wspolne:
- klucz: pobieranie
label: "Pobieranie"
value: "do 300 Mb/s"
- klucz: wysylanie
label: "Wysyłanie"
value: "do 150 Mb/s"
- klucz: router_wifi
label: "Router Wi-Fi"
value: "Tak"
- klucz: adres_ip
label: "Adres IP"
value: "Dynamiczny"
cards:
# =========================
# EVIO
# =========================
- id: "evio_86"
source: "EVIO"
tid: 86
nazwa: "Smart"
slug: "smart"
widoczny: true
popularny: false
# parametry = internet (wspólne) + TV (z pakietu)
parametry:
- klucz: canals
label: "Kanały"
value: 130
- klucz: canalshd
label: "Kanały HD"
value: 109
ceny:
- budynek: 1
umowa: 1
miesiecznie: 109
aktywacja: 149
- budynek: 1
umowa: 2
miesiecznie: 129
aktywacja: 199
- budynek: 2
umowa: 1
miesiecznie: 99
aktywacja: 99
- budynek: 2
umowa: 2
miesiecznie: 119
aktywacja: 149
- id: "evio_87"
source: "EVIO"
tid: 87
nazwa: "Optimum"
slug: "optimum"
widoczny: true
popularny: false
parametry:
- klucz: canals
label: "Kanały"
value: 174
- klucz: canalshd
label: "Kanały HD"
value: 142
ceny:
- budynek: 1
umowa: 1
miesiecznie: 125
aktywacja: 149
- budynek: 1
umowa: 2
miesiecznie: 145
aktywacja: 199
- budynek: 2
umowa: 1
miesiecznie: 115
aktywacja: 99
- budynek: 2
umowa: 2
miesiecznie: 135
aktywacja: 149
- id: "evio_88"
source: "EVIO"
tid: 88
nazwa: "Platinum"
slug: "platinum"
widoczny: true
popularny: false
parametry:
- klucz: canals
label: "Kanały"
value: 211
- klucz: canalshd
label: "Kanały HD"
value: 171
ceny:
- budynek: 1
umowa: 1
miesiecznie: 158
aktywacja: 149
- budynek: 1
umowa: 2
miesiecznie: 178
aktywacja: 199
- budynek: 2
umowa: 1
miesiecznie: 148
aktywacja: 99
- budynek: 2
umowa: 2
miesiecznie: 168
aktywacja: 149
# =========================
# PLUS
# =========================
- id: "plus_75"
source: "PLUS"
tid: 75
nazwa: "Podstawowy"
slug: "podstawowy"
widoczny: true
popularny: false
parametry:
- klucz: canals
label: "Kanały"
value: 78
- klucz: canalshd
label: "Kanały HD"
value: 68
ceny:
- budynek: 1
umowa: 1
miesiecznie: 88
aktywacja: 149
- budynek: 1
umowa: 2
miesiecznie: 108
aktywacja: 199
- budynek: 2
umowa: 1
miesiecznie: 78
aktywacja: 99
- budynek: 2
umowa: 2
miesiecznie: 98
aktywacja: 149
- id: "plus_76"
source: "PLUS"
tid: 76
nazwa: "Korzystny"
slug: "korzystny"
widoczny: true
popularny: false
parametry:
- klucz: canals
label: "Kanały"
value: 138
- klucz: canalshd
label: "Kanały HD"
value: 119
ceny:
- budynek: 1
umowa: 1
miesiecznie: 110
aktywacja: 149
- budynek: 1
umowa: 2
miesiecznie: 130
aktywacja: 199
- budynek: 2
umowa: 1
miesiecznie: 100
aktywacja: 99
- budynek: 2
umowa: 2
miesiecznie: 120
aktywacja: 149
- id: "plus_77"
source: "PLUS"
tid: 77
nazwa: "Bogaty"
slug: "bogaty"
widoczny: true
popularny: false
parametry:
- klucz: canals
label: "Kanały"
value: 182
- klucz: canalshd
label: "Kanały HD"
value: 153
ceny:
- budynek: 1
umowa: 1
miesiecznie: 120
aktywacja: 149
- budynek: 1
umowa: 2
miesiecznie: 140
aktywacja: 199
- budynek: 2
umowa: 1
miesiecznie: 110
aktywacja: 99
- budynek: 2
umowa: 2
miesiecznie: 130
aktywacja: 149

View File

@@ -1,23 +1,63 @@
sections:
- title: "Dekoder telewizyjny"
- title: "Dekoder Arris 4302 HD"
image: "VIP4302.png"
button:
text: "Zobacz możliwości dekodera →"
url: "/internet-telewizja/mozliwosci"
url: "/internet-telewizja/telewizja-mozliwosci"
title: "Zobacz możliwości dekodera"
content: |
Arris 4302 HD to kompaktowy sprzęt z możliwością korzystania z jakości HD. Oprogramowanie Kyanit z wygodnym i szybkim interfejsem użytkownika.
Arris 4302 HD to kompaktowy sprzęt z możliwością korzystania z jakości HD.
Oprogramowanie Kyanit z wygodnym i szybkim interfejsem użytkownika.
Dekoder obsługuje usługi takie jak: CatchUp, StartOver, nagrywanie w chmurze (JAMBO Nagrywarka), nagrywanie na dysku (USB PVR oraz MultiPVR), dostęp do wideo na życzenie VOD i inne.
Oferuje opcjonalne podłączenie dedykowanego dysku zewnętrznego Arris DVR-One.
Dekoder obsługuje usługi takie jak: CatchUp, StartOver, nagrywanie w chmurze, nagrywanie na dysku (USB PVR oraz MultiPVR), dostęp do wideo na życzenie VOD i inne.
Oferuje opcjonalne podłączenie dedykowanego dysku zewnętrznego Arris DVR-One lub własnego.
**Specyfikacja:**
Ogólna specyfikacja techniczna:
- Procesor 6000 DMIPS z zaawansowaną kartą graficzną z dekoderem telewizji cyfrowej HD
- Pamięć RAM 1 GB DDR3
- Pamięć Flash 256 MB
- Dekoder wspiera pilot w wersji IR (podczerwień) oraz BT (Bluetooth)
- Standardowe wejścia/wyjścia dla dźwięku, obrazu i danych:
- Interfejsy tylnego panelu obejmują m.in. Ethernet, USB, HDMI, CVBS, Optyczny i analogowy audio 3,5mm
- Dekoder wspiera tylko pilot w wersji IR (podczerwień)
- Standardowe wejścia/wyjścia dla dźwięku, obrazu i danych: Interfejsy tylnego panelu obejmują m.in. Ethernet, USB, HDMI, CVBS, Optyczny i analogowy audio 3,5mm
- Przedni panel zawiera m.in. diodę LED i odbiornik podczerwieni
- Wymiary modelu (szer/dł/wys): 130 x 130 x 26 mm
- title: "Dekoder Arris 5202-4k"
image: "arris5202.png"
button:
text: "Zobacz możliwości dekodera →"
url: "/internet-telewizja/telewizja-mozliwosci"
title: "Zobacz możliwości dekodera"
content: |
Wydajny dekoder z możliwością korzystania z technologii 4K.
Oprogramowanie Kyanit z wygodnym i szybkim interfejsem użytkownika.
Dekoder obsługuje także technologie: High Dynamic Range (HDR), 10-bitowy kolor z dekodowaniem HEVC.
Ogólna specyfikacja techniczna dekodera Arris 5202 4K
- Szybki procesor Dual-core, 8500 DMIPS
- Pamięć RAM: DDR3 2GB, 8 GB eMMC Flash
- Rozdzielczość obrazu: 4K, Full HD, wsparcie dla technologii HDR10
- Dekodowanie sygnału: HEVC H.265 | MPEG-2 | MPEG-4 AVC H.264 | VP9
- Obsługa formatu audio: AAC | Dolby Digital Plus | PCM
- Sieć: Ethernet
- Złącza video: HDMI 2.1
- Złącza: RJ45 Fast Ethernet | Toslink Digital Audio | USB 2.0 Type A 500 mA
- Wymiary (szer/wys/dł): 120/25/120 mm
- title: "Dekoder TV Smart 4K BOX"
image: "tv-smart-4k.webp"
# button:
# text: "Zobacz możliwości dekodera →"
# url: "/internet-telewizja/mozliwosci"
# title: "Zobacz możliwości dekodera"
content: |
TV Smart 4K BOX to kompaktowe i mobilne centrum domowej rozrywki, które zapewnia dostęp do ulubionych programów oraz filmów wszędzie tam, gdzie możesz połączyć się z Internetem.
- Wbudowana karta sieciowa Wi-Fi
- Telewizja linearna z funkcjami: StartOver, CatchUp, Nagrywarka w chmurze
- Pilot bluetooth z możliwością głosowej obsługi
- Dostęp do serwisów: HBO Max, CANAL+, Filmbox+, Disney+, Netflix, Amazon Prime, YouTube, Spotify, Tidal i innych
- Dostęp do bogatej biblioteki VOD
- Możliwość instalacji aplikacji zgodnych z Android TV
- Wbudowany Chromecast
- Zawsze dostępne, aktualne, rozszerzone EPG
- Możliwość wyboru napisów i ścieżek dźwiękowych
- Samodzielne i natychmiastowe uruchamianie usług dodatkowych z użyciem pilota
- Ochrona rodzicielska

View File

@@ -0,0 +1,228 @@
sections:
- title: "CatchUp - Archiwum TV"
image: "https://www.jambox.pl/sites/default/files/jambox-kyanit-catchup1.png"
content: |
Oglądaj audycje do 7 dni wstecz
Funkcja CatchUp pozwala na oglądanie archiwalnych audycji na wybranych kanałach do 7 dni wstecz.
Przegapiłeś jakiś program i zapomniałeś go nagrać? Znajdź go w EPG, cofając się w lewo na ekranie.
- title: "JAMBO Nagrywarka"
image: "https://www.jambox.pl/sites/default/files/jambox-kyanit-jpvr.png"
content: |
Nagrywaj bez dysku
Nagrywarka sieciowa pozwala na nagrywanie i oglądanie audycji bez dysku.
Dzięki tej wersji nagrywarki możesz zlecać w tym samym czasie 3 niezależne nagrania.
Korzyści jakie daje Ci JAMBO Nagrywarka to między innymi możliwość oglądania nagrania od początku, nawet jak włączyłeś je w trakcie emisji na żywo.
Przydatna funkcja to możliwość zaprogramowania nagrywarki także z aplikacji JAMBOX go! Nagrania są dostępne na dekoderze przez 7 dni.
Przestrzeń w chmurze na nagrania to 250 GB, co oznacza około 22 godziny nagrań w jakości HD.
- title: "StartOver - Oglądaj od początku"
image: "https://www.jambox.pl/sites/default/files/jambox-kyanit-startover1.png"
content: |
Oglądaj audycje od początku
Funkcja StartOver pozwala na oglądanie od początku tych audycji, które już się rozpoczęły, lecz jeszcze nie skończyły.
Spóźniłeś się na emisję na żywo? Nic nie szkodzi! Kliknij "Oglądaj od początku" i już nic Ci nie umknie!
- title: "Informacja o programie"
image: "https://www.jambox.pl/sites/default/files/jambox-kyanit-miniepg-info_0.jpg"
content: |
Dowiedz się więcej o programie
Dowiedz się więcej o programie, który właśnie oglądasz lub sobie nagrałeś.
Interesuje Cię kto gra w tym filmie albo o czym on jest? Wystarczy włączyć INFO i już wiesz.
- title: "Napisy i ścieżki dźwiękowe"
image: "https://www.jambox.pl/sites/default/files/jambox-kyanit-design-settings-popup.jpg"
content: |
Wybieraj ścieżki dźwiękowe i włączaj napisy na wybranych kanałach
Możliwość wyświetlania napisów to funkcja umożliwiająca oglądanie telewizji przez osoby niesłyszące.
Dostępne są także alternatywne ścieżki dźwiękowe na wybranych kanałach, umożliwiające wybór oryginalnej ścieżki lub wersji językowych.
- title: "Przewodnik po programach EPG"
image: "https://www.jambox.pl/sites/default/files/jambox-kyanit-epg-fullvideo_0.jpg"
content: |
Twój przewodnik po programach, dzięki któremu znajdziesz interesujące Cię pozycje w wielu kanałach tv
Program kanałów telewizyjnych dostępny z pilota twojego telewizora.
Przewodnik umożliwia sprawdzenie aktualnie oglądanego programu i ramówki innych kanałów.
Widzimy programy, które są emitowane teraz lub będą za chwilę, możemy zobaczyć program na najbliższe dni.
To Twoja gazeta telewizyjna z programem.
Dodatkowo możesz ustawić sobie powiadomienie lub automatyczne przełączenie przy dowolnym programie z ramówki.
Użycie tej funkcjonalności spowoduje, że zostaniesz poinformowany na ekranie telewizora o tym, że wybrany program właśnie się zaczyna lub też od razu zostaniesz przełączony na dany program.
- title: "Jakość obrazu 4K"
image: "https://www.jambox.pl/sites/default/files/jambox-kyanit-4k.jpg"
content: |
JAMBOX to bezkompromisowa jakość obrazu 4K (UltraHD)
Dostępna w dekodderach TV Smart 4K i Arris 5202.
Wyświetlany obraz w technologii 4K jest bardziej szczegółowy i zawiera więcej detali dzięki zastosowaniu bardzo dużej liczby pikseli.
Tak wysoka rozdzielczość odtwarzanego obrazu pozwala wydobyć pełnię potencjału oglądanych filmów czy programów.
Jest to szczególnie istotne dla fanów sportu oraz miłośników widowiskowych filmów.
4K ma czterokrotnie większą liczbę pikseli w porównaniu do powszechnie używanej rozdzielczości Full HD.
Zaawansowane telewizory do odbioru telewizji 4K oprócz wysokiej rozdzielczości wyposażone są w technologie wpływające na jakość wyświetlanego obrazu, np.
technologia HDR polepszająca realizm oglądanego obrazu.
W telewizji JAMBOX sygnał TV jest dostarczany z najlepszych źródeł i nie jest kompresowany.
Tak, że do Twojego dekodera dociera w najwyższej możliwej i bezstratnej jakości.
- title: "Jakość obrazu Full HD"
image: "https://www.jambox.pl/sites/default/files/jambox-kyanit-miniepg.jpg"
content: |
JAMBOX HD to bezkompromisowa jakość obrazu i dźwięku
Telewizja wysokiej rozdzielczości (HD) to gwarancja niespotykanych wrażeń.
Bogatszy w szczegóły obraz w porównaniu do tradycyjnej telewizji.
To krystaliczny dźwięk przestrzenny, a dzięki panoramicznemu obrazowi zobaczysz więcej.
W telewizji JAMBOX HD sygnał TV jest dostarczany z najlepszych źródeł i nie jest kompresowany.
Tak, że do Twojego dekodera dociera w najwyższej możliwej i bezstratnej jakości.
- title: "Nagrywarka multiPVR"
image: "https://www.jambox.pl/sites/default/files/jambox-kyanit-design-wnagrane2.jpg"
content: |
Nagrywaj kilka program&oacute;w r&oacute;wnocześnie i oglądaj wtedy kiedy chcesz, a nie wtedy kiedy nadają
Mamy kontrolę nad czasem, w którym oglądamy programy.
Możliwość nagrywania konkretnych audycji wprost z przewodnika EPG, dzięki temu sami tworzymy własny program telewizyjny.
Przeglądając przewodnik po programach możesz sobie zamówić nagranie wybranych programów telewizyjnych na twój dysk aby zobaczyć te programy w dogodnym dla Ciebie czasie.
Nagrywanie wybranych programów telewizyjnych jest niezależne od aktualnie oglądanego programu.
Możesz oglądać program na jednym kanale i w tym samym czasie nagrać 2 różne programy nadawane na innych kanałach.
- title: "JAMBOX go!"
image: "https://www.jambox.pl/sites/default/files/jpanel-desktop-epg.png"
content: |
Oglądanie TV i zarządzanie twoimi usługami ze smartfona i laptopa
Jakie korzyści daje JAMBOX go!
Oglądanie TV ze smartfona i laptopa
Zlecanie nagrań poza domem
Układanie listy 100
Tworzenie własnej listy filmów VOD
Wyszukiwarka audycji
Pełny dostęp do informacji o audycjach z przewodnika
Zamawianie pakietów dodatkowych
Zarządzanie twoją telewizją JAMBOX oraz usługami Mobile
Ochrona rodzicielska
Wygodna zmiana haseł i pinów
Logowanie e-mailem
...i inne udogodnienia
Wejdź do JAMBOX go!
Poradniki:
Jak zalogować się do JAMBOX go!?
Jak ustawić e-mail oraz hasła i piny w JAMBOX go!?
- title: "Menu główne i podręczne"
image: "https://www.jambox.pl/sites/default/files/jambox-kyanit-epg-novideo-menu_0.jpg"
content: |
Szybkie przejście do najważniejszych funkcji
Naciśnij przycisk MENU i przejdź szybko do twoich nagrań, zobacz program telewizyjny, wyszukaj programy i korzystaj z innych możliwości telewizji JAMBOX.
Drugie menu - podręczne dostępne z prawej strony ekranu i pozwala na działania bezpośrednio na audycji, nagraniu i innych.
Wywoływane przyciskiem OK lub Info daje dostęp do funkcji kontekstowych.
- title: "Nagrywarka PVR USB"
image: "https://www.jambox.pl/sites/default/files/jambox-kyanit-design-wnagrane2_0.jpg"
content: |
Nagrywaj programy i filmy i oglądaj wtedy kiedy chcesz, a nie wtedy kiedy nadają
Funkcja jest dostępna po podłączeniu własnego dysku USB.
Dzięki temu możesz nagrywać konkretne pozycje z przewodnika EPG lub podczas oglądania danego kanału.
Nagrywanie wybranych programów telewizyjnych jest niezależne od aktualnie oglądanego programu.
Możesz oglądać program na jednym kanale i w tym samym czasie nagrać program nadawany na innym.
- title: "TV Portal"
image: "https://www.jambox.pl/sites/default/files/jambox-kyanit-portal_0.jpg"
content: |
Twoja strona domowa w telewizji
Twoja strona domowa w telewizorze.
Umożliwia w łatwy i wygodny sposób dostęp do szeregu usług JAMBOX.
W TV Portalu są wyświetlane wiadomości abonenckie i bannery z promocjami.
Z menu po prawej stronie można korzystać ze skrótów do zamawiania usług i płatności.
- title: "Nagrywanie czasowe/cykliczne"
image: "https://www.jambox.pl/sites/default/files/jambox-kyanit-record-time.jpg"
content: |
Nagraj każdy odcinek Twojego ulubionego serialu
Jeśli lubisz oglądać każdego dnia wiadomości, albo chcesz nagrać Twojemu dziecku wszystkie wieczorynki to możesz teraz w prosty sposób zaplanować nagrania.
Nagrywanie według czasu to funkcja, która umożliwia Ci zaprogramowanie nagrań powtarzających się.
Zaplanuj nagrania codzienne, weekendowe czy w wybranych przez Ciebie dniach.
Dodatkowo możesz nagrać dowolny przedział czasu na kanale.
Nagrywanie czasowe jest dostępne na dekoderze wyposażonym w dysk dedykowany lub USB.
- title: "Ochrona rodzicielska"
image: "https://www.jambox.pl/sites/default/files/jambox-kyanit-parental.jpg"
content: |
Blokowanie wybranych kanał&oacute;w
Funkcja umożliwia blokowanie wybranych kanałów przez wprowadzenie kodu tvPIN.
- title: "Radio HD"
image: "https://www.jambox.pl/sites/default/files/jambox-kyanit-radiohd.jpg"
content: |
Ustaw telewizor jako kominek czy akwarium i słuchaj radia
Niesamowity radioodtwarzacz, który pozwala na wybór jednej z wielu stacji radiowych oraz podkładu wideo w jakości HD takiego jak Kominek czy Akwarium.
Lista stacji dostępnych stacji radiowych:
Radio Zet
RMF Maxxx
RMF Classic
RMF FM
Radio Maryja
PR 24 (dawniej Czwórka)
PR Trójka
PR Dwójka
PR Jedynka
Radio Nowy Świat
(na wybranych dekoderach)
Na innych dekoderach stację można słuchać na kanale 994
- title: "Wideo na życzenie VOD"
image: "https://www.jambox.pl/sites/default/files/jambox-kyanit-vod-sel-movie_0.jpg"
content: |
VOD to wideo na życzenie, czyli oglądasz co chcesz i kiedy chcesz
Dzięki tej usłudze abonent może za pomocą pilota wybrać z listy interesujący go materiał i oglądać go o dowolnej porze, przez 24 godziny na dobę, 7 dni w tygodniu.
VOD pozwala decydować użytkownikowi co chce w danej chwili oglądać.
Zobacz wszystkie kioski VOD
- title: "Wyszukiwarka"
image: "https://www.jambox.pl/sites/default/files/jambox-kyanit-design-wyszukiwanie.jpg"
content: |
Znajdź programy z wybranej kategorii tematycznej
Chciałbyś wiedzieć jakie filmy zaraz się rozpoczną albo będą emitowane w najbliższym czasie? Warto korzystać z wyszukiwarki programów według wybranej kategorii.
Do wyboru masz kategorie takie jak: filmy, dzieci, hobby, informacja, muzyka, sport, wiedza, serial.
W prosty i szybki sposób znajdziesz wszystkie dostępne programy i jeśli Twój dekoder jest wyposażony w możliwość nagrywania od razu możesz zaplanować nagrania.
- title: "Zamawianie z pilota"
image: "https://www.jambox.pl/sites/default/files/jambox-kyanit-tvpanel-zamow1.jpg"
content: |
Możliwość zamawiania dodatkowych pakiet&oacute;w za pomocą pilota &ndash; bez dzwonienia do konsultant&oacute;w czy klikania na stronie www.
Unikalny sposób zamawiania dodatkowych pakietów i usług telewizyjnych, za pomocą pilota &ndash; bez dzwonienia do konsultantów czy klikania na stronie www.
Jak zamawiać?
Z poziomu TV Panelu możesz zamówić dodatkowe programy i pakiety.
W tym celu przygotuj sobie jPIN zapisany w Umowie oraz zapoznaj się z regulaminem określonych usług na stronie www.jambox.pl.
Wejdź do MENU &bdquo;Przejdź do...&rdquo; &raquo; TV Panel
Wejdź do zakładki ZAMÓW USŁUGI, za pomocą strzałek wybierz żądany pakiet i zatwierdź OK
Zaznacz w ten sposób wszystkie usługi, które chcesz zamówić, a następnie przejdź dalej za pomocą zielonego klawisza
Na ekranie pojawi się lista zamówionych pakietów oraz akceptacja regulaminu
Kółko obok akceptacji powinno być zaznaczone na zielono.
Jeśli nie jest, zaznacz pole akceptacji i zatwierdź OK
W celu potwierdzenia zamówienia wpisz jPIN i potwierdź OK
- title: "Zatrzymywanie telewizji na żywo (Time Shifting)"
image: "https://www.jambox.pl/sites/default/files/jambox-kyanit-tsplayer.jpg"
content: |
Zatrzymanie i przewijanie oglądanego programu na żywo w dowolnym momencie.
Zatrzymanie i przewijanie oglądanego programu w dowolnym momencie.
Funkcjonalność TimeShifting dostępna jest w dwóch wariantach:
Z użyciem dysku dedykowanego Arris z usługą MultiPVR możliwe jest przewijanie audycji do momentu przełączenia na dany kanał
Z użyciem dysku zewnętrznego z usługą USB PVR możliwe jest przewijanie audycji do momentu naciśnięciu PAUZY na pilocie
TimeShifting jest dostępny na wybranych dekoderach z dyskiem.

View File

@@ -0,0 +1,120 @@
tytul: Dodatkowe pakiety TV
opis: "Rozszerz ofertę telewizyjną o dodatkowe pakiety."
cena_opis: "zł/mies."
dodatki:
- id: canal_seriale_filmy
nazwa: "Canal+ Seriale i Filmy"
typ: checkbox
opis: "Pakiet filmowo-serialowy Canal+."
cena:
- pakiety: [Smart, Optimum, Platinum, Podstawowy, Korzystny, Bogaty]
12m: 24.99
bezterminowo: 28.99
- id: canal_super_sport
nazwa: "Canal+ Super Sport"
typ: checkbox
opis: "Pakiet sportowy Canal+."
cena:
- pakiety: [Smart, Optimum, Platinum, Podstawowy, Korzystny, Bogaty]
12m: 64.99
bezterminowo: 68.99
- id: cinemax
nazwa: "Cinemax"
typ: checkbox
opis: "Kanały Cinemax."
cena:
# SGT (PLUS): 10 / 15
- pakiety: [Podstawowy, Korzystny, Bogaty]
12m: 10.00
bezterminowo: 15.00
# EVIO: jedna cena 14.90
- pakiety: [Smart, Optimum, Platinum]
12m: 14.90
bezterminowo: 14.90
- id: eleven
nazwa: "Eleven"
typ: checkbox
opis: "Kanały Eleven Sports."
cena:
- pakiety: [Podstawowy, Korzystny, Bogaty]
12m: 15.00
bezterminowo: 25.00
- id: filmbox
nazwa: "Filmbox"
typ: checkbox
opis: "Kanały FilmBox."
cena:
- pakiety: [Podstawowy, Korzystny, Bogaty]
12m: 10.00
bezterminowo: 15.00
- id: hbo_max_podstawowy
nazwa: "HBO + Max Podstawowy"
typ: checkbox
opis: |
W ramach Pakietu Podstawowego HBO Max możesz oglądać filmy i seriale w jakości FullHD na dwóch urządzeniach jednocześnie.
Pakiet Podstawowy HBO Max to również dostęp do bogatej Biblioteki TVN oraz możliwość śledzenia kanału live TVN.
Treści dostępne w Pakiecie Podstawowym wyświetlane są wraz z reklamami.
cena:
- pakiety: [Smart, Optimum, Platinum, Podstawowy, Korzystny, Bogaty]
12m: 27.99
bezterminowo: 29.99
- id: hbo_max_standardowy
nazwa: "HBO + Max Standardowy"
typ: checkbox
opis: "HBO + Max (wariant standardowy)."
cena:
- pakiety: [Smart, Optimum, Platinum, Podstawowy, Korzystny, Bogaty]
12m: 36.99
bezterminowo: 39.99
- id: hbo_max_premium
nazwa: "HBO + Max Premium"
typ: checkbox
opis: "HBO + Max (wariant premium)."
cena:
- pakiety: [Smart, Optimum, Platinum, Podstawowy, Korzystny, Bogaty]
12m: 44.99
bezterminowo: 49.99
- id: wiecej_sportu_plus
nazwa: "Więcej Sportu Plus"
typ: checkbox
opis: "Dodatkowy pakiet sportowy."
cena:
- pakiety: [Podstawowy, Korzystny, Bogaty]
12m: 15.00
bezterminowo: 25.00
- id: wiecej_erotyki
nazwa: "Więcej Erotyki"
typ: checkbox
opis: "Pakiet kanałów erotycznych."
cena:
- pakiety: [Podstawowy, Korzystny, Bogaty]
12m: 15.00
bezterminowo: 25.00
- id: disney_standard
nazwa: "Disney+ Standard"
typ: checkbox
opis: |
Odkryj hity filmowe, nowe seriale i produkcje oryginalne ze świata Disneya, Pixara, Gwiezdnych wojen, Marvela, a także produkcje Hulu, National Geographic i FX
cena:
- pakiety: [Smart, Optimum, Platinum, Podstawowy, Korzystny, Bogaty]
bezterminowo: 34.99
- id: disney_premium
nazwa: "Disney+ Premium"
typ: checkbox
opis: |
Odkryj hity filmowe, nowe seriale i produkcje oryginalne ze świata Disneya, Pixara, Gwiezdnych wojen, Marvela, a także produkcje Hulu, National Geographic i FX
cena:
- pakiety: [Smart, Optimum, Platinum, Podstawowy, Korzystny, Bogaty]
bezterminowo: 59.99

View File

@@ -0,0 +1,82 @@
tytul:
opis:
cards:
- nazwa: "TELE 30"
widoczny: true
popularny: false
cena:
wartosc: 9.9
opis: "zł/mies."
parametry:
- klucz: darmowe_minuty
label: "Darmowe minuty"
value: 30
- klucz: stacjonarne
label: "Połączenia do krajowych sieci stacjonarnych"
value: "0,07 zł / min."
- klucz: komorkowe
label: "Połączenia do krajowych sieci komórkowych"
value: "0,19 zł / min"
- klucz: aktywacja
label: "Aktywacja"
value: "1,23 zł"
- nazwa: "TELE 100"
widoczny: true
popularny: false
cena:
wartosc: 15
opis: "zł/mies."
parametry:
- klucz: darmowe_minuty
label: "Darmowe minuty"
value: 100
- klucz: stacjonarne
label: "Połączenia do krajowych sieci stacjonarnych"
value: "0,07 zł / min."
- klucz: komorkowe
label: "Połączenia do krajowych sieci komórkowych"
value: "0,19 zł / min"
- klucz: aktywacja
label: "Aktywacja"
value: "1,23 zł"
- nazwa: "TELE 300"
widoczny: true
popularny: false
cena:
wartosc: 29
opis: "zł/mies."
parametry:
- klucz: darmowe_minuty
label: "Darmowe minuty"
value: 300
- klucz: stacjonarne
label: "Połączenia do krajowych sieci stacjonarnych"
value: "0,07 zł / min."
- klucz: komorkowe
label: "Połączenia do krajowych sieci komórkowych"
value: "0,19 zł / min"
- klucz: aktywacja
label: "Aktywacja"
value: "1,23 zł"
- nazwa: "TELE 500"
widoczny: true
popularny: false
cena:
wartosc: 44
opis: "zł/mies."
parametry:
- klucz: darmowe_minuty
label: "Darmowe minuty"
value: 500
- klucz: stacjonarne
label: "Połączenia do krajowych sieci stacjonarnych"
value: "0,07 zł / min."
- klucz: komorkowe
label: "Połączenia do krajowych sieci komórkowych"
value: "0,19 zł / min"
- klucz: aktywacja
label: "Aktywacja"
value: "1,23 zł"

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,28 +1,109 @@
import { useEffect, useState } from "preact/hooks";
import { useEffect, useMemo, useState } from "preact/hooks";
import "../../styles/modal.css";
import "../../styles/offers/offers-table.css";
export default function InternetAddonsModal({ isOpen, onClose, plan }) {
const [phonePlans, setPhonePlans] = useState([]);
const [addons, setAddons] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [selectedPhoneId, setSelectedPhoneId] = useState(null);
const [selectedAddons, setSelectedAddons] = useState([]);
// który pakiet telefoniczny jest rozwinięty
const [openPhoneId, setOpenPhoneId] = useState(null);
// czy akordeon internetu (fiber) jest rozwinięty
const [baseOpen, setBaseOpen] = useState(true);
const formatFeatureValue = (val) => {
function formatFeatureValue(val) {
if (val === true || val === "true") return "✓";
if (val === false || val === "false" || val == null) return "✕";
return val;
};
}
function money(amount) {
const n = Number(amount || 0);
return n.toFixed(2).replace(".", ",");
}
/**
* Mapuje YAML telefonu (cards.yaml) na format używany w modalu:
* { id, name, price_monthly, features: [{label, value}] }
*/
function mapPhoneYamlToPlans(phoneCards) {
const list = Array.isArray(phoneCards) ? phoneCards : [];
return list
.filter((c) => c?.widoczny !== false)
.map((c, idx) => ({
id: String(c?.id ?? c?.nazwa ?? idx),
name: c?.nazwa ?? "—",
price_monthly: Number(c?.cena?.wartosc ?? 0),
features: (Array.isArray(c?.parametry) ? c.parametry : []).map((p) => ({
label: p.label,
value: p.value,
})),
}));
}
/**
* Dodatki z YAML:
* { id, nazwa, typ, ilosc, min, max, krok, opis, cena }
*/
function normalizeAddons(addons) {
const list = Array.isArray(addons) ? addons : [];
return list
.filter((a) => a?.id && a?.nazwa)
.map((a) => ({
id: String(a.id),
nazwa: String(a.nazwa),
typ: String(a.typ || "checkbox"),
ilosc: !!a.ilosc,
min: a.min != null ? Number(a.min) : 0,
max: a.max != null ? Number(a.max) : 10,
krok: a.krok != null ? Number(a.krok) : 1,
opis: a.opis ? String(a.opis) : "",
cena: Number(a.cena ?? 0),
}));
}
export default function InternetAddonsModal({
isOpen,
onClose,
plan,
// ✅ nowe: z YAML
phoneCards = [], // telefon/cards.yaml -> cards[]
addons = [], // internet-swiatlowodowy/addons.yaml -> dodatki[]
cenaOpis = "zł/mies.",
}) {
const phonePlans = useMemo(() => mapPhoneYamlToPlans(phoneCards), [phoneCards]);
const addonsList = useMemo(() => normalizeAddons(addons), [addons]);
const [error, setError] = useState("");
const [selectedPhoneId, setSelectedPhoneId] = useState(null);
// zamiast selectedAddons (DB) -> mapka ilości
// { public_ip: 1, ip_v4_extra: 3 }
const [selectedQty, setSelectedQty] = useState({});
// akordeony
const [openPhoneId, setOpenPhoneId] = useState(null);
const [baseOpen, setBaseOpen] = useState(true);
// reset wyborów po otwarciu / zmianie planu
useEffect(() => {
if (!isOpen) return;
setError("");
setSelectedPhoneId(null);
setSelectedQty({});
setOpenPhoneId(null);
setBaseOpen(true);
}, [isOpen, plan]);
if (!isOpen || !plan) return null;
const basePrice = Number(plan.price_monthly || 0);
const phonePrice = (() => {
if (!selectedPhoneId) return 0;
const p = phonePlans.find((p) => String(p.id) === String(selectedPhoneId));
return Number(p?.price_monthly || 0);
})();
const addonsPrice = addonsList.reduce((sum, a) => {
const qty = Number(selectedQty[a.id] || 0);
return sum + qty * Number(a.cena || 0);
}, 0);
const totalMonthly = basePrice + phonePrice + addonsPrice;
const handlePhoneSelect = (id) => {
if (id === null) {
@@ -32,98 +113,22 @@ export default function InternetAddonsModal({ isOpen, onClose, plan }) {
}
setSelectedPhoneId(id);
setOpenPhoneId((prev) => (prev === id ? null : id));
setOpenPhoneId((prev) => (String(prev) === String(id) ? null : id));
};
// reset wyborów po otwarciu nowego planu
useEffect(() => {
if (!isOpen) return;
setSelectedPhoneId(null);
setSelectedAddons([]);
setOpenPhoneId(null);
setBaseOpen(true);
}, [isOpen, plan]);
// ładowanie danych
useEffect(() => {
if (!isOpen) 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
const addonsRes = await fetch("/api/internet/addons");
if (!addonsRes.ok) throw new Error(`HTTP ${addonsRes.status} (addons)`);
const addonsJson = await addonsRes.json();
const addonsData = Array.isArray(addonsJson.data) ? addonsJson.data : [];
if (!cancelled) {
setPhonePlans(phoneData);
setAddons(addonsData);
}
} catch (err) {
console.error("❌ Błąd ładowania danych do InternetAddonsModal:", err);
if (!cancelled) {
setError("Nie udało się załadować danych dodatkowych usług.");
}
} finally {
if (!cancelled) setLoading(false);
}
}
loadData();
return () => {
cancelled = true;
};
}, [isOpen]);
if (!isOpen || !plan) return null;
const basePrice = plan.price_monthly || 0;
const phonePrice = (() => {
if (!selectedPhoneId) return 0;
const p = phonePlans.find((p) => p.id === selectedPhoneId);
return p?.price_monthly || 0;
})();
const addonsPrice = selectedAddons.reduce((sum, sel) => {
const addon = addons.find((a) => a.id === sel.addonId);
if (!addon) return sum;
const opt = addon.options.find((o) => o.id === sel.optionId);
if (!opt) return sum;
return sum + (opt.price || 0);
}, 0);
const totalMonthly = basePrice + phonePrice + addonsPrice;
const handleAddonToggle = (addonId, optionId) => {
setSelectedAddons((prev) => {
const exists = prev.some(
(x) => x.addonId === addonId && x.optionId === optionId
);
if (exists) {
return prev.filter(
(x) => !(x.addonId === addonId && x.optionId === optionId)
);
} else {
return [...prev, { addonId, optionId }];
}
const toggleCheckboxAddon = (id) => {
setSelectedQty((prev) => {
const next = { ...prev };
next[id] = (next[id] || 0) > 0 ? 0 : 1;
return next;
});
};
const setQtyAddon = (id, qty, min, max) => {
const safe = Math.max(min, Math.min(max, qty));
setSelectedQty((prev) => ({ ...prev, [id]: safe }));
};
return (
<div class="f-modal-overlay" onClick={onClose}>
<button
@@ -155,7 +160,7 @@ export default function InternetAddonsModal({ isOpen, onClose, plan }) {
>
<span class="f-modal-phone-name">{plan.name}</span>
<span class="f-modal-phone-price">
{basePrice.toFixed(2)} /mies.
{money(basePrice)} {cenaOpis}
</span>
</button>
@@ -176,11 +181,8 @@ export default function InternetAddonsModal({ isOpen, onClose, plan }) {
</div>
</div>
{loading && <p>Ładowanie danych...</p>}
{error && <p class="text-red-600">{error}</p>}
{!loading && !error && (
<>
{/* Telefon */}
<div class="f-modal-section">
<h3>Usługa telefoniczna</h3>
@@ -207,18 +209,15 @@ export default function InternetAddonsModal({ isOpen, onClose, plan }) {
}}
onClick={(e) => e.stopPropagation()}
/>
<span class="f-modal-phone-name">
Nie potrzebuję telefonu
<span class="f-modal-phone-name">Nie potrzebuję telefonu</span>
</span>
</span>
<span class="f-modal-phone-price">0,00 /mies.</span>
<span class="f-modal-phone-price">0,00 {cenaOpis}</span>
</button>
</div>
{/* lista pakietów telefonu */}
{phonePlans.map((p) => {
const isSelected = selectedPhoneId === p.id;
const isOpen = openPhoneId === p.id;
const isSelected = String(selectedPhoneId) === String(p.id);
const isOpen = String(openPhoneId) === String(p.id);
return (
<div
@@ -245,7 +244,7 @@ export default function InternetAddonsModal({ isOpen, onClose, plan }) {
</span>
<span class="f-modal-phone-price">
{p.price_monthly.toFixed(2)} /mies.
{money(p.price_monthly)} {cenaOpis}
</span>
</button>
@@ -258,12 +257,14 @@ export default function InternetAddonsModal({ isOpen, onClose, plan }) {
(f) =>
!String(f.label || "")
.toLowerCase()
.includes("aktyw")
.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>
<span class="f-card-value">
{formatFeatureValue(f.value)}
</span>
</li>
))}
</ul>
@@ -281,40 +282,93 @@ export default function InternetAddonsModal({ isOpen, onClose, plan }) {
<div class="f-modal-section">
<h3>Dodatkowe usługi</h3>
{addons.length === 0 ? (
{addonsList.length === 0 ? (
<p>Brak dodatkowych usług.</p>
) : (
<div class="f-addon-list">
{addons.map((addon) =>
addon.options.map((opt) => {
const checked = selectedAddons.some(
(x) => x.addonId === addon.id && x.optionId === opt.id
);
{addonsList.map((a) => {
const qty = Number(selectedQty[a.id] || 0);
const isQty = a.typ === "quantity" || a.ilosc === true;
if (!isQty) {
const checked = qty > 0;
return (
<label class="f-addon-item" key={`${addon.id}-${opt.id}`}>
<label class="f-addon-item" key={a.id}>
<div class="f-addon-checkbox">
<input
type="checkbox"
checked={checked}
onChange={() => handleAddonToggle(addon.id, opt.id)}
onChange={() => toggleCheckboxAddon(a.id)}
/>
</div>
<div class="f-addon-main">
<div class="f-addon-name">{addon.name}</div>
{addon.description && (
<div class="f-addon-desc">{addon.description}</div>
)}
<div class="f-addon-name">{a.nazwa}</div>
{a.opis && <div class="f-addon-desc">{a.opis}</div>}
</div>
<div class="f-addon-price">
{opt.price.toFixed(2)} {opt.currency}
{money(a.cena)} {cenaOpis}
</div>
</label>
);
})
)}
}
// quantity
const min = Number.isFinite(a.min) ? a.min : 0;
const max = Number.isFinite(a.max) ? a.max : 10;
const step = Number.isFinite(a.krok) ? a.krok : 1;
const lineTotal = qty * Number(a.cena || 0);
return (
<div class="f-addon-item f-addon-item--qty" key={a.id}>
{/* slot na checkbox (dla wyrównania kolumn) */}
<div class="f-addon-checkbox" aria-hidden="true"></div>
<div class="f-addon-main">
<div class="f-addon-name">{a.nazwa}</div>
{a.opis && <div class="f-addon-desc">{a.opis}</div>}
</div>
{/* licznik ilości bliżej prawej */}
<div class="f-addon-qty" onClick={(e) => e.stopPropagation()}>
<button
type="button"
class="btn btn-outline"
onClick={() => setQtyAddon(a.id, qty - step, min, max)}
disabled={qty <= min}
>
</button>
<span class="f-addon-qty-value">{qty}</span>
<button
type="button"
class="btn btn-outline"
onClick={() => setQtyAddon(a.id, qty + step, min, max)}
disabled={qty >= max}
>
+
</button>
</div>
{/* cena po prawej + suma pod spodem */}
<div class="f-addon-price">
<div>
{money(a.cena)} {cenaOpis}
</div>
<div class="f-addon-price-total">
{qty > 0 ? `${money(lineTotal)} ${cenaOpis}` : "—"}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
@@ -326,26 +380,22 @@ export default function InternetAddonsModal({ isOpen, onClose, plan }) {
<div class="f-summary-list">
<div class="f-summary-row">
<span>Internet</span>
<span>{basePrice.toFixed(2)} /mies.</span>
<span>{money(basePrice)} {cenaOpis}</span>
</div>
<div class="f-summary-row">
<span>Telefon</span>
<span>
{phonePrice ? `${phonePrice.toFixed(2)} zł/mies.` : "—"}
</span>
<span>{phonePrice ? `${money(phonePrice)} ${cenaOpis}` : "—"}</span>
</div>
<div class="f-summary-row">
<span>Dodatki</span>
<span>
{addonsPrice ? `${addonsPrice.toFixed(2)} zł/mies.` : "—"}
</span>
<span>{addonsPrice ? `${money(addonsPrice)} ${cenaOpis}` : "—"}</span>
</div>
<div class="f-summary-total">
<span>Łącznie</span>
<span>{totalMonthly.toFixed(2)} /mies.</span>
<span>{money(totalMonthly)} {cenaOpis}</span>
</div>
</div>
</div>
@@ -353,11 +403,11 @@ export default function InternetAddonsModal({ isOpen, onClose, plan }) {
<div class="f-floating-total" onClick={(e) => e.stopPropagation()}>
<div class="f-floating-total-inner">
<span class="f-floating-total-label">Suma</span>
<span class="f-floating-total-value">{totalMonthly.toFixed(2)} /mies.</span>
<span class="f-floating-total-value">
{money(totalMonthly)} {cenaOpis}
</span>
</div>
</div>
</>
)}
</div>
</div>
</div>

View File

@@ -1,16 +1,75 @@
//InternetCards.jsx
import { useEffect, useState } from "preact/hooks";
import Markdown from "../Markdown.jsx";
import OffersSwitches from "../OffersSwitches.jsx";
import InternetAddonsModal from "./InternetAddonsModal.jsx";
import "../../styles/offers/offers-table.css";
export default function InternetDbOffersCards({
function formatMoney(amount, currency = "PLN") {
if (typeof amount !== "number" || Number.isNaN(amount)) return "";
try {
return new Intl.NumberFormat("pl-PL", {
style: "currency",
currency,
maximumFractionDigits: 0,
}).format(amount);
} catch {
return String(amount);
}
}
// ✅ mapper: InternetCard(YAML) + match + labels -> plan (dla modala)
function mapCardToPlan(card, match, labels, waluta) {
const baseParams = Array.isArray(card?.parametry) ? card.parametry : [];
const features = baseParams.map((p) => ({
label: p.label,
value: p.value,
}));
// na końcu jak parametry:
features.push({ label: "Umowa", value: labels?.umowa || "—" });
features.push({
label: "Aktywacja",
value: typeof match?.aktywacja === "number" ? formatMoney(match.aktywacja, waluta) : "—",
});
return {
name: card?.nazwa || "—",
price_monthly: typeof match?.miesiecznie === "number" ? match.miesiecznie : 0,
price_installation: typeof match?.aktywacja === "number" ? match.aktywacja : 0,
features,
};
}
/**
* @param {{
* title?: string,
* description?: string,
* cards?: any[],
* waluta?: string,
* cenaOpis?: string,
* phoneCards?: any[],
* addons?: any[],
* addonsCenaOpis?: string
* }} props
*/
export default function InternetCards({
title = "",
description = "",
cards = [],
waluta = "PLN",
cenaOpis = "zł/mies.",
phoneCards = [],
addons = [],
addonsCenaOpis = "zł/mies.",
}) {
const visibleCards = Array.isArray(cards) ? cards : [];
// switch state (z /api/switches)
const [selected, setSelected] = useState({});
const [labels, setLabels] = useState({});
const [plans, setPlans] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
// modal
const [addonsModalOpen, setAddonsModalOpen] = useState(false);
const [activePlan, setActivePlan] = useState(null);
@@ -22,75 +81,40 @@ export default function InternetDbOffersCards({
}
function handler(e) {
const detail = e.detail || {};
if (detail.selected) {
setSelected(detail.selected);
}
if (detail.labels) {
setLabels(detail.labels);
}
const detail = e?.detail || {};
if (detail.selected) setSelected(detail.selected);
if (detail.labels) setLabels(detail.labels);
}
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({
building: String(buildingCode),
contract: String(contractCode),
});
const res = await fetch(`/api/internet/plans?${params.toString()}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
if (!cancelled) {
setPlans(Array.isArray(json.data) ? json.data : []);
}
} catch (err) {
console.error("Błąd pobierania planów internetu:", err);
if (!cancelled) setError("Nie udało się załadować ofert.");
} finally {
if (!cancelled) setLoading(false);
}
}
load();
return () => {
cancelled = true;
};
}, [buildingCode, contractCode]);
const contractLabel = labels.umowa || "";
return (
<section class="f-offers">
{title && <h1 class="f-section-header">{title}</h1>}
{loading && <p>Ładowanie ofert...</p>}
{error && <p class="text-red-600">{error}</p>}
{description && (
<div class="mb-4">
<Markdown text={description} />
</div>
)}
{!loading && !error && (
<div class={`f-offers-grid f-count-${plans.length || 1}`}>
{plans.map((plan) => (
<OffersSwitches />
{visibleCards.length === 0 ? (
<p class="opacity-80">Brak dostępnych pakietów.</p>
) : (
<div class={`f-offers-grid f-count-${visibleCards.length || 1}`}>
{visibleCards.map((card) => (
<OfferCard
key={plan.id}
plan={plan}
contractLabel={contractLabel}
onConfigureAddons={() => {
key={card.nazwa}
card={card}
selected={selected}
labels={labels}
waluta={waluta}
cenaOpis={cenaOpis}
onConfigureAddons={(plan) => {
setActivePlan(plan);
setAddonsModalOpen(true);
}}
@@ -103,62 +127,77 @@ export default function InternetDbOffersCards({
isOpen={addonsModalOpen}
onClose={() => setAddonsModalOpen(false)}
plan={activePlan}
phoneCards={phoneCards}
addons={addons}
cenaOpis={addonsCenaOpis || cenaOpis}
/>
</section>
);
}
function OfferCard({ plan, contractLabel, onConfigureAddons }) {
const basePrice = plan.price_monthly;
const installPrice = plan.price_installation;
function OfferCard({ card, selected, labels, waluta, cenaOpis, onConfigureAddons }) {
const baseParams = Array.isArray(card?.parametry) ? card.parametry : [];
const ceny = Array.isArray(card?.ceny) ? card.ceny : [];
const featureRows = plan.features || [];
const budynek = selected?.budynek;
const umowa = selected?.umowa;
const effectiveContract = contractLabel || "—";
const match = ceny.find(
(c) => String(c?.budynek) === String(budynek) && String(c?.umowa) === String(umowa),
);
const mies = match?.miesiecznie;
const akt = match?.aktywacja;
// na końcu jako parametry
const params = [
...baseParams,
{ klucz: "umowa", label: "Umowa", value: labels?.umowa || "—" },
{
klucz: "aktywacja",
label: "Aktywacja",
value: typeof akt === "number" ? formatMoney(akt, waluta) : "—",
},
];
const canConfigureAddons = !!match;
return (
<div class={`f-card ${plan.popular ? "f-card-popular" : ""}`}>
<div class={`f-card ${card.popularny ? "f-card-popular" : ""}`}>
{card.popularny && <div class="f-card-badge">Najczęściej wybierany</div>}
<div class="f-card-header">
<div class="f-card-name">{plan.name}</div>
<div class="f-card-name">{card.nazwa}</div>
<div class="f-card-price">
{basePrice != null ? `${basePrice} zł/mies.` : "—"}
{typeof mies === "number" ? (
<>
{formatMoney(mies, waluta)} <span class="opacity-80">{cenaOpis}</span>
</>
) : (
<span class="opacity-70">Wybierz opcje</span>
)}
</div>
</div>
<ul class="f-card-features">
{featureRows.map((f) => {
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">
<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">Umowa</span>
<span class="f-card-value">{effectiveContract}</span>
</li>
<li class="f-card-row">
<span class="f-card-label">Aktywacja</span>
<span class="f-card-value">
{installPrice != null ? `${installPrice}` : "—"}
</span>
{params.map((p) => (
<li class="f-card-row" key={p.klucz || p.label}>
<span class="f-card-label">{p.label}</span>
<span class="f-card-value">{p.value}</span>
</li>
))}
</ul>
<button
type="button"
class="btn btn-primary mt-4"
onClick={onConfigureAddons}
disabled={!canConfigureAddons}
onClick={() => {
const plan = mapCardToPlan(card, match, labels, waluta);
onConfigureAddons(plan);
}}
title={!canConfigureAddons ? "Wybierz typ budynku i umowę" : ""}
>
Skonfiguruj usługi dodatkowe
</button>

View File

@@ -2,28 +2,261 @@ 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) => {
function formatFeatureValue(val) {
if (val === true || val === "true") return "✓";
if (val === false || val === "false" || val == null) return "✕";
return val;
};
}
function money(amount) {
const n = Number(amount || 0);
return n.toFixed(2).replace(".", ",");
}
/** telefon z YAML (phone/cards.yaml -> cards[]) => { id, name, price_monthly, features[] } */
function mapPhoneYamlToPlans(phoneCards) {
const list = Array.isArray(phoneCards) ? phoneCards : [];
return list
.filter((c) => c?.widoczny !== false)
.map((c, idx) => ({
id: String(c?.id ?? c?.nazwa ?? idx),
name: c?.nazwa ?? "—",
price_monthly: Number(c?.cena?.wartosc ?? 0),
features: (Array.isArray(c?.parametry) ? c.parametry : []).map((p) => ({
label: p.label,
value: p.value,
})),
}));
}
/** dekodery z YAML */
function normalizeDecoders(list) {
const arr = Array.isArray(list) ? list : [];
return arr
.filter((d) => d?.id && d?.nazwa)
.map((d) => ({
id: String(d.id),
nazwa: String(d.nazwa),
cena: Number(d.cena ?? 0),
}));
}
/** dodatki z YAML (tv-addons.yaml / addons.yaml) */
function normalizeAddons(addons) {
const list = Array.isArray(addons) ? addons : [];
return list
.filter((a) => a?.id && a?.nazwa)
.map((a) => ({
id: String(a.id),
nazwa: String(a.nazwa),
typ: String(a.typ ?? a.type ?? "checkbox"),
ilosc: !!a.ilosc,
min: a.min != null ? Number(a.min) : 0,
max: a.max != null ? Number(a.max) : 10,
krok: a.krok != null ? Number(a.krok) : 1,
opis: a.opis ? String(a.opis) : "",
// addons.yaml -> number albo {default, by_name}
// tv-addons.yaml -> [{pakiety, 12m, bezterminowo}]
cena: a.cena ?? 0,
}));
}
function normKey(s) {
return String(s || "")
.trim()
.toLowerCase()
.replace(/\s+/g, " ");
}
/** TV: wybór wariantu ceny po pkg.name, albo fallback "*" */
function pickTvVariant(addon, pkgName) {
const c = addon?.cena;
if (!Array.isArray(c)) return null;
const wanted = normKey(pkgName);
// 1) po nazwie pakietu
for (const row of c) {
const pk = row?.pakiety;
if (!Array.isArray(pk)) continue;
if (pk.some((p) => normKey(p) === wanted)) return row;
}
// 2) fallback "*"
for (const row of c) {
const pk = row?.pakiety;
if (!Array.isArray(pk)) continue;
if (pk.some((p) => String(p).trim() === "*")) return row;
}
return null;
}
/** TV: czy addon w ogóle dostępny dla pakietu */
function isTvAddonAvailableForPkg(addon, pkg) {
if (!pkg) return false;
const v = pickTvVariant(addon, String(pkg?.name ?? ""));
return !!v;
}
/** TV: czy ma dwie ceny (12m/bezterminowo) */
function hasTvTermPricing(addon, pkg) {
const c = addon?.cena;
if (!Array.isArray(c)) return false;
// sprawdzamy wariant dla konkretnego pakietu (bo może się różnić)
const v = pickTvVariant(addon, String(pkg?.name ?? ""));
if (!v || typeof v !== "object") return false;
// ✅ radio tylko jeśli są OBIE ceny
return v["12m"] != null && v.bezterminowo != null;
}
/**
* ✅ cena jednostkowa:
* - addons.yaml: number / string / legacy {default, by_name}
* - tv-addons.yaml: tablica wariantów
*/
function getAddonUnitPrice(addon, pkg, term /* "12m"|"bezterminowo"|null */) {
const c = addon?.cena;
// addons.yaml: liczba / liczba jako string
if (typeof c === "number") return c;
if (typeof c === "string" && c.trim() !== "" && !Number.isNaN(Number(c))) return Number(c);
// tv-addons.yaml: tablica wariantów [{pakiety, 12m, bezterminowo}]
if (Array.isArray(c)) {
const v = pickTvVariant(addon, String(pkg?.name ?? ""));
if (!v) return 0;
const t = term || "12m";
if (v[t] != null) return Number(v[t]) || 0;
// fallback
if (v.bezterminowo != null) return Number(v.bezterminowo) || 0;
if (v["12m"] != null) return Number(v["12m"]) || 0;
return 0;
}
// ✅ LEGACY: addons.yaml może mieć cenę zależną od pakietu:
// cena: { default: 19.9, by_name: { "Smart": 15.0, ... } }
if (c && typeof c === "object") {
const name = String(pkg?.name ?? "");
const wanted = normKey(name);
const byName = c.by_name || c.byName || c.by_nazwa || c.byNazwa;
if (byName && typeof byName === "object" && name) {
for (const k of Object.keys(byName)) {
if (normKey(k) === wanted) return Number(byName[k]) || 0;
}
}
if (c.default != null) return Number(c.default) || 0;
}
return 0;
}
export default function JamboxAddonsModal({
isOpen,
onClose,
pkg,
// ✅ YAML
phoneCards = [],
tvAddons = [],
addons = [],
decoders = [],
cenaOpis = "zł/mies.",
}) {
const phonePlans = useMemo(() => mapPhoneYamlToPlans(phoneCards), [phoneCards]);
const tvAddonsList = useMemo(() => normalizeAddons(tvAddons), [tvAddons]);
const addonsList = useMemo(() => normalizeAddons(addons), [addons]);
const decodersList = useMemo(() => normalizeDecoders(decoders), [decoders]);
// ✅ TV: pokazujemy tylko dostępne dla pkg.name
const tvAddonsVisible = useMemo(() => {
if (!pkg) return [];
return tvAddonsList.filter((a) => isTvAddonAvailableForPkg(a, pkg));
}, [tvAddonsList, pkg]);
// wybory
const [selectedPhoneId, setSelectedPhoneId] = useState(null);
const [openPhoneId, setOpenPhoneId] = useState(null);
// dekoder (radio)
const [selectedDecoderId, setSelectedDecoderId] = useState(null);
// checkbox/quantity: { [id]: qty }
const [selectedQty, setSelectedQty] = useState({});
// ✅ TV: term per dodatek (12m / bezterminowo)
const [tvTerm, setTvTerm] = useState({}); // { [id]: "12m" | "bezterminowo" }
// akordeon pakietu bazowego
const [baseOpen, setBaseOpen] = useState(true);
// reset po otwarciu / zmianie pakietu
useEffect(() => {
if (!isOpen) return;
setSelectedPhoneId(null);
setOpenPhoneId(null);
setSelectedDecoderId(null);
setSelectedQty({});
setTvTerm({});
setBaseOpen(true);
const d0 =
(Array.isArray(decodersList) && decodersList.find((d) => Number(d.cena) === 0)) ||
(Array.isArray(decodersList) ? decodersList[0] : null);
setSelectedDecoderId(d0 ? String(d0.id) : null);
}, [isOpen, pkg?.id, decodersList]);
if (!isOpen || !pkg) return null;
const basePrice = Number(pkg.price_monthly || 0);
const phonePrice = useMemo(() => {
if (!selectedPhoneId) return 0;
const p = phonePlans.find((x) => String(x.id) === String(selectedPhoneId));
return Number(p?.price_monthly || 0);
}, [selectedPhoneId, phonePlans]);
const decoderPrice = useMemo(() => {
if (!selectedDecoderId) return 0;
const d = decodersList.find((x) => String(x.id) === String(selectedDecoderId));
return Number(d?.cena || 0);
}, [selectedDecoderId, decodersList]);
// ✅ TV: suma liczona tylko po widocznych (czyli dostępnych)
const tvAddonsPrice = useMemo(() => {
return tvAddonsVisible.reduce((sum, a) => {
const qty = Number(selectedQty[a.id] || 0);
if (qty <= 0) return sum;
const termPricing = hasTvTermPricing(a, pkg);
const term = tvTerm[a.id] || "12m";
const unit = getAddonUnitPrice(a, pkg, termPricing ? term : null);
return sum + qty * unit;
}, 0);
}, [selectedQty, tvAddonsVisible, tvTerm, pkg]);
// zwykłe dodatki (addons.yaml) stara logika (multiroom itp.)
const addonsOnlyPrice = useMemo(() => {
return addonsList.reduce((sum, a) => {
const qty = Number(selectedQty[a.id] || 0);
const unit = getAddonUnitPrice(a, pkg, null);
return sum + qty * unit;
}, 0);
}, [selectedQty, addonsList, pkg]);
const addonsPrice = tvAddonsPrice + addonsOnlyPrice;
const totalMonthly = basePrice + phonePrice + decoderPrice + addonsPrice;
const handlePhoneSelect = (id) => {
if (id === null) {
@@ -32,105 +265,121 @@ export default function JamboxAddonsModal({ isOpen, onClose, pkg }) {
return;
}
setSelectedPhoneId(id);
setOpenPhoneId((prev) => (prev === id ? null : id));
setOpenPhoneId((prev) => (String(prev) === String(id) ? null : id));
};
const toggleAddon = (addonId) => {
setSelectedAddonIds((prev) =>
prev.includes(addonId) ? prev.filter((x) => x !== addonId) : [...prev, addonId]
const toggleCheckboxAddon = (id) => {
setSelectedQty((prev) => {
const next = { ...prev };
next[id] = (next[id] || 0) > 0 ? 0 : 1;
return next;
});
};
const setQtyAddon = (id, qty, min, max) => {
const safe = Math.max(min, Math.min(max, qty));
setSelectedQty((prev) => ({ ...prev, [id]: safe }));
};
const renderAddonRow = (a, isTv = false) => {
const qty = Number(selectedQty[a.id] || 0);
const isQty = a.typ === "quantity" || a.ilosc === true;
// TV: term i cena
const termPricing = isTv && hasTvTermPricing(a, pkg);
const term = tvTerm[a.id] || "12m";
const unit = getAddonUnitPrice(a, pkg, termPricing ? term : null);
if (!isQty) {
return (
<label class="f-addon-item" key={(isTv ? "tv-" : "a-") + a.id}>
<div class="f-addon-checkbox">
<input
type="checkbox"
checked={qty > 0}
onChange={() => toggleCheckboxAddon(a.id)}
/>
</div>
<div class="f-addon-main">
<div class="f-addon-name">{a.nazwa}</div>
{a.opis && <div class="f-addon-desc">{a.opis}</div>}
{termPricing && (
<div class="mt-2 flex flex-wrap gap-3 text-sm" onClick={(e) => e.stopPropagation()}>
<label class="inline-flex items-center gap-2">
<input
type="radio"
name={`term-${a.id}`}
checked={(tvTerm[a.id] || "12m") === "12m"}
onChange={() => setTvTerm((p) => ({ ...p, [a.id]: "12m" }))}
/>
<span>12 miesięcy</span>
</label>
<label class="inline-flex items-center gap-2">
<input
type="radio"
name={`term-${a.id}`}
checked={(tvTerm[a.id] || "12m") === "bezterminowo"}
onChange={() => setTvTerm((p) => ({ ...p, [a.id]: "bezterminowo" }))}
/>
<span>Bezterminowo</span>
</label>
</div>
)}
</div>
<div class="f-addon-price">
{money(unit)} {cenaOpis}
</div>
</label>
);
};
// 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 : [];
// pakiety TV
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]);
const min = Number.isFinite(a.min) ? a.min : 0;
const max = Number.isFinite(a.max) ? a.max : 10;
const step = Number.isFinite(a.krok) ? a.krok : 1;
const lineTotal = qty * unit;
if (!isOpen || !pkg) return null;
return (
<div class="f-addon-item f-addon-item--qty" key={(isTv ? "tvq-" : "aq-") + a.id}>
<div class="f-addon-checkbox" aria-hidden="true"></div>
const basePrice = Number(pkg.price_monthly || 0);
<div class="f-addon-main">
<div class="f-addon-name">{a.nazwa}</div>
{a.opis && <div class="f-addon-desc">{a.opis}</div>}
</div>
const phonePrice = useMemo(() => {
if (!selectedPhoneId) return 0;
const p = phonePlans.find((x) => x.id === selectedPhoneId);
return Number(p?.price_monthly || 0);
}, [selectedPhoneId, phonePlans]);
<div class="f-addon-qty" onClick={(e) => e.stopPropagation()}>
<button
type="button"
class="btn btn-outline"
onClick={() => setQtyAddon(a.id, qty - step, min, max)}
disabled={qty <= min}
>
</button>
// 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]);
<span class="f-addon-qty-value">{qty}</span>
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]);
<button
type="button"
class="btn btn-outline"
onClick={() => setQtyAddon(a.id, qty + step, min, max)}
disabled={qty >= max}
>
+
</button>
</div>
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]
<div class="f-addon-price">
<div>
{money(unit)} {cenaOpis}
</div>
<div class="f-addon-price-total">{qty > 0 ? `${money(lineTotal)} ${cenaOpis}` : "—"}</div>
</div>
</div>
);
};
@@ -152,7 +401,7 @@ export default function JamboxAddonsModal({ isOpen, onClose, pkg }) {
<div class="f-modal-inner">
<h2 class="f-modal-title">Konfiguracja usług dodatkowych</h2>
{/* PAKIET JAMBOX jako akordeon */}
{/* PAKIET jako akordeon */}
<div class="f-modal-section">
<div class={`f-accordion-item ${baseOpen ? "is-open" : ""}`}>
<button
@@ -162,7 +411,7 @@ export default function JamboxAddonsModal({ isOpen, onClose, pkg }) {
>
<span class="f-modal-phone-name">{pkg.name}</span>
<span class="f-modal-phone-price">
{basePrice ? `${basePrice.toFixed(2)} zł/mies.` : "—"}
{money(basePrice)} {cenaOpis}
</span>
</button>
@@ -181,49 +430,60 @@ export default function JamboxAddonsModal({ isOpen, onClose, pkg }) {
</div>
</div>
{loading && <p>Ładowanie danych...</p>}
{error && <p class="text-red-600">{error}</p>}
{!loading && !error && (
<>
{/* TV ADDONS */}
{/* ✅ DEKODER (radio) — NAD TV ADDONS */}
<div class="f-modal-section">
<h3>Pakiety dodatkowe TV</h3>
<h3>Wybór dekodera</h3>
{tvAddons.length === 0 ? (
<p>Brak pakietów dodatkowych TV dla tego pakietu.</p>
{decodersList.length === 0 ? (
<p>Brak dostępnych dekoderów.</p>
) : (
<div class="f-addon-list">
{tvAddons.map((a) => {
const tid = Number(a.tid);
const checked = selectedTvAddonTids.includes(tid);
const priceNum = Number(a.price || 0);
<div class="f-modal-phone-list f-accordion">
{decodersList.map((d) => {
const isSelected = String(selectedDecoderId) === String(d.id);
return (
<label class="f-addon-item" key={`tv-${tid}`}>
<div class="f-addon-checkbox">
<div class={`f-accordion-item ${isSelected ? "is-open" : ""}`} key={d.id}>
<button
type="button"
class="f-accordion-header"
onClick={() => setSelectedDecoderId(String(d.id))}
>
<span class="f-accordion-header-left">
<input
type="checkbox"
checked={checked}
onChange={() => toggleTvAddon(tid)}
type="radio"
name="decoder"
checked={isSelected}
onChange={(e) => {
e.stopPropagation();
setSelectedDecoderId(String(d.id));
}}
onClick={(e) => e.stopPropagation()}
/>
</div>
<span class="f-modal-phone-name">{d.nazwa}</span>
</span>
<div class="f-addon-main">
<div class="f-addon-name">{a.name}</div>
<div class="f-addon-desc">{a.description}</div>
<span class="f-modal-phone-price">
{money(d.cena)} {cenaOpis}
</span>
</button>
</div>
<div class="f-addon-price">
{Number.isFinite(priceNum) ? `${priceNum.toFixed(2)} zł/mies.` : "—"}
</div>
</label>
);
})}
</div>
)}
</div>
{/* TV ADDONS */}
<div class="f-modal-section">
<h3>Pakiety dodatkowe TV</h3>
{tvAddonsVisible.length === 0 ? (
<p>Brak pakietów dodatkowych TV.</p>
) : (
<div class="f-addon-list">{tvAddonsVisible.map((a) => renderAddonRow(a, true))}</div>
)}
</div>
{/* TELEFON */}
<div class="f-modal-section">
<h3>Usługa telefoniczna</h3>
@@ -232,7 +492,6 @@ export default function JamboxAddonsModal({ isOpen, onClose, pkg }) {
<p>Brak dostępnych pakietów telefonicznych.</p>
) : (
<div class="f-modal-phone-list f-accordion">
{/* brak telefonu */}
<div class="f-accordion-item f-accordion-item--no-phone">
<button
type="button"
@@ -252,14 +511,13 @@ export default function JamboxAddonsModal({ isOpen, onClose, pkg }) {
/>
<span class="f-modal-phone-name">Nie potrzebuję telefonu</span>
</span>
<span class="f-modal-phone-price">0,00 /mies.</span>
<span class="f-modal-phone-price">0,00 {cenaOpis}</span>
</button>
</div>
{/* pakiety telefonu */}
{phonePlans.map((p) => {
const isSelected = selectedPhoneId === p.id;
const isOpen = openPhoneId === p.id;
const isSelected = String(selectedPhoneId) === String(p.id);
const isOpen = String(openPhoneId) === String(p.id);
return (
<div class={`f-accordion-item ${isOpen ? "is-open" : ""}`} key={p.id}>
@@ -281,24 +539,24 @@ export default function JamboxAddonsModal({ isOpen, onClose, pkg }) {
/>
<span class="f-modal-phone-name">{p.name}</span>
</span>
<span class="f-modal-phone-price">
{Number(p.price_monthly || 0).toFixed(2)} /mies.
{money(p.price_monthly)} {cenaOpis}
</span>
</button>
{isOpen && (
<div class="f-accordion-body">
{p.features && p.features.length > 0 && (
{p.features?.length > 0 && (
<ul class="f-card-features">
{p.features
.filter(
(f) =>
!String(f.label || "").toLowerCase().includes("aktyw")
(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>
<span class="f-card-value">{formatFeatureValue(f.value)}</span>
</li>
))}
</ul>
@@ -312,39 +570,14 @@ export default function JamboxAddonsModal({ isOpen, onClose, pkg }) {
)}
</div>
{/* DODATKI JAMBOX */}
{/* DODATKI (addons.yaml) */}
<div class="f-modal-section">
<h3>Dodatkowe usługi</h3>
{addons.length === 0 ? (
<p>Brak usług dodatkowych dla tego pakietu.</p>
{addonsList.length === 0 ? (
<p>Brak usług dodatkowych.</p>
) : (
<div class="f-addon-list">
{addons.map((a) => {
const addonId = a.id ?? a.addon_id;
const checked = selectedAddonIds.includes(addonId);
const priceNum = Number(a.price || 0);
return (
<label class="f-addon-item" key={addonId}>
<div class="f-addon-checkbox">
<input
type="checkbox"
checked={checked}
onChange={() => toggleAddon(addonId)}
/>
</div>
<div class="f-addon-main">
<div class="f-addon-name">{a.name}</div>
{a.description && <div class="f-addon-desc">{a.description}</div>}
</div>
<div class="f-addon-price">{priceNum.toFixed(2)} /mies.</div>
</label>
);
})}
</div>
<div class="f-addon-list">{addonsList.map((a) => renderAddonRow(a, false))}</div>
)}
</div>
@@ -355,40 +588,48 @@ export default function JamboxAddonsModal({ isOpen, onClose, pkg }) {
<div class="f-summary-list">
<div class="f-summary-row">
<span>Pakiet</span>
<span>{basePrice ? `${basePrice.toFixed(2)} zł/mies.` : "—"}</span>
</div>
<div class="f-summary-row">
<span>Pakiety TV</span>
<span>{tvAddonsPrice ? `${tvAddonsPrice.toFixed(2)} zł/mies.` : "—"}</span>
<span>
{money(basePrice)} {cenaOpis}
</span>
</div>
<div class="f-summary-row">
<span>Telefon</span>
<span>{phonePrice ? `${phonePrice.toFixed(2)} zł/mies.` : "—"}</span>
<span>{phonePrice ? `${money(phonePrice)} ${cenaOpis}` : "—"}</span>
</div>
<div class="f-summary-row">
<span>Dekoder</span>
<span>{decoderPrice ? `${money(decoderPrice)} ${cenaOpis}` : "—"}</span>
</div>
<div class="f-summary-row">
<span>Dodatki TV</span>
<span>{tvAddonsPrice ? `${money(tvAddonsPrice)} ${cenaOpis}` : "—"}</span>
</div>
<div class="f-summary-row">
<span>Dodatki</span>
<span>{addonsPrice ? `${addonsPrice.toFixed(2)} zł/mies.` : "—"}</span>
<span>{addonsOnlyPrice ? `${money(addonsOnlyPrice)} ${cenaOpis}` : "—"}</span>
</div>
<div class="f-summary-total">
<span>Łącznie</span>
<span>{totalMonthly.toFixed(2)} /mies.</span>
<span>
{money(totalMonthly)} {cenaOpis}
</span>
</div>
</div>
</div>
{/* FLOATING TOTAL (dymek jak czat) */}
<div class="f-floating-total" onClick={(e) => e.stopPropagation()}>
<div class="f-floating-total-inner">
<span class="f-floating-total-label">Suma</span>
<span class="f-floating-total-value">{totalMonthly.toFixed(2)} /mies.</span>
<span class="f-floating-total-value">
{money(totalMonthly)} {cenaOpis}
</span>
</div>
</div>
</>
)}
</div>
</div>
</div>

View File

@@ -1,184 +1,252 @@
// src/islands/JamboxCards.jsx
import { useEffect, useState } from "preact/hooks";
import "../../styles/offers/offers-table.css";
import OffersSwitches from "../OffersSwitches.jsx";
import JamboxChannelsModal from "./JamboxChannelsModal.jsx";
import JamboxAddonsModal from "./JamboxAddonsModal.jsx";
import Markdown from "../Markdown.jsx";
export default function JamboxBasePackages({ source = "ALL" }) {
function formatMoney(amount, currency = "PLN") {
if (typeof amount !== "number" || Number.isNaN(amount)) return "";
try {
return new Intl.NumberFormat("pl-PL", {
style: "currency",
currency,
maximumFractionDigits: 0,
}).format(amount);
} catch {
return String(amount);
}
}
function toFeatureRows(params) {
const list = Array.isArray(params) ? params : [];
return list.map((p) => ({ label: p.label, value: p.value }));
}
/**
* @typedef {{ label: string, value: any, klucz?: string }} Param
* @typedef {{ id?: any, tid?: any, source?: string, nazwa?: string, slug?: string, ceny?: any[], parametry?: any[] }} Card
* @typedef {{ id?: any, nazwa?: string }} PhoneCard
* @typedef {{ id?: any, nazwa?: string }} Addon
* @typedef {{ id?: any, nazwa?: string }} Decoder
*
* @typedef {{
* nazwa: string;
* opis?: string;
* image?: string;
* pakiety?: string[];
* }} ChannelYaml
*
* @param {{
* title?: string,
* description?: string,
* cards?: Card[],
* internetWspolne?: Param[],
* waluta?: string,
* cenaOpis?: string,
*
* phoneCards?: PhoneCard[],
* tvAddons?: any[],
* addons?: Addon[],
* decoders?: Decoder[],
*
* addonsCenaOpis?: string,
*
* // ✅ NOWE
* channels?: ChannelYaml[]
* }} props
*/
export default function JamboxCards({
title = "",
description = "",
cards = [],
internetWspolne = [],
waluta = "PLN",
cenaOpis = "zł/mies.",
phoneCards = [],
tvAddons = [],
addons = [],
decoders = [],
channels = [],
}) {
const visibleCards = Array.isArray(cards) ? cards : [];
const wsp = Array.isArray(internetWspolne) ? internetWspolne : [];
// stan switchera (window.fuzSwitchState + event)
const [selected, setSelected] = useState({});
const [packages, setPackages] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [labels, setLabels] = useState({});
// modale
const [channelsModalOpen, setChannelsModalOpen] = useState(false);
const [addonsModalOpen, setAddonsModalOpen] = useState(false);
const [activePackage, setActivePackage] = useState(null);
const [activePkg, setActivePkg] = useState(null);
useEffect(() => {
if (typeof window !== "undefined" && window.fuzSwitchState) {
const { selected: sel } = window.fuzSwitchState;
const { selected: sel, labels: labs } = window.fuzSwitchState;
if (sel) setSelected(sel);
if (labs) setLabels(labs);
}
function handler(e) {
const detail = e.detail || {};
const handler = (e) => {
const detail = e?.detail || {};
if (detail.selected) setSelected(detail.selected);
}
if (detail.labels) setLabels(detail.labels);
};
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>
);
}
{title && <h1 class="f-section-header">{title}</h1>}
if (error) {
return (
<section class="f-offers">
<p class="text-red-600">{error}</p>
</section>
);
}
{description && (
<div class="mb-4">
<Markdown text={description} />
</div>
)}
if (!packages.length) {
return (
<section class="f-offers">
<p>Brak pakietów do wyświetlenia.</p>
</section>
);
}
<OffersSwitches />
return (
<section class="f-offers">
<div class={`f-offers-grid f-count-${packages.length || 1}`}>
{packages.map((pkg) => (
{visibleCards.length === 0 ? (
<p class="opacity-80">Brak pakietów do wyświetlenia.</p>
) : (
<div class={`f-offers-grid f-count-${visibleCards.length || 1}`}>
{visibleCards.map((card) => (
<JamboxPackageCard
key={pkg.id}
pkg={pkg}
onShowChannels={() => {
setActivePackage(pkg);
key={card.id || card.nazwa}
card={card}
wsp={wsp}
selected={selected}
labels={labels}
waluta={waluta}
cenaOpis={cenaOpis}
onShowChannels={(pkg) => {
setActivePkg(pkg);
setChannelsModalOpen(true);
}}
onConfigureAddons={() => {
setActivePackage(pkg);
onConfigureAddons={(pkg) => {
setActivePkg(pkg);
setAddonsModalOpen(true);
}}
/>
))}
</div>
)}
<JamboxChannelsModal
isOpen={channelsModalOpen}
onClose={() => setChannelsModalOpen(false)}
pkg={activePackage}
pkg={activePkg}
allChannels={channels}
/>
<JamboxAddonsModal
isOpen={addonsModalOpen}
onClose={() => setAddonsModalOpen(false)}
pkg={activePackage}
pkg={activePkg}
phoneCards={phoneCards}
tvAddons={tvAddons}
addons={addons}
decoders={decoders}
cenaOpis={cenaOpis}
/>
</section>
);
}
function JamboxPackageCard({ pkg, onShowChannels, onConfigureAddons }) {
const basePrice = pkg.price_monthly;
const installPrice = pkg.price_installation;
function JamboxPackageCard({
card,
wsp,
selected,
labels,
waluta,
cenaOpis,
onShowChannels,
onConfigureAddons,
}) {
const baseParams = Array.isArray(card?.parametry) ? card.parametry : [];
const ceny = Array.isArray(card?.ceny) ? card.ceny : [];
const featureRows = pkg.features || [];
const hasPrice = basePrice != null;
const budynek = selected?.budynek;
const umowa = selected?.umowa;
const match = ceny.find(
(c) => String(c?.budynek) === String(budynek) && String(c?.umowa) === String(umowa),
);
const basePrice = match?.miesiecznie;
const installPrice = match?.aktywacja;
const dynamicParams = [
{ klucz: "umowa", label: "Umowa", value: labels?.umowa || "—" },
{
klucz: "aktywacja",
label: "Aktywacja",
value: typeof installPrice === "number" ? formatMoney(installPrice, waluta) : "—",
},
];
const mergedParams = [...(Array.isArray(wsp) ? wsp : []), ...baseParams, ...dynamicParams];
const pkgForModals = {
id: card?.id,
tid: card?.tid,
source: card?.source,
name: card?.nazwa,
slug: card?.slug,
price_monthly: typeof basePrice === "number" ? basePrice : null,
price_installation: typeof installPrice === "number" ? installPrice : null,
features: toFeatureRows(mergedParams),
};
const hasPrice = typeof basePrice === "number";
return (
<div class="f-card" id={`pkg-${pkg.id}`} data-pkgid={pkg.id}>
<div class="f-card" id={`pkg-${card?.nazwa}`} data-pkg={card?.nazwa} >
<div class="f-card-header">
<div class="f-card-name">{pkg.name}</div>
<div class="f-card-name">{card.nazwa}</div>
<div class="f-card-price">
{hasPrice
? `${basePrice} zł/mies.`
: pkg.source === "PLUS"
? "JAMBOX PLUS"
: "JAMBOX EVIO"}
{hasPrice ? (
<>
{formatMoney(basePrice, waluta)} <span class="opacity-80">{cenaOpis}</span>
</>
) : (
<span class="opacity-70">Wybierz opcje</span>
)}
</div>
</div>
<ul class="f-card-features">
{featureRows.map((f, idx) => {
let val = f.value;
let display;
if (val === true || val === "true") display = "✓";
else if (val === false || val === "false" || val == null) display = "✕";
else display = val;
return (
<li class="f-card-row" key={idx}>
<span class="f-card-label">{f.label}</span>
<span class="f-card-value">{display}</span>
</li>
);
})}
<li class="f-card-row">
<span class="f-card-label">Aktywacja</span>
<span class="f-card-value">
{installPrice != null ? `${installPrice}` : "—"}
</span>
{mergedParams.map((p, idx) => (
<li class="f-card-row" key={p.klucz || p.label || idx}>
<span class="f-card-label">{p.label}</span>
<span class="f-card-value">{p.value}</span>
</li>
))}
</ul>
<button type="button" class="btn btn-primary mt-2" onClick={onShowChannels}>
<button
type="button"
class="btn btn-primary mt-2"
disabled={!hasPrice}
onClick={() => onShowChannels(pkgForModals)}
title={!hasPrice ? "Wybierz typ budynku i umowę" : ""}
>
Pokaż listę kanałów
</button>
<button
type="button"
class="btn btn-primary mt-2"
onClick={onConfigureAddons}
disabled={!hasPrice}
onClick={() => onConfigureAddons(pkgForModals)}
title={!hasPrice ? "Wybierz typ budynku i umowę" : ""}
>
Skonfiguruj usługi dodatkowe
</button>

View File

@@ -22,7 +22,7 @@ export default function JamboxChannelsModal({ isOpen, onClose, pkg }) {
}, [loading, error, filtered.length, channels.length]);
useEffect(() => {
if (!isOpen || !pkg?.id) return;
if (!isOpen || !pkg?.name) return;
let cancelled = false;
@@ -33,12 +33,27 @@ export default function JamboxChannelsModal({ isOpen, onClose, pkg }) {
setQuery("");
try {
const params = new URLSearchParams({ packageId: String(pkg.id) });
const res = await fetch(`/api/jambox/channels?${params.toString()}`);
// ✅ NOWE API: po nazwie pakietu
const params = new URLSearchParams({ package: String(pkg.name) });
const res = await fetch(`/api/jambox/package-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 : []);
if (!json?.ok) throw new Error(json?.error || "API_ERROR");
const list = Array.isArray(json.data) ? json.data : [];
// ✅ Normalizacja do UI (żeby reszta modala się nie sypała)
// - number: nie ma w DB, więc dajemy null/"—"
const normalized = list.map((ch, i) => ({
name: ch?.name ?? "",
description: ch?.description ?? "",
logo_url: ch?.logo_url ?? "",
number: ch?.number ?? "—",
_key: `${ch?.name ?? "?"}-${i}`,
}));
if (!cancelled) setChannels(normalized);
} catch (err) {
console.error("❌ Błąd pobierania listy kanałów:", err);
if (!cancelled) setError("Nie udało się załadować listy kanałów.");
@@ -51,7 +66,7 @@ export default function JamboxChannelsModal({ isOpen, onClose, pkg }) {
return () => {
cancelled = true;
};
}, [isOpen, pkg?.id]);
}, [isOpen, pkg?.name]);
if (!isOpen || !pkg) return null;
@@ -103,7 +118,6 @@ export default function JamboxChannelsModal({ isOpen, onClose, pkg }) {
)}
</div>
{/* ✅ tu musi być __meta */}
<div class="f-chsearch__meta">{meta}</div>
</div>
@@ -119,7 +133,7 @@ export default function JamboxChannelsModal({ isOpen, onClose, pkg }) {
{filtered.map((ch) => (
<div
class="jmb-channel-card"
key={ch.number}
key={ch._key}
role="button"
tabIndex={0}
onClick={(e) => {
@@ -144,7 +158,8 @@ export default function JamboxChannelsModal({ isOpen, onClose, pkg }) {
/>
)}
<div class="jmb-channel-name">{ch.name}</div>
<div class="jmb-channel-number">kanał {ch.number}</div>
</div>
<div class="jmb-channel-face jmb-channel-back">

View File

@@ -65,15 +65,20 @@ export default function JamboxChannelsSearch() {
return `Znaleziono: ${items.length}`;
}, [q, loading, err, items]);
function scrollToPackage(packageId) {
const el = document.getElementById(`pkg-${packageId}`);
if (!el) return;
function scrollToPackage(packageName) {
const key = String(packageName || "").trim();
if (!key) return;
const el = document.getElementById(`pkg-${key}`);
if (!el) {
console.warn("❌ Nie znaleziono pakietu w DOM:", `pkg-${key}`);
return;
}
el.scrollIntoView({ behavior: "smooth", block: "start" });
el.classList.add("is-target");
window.setTimeout(() => el.classList.remove("is-target"), 5400);
}
}
return (
<div class="f-chsearch">
@@ -137,20 +142,18 @@ export default function JamboxChannelsSearch() {
{Array.isArray(c.packages) && c.packages.length > 0 && (
<div class="f-chsearch__packages">
Dostępny w:&nbsp;
Dostępny w pakietach:&nbsp;
{c.packages.map((p, i) => (
<span key={p.id}>
<button
type="button"
class="f-chsearch__pkg"
onClick={() => scrollToPackage(p.id)}
class="f-chsearch-pkg"
onClick={() => scrollToPackage(p.name)}
>
{p.name}
</button>
<span class="f-chsearch__pkgnum">
{" "} (kanał {p.number})
</span>
{i < c.packages.length - 1 ? ", " : ""}
{/* {i < c.packages.length - 1 ? ", " : ""} */}
</span>
))}
</div>
@@ -159,7 +162,7 @@ export default function JamboxChannelsSearch() {
</div>
))}
{q.trim().length >= 2 && !loading && items.length === 0 && (
{q.trim().length >= 1 && !loading && items.length === 0 && (
<div class="f-chsearch-empty">
Brak wyników dla: <strong>{q}</strong>
</div>

View File

@@ -0,0 +1,200 @@
import { useMemo, useState } from "preact/hooks";
import { marked } from "marked";
import "../../styles/channels-search.css";
function norm(s) {
return String(s || "")
.toLowerCase()
.replace(/\u00a0/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function escapeRegExp(s) {
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/** Podświetlenie w czystym tekście (np. title) */
function highlightText(text, q) {
const qq = (q || "").trim();
if (!qq) return text;
const re = new RegExp(escapeRegExp(qq), "ig");
const parts = String(text || "").split(re);
if (parts.length === 1) return text;
// split() gubi match — więc budujemy przez exec na oryginale
const matches = String(text || "").match(re) || [];
const out = [];
for (let i = 0; i < parts.length; i++) {
out.push(parts[i]);
if (i < matches.length) out.push(<mark class="f-hl">{matches[i]}</mark>);
}
return out;
}
/** Podświetlenie wewnątrz HTML (po markdown), omijamy PRE/CODE */
function highlightHtml(html, q) {
const qq = (q || "").trim();
if (!qq) return html;
const re = new RegExp(escapeRegExp(qq), "ig");
const doc = new DOMParser().parseFromString(html, "text/html");
const root = doc.body;
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
const toSkip = (node) => {
const p = node.parentElement;
if (!p) return true;
const tag = p.tagName;
return tag === "SCRIPT" || tag === "STYLE" || tag === "CODE" || tag === "PRE";
};
const nodes = [];
let n;
while ((n = walker.nextNode())) nodes.push(n);
for (const textNode of nodes) {
if (toSkip(textNode)) continue;
const txt = textNode.nodeValue || "";
if (!re.test(txt)) continue;
// reset RegExp state (bo test() z /g/ potrafi przesuwać lastIndex)
re.lastIndex = 0;
const frag = doc.createDocumentFragment();
let last = 0;
let m;
while ((m = re.exec(txt))) {
const start = m.index;
const end = start + m[0].length;
if (start > last) frag.appendChild(doc.createTextNode(txt.slice(last, start)));
const mark = doc.createElement("mark");
mark.className = "f-hl";
mark.textContent = txt.slice(start, end);
frag.appendChild(mark);
last = end;
}
if (last < txt.length) frag.appendChild(doc.createTextNode(txt.slice(last)));
textNode.parentNode?.replaceChild(frag, textNode);
}
return root.innerHTML;
}
function HighlightedMarkdown({ text, q }) {
const html = useMemo(() => {
// markdown -> html
const raw = marked.parse(String(text || ""), {
gfm: true,
breaks: true,
headerIds: false,
mangle: false,
});
// highlight w HTML
return highlightHtml(raw, q);
}, [text, q]);
return (
<div
class="fuz-markdown"
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
export default function JamboxMozliwosciSearch({ items = [] }) {
const [q, setQ] = useState("");
const filtered = useMemo(() => {
const qq = norm(q);
if (qq.length === 0) return items;
return items.filter((it) => norm(`${it.title}\n${it.content}`).includes(qq));
}, [items, q]);
const meta = useMemo(() => {
const qq = q.trim();
if (qq.length === 0) return "";
return `Znaleziono: ${filtered.length} sekcje`;
}, [q, filtered]);
return (
<div class="f-chsearch">
<div class="f-chsearch__top">
<div class="f-chsearch__inputwrap">
<input
class="f-chsearch__input"
type="search"
value={q}
onInput={(e) => setQ(e.currentTarget.value)}
placeholder="Szukaj funkcji po nazwie lub opisie…"
aria-label="Szukaj funkcji po nazwie lub opisie"
/>
{q && (
<button
type="button"
class="f-chsearch__clear"
aria-label="Wyczyść wyszukiwanie"
onClick={() => setQ("")}
>
</button>
)}
</div>
<div class="f-chsearch-meta">{meta}</div>
</div>
{filtered.map((it, index) => {
const reverse = index % 2 === 1;
const imageUrl = it.image || "";
const hasImage = !!imageUrl;
return (
<section class="f-section" id={it.id} key={it.id}>
<div class={`f-section-grid ${hasImage ? "md:grid-cols-2" : "md:grid-cols-1"}`}>
{hasImage && (
<img
src={imageUrl}
alt={it.title}
class={`f-section-image ${reverse ? "md:order-1" : "md:order-2"}`}
loading="lazy"
decoding="async"
/>
)}
<div class={`${hasImage ? (reverse ? "md:order-2" : "md:order-1") : ""}`}>
<h2 class="f-section-title">{highlightText(it.title, q)}</h2>
<HighlightedMarkdown text={it.content} q={q} />
<div class="f-section-nav">
<a href="#top" class="btn btn-outline">Do góry </a>
</div>
</div>
</div>
</section>
);
})}
{q.length > 0 && filtered.length === 0 && (
<div class="f-chsearch-empty">
Brak wyników dla: <strong>{q}</strong>
</div>
)}
</div>
);
}

View File

@@ -1,52 +1,39 @@
import { useEffect, useState } from "preact/hooks";
import Markdown from "../../islands/Markdown.jsx";
import "../../styles/offers/offers-table.css";
/**
* @typedef {{ klucz: string, label: string, value: (string|number) }} PhoneParam
* @typedef {{
* nazwa: string,
* widoczny?: boolean,
* popularny?: boolean,
* cena?: { wartosc: number, opis?: string },
* parametry?: PhoneParam[]
* }} PhoneCard
*/
/**
* @param {{ title?: string, description?: string, cards?: PhoneCard[] }} props
*/
export default function PhoneDbOffersCards({
title = "Telefonia stacjonarna FUZ",
title = "",
description = "",
cards = [],
}) {
const [plans, setPlans] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
let cancelled = false;
async function load() {
setLoading(true);
try {
const res = await fetch("/api/phone/plans");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
if (!cancelled) {
setPlans(Array.isArray(json.data) ? json.data : []);
}
} catch (err) {
console.error("Błąd pobierania planów telefonii:", err);
if (!cancelled) {
setError("Nie udało się załadować pakietów telefonicznych.");
}
} finally {
if (!cancelled) setLoading(false);
}
}
load();
return () => {
cancelled = true;
};
}, []);
const visibleCards = Array.isArray(cards) ? cards : [];
return (
<section class="f-offers">
{loading && <p>Ładowanie pakietów telefonicznych...</p>}
{error && <p class="text-red-600">{error}</p>}
{!loading && !error && (
<div class={`f-offers-grid f-count-${plans.length || 1}`}>
{plans.map((plan) => (
<PhoneOfferCard key={plan.id} plan={plan} />
{title && <h2 class="f-section-header">{title}</h2>}
<div>
<Markdown text={description} />
</div>
{visibleCards.length === 0 ? (
<p class="opacity-80">Brak dostępnych pakietów.</p>
) : (
<div class={`f-offers-grid f-count-${visibleCards.length || 1}`}>
{visibleCards.map((card) => (
<PhoneOfferCard key={card.nazwa} card={card} />
))}
</div>
)}
@@ -54,21 +41,28 @@ export default function PhoneDbOffersCards({
);
}
function PhoneOfferCard({ plan }) {
function PhoneOfferCard({ card }) {
const price = card?.cena?.wartosc ?? "";
const priceDesc = card?.cena?.opis ?? "zł/mies.";
const params = Array.isArray(card?.parametry) ? card.parametry : [];
return (
<div class={`f-card ${plan.popular ? "f-card-popular" : ""}`}>
{plan.popular && <div class="f-card-badge">Najczęściej wybierany</div>}
<div class={`f-card ${card.popularny ? "f-card-popular" : ""}`}>
{card.popularny && <div class="f-card-badge">Najczęściej wybierany</div>}
<div class="f-card-header">
<div class="f-card-name">{plan.name}</div>
<div class="f-card-price">{plan.price_monthly} /mies.</div>
<div class="f-card-name">{card.nazwa}</div>
<div class="f-card-price">
{price.toFixed(2)} {priceDesc}
</div>
</div>
<ul class="f-card-features">
{plan.features.map((f) => (
<li class="f-card-row">
<span class="f-card-label">{f.label}</span>
<span class="f-card-value">{f.value}</span>
{params.map((p) => (
<li class="f-card-row" key={p.klucz || p.label}>
<span class="f-card-label">{p.label}</span>
<span class="f-card-value">{p.value}</span>
</li>
))}
</ul>

7
src/lib/loadYaml.ts Normal file
View File

@@ -0,0 +1,7 @@
import fs from "node:fs";
import yaml from "js-yaml";
export function loadYamlFile<T>(filePath: string): T {
const raw = fs.readFileSync(filePath, "utf8");
return yaml.load(raw) as T;
}

View File

@@ -1,42 +0,0 @@
import type { APIRoute } from "astro";
import { JSDOM } from "jsdom";
interface Channel {
title: string;
logo: string;
}
const cache = new Map<string, { time: number; data: Channel[] }>();
const CACHE_TIME = 1000 * 60 * 60 * 24 * 30; //miesiąc
export const GET: APIRoute = async ({ params }) => {
const id = params.id!;
const cached = cache.get(id);
if (cached && Date.now() - cached.time < CACHE_TIME) {
return new Response(JSON.stringify(cached.data), {
headers: { "Content-Type": "application/json" }
});
}
const url = `https://www.jambox.pl/iframe-pakiet-logo?p=${id}`;
const resp = await fetch(url, { headers: { "User-Agent": "Mozilla/5.0" } });
const html = await resp.text();
const dom = new JSDOM(html);
const images = [
...dom.window.document.querySelectorAll("img.imagefield-field_logo")
];
const channels = images.map((img) => ({
title: img.getAttribute("alt")?.trim() ?? "",
logo: img.getAttribute("src") ?? "",
}));
cache.set(id, { time: Date.now(), data: channels });
return new Response(JSON.stringify(channels), {
headers: { "Content-Type": "application/json" }
});
};

View File

@@ -1,55 +0,0 @@
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,
a.description AS description,
CAST(o.price AS REAL) AS price
FROM jambox_package_addon_options o
JOIN internet_addons a
ON a.id = o.addon_id
WHERE o.package_id = ?
ORDER BY a.type, a.name
`
)
.all(packageId);
return new Response(
JSON.stringify({
ok: true,
count: rows.length,
data: rows,
}),
{
status: 200,
headers: { "Content-Type": "application/json; charset=utf-8" },
}
);
} catch (err) {
console.error("❌ Błąd w /api/jambox/addons:", err);
return new Response(
JSON.stringify({ ok: false, error: err.message || "DB_ERROR" }),
{ status: 500, headers: { "Content-Type": "application/json; charset=utf-8" } }
);
} finally {
db.close();
}
}

View File

@@ -1,125 +0,0 @@
// src/pages/api/jambox/base-packages.js
//import { getDb } from "../db.js";
import Database from "better-sqlite3";
const DB_PATH =
process.env.FUZ_DB_PATH || "./src/data/ServicesRange.db";
function getDb() {
return new Database(DB_PATH, { readonly: true });
}
/**
* GET /api/jambox/base-packages?source=PLUS|EVIO|ALL&building=1|2&contract=1|2
*/
export function GET({ url }) {
const sourceParam = url.searchParams.get("source") || "PLUS";
const buildingParam = url.searchParams.get("building");
const contractParam = url.searchParams.get("contract");
const building = buildingParam ? Number(buildingParam) : 1;
const contract = contractParam ? Number(contractParam) : 1;
const db = getDb();
try {
const rows = db
.prepare(
`
SELECT
p.id AS package_id,
p.source AS package_source,
p.tid AS package_tid,
p.name AS package_name,
p.slug AS package_slug,
p.sort_order AS package_sort_order,
p.updated_at AS package_updated_at,
pr.price_monthly AS price_monthly,
pr.price_installation AS price_installation,
f.id AS feature_id,
f.label AS feature_label,
fv.value AS feature_value
FROM jambox_base_packages p
LEFT JOIN jambox_base_package_prices pr
ON pr.package_id = p.id
AND pr.building_type = ?
AND pr.contract_type = ?
LEFT JOIN jambox_package_feature_values fv
ON fv.package_id = p.id
LEFT JOIN internet_features f
ON f.id = fv.feature_id
WHERE (? = 'ALL' OR p.source = ?)
ORDER BY p.sort_order ASC, p.id ASC, f.id ASC;
`.trim()
)
.all(building, contract, sourceParam, sourceParam);
// grupowanie jak w /api/internet/plans
const byPackage = new Map();
for (const row of rows) {
if (!byPackage.has(row.package_id)) {
byPackage.set(row.package_id, {
id: row.package_id,
source: row.package_source,
tid: row.package_tid,
name: row.package_name,
slug: row.package_slug,
sort_order: row.package_sort_order,
updated_at: row.package_updated_at,
price_monthly: row.price_monthly,
price_installation: row.price_installation,
features: [],
});
}
if (row.feature_id) {
byPackage.get(row.package_id).features.push({
id: row.feature_id,
label: row.feature_label,
value: row.feature_value,
});
}
}
const data = Array.from(byPackage.values());
return new Response(
JSON.stringify({
ok: true,
source: sourceParam,
building,
contract,
count: data.length,
data,
}),
{
status: 200,
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "public, max-age=30",
},
}
);
} catch (err) {
console.error("❌ Błąd w /api/jambox/base-packages:", err);
return new Response(
JSON.stringify({ ok: false, error: "DB_ERROR" }),
{
status: 500,
headers: {
"Content-Type": "application/json; charset=utf-8",
},
}
);
}
}

View File

@@ -10,17 +10,33 @@ function clamp(n, min, max) {
return Math.max(min, Math.min(max, n));
}
function uniq(arr) {
return Array.from(new Set(arr));
}
// jeśli chcesz id do scrollowania (pkg-smart), to możesz dać slug
function slugifyPkg(name) {
return String(name || "")
.toLowerCase()
.replace(/\s+/g, " ")
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
}
export function GET({ url }) {
const q = (url.searchParams.get("q") || "").trim();
const limit = clamp(Number(url.searchParams.get("limit") || 50), 1, 200);
if (q.length < 0) {
if (q.length < 1) {
return new Response(JSON.stringify({ ok: true, data: [] }), {
status: 200,
headers: { "Content-Type": "application/json; charset=utf-8" },
});
}
// escape LIKE wildcardów
const safe = q.replace(/[%_]/g, (m) => `\\${m}`);
const like = `%${safe}%`;
@@ -31,45 +47,40 @@ export function GET({ url }) {
.prepare(
`
SELECT
c.name,
c.logo_url,
MAX(c.description) AS description,
MIN(c.number) AS min_number,
GROUP_CONCAT(
p.id || '::' || p.name || '::' || c.number || '::' || c.guaranteed,
'||'
) AS packages_blob
FROM jambox_package_channels c
JOIN jambox_base_packages p ON p.id = c.package_id
WHERE
c.name LIKE ? ESCAPE '\\'
GROUP BY c.name, c.logo_url
ORDER BY min_number ASC, c.name ASC
nazwa AS name,
MAX(image) AS logo_url,
MAX(opis) AS description,
GROUP_CONCAT(pckg_name, '||') AS packages_blob
FROM jambox_channels
WHERE nazwa LIKE ? ESCAPE '\\'
GROUP BY nazwa
ORDER BY name COLLATE NOCASE ASC
LIMIT ?;
`.trim()
)
.all(like, limit);
const data = rows.map((r) => {
const packages = String(r.packages_blob || "")
const pkgsRaw = String(r.packages_blob || "")
.split("||")
.filter(Boolean)
.map((s) => {
const [id, name, number, guaranteed] = s.split("::");
return {
id: Number(id),
name,
number: Number(number),
guaranteed: Number(guaranteed) === 1,
};
})
.sort((a, b) => a.id - b.id);
.map((x) => x.trim())
.filter(Boolean);
const packages = uniq(pkgsRaw)
.map((p) => ({
// jeśli UI wymaga ID do scrolla, to to jest najbezpieczniejsze:
id: slugifyPkg(p), // np. "smart" -> użyjesz jako pkg-smart
name: p,
number: "—", // brak w nowej tabeli
guaranteed: false, // brak w nowej tabeli
}))
.sort((a, b) => a.name.localeCompare(b.name, "pl"));
return {
name: r.name,
logo_url: r.logo_url,
logo_url: r.logo_url || "", // base64 data-url albo ""
description: r.description || "",
min_number: Number(r.min_number || 0),
min_number: 0, // brak numerów
packages,
};
});
@@ -84,5 +95,9 @@ export function GET({ url }) {
status: 500,
headers: { "Content-Type": "application/json; charset=utf-8" },
});
} finally {
try {
db.close();
} catch {}
}
}

View File

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

View File

@@ -0,0 +1,231 @@
import path from "node:path";
import { XMLParser } from "fast-xml-parser";
import Database from "better-sqlite3";
/* =====================
KONFIG
===================== */
const FEEDS = [
{ url: "https://www.jambox.pl/xml/listakanalow-smart.xml", name: "Smart" },
{ url: "https://www.jambox.pl/xml/listakanalow-optimum.xml", name: "Optimum" },
{ url: "https://www.jambox.pl/xml/listakanalow-platinum.xml", name: "Platinum" },
{ url: "https://www.jambox.pl/xml/listakanalow-pluspodstawowy.xml", name: "Podstawowy" },
{ url: "https://www.jambox.pl/xml/listakanalow-pluskorzystny.xml", name: "Korzystny" },
{ url: "https://www.jambox.pl/xml/listakanalow-plusbogaty.xml", name: "Bogaty" },
];
// 👉 ustaw jeśli chcesz inną bazę
const DB_PATH =
process.env.FUZ_DB_PATH ||
path.join(process.cwd(), "src", "data", "ServicesRange.db");
/* =====================
DB
===================== */
function getDb() {
const db = new Database(DB_PATH);
db.pragma("journal_mode = WAL");
return db;
}
/* =====================
XML / HTML HELPERS
===================== */
async function fetchXml(url) {
const res = await fetch(url, {
headers: { accept: "application/xml,text/xml,*/*" },
});
if (!res.ok) throw new Error(`XML HTTP ${res.status} for ${url}`);
return await res.text();
}
function parseNodes(xmlText) {
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: "@_",
});
const json = parser.parse(xmlText);
const nodes = json?.xml?.node ?? json?.node ?? [];
return Array.isArray(nodes) ? nodes : [nodes];
}
function decodeEntities(input) {
if (!input) return "";
let s = String(input)
.replace(/&nbsp;/g, " ")
.replace(/&ndash;/g, "")
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&#39;/g, "'")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">");
s = s.replace(/&#(\d+);/g, (_, d) =>
String.fromCodePoint(Number(d))
);
s = s.replace(/&#x([0-9a-fA-F]+);/g, (_, h) =>
String.fromCodePoint(parseInt(h, 16))
);
return s;
}
function htmlToMarkdown(input) {
if (!input) return "";
let html = "";
if (typeof input === "string") html = input;
else if (input?.p) {
if (typeof input.p === "string") html = `<p>${input.p}</p>`;
else if (Array.isArray(input.p))
html = input.p.map((p) => `<p>${p}</p>`).join("");
} else html = String(input);
let s = decodeEntities(html);
s = s
.replace(/<\s*(ul|ol)[^>]*>/gi, "\n__LIST_START__\n")
.replace(/<\/\s*(ul|ol)\s*>/gi, "\n__LIST_END__\n")
.replace(/<\s*li[^>]*>/gi, "__LI__")
.replace(/<\/\s*li\s*>/gi, "\n")
.replace(/<\s*br\s*\/?\s*>/gi, "\n")
.replace(/<\/\s*(p|div)\s*>/gi, "\n")
.replace(/<\s*(p|div)[^>]*>/gi, "")
.replace(/<[^>]+>/g, "")
.replace(/\r/g, "")
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
const lines = s.split("\n").map((x) => x.trim());
const out = [];
let inList = false;
for (const line of lines) {
if (!line) {
if (!inList) out.push("");
continue;
}
if (line === "__LIST_START__") {
inList = true;
continue;
}
if (line === "__LIST_END__") {
inList = false;
out.push("");
continue;
}
if (inList && line.startsWith("__LI__")) {
out.push(`- ${line.replace("__LI__", "").trim()}`);
continue;
}
out.push(line);
}
return out.join("\n").trim();
}
function extractLogoUrl(node) {
const logo = node?.field_logo_fid;
if (!logo) return null;
if (typeof logo === "string") {
const m = logo.match(/src="([^"]+)"/);
return m?.[1] ?? null;
}
if (logo?.img?.["@_src"]) return logo.img["@_src"];
return null;
}
async function downloadLogoAsBase64(url) {
try {
const res = await fetch(url);
if (!res.ok) return null;
const ct = res.headers.get("content-type") || "image/png";
const buf = Buffer.from(await res.arrayBuffer());
if (!buf.length) return null;
return `data:${ct};base64,${buf.toString("base64")}`;
} catch {
return null;
}
}
/* =====================
API ROUTE
===================== */
export async function POST() {
const db = getDb();
// ⚠️ WYMAGANE:
// CREATE UNIQUE INDEX ux_jambox_channels_nazwa_pckg
// ON jambox_channels(nazwa, pckg_name);
const upsert = db.prepare(`
INSERT INTO jambox_channels (nazwa, pckg_name, image, opis)
VALUES (@nazwa, @pckg_name, @image, @opis)
ON CONFLICT(nazwa, pckg_name) DO UPDATE SET
image = COALESCE(excluded.image, jambox_channels.image),
opis = COALESCE(excluded.opis, jambox_channels.opis)
`);
const logoCache = new Map(); // nazwa(lower) -> base64 | null
const rows = [];
try {
for (const feed of FEEDS) {
const xml = await fetchXml(feed.url);
const nodes = parseNodes(xml);
for (const node of nodes) {
const name = (node?.nazwa_kanalu ?? node?.nazwa ?? "").trim();
if (!name) continue;
const opis = htmlToMarkdown(node?.opis) || null;
const key = name.toLowerCase();
let img = logoCache.get(key);
if (img === undefined) {
const logoUrl = extractLogoUrl(node);
img = logoUrl ? await downloadLogoAsBase64(logoUrl) : null;
logoCache.set(key, img);
}
rows.push({
nazwa: name,
pckg_name: feed.name,
image: img,
opis,
});
}
}
const trx = db.transaction((data) => {
for (const r of data) upsert.run(r);
});
trx(rows);
return new Response(
JSON.stringify({
ok: true,
rows: rows.length,
db: DB_PATH,
}),
{ headers: { "content-type": "application/json; charset=utf-8" } }
);
} catch (e) {
console.error("❌ import jambox_channels:", e);
return new Response(
JSON.stringify({ ok: false, error: String(e.message || e) }),
{ status: 500 }
);
} finally {
try { db.close(); } catch {}
}
}

View File

@@ -0,0 +1,263 @@
import type { APIRoute } from "astro";
import { XMLParser } from "fast-xml-parser";
import path from "node:path";
import fs from "node:fs/promises";
const URL = "https://www.jambox.pl/xml/mozliwosci.xml";
type Section = {
title: string;
image?: string;
content: string;
};
type ContentBlock =
| { type: "text"; value: string }
| { type: "list"; items: string[] };
function toArray<T>(v: T | T[] | undefined | null): T[] {
if (!v) return [];
return Array.isArray(v) ? v : [v];
}
/* =======================
HTML / XML HELPERS
======================= */
function decodeEntities(input: string): string {
if (!input) return "";
let s = String(input)
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">");
s = s.replace(/&#(\d+);/g, (_, d) =>
String.fromCodePoint(Number(d))
);
s = s.replace(/&#x([0-9a-fA-F]+);/g, (_, h) =>
String.fromCodePoint(parseInt(h, 16))
);
return s;
}
/**
* Parsuje HTML:
* - <p>, <div>, <br> → zwykłe nowe linie
* - <ul>/<ol><li> → markdown lista
*/
function parseHtmlContent(input?: string): ContentBlock[] {
if (!input) return [];
let s = decodeEntities(String(input));
// znaczniki list
s = s
.replace(/<\s*(ul|ol)[^>]*>/gi, "\n__LIST_START__\n")
.replace(/<\/\s*(ul|ol)\s*>/gi, "\n__LIST_END__\n")
.replace(/<\s*li[^>]*>/gi, "__LI__")
.replace(/<\/\s*li\s*>/gi, "\n");
// normalne bloki
s = s
.replace(/<\s*br\s*\/?\s*>/gi, "\n")
.replace(/<\/\s*(p|div)\s*>/gi, "\n")
.replace(/<\s*(p|div)[^>]*>/gi, "");
// usuń resztę tagów
s = s.replace(/<[^>]+>/g, "");
s = s
.replace(/\r/g, "")
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
const blocks: ContentBlock[] = [];
const lines = s.split("\n");
let textBuf: string[] = [];
let listBuf: string[] | null = null;
const flushText = () => {
const txt = textBuf.join("\n").trim();
if (txt) blocks.push({ type: "text", value: txt });
textBuf = [];
};
const flushList = () => {
if (listBuf && listBuf.length) {
blocks.push({
type: "list",
items: listBuf.map((x) => x.trim()).filter(Boolean),
});
}
listBuf = null;
};
for (const raw of lines) {
const line = raw.trim();
if (line === "__LIST_START__") {
flushText();
listBuf = [];
continue;
}
if (line === "__LIST_END__") {
flushList();
continue;
}
if (listBuf && line.startsWith("__LI__")) {
listBuf.push(line.replace("__LI__", "").trim());
continue;
}
if (!line) {
if (!listBuf) textBuf.push("");
continue;
}
textBuf.push(line);
}
flushText();
flushList();
return blocks;
}
function blocksToMarkdown(blocks: ContentBlock[]): string {
const out: string[] = [];
for (const b of blocks) {
if (b.type === "text") {
// 👉 każde zdanie zakończone kropką = nowa linia
const lines = b.value
.replace(/\.\s+/g, ".\n")
.split("\n")
.map((x) => x.trim())
.filter(Boolean);
out.push(lines.join("\n"));
}
if (b.type === "list") {
for (const item of b.items) {
out.push(`- ${item}`);
}
}
}
return out.join("\n\n").trim();
}
/* =======================
SCREEN / YAML
======================= */
function extractUrlsFromString(s: string): string[] {
return s.match(/https?:\/\/[^\s<"]+/g) ?? [];
}
function extractScreens(screen: any): string[] {
if (!screen) return [];
if (typeof screen === "string") return extractUrlsFromString(screen);
const divs = (screen as any)?.div;
if (divs) {
return toArray(divs)
.map((d) => (typeof d === "string" ? d : d?.["#text"] ?? ""))
.flatMap(extractUrlsFromString);
}
return extractUrlsFromString(JSON.stringify(screen));
}
function yamlQuote(v: string): string {
return `"${String(v).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
}
function toYaml(sections: Section[]): string {
const out: string[] = ["sections:"];
for (const s of sections) {
out.push(` - title: ${yamlQuote(s.title)}`);
if (s.image) out.push(` image: ${yamlQuote(s.image)}`);
out.push(" content: |");
for (const line of s.content.split("\n")) {
out.push(` ${line}`);
}
out.push("");
}
return out.join("\n").trimEnd() + "\n";
}
/* =======================
API
======================= */
export const POST: APIRoute = async () => {
const res = await fetch(URL, {
headers: { accept: "application/xml,text/xml,*/*" },
});
if (!res.ok) {
return new Response(`JAMBOX XML: HTTP ${res.status}`, { status: 502 });
}
const xml = await res.text();
const parser = new XMLParser({ trimValues: true });
const parsed = parser.parse(xml);
const nodes = toArray((parsed as any)?.xml?.node ?? (parsed as any)?.node);
const sections: Section[] = nodes
.map((n: any) => {
const title = parseHtmlContent(n?.title)
.map((b) => (b.type === "text" ? b.value : ""))
.join(" ")
.trim();
if (!title) return null;
const blocks = [
...parseHtmlContent(n?.teaser),
...parseHtmlContent(n?.description),
];
const content = blocksToMarkdown(blocks);
if (!content) return null;
const screens = extractScreens(n?.screen);
const image = screens?.[0];
return { title, image, content };
})
.filter(Boolean) as Section[];
const outDir = path.join(
process.cwd(),
"src",
"content",
"internet-telewizja"
);
const outFile = path.join(outDir, "telewizja-mozliwosci.yaml");
await fs.mkdir(outDir, { recursive: true });
await fs.writeFile(outFile, toYaml(sections), "utf8");
return new Response(
JSON.stringify({ ok: true, count: sections.length }),
{ headers: { "content-type": "application/json" } }
);
};

View File

@@ -0,0 +1,67 @@
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 });
}
function cleanPkgName(v) {
const s = String(v || "").trim();
// prosta sanity: niepuste, nieprzesadnie długie
if (!s) return null;
if (s.length > 64) return null;
return s;
}
export function GET({ url }) {
const pkg =
cleanPkgName(url.searchParams.get("package")) ||
cleanPkgName(url.searchParams.get("pckg_name"));
if (!pkg) {
return new Response(
JSON.stringify({ ok: false, error: "MISSING_PACKAGE_NAME" }),
{
status: 400,
headers: { "Content-Type": "application/json; charset=utf-8" },
}
);
}
const db = getDb();
try {
const rows = db
.prepare(
`
SELECT
nazwa AS name,
opis AS description,
image AS logo_url
FROM jambox_channels
WHERE pckg_name = ?
ORDER BY nazwa COLLATE NOCASE ASC;
`.trim()
)
.all(pkg);
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" },
}
);
} finally {
try {
db.close();
} catch {}
}
}

View File

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

View File

@@ -1,85 +0,0 @@
import Database from "better-sqlite3";
const DB_PATH = process.env.FUZ_DB_PATH || "./src/data/ServicesRange.db";
export async function GET() {
const db = new Database(DB_PATH, { readonly: true });
try {
const stmt = db.prepare(`
SELECT
p.id AS plan_id,
p.name AS plan_name,
IFNULL(p.popular, 0) AS plan_popular,
p.price_monthly AS price_monthly,
p.currency AS currency,
f.id AS feature_id,
f.label AS feature_label,
fv.value AS feature_value
FROM phone_plans p
LEFT JOIN phone_plan_feature_values fv
ON fv.plan_id = p.id
LEFT JOIN phone_features f
ON f.id = fv.feature_id
ORDER BY p.id ASC, f.id ASC
`);
const rows = stmt.all();
const byPlan = new Map();
for (const row of rows) {
if (!byPlan.has(row.plan_id)) {
byPlan.set(row.plan_id, {
id: row.plan_id,
code: row.plan_code,
name: row.plan_name,
popular: !!row.plan_popular,
price_monthly: row.price_monthly,
currency: row.currency || "PLN",
features: [],
});
}
if (row.feature_id) {
byPlan.get(row.plan_id).features.push({
id: row.feature_id,
label: row.feature_label,
value: row.feature_value,
});
}
}
const data = Array.from(byPlan.values());
return new Response(
JSON.stringify({
ok: true,
count: data.length,
data,
}),
{
status: 200,
headers: {
"Content-Type": "application/json; charset=utf-8",
},
}
);
} catch (err) {
console.error("Błąd w /api/phone/plans:", err);
return new Response(
JSON.stringify({
ok: false,
error: err.message || "DB_ERROR",
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
);
} finally {
db.close();
}
}

View File

@@ -1,26 +1,86 @@
---
import path from "node:path";
import DefaultLayout from "../../layouts/DefaultLayout.astro";
import OffersSwitches from "../../islands/OffersSwitches.jsx";
import InternetCards from "../../islands/Internet/InternetCards.jsx";
import SectionRenderer from "../../components/sections/SectionRenderer.astro";
import InternetCards from "../../islands/Internet/InternetCards.jsx";
import yaml from "js-yaml";
import fs from "fs";
import { loadYamlFile } from "../../lib/loadYaml";
const seo = yaml.load(
fs.readFileSync("./src/content/internet-swiatlowodowy/seo.yaml", "utf8"),
type SeoYaml = any;
type InternetParam = { klucz: string; label: string; value: string | number };
type InternetCena = { budynek: number | string; umowa: number | string; miesiecznie: number; aktywacja?: number };
type InternetCard = { nazwa: string; widoczny?: boolean; popularny?: boolean; parametry?: InternetParam[]; ceny?: InternetCena[] };
type InternetCardsYaml = {
tytul?: string;
opis?: string;
waluta?: string;
cena_opis?: string;
cards?: InternetCard[];
};
// TELEFON YAML (twój format)
type PhoneParam = { klucz: string; label: string; value: string | number };
type PhoneCard = { nazwa: string; widoczny?: boolean; popularny?: boolean; cena?: { wartosc: number; opis?: string }; parametry?: PhoneParam[] };
type PhoneCardsYaml = { cards?: PhoneCard[] };
// ADDONS YAML (twój format)
type Addon = { id: string; nazwa: string; typ?: string; ilosc?: boolean; min?: number; max?: number; krok?: number; opis?: string; cena: number };
type AddonsYaml = { cena_opis?: string; dodatki?: Addon[] };
const seo = loadYamlFile<SeoYaml>(
path.join(process.cwd(), "src", "content", "internet-swiatlowodowy", "seo.yaml"),
);
const data = loadYamlFile<InternetCardsYaml>(
path.join(process.cwd(), "src", "content", "internet-swiatlowodowy", "cards.yaml"),
);
const phoneData = loadYamlFile<PhoneCardsYaml>(
path.join(process.cwd(), "src", "content", "telefon", "cards.yaml"),
);
const addonsData = loadYamlFile<AddonsYaml>(
path.join(process.cwd(), "src", "content", "internet-swiatlowodowy", "addons.yaml"),
);
const tytul = data?.tytul ?? "";
const opis = data?.opis ?? "Wybierz rodzaj budynku i czas trwania umowy";
const waluta = data?.waluta ?? "PLN";
const cenaOpis = data?.cena_opis ?? "zł/mies.";
const cards: InternetCard[] = Array.isArray(data?.cards)
? data.cards.filter((c) => c?.widoczny === true)
: [];
const phoneCards: PhoneCard[] = Array.isArray(phoneData?.cards)
? phoneData.cards.filter((c) => c?.widoczny === true)
: [];
const addons: Addon[] = Array.isArray(addonsData?.dodatki)
? addonsData.dodatki
: [];
// jeśli chcesz, możesz nadpisać cenaOpis w modalu z addons.yaml:
const addonsCenaOpis = addonsData?.cena_opis ?? cenaOpis;
---
<DefaultLayout seo={seo}>
<section class="f-section">
<div class="f-section-grid-single md:grid-cols-1">
<h1 class="f-section-title">Internet światłowodowy</h1>
<div class="fuz-markdown max-w-none">
<p>Wybierz rodzaj budynku i czas trwania umowy</p>
</div>
<OffersSwitches client:load />
<InternetCards client:load />
<InternetCards
client:load
title={tytul}
description={opis}
cards={cards}
waluta={waluta}
cenaOpis={cenaOpis}
phoneCards={phoneCards}
addons={addons}
addonsCenaOpis={addonsCenaOpis}
/>
</div>
</section>

View File

@@ -1,27 +1,165 @@
---
import path from "node:path";
import DefaultLayout from "../../layouts/DefaultLayout.astro";
import OffersSwitches from "../../islands/OffersSwitches.jsx";
import JamboxCards from "../../islands/jambox/JamboxCards.jsx";
import SectionRenderer from "../../components/sections/SectionRenderer.astro";
import JamboxCards from "../../islands/jambox/JamboxCards.jsx";
import SectionChannelsSearch from "../../components/sections/SectionChannelsSearch.astro";
import yaml from "js-yaml";
import fs from "fs";
import { loadYamlFile } from "../../lib/loadYaml";
const seo = yaml.load(
fs.readFileSync("./src/content/internet-telewizja/seo.yaml", "utf8"),
type SeoYaml = any;
type Param = { klucz: string; label: string; value: string | number };
type Cena = {
budynek: number | string;
umowa: number | string;
miesiecznie: number;
aktywacja?: number;
};
type Card = {
id?: string;
source?: string;
tid?: number;
nazwa: string;
slug?: string;
widoczny?: boolean;
popularny?: boolean;
parametry?: Param[];
ceny?: Cena[];
};
type CardsYaml = {
tytul?: string;
opis?: string;
waluta?: string;
cena_opis?: string;
internet_parametry_wspolne?: Param[];
cards?: Card[];
};
// ✅ telefon z YAML (do modala)
type PhoneParam = { klucz: string; label: string; value: string | number };
type PhoneCard = {
id?: string;
nazwa: string;
widoczny?: boolean;
popularny?: boolean;
cena?: { wartosc: number; opis?: string };
parametry?: PhoneParam[];
};
type PhoneYaml = { cards?: PhoneCard[] };
type Decoder = { id: string; nazwa: string; cena: number };
// ✅ dodatki z YAML (do modala)
type Addon = {
id: string;
nazwa: string;
typ?: "checkbox" | "quantity";
ilosc?: boolean;
min?: number;
max?: number;
krok?: number;
opis?: string;
cena: number;
};
type AddonsYaml = {
tytul?: string;
opis?: string;
cena_opis?: string;
dekodery?: Decoder[];
dodatki?: Addon[];
};
type ChannelsYaml = {
title?: string;
updated_at?: string;
channels?: Array<{
nazwa: string;
opis?: string;
image?: string;
pakiety?: string[];
}>;
};
const seo = loadYamlFile<SeoYaml>(
path.join(process.cwd(), "src", "content", "internet-telewizja", "seo.yaml"),
);
const data = loadYamlFile<CardsYaml>(
path.join(
process.cwd(),
"src",
"content",
"internet-telewizja",
"cards.yaml",
),
);
const tytul = data?.tytul ?? "";
const opis = data?.opis ?? "Wybierz rodzaj budynku i czas trwania umowy";
const waluta = data?.waluta ?? "PLN";
const cenaOpis = data?.cena_opis ?? "zł/mies.";
const internetWspolne: Param[] = Array.isArray(data?.internet_parametry_wspolne)
? data.internet_parametry_wspolne
: [];
const cards: Card[] = Array.isArray(data?.cards)
? data.cards.filter((c) => c?.widoczny === true)
: [];
// ✅ NOWE: dane do modala dodatków (bez ruszania reszty)
const phoneYaml = loadYamlFile<PhoneYaml>(
path.join(process.cwd(), "src", "content", "telefon", "cards.yaml"),
);
const tvAddonsYaml = loadYamlFile<any>(
path.join(process.cwd(), "src", "content", "internet-telewizja", "tv-addons.yaml"),
);
const phoneCards = Array.isArray(phoneYaml?.cards) ? phoneYaml.cards : [];
const tvAddons = Array.isArray(tvAddonsYaml?.dodatki) ? tvAddonsYaml.dodatki : [];
const addonsYaml = loadYamlFile<AddonsYaml>(
path.join(
process.cwd(),
"src",
"content",
"internet-telewizja",
"addons.yaml",
),
);
const addons: Addon[] = Array.isArray(addonsYaml?.dodatki)
? addonsYaml.dodatki
: [];
const decoders: Decoder[] = Array.isArray(addonsYaml?.dekodery)
? addonsYaml.dekodery
: [];
const addonsCenaOpis = addonsYaml?.cena_opis ?? cenaOpis;
---
<DefaultLayout seo={seo}>
<section class="f-section">
<div class="f-section-grid-single md:grid-cols-1">
<h1 class="f-section-title">Telewizja z interentem</h1>
<div class="fuz-markdown max-w-none">
<p>Wybierz rodzaj budynku i czas trwania umowy</p>
</div>
<OffersSwitches client:load />
<JamboxCards client:load />
<JamboxCards
client:load
title={tytul}
description={opis}
cards={cards}
internetWspolne={internetWspolne}
waluta={waluta}
cenaOpis={cenaOpis}
tvAddons={tvAddons}
phoneCards={phoneCards}
decoders={decoders}
addons={addons}
addonsCenaOpis={addonsCenaOpis}
/>
</div>
</section>
<SectionChannelsSearch />

View File

@@ -1,96 +0,0 @@
---
export const prerender = false;
import DefaultLayout from "../../layouts/DefaultLayout.astro";
import Markdown from "../../islands/Markdown.jsx";
import { fetchMozliwosci, type Feature } from "../../lib/mozliwosci";
let items: Feature[] = [];
let err = "";
try {
items = await fetchMozliwosci(60_000);
} catch (e) {
err = e instanceof Error ? e.message : String(e);
}
function buildMarkdown(it: Feature) {
const parts: string[] = [];
if (it.teaser) parts.push(`> ${it.teaser}\n`);
if (it.description) parts.push(it.description);
return parts.join("\n\n").trim();
}
---
<DefaultLayout title="Możliwości JAMBOX">
<!-- NAGŁÓWEK STRONY zgodny z FUZ -->
<section class="f-section" id="top">
<div class="f-section-grid-single">
<h1 class="f-section-title">Możliwości JAMBOX</h1>
<div class="fuz-markdown max-w-none">
<p>Funkcje i udogodnienia dostępne w JAMBOX.</p>
</div>
</div>
{
err && (
<div class="mt-6 max-w-7xl mx-auto text-left rounded-2xl border border-red-300 bg-red-50 p-4">
<p class="font-bold">Nie udało się pobrać danych</p>
<p class="opacity-80">{err}</p>
</div>
)
}
</section>
{
!err && (
<>
{items.map((it, index) => {
const reverse = index % 2 === 1;
const imageUrl = it.screens?.[0] || "";
const hasImage = !!imageUrl;
return (
<section class="f-section" id={it.id}>
<div
class={`f-section-grid ${
hasImage
? "md:grid-cols-2"
: "md:grid-cols-1"
}`}
>
{hasImage && (
<img
src={imageUrl}
alt={it.title}
class={`f-section-image ${
reverse
? "md:order-1"
: "md:order-2"
} rounded-2xl border border-[--f-border-color]`}
loading="lazy"
decoding="async"
/>
)}
<div
class={`${hasImage ? (reverse ? "md:order-2" : "md:order-1") : ""}`}
>
<h2 class="f-section-title">{it.title}</h2>
<Markdown text={buildMarkdown(it)} />
<div class="f-section-nav">
<a href="#top" class="btn btn-outline">
Do góry ↑
</a>
</div>
</div>
</div>
</section>
);
})}
</>
)
}
</DefaultLayout>

View File

@@ -0,0 +1,94 @@
---
export const prerender = false;
import path from "node:path";
import DefaultLayout from "../../layouts/DefaultLayout.astro";
import { loadYamlFile } from "../../lib/loadYaml";
import MozliwosciSearch from "../../islands/jambox/JamboxMozliwosciSearch.jsx";
type YamlSection = {
title: string;
image?: string;
content?: string;
};
type YamlData = {
sections?: YamlSection[];
};
function slugify(s: string) {
return String(s || "")
.toLowerCase()
.replace(/[\u0105]/g, "a")
.replace(/[\u0107]/g, "c")
.replace(/[\u0119]/g, "e")
.replace(/[\u0142]/g, "l")
.replace(/[\u0144]/g, "n")
.replace(/[\u00f3]/g, "o")
.replace(/[\u015b]/g, "s")
.replace(/[\u017a\u017c]/g, "z")
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
}
let items: Array<{
id: string;
title: string;
image?: string;
content: string;
}> = [];
let err = "";
try {
const data = loadYamlFile<YamlData>(
path.join(
process.cwd(),
"src",
"content",
"internet-telewizja",
"telewizja-mozliwosci.yaml"
)
);
const sections = Array.isArray(data?.sections) ? data.sections : [];
items = sections
.filter((s) => s?.title)
.map((s) => ({
id: slugify(s.title),
title: s.title,
image: s.image,
content: (s.content || "").trim(),
}))
.filter((x) => x.content);
} catch (e) {
err = e instanceof Error ? e.message : String(e);
}
---
<DefaultLayout title="Możliwości JAMBOX">
<!-- NAGŁÓWEK STRONY zgodny z FUZ -->
<section class="f-section" id="top">
<div class="f-section-grid-single">
<h1 class="f-section-title">Możliwości JAMBOX</h1>
<div class="fuz-markdown max-w-none">
<p>Funkcje i udogodnienia dostępne w JAMBOX.</p>
</div>
{!err && <MozliwosciSearch client:load items={items} />}
</div>
{
err && (
<div class="mt-6 max-w-7xl mx-auto text-left rounded-2xl border border-red-300 bg-red-50 p-4">
<p class="font-bold">Nie udało się wczytać danych</p>
<p class="opacity-80">{err}</p>
</div>
)
}
</section>
{/* UWAGA: render sekcji przeniesiony do wyspy, żeby filtr działał */}
</DefaultLayout>

View File

@@ -1,21 +1,56 @@
---
import path from "node:path";
import DefaultLayout from "../../layouts/DefaultLayout.astro";
import SectionRenderer from "../../components/sections/SectionRenderer.astro";
import OffersPhoneCards from "../../islands/phone/OffersPhoneCards.jsx";
import yaml from "js-yaml";
import fs from "fs";
import { loadYamlFile } from "../../lib/loadYaml";
const seo = yaml.load(
fs.readFileSync("./src/content/telefon/seo.yaml", "utf8"),
type SeoYaml = any;
type PhoneParam = {
klucz: string;
label: string;
value: string | number;
};
type PhoneCard = {
nazwa: string;
widoczny?: boolean;
popularny?: boolean;
cena?: { wartosc: number; opis?: string };
parametry?: PhoneParam[];
};
type PhoneCardsYaml = {
tytul?: string;
opis?: string;
cards?: PhoneCard[];
};
const seo = loadYamlFile<SeoYaml>(
path.join(process.cwd(), "src", "content", "telefon", "seo.yaml"),
);
const phoneCards = loadYamlFile<PhoneCardsYaml>(
path.join(process.cwd(), "src", "content", "telefon", "cards.yaml"),
);
const tytul = phoneCards?.tytul ?? "";
const opis = phoneCards?.opis ?? "";
const cards: PhoneCard[] = Array.isArray(phoneCards?.cards)
? phoneCards.cards.filter((c) => c?.widoczny === true)
: [];
---
<DefaultLayout seo={seo}>
<section class="f-section">
<div class="f-section-grid-single md:grid-cols-1">
<h1 class="f-section-title">Usługa telefonu</h1>
<OffersPhoneCards client:load />
<OffersPhoneCards client:load title={tytul} description={opis} cards={cards} />
</div>
</section>

View File

@@ -13,7 +13,8 @@
.f-chsearch__input {
@apply w-full md:flex-1 px-4 py-3 rounded-xl border border-[--f-input-border] bg-[--f-background] text-[--f-text] outline-none focus:ring-2 focus:ring-[--btn-background];
padding-right: 2.75rem; /* miejsce na X */
padding-right: 2.75rem;
/* miejsce na X */
}
.f-chsearch__clear {
@@ -88,22 +89,28 @@
}
/* klikalny pakiet jak link */
.f-chsearch__pkg {
@apply inline;
.f-chsearch-pkg {
@apply inline-flex items-center justify-center gap-2 font-semibold rounded-lg px-2 py-0 ml-2
text-base transition-all duration-200 cursor-pointer select-none
focus:outline-none focus-visible:ring-2 focus-visible:ring-[--f-header] focus-visible:ring-offset-2 focus-visible:ring-offset-[--f-background];
@apply border border-transparent bg-[--btn-background] text-[--btn-text];
/* @apply inline
text-xl;
background: transparent;
border: 0;
padding: 0;
cursor: pointer;
font: inherit;
color: var(--f-link, var(--btn-background));
text-decoration: underline;
text-decoration: underline; */
}
.f-chsearch__pkg:hover {
text-decoration-thickness: 2px;
.f-chsearch-pkg:hover {
@apply bg-[--btn-background-hover] text-[--btn-text-hover];
/* text-decoration-thickness: 2px; */
}
.f-chsearch__pkgnum {
.f-chsearch-pkgnum {
@apply opacity-70;
}
@@ -116,12 +123,12 @@
.f-chsearch__input::-webkit-search-cancel-button {
-webkit-appearance: none;
}
}
.f-chsearch__input::-ms-clear {
.f-chsearch__input::-ms-clear {
display: none;
}
}
.f-chsearch__input[type="search"] {
.f-chsearch__input[type="search"] {
appearance: none;
}
}

View File

@@ -5,7 +5,7 @@
}
.fuz-markdown p {
@apply mb-4 text-2xl;
@apply text-2xl;
}
.fuz-markdown h1 {
@@ -52,3 +52,9 @@
.fuz-markdown strong {
@apply font-semibold;
}
.f-hl {
padding: 0 .15em;
border-radius: .35em;
background: rgba(255, 215, 0, 0.35);
}

View File

@@ -93,30 +93,54 @@
}
/* ===========================
DODATKI — KOLUMNOWA LISTA
DODATKI — KOLUMNOWA LISTA (GRID)
checkbox: checkbox | main | price
quantity: slot | main | qty | price
=========================== */
.f-addon-list {
@apply flex flex-col gap-2;
}
/* BAZA: checkbox | main | price */
.f-addon-item {
@apply grid items-center gap-3 px-3 py-2 rounded-xl border cursor-pointer;
@apply grid items-start gap-3 px-3 py-2 rounded-xl border cursor-pointer;
grid-template-columns: auto 1fr auto;
border-color: rgba(148, 163, 184, 0.5);
background: var(--f-background);
}
.f-addon-item:hover {
border-color: color-mix(
in srgb,
var(--fuz-accent, #2563eb) 70%,
rgba(148, 163, 184, 0.5) 30%
);
}
.f-addon-item input[type="checkbox"] {
@apply cursor-pointer;
}
/* kolumna 1 */
.f-addon-checkbox {
@apply flex items-center justify-center;
align-items: center;
margin-top: 0.1rem;
}
.f-addon-checkbox input[type="checkbox"] {
width: 1.05rem;
height: 1.05rem;
transform: scale(1.05);
accent-color: var(--fuz-accent, #2563eb);
cursor: pointer;
}
/* kolumna 2 */
.f-addon-main {
@apply flex flex-col gap-0.5;
min-width: 0;
}
.f-addon-name {
@@ -127,18 +151,69 @@
@apply text-sm opacity-85;
}
/* kolumna 3 (cena) */
.f-addon-price {
@apply font-semibold whitespace-nowrap;
justify-self: end;
text-align: right;
min-width: 140px; /* stała kolumna cen */
}
.f-addon-item:hover {
border-color: color-mix(
in srgb,
var(--fuz-accent, #2563eb) 70%,
rgba(148, 163, 184, 0.5) 30%
);
/* suma pod ceną (quantity) */
.f-addon-price-total {
margin-top: 0.15rem;
font-size: 0.9em;
font-weight: 600;
opacity: 0.85;
white-space: nowrap;
color: var(--fuz-accent, #2563eb);
}
/* WARIANT: quantity -> slot | main | qty | price */
.f-addon-item--qty {
grid-template-columns: auto 1fr auto auto;
align-items: start;
}
/* “pusty” slot w kolumnie 1 (żeby wyrównać do checkboxa) */
.f-addon-item--qty .f-addon-checkbox {
visibility: hidden; /* zajmuje miejsce, ale nie widać */
width: 1.05rem;
height: 1.05rem;
transform: scale(1.05);
}
/* kolumna qty (3) bliżej prawej */
.f-addon-item--qty .f-addon-qty {
justify-self: end;
display: inline-flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.05rem;
}
/* wartość qty */
.f-addon-qty-value {
min-width: 2ch;
text-align: center;
}
/* mobile: w razie ciasnoty przenosimy qty pod main, cena zostaje po prawej */
@media (max-width: 640px) {
.f-addon-item--qty {
grid-template-columns: auto 1fr auto;
grid-template-areas:
"slot main price"
"slot qty price";
}
.f-addon-item--qty .f-addon-checkbox { grid-area: slot; }
.f-addon-item--qty .f-addon-main { grid-area: main; }
.f-addon-item--qty .f-addon-qty { grid-area: qty; justify-self: start; }
.f-addon-item--qty .f-addon-price { grid-area: price; }
}
/* ===========================
PODSUMOWANIE MIESIĘCZNE
=========================== */
@@ -246,3 +321,13 @@
font-size: 1rem;
}
}
.f-addon-price-total {
margin-top: 0.15rem;
font-size: 0.9em;
font-weight: 600;
opacity: 0.85;
white-space: nowrap;
color: var(--fuz-accent, #2563eb);
}

View File

@@ -20,7 +20,7 @@
.f-mobile-menu {
@apply fixed right-4 bg-[--f-background] border border-[--f-border-color] p-4 flex flex-col gap-2 opacity-0 scale-95 -translate-y-2 pointer-events-none transition duration-200 divide-y divide-[--f-border-color];
top: calc(var(--f-navbar-height, 64px) + 0.75rem);
top: calc(var(--f-navbar-height, 84px) + 0.75rem);
width: 18rem;
max-width: calc(100vw - 2rem);
z-index: 70;

View File

@@ -1,5 +1,5 @@
.f-switches-wrapper {
@apply flex flex-wrap justify-center gap-6;
@apply flex flex-wrap justify-center gap-6 mb-4;
}
.f-switch-group {

View File

@@ -2,25 +2,16 @@
@apply my-6;
}
/* GRID FLEX — zawsze centrowany */
.f-offers-grid {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 2rem;
padding: 0 1rem;
@apply flex flex-wrap justify-center gap-8;
}
/* MOBILE: każda karta prawie pełna szerokość */
.f-card {
@apply bg-[--f-bg] text-[--f-text] border border-[--f-offers-border] rounded-2xl shadow-md p-6 relative flex flex-col gap-4;
/* przewijanie uwzględnia sticky navbar */
scroll-margin-top: calc(var(--f-navbar-height, 64px) + 16px);
scroll-margin-top: calc(var(--f-navbar-height, 84px) + 16px);
flex: 1 1 100%;
max-width: 26rem;
@apply bg-[--f-bg] text-[--f-text] border border-[--f-offers-border] rounded-2xl shadow-md p-6 relative flex flex-col gap-4;
transition: transform 220ms ease, box-shadow 220ms ease;
}
@@ -29,7 +20,7 @@
transform: translateY(-3px);
}
/* POPULARNY PLAN */
.f-card-popular {
border: 2px solid var(--f-offers-popular);
background: var(--f-offers-popular-bg);
@@ -58,12 +49,7 @@
}
.f-card-row {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 0.75rem;
padding: 0.5rem 0;
border-bottom: 1px solid var(--f-offers-border);
align-items: center;
@apply grid grid-cols-[2fr_1fr] gap-2 py-1 border-b border-[--f-offers-border] items-center;
}
.f-card-row:last-child {

View File

@@ -43,7 +43,7 @@
--link-hover-dark: hsla(45, 80%, 70%, 1);
--cookie-accept-dark: hsla(120, 60%, 45%, 1);
--f-navbar-height: 84px;
}
:root {