Kolejne zmiany,

This commit is contained in:
dm
2025-12-15 11:28:53 +01:00
parent c0b9d5a584
commit 6b5a913666
48 changed files with 1630 additions and 868 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 17 KiB

BIN
src/assets/logo2.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -42,7 +42,6 @@ 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;
@@ -51,7 +50,6 @@ 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;

View File

@@ -15,11 +15,11 @@ const footer = yaml.load(
{footer.company.address.line1}<br />
{footer.company.address.line2}
</p>
<a href="/polityka-prywatnosci" title="Polityka prywatności">Polityka prywatności</a>
<a href="/dokumenty/polityka-prywatnosci" title="Polityka prywatności">Polityka prywatności</a>
</div>
<div class="f-footer-col">
<h4>Kontakt</h4>
<h4>{footer.contact.title}</h4>
{
footer.contact.phones.map((phone: string) => (
<p>

View File

@@ -14,17 +14,14 @@ const section = props.section ?? {};
<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 class="flex justify-center gap-4 flex-wrap">
<a href="/files/EVIO TV.pdf" download class="btn btn-primary"
>Pobierz listę kanałów (SMART OPTIMUM PLATINUM)
</a>
<a href="/files/JAMBOX TV.pdf" download class="btn btn-primary"
>Pobierz listę kanałów (PODSTAWOWY KORZYSTNY BOGATY)
</a>
</div>
</div>
</div>
</section>

View File

@@ -41,7 +41,7 @@ form:
rodo:
label: "Wyrażam zgodę na przetwarzanie moich danych osobowych zgodnie z"
policyLink: "/polityka-prywatnosci"
policyLink: "/dokumenty/polityka-prywatnosci"
policyText: "polityką prywatności"
policyTitle: "Polityka prywatności FUZ Adam Rojek"

View File

@@ -5,14 +5,22 @@ cena_opis: "zł/mies."
dekodery:
- id: arris_4302
nazwa: "Arris 4302"
opis: |
Arris 4302 HD to kompaktowy sprzęt z możliwością korzystania z jakości HD.
Oferuje opcjonalne podłączenie dedykowanego dysku zewnętrznego Arris DVR-One
cena: 0
- id: arris_5202
nazwa: "Arris 5202"
opis: |
Wydajny dekoder z możliwością korzystania z technologii 4K.
Dekoder obsługuje także technologie: High Dynamic Range (HDR), 10-bitowy kolor z dekodowaniem HEVC.
cena: 5
- id: tv_smart_4k
nazwa: "TV Smart 4K"
opis: |
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.
cena: 10
dodatki:

View File

@@ -11,7 +11,7 @@ sections:
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.
Ogólna specyfikacja techniczna:
## 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
@@ -31,7 +31,7 @@ sections:
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
## Ogólna specyfikacja techniczna:
- Szybki procesor Dual-core, 8500 DMIPS
- Pamięć RAM: DDR3 2GB, 8 GB eMMC Flash
- Rozdzielczość obrazu: 4K, Full HD, wsparcie dla technologii HDR10

View File

@@ -1,87 +0,0 @@
title: "Polityka Prywatności"
intro:
content: |
## §1. Informacje podstawowe.
1. Administratorem Twoich danych osobowych jest: FUZ Adam Rojek, ul. Świętojańska 46, 07-202 Wyszków, zwanym dalej Administratorem.
2. Kontakt z Administratorem jest możliwy za pośrednictwem:
- poczty e-mail: bok@fuz.pl,
- korespondencyjnie na adres: ul. Świętojańska 46, 07-202 Wyszków.
## §2. Zasady przetwarzania danych.
1. Administrator przetwarza dane osobowe z poszanowaniem następujących zasad:
1. w oparciu o podstawę prawną i zgodnie z prawem (legalizm);
2. rzetelnie i uczciwie (rzetelność);
3. w sposób przejrzysty dla osoby, której dane dotyczą (transparentność);
4. w konkretnych celach i nie „na zapas” (minimalizacja);
5. nie więcej niż potrzeba (adekwatność);
6. z dbałością o prawidłowość danych (prawidłowość);
7. nie dłużej niż potrzeba (czasowość);
8. zapewniając odpowiednie bezpieczeństwo danych (bezpieczeństwo).
## §3. Cele, podstawy prawne i zakres przetwarzania danych.
1. Twoje dane będziemy przetwarzać:
1. w celach kontaktowych, aby udzielić ci odpowiedzi na Twoją wiadomość przesłaną nam bezpośrednio drogą e-mailową lub za pośrednictwem formularza na podstawie realizacji prawnie uzasadnionego interesu Administratora związanego z koniecznością udzielenia Ci odpowiedzi.
2. w celach marketingowych, jeśli wyraziłeś zgodę,
3. w celu utworzenia i prowadzenia dla Ciebie konta użytkownika w Panelu Klienta,
4. w celu realizacji Twojego zamówienia, na podstawie zawartej umowy sprzedaży (poprzez skuteczne złożenie zamówienia dochodzi do zawarcia umowy, w której my występujemy w roli sprzedawcy, a Ty kupującego),
5. w celach statystycznych, analitycznych oraz monitorowania ruchu na Stronie przy użyciu plików cookies, na podstawie realizacji prawnie uzasadnionego interesu Administratora związanego z prawidłowym działaniem i funkcjonowaniem Strony oraz prowadzeniem analizy ruchu na stronie.
2. Przetwarzamy Twoje dane w zakresie:
1. jeśli prześlesz nam wiadomość drogą mailową lub za pośrednictwem formularza to będziemy przetwarzać dane, które będą w tej wiadomości zawarte. Przekazanie nam danych w ten sposób następuje dobrowolnie. W każdym czasie możesz zwrócić się do nas z prośbą o usunięcie tych danych.
2. w przypadku założenia konta użytkownika w Panelu Klienta będziemy przetwarzać dane, które podasz w trakcie podpisywania umowy abonenckiej, a w szczególności: Twoje imię i nazwisko, adres e-mail, nr telefonu, adres zamieszkania. Podanie imienia, nazwiska, adresu e-mail stanowi warunek założenia konta użytkownika.
3. pozostałe dane, które Administrator przetwarza to adres IP i inne dane zapisywane w plikach cookies, które służą Administratorowi do analizy korzystania przez Ciebie i innych użytkowników Strony. Przetwarzanie danych w tym zakresie może zostać wstrzymane po złożeniu przez Ciebie sprzeciwu.
3. Serwis wykorzystuje dane osobowe w następujących celach:
- prowadzenie rozmów typu chat online,
- prowadzenie rozmów telefonicznych,
- obsługa zapytań przez formularz,
- realizacja zamówionych usług,
- prezentacja oferty lub informacji,
- wstępna zdalna weryfikacja możliwości technicznych świadczenia usługi w danej lokalizacji (podstawą prawną przetwarzania danych jest udzielona zgoda).
4. Serwis realizuje funkcje pozyskiwania informacji o użytkownikach i ich zachowaniu w następujący sposób:
- poprzez dobrowolnie wprowadzone w formularzach dane, które zostają wprowadzone do systemów Operatora.
- poprzez zapisywanie w urządzeniach końcowych plików cookie (tzw. „ciasteczka”).
## §4. Odbiorcy danych oraz zamiar przekazywania danych do Państwa spoza EOG lub organizacji międzynarodowej.
1. Odbiorcami Twoich danych osobowych są:
- podmiot dostarczający oprogramowanie do analizy ruchu naszej stronie (np. Google Analytics);
- podmiot dostarczający oprogramowanie obsługujące konta użytkowników;
- pośrednicy płatności internetowych z których usług korzystamy.
Twoje dane nie są przekazywane do Państwa spoza EOG lub organizacji międzynarodowej.
## §5. Termin przechowywania danych.
Jeśli przekazałeś nam swoje dane w wiadomości przesłanej drogą mailową lub za pośrednictwem formularza, to Twoje dane będziemy przetwarzać do momentu złożenia przez Ciebie żądania usunięcia tych danych lub sprzeciwu wobec przetwarzania, ale również w przypadku, w którym uznamy, że zrealizowaliśmy prawnie uzasadniony interes Administratora.
## §6. Prawa użytkowników.
1. W związku z przetwarzaniem Twoich danych przysługują Ci następujące prawa:
- dostępu do treści swoich danych oraz
- prawo ich sprostowania, usunięcia, ograniczenia przetwarzania,
- prawo do przenoszenia danych,
- prawo wniesienia sprzeciwu,
- prawo do cofnięcia zgody na ich przetwarzanie w dowolnym momencie i w dowolnej formie, chyba że przetwarzanie Twoich danych odbywa się w celu wykonywania umowy przez Administratora, w celu wywiązania się przez Administratora z obowiązków prawnych względem instytucji państwowych lub w celu realizacji prawnie uzasadnionych interesów Administratora.
2. Masz także prawo wniesienia skargi do Prezesa Urzędu Ochrony Danych Osobowych (na adres Urzędu Ochrony Danych Osobowych, ul. Stawki 2, 00-193 Warszawa).
3. Więcej informacji w przedmiocie ochrony danych osobowych możesz otrzymać na stronie internetowej Urzędu Ochrony Danych Osobowych: www.uodo.gov.pl.
## §7. Pliki cookies.
1. Pliki cookies (tzw. „ciasteczka”) stanowią dane informatyczne, w szczególności pliki tekstowe, które przechowywane są w urządzeniu końcowym, czyli Twoim komputerze, laptopie lub smartfonie, w zależności jakiego urządzenia używasz do oglądania Strony. Cookies zazwyczaj zawierają nazwę strony internetowej, z której pochodzą, czas przechowywania ich na urządzeniu końcowym oraz unikalny numer. Sam możesz zadecydować o formie wykorzystania cookies ustawienia te są dostępne w każdej przeglądarce internetowej.
2. Pliki cookies służą przede wszystkim Twojej wygodzie dzięki ich użyciu, znacznie skraca się czas ładowania strony podczas kolejnych odwiedzin.
3. Cechy plików cookies:
-dostosowują zawartość stron internetowych serwisu do Użytkownika. Optymalizują poruszanie Użytkownika na stronie, w szczególności pliki te pozwalają rozpoznać urządzenie, za pomocą, którego wyświetlana jest strona i tak ustawić jej parametry, by nawigacja nie sprawiała problemów i zoptymalizowana pod względem indywidualnych potrzeb Użytkownika.
- pozwalają wyświetlić stronę internetową, dostosowaną do Twoich indywidualnych potrzeb;
- tworzą statystyki, dzięki którym Administrator wie, które treści cieszą się zainteresowaniem Użytkowników. Pozwala to na ciągłe ulepszanie strony i taką konstrukcję treści, która będzie odpowiadała osobom odwiedzającym stronę.
- pozwalają na wielokrotne wykorzystanie opcji logowania przez Użytkownika, co jest dla Ciebie dużo wygodniejsze, gdyż podczas przemieszczania się po stronie i wielokrotnych odwiedzin nie jesteś zmuszony do każdorazowego wpisywania loginu i hasła.
4. W ramach Strony stosowane są dwa zasadnicze rodzaje plików cookies: „sesyjne” (session cookies) oraz „stałe” (persistent cookies). Cookies „sesyjne” są plikami tymczasowymi, które przechowywane są w urządzeniu końcowym Użytkownika do czasu wylogowania, opuszczenia strony internetowej lub wyłączenia oprogramowania (przeglądarki internetowej). „Stałe” pliki cookies przechowywane są w urządzeniu końcowym Użytkownika przez czas określony w parametrach plików cookies lub do czasu ich usunięcia przez Użytkownika.
5. Użytkownik może w każdym momencie usunąć pliki cookies.
6. Wprowadzone przez użytkownika ograniczenia stosowania plików cookies mogą wpłynąć na niektóre funkcjonalności Strony, znacznie utrudniając swobodne korzystanie ze wszystkich jego opcji.
7. Pliki cookies są zamieszczane w urządzeniu końcowym Użytkownika i wykorzystywane mogą być również przez współpracujących z operatorem Strony reklamodawców oraz innych partnerów. Użytkownik jednakże w każdej chwili może je usunąć.
8. Jeżeli masz wątpliwości, co do ustawień plików cookies, skontaktuj się z operatorem swojej przeglądarki internetowej.
9. Jeżeli nie zgadzasz się na wykorzystywanie cookies przez Stronę, opuść ją lub aktywuj odpowiednie ustawienia w swojej przeglądarce internetowej.

View File

@@ -1,33 +1,42 @@
company:
name: "FUZ Adam Rojek"
name: FUZ Adam Rojek
address:
line1: "ul. Świętojańska 46"
line2: "07-200 Wyszków"
line1: ul. Świętojańska 46
line2: 07-200 Wyszków
contact:
title: Kontakt
phones:
- "+48 606 369 650"
- "+48 (29) 643 80 55"
email: "biuro@fuz.pl"
- +48 606 369 650
- +48 (29) 643 80 55
email: biuro@fuz.pl
services:
title: "Mapa strony"
title: Mapa strony
items:
- name: "Internet Światłowodowy"
url: "/internet-swiatlowodowy"
title: "Przejdź do oferty Internetu światłowodowego"
- name: Internet Światłowodowy
url: /internet-swiatlowodowy
title: Przejdź do oferty Internetu światłowodowego
- name: "Internet + Telewizja"
url: "/internet-telewizja"
title: "Przejdź do oferty Internet + Telewizja w FUZ"
- name: Internet i Telewizja
url: /internet-telewizja
title: Przejdź do oferty Internet + Telewizja w FUZ
- name: "Telefon"
url: "/telefon"
title: "Przejdź do oferty telefonu"
- name: Telefon
url: /telefon
title: Przejdź do oferty telefonu
- name: "Zasięg sieci"
url: "/zasieg-sieci"
title: "Sprawdź zasięg sieci FUZ"
- name: Mapa zasiegu sieci
url: /mapa-zasiegu
title: Sprawdź zasięg sieci FUZ
- name: Kontakt
url: /kontakt
title: Skontaktuj się z nami
- name: Dokumenty, Regulaminy
url: /dokumenty
title: Dokumenty, regulaminy
recaptcha:
Ta strona jest chroniona przez reCAPTCHA.

View File

@@ -0,0 +1,20 @@
switches:
- id: budynek
etykieta: Rodzaj budynku
title: Zmień rodzaj budynku by zobaczyć odpowiednie ceny
domyslny: 2
opcje:
- id: 2
nazwa: Wielorodzinny
- id: 1
nazwa: Jednorodzinny
- id: umowa
etykieta: Okres umowy
title: Wybierz okres umowy by zobaczyć odpowiednie ceny
domyslny: 1
opcje:
- id: 1
nazwa: 24 miesiące
- id: 2
nazwa: Bezterminowa

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -148,7 +148,7 @@ export default function InternetAddonsModal({
onClick={(e) => e.stopPropagation()}
>
<div class="f-modal-inner">
<h2 class="f-modal-title">Konfiguracja usług dodatkowych</h2>
<h2 class="f-modal-title">{plan.name} konfiguracja usług</h2>
{/* INTERNET (fiber) jako akordeon */}
<div class="f-modal-section">

View File

@@ -0,0 +1,449 @@
import { useEffect, useMemo, useState } from "preact/hooks";
import "../../styles/modal.css";
import "../../styles/offers/offers-table.css";
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),
}));
}
function SectionAccordion({ title, right, open, onToggle, children }) {
return (
<div class={`f-accordion-item f-section-acc ${open ? "is-open" : ""}`}>
<button type="button" class="f-accordion-header" onClick={onToggle}>
<span class="f-accordion-header-left">
<span class="f-modal-phone-name">{title}</span>
</span>
<span class="f-accordion-header-right">
{right}
<span class="f-acc-chevron" aria-hidden="true">
{open ? "▲" : "▼"}
</span>
</span>
</button>
{open && <div class="f-accordion-body">{children}</div>}
</div>
);
}
export default function InternetAddonsModal({
isOpen,
onClose,
plan,
phoneCards = [], // telefon/cards.yaml -> cards[]
addons = [], // internet-swiatlowodowy/addons.yaml -> dodatki[]
cenaOpis = "zł/mies.",
}) {
const phonePlans = useMemo(() => mapPhoneYamlToPlans(phoneCards), [phoneCards]);
const addonsList = useMemo(() => normalizeAddons(addons), [addons]);
const [error, setError] = useState("");
const [selectedPhoneId, setSelectedPhoneId] = useState(null);
const [selectedQty, setSelectedQty] = useState({});
// ✅ sekcje jako akordeony (jedna otwarta naraz)
const [openSections, setOpenSections] = useState({
internet: true,
phone: false,
addons: false,
summary: false,
});
const toggleSection = (key) => {
setOpenSections((prev) => {
const nextOpen = !prev[key];
return {
internet: false,
phone: false,
addons: false,
summary: false,
[key]: nextOpen,
};
});
};
// reset wyborów po otwarciu / zmianie planu
useEffect(() => {
if (!isOpen) return;
setError("");
setSelectedPhoneId(null);
setSelectedQty({});
setOpenSections({ internet: true, phone: false, addons: false, summary: false });
}, [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) {
setSelectedPhoneId(null);
return;
}
setSelectedPhoneId(id);
};
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
class="f-modal-close"
type="button"
aria-label="Zamknij"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
>
</button>
<div
class="f-modal-panel f-modal-panel--compact"
onClick={(e) => e.stopPropagation()}
>
<div class="f-modal-inner">
<h2 class="f-modal-title">{plan.name} konfiguracja usług</h2>
{error && <p class="text-red-600">{error}</p>}
{/* ✅ INTERNET */}
<div class="f-modal-section">
<SectionAccordion
title={plan.name}
right={<span class="f-modal-phone-price">{money(basePrice)} {cenaOpis}</span>}
open={openSections.internet}
onToggle={() => toggleSection("internet")}
>
{plan.features?.length ? (
<ul class="f-card-features">
{plan.features.map((f, idx) => (
<li class="f-card-row" key={idx}>
<span class="f-card-label">{f.label}</span>
<span class="f-card-value">{formatFeatureValue(f.value)}</span>
</li>
))}
</ul>
) : (
<p class="opacity-80">Brak szczegółów.</p>
)}
</SectionAccordion>
</div>
{/* ✅ TELEFON */}
<div class="f-modal-section">
<SectionAccordion
title="Usługa telefoniczna"
right={
<span class="f-modal-phone-price">
{phonePrice ? `${money(phonePrice)} ${cenaOpis}` : "—"}
</span>
}
open={openSections.phone}
onToggle={() => toggleSection("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);
return (
<div class="f-accordion-item" 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">
{money(p.price_monthly)} {cenaOpis}
</span>
</button>
{/* pokazuj parametry tylko dla wybranego (czytelniej) */}
{isSelected && p.features?.length > 0 && (
<div class="f-accordion-body">
<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>
)}
</SectionAccordion>
</div>
{/* ✅ DODATKI */}
<div class="f-modal-section">
<SectionAccordion
title="Dodatkowe usługi"
right={
<span class="f-modal-phone-price">
{addonsPrice ? `${money(addonsPrice)} ${cenaOpis}` : "—"}
</span>
}
open={openSections.addons}
onToggle={() => toggleSection("addons")}
>
{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}>
<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>
<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>
<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>
)}
</SectionAccordion>
</div>
{/* ✅ PODSUMOWANIE */}
<div class="f-modal-section">
<SectionAccordion
title="Podsumowanie miesięczne"
right={<span class="f-modal-phone-price">{money(totalMonthly)} {cenaOpis}</span>}
open={openSections.summary}
onToggle={() => toggleSection("summary")}
>
<div class="f-summary">
<div class="f-summary-list">
<div class="f-summary-row">
<span>Internet</span>
<span>{money(basePrice)} {cenaOpis}</span>
</div>
<div class="f-summary-row">
<span>Telefon</span>
<span>{phonePrice ? `${money(phonePrice)} ${cenaOpis}` : "—"}</span>
</div>
<div class="f-summary-row">
<span>Dodatki</span>
<span>{addonsPrice ? `${money(addonsPrice)} ${cenaOpis}` : "—"}</span>
</div>
<div class="f-summary-total">
<span>Łącznie</span>
<span>{money(totalMonthly)} {cenaOpis}</span>
</div>
</div>
</div>
</SectionAccordion>
</div>
{/* ✅ zawsze widoczne w rogu */}
<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>
);
}

View File

@@ -30,7 +30,8 @@ function mapCardToPlan(card, match, labels, waluta) {
features.push({ label: "Umowa", value: labels?.umowa || "—" });
features.push({
label: "Aktywacja",
value: typeof match?.aktywacja === "number" ? formatMoney(match.aktywacja, waluta) : "—",
value:
typeof match?.aktywacja === "number" ? formatMoney(match.aktywacja, waluta) : "—",
});
return {
@@ -50,7 +51,8 @@ function mapCardToPlan(card, match, labels, waluta) {
* cenaOpis?: string,
* phoneCards?: any[],
* addons?: any[],
* addonsCenaOpis?: string
* addonsCenaOpis?: string,
* switches?: any[] // ✅ NOWE: przełączniki z YAML
* }} props
*/
export default function InternetCards({
@@ -62,10 +64,11 @@ export default function InternetCards({
phoneCards = [],
addons = [],
addonsCenaOpis = "zł/mies.",
switches = [], // ✅ NOWE
}) {
const visibleCards = Array.isArray(cards) ? cards : [];
// switch state (z /api/switches)
// switch state (teraz idzie z OffersSwitches na podstawie YAML)
const [selected, setSelected] = useState({});
const [labels, setLabels] = useState({});
@@ -100,7 +103,8 @@ export default function InternetCards({
</div>
)}
<OffersSwitches />
{/* ✅ TERAZ switcher dostaje dane z YAML */}
<OffersSwitches switches={switches} />
{visibleCards.length === 0 ? (
<p class="opacity-80">Brak dostępnych pakietów.</p>

View File

@@ -1,170 +1,79 @@
import { useEffect, useState } from "preact/hooks";
import { useEffect, useMemo, useState } from "preact/hooks";
function buildLabels(switches, selected) {
const out = {};
for (const sw of switches || []) {
const currentId = selected[sw.id];
const opt = sw.opcje?.find((op) => String(op.id) === String(currentId));
const currentId = selected?.[sw.id];
const opt = sw?.opcje?.find((op) => String(op.id) === String(currentId));
if (opt) out[sw.id] = opt.nazwa;
}
return out;
}
export default function OffersSwitches(props) {
const { switches, selected, onSwitch } = props || {};
const isControlled =
Array.isArray(switches) &&
switches.length > 0 &&
typeof onSwitch === "function";
const [autoSwitches, setAutoSwitches] = useState([]);
const [autoSelected, setAutoSelected] = useState({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (isControlled) return;
let cancelled = false;
async function load() {
setLoading(true);
setError("");
try {
const res = await fetch("/api/switches");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
const sws = Array.isArray(json.data) ? json.data : [];
if (cancelled) return;
const initial = {};
for (const sw of sws) {
if (sw.domyslny != null) initial[sw.id] = sw.domyslny;
else if (sw.opcje?.length) initial[sw.id] = sw.opcje[0].id;
}
const labels = buildLabels(sws, initial);
setAutoSwitches(sws);
setAutoSelected(initial);
window.fuzSwitchState = {
selected: initial,
labels,
};
window.dispatchEvent(
new CustomEvent("fuz:switch-change", {
detail: {
id: null,
value: null,
selected: initial,
labels,
},
}),
);
} catch (err) {
console.error("Błąd pobierania przełączników:", err);
if (!cancelled) setError("Nie udało się załadować przełączników.");
} finally {
if (!cancelled) setLoading(false);
}
export default function OffersSwitches({ switches = [] }) {
const initialSelected = useMemo(() => {
const init = {};
for (const sw of switches) {
if (!sw?.id) continue;
if (sw.domyslny != null) init[sw.id] = sw.domyslny;
else if (sw.opcje?.length) init[sw.id] = sw.opcje[0].id;
}
return init;
}, [switches]);
load();
return () => {
cancelled = true;
};
}, [isControlled]);
const effectiveSwitches = isControlled ? switches : autoSwitches;
const effectiveSelected = isControlled ? selected || {} : autoSelected;
const handleClick = (id, value) => {
if (isControlled) {
onSwitch(id, value);
} else {
setAutoSelected((prev) => {
const next = { ...prev, [id]: value };
const labels = buildLabels(autoSwitches, next);
window.fuzSwitchState = {
selected: next,
labels,
};
window.dispatchEvent(
new CustomEvent("fuz:switch-change", {
detail: {
id,
value,
selected: next,
labels,
},
}),
);
return next;
});
}
};
const [selected, setSelected] = useState(initialSelected);
// gdy switches się zmienią (np. hot reload) zresetuj sensownie
useEffect(() => {
if (!isControlled) return;
if (!Array.isArray(switches) || !switches.length) return;
setSelected(initialSelected);
}, [initialSelected]);
const safeSelected = selected || {};
const labels = buildLabels(switches, safeSelected);
// globalny stan + event (tak jak masz teraz)
useEffect(() => {
const labels = buildLabels(switches, selected);
window.fuzSwitchState = {
selected: safeSelected,
labels,
};
window.fuzSwitchState = { selected, labels };
window.dispatchEvent(
new CustomEvent("fuz:switch-change", {
detail: {
id: null,
value: null,
selected: safeSelected,
labels,
},
detail: { id: null, value: null, selected, labels },
}),
);
}, [isControlled, switches, selected]);
}, [switches, selected]);
if (!isControlled && loading) {
return (
<div class="f-switches-wrapper">
<p>Ładowanie opcji przełączników...</p>
</div>
);
}
const handleClick = (id, value) => {
setSelected((prev) => {
const next = { ...prev, [id]: value };
const labels = buildLabels(switches, next);
if (!isControlled && error) {
return (
<div class="f-switches-wrapper">
<p class="text-red-600">{error}</p>
</div>
);
}
window.fuzSwitchState = { selected: next, labels };
if (!effectiveSwitches.length) return null;
window.dispatchEvent(
new CustomEvent("fuz:switch-change", {
detail: { id, value, selected: next, labels },
}),
);
return next;
});
};
if (!Array.isArray(switches) || !switches.length) return null;
return (
<div class="f-switches-wrapper">
{effectiveSwitches.map((sw) => (
<div class="f-switch-group">
{sw.opcje.map((op) => (
{switches.map((sw) => (
<div class="f-switch-group" key={sw.id}>
{/* (opcjonalnie) etykieta */}
{/* <div class="f-switch-label">{sw.etykieta}</div> */}
{sw.opcje?.map((op) => (
<button
key={`${sw.id}:${op.id}`}
type="button"
class={`f-switch ${String(effectiveSelected[sw.id]) === String(op.id)
? "active"
: ""
}`}
class={`f-switch ${
String(selected?.[sw.id]) === String(op.id) ? "active" : ""
}`}
onClick={() => handleClick(sw.id, op.id)}
title={sw.title}
>

View File

@@ -0,0 +1,704 @@
import { useEffect, useMemo, useState } from "preact/hooks";
import "../../styles/modal.css";
import "../../styles/offers/offers-table.css";
function formatFeatureValue(val) {
if (val === true || val === "true") return "✓";
if (val === false || val === "false" || val == null) return "✕";
return val;
}
function money(amount) {
const n = Number(amount || 0);
return n.toFixed(2).replace(".", ",");
}
/** telefon z YAML (phone/cards.yaml -> cards[]) => { id, name, price_monthly, features[] } */
function mapPhoneYamlToPlans(phoneCards) {
const list = Array.isArray(phoneCards) ? phoneCards : [];
return list
.filter((c) => c?.widoczny !== false)
.map((c, idx) => ({
id: String(c?.id ?? c?.nazwa ?? idx),
name: c?.nazwa ?? "—",
price_monthly: Number(c?.cena?.wartosc ?? 0),
features: (Array.isArray(c?.parametry) ? c.parametry : []).map((p) => ({
label: p.label,
value: p.value,
})),
}));
}
/** dekodery z YAML */
function normalizeDecoders(list) {
const arr = Array.isArray(list) ? list : [];
return arr
.filter((d) => d?.id && d?.nazwa)
.map((d) => ({
id: String(d.id),
nazwa: String(d.nazwa),
opis: d.opis ? String(d.opis) : "",
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) : "",
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);
for (const row of c) {
const pk = row?.pakiety;
if (!Array.isArray(pk)) continue;
if (pk.some((p) => normKey(p) === wanted)) return row;
}
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;
}
function isTvAddonAvailableForPkg(addon, pkg) {
if (!pkg) return false;
const v = pickTvVariant(addon, String(pkg?.name ?? ""));
return !!v;
}
function hasTvTermPricing(addon, pkg) {
const c = addon?.cena;
if (!Array.isArray(c)) return false;
const v = pickTvVariant(addon, String(pkg?.name ?? ""));
if (!v || typeof v !== "object") return false;
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;
if (typeof c === "number") return c;
if (typeof c === "string" && c.trim() !== "" && !Number.isNaN(Number(c))) return Number(c);
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;
if (v.bezterminowo != null) return Number(v.bezterminowo) || 0;
if (v["12m"] != null) return Number(v["12m"]) || 0;
return 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;
}
/** ✅ Sekcja-akordeon (jak w internet modal) */
function SectionAccordion({ title, right, open, onToggle, children }) {
return (
<div class={`f-accordion-item f-section-acc ${open ? "is-open" : ""}`}>
<button type="button" class="f-accordion-header" onClick={onToggle}>
<span class="f-accordion-header-left">
<span class="f-modal-phone-name">{title}</span>
</span>
<span class="f-accordion-header-right">
{right}
<span class="f-acc-chevron" aria-hidden="true">
{open ? "▲" : "▼"}
</span>
</span>
</button>
{open && <div class="f-accordion-body">{children}</div>}
</div>
);
}
export default function JamboxAddonsModal({
isOpen,
onClose,
pkg,
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]);
const tvAddonsVisible = useMemo(() => {
if (!pkg) return [];
return tvAddonsList.filter((a) => isTvAddonAvailableForPkg(a, pkg));
}, [tvAddonsList, pkg]);
// wybory
const [selectedPhoneId, setSelectedPhoneId] = useState(null);
const [openPhoneId, setOpenPhoneId] = useState(null);
const [selectedDecoderId, setSelectedDecoderId] = useState(null);
const [selectedQty, setSelectedQty] = useState({});
const [tvTerm, setTvTerm] = useState({}); // { [id]: "12m" | "bezterminowo" }
// ✅ sekcje (jedna otwarta naraz)
const [openSections, setOpenSections] = useState({
base: true,
decoder: false,
tv: false,
phone: false,
addons: false,
summary: false,
});
const toggleSection = (key) => {
setOpenSections((prev) => {
const nextOpen = !prev[key];
return {
base: false,
decoder: false,
tv: false,
phone: false,
addons: false,
summary: false,
[key]: nextOpen,
};
});
};
// reset po otwarciu / zmianie pakietu
useEffect(() => {
if (!isOpen) return;
setSelectedPhoneId(null);
setOpenPhoneId(null);
setSelectedDecoderId(null);
setSelectedQty({});
setTvTerm({});
setOpenSections({
base: true,
decoder: false,
tv: false,
phone: false,
addons: false,
summary: false,
});
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]);
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]);
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) {
setSelectedPhoneId(null);
setOpenPhoneId(null);
return;
}
setSelectedPhoneId(id);
setOpenPhoneId((prev) => (String(prev) === String(id) ? null : id));
};
const toggleCheckboxAddon = (id) => {
setSelectedQty((prev) => {
const next = { ...prev };
next[id] = (next[id] || 0) > 0 ? 0 : 1;
return next;
});
};
const setQtyAddon = (id, qty, min, max) => {
const safe = Math.max(min, Math.min(max, qty));
setSelectedQty((prev) => ({ ...prev, [id]: safe }));
};
const renderAddonRow = (a, isTv = false) => {
const qty = Number(selectedQty[a.id] || 0);
const isQty = a.typ === "quantity" || a.ilosc === true;
const termPricing = isTv && hasTvTermPricing(a, pkg);
const term = tvTerm[a.id] || "12m";
const unit = getAddonUnitPrice(a, pkg, termPricing ? term : null);
if (!isQty) {
return (
<label class="f-addon-item" key={(isTv ? "tv-" : "a-") + a.id}>
<div class="f-addon-checkbox">
<input
type="checkbox"
checked={qty > 0}
onChange={() => toggleCheckboxAddon(a.id)}
/>
</div>
<div class="f-addon-main">
<div class="f-addon-name">{a.nazwa}</div>
{a.opis && <div class="f-addon-desc">{a.opis}</div>}
{termPricing && (
<div class="mt-2 flex flex-wrap gap-3 text-sm" onClick={(e) => e.stopPropagation()}>
<label class="inline-flex items-center gap-2">
<input
type="radio"
name={`term-${a.id}`}
checked={(tvTerm[a.id] || "12m") === "12m"}
onChange={() => setTvTerm((p) => ({ ...p, [a.id]: "12m" }))}
/>
<span>12 miesięcy</span>
</label>
<label class="inline-flex items-center gap-2">
<input
type="radio"
name={`term-${a.id}`}
checked={(tvTerm[a.id] || "12m") === "bezterminowo"}
onChange={() => setTvTerm((p) => ({ ...p, [a.id]: "bezterminowo" }))}
/>
<span>Bezterminowo</span>
</label>
</div>
)}
</div>
<div class="f-addon-price">
{money(unit)} {cenaOpis}
</div>
</label>
);
}
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;
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>
<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-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>
<div class="f-addon-price">
<div>
{money(unit)} {cenaOpis}
</div>
<div class="f-addon-price-total">{qty > 0 ? `${money(lineTotal)} ${cenaOpis}` : "—"}</div>
</div>
</div>
);
};
return (
<div class="f-modal-overlay" onClick={onClose}>
<button
class="f-modal-close"
type="button"
aria-label="Zamknij"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
>
</button>
<div class="f-modal-panel f-modal-panel--compact" onClick={(e) => e.stopPropagation()}>
<div class="f-modal-inner">
<h2 class="f-modal-title">{pkg.name} konfiguracja usług</h2>
{/* ✅ PAKIET (sekcja) */}
<div class="f-modal-section">
<SectionAccordion
title={pkg.name}
right={<span class="f-modal-phone-price">{money(basePrice)} {cenaOpis}</span>}
open={openSections.base}
onToggle={() => toggleSection("base")}
>
{pkg.features?.length ? (
<ul class="f-card-features">
{pkg.features.map((f, idx) => (
<li class="f-card-row" key={idx}>
<span class="f-card-label">{f.label}</span>
<span class="f-card-value">{formatFeatureValue(f.value)}</span>
</li>
))}
</ul>
) : (
<p class="opacity-80">Brak szczegółów pakietu.</p>
)}
</SectionAccordion>
</div>
{/* ✅ DEKODER (sekcja) */}
<div class="f-modal-section">
<SectionAccordion
title="Wybór dekodera"
right={
<span class="f-modal-phone-price">
{decoderPrice ? `${money(decoderPrice)} ${cenaOpis}` : "—"}
</span>
}
open={openSections.decoder}
onToggle={() => toggleSection("decoder")}
>
{decodersList.length === 0 ? (
<p>Brak dostępnych dekoderów.</p>
) : (
<div class="f-radio-list">
{decodersList.map((d) => {
const isSelected = String(selectedDecoderId) === String(d.id);
return (
<label class={`f-radio-item ${isSelected ? "is-selected" : ""}`} key={d.id}>
<div class="f-radio-check">
<input
type="radio"
name="decoder"
checked={isSelected}
onChange={() => setSelectedDecoderId(String(d.id))}
/>
</div>
<div class="f-radio-main">
<div class="f-radio-name">{d.nazwa}</div>
{d.opis && <div class="f-addon-desc">{d.opis}</div>}
</div>
<div class="f-radio-price">
{money(d.cena)} {cenaOpis}
</div>
</label>
);
})}
</div>
)}
</SectionAccordion>
</div>
{/* ✅ TV ADDONS (sekcja) */}
<div class="f-modal-section">
<SectionAccordion
title="Pakiety dodatkowe TV"
right={
<span class="f-modal-phone-price">
{tvAddonsPrice ? `${money(tvAddonsPrice)} ${cenaOpis}` : "—"}
</span>
}
open={openSections.tv}
onToggle={() => toggleSection("tv")}
>
{tvAddonsVisible.length === 0 ? (
<p>Brak pakietów dodatkowych TV.</p>
) : (
<div class="f-addon-list">{tvAddonsVisible.map((a) => renderAddonRow(a, true))}</div>
)}
</SectionAccordion>
</div>
{/* ✅ TELEFON (sekcja) */}
<div class="f-modal-section">
<SectionAccordion
title="Usługa telefoniczna"
right={
<span class="f-modal-phone-price">
{phonePrice ? `${money(phonePrice)} ${cenaOpis}` : "—"}
</span>
}
open={openSections.phone}
onToggle={() => toggleSection("phone")}
>
{phonePlans.length === 0 ? (
<p>Brak dostępnych pakietów telefonicznych.</p>
) : (
<div class="f-radio-list">
{/* brak telefonu */}
<label class={`f-radio-item ${selectedPhoneId === null ? "is-selected" : ""}`}>
<div class="f-radio-check">
<input
type="radio"
name="phone-plan"
checked={selectedPhoneId === null}
onChange={() => handlePhoneSelect(null)}
/>
</div>
<div class="f-radio-main">
<div class="f-radio-name">Nie potrzebuję telefonu</div>
</div>
<div class="f-radio-price">0,00 {cenaOpis}</div>
</label>
{/* pakiety */}
{phonePlans.map((p) => {
const isSelected = String(selectedPhoneId) === String(p.id);
return (
<div class="f-radio-block" key={p.id}>
<label class={`f-radio-item ${isSelected ? "is-selected" : ""}`}>
<div class="f-radio-check">
<input
type="radio"
name="phone-plan"
checked={isSelected}
onChange={() => handlePhoneSelect(p.id)}
/>
</div>
<div class="f-radio-main">
<div class="f-radio-name">{p.name}</div>
</div>
<div class="f-radio-price">
{money(p.price_monthly)} {cenaOpis}
</div>
</label>
{/* ✅ detale ZAWSZE widoczne */}
{p.features?.length > 0 && (
<div class="f-radio-details">
<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>
)}
</SectionAccordion>
</div>
{/* ✅ DODATKI (sekcja) */}
<div class="f-modal-section">
<SectionAccordion
title="Dodatkowe usługi"
right={
<span class="f-modal-phone-price">
{addonsOnlyPrice ? `${money(addonsOnlyPrice)} ${cenaOpis}` : "—"}
</span>
}
open={openSections.addons}
onToggle={() => toggleSection("addons")}
>
{addonsList.length === 0 ? (
<p>Brak usług dodatkowych.</p>
) : (
<div class="f-addon-list">{addonsList.map((a) => renderAddonRow(a, false))}</div>
)}
</SectionAccordion>
</div>
{/* ✅ PODSUMOWANIE (sekcja) */}
<div class="f-modal-section">
<SectionAccordion
title="Podsumowanie miesięczne"
right={<span class="f-modal-phone-price">{money(totalMonthly)} {cenaOpis}</span>}
open={openSections.summary}
onToggle={() => toggleSection("summary")}
>
<div class="f-summary">
<div class="f-summary-list">
<div class="f-summary-row">
<span>Pakiet</span>
<span>{money(basePrice)} {cenaOpis}</span>
</div>
<div class="f-summary-row">
<span>Telefon</span>
<span>{phonePrice ? `${money(phonePrice)} ${cenaOpis}` : "—"}</span>
</div>
<div class="f-summary-row">
<span>Dekoder</span>
<span>{decoderPrice ? `${money(decoderPrice)} ${cenaOpis}` : "—"}</span>
</div>
<div class="f-summary-row">
<span>Dodatki TV</span>
<span>{tvAddonsPrice ? `${money(tvAddonsPrice)} ${cenaOpis}` : "—"}</span>
</div>
<div class="f-summary-row">
<span>Dodatkowe usługi</span>
<span>{addonsOnlyPrice ? `${money(addonsOnlyPrice)} ${cenaOpis}` : "—"}</span>
</div>
<div class="f-summary-total">
<span>Łącznie</span>
<span>{money(totalMonthly)} {cenaOpis}</span>
</div>
</div>
</div>
</SectionAccordion>
</div>
{/* ✅ pływająca suma jak w internecie */}
{/* <div class="f-floating-total" onClick={(e) => e.stopPropagation()}>
<div class="f-floating-total-inner">
<span class="f-floating-total-label">Suma</span>
<span class="f-floating-total-value">
{money(totalMonthly)} {cenaOpis}
</span>
</div>
</div> */}
<div class="f-floating-total" onClick={(e) => e.stopPropagation()}>
<div class="f-floating-total-circle" role="status" aria-label="Cena miesięczna">
<span class="f-floating-total-unit">
Razem
</span>
<span class="f-floating-total-amount">
{money(totalMonthly)}
</span>
<span class="f-floating-total-unit">
{cenaOpis}
</span>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -3,7 +3,7 @@ import "../../styles/offers/offers-table.css";
import OffersSwitches from "../OffersSwitches.jsx";
import JamboxChannelsModal from "./JamboxChannelsModal.jsx";
import JamboxAddonsModal from "./JamboxAddonsModal.jsx";
import JamboxAddonsModal from "./JamboxAddonsModalCompact.jsx";
import Markdown from "../Markdown.jsx";
function formatMoney(amount, currency = "PLN") {
@@ -50,10 +50,8 @@ function toFeatureRows(params) {
* tvAddons?: any[],
* addons?: Addon[],
* decoders?: Decoder[],
*
* addonsCenaOpis?: string,
*
* // ✅ NOWE
* channels?: ChannelYaml[],
* channels?: ChannelYaml[]
* }} props
*/
@@ -70,6 +68,7 @@ export default function JamboxCards({
addons = [],
decoders = [],
channels = [],
switches = [],
}) {
const visibleCards = Array.isArray(cards) ? cards : [];
const wsp = Array.isArray(internetWspolne) ? internetWspolne : [];
@@ -110,7 +109,7 @@ export default function JamboxCards({
</div>
)}
<OffersSwitches />
<OffersSwitches switches={switches} />
{visibleCards.length === 0 ? (
<p class="opacity-80">Brak pakietów do wyświetlenia.</p>

View File

@@ -35,7 +35,7 @@ export default function JamboxChannelsModal({ isOpen, onClose, pkg }) {
try {
// ✅ NOWE API: po nazwie pakietu
const params = new URLSearchParams({ package: String(pkg.name) });
const res = await fetch(`/api/jambox/package-channels?${params.toString()}`);
const res = await fetch(`/api/jambox/jambox-channels-package?${params.toString()}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();

View File

@@ -32,7 +32,7 @@ export default function JamboxChannelsSearch() {
params.set("limit", "80");
const res = await fetch(
`/api/jambox/channels-search?${params.toString()}`,
`/api/jambox/jambox-channels-search?${params.toString()}`,
{
signal: ac.signal,
headers: { Accept: "application/json" },
@@ -45,7 +45,7 @@ export default function JamboxChannelsSearch() {
setItems(Array.isArray(json.data) ? json.data : []);
} catch (e) {
if (e?.name !== "AbortError") {
console.error("channels search:", e);
console.error("jambox-channels-search:", e);
setErr("Błąd wyszukiwania.");
}
} finally {

View File

@@ -6,7 +6,7 @@ export type DocYaml = {
title: string;
visible?: boolean;
intro?: string;
content: string; // markdown
content: string;
};
export type DocEntry = DocYaml & {
@@ -32,7 +32,6 @@ export function listDocuments(): DocEntry[] {
const slug = file.replace(/\.ya?ml$/i, "");
// minimalna walidacja, żeby nic nie wybuchało
if (!data.title || typeof data.title !== "string") continue;
if (!data.content || typeof data.content !== "string") continue;
@@ -50,7 +49,6 @@ export function listDocuments(): DocEntry[] {
}
export function getDocumentBySlug(slug: string): DocEntry | null {
// akceptuj .yaml i .yml
const candidates = [`${slug}.yaml`, `${slug}.yml`];
for (const file of candidates) {

View File

@@ -1,117 +0,0 @@
import { XMLParser } from "fast-xml-parser";
const URL = "https://www.jambox.pl/xml/mozliwosci.xml";
// mały cache w pamięci procesu (SSR)
let cache: { ts: number; items: Feature[] } | null = null;
export type Feature = {
id: string;
title: string;
icon?: string;
teaser?: string;
description?: string;
screens: string[];
};
function toArray<T>(v: T | T[] | undefined | null): T[] {
if (!v) return [];
return Array.isArray(v) ? v : [v];
}
// minimalne “od-HTML-owanie” dla &nbsp; itd.
function cleanHtmlText(s?: string): string {
if (!s) return "";
return String(s)
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\s+/g, " ")
.trim();
}
function slugify(s: string) {
return cleanHtmlText(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, "");
}
function extractUrlsFromString(s: string): string[] {
// wyciąga URL-e zarówno z czystego tekstu, jak i z HTML (<div>url</div>)
const urls = s.match(/https?:\/\/[^\s<"]+/g) ?? [];
return urls.map((u) => u.trim());
}
function extractScreens(screen: any): string[] {
if (!screen) return [];
// przypadek: <screen>https://...png</screen>
if (typeof screen === "string") return extractUrlsFromString(screen);
// przypadek: <screen><div class="field-item">URL</div>...</screen>
// fast-xml-parser zrobi np. screen.div albo screen["div"]
const divs = (screen as any)?.div;
if (divs) {
return toArray(divs)
.map((d) => {
if (typeof d === "string") return d;
// czasem parser daje obiekt z #text
return d?.["#text"] ?? "";
})
.flatMap((x) => extractUrlsFromString(String(x)))
.filter(Boolean);
}
// fallback: spróbuj stringifikacji i regex na URL
return extractUrlsFromString(JSON.stringify(screen));
}
export async function fetchMozliwosci(ttlMs = 60_000): Promise<Feature[]> {
const now = Date.now();
if (cache && now - cache.ts < ttlMs) return cache.items;
const res = await fetch(URL, {
headers: { accept: "application/xml,text/xml,*/*" },
});
if (!res.ok) throw new Error(`JAMBOX XML: HTTP ${res.status}`);
const xml = await res.text();
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: "@_",
trimValues: true,
});
const parsed = parser.parse(xml);
const nodes = toArray(parsed?.xml?.node ?? parsed?.node);
const items: Feature[] = nodes
.map((n: any) => {
const title = cleanHtmlText(n?.title ?? "");
const teaser = cleanHtmlText(n?.teaser ?? "");
const description = cleanHtmlText(n?.description ?? "");
const icon = typeof n?.icon === "string" ? n.icon.trim() : undefined;
const screens = extractScreens(n?.screen);
const id = slugify(title || "feature");
return { id, title, icon, teaser, description, screens };
})
.filter((x) => x.title);
cache = { ts: now, items };
return items;
}

View File

@@ -1,50 +1,163 @@
import nodemailer from "nodemailer";
function esc(str = "") {
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function buildHtmlMail(form) {
const when = new Date().toLocaleString("pl-PL");
return `<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Nowa wiadomość FUZ</title>
</head>
<body style="margin:0;padding:0;background:#f5f7fa;font-family:Arial,Helvetica,sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="background:#f5f7fa;">
<tr>
<td align="center" style="padding:24px;">
<table width="600" cellpadding="0" cellspacing="0" role="presentation"
style="background:#ffffff;border-radius:14px;overflow:hidden;box-shadow:0 8px 24px rgba(0,0,0,0.08);">
<!-- Header -->
<tr>
<td style="background:#0066ff;color:#ffffff;padding:20px 24px;">
<div style="font-size:12px;opacity:0.9;margin-bottom:6px;">FUZ • Formularz kontaktowy</div>
<div style="font-size:20px;font-weight:700;line-height:1.2;">📩 Nowa wiadomość</div>
</td>
</tr>
<!-- Content -->
<tr>
<td style="padding:24px;">
<div style="font-size:14px;color:#111827;margin-bottom:14px;">
Poniżej szczegóły zgłoszenia:
</div>
<table width="100%" cellpadding="0" cellspacing="0" role="presentation"
style="font-size:14px;color:#111827;border-collapse:collapse;">
<tr>
<td style="padding:8px 0;width:160px;color:#374151;"><strong>Imię</strong></td>
<td style="padding:8px 0;">${esc(form.firstName)}</td>
</tr>
<tr>
<td style="padding:8px 0;color:#374151;"><strong>Nazwisko</strong></td>
<td style="padding:8px 0;">${esc(form.lastName)}</td>
</tr>
<tr>
<td style="padding:8px 0;color:#374151;"><strong>Email</strong></td>
<td style="padding:8px 0;">
<a href="mailto:${esc(form.email)}" style="color:#0066ff;text-decoration:none;">
${esc(form.email)}
</a>
</td>
</tr>
<tr>
<td style="padding:8px 0;color:#374151;"><strong>Telefon</strong></td>
<td style="padding:8px 0;">${esc(form.phone)}</td>
</tr>
<tr>
<td style="padding:8px 0;color:#374151;"><strong>Temat</strong></td>
<td style="padding:8px 0;">${esc(form.subject)}</td>
</tr>
</table>
<div style="height:1px;background:#e5e7eb;margin:18px 0;"></div>
<div style="font-size:14px;color:#111827;margin:0 0 8px;font-weight:700;">
Wiadomość:
</div>
<div style="font-size:14px;color:#111827;white-space:pre-line;line-height:1.6;
background:#f8fafc;border:1px solid #e5e7eb;border-radius:12px;padding:14px;">
${esc(form.message)}
</div>
<div style="margin-top:16px;font-size:12px;color:#6b7280;">
Wysłano: ${when}
</div>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background:#f1f5f9;padding:14px 24px;font-size:12px;color:#6b7280;">
To jest automatyczna wiadomość wygenerowana przez formularz na stronie FUZ.
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
export async function POST({ request }) {
try {
const form = await request.json();
// (opcjonalnie) prosta walidacja minimum:
if (!form?.email || !form?.message) {
return new Response(JSON.stringify({ ok: false, error: "Brak danych" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
const transporter = nodemailer.createTransport({
host: import.meta.env.SMTP_HOST,
port: Number(import.meta.env.SMTP_PORT),
secure: true, // true = 465, false = 587
secure: true,
auth: {
user: import.meta.env.SMTP_USER,
pass: import.meta.env.SMTP_PASS,
},
// ⚠️ tylko jeśli masz self-signed / dziwny cert
tls: {
rejectUnauthorized: false,
},
// Uwaga: lepiej NIE wyłączać TLS w prod, ale zostawiam zgodnie z Twoją wersją
tls: { rejectUnauthorized: false },
});
const subject = `FUZ: wiadomość od ${form.firstName || ""} ${form.lastName || ""}`.trim();
const text = `
Imię: ${form.firstName || ""}
Nazwisko: ${form.lastName || ""}
Email: ${form.email || ""}
Telefon: ${form.phone || ""}
Temat: ${form.subject || ""}
Wiadomość:
${form.message || ""}
`.trim();
const html = buildHtmlMail(form);
await transporter.sendMail({
from: `"${import.meta.env.SMTP_FROM_NAME}" <${import.meta.env.SMTP_USER}>`,
to: import.meta.env.SMTP_TO,
subject: `FUZ: wiadomość od ${form.firstName} ${form.lastName}`,
text: `
Imię: ${form.firstName}
Nazwisko: ${form.lastName}
Email: ${form.email}
Telefon: ${form.phone}
Temat: ${form.subject}
Wiadomość:
${form.message}
`.trim(),
subject,
text,
html,
replyTo: form.email ? String(form.email) : undefined, // wygodne do "Odpowiedz"
});
return new Response(
JSON.stringify({ ok: true }),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("MAIL ERROR:", error);
return new Response(
JSON.stringify({ ok: false }),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
return new Response(JSON.stringify({ ok: false }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
}

View File

@@ -1,83 +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 addonsRows = db
.prepare(
`
SELECT id, name, type, description
FROM internet_addons
ORDER BY id
`
)
.all();
const optionsRows = db
.prepare(
`
SELECT id, addon_id, code, name, price
FROM internet_addon_options
ORDER BY addon_id, id
`
)
.all();
const byAddon = new Map();
for (const addon of addonsRows) {
byAddon.set(addon.id, {
id: addon.id,
name: addon.name,
type: addon.type, // 'checkbox' / 'select'
description: addon.description || "",
options: [],
});
}
for (const opt of optionsRows) {
const parent = byAddon.get(opt.addon_id);
if (!parent) continue;
parent.options.push({
id: opt.id,
code: opt.code,
name: opt.name,
price: opt.price,
});
}
const data = Array.from(byAddon.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/internet/addons:", err);
return new Response(
JSON.stringify({
ok: false,
error: err.message || "DB_ERROR",
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
);
} finally {
db.close();
}
}

View File

@@ -1,111 +0,0 @@
// src/pages/api/internet/plans.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/internet/plans?building=1|2&contract=1|2
*/
export function GET({ url }) {
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 stmt = db.prepare(
`
SELECT
p.id AS plan_id,
p.name AS plan_name,
p.popular AS plan_popular,
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 internet_plans p
LEFT JOIN internet_plan_prices pr
ON pr.plan_id = p.id
AND pr.building_type = ?
AND pr.contract_type = ?
LEFT JOIN internet_plan_feature_values fv
ON fv.plan_id = p.id
LEFT JOIN internet_features f
ON f.id = fv.feature_id
ORDER BY p.id ASC, f.id ASC;
`.trim()
);
const rows = stmt.all(building, contract);
// grupowanie do struktury: jeden plan = jedna karta
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,
price_installation: row.price_installation,
features: [], // później wypełniamy
});
}
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,
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/internet/plans:", err);
return new Response(
JSON.stringify({ ok: false, error: "DB_ERROR" }),
{
status: 500,
headers: { "Content-Type": "application/json; charset=utf-8" },
}
);
} finally {
db.close();
}
}

View File

@@ -2,10 +2,6 @@ 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" },
@@ -15,25 +11,16 @@ const FEEDS = [
{ 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,*/*" },
@@ -155,17 +142,9 @@ async function downloadLogoAsBase64(url) {
}
}
/* =====================
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)
@@ -174,7 +153,7 @@ export async function POST() {
opis = COALESCE(excluded.opis, jambox_channels.opis)
`);
const logoCache = new Map(); // nazwa(lower) -> base64 | null
const logoCache = new Map();
const rows = [];
try {
@@ -220,7 +199,7 @@ export async function POST() {
{ headers: { "content-type": "application/json; charset=utf-8" } }
);
} catch (e) {
console.error("import jambox_channels:", e);
console.error("import jambox_channels:", e);
return new Response(
JSON.stringify({ ok: false, error: String(e.message || e) }),
{ status: 500 }

View File

@@ -1,30 +1,15 @@
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[] {
function toArray(v) {
if (!v) return [];
return Array.isArray(v) ? v : [v];
}
/* =======================
HTML / XML HELPERS
======================= */
function decodeEntities(input: string): string {
function decodeEntities(input) {
if (!input) return "";
let s = String(input)
@@ -35,9 +20,7 @@ function decodeEntities(input: string): string {
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">");
s = s.replace(/&#(\d+);/g, (_, d) =>
String.fromCodePoint(Number(d))
);
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))
);
@@ -45,30 +28,22 @@ function decodeEntities(input: string): string {
return s;
}
/**
* Parsuje HTML:
* - <p>, <div>, <br> zwykłe nowe linie
* - <ul>/<ol><li> markdown lista
*/
function parseHtmlContent(input?: string): ContentBlock[] {
function parseHtmlContent(input) {
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
@@ -77,11 +52,11 @@ function parseHtmlContent(input?: string): ContentBlock[] {
.replace(/\n{3,}/g, "\n\n")
.trim();
const blocks: ContentBlock[] = [];
const blocks = [];
const lines = s.split("\n");
let textBuf: string[] = [];
let listBuf: string[] | null = null;
let textBuf = [];
let listBuf = null;
const flushText = () => {
const txt = textBuf.join("\n").trim();
@@ -132,12 +107,11 @@ function parseHtmlContent(input?: string): ContentBlock[] {
return blocks;
}
function blocksToMarkdown(blocks: ContentBlock[]): string {
const out: string[] = [];
function blocksToMarkdown(blocks) {
const out = [];
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")
@@ -157,20 +131,15 @@ function blocksToMarkdown(blocks: ContentBlock[]): string {
return out.join("\n\n").trim();
}
/* =======================
SCREEN / YAML
======================= */
function extractUrlsFromString(s: string): string[] {
return s.match(/https?:\/\/[^\s<"]+/g) ?? [];
function extractUrlsFromString(s) {
return String(s).match(/https?:\/\/[^\s<"]+/g) ?? [];
}
function extractScreens(screen: any): string[] {
function extractScreens(screen) {
if (!screen) return [];
if (typeof screen === "string") return extractUrlsFromString(screen);
const divs = (screen as any)?.div;
const divs = screen?.div;
if (divs) {
return toArray(divs)
.map((d) => (typeof d === "string" ? d : d?.["#text"] ?? ""))
@@ -180,19 +149,19 @@ function extractScreens(screen: any): string[] {
return extractUrlsFromString(JSON.stringify(screen));
}
function yamlQuote(v: string): string {
function yamlQuote(v) {
return `"${String(v).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
}
function toYaml(sections: Section[]): string {
const out: string[] = ["sections:"];
function toYaml(sections) {
const out = ["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")) {
for (const line of String(s.content).split("\n")) {
out.push(` ${line}`);
}
@@ -202,11 +171,7 @@ function toYaml(sections: Section[]): string {
return out.join("\n").trimEnd() + "\n";
}
/* =======================
API
======================= */
export const POST: APIRoute = async () => {
export async function POST() {
const res = await fetch(URL, {
headers: { accept: "application/xml,text/xml,*/*" },
});
@@ -219,10 +184,10 @@ export const POST: APIRoute = async () => {
const parser = new XMLParser({ trimValues: true });
const parsed = parser.parse(xml);
const nodes = toArray((parsed as any)?.xml?.node ?? (parsed as any)?.node);
const nodes = toArray(parsed?.xml?.node ?? parsed?.node);
const sections: Section[] = nodes
.map((n: any) => {
const sections = nodes
.map((n) => {
const title = parseHtmlContent(n?.title)
.map((b) => (b.type === "text" ? b.value : ""))
.join(" ")
@@ -243,21 +208,15 @@ export const POST: APIRoute = async () => {
return { title, image, content };
})
.filter(Boolean) as Section[];
.filter(Boolean);
const outDir = path.join(
process.cwd(),
"src",
"content",
"internet-telewizja"
);
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" } }
);
};
return new Response(JSON.stringify({ ok: true, count: sections.length }), {
headers: { "content-type": "application/json" },
});
}

View File

@@ -8,7 +8,6 @@ function getDb() {
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;
@@ -51,7 +50,7 @@ export function GET({ url }) {
headers: { "Content-Type": "application/json; charset=utf-8" },
});
} catch (err) {
console.error("Błąd w /api/jambox/channels:", err);
console.error("Błąd w /api/jambox/jambox-channels-package:", err);
return new Response(
JSON.stringify({ ok: false, error: "DB_ERROR" }),
{

View File

@@ -14,7 +14,6 @@ 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()
@@ -28,7 +27,6 @@ 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 < 1) {
return new Response(JSON.stringify({ ok: true, data: [] }), {
status: 200,
@@ -36,7 +34,6 @@ export function GET({ url }) {
});
}
// escape LIKE wildcardów
const safe = q.replace(/[%_]/g, (m) => `\\${m}`);
const like = `%${safe}%`;
@@ -68,19 +65,15 @@ export function GET({ url }) {
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
id: slugifyPkg(p),
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 || "", // base64 data-url albo ""
logo_url: r.logo_url || "",
description: r.description || "",
min_number: 0, // brak numerów
packages,
};
});
@@ -90,7 +83,7 @@ export function GET({ url }) {
headers: { "Content-Type": "application/json; charset=utf-8" },
});
} catch (err) {
console.error("Błąd w /api/jambox/channels-search:", err);
console.error("Błąd w /api/jambox/jambox-channels-search:", err);
return new Response(JSON.stringify({ ok: false, error: "DB_ERROR" }), {
status: 500,
headers: { "Content-Type": "application/json; charset=utf-8" },

View File

@@ -1,70 +0,0 @@
// src/pages/api/switches.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() {
const db = getDb();
try {
const buildingTypes = db
.prepare("SELECT code, label FROM jambox_building_types ORDER BY is_default DESC, code")
.all();
const contractTypes = db
.prepare("SELECT code, label FROM jambox_contract_types ORDER BY is_default DESC, code")
.all();
const switches = [
{
id: "budynek",
etykieta: "Rodzaj budynku",
domyslny: buildingTypes[0]?.code ?? 1,
title: "Zmień rodzaj budynku by zobaczyć odpowiednie ceny",
opcje: buildingTypes.map((b) => ({
id: b.code,
nazwa: b.label,
})),
},
{
id: "umowa",
etykieta: "Okres umowy",
domyslny: contractTypes[0]?.code ?? 1,
title: "Wybierz okres umowy by zobaczyć odpowiednie ceny",
opcje: contractTypes.map((c) => ({
id: c.code,
nazwa: c.label,
})),
},
];
return new Response(
JSON.stringify({ ok: true, data: switches }),
{
status: 200,
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "public, max-age=60",
},
}
);
} catch (err) {
console.error("Błąd w /api/switches:", err);
return new Response(
JSON.stringify({ ok: false, error: err.message || "DB_ERROR" }),
{
status: 500,
headers: {
"Content-Type": "application/json; charset=utf-8",
},
}
);
} finally {
db.close();
}
}

View File

@@ -15,18 +15,19 @@ const html = marked.parse(doc.content);
---
<DefaultLayout title={doc.title}>
<section class="max-w-4xl mx-auto px-4 py-10">
<a href="/dokumenty" class="text-sm opacity-70 hover:opacity-100">
← Wróć do dokumentów
</a>
<section class="f-section">
<div class="f-section-grid-single">
<a href="/dokumenty" class="text-sm opacity-70 hover:opacity-100">
← Wróć do dokumentów
</a>
<h1 class="mt-4 text-4xl md:text-5xl font-bold text-[--f-header]">
{doc.title}
</h1>
<h1 class="f-section-title">
{doc.title}
</h1>
<article class="mt-8 prose max-w-none">
<div class="fuz-markdown max-w-none">
<Markdown text={html} />
<!-- <div set:html={html} /> -->
</article>
</div>
</div>
</section>
</DefaultLayout>

View File

@@ -65,6 +65,24 @@ const addons: Addon[] = Array.isArray(addonsData?.dodatki)
// jeśli chcesz, możesz nadpisać cenaOpis w modalu z addons.yaml:
const addonsCenaOpis = addonsData?.cena_opis ?? cenaOpis;
type SwitchOption = { id: string | number; nazwa: string };
type SwitchDef = {
id: string;
etykieta?: string;
title?: string;
domyslny?: string | number;
opcje: SwitchOption[];
};
type SwitchesYaml = { switches?: SwitchDef[] };
const switchesData = loadYamlFile<SwitchesYaml>(
path.join(process.cwd(), "src", "content", "site", "switches.yaml"),
);
const switches: SwitchDef[] = Array.isArray(switchesData?.switches)
? switchesData.switches
: [];
---
<DefaultLayout seo={seo}>
@@ -80,6 +98,7 @@ const addonsCenaOpis = addonsData?.cena_opis ?? cenaOpis;
phoneCards={phoneCards}
addons={addons}
addonsCenaOpis={addonsCenaOpis}
switches={switches}
/>
</div>
</section>

View File

@@ -50,7 +50,7 @@ type PhoneCard = {
};
type PhoneYaml = { cards?: PhoneCard[] };
type Decoder = { id: string; nazwa: string; cena: number };
type Decoder = { id: string; nazwa: string; opis: string; cena: number };
// ✅ dodatki z YAML (do modala)
type Addon = {
@@ -72,16 +72,16 @@ type AddonsYaml = {
dodatki?: Addon[];
};
type ChannelsYaml = {
title?: string;
updated_at?: string;
channels?: Array<{
nazwa: string;
opis?: string;
image?: string;
pakiety?: string[];
}>;
};
// 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"),
@@ -141,6 +141,25 @@ const decoders: Decoder[] = Array.isArray(addonsYaml?.dekodery)
: [];
const addonsCenaOpis = addonsYaml?.cena_opis ?? cenaOpis;
type SwitchOption = { id: string | number; nazwa: string };
type SwitchDef = {
id: string;
etykieta?: string;
title?: string;
domyslny?: string | number;
opcje: SwitchOption[];
};
type SwitchesYaml = { switches?: SwitchDef[] };
const switchesYaml = loadYamlFile<SwitchesYaml>(
path.join(process.cwd(), "src", "content", "site", "switches.yaml"),
);
const switches: SwitchDef[] = Array.isArray(switchesYaml?.switches)
? switchesYaml.switches
: [];
---
<DefaultLayout seo={seo}>
@@ -159,6 +178,7 @@ const addonsCenaOpis = addonsYaml?.cena_opis ?? cenaOpis;
decoders={decoders}
addons={addons}
addonsCenaOpis={addonsCenaOpis}
switches={switches}
/>
</div>
</section>

View File

@@ -1,21 +0,0 @@
---
import yaml from "js-yaml";
import fs from "fs";
import DefaultLayout from "../../layouts/DefaultLayout.astro";
import Markdown from "../../islands/Markdown.jsx";
const privacy = yaml.load(
fs.readFileSync("./src/content/polityka-prywatnosci/privacy.yaml", "utf8"),
);
---
<DefaultLayout title={privacy.title}>
<section class="f-section">
<div class="f-section-grid-single">
<h1 class="f-section-title">
{privacy.title}
</h1>
<Markdown text={privacy.content} />
</div>
</section>
</DefaultLayout>

View File

@@ -32,7 +32,7 @@
background-image: linear-gradient(
to left,
rgba(0, 0, 0, 0) 0%,
rgba(0, 0, 0, 0.1) 30%,
rgba(0, 0, 0, 0.1) 50%,
rgba(0, 0, 0, 0.5) 60%,
rgba(0, 0, 0, 0.75) 100%
);

View File

@@ -1,7 +1,5 @@
.fuz-markdown {
@apply leading-relaxed text-base md:text-lg;
/* odstępy między elementami */
}
.fuz-markdown p {
@@ -21,15 +19,15 @@
}
.fuz-markdown ul {
@apply list-disc pl-10 mb-4;
@apply list-disc pl-10 my-3;
}
.fuz-markdown ol {
@apply list-decimal pl-6 mb-4;
@apply list-decimal pl-6 my-3;
}
.fuz-markdown li {
@apply mb-1;
@apply text-xl ;
}
.fuz-markdown ul li::marker {

View File

@@ -104,18 +104,24 @@
/* BAZA: checkbox | main | price */
.f-addon-item {
@apply grid items-start gap-3 px-3 py-2 rounded-xl border cursor-pointer;
@apply grid items-start gap-3 px-3 py-2;
border-bottom: 1px solid rgba(148, 163, 184, 0.4);
/* rounded-xl border cursor-pointer; */
grid-template-columns: auto 1fr auto;
border-color: rgba(148, 163, 184, 0.5);
/* border-color: rgba(148, 163, 184, 0.5); */
background: var(--f-background);
}
.f-addon-item:last-child {
border-bottom: none;
}
.f-addon-item:hover {
border-color: color-mix(
/* border-color: color-mix(
in srgb,
var(--fuz-accent, #2563eb) 70%,
rgba(148, 163, 184, 0.5) 30%
);
); */
}
.f-addon-item input[type="checkbox"] {
@@ -331,3 +337,41 @@
color: var(--fuz-accent, #2563eb);
}
/* -------------------------- */
.f-radio-item {
@apply grid items-start gap-3 px-3 py-2 cursor-pointer;
grid-template-columns: auto 1fr auto;
border-bottom: 1px solid rgba(148, 163, 184, 0.4);
background: var(--f-background);
}
.f-radio-item:last-child {
border-bottom: none;
}
.f-radio-check input {
@apply mt-1;
}
.f-radio-name {
@apply font-medium;
}
.f-radio-price {
@apply whitespace-nowrap font-semibold;
}
.f-radio-item.is-selected {
/* delikatne wyróżnienie wybranego */
/* @apply rounded-xl; */
/* background: rgba(148, 163, 184, 0.12); */
}
.f-radio-details {
@apply pl-10 pr-3 pb-3 -mt-1 text-sm;
/* pl-10 = przesunięcie w prawo (radio + gap) */
}

View File

@@ -3,7 +3,7 @@
}
.f-navbar-inner {
@apply max-w-7xl mx-auto flex items-center justify-between py-4 px-4;
@apply max-w-7xl mx-auto flex items-center justify-between py-1 px-4;
}
.f-navbar-links {
@@ -41,7 +41,9 @@
}
.f-navbar-logo {
@apply w-[70] h-[36];
@apply block;
height: 60px; /* testowo: 44px / 40px jak chcesz ciaśniej */
width: auto;
}
.f-mobile-backdrop {

View File

@@ -14,6 +14,6 @@
@apply text-[--f-switcher-text] bg-[--f-switcher-background] ;
}
.f-switch:hover {
/* .f-switch:hover {
@apply text-[--f-switcher-text-hover] bg-[--f-switcher-background-hover] ;
}
} */

View File

@@ -49,7 +49,7 @@
}
.f-card-row {
@apply grid grid-cols-[2fr_1fr] gap-2 py-1 border-b border-[--f-offers-border] items-center;
@apply grid grid-cols-[2fr_1fr] gap-2 py-2 border-b border-[--f-offers-border] items-center;
}
.f-card-row:last-child {
@@ -57,11 +57,11 @@
}
.f-card-label {
@apply text-base font-medium opacity-80;
@apply text-sm font-medium opacity-80;
}
.f-card-value {
@apply text-base font-semibold text-right;
@apply text-sm font-semibold text-right;
}
.f-card-value.yes {
@@ -268,4 +268,47 @@
.jmb-channel-desc::-webkit-scrollbar-track {
background: transparent;
}
}
/* --------------------------------- */
.f-section-acc .f-accordion-header {
@apply flex items-center justify-between gap-3;
}
.f-accordion-header-right {
@apply flex items-center gap-3;
}
.f-acc-chevron {
@apply opacity-60 text-sm;
}
.f-floating-total {
@apply fixed bottom-5 right-5 z-[10000];
@apply pointer-events-auto;
}
/* kółko */
.f-floating-total-circle {
@apply w-24 h-24 md:w-32 md:h-32 rounded-full;
@apply flex flex-col items-center justify-center text-center;
@apply shadow-xl ;
@apply bg-[--link-hover-light] ;
/* text-[--f-text] ; */
/* border-[--f-border-color]; */
@apply backdrop-blur-md;
}
/* kwota */
.f-floating-total-amount {
@apply text-lg md:text-xl font-bold leading-none;
color: hsla(45, 100%, 92%, 1);
}
/* jednostka */
.f-floating-total-unit {
@apply my-1 text-xs md:text-sm opacity-70;
color: hsla(45, 100%, 92%, 1);
}

View File

@@ -1,11 +1,13 @@
@layer components {
.f-section-header {
@apply text-3xl md:text-4xl font-bold mb-6 text-[--f-header];
@apply text-4xl md:text-5xl font-bold mb-3 text-[--f-header];
}
.f-section {
@apply pt-10 pb-1 mx-2;
@apply pt-1 pb-1 mx-2;
}
.f-section-center {
@@ -13,7 +15,7 @@
}
.f-section-grid {
@apply grid items-center gap-5 max-w-7xl mx-auto;
@apply grid items-center gap-5 max-w-7xl mx-auto mt-8;
}
.f-section-grid-single {

View File

@@ -8,7 +8,7 @@
--brand-light: hsl(var(--brand-hue) var(--brand-saturation) var(--brand-lightness));
--link-color-light: hsla(210, 100%, 40%, 1);
--link-hover-light: hsla(165, 80%, 35%, 1);
--link-hover-light: hsla(165, 80%, 25%, 1);
/* --link-background-light: hsla(0 0% 50% 1); */