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