Rezygnacja z bazy, przeniesienie danych do plików yamla
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 54 KiB |
BIN
src/assets/logo.webp
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
src/assets/sections/arris5202.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
src/assets/sections/tv-smart-4k.webp
Normal file
|
After Width: | Height: | Size: 11 KiB |
@@ -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"}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
22
src/content/internet-swiatlowodowy/addons.yaml
Normal 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
|
||||
133
src/content/internet-swiatlowodowy/cards.yaml
Normal 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
|
||||
@@ -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,5 Gb/s oraz 1 Gb/s, wsparcie dla sieci Mesh i VoIP. Stabilność, niezawodność i pełne wykorzystanie łącza – w całym Twoim domu.
|
||||
|
||||
|
||||
49
src/content/internet-telewizja/addons.yaml
Normal 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
|
||||
242
src/content/internet-telewizja/cards.yaml
Normal 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
|
||||
@@ -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
|
||||
228
src/content/internet-telewizja/telewizja-mozliwosci.yaml
Normal 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ów ró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łó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ów za pomocą pilota – bez dzwonienia do konsultantów czy klikania na stronie www.
|
||||
|
||||
Unikalny sposób zamawiania dodatkowych pakietów i usług telewizyjnych, za pomocą pilota – 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 „Przejdź do...” » 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.
|
||||
120
src/content/internet-telewizja/tv-addons.yaml
Normal 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
|
||||
82
src/content/telefon/cards.yaml
Normal 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ł"
|
||||
BIN
src/data/ServicesRange.db-shm
Normal file
BIN
src/data/ServicesRange.db-wal
Normal 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([]);
|
||||
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 [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const [selectedPhoneId, setSelectedPhoneId] = useState(null);
|
||||
const [selectedAddons, setSelectedAddons] = useState([]);
|
||||
|
||||
// który pakiet telefoniczny jest rozwinięty
|
||||
// zamiast selectedAddons (DB) -> mapka ilości
|
||||
// { public_ip: 1, ip_v4_extra: 3 }
|
||||
const [selectedQty, setSelectedQty] = useState({});
|
||||
|
||||
// akordeony
|
||||
const [openPhoneId, setOpenPhoneId] = useState(null);
|
||||
|
||||
// czy akordeon internetu (fiber) jest rozwinięty
|
||||
const [baseOpen, setBaseOpen] = useState(true);
|
||||
|
||||
const formatFeatureValue = (val) => {
|
||||
if (val === true || val === "true") return "✓";
|
||||
if (val === false || val === "false" || val == null) return "✕";
|
||||
return val;
|
||||
};
|
||||
// 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)} zł/mies.
|
||||
{money(basePrice)} {cenaOpis}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -176,188 +181,233 @@ 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>
|
||||
{/* Telefon */}
|
||||
<div class="f-modal-section">
|
||||
<h3>Usługa telefoniczna</h3>
|
||||
|
||||
{phonePlans.length === 0 ? (
|
||||
<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">
|
||||
{phonePlans.length === 0 ? (
|
||||
<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"
|
||||
class="f-accordion-header"
|
||||
onClick={() => handlePhoneSelect(null)}
|
||||
>
|
||||
<span class="f-accordion-header-left">
|
||||
<input
|
||||
type="radio"
|
||||
name="phone-plan"
|
||||
checked={selectedPhoneId === null}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePhoneSelect(null);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span class="f-modal-phone-name">Nie potrzebuję telefonu</span>
|
||||
</span>
|
||||
<span class="f-modal-phone-price">0,00 {cenaOpis}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{phonePlans.map((p) => {
|
||||
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}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="f-accordion-header"
|
||||
onClick={() => handlePhoneSelect(null)}
|
||||
onClick={() => handlePhoneSelect(p.id)}
|
||||
>
|
||||
<span class="f-accordion-header-left">
|
||||
<input
|
||||
type="radio"
|
||||
name="phone-plan"
|
||||
checked={selectedPhoneId === null}
|
||||
checked={isSelected}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePhoneSelect(null);
|
||||
handlePhoneSelect(p.id);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span class="f-modal-phone-name">
|
||||
Nie potrzebuję telefonu
|
||||
</span>
|
||||
<span class="f-modal-phone-name">{p.name}</span>
|
||||
</span>
|
||||
|
||||
<span class="f-modal-phone-price">
|
||||
{money(p.price_monthly)} {cenaOpis}
|
||||
</span>
|
||||
<span class="f-modal-phone-price">0,00 zł/mies.</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* lista pakietów telefonu */}
|
||||
{phonePlans.map((p) => {
|
||||
const isSelected = selectedPhoneId === p.id;
|
||||
const isOpen = openPhoneId === p.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
class={`f-accordion-item ${isOpen ? "is-open" : ""}`}
|
||||
key={p.id}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="f-accordion-header"
|
||||
onClick={() => handlePhoneSelect(p.id)}
|
||||
>
|
||||
<span class="f-accordion-header-left">
|
||||
<input
|
||||
type="radio"
|
||||
name="phone-plan"
|
||||
checked={isSelected}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePhoneSelect(p.id);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span class="f-modal-phone-name">{p.name}</span>
|
||||
</span>
|
||||
|
||||
<span class="f-modal-phone-price">
|
||||
{p.price_monthly.toFixed(2)} zł/mies.
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div class="f-accordion-body">
|
||||
{p.features && p.features.length > 0 && (
|
||||
<ul class="f-card-features">
|
||||
{p.features
|
||||
.filter(
|
||||
(f) =>
|
||||
!String(f.label || "")
|
||||
.toLowerCase()
|
||||
.includes("aktyw")
|
||||
)
|
||||
.map((f, idx) => (
|
||||
<li class="f-card-row" key={idx}>
|
||||
<span class="f-card-label">{f.label}</span>
|
||||
<span class="f-card-value">{f.value}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div class="f-accordion-body">
|
||||
{p.features && p.features.length > 0 && (
|
||||
<ul class="f-card-features">
|
||||
{p.features
|
||||
.filter(
|
||||
(f) =>
|
||||
!String(f.label || "")
|
||||
.toLowerCase()
|
||||
.includes("aktyw"),
|
||||
)
|
||||
.map((f, idx) => (
|
||||
<li class="f-card-row" key={idx}>
|
||||
<span class="f-card-label">{f.label}</span>
|
||||
<span class="f-card-value">
|
||||
{formatFeatureValue(f.value)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Dodatki internetowe */}
|
||||
<div class="f-modal-section">
|
||||
<h3>Dodatkowe usługi</h3>
|
||||
|
||||
{addonsList.length === 0 ? (
|
||||
<p>Brak dodatkowych usług.</p>
|
||||
) : (
|
||||
<div class="f-addon-list">
|
||||
{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={a.id}>
|
||||
<div class="f-addon-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
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>}
|
||||
</div>
|
||||
|
||||
<div class="f-addon-price">
|
||||
{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>
|
||||
|
||||
{/* Podsumowanie */}
|
||||
<div class="f-modal-section f-summary">
|
||||
<h3>Podsumowanie miesięczne</h3>
|
||||
|
||||
<div class="f-summary-list">
|
||||
<div class="f-summary-row">
|
||||
<span>Internet</span>
|
||||
<span>{money(basePrice)} {cenaOpis}</span>
|
||||
</div>
|
||||
|
||||
{/* Dodatki internetowe */}
|
||||
<div class="f-modal-section">
|
||||
<h3>Dodatkowe usługi</h3>
|
||||
|
||||
{addons.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
|
||||
);
|
||||
|
||||
return (
|
||||
<label class="f-addon-item" key={`${addon.id}-${opt.id}`}>
|
||||
<div class="f-addon-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => handleAddonToggle(addon.id, opt.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>
|
||||
|
||||
<div class="f-addon-price">
|
||||
{opt.price.toFixed(2)} {opt.currency}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div class="f-summary-row">
|
||||
<span>Telefon</span>
|
||||
<span>{phonePrice ? `${money(phonePrice)} ${cenaOpis}` : "—"}</span>
|
||||
</div>
|
||||
|
||||
{/* Podsumowanie */}
|
||||
<div class="f-modal-section f-summary">
|
||||
<h3>Podsumowanie miesięczne</h3>
|
||||
|
||||
<div class="f-summary-list">
|
||||
<div class="f-summary-row">
|
||||
<span>Internet</span>
|
||||
<span>{basePrice.toFixed(2)} zł/mies.</span>
|
||||
</div>
|
||||
|
||||
<div class="f-summary-row">
|
||||
<span>Telefon</span>
|
||||
<span>
|
||||
{phonePrice ? `${phonePrice.toFixed(2)} zł/mies.` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="f-summary-row">
|
||||
<span>Dodatki</span>
|
||||
<span>
|
||||
{addonsPrice ? `${addonsPrice.toFixed(2)} zł/mies.` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="f-summary-total">
|
||||
<span>Łącznie</span>
|
||||
<span>{totalMonthly.toFixed(2)} zł/mies.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="f-summary-row">
|
||||
<span>Dodatki</span>
|
||||
<span>{addonsPrice ? `${money(addonsPrice)} ${cenaOpis}` : "—"}</span>
|
||||
</div>
|
||||
|
||||
<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)} zł/mies.</span>
|
||||
</div>
|
||||
<div class="f-summary-total">
|
||||
<span>Łącznie</span>
|
||||
<span>{money(totalMonthly)} {cenaOpis}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="f-floating-total" onClick={(e) => e.stopPropagation()}>
|
||||
<div class="f-floating-total-inner">
|
||||
<span class="f-floating-total-label">Suma</span>
|
||||
<span class="f-floating-total-value">
|
||||
{money(totalMonthly)} {cenaOpis}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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} zł` : "—"}
|
||||
</span>
|
||||
</li>
|
||||
{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>
|
||||
|
||||
@@ -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([]);
|
||||
function formatFeatureValue(val) {
|
||||
if (val === true || val === "true") return "✓";
|
||||
if (val === false || val === "false" || val == null) return "✕";
|
||||
return val;
|
||||
}
|
||||
|
||||
const [tvAddons, setTvAddons] = useState([]);
|
||||
const [selectedTvAddonTids, setSelectedTvAddonTids] = useState([]);
|
||||
function money(amount) {
|
||||
const n = Number(amount || 0);
|
||||
return n.toFixed(2).replace(".", ",");
|
||||
}
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
/** 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 [selectedAddonIds, setSelectedAddonIds] = useState([]);
|
||||
|
||||
// akordeony
|
||||
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);
|
||||
|
||||
const formatFeatureValue = (val) => {
|
||||
if (val === true || val === "true") return "✓";
|
||||
if (val === false || val === "false" || val == null) return "✕";
|
||||
return val;
|
||||
};
|
||||
// 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;
|
||||
});
|
||||
};
|
||||
|
||||
// reset po otwarciu / zmianie pakietu
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setSelectedPhoneId(null);
|
||||
setSelectedAddonIds([]);
|
||||
setOpenPhoneId(null);
|
||||
setBaseOpen(true);
|
||||
setError("");
|
||||
setSelectedTvAddonTids([]);
|
||||
}, [isOpen, pkg?.id]);
|
||||
const setQtyAddon = (id, qty, min, max) => {
|
||||
const safe = Math.max(min, Math.min(max, qty));
|
||||
setSelectedQty((prev) => ({ ...prev, [id]: safe }));
|
||||
};
|
||||
|
||||
// load danych
|
||||
useEffect(() => {
|
||||
if (!isOpen || !pkg?.id) return;
|
||||
const renderAddonRow = (a, isTv = false) => {
|
||||
const qty = Number(selectedQty[a.id] || 0);
|
||||
const isQty = a.typ === "quantity" || a.ilosc === true;
|
||||
|
||||
let cancelled = false;
|
||||
// TV: term i cena
|
||||
const termPricing = isTv && hasTvTermPricing(a, pkg);
|
||||
const term = tvTerm[a.id] || "12m";
|
||||
const unit = getAddonUnitPrice(a, pkg, termPricing ? term : null);
|
||||
|
||||
async function loadData() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
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>
|
||||
|
||||
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 : [];
|
||||
<div class="f-addon-main">
|
||||
<div class="f-addon-name">{a.nazwa}</div>
|
||||
{a.opis && <div class="f-addon-desc">{a.opis}</div>}
|
||||
|
||||
// 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 : [];
|
||||
{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>
|
||||
|
||||
// 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 : [];
|
||||
<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>
|
||||
|
||||
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);
|
||||
}
|
||||
<div class="f-addon-price">
|
||||
{money(unit)} {cenaOpis}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
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,214 +430,206 @@ export default function JamboxAddonsModal({ isOpen, onClose, pkg }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && <p>Ładowanie danych...</p>}
|
||||
{error && <p class="text-red-600">{error}</p>}
|
||||
{/* ✅ DEKODER (radio) — NAD TV ADDONS */}
|
||||
<div class="f-modal-section">
|
||||
<h3>Wybór dekodera</h3>
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{/* TV ADDONS */}
|
||||
<div class="f-modal-section">
|
||||
<h3>Pakiety dodatkowe TV</h3>
|
||||
{decodersList.length === 0 ? (
|
||||
<p>Brak dostępnych dekoderów.</p>
|
||||
) : (
|
||||
<div class="f-modal-phone-list f-accordion">
|
||||
{decodersList.map((d) => {
|
||||
const isSelected = String(selectedDecoderId) === String(d.id);
|
||||
|
||||
{tvAddons.length === 0 ? (
|
||||
<p>Brak pakietów dodatkowych TV dla tego pakietu.</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);
|
||||
|
||||
return (
|
||||
<label class="f-addon-item" key={`tv-${tid}`}>
|
||||
<div class="f-addon-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleTvAddon(tid)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="f-addon-main">
|
||||
<div class="f-addon-name">{a.name}</div>
|
||||
<div class="f-addon-desc">{a.description}</div>
|
||||
</div>
|
||||
|
||||
<div class="f-addon-price">
|
||||
{Number.isFinite(priceNum) ? `${priceNum.toFixed(2)} zł/mies.` : "—"}
|
||||
</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* TELEFON */}
|
||||
<div class="f-modal-section">
|
||||
<h3>Usługa telefoniczna</h3>
|
||||
|
||||
{phonePlans.length === 0 ? (
|
||||
<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">
|
||||
return (
|
||||
<div class={`f-accordion-item ${isSelected ? "is-open" : ""}`} key={d.id}>
|
||||
<button
|
||||
type="button"
|
||||
class="f-accordion-header"
|
||||
onClick={() => handlePhoneSelect(null)}
|
||||
onClick={() => setSelectedDecoderId(String(d.id))}
|
||||
>
|
||||
<span class="f-accordion-header-left">
|
||||
<input
|
||||
type="radio"
|
||||
name="decoder"
|
||||
checked={isSelected}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedDecoderId(String(d.id));
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span class="f-modal-phone-name">{d.nazwa}</span>
|
||||
</span>
|
||||
|
||||
<span class="f-modal-phone-price">
|
||||
{money(d.cena)} {cenaOpis}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
|
||||
{phonePlans.length === 0 ? (
|
||||
<p>Brak dostępnych pakietów telefonicznych.</p>
|
||||
) : (
|
||||
<div class="f-modal-phone-list f-accordion">
|
||||
<div class="f-accordion-item f-accordion-item--no-phone">
|
||||
<button
|
||||
type="button"
|
||||
class="f-accordion-header"
|
||||
onClick={() => handlePhoneSelect(null)}
|
||||
>
|
||||
<span class="f-accordion-header-left">
|
||||
<input
|
||||
type="radio"
|
||||
name="phone-plan"
|
||||
checked={selectedPhoneId === null}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePhoneSelect(null);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span class="f-modal-phone-name">Nie potrzebuję telefonu</span>
|
||||
</span>
|
||||
<span class="f-modal-phone-price">0,00 {cenaOpis}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{phonePlans.map((p) => {
|
||||
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}>
|
||||
<button
|
||||
type="button"
|
||||
class="f-accordion-header"
|
||||
onClick={() => handlePhoneSelect(p.id)}
|
||||
>
|
||||
<span class="f-accordion-header-left">
|
||||
<input
|
||||
type="radio"
|
||||
name="phone-plan"
|
||||
checked={selectedPhoneId === null}
|
||||
checked={isSelected}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePhoneSelect(null);
|
||||
handlePhoneSelect(p.id);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span class="f-modal-phone-name">Nie potrzebuję telefonu</span>
|
||||
<span class="f-modal-phone-name">{p.name}</span>
|
||||
</span>
|
||||
|
||||
<span class="f-modal-phone-price">
|
||||
{money(p.price_monthly)} {cenaOpis}
|
||||
</span>
|
||||
<span class="f-modal-phone-price">0,00 zł/mies.</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* pakiety telefonu */}
|
||||
{phonePlans.map((p) => {
|
||||
const isSelected = selectedPhoneId === p.id;
|
||||
const isOpen = openPhoneId === p.id;
|
||||
|
||||
return (
|
||||
<div class={`f-accordion-item ${isOpen ? "is-open" : ""}`} key={p.id}>
|
||||
<button
|
||||
type="button"
|
||||
class="f-accordion-header"
|
||||
onClick={() => handlePhoneSelect(p.id)}
|
||||
>
|
||||
<span class="f-accordion-header-left">
|
||||
<input
|
||||
type="radio"
|
||||
name="phone-plan"
|
||||
checked={isSelected}
|
||||
onChange={(e) => {
|
||||
e.stopPropagation();
|
||||
handlePhoneSelect(p.id);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span class="f-modal-phone-name">{p.name}</span>
|
||||
</span>
|
||||
<span class="f-modal-phone-price">
|
||||
{Number(p.price_monthly || 0).toFixed(2)} zł/mies.
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div class="f-accordion-body">
|
||||
{p.features && p.features.length > 0 && (
|
||||
<ul class="f-card-features">
|
||||
{p.features
|
||||
.filter(
|
||||
(f) =>
|
||||
!String(f.label || "").toLowerCase().includes("aktyw")
|
||||
)
|
||||
.map((f, idx) => (
|
||||
<li class="f-card-row" key={idx}>
|
||||
<span class="f-card-label">{f.label}</span>
|
||||
<span class="f-card-value">{f.value}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
{isOpen && (
|
||||
<div class="f-accordion-body">
|
||||
{p.features?.length > 0 && (
|
||||
<ul class="f-card-features">
|
||||
{p.features
|
||||
.filter(
|
||||
(f) => !String(f.label || "").toLowerCase().includes("aktyw"),
|
||||
)
|
||||
.map((f, idx) => (
|
||||
<li class="f-card-row" key={idx}>
|
||||
<span class="f-card-label">{f.label}</span>
|
||||
<span class="f-card-value">{formatFeatureValue(f.value)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* DODATKI (addons.yaml) */}
|
||||
<div class="f-modal-section">
|
||||
<h3>Dodatkowe usługi</h3>
|
||||
|
||||
{addonsList.length === 0 ? (
|
||||
<p>Brak usług dodatkowych.</p>
|
||||
) : (
|
||||
<div class="f-addon-list">{addonsList.map((a) => renderAddonRow(a, false))}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* PODSUMOWANIE */}
|
||||
<div class="f-modal-section f-summary">
|
||||
<h3>Podsumowanie miesięczne</h3>
|
||||
|
||||
<div class="f-summary-list">
|
||||
<div class="f-summary-row">
|
||||
<span>Pakiet</span>
|
||||
<span>
|
||||
{money(basePrice)} {cenaOpis}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* DODATKI JAMBOX */}
|
||||
<div class="f-modal-section">
|
||||
<h3>Dodatkowe usługi</h3>
|
||||
|
||||
{addons.length === 0 ? (
|
||||
<p>Brak usług dodatkowych dla tego pakietu.</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)} zł/mies.</div>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div class="f-summary-row">
|
||||
<span>Telefon</span>
|
||||
<span>{phonePrice ? `${money(phonePrice)} ${cenaOpis}` : "—"}</span>
|
||||
</div>
|
||||
|
||||
{/* PODSUMOWANIE */}
|
||||
<div class="f-modal-section f-summary">
|
||||
<h3>Podsumowanie miesięczne</h3>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="f-summary-row">
|
||||
<span>Telefon</span>
|
||||
<span>{phonePrice ? `${phonePrice.toFixed(2)} zł/mies.` : "—"}</span>
|
||||
</div>
|
||||
|
||||
<div class="f-summary-row">
|
||||
<span>Dodatki</span>
|
||||
<span>{addonsPrice ? `${addonsPrice.toFixed(2)} zł/mies.` : "—"}</span>
|
||||
</div>
|
||||
|
||||
<div class="f-summary-total">
|
||||
<span>Łącznie</span>
|
||||
<span>{totalMonthly.toFixed(2)} zł/mies.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="f-summary-row">
|
||||
<span>Dekoder</span>
|
||||
<span>{decoderPrice ? `${money(decoderPrice)} ${cenaOpis}` : "—"}</span>
|
||||
</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)} zł/mies.</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>{addonsOnlyPrice ? `${money(addonsOnlyPrice)} ${cenaOpis}` : "—"}</span>
|
||||
</div>
|
||||
|
||||
<div class="f-summary-total">
|
||||
<span>Łącznie</span>
|
||||
<span>
|
||||
{money(totalMonthly)} {cenaOpis}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="f-floating-total" onClick={(e) => e.stopPropagation()}>
|
||||
<div class="f-floating-total-inner">
|
||||
<span class="f-floating-total-label">Suma</span>
|
||||
<span class="f-floating-total-value">
|
||||
{money(totalMonthly)} {cenaOpis}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<section class="f-offers">
|
||||
<p class="text-red-600">{error}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!packages.length) {
|
||||
return (
|
||||
<section class="f-offers">
|
||||
<p>Brak pakietów do wyświetlenia.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section class="f-offers">
|
||||
<div class={`f-offers-grid f-count-${packages.length || 1}`}>
|
||||
{packages.map((pkg) => (
|
||||
<JamboxPackageCard
|
||||
key={pkg.id}
|
||||
pkg={pkg}
|
||||
onShowChannels={() => {
|
||||
setActivePackage(pkg);
|
||||
setChannelsModalOpen(true);
|
||||
}}
|
||||
onConfigureAddons={() => {
|
||||
setActivePackage(pkg);
|
||||
setAddonsModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{title && <h1 class="f-section-header">{title}</h1>}
|
||||
|
||||
{description && (
|
||||
<div class="mb-4">
|
||||
<Markdown text={description} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<OffersSwitches />
|
||||
|
||||
{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={card.id || card.nazwa}
|
||||
card={card}
|
||||
wsp={wsp}
|
||||
selected={selected}
|
||||
labels={labels}
|
||||
waluta={waluta}
|
||||
cenaOpis={cenaOpis}
|
||||
onShowChannels={(pkg) => {
|
||||
setActivePkg(pkg);
|
||||
setChannelsModalOpen(true);
|
||||
}}
|
||||
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} zł` : "—"}
|
||||
</span>
|
||||
</li>
|
||||
{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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -58,23 +58,28 @@ export default function JamboxChannelsSearch() {
|
||||
|
||||
const meta = useMemo(() => {
|
||||
const qq = q.trim();
|
||||
if (qq.length === 0) return "";
|
||||
if (qq.length === 0) return "";
|
||||
// "Zacznij pisać, aby wyszukać"
|
||||
if (loading) return "Szukam…";
|
||||
if (err) return err;
|
||||
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;
|
||||
|
||||
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
|
||||
el.classList.add("is-target");
|
||||
window.setTimeout(() => el.classList.remove("is-target"), 5400);
|
||||
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">
|
||||
<h1 class="f-section-title">Wyszukiwanie kanałów w pakietach telewizji</h1>
|
||||
@@ -137,20 +142,18 @@ export default function JamboxChannelsSearch() {
|
||||
|
||||
{Array.isArray(c.packages) && c.packages.length > 0 && (
|
||||
<div class="f-chsearch__packages">
|
||||
Dostępny w:
|
||||
Dostępny w pakietach:
|
||||
{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>
|
||||
|
||||
200
src/islands/jambox/JamboxMozliwosciSearch.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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} zł/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
@@ -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;
|
||||
}
|
||||
@@ -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" }
|
||||
});
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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}%`;
|
||||
|
||||
@@ -28,48 +44,43 @@ export function GET({ url }) {
|
||||
|
||||
try {
|
||||
const rows = db
|
||||
.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
|
||||
LIMIT ?;
|
||||
`.trim()
|
||||
)
|
||||
.all(like, limit);
|
||||
.prepare(
|
||||
`
|
||||
SELECT
|
||||
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 {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
231
src/pages/api/jambox/import-channels.js
Normal 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(/ /g, " ")
|
||||
.replace(/–/g, "–")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
|
||||
s = s.replace(/&#(\d+);/g, (_, d) =>
|
||||
String.fromCodePoint(Number(d))
|
||||
);
|
||||
s = s.replace(/&#x([0-9a-fA-F]+);/g, (_, h) =>
|
||||
String.fromCodePoint(parseInt(h, 16))
|
||||
);
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
function htmlToMarkdown(input) {
|
||||
if (!input) return "";
|
||||
|
||||
let html = "";
|
||||
if (typeof input === "string") html = input;
|
||||
else if (input?.p) {
|
||||
if (typeof input.p === "string") html = `<p>${input.p}</p>`;
|
||||
else if (Array.isArray(input.p))
|
||||
html = input.p.map((p) => `<p>${p}</p>`).join("");
|
||||
} else html = String(input);
|
||||
|
||||
let s = decodeEntities(html);
|
||||
|
||||
s = s
|
||||
.replace(/<\s*(ul|ol)[^>]*>/gi, "\n__LIST_START__\n")
|
||||
.replace(/<\/\s*(ul|ol)\s*>/gi, "\n__LIST_END__\n")
|
||||
.replace(/<\s*li[^>]*>/gi, "__LI__")
|
||||
.replace(/<\/\s*li\s*>/gi, "\n")
|
||||
.replace(/<\s*br\s*\/?\s*>/gi, "\n")
|
||||
.replace(/<\/\s*(p|div)\s*>/gi, "\n")
|
||||
.replace(/<\s*(p|div)[^>]*>/gi, "")
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.replace(/\r/g, "")
|
||||
.replace(/[ \t]+\n/g, "\n")
|
||||
.replace(/\n{3,}/g, "\n\n")
|
||||
.trim();
|
||||
|
||||
const lines = s.split("\n").map((x) => x.trim());
|
||||
const out = [];
|
||||
let inList = false;
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line) {
|
||||
if (!inList) out.push("");
|
||||
continue;
|
||||
}
|
||||
if (line === "__LIST_START__") {
|
||||
inList = true;
|
||||
continue;
|
||||
}
|
||||
if (line === "__LIST_END__") {
|
||||
inList = false;
|
||||
out.push("");
|
||||
continue;
|
||||
}
|
||||
if (inList && line.startsWith("__LI__")) {
|
||||
out.push(`- ${line.replace("__LI__", "").trim()}`);
|
||||
continue;
|
||||
}
|
||||
out.push(line);
|
||||
}
|
||||
|
||||
return out.join("\n").trim();
|
||||
}
|
||||
|
||||
function extractLogoUrl(node) {
|
||||
const logo = node?.field_logo_fid;
|
||||
if (!logo) return null;
|
||||
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 {}
|
||||
}
|
||||
}
|
||||
263
src/pages/api/jambox/import-mozliwosci-to-yaml.ts
Normal 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(/ /g, " ")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
|
||||
s = s.replace(/&#(\d+);/g, (_, d) =>
|
||||
String.fromCodePoint(Number(d))
|
||||
);
|
||||
s = s.replace(/&#x([0-9a-fA-F]+);/g, (_, h) =>
|
||||
String.fromCodePoint(parseInt(h, 16))
|
||||
);
|
||||
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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" } }
|
||||
);
|
||||
};
|
||||
67
src/pages/api/jambox/package-channels.js
Normal 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 {}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
94
src/pages/internet-telewizja/telewizja-mozliwosci.astro
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -115,13 +122,13 @@
|
||||
}
|
||||
|
||||
.f-chsearch__input::-webkit-search-cancel-button {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.f-chsearch__input::-ms-clear {
|
||||
display: none;
|
||||
}
|
||||
.f-chsearch__input::-ms-clear {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.f-chsearch__input[type="search"] {
|
||||
appearance: none;
|
||||
}
|
||||
.f-chsearch__input[type="search"] {
|
||||
appearance: none;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||