Kolejne zmiany,
This commit is contained in:
16
.env.example
16
.env.example
@@ -1,16 +0,0 @@
|
|||||||
# Public URL of the website
|
|
||||||
PUBLIC_SITE_URL=https://www.fuz.dariuszm.eu
|
|
||||||
PUBLIC_GOOGLE_MAPS_KEY=AIzaSyDbUU6gvHCQilHyBEWL31FIM4D9-HuvgQw
|
|
||||||
PUBLIC_RECAPTCHA_SITE_KEY=6Ld0ixIsAAAAAJdeOdzRy0Wd1TR-Xg6n7GMFxe4x
|
|
||||||
|
|
||||||
# Contact form API
|
|
||||||
FORMS_ENDPOINT=TEST
|
|
||||||
|
|
||||||
# Astro SSR
|
|
||||||
NODE_ENV=production
|
|
||||||
|
|
||||||
# Smtp
|
|
||||||
SMTP_HOST=smtp.webio.pl
|
|
||||||
SMTP_PORT=465
|
|
||||||
SMTP_USER=admin@fuz.hostingasp.pl
|
|
||||||
SMTP_PASS=Janeczek@12
|
|
||||||
BIN
public/files/EVIO TV.pdf
Normal file
BIN
public/files/EVIO TV.pdf
Normal file
Binary file not shown.
BIN
public/files/JAMBOX TV.pdf
Normal file
BIN
public/files/JAMBOX TV.pdf
Normal file
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 17 KiB |
BIN
src/assets/logo2.webp
Normal file
BIN
src/assets/logo2.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
@@ -42,7 +42,6 @@ const mobile = findImage("mobile");
|
|||||||
const tablet = findImage("tablet");
|
const tablet = findImage("tablet");
|
||||||
const desktop = findImage("desktop");
|
const desktop = findImage("desktop");
|
||||||
|
|
||||||
// Generujemy prawdziwe srcsety (a nie pojedynczy URL)
|
|
||||||
const mobileSet = mobile
|
const mobileSet = mobile
|
||||||
? await getImage({ src: mobile, widths: [480, 640], format: "webp" })
|
? await getImage({ src: mobile, widths: [480, 640], format: "webp" })
|
||||||
: null;
|
: null;
|
||||||
@@ -51,7 +50,6 @@ const tabletSet = tablet
|
|||||||
? await getImage({ src: tablet, widths: [768, 1024], format: "webp" })
|
? await getImage({ src: tablet, widths: [768, 1024], format: "webp" })
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
// Fallback (największy, eager)
|
|
||||||
const desktopImg = desktop
|
const desktopImg = desktop
|
||||||
? await getImage({ src: desktop, widths: [1280, 1440, 1920], format: "webp" })
|
? await getImage({ src: desktop, widths: [1280, 1440, 1920], format: "webp" })
|
||||||
: null;
|
: null;
|
||||||
|
|||||||
@@ -15,11 +15,11 @@ const footer = yaml.load(
|
|||||||
{footer.company.address.line1}<br />
|
{footer.company.address.line1}<br />
|
||||||
{footer.company.address.line2}
|
{footer.company.address.line2}
|
||||||
</p>
|
</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>
|
||||||
|
|
||||||
<div class="f-footer-col">
|
<div class="f-footer-col">
|
||||||
<h4>Kontakt</h4>
|
<h4>{footer.contact.title}</h4>
|
||||||
{
|
{
|
||||||
footer.contact.phones.map((phone: string) => (
|
footer.contact.phones.map((phone: string) => (
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@@ -14,17 +14,14 @@ const section = props.section ?? {};
|
|||||||
|
|
||||||
<TvChannelsSearch client:load />
|
<TvChannelsSearch client:load />
|
||||||
|
|
||||||
{section.button && (
|
<div class="flex justify-center gap-4 flex-wrap">
|
||||||
<div class="f-section-nav">
|
<a href="/files/EVIO TV.pdf" download class="btn btn-primary"
|
||||||
<a
|
>Pobierz listę kanałów (SMART OPTIMUM PLATINUM)
|
||||||
href={section.button.url}
|
</a>
|
||||||
class="btn btn-primary"
|
<a href="/files/JAMBOX TV.pdf" download class="btn btn-primary"
|
||||||
title={section.button.title}
|
>Pobierz listę kanałów (PODSTAWOWY KORZYSTNY BOGATY)
|
||||||
>
|
</a>
|
||||||
{section.button.text}
|
</div>
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ form:
|
|||||||
|
|
||||||
rodo:
|
rodo:
|
||||||
label: "Wyrażam zgodę na przetwarzanie moich danych osobowych zgodnie z"
|
label: "Wyrażam zgodę na przetwarzanie moich danych osobowych zgodnie z"
|
||||||
policyLink: "/polityka-prywatnosci"
|
policyLink: "/dokumenty/polityka-prywatnosci"
|
||||||
policyText: "polityką prywatności"
|
policyText: "polityką prywatności"
|
||||||
policyTitle: "Polityka prywatności FUZ Adam Rojek"
|
policyTitle: "Polityka prywatności FUZ Adam Rojek"
|
||||||
|
|
||||||
|
|||||||
@@ -5,14 +5,22 @@ cena_opis: "zł/mies."
|
|||||||
dekodery:
|
dekodery:
|
||||||
- id: arris_4302
|
- id: arris_4302
|
||||||
nazwa: "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
|
cena: 0
|
||||||
|
|
||||||
- id: arris_5202
|
- id: arris_5202
|
||||||
nazwa: "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
|
cena: 5
|
||||||
|
|
||||||
- id: tv_smart_4k
|
- id: tv_smart_4k
|
||||||
nazwa: "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
|
cena: 10
|
||||||
|
|
||||||
dodatki:
|
dodatki:
|
||||||
|
|||||||
@@ -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.
|
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.
|
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
|
- Procesor 6000 DMIPS z zaawansowaną kartą graficzną z dekoderem telewizji cyfrowej HD
|
||||||
- Pamięć RAM 1 GB DDR3
|
- Pamięć RAM 1 GB DDR3
|
||||||
- Pamięć Flash 256 MB
|
- Pamięć Flash 256 MB
|
||||||
@@ -31,7 +31,7 @@ sections:
|
|||||||
Oprogramowanie Kyanit z wygodnym i szybkim interfejsem użytkownika.
|
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.
|
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
|
- Szybki procesor Dual-core, 8500 DMIPS
|
||||||
- Pamięć RAM: DDR3 2GB, 8 GB eMMC Flash
|
- Pamięć RAM: DDR3 2GB, 8 GB eMMC Flash
|
||||||
- Rozdzielczość obrazu: 4K, Full HD, wsparcie dla technologii HDR10
|
- Rozdzielczość obrazu: 4K, Full HD, wsparcie dla technologii HDR10
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -1,33 +1,42 @@
|
|||||||
company:
|
company:
|
||||||
name: "FUZ Adam Rojek"
|
name: FUZ Adam Rojek
|
||||||
address:
|
address:
|
||||||
line1: "ul. Świętojańska 46"
|
line1: ul. Świętojańska 46
|
||||||
line2: "07-200 Wyszków"
|
line2: 07-200 Wyszków
|
||||||
|
|
||||||
contact:
|
contact:
|
||||||
|
title: Kontakt
|
||||||
phones:
|
phones:
|
||||||
- "+48 606 369 650"
|
- +48 606 369 650
|
||||||
- "+48 (29) 643 80 55"
|
- +48 (29) 643 80 55
|
||||||
email: "biuro@fuz.pl"
|
email: biuro@fuz.pl
|
||||||
|
|
||||||
services:
|
services:
|
||||||
title: "Mapa strony"
|
title: Mapa strony
|
||||||
items:
|
items:
|
||||||
- name: "Internet Światłowodowy"
|
- name: Internet Światłowodowy
|
||||||
url: "/internet-swiatlowodowy"
|
url: /internet-swiatlowodowy
|
||||||
title: "Przejdź do oferty Internetu światłowodowego"
|
title: Przejdź do oferty Internetu światłowodowego
|
||||||
|
|
||||||
- name: "Internet + Telewizja"
|
- name: Internet i Telewizja
|
||||||
url: "/internet-telewizja"
|
url: /internet-telewizja
|
||||||
title: "Przejdź do oferty Internet + Telewizja w FUZ"
|
title: Przejdź do oferty Internet + Telewizja w FUZ
|
||||||
|
|
||||||
- name: "Telefon"
|
- name: Telefon
|
||||||
url: "/telefon"
|
url: /telefon
|
||||||
title: "Przejdź do oferty telefonu"
|
title: Przejdź do oferty telefonu
|
||||||
|
|
||||||
- name: "Zasięg sieci"
|
- name: Mapa zasiegu sieci
|
||||||
url: "/zasieg-sieci"
|
url: /mapa-zasiegu
|
||||||
title: "Sprawdź zasięg sieci FUZ"
|
title: Sprawdź zasięg sieci FUZ
|
||||||
|
|
||||||
|
- name: Kontakt
|
||||||
|
url: /kontakt
|
||||||
|
title: Skontaktuj się z nami
|
||||||
|
|
||||||
|
- name: Dokumenty, Regulaminy
|
||||||
|
url: /dokumenty
|
||||||
|
title: Dokumenty, regulaminy
|
||||||
|
|
||||||
recaptcha:
|
recaptcha:
|
||||||
Ta strona jest chroniona przez reCAPTCHA.
|
Ta strona jest chroniona przez reCAPTCHA.
|
||||||
|
|||||||
20
src/content/site/switches.yaml
Normal file
20
src/content/site/switches.yaml
Normal 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.
@@ -148,7 +148,7 @@ export default function InternetAddonsModal({
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div class="f-modal-inner">
|
<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 */}
|
{/* INTERNET (fiber) jako akordeon */}
|
||||||
<div class="f-modal-section">
|
<div class="f-modal-section">
|
||||||
|
|||||||
449
src/islands/Internet/InternetAddonsModalCompact.jsx
Normal file
449
src/islands/Internet/InternetAddonsModalCompact.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -30,7 +30,8 @@ function mapCardToPlan(card, match, labels, waluta) {
|
|||||||
features.push({ label: "Umowa", value: labels?.umowa || "—" });
|
features.push({ label: "Umowa", value: labels?.umowa || "—" });
|
||||||
features.push({
|
features.push({
|
||||||
label: "Aktywacja",
|
label: "Aktywacja",
|
||||||
value: typeof match?.aktywacja === "number" ? formatMoney(match.aktywacja, waluta) : "—",
|
value:
|
||||||
|
typeof match?.aktywacja === "number" ? formatMoney(match.aktywacja, waluta) : "—",
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -50,7 +51,8 @@ function mapCardToPlan(card, match, labels, waluta) {
|
|||||||
* cenaOpis?: string,
|
* cenaOpis?: string,
|
||||||
* phoneCards?: any[],
|
* phoneCards?: any[],
|
||||||
* addons?: any[],
|
* addons?: any[],
|
||||||
* addonsCenaOpis?: string
|
* addonsCenaOpis?: string,
|
||||||
|
* switches?: any[] // ✅ NOWE: przełączniki z YAML
|
||||||
* }} props
|
* }} props
|
||||||
*/
|
*/
|
||||||
export default function InternetCards({
|
export default function InternetCards({
|
||||||
@@ -62,10 +64,11 @@ export default function InternetCards({
|
|||||||
phoneCards = [],
|
phoneCards = [],
|
||||||
addons = [],
|
addons = [],
|
||||||
addonsCenaOpis = "zł/mies.",
|
addonsCenaOpis = "zł/mies.",
|
||||||
|
switches = [], // ✅ NOWE
|
||||||
}) {
|
}) {
|
||||||
const visibleCards = Array.isArray(cards) ? cards : [];
|
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 [selected, setSelected] = useState({});
|
||||||
const [labels, setLabels] = useState({});
|
const [labels, setLabels] = useState({});
|
||||||
|
|
||||||
@@ -100,7 +103,8 @@ export default function InternetCards({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<OffersSwitches />
|
{/* ✅ TERAZ switcher dostaje dane z YAML */}
|
||||||
|
<OffersSwitches switches={switches} />
|
||||||
|
|
||||||
{visibleCards.length === 0 ? (
|
{visibleCards.length === 0 ? (
|
||||||
<p class="opacity-80">Brak dostępnych pakietów.</p>
|
<p class="opacity-80">Brak dostępnych pakietów.</p>
|
||||||
|
|||||||
@@ -1,170 +1,79 @@
|
|||||||
import { useEffect, useState } from "preact/hooks";
|
import { useEffect, useMemo, useState } from "preact/hooks";
|
||||||
|
|
||||||
function buildLabels(switches, selected) {
|
function buildLabels(switches, selected) {
|
||||||
const out = {};
|
const out = {};
|
||||||
for (const sw of switches || []) {
|
for (const sw of switches || []) {
|
||||||
const currentId = selected[sw.id];
|
const currentId = selected?.[sw.id];
|
||||||
const opt = sw.opcje?.find((op) => String(op.id) === String(currentId));
|
const opt = sw?.opcje?.find((op) => String(op.id) === String(currentId));
|
||||||
if (opt) out[sw.id] = opt.nazwa;
|
if (opt) out[sw.id] = opt.nazwa;
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function OffersSwitches(props) {
|
export default function OffersSwitches({ switches = [] }) {
|
||||||
const { switches, selected, onSwitch } = props || {};
|
const initialSelected = useMemo(() => {
|
||||||
|
const init = {};
|
||||||
const isControlled =
|
for (const sw of switches) {
|
||||||
Array.isArray(switches) &&
|
if (!sw?.id) continue;
|
||||||
switches.length > 0 &&
|
if (sw.domyslny != null) init[sw.id] = sw.domyslny;
|
||||||
typeof onSwitch === "function";
|
else if (sw.opcje?.length) init[sw.id] = sw.opcje[0].id;
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return init;
|
||||||
|
}, [switches]);
|
||||||
|
|
||||||
load();
|
const [selected, setSelected] = useState(initialSelected);
|
||||||
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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// gdy switches się zmienią (np. hot reload) – zresetuj sensownie
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isControlled) return;
|
setSelected(initialSelected);
|
||||||
if (!Array.isArray(switches) || !switches.length) return;
|
}, [initialSelected]);
|
||||||
|
|
||||||
const safeSelected = selected || {};
|
// globalny stan + event (tak jak masz teraz)
|
||||||
const labels = buildLabels(switches, safeSelected);
|
useEffect(() => {
|
||||||
|
const labels = buildLabels(switches, selected);
|
||||||
|
|
||||||
window.fuzSwitchState = {
|
window.fuzSwitchState = { selected, labels };
|
||||||
selected: safeSelected,
|
|
||||||
labels,
|
|
||||||
};
|
|
||||||
|
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent("fuz:switch-change", {
|
new CustomEvent("fuz:switch-change", {
|
||||||
detail: {
|
detail: { id: null, value: null, selected, labels },
|
||||||
id: null,
|
|
||||||
value: null,
|
|
||||||
selected: safeSelected,
|
|
||||||
labels,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}, [isControlled, switches, selected]);
|
}, [switches, selected]);
|
||||||
|
|
||||||
if (!isControlled && loading) {
|
const handleClick = (id, value) => {
|
||||||
return (
|
setSelected((prev) => {
|
||||||
<div class="f-switches-wrapper">
|
const next = { ...prev, [id]: value };
|
||||||
<p>Ładowanie opcji przełączników...</p>
|
const labels = buildLabels(switches, next);
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isControlled && error) {
|
window.fuzSwitchState = { selected: next, labels };
|
||||||
return (
|
|
||||||
<div class="f-switches-wrapper">
|
|
||||||
<p class="text-red-600">{error}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div class="f-switches-wrapper">
|
<div class="f-switches-wrapper">
|
||||||
{effectiveSwitches.map((sw) => (
|
{switches.map((sw) => (
|
||||||
<div class="f-switch-group">
|
<div class="f-switch-group" key={sw.id}>
|
||||||
{sw.opcje.map((op) => (
|
{/* (opcjonalnie) etykieta */}
|
||||||
|
{/* <div class="f-switch-label">{sw.etykieta}</div> */}
|
||||||
|
|
||||||
|
{sw.opcje?.map((op) => (
|
||||||
<button
|
<button
|
||||||
|
key={`${sw.id}:${op.id}`}
|
||||||
type="button"
|
type="button"
|
||||||
class={`f-switch ${String(effectiveSelected[sw.id]) === String(op.id)
|
class={`f-switch ${
|
||||||
? "active"
|
String(selected?.[sw.id]) === String(op.id) ? "active" : ""
|
||||||
: ""
|
}`}
|
||||||
}`}
|
|
||||||
onClick={() => handleClick(sw.id, op.id)}
|
onClick={() => handleClick(sw.id, op.id)}
|
||||||
title={sw.title}
|
title={sw.title}
|
||||||
>
|
>
|
||||||
|
|||||||
704
src/islands/jambox/JamboxAddonsModalCompact.jsx
Normal file
704
src/islands/jambox/JamboxAddonsModalCompact.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import "../../styles/offers/offers-table.css";
|
|||||||
|
|
||||||
import OffersSwitches from "../OffersSwitches.jsx";
|
import OffersSwitches from "../OffersSwitches.jsx";
|
||||||
import JamboxChannelsModal from "./JamboxChannelsModal.jsx";
|
import JamboxChannelsModal from "./JamboxChannelsModal.jsx";
|
||||||
import JamboxAddonsModal from "./JamboxAddonsModal.jsx";
|
import JamboxAddonsModal from "./JamboxAddonsModalCompact.jsx";
|
||||||
import Markdown from "../Markdown.jsx";
|
import Markdown from "../Markdown.jsx";
|
||||||
|
|
||||||
function formatMoney(amount, currency = "PLN") {
|
function formatMoney(amount, currency = "PLN") {
|
||||||
@@ -50,10 +50,8 @@ function toFeatureRows(params) {
|
|||||||
* tvAddons?: any[],
|
* tvAddons?: any[],
|
||||||
* addons?: Addon[],
|
* addons?: Addon[],
|
||||||
* decoders?: Decoder[],
|
* decoders?: Decoder[],
|
||||||
*
|
|
||||||
* addonsCenaOpis?: string,
|
* addonsCenaOpis?: string,
|
||||||
*
|
* channels?: ChannelYaml[],
|
||||||
* // ✅ NOWE
|
|
||||||
* channels?: ChannelYaml[]
|
* channels?: ChannelYaml[]
|
||||||
* }} props
|
* }} props
|
||||||
*/
|
*/
|
||||||
@@ -70,6 +68,7 @@ export default function JamboxCards({
|
|||||||
addons = [],
|
addons = [],
|
||||||
decoders = [],
|
decoders = [],
|
||||||
channels = [],
|
channels = [],
|
||||||
|
switches = [],
|
||||||
}) {
|
}) {
|
||||||
const visibleCards = Array.isArray(cards) ? cards : [];
|
const visibleCards = Array.isArray(cards) ? cards : [];
|
||||||
const wsp = Array.isArray(internetWspolne) ? internetWspolne : [];
|
const wsp = Array.isArray(internetWspolne) ? internetWspolne : [];
|
||||||
@@ -110,7 +109,7 @@ export default function JamboxCards({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<OffersSwitches />
|
<OffersSwitches switches={switches} />
|
||||||
|
|
||||||
{visibleCards.length === 0 ? (
|
{visibleCards.length === 0 ? (
|
||||||
<p class="opacity-80">Brak pakietów do wyświetlenia.</p>
|
<p class="opacity-80">Brak pakietów do wyświetlenia.</p>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export default function JamboxChannelsModal({ isOpen, onClose, pkg }) {
|
|||||||
try {
|
try {
|
||||||
// ✅ NOWE API: po nazwie pakietu
|
// ✅ NOWE API: po nazwie pakietu
|
||||||
const params = new URLSearchParams({ package: String(pkg.name) });
|
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}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
|
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export default function JamboxChannelsSearch() {
|
|||||||
params.set("limit", "80");
|
params.set("limit", "80");
|
||||||
|
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/api/jambox/channels-search?${params.toString()}`,
|
`/api/jambox/jambox-channels-search?${params.toString()}`,
|
||||||
{
|
{
|
||||||
signal: ac.signal,
|
signal: ac.signal,
|
||||||
headers: { Accept: "application/json" },
|
headers: { Accept: "application/json" },
|
||||||
@@ -45,7 +45,7 @@ export default function JamboxChannelsSearch() {
|
|||||||
setItems(Array.isArray(json.data) ? json.data : []);
|
setItems(Array.isArray(json.data) ? json.data : []);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e?.name !== "AbortError") {
|
if (e?.name !== "AbortError") {
|
||||||
console.error("❌ channels search:", e);
|
console.error("jambox-channels-search:", e);
|
||||||
setErr("Błąd wyszukiwania.");
|
setErr("Błąd wyszukiwania.");
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export type DocYaml = {
|
|||||||
title: string;
|
title: string;
|
||||||
visible?: boolean;
|
visible?: boolean;
|
||||||
intro?: string;
|
intro?: string;
|
||||||
content: string; // markdown
|
content: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DocEntry = DocYaml & {
|
export type DocEntry = DocYaml & {
|
||||||
@@ -32,7 +32,6 @@ export function listDocuments(): DocEntry[] {
|
|||||||
|
|
||||||
const slug = file.replace(/\.ya?ml$/i, "");
|
const slug = file.replace(/\.ya?ml$/i, "");
|
||||||
|
|
||||||
// minimalna walidacja, żeby nic nie wybuchało
|
|
||||||
if (!data.title || typeof data.title !== "string") continue;
|
if (!data.title || typeof data.title !== "string") continue;
|
||||||
if (!data.content || typeof data.content !== "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 {
|
export function getDocumentBySlug(slug: string): DocEntry | null {
|
||||||
// akceptuj .yaml i .yml
|
|
||||||
const candidates = [`${slug}.yaml`, `${slug}.yml`];
|
const candidates = [`${slug}.yaml`, `${slug}.yml`];
|
||||||
|
|
||||||
for (const file of candidates) {
|
for (const file of candidates) {
|
||||||
|
|||||||
@@ -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 itd.
|
|
||||||
function cleanHtmlText(s?: string): string {
|
|
||||||
if (!s) return "";
|
|
||||||
return String(s)
|
|
||||||
.replace(/ /g, " ")
|
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/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;
|
|
||||||
}
|
|
||||||
@@ -1,50 +1,163 @@
|
|||||||
import nodemailer from "nodemailer";
|
import nodemailer from "nodemailer";
|
||||||
|
|
||||||
|
function esc(str = "") {
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }) {
|
export async function POST({ request }) {
|
||||||
try {
|
try {
|
||||||
const form = await request.json();
|
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({
|
const transporter = nodemailer.createTransport({
|
||||||
host: import.meta.env.SMTP_HOST,
|
host: import.meta.env.SMTP_HOST,
|
||||||
port: Number(import.meta.env.SMTP_PORT),
|
port: Number(import.meta.env.SMTP_PORT),
|
||||||
secure: true, // true = 465, false = 587
|
secure: true,
|
||||||
auth: {
|
auth: {
|
||||||
user: import.meta.env.SMTP_USER,
|
user: import.meta.env.SMTP_USER,
|
||||||
pass: import.meta.env.SMTP_PASS,
|
pass: import.meta.env.SMTP_PASS,
|
||||||
},
|
},
|
||||||
// ⚠️ tylko jeśli masz self-signed / dziwny cert
|
// Uwaga: lepiej NIE wyłączać TLS w prod, ale zostawiam zgodnie z Twoją wersją
|
||||||
tls: {
|
tls: { rejectUnauthorized: false },
|
||||||
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({
|
await transporter.sendMail({
|
||||||
from: `"${import.meta.env.SMTP_FROM_NAME}" <${import.meta.env.SMTP_USER}>`,
|
from: `"${import.meta.env.SMTP_FROM_NAME}" <${import.meta.env.SMTP_USER}>`,
|
||||||
to: import.meta.env.SMTP_TO,
|
to: import.meta.env.SMTP_TO,
|
||||||
subject: `FUZ: wiadomość od ${form.firstName} ${form.lastName}`,
|
subject,
|
||||||
text: `
|
text,
|
||||||
Imię: ${form.firstName}
|
html,
|
||||||
Nazwisko: ${form.lastName}
|
replyTo: form.email ? String(form.email) : undefined, // wygodne do "Odpowiedz"
|
||||||
Email: ${form.email}
|
|
||||||
Telefon: ${form.phone}
|
|
||||||
Temat: ${form.subject}
|
|
||||||
|
|
||||||
Wiadomość:
|
|
||||||
${form.message}
|
|
||||||
`.trim(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return new Response(
|
return new Response(JSON.stringify({ ok: true }), {
|
||||||
JSON.stringify({ ok: true }),
|
status: 200,
|
||||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
headers: { "Content-Type": "application/json" },
|
||||||
);
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("MAIL ERROR:", error);
|
console.error("MAIL ERROR:", error);
|
||||||
|
|
||||||
return new Response(
|
return new Response(JSON.stringify({ ok: false }), {
|
||||||
JSON.stringify({ ok: false }),
|
status: 500,
|
||||||
{ status: 500, headers: { "Content-Type": "application/json" } }
|
headers: { "Content-Type": "application/json" },
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,10 +2,6 @@ import path from "node:path";
|
|||||||
import { XMLParser } from "fast-xml-parser";
|
import { XMLParser } from "fast-xml-parser";
|
||||||
import Database from "better-sqlite3";
|
import Database from "better-sqlite3";
|
||||||
|
|
||||||
/* =====================
|
|
||||||
KONFIG
|
|
||||||
===================== */
|
|
||||||
|
|
||||||
const FEEDS = [
|
const FEEDS = [
|
||||||
{ url: "https://www.jambox.pl/xml/listakanalow-smart.xml", name: "Smart" },
|
{ 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-optimum.xml", name: "Optimum" },
|
||||||
@@ -15,25 +11,16 @@ const FEEDS = [
|
|||||||
{ url: "https://www.jambox.pl/xml/listakanalow-plusbogaty.xml", name: "Bogaty" },
|
{ url: "https://www.jambox.pl/xml/listakanalow-plusbogaty.xml", name: "Bogaty" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// 👉 ustaw jeśli chcesz inną bazę
|
|
||||||
const DB_PATH =
|
const DB_PATH =
|
||||||
process.env.FUZ_DB_PATH ||
|
process.env.FUZ_DB_PATH ||
|
||||||
path.join(process.cwd(), "src", "data", "ServicesRange.db");
|
path.join(process.cwd(), "src", "data", "ServicesRange.db");
|
||||||
|
|
||||||
/* =====================
|
|
||||||
DB
|
|
||||||
===================== */
|
|
||||||
|
|
||||||
function getDb() {
|
function getDb() {
|
||||||
const db = new Database(DB_PATH);
|
const db = new Database(DB_PATH);
|
||||||
db.pragma("journal_mode = WAL");
|
db.pragma("journal_mode = WAL");
|
||||||
return db;
|
return db;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =====================
|
|
||||||
XML / HTML HELPERS
|
|
||||||
===================== */
|
|
||||||
|
|
||||||
async function fetchXml(url) {
|
async function fetchXml(url) {
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
headers: { accept: "application/xml,text/xml,*/*" },
|
headers: { accept: "application/xml,text/xml,*/*" },
|
||||||
@@ -155,17 +142,9 @@ async function downloadLogoAsBase64(url) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =====================
|
|
||||||
API ROUTE
|
|
||||||
===================== */
|
|
||||||
|
|
||||||
export async function POST() {
|
export async function POST() {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
|
|
||||||
// ⚠️ WYMAGANE:
|
|
||||||
// CREATE UNIQUE INDEX ux_jambox_channels_nazwa_pckg
|
|
||||||
// ON jambox_channels(nazwa, pckg_name);
|
|
||||||
|
|
||||||
const upsert = db.prepare(`
|
const upsert = db.prepare(`
|
||||||
INSERT INTO jambox_channels (nazwa, pckg_name, image, opis)
|
INSERT INTO jambox_channels (nazwa, pckg_name, image, opis)
|
||||||
VALUES (@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)
|
opis = COALESCE(excluded.opis, jambox_channels.opis)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const logoCache = new Map(); // nazwa(lower) -> base64 | null
|
const logoCache = new Map();
|
||||||
const rows = [];
|
const rows = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -220,7 +199,7 @@ export async function POST() {
|
|||||||
{ headers: { "content-type": "application/json; charset=utf-8" } }
|
{ headers: { "content-type": "application/json; charset=utf-8" } }
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("❌ import jambox_channels:", e);
|
console.error("import jambox_channels:", e);
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({ ok: false, error: String(e.message || e) }),
|
JSON.stringify({ ok: false, error: String(e.message || e) }),
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
@@ -1,30 +1,15 @@
|
|||||||
import type { APIRoute } from "astro";
|
|
||||||
import { XMLParser } from "fast-xml-parser";
|
import { XMLParser } from "fast-xml-parser";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
|
|
||||||
const URL = "https://www.jambox.pl/xml/mozliwosci.xml";
|
const URL = "https://www.jambox.pl/xml/mozliwosci.xml";
|
||||||
|
|
||||||
type Section = {
|
function toArray(v) {
|
||||||
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 [];
|
if (!v) return [];
|
||||||
return Array.isArray(v) ? v : [v];
|
return Array.isArray(v) ? v : [v];
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =======================
|
function decodeEntities(input) {
|
||||||
HTML / XML HELPERS
|
|
||||||
======================= */
|
|
||||||
|
|
||||||
function decodeEntities(input: string): string {
|
|
||||||
if (!input) return "";
|
if (!input) return "";
|
||||||
|
|
||||||
let s = String(input)
|
let s = String(input)
|
||||||
@@ -35,9 +20,7 @@ function decodeEntities(input: string): string {
|
|||||||
.replace(/</g, "<")
|
.replace(/</g, "<")
|
||||||
.replace(/>/g, ">");
|
.replace(/>/g, ">");
|
||||||
|
|
||||||
s = s.replace(/&#(\d+);/g, (_, d) =>
|
s = s.replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(Number(d)));
|
||||||
String.fromCodePoint(Number(d))
|
|
||||||
);
|
|
||||||
s = s.replace(/&#x([0-9a-fA-F]+);/g, (_, h) =>
|
s = s.replace(/&#x([0-9a-fA-F]+);/g, (_, h) =>
|
||||||
String.fromCodePoint(parseInt(h, 16))
|
String.fromCodePoint(parseInt(h, 16))
|
||||||
);
|
);
|
||||||
@@ -45,30 +28,22 @@ function decodeEntities(input: string): string {
|
|||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function parseHtmlContent(input) {
|
||||||
* Parsuje HTML:
|
|
||||||
* - <p>, <div>, <br> → zwykłe nowe linie
|
|
||||||
* - <ul>/<ol><li> → markdown lista
|
|
||||||
*/
|
|
||||||
function parseHtmlContent(input?: string): ContentBlock[] {
|
|
||||||
if (!input) return [];
|
if (!input) return [];
|
||||||
|
|
||||||
let s = decodeEntities(String(input));
|
let s = decodeEntities(String(input));
|
||||||
|
|
||||||
// znaczniki list
|
|
||||||
s = s
|
s = s
|
||||||
.replace(/<\s*(ul|ol)[^>]*>/gi, "\n__LIST_START__\n")
|
.replace(/<\s*(ul|ol)[^>]*>/gi, "\n__LIST_START__\n")
|
||||||
.replace(/<\/\s*(ul|ol)\s*>/gi, "\n__LIST_END__\n")
|
.replace(/<\/\s*(ul|ol)\s*>/gi, "\n__LIST_END__\n")
|
||||||
.replace(/<\s*li[^>]*>/gi, "__LI__")
|
.replace(/<\s*li[^>]*>/gi, "__LI__")
|
||||||
.replace(/<\/\s*li\s*>/gi, "\n");
|
.replace(/<\/\s*li\s*>/gi, "\n");
|
||||||
|
|
||||||
// normalne bloki
|
|
||||||
s = s
|
s = s
|
||||||
.replace(/<\s*br\s*\/?\s*>/gi, "\n")
|
.replace(/<\s*br\s*\/?\s*>/gi, "\n")
|
||||||
.replace(/<\/\s*(p|div)\s*>/gi, "\n")
|
.replace(/<\/\s*(p|div)\s*>/gi, "\n")
|
||||||
.replace(/<\s*(p|div)[^>]*>/gi, "");
|
.replace(/<\s*(p|div)[^>]*>/gi, "");
|
||||||
|
|
||||||
// usuń resztę tagów
|
|
||||||
s = s.replace(/<[^>]+>/g, "");
|
s = s.replace(/<[^>]+>/g, "");
|
||||||
|
|
||||||
s = s
|
s = s
|
||||||
@@ -77,11 +52,11 @@ function parseHtmlContent(input?: string): ContentBlock[] {
|
|||||||
.replace(/\n{3,}/g, "\n\n")
|
.replace(/\n{3,}/g, "\n\n")
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
const blocks: ContentBlock[] = [];
|
const blocks = [];
|
||||||
const lines = s.split("\n");
|
const lines = s.split("\n");
|
||||||
|
|
||||||
let textBuf: string[] = [];
|
let textBuf = [];
|
||||||
let listBuf: string[] | null = null;
|
let listBuf = null;
|
||||||
|
|
||||||
const flushText = () => {
|
const flushText = () => {
|
||||||
const txt = textBuf.join("\n").trim();
|
const txt = textBuf.join("\n").trim();
|
||||||
@@ -132,12 +107,11 @@ function parseHtmlContent(input?: string): ContentBlock[] {
|
|||||||
return blocks;
|
return blocks;
|
||||||
}
|
}
|
||||||
|
|
||||||
function blocksToMarkdown(blocks: ContentBlock[]): string {
|
function blocksToMarkdown(blocks) {
|
||||||
const out: string[] = [];
|
const out = [];
|
||||||
|
|
||||||
for (const b of blocks) {
|
for (const b of blocks) {
|
||||||
if (b.type === "text") {
|
if (b.type === "text") {
|
||||||
// 👉 każde zdanie zakończone kropką = nowa linia
|
|
||||||
const lines = b.value
|
const lines = b.value
|
||||||
.replace(/\.\s+/g, ".\n")
|
.replace(/\.\s+/g, ".\n")
|
||||||
.split("\n")
|
.split("\n")
|
||||||
@@ -157,20 +131,15 @@ function blocksToMarkdown(blocks: ContentBlock[]): string {
|
|||||||
return out.join("\n\n").trim();
|
return out.join("\n\n").trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extractUrlsFromString(s) {
|
||||||
/* =======================
|
return String(s).match(/https?:\/\/[^\s<"]+/g) ?? [];
|
||||||
SCREEN / YAML
|
|
||||||
======================= */
|
|
||||||
|
|
||||||
function extractUrlsFromString(s: string): string[] {
|
|
||||||
return s.match(/https?:\/\/[^\s<"]+/g) ?? [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractScreens(screen: any): string[] {
|
function extractScreens(screen) {
|
||||||
if (!screen) return [];
|
if (!screen) return [];
|
||||||
if (typeof screen === "string") return extractUrlsFromString(screen);
|
if (typeof screen === "string") return extractUrlsFromString(screen);
|
||||||
|
|
||||||
const divs = (screen as any)?.div;
|
const divs = screen?.div;
|
||||||
if (divs) {
|
if (divs) {
|
||||||
return toArray(divs)
|
return toArray(divs)
|
||||||
.map((d) => (typeof d === "string" ? d : d?.["#text"] ?? ""))
|
.map((d) => (typeof d === "string" ? d : d?.["#text"] ?? ""))
|
||||||
@@ -180,19 +149,19 @@ function extractScreens(screen: any): string[] {
|
|||||||
return extractUrlsFromString(JSON.stringify(screen));
|
return extractUrlsFromString(JSON.stringify(screen));
|
||||||
}
|
}
|
||||||
|
|
||||||
function yamlQuote(v: string): string {
|
function yamlQuote(v) {
|
||||||
return `"${String(v).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
return `"${String(v).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toYaml(sections: Section[]): string {
|
function toYaml(sections) {
|
||||||
const out: string[] = ["sections:"];
|
const out = ["sections:"];
|
||||||
|
|
||||||
for (const s of sections) {
|
for (const s of sections) {
|
||||||
out.push(` - title: ${yamlQuote(s.title)}`);
|
out.push(` - title: ${yamlQuote(s.title)}`);
|
||||||
if (s.image) out.push(` image: ${yamlQuote(s.image)}`);
|
if (s.image) out.push(` image: ${yamlQuote(s.image)}`);
|
||||||
out.push(" content: |");
|
out.push(" content: |");
|
||||||
|
|
||||||
for (const line of s.content.split("\n")) {
|
for (const line of String(s.content).split("\n")) {
|
||||||
out.push(` ${line}`);
|
out.push(` ${line}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,11 +171,7 @@ function toYaml(sections: Section[]): string {
|
|||||||
return out.join("\n").trimEnd() + "\n";
|
return out.join("\n").trimEnd() + "\n";
|
||||||
}
|
}
|
||||||
|
|
||||||
/* =======================
|
export async function POST() {
|
||||||
API
|
|
||||||
======================= */
|
|
||||||
|
|
||||||
export const POST: APIRoute = async () => {
|
|
||||||
const res = await fetch(URL, {
|
const res = await fetch(URL, {
|
||||||
headers: { accept: "application/xml,text/xml,*/*" },
|
headers: { accept: "application/xml,text/xml,*/*" },
|
||||||
});
|
});
|
||||||
@@ -219,10 +184,10 @@ export const POST: APIRoute = async () => {
|
|||||||
const parser = new XMLParser({ trimValues: true });
|
const parser = new XMLParser({ trimValues: true });
|
||||||
const parsed = parser.parse(xml);
|
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
|
const sections = nodes
|
||||||
.map((n: any) => {
|
.map((n) => {
|
||||||
const title = parseHtmlContent(n?.title)
|
const title = parseHtmlContent(n?.title)
|
||||||
.map((b) => (b.type === "text" ? b.value : ""))
|
.map((b) => (b.type === "text" ? b.value : ""))
|
||||||
.join(" ")
|
.join(" ")
|
||||||
@@ -243,21 +208,15 @@ export const POST: APIRoute = async () => {
|
|||||||
|
|
||||||
return { title, image, content };
|
return { title, image, content };
|
||||||
})
|
})
|
||||||
.filter(Boolean) as Section[];
|
.filter(Boolean);
|
||||||
|
|
||||||
const outDir = path.join(
|
const outDir = path.join(process.cwd(), "src", "content", "internet-telewizja");
|
||||||
process.cwd(),
|
|
||||||
"src",
|
|
||||||
"content",
|
|
||||||
"internet-telewizja"
|
|
||||||
);
|
|
||||||
const outFile = path.join(outDir, "telewizja-mozliwosci.yaml");
|
const outFile = path.join(outDir, "telewizja-mozliwosci.yaml");
|
||||||
|
|
||||||
await fs.mkdir(outDir, { recursive: true });
|
await fs.mkdir(outDir, { recursive: true });
|
||||||
await fs.writeFile(outFile, toYaml(sections), "utf8");
|
await fs.writeFile(outFile, toYaml(sections), "utf8");
|
||||||
|
|
||||||
return new Response(
|
return new Response(JSON.stringify({ ok: true, count: sections.length }), {
|
||||||
JSON.stringify({ ok: true, count: sections.length }),
|
headers: { "content-type": "application/json" },
|
||||||
{ headers: { "content-type": "application/json" } }
|
});
|
||||||
);
|
}
|
||||||
};
|
|
||||||
@@ -8,7 +8,6 @@ function getDb() {
|
|||||||
|
|
||||||
function cleanPkgName(v) {
|
function cleanPkgName(v) {
|
||||||
const s = String(v || "").trim();
|
const s = String(v || "").trim();
|
||||||
// prosta sanity: niepuste, nieprzesadnie długie
|
|
||||||
if (!s) return null;
|
if (!s) return null;
|
||||||
if (s.length > 64) return null;
|
if (s.length > 64) return null;
|
||||||
return s;
|
return s;
|
||||||
@@ -51,7 +50,7 @@ export function GET({ url }) {
|
|||||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} 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(
|
return new Response(
|
||||||
JSON.stringify({ ok: false, error: "DB_ERROR" }),
|
JSON.stringify({ ok: false, error: "DB_ERROR" }),
|
||||||
{
|
{
|
||||||
@@ -14,7 +14,6 @@ function uniq(arr) {
|
|||||||
return Array.from(new Set(arr));
|
return Array.from(new Set(arr));
|
||||||
}
|
}
|
||||||
|
|
||||||
// jeśli chcesz id do scrollowania (pkg-smart), to możesz dać slug
|
|
||||||
function slugifyPkg(name) {
|
function slugifyPkg(name) {
|
||||||
return String(name || "")
|
return String(name || "")
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -28,7 +27,6 @@ export function GET({ url }) {
|
|||||||
const q = (url.searchParams.get("q") || "").trim();
|
const q = (url.searchParams.get("q") || "").trim();
|
||||||
const limit = clamp(Number(url.searchParams.get("limit") || 50), 1, 200);
|
const limit = clamp(Number(url.searchParams.get("limit") || 50), 1, 200);
|
||||||
|
|
||||||
|
|
||||||
if (q.length < 1) {
|
if (q.length < 1) {
|
||||||
return new Response(JSON.stringify({ ok: true, data: [] }), {
|
return new Response(JSON.stringify({ ok: true, data: [] }), {
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -36,7 +34,6 @@ export function GET({ url }) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// escape LIKE wildcardów
|
|
||||||
const safe = q.replace(/[%_]/g, (m) => `\\${m}`);
|
const safe = q.replace(/[%_]/g, (m) => `\\${m}`);
|
||||||
const like = `%${safe}%`;
|
const like = `%${safe}%`;
|
||||||
|
|
||||||
@@ -68,19 +65,15 @@ export function GET({ url }) {
|
|||||||
|
|
||||||
const packages = uniq(pkgsRaw)
|
const packages = uniq(pkgsRaw)
|
||||||
.map((p) => ({
|
.map((p) => ({
|
||||||
// jeśli UI wymaga ID do scrolla, to to jest najbezpieczniejsze:
|
id: slugifyPkg(p),
|
||||||
id: slugifyPkg(p), // np. "smart" -> użyjesz jako pkg-smart
|
|
||||||
name: p,
|
name: p,
|
||||||
number: "—", // brak w nowej tabeli
|
|
||||||
guaranteed: false, // brak w nowej tabeli
|
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => a.name.localeCompare(b.name, "pl"));
|
.sort((a, b) => a.name.localeCompare(b.name, "pl"));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: r.name,
|
name: r.name,
|
||||||
logo_url: r.logo_url || "", // base64 data-url albo ""
|
logo_url: r.logo_url || "",
|
||||||
description: r.description || "",
|
description: r.description || "",
|
||||||
min_number: 0, // brak numerów
|
|
||||||
packages,
|
packages,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -90,7 +83,7 @@ export function GET({ url }) {
|
|||||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} 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" }), {
|
return new Response(JSON.stringify({ ok: false, error: "DB_ERROR" }), {
|
||||||
status: 500,
|
status: 500,
|
||||||
headers: { "Content-Type": "application/json; charset=utf-8" },
|
headers: { "Content-Type": "application/json; charset=utf-8" },
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -15,18 +15,19 @@ const html = marked.parse(doc.content);
|
|||||||
---
|
---
|
||||||
|
|
||||||
<DefaultLayout title={doc.title}>
|
<DefaultLayout title={doc.title}>
|
||||||
<section class="max-w-4xl mx-auto px-4 py-10">
|
<section class="f-section">
|
||||||
<a href="/dokumenty" class="text-sm opacity-70 hover:opacity-100">
|
<div class="f-section-grid-single">
|
||||||
← Wróć do dokumentów
|
<a href="/dokumenty" class="text-sm opacity-70 hover:opacity-100">
|
||||||
</a>
|
← Wróć do dokumentów
|
||||||
|
</a>
|
||||||
|
|
||||||
<h1 class="mt-4 text-4xl md:text-5xl font-bold text-[--f-header]">
|
<h1 class="f-section-title">
|
||||||
{doc.title}
|
{doc.title}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<article class="mt-8 prose max-w-none">
|
<div class="fuz-markdown max-w-none">
|
||||||
<Markdown text={html} />
|
<Markdown text={html} />
|
||||||
<!-- <div set:html={html} /> -->
|
</div>
|
||||||
</article>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</DefaultLayout>
|
</DefaultLayout>
|
||||||
|
|||||||
@@ -65,6 +65,24 @@ const addons: Addon[] = Array.isArray(addonsData?.dodatki)
|
|||||||
|
|
||||||
// jeśli chcesz, możesz nadpisać cenaOpis w modalu z addons.yaml:
|
// jeśli chcesz, możesz nadpisać cenaOpis w modalu z addons.yaml:
|
||||||
const addonsCenaOpis = addonsData?.cena_opis ?? cenaOpis;
|
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}>
|
<DefaultLayout seo={seo}>
|
||||||
@@ -80,6 +98,7 @@ const addonsCenaOpis = addonsData?.cena_opis ?? cenaOpis;
|
|||||||
phoneCards={phoneCards}
|
phoneCards={phoneCards}
|
||||||
addons={addons}
|
addons={addons}
|
||||||
addonsCenaOpis={addonsCenaOpis}
|
addonsCenaOpis={addonsCenaOpis}
|
||||||
|
switches={switches}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ type PhoneCard = {
|
|||||||
};
|
};
|
||||||
type PhoneYaml = { cards?: 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)
|
// ✅ dodatki z YAML (do modala)
|
||||||
type Addon = {
|
type Addon = {
|
||||||
@@ -72,16 +72,16 @@ type AddonsYaml = {
|
|||||||
dodatki?: Addon[];
|
dodatki?: Addon[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type ChannelsYaml = {
|
// type ChannelsYaml = {
|
||||||
title?: string;
|
// title?: string;
|
||||||
updated_at?: string;
|
// updated_at?: string;
|
||||||
channels?: Array<{
|
// channels?: Array<{
|
||||||
nazwa: string;
|
// nazwa: string;
|
||||||
opis?: string;
|
// opis?: string;
|
||||||
image?: string;
|
// image?: string;
|
||||||
pakiety?: string[];
|
// pakiety?: string[];
|
||||||
}>;
|
// }>;
|
||||||
};
|
// };
|
||||||
|
|
||||||
const seo = loadYamlFile<SeoYaml>(
|
const seo = loadYamlFile<SeoYaml>(
|
||||||
path.join(process.cwd(), "src", "content", "internet-telewizja", "seo.yaml"),
|
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;
|
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}>
|
<DefaultLayout seo={seo}>
|
||||||
@@ -159,6 +178,7 @@ const addonsCenaOpis = addonsYaml?.cena_opis ?? cenaOpis;
|
|||||||
decoders={decoders}
|
decoders={decoders}
|
||||||
addons={addons}
|
addons={addons}
|
||||||
addonsCenaOpis={addonsCenaOpis}
|
addonsCenaOpis={addonsCenaOpis}
|
||||||
|
switches={switches}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
background-image: linear-gradient(
|
background-image: linear-gradient(
|
||||||
to left,
|
to left,
|
||||||
rgba(0, 0, 0, 0) 0%,
|
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.5) 60%,
|
||||||
rgba(0, 0, 0, 0.75) 100%
|
rgba(0, 0, 0, 0.75) 100%
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
.fuz-markdown {
|
.fuz-markdown {
|
||||||
@apply leading-relaxed text-base md:text-lg;
|
@apply leading-relaxed text-base md:text-lg;
|
||||||
|
|
||||||
/* odstępy między elementami */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.fuz-markdown p {
|
.fuz-markdown p {
|
||||||
@@ -21,15 +19,15 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.fuz-markdown ul {
|
.fuz-markdown ul {
|
||||||
@apply list-disc pl-10 mb-4;
|
@apply list-disc pl-10 my-3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fuz-markdown ol {
|
.fuz-markdown ol {
|
||||||
@apply list-decimal pl-6 mb-4;
|
@apply list-decimal pl-6 my-3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fuz-markdown li {
|
.fuz-markdown li {
|
||||||
@apply mb-1;
|
@apply text-xl ;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fuz-markdown ul li::marker {
|
.fuz-markdown ul li::marker {
|
||||||
|
|||||||
@@ -104,18 +104,24 @@
|
|||||||
|
|
||||||
/* BAZA: checkbox | main | price */
|
/* BAZA: checkbox | main | price */
|
||||||
.f-addon-item {
|
.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;
|
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);
|
background: var(--f-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.f-addon-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
.f-addon-item:hover {
|
.f-addon-item:hover {
|
||||||
border-color: color-mix(
|
/* border-color: color-mix(
|
||||||
in srgb,
|
in srgb,
|
||||||
var(--fuz-accent, #2563eb) 70%,
|
var(--fuz-accent, #2563eb) 70%,
|
||||||
rgba(148, 163, 184, 0.5) 30%
|
rgba(148, 163, 184, 0.5) 30%
|
||||||
);
|
); */
|
||||||
}
|
}
|
||||||
|
|
||||||
.f-addon-item input[type="checkbox"] {
|
.f-addon-item input[type="checkbox"] {
|
||||||
@@ -331,3 +337,41 @@
|
|||||||
color: var(--fuz-accent, #2563eb);
|
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) */
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.f-navbar-inner {
|
.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 {
|
.f-navbar-links {
|
||||||
@@ -41,7 +41,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.f-navbar-logo {
|
.f-navbar-logo {
|
||||||
@apply w-[70] h-[36];
|
@apply block;
|
||||||
|
height: 60px; /* testowo: 44px / 40px jak chcesz ciaśniej */
|
||||||
|
width: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.f-mobile-backdrop {
|
.f-mobile-backdrop {
|
||||||
|
|||||||
@@ -14,6 +14,6 @@
|
|||||||
@apply text-[--f-switcher-text] bg-[--f-switcher-background] ;
|
@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] ;
|
@apply text-[--f-switcher-text-hover] bg-[--f-switcher-background-hover] ;
|
||||||
}
|
} */
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.f-card-row {
|
.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 {
|
.f-card-row:last-child {
|
||||||
@@ -57,11 +57,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.f-card-label {
|
.f-card-label {
|
||||||
@apply text-base font-medium opacity-80;
|
@apply text-sm font-medium opacity-80;
|
||||||
}
|
}
|
||||||
|
|
||||||
.f-card-value {
|
.f-card-value {
|
||||||
@apply text-base font-semibold text-right;
|
@apply text-sm font-semibold text-right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.f-card-value.yes {
|
.f-card-value.yes {
|
||||||
@@ -268,4 +268,47 @@
|
|||||||
|
|
||||||
.jmb-channel-desc::-webkit-scrollbar-track {
|
.jmb-channel-desc::-webkit-scrollbar-track {
|
||||||
background: transparent;
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
@layer components {
|
@layer components {
|
||||||
|
|
||||||
.f-section-header {
|
.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 {
|
.f-section {
|
||||||
@apply pt-10 pb-1 mx-2;
|
@apply pt-1 pb-1 mx-2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.f-section-center {
|
.f-section-center {
|
||||||
@@ -13,7 +15,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.f-section-grid {
|
.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 {
|
.f-section-grid-single {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
--brand-light: hsl(var(--brand-hue) var(--brand-saturation) var(--brand-lightness));
|
--brand-light: hsl(var(--brand-hue) var(--brand-saturation) var(--brand-lightness));
|
||||||
--link-color-light: hsla(210, 100%, 40%, 1);
|
--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); */
|
/* --link-background-light: hsla(0 0% 50% 1); */
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user