Przebudowa stron na indywidualne karty , pobierane z bazy danych

This commit is contained in:
dm
2025-12-11 15:25:00 +01:00
parent 49c5beb362
commit 0cf7c45131
27 changed files with 1133 additions and 420 deletions

View File

@@ -5,7 +5,8 @@
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview"
"preview": "astro preview",
"update:jambox:base": "node src/scripts/update-jambox-base.js"
},
"dependencies": {
"@astrojs/node": "^9.5.1",
@@ -13,6 +14,7 @@
"@preact/signals": "^2.5.1",
"astro": "^5.16.0",
"better-sqlite3": "^12.4.6",
"fast-xml-parser": "^5.3.2",
"globby": "^16.0.0",
"js-yaml": "^4.1.0",
"jsdom": "^27.2.0",

View File

@@ -1,27 +0,0 @@
title:
- "Internet światłowodowy"
subtitle:
- Internet bez kompromisów
- Szybkie i stabilne łącze
- Optymalna oferta
- Lokalny operator, znamy Twoją okolicę
# description: |
# Szybki i stabilny Internet światłowodowy w Wyszkowie oraz okolicach.
# Sprawdź zasięg usług i wybierz najlepsze łącze dla swojego domu.
imageUrl: "section-fiber.webp"
ctas:
- label: "Zobacz ofertę Telewizji"
href: "/internet-telewizja"
title: "Przejdź do oferty Internet + Telewizja w FUZ"
primary: false
- label: "Zobacz ofertę Telefonu "
href: "/telefon"
primary: false
title: "Przejdź do oferty telefonu"
# - label: "Sprawdź dostępność usługi"
# href: "/mapa-zasiegu"
# title: "Sprawdź zasięg Internetu światłowodowego FUZ"
# primary: false

View File

@@ -1,153 +0,0 @@
przelaczniki:
- id: "budynek"
etykieta: "Rodzaj budynku"
domyslny: "jednorodzinny"
title: "Zmień rodzaj budynku by zobaczyć odpowiednie ceny"
opcje:
- id: "jednorodzinny"
nazwa: "Jednorodzinny"
- id: "wielorodzinny"
nazwa: "Wielorodzinny"
- id: "umowa"
etykieta: "Okres umowy"
domyslny: "24m"
title: "Wybierz okres umowy by zobaczyć odpowiednie ceny"
opcje:
- id: "24m"
nazwa: "24 miesiące"
- id: "bezterminowa"
nazwa: "Bezterminowa"
funkcje:
- id: "pobieranie"
etykieta: "Prędkość pobierania"
- id: "wysylanie"
etykieta: "Prędkość wysyłania"
- id: "router"
etykieta: "Router Wi-Fi"
- id: "adres_ip"
etykieta: "Adres IP"
- id: "umowa_info"
etykieta: "Umowa"
- id: "instalacja"
etykieta: "Aktywacja"
plany:
- id: "fiber100"
nazwa: "FIBER 100"
popularny: false
ceny:
jednorodzinny:
24m: 64
bezterminowa: 84
wielorodzinny:
24m: 54
bezterminowa: 74
koszty:
instalacja:
jednorodzinny:
24m: 149
bezterminowa: 199
wielorodzinny:
24m: 99
bezterminowa: 149
funkcje:
pobieranie: "do 100 Mb/s"
wysylanie: "do 50 Mb/s"
router: true
adres_ip: "Dynamiczny"
umowa_info: "12 / 24 / bez umowy"
- id: "fiber300"
nazwa: "FIBER 300"
popularny: true
ceny:
jednorodzinny:
24m: 75
bezterminowa: 95
wielorodzinny:
24m: 65
bezterminowa: 85
koszty:
instalacja:
jednorodzinny:
24m: 149
bezterminowa: 199
wielorodzinny:
24m: 99
bezterminowa: 149
funkcje:
pobieranie: "do 300 Mb/s"
wysylanie: "do 150 Mb/s"
router: true
adres_ip: "Dynamiczny"
umowa_info: "12 / 24 / bez umowy"
- id: "fiber600"
nazwa: "FIBER 600"
popularny: false
ceny:
jednorodzinny:
24m: 85
bezterminowa: 105
wielorodzinny:
24m: 75
bezterminowa: 95
koszty:
instalacja:
jednorodzinny:
24m: 149
bezterminowa: 199
wielorodzinny:
24m: 99
bezterminowa: 149
funkcje:
pobieranie: "do 600 Mb/s"
wysylanie: "do 300 Mb/s"
router: true
adres_ip: "Dynamiczny"
umowa_info: "24 / bez umowy"
addons:
- id: "public_ip"
nazwa: "Publiczny adres IP"
typ: "checkbox"
cena: 18.45
- id: "telefon"
nazwa: "Telefon VoIP"
typ: "select"
opis: "Wybierz pakiet telefonii"
opcje:
- id: "brak"
nazwa: "Bez telefonu"
cena: 0
- id: "tele30"
nazwa: "TELE 30"
cena: 9.90
- id: "tele100"
nazwa: "TELE 100"
cena: 15.00
- id: "tele300"
nazwa: "TELE 300"
cena: 29.00
- id: "tele500"
nazwa: "TELE 500"
cena: 44.00

View File

@@ -1,22 +0,0 @@
title:
- INTERNET ŚWIATŁOWODOWY
paragraphs:
- title:
content: |
Wybierz rodzaj budynku i czas trwania umowy
# Gwarantują błyskawiczną prędkość, stabilność i niezawodność bez względu na warunki.
# Dedykowane pasmo,pełna prędkość tylko dla Ciebie.
# Stała jakość, pogoda ani liczba użytkowników w sieci nie mają znaczenia.
# Wiele urządzeń jednocześnie, komputer, telefon, tablet, konsola wszystko działa płynnie.
# Światłowód to również dostęp do telewizji i telefonu w najwyższej jakości.
# Sprawdź naszą pełną ofertę i wybierz rozwiązanie dopasowane do Twoich potrzeb.
# - title:
# content:

View File

@@ -1,21 +1,18 @@
sections:
- title: Sprawdź dostępność usługi
image: section-range.webp
button:
text: "Sprawdź dostępność pod Twoim adresem →"
url: "/mapa-zasiegu"
title: "Sprawdź zasięg Internetu światłowodowego FUZ"
content: |
Naszą sieć światłowodową systematycznie rozbudowujemy, ale infrastruktura nie dociera jeszcze do wszystkich adresów.
Proces budowy wymaga czasu może jednak akurat Twoja lokalizacja jest już podłączona?
[Sprawdź](/mapa-zasiegu "Sprawdź zasięg naszego Internetu") na interaktywnej mapie, czy internet światłowodowy jest już dostępny pod Twoim adresem.
# - title: Sprawdź dostępność usługi
# image:
# button:
# text: "Sprawdź dostępność pod Twoim adresem →"
# url: "/mapa-zasiegu"
# title: "Sprawdź zasięg Internetu światłowodowego FUZ"
# content: |
# Naszą sieć światłowodową systematycznie rozbudowujemy, ale infrastruktura nie dociera jeszcze do wszystkich adresów.
# Sprawdź zasięg naszego Internetu na interaktywnej mapie, czy internet światłowodowy jest już dostępny pod Twoim adresem.
- title: Router WiFi HL-4BX3V-F
image: "HL-4BX3V-F.webp"
content: |
Nowoczesny router marki HALNy to urządzenie stworzone z myślą o wymagających użytkownikach.
W ramach instalacji otrzymujesz nowoczesny router marki HALNy to urządzenie stworzone z myślą o wymagających użytkownikach.
Znajdziesz w nim nowoczesny standard WiFi 6, porty 2,5Gb/s oraz 1 Gb/s, wsparcie dla sieci Mesh i VoIP. Stabilność, niezawodność i pełne wykorzystanie łącza w całym Twoim domu.

View File

@@ -1,26 +0,0 @@
title:
- Telefon usługa dodatkowa
subtitle:
- Niezawodna łączność głosowa dzięki technologii VoIP
- Atrakcyjne ceny
- Bez telefonu jak bez ręki
# - Wszystko, czego potrzebujesz w jednym miejscu
# description: |
# Dziś dla wielu to niezbędne narzędzie pełne funkcji „bez telefonu jak bez ręki".
imageUrl: section-telefon.webp
ctas:
- label: "Zobacz ofertę Internetu"
href: "/internet-swiatlowodowy"
title: "Przejdź do oferty Internetu światłowodowego"
primary: false
- label: "Zobacz ofertę Telewizji"
href: "/internet-telewizja"
title: "Przejdź do oferty Internet + Telewizja w FUZ"
primary: false
# - label: "Sprawdź dostępność usługi "
# href: "/mapa-zasiegu"
# title: "Sprawdź zasięg Internetu światłowodowego FUZ"
# primary: false

View File

@@ -1,55 +0,0 @@
funkcje:
- id: "minuty_darmowe"
etykieta: "Darmowe minuty"
- id: "cena_minuta_stacjonarna"
etykieta: "Połączenia do krajowych sieci stacjonarnych"
- id: "cena_minuta_komorkowe"
etykieta: "Połączenia do krajowych sieci komórkowych"
- id: "instalacja"
etykieta: "Aktywacja"
plany:
- id: "tele30"
nazwa: "TELE 30"
popularny: false
cena: 9.90
funkcje:
minuty_darmowe: "30"
cena_minuta_stacjonarna: "0,07 zł / min."
cena_minuta_komorkowe: "0,19 zł / min"
instalacja: "1,23 zł"
- id: "tele100"
nazwa: "TELE 100"
popularny: false
cena: 15.00
funkcje:
minuty_darmowe: "100"
cena_minuta_stacjonarna: "0,07 zł / min."
cena_minuta_komorkowe: "0,19 zł / min"
instalacja: "1,23 zł"
- id: "tele300"
nazwa: "TELE 300"
popularny: false
cena: 29.00
funkcje:
minuty_darmowe: "300"
cena_minuta_stacjonarna: "0,07 zł / min."
cena_minuta_komorkowe: "0,19 zł / min"
instalacja: "1,23 zł"
- id: "tele500"
nazwa: "TELE 500"
popularny: false
cena: 44.00
funkcje:
minuty_darmowe: "500"
cena_minuta_stacjonarna: "0,07 zł / min."
cena_minuta_komorkowe: "0,19 zł / min"
instalacja: "1,23 zł"
addons: []

View File

@@ -1,27 +0,0 @@
title:
- USŁUGA TELEFONU
paragraphs:
- title:
# content: |
# Od czasów Aleksandra Bella telefon przeszedł niesamowitą ewolucję.
# Dziś dla wielu to niezbędne narzędzie pełne funkcji „bez telefonu jak bez ręki".
# Ale są też tacy, którzy używają go po prostu do rozmów. I to w zupełności wystarczy.
# Stworzyliśmy zróżnicowaną ofertę, abyś mógł wybrać rozwiązanie idealnie dopasowane do swoich potrzeb.
# Od prostej linii telefonicznej po rozbudowane opcje.
# Zapoznaj się z naszymi propozycjami i wybierz to, co jest dla Ciebie najlepsze.
- title:
content: |
Usługa telefonu stacjonarnego dostępna jest wyłącznie w pakiecie z Internetem światłowodowym lub Internetem z telewizją.
Dzięki temu możesz cieszyć się niezawodną łącznością głosową dzięki technologii VoIP, jednocześnie korzystając z szybkiego i stabilnego dostępu do Internetu oraz bogatej oferty telewizyjnej.
Wybierz naszą usługę telefonu stacjonarnego z [internetem światłowodowym](/internet-swiatlowodowy "Przejdź do oferty Internetu światłowodowego") lub [internetem + telewizją](/internet-telewizja "Przejdź do oferty Internet + Telewizja w FUZ") i zyskaj kompleksowe rozwiązanie komunikacyjne dostosowane do Twoich potrzeb.
# Kolejne sekcje mozna dodawać poja wiać się bedą pod tabela produktów

View File

@@ -1,4 +1,12 @@
sections:
- title:
content: |
Usługa telefonu stacjonarnego dostępna jest wyłącznie w pakiecie z Internetem światłowodowym lub Internetem z telewizją.
Dzięki temu możesz cieszyć się niezawodną łącznością głosową dzięki technologii VoIP, jednocześnie korzystając z szybkiego i stabilnego dostępu do Internetu oraz bogatej oferty telewizyjnej.
Wybierz naszą usługę telefonu stacjonarnego z [internetem światłowodowym](/internet-swiatlowodowy "Przejdź do oferty Internetu światłowodowego") lub [internetem + telewizją](/internet-telewizja "Przejdź do oferty Internet + Telewizja w FUZ") i zyskaj kompleksowe rozwiązanie komunikacyjne dostosowane do Twoich potrzeb.
- title: Zachowaj dotychczasowy numer telefonu
image: "przeniesienie.png"
content: |

Binary file not shown.

View File

@@ -0,0 +1,193 @@
import { useEffect, useMemo, useState } from "preact/hooks";
import "../../styles/offers/offers-table.css"; //
const BUILDING_MAP = {
jednorodzinny: 1,
wielorodzinny: 2,
};
const CONTRACT_MAP = {
"24m": 1,
bezterminowa: 2,
};
export default function JamboxBasePackages({
source = "PLUS",
title,
selected = {},
}) {
const [packages, setPackages] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
// wyliczamy kody na podstawie tego samego selected, którego używasz w OffersCards
const buildingCode = useMemo(() => {
const val = selected?.budynek;
return BUILDING_MAP[val] ?? 1; // domyślnie jednorodzinny
}, [selected?.budynek]);
const contractCode = useMemo(() => {
const val = selected?.umowa;
return CONTRACT_MAP[val] ?? 1; // domyślnie 24m
}, [selected?.umowa]);
useEffect(() => {
let cancelled = false;
async function load() {
setLoading(true);
setError("");
try {
const params = new URLSearchParams();
if (source && source !== "ALL") {
params.set("source", source);
}
if (buildingCode) {
params.set("building", String(buildingCode));
}
if (contractCode) {
params.set("contract", String(contractCode));
}
const query = params.toString();
const url = `/api/jambox/base-packages${query ? `?${query}` : ""}`;
const res = await fetch(url);
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const json = await res.json();
if (!cancelled) {
setPackages(Array.isArray(json.data) ? json.data : []);
}
} catch (err) {
console.error("Błąd pobierania pakietów JAMBOX:", err);
if (!cancelled) {
setError("Nie udało się załadować pakietów JAMBOX.");
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
load();
return () => {
cancelled = true;
};
}, [source, buildingCode, contractCode]);
const effectiveTitle =
title ||
(source === "PLUS"
? "Pakiety podstawowe JAMBOX PLUS"
: source === "EVIO"
? "Pakiety podstawowe JAMBOX EVIO"
: "Pakiety podstawowe JAMBOX");
if (loading) {
return (
<section class="f-offers">
<h2 class="f-offers-title">{effectiveTitle}</h2>
<p>Ładowanie pakietów...</p>
</section>
);
}
if (error) {
return (
<section class="f-offers">
<h2 class="f-offers-title">{effectiveTitle}</h2>
<p class="text-red-600">{error}</p>
</section>
);
}
if (!packages.length) {
return (
<section class="f-offers">
<h2 class="f-offers-title">{effectiveTitle}</h2>
<p>Brak pakietów do wyświetlenia.</p>
</section>
);
}
return (
<section class="f-offers">
{effectiveTitle && <h2 class="f-offers-title">{effectiveTitle}</h2>}
<div class={`f-offers-grid f-count-${packages.length}`}>
{packages.map((pkg) => (
<JamboxPackageCard key={`${pkg.source}-${pkg.tid}`} pkg={pkg} />
))}
</div>
</section>
);
}
function JamboxPackageCard({ pkg }) {
const updatedDate = pkg.updated_at
? new Date(pkg.updated_at).toLocaleDateString("pl-PL")
: "-";
const hasPrice = pkg.price_monthly != null;
const hasInstall = pkg.price_installation != null;
return (
<div class="f-card">
<div class="f-card-header">
<div class="f-card-name">{pkg.name}</div>
{/* TU zamiast JAMBOX PLUS/EVIO pokazujemy cenę */}
<div class="f-card-price">
{hasPrice
? `${pkg.price_monthly} zł/mies.`
: pkg.source === "PLUS"
? "JAMBOX PLUS"
: "JAMBOX EVIO"}
</div>
</div>
<ul class="f-card-features">
{/* ID / slug jako techniczne info */}
<li class="f-card-row">
<span class="f-card-label">ID pakietu</span>
<span class="f-card-value">{pkg.tid}</span>
</li>
<li class="f-card-row">
<span class="f-card-label">Slug</span>
<span class="f-card-value">{pkg.slug || "—"}</span>
</li>
{/* nowa linia: cena instalacji z bazy */}
<li class="f-card-row">
<span class="f-card-label">Aktywacja</span>
<span class="f-card-value">
{hasInstall ? `${pkg.price_installation}` : "—"}
</span>
</li>
{/* opcjonalnie informacja o źródle pakietu */}
<li class="f-card-row">
<span class="f-card-label">Platforma</span>
<span class="f-card-value">
{pkg.source === "PLUS" ? "JAMBOX PLUS" : "JAMBOX EVIO"}
</span>
</li>
<li class="f-card-row">
<span class="f-card-label">Ostatnia aktualizacja</span>
<span class="f-card-value">{updatedDate}</span>
</li>
</ul>
</div>
);
}

View File

@@ -1,19 +1,141 @@
import { useEffect, useState } from "preact/hooks";
// import "../../styles/offers/offers-switches.css";
export default function OffersSwitches({ switches, selected, onSwitch }) {
if (!switches.length) return null;
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));
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("");
// AUTO: pobieramy konfigurację z API
useEffect(() => {
if (isControlled) return;
let cancelled = false;
async function load() {
setLoading(true);
setError("");
try {
const res = await fetch("/api/internet");
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.dispatchEvent(
new CustomEvent("fuz:switch-change", {
detail: {
id: null,
value: null,
selected: initial,
labels, // tu lecą etykiety z DB
},
}),
);
} catch (err) {
console.error("❌ Błąd pobierania switchy:", err);
if (!cancelled) setError("Nie udało się załadować przełączników.");
} finally {
if (!cancelled) setLoading(false);
}
}
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.dispatchEvent(
new CustomEvent("fuz:switch-change", {
detail: {
id,
value,
selected: next,
labels, // etykiety po kliknięciu
},
}),
);
return next;
});
}
};
if (!isControlled && loading) {
return (
<div class="f-switches-wrapper">
<p>Ładowanie opcji przełączników...</p>
</div>
);
}
if (!isControlled && error) {
return (
<div class="f-switches-wrapper">
<p class="text-red-600">{error}</p>
</div>
);
}
if (!effectiveSwitches.length) return null;
return (
<div class="f-switches-wrapper">
{switches.map((sw) => (
{effectiveSwitches.map((sw) => (
<div class="f-switch-box">
<div class="f-switch-group">
{sw.opcje.map((op) => (
<button
type="button"
class={`f-switch ${selected[sw.id] === op.id ? "active" : ""
class={`f-switch ${
String(effectiveSelected[sw.id]) === String(op.id)
? "active"
: ""
}`}
onClick={() => onSwitch(sw.id, op.id)}
onClick={() => handleClick(sw.id, op.id)}
title={sw.title}
>
{op.nazwa}

View File

@@ -0,0 +1,138 @@
import { useEffect, useState } from "preact/hooks";
import "../styles/offers/offers-table.css";
export default function InternetDbOffersCards({
title = "Oferty Internetu FUZ",
}) {
const [selected, setSelected] = useState({});
const [labels, setLabels] = useState({});
const [plans, setPlans] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
// nasłuchuj globalnego eventu z OffersSwitches
useEffect(() => {
function handler(e) {
const detail = e.detail || {};
if (detail.selected) {
setSelected(detail.selected);
}
if (detail.labels) {
setLabels(detail.labels);
}
}
window.addEventListener("fuz:switch-change", handler);
return () => window.removeEventListener("fuz:switch-change", handler);
}, []);
const buildingCode = Number(selected.budynek) || 1;
const contractCode = Number(selected.umowa) || 1;
useEffect(() => {
if (!buildingCode || !contractCode) return;
let cancelled = false;
async function load() {
setLoading(true);
setError("");
try {
const params = new URLSearchParams({
building: String(buildingCode),
contract: String(contractCode),
});
const res = await fetch(`/api/internet/plans?${params.toString()}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
if (!cancelled) {
setPlans(Array.isArray(json.data) ? json.data : []);
}
} catch (err) {
console.error("Błąd pobierania planów internetu:", err);
if (!cancelled) setError("Nie udało się załadować ofert.");
} finally {
if (!cancelled) setLoading(false);
}
}
load();
return () => {
cancelled = true;
};
}, [buildingCode, contractCode]);
const contractLabel = labels.umowa || "";
return (
<section class="f-offers">
{loading && <p>Ładowanie ofert...</p>}
{error && <p class="text-red-600">{error}</p>}
{!loading && !error && (
<div class={`f-offers-grid f-count-${plans.length || 1}`}>
{plans.map((plan) => (
<OfferCard
key={plan.id}
plan={plan}
contractLabel={contractLabel}
/>
))}
</div>
)}
</section>
);
}
function OfferCard({ plan, contractLabel }) {
const basePrice = plan.price_monthly;
const installPrice = plan.price_installation;
const featureRows = (plan.features || []).filter(
(f) => f.id !== "umowa_info" && f.id !== "instalacja"
);
return (
<div class={`f-card ${plan.popular ? "f-card-popular" : ""}`}>
{/* {plan.popular && <div class="f-card-badge">Najczęściej wybierany</div>} */}
<div class="f-card-header">
<div class="f-card-name">{plan.name}</div>
<div class="f-card-price">{basePrice} /mies.</div>
</div>
<ul class="f-card-features">
{featureRows.map((f) => {
let val = f.value;
let display;
if (val === true || val === "true") display = "✓";
else if (val === false || val === "false" || val == null) display = "✕";
else display = val;
return (
<li class="f-card-row">
<span class="f-card-label">{f.label}</span>
<span class="f-card-value">{display}</span>
</li>
);
})}
<li class="f-card-row">
<span class="f-card-label">Umowa</span>
<span class="f-card-value">{contractLabel}</span>
</li>
<li class="f-card-row">
<span class="f-card-label">Aktywacja</span>
<span class="f-card-value">
{installPrice != null ? `${installPrice}` : "—"}
</span>
</li>
</ul>
</div>
);
}

View File

@@ -1,7 +1,7 @@
import { useState } from "preact/hooks";
import OffersSwitches from "./Offers/OffersSwitches.jsx";
import OffersCards from "./Offers/OffersTable.jsx"; // <-- WAŻNE!!
import OffersCards from "./Offers/OffersCards.jsx"; // <-- WAŻNE!!
import OffersExtraServices from "./Offers/OffersExtraServices.jsx";
export default function OffersIsland({ data }) {

View File

@@ -0,0 +1,77 @@
import { useEffect, useState } from "preact/hooks";
import "../styles/offers/offers-table.css";
export default function PhoneDbOffersCards({
title = "Telefonia stacjonarna FUZ",
}) {
const [plans, setPlans] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
let cancelled = false;
async function load() {
setLoading(true);
try {
const res = await fetch("/api/phone/plans");
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
if (!cancelled) {
setPlans(Array.isArray(json.data) ? json.data : []);
}
} catch (err) {
console.error("❌ Błąd pobierania planów telefonii:", err);
if (!cancelled) {
setError("Nie udało się załadować pakietów telefonicznych.");
}
} finally {
if (!cancelled) setLoading(false);
}
}
load();
return () => {
cancelled = true;
};
}, []);
return (
<section class="f-offers">
{loading && <p>Ładowanie pakietów telefonicznych...</p>}
{error && <p class="text-red-600">{error}</p>}
{!loading && !error && (
<div class={`f-offers-grid f-count-${plans.length || 1}`}>
{plans.map((plan) => (
<PhoneOfferCard key={plan.id} plan={plan} />
))}
</div>
)}
</section>
);
}
function PhoneOfferCard({ plan }) {
return (
<div class={`f-card ${plan.popular ? "f-card-popular" : ""}`}>
{plan.popular && <div class="f-card-badge">Najczęściej wybierany</div>}
<div class="f-card-header">
<div class="f-card-name">{plan.name}</div>
<div class="f-card-price">{plan.price_monthly} /mies.</div>
</div>
<ul class="f-card-features">
{plan.features.map((f) => (
<li class="f-card-row">
<span class="f-card-label">{f.label}</span>
<span class="f-card-value">{f.value}</span>
</li>
))}
</ul>
</div>
);
}

71
src/pages/api/internet.js Normal file
View File

@@ -0,0 +1,71 @@
// src/pages/api/switches/internet.js
import Database from "better-sqlite3";
const 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 code")
.all();
const contractTypes = db
.prepare(
"SELECT code, label FROM jambox_contract_types ORDER BY 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, // 1,2,...
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, // 1,2,...
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/internet:", err);
return new Response(
JSON.stringify({ ok: false, error: err.message || "DB_ERROR" }),
{
status: 500,
headers: {
"Content-Type": "application/json; charset=utf-8",
},
}
);
} finally {
db.close();
}
}

View File

@@ -0,0 +1,110 @@
// src/pages/api/internet/plans.js
import Database from "better-sqlite3";
const DB_PATH = "./src/data/ServicesRange.db";
function getDb() {
return new Database(DB_PATH, { readonly: true });
}
/**
* GET /api/internet/plans?building=1|2&contract=1|2
*/
export function GET({ url }) {
const buildingParam = url.searchParams.get("building");
const contractParam = url.searchParams.get("contract");
const building = buildingParam ? Number(buildingParam) : 1;
const contract = contractParam ? Number(contractParam) : 1;
const db = getDb();
try {
const stmt = db.prepare(
`
SELECT
p.id AS plan_id,
p.name AS plan_name,
p.popular AS plan_popular,
pr.price_monthly AS price_monthly,
pr.price_installation AS price_installation,
f.id AS feature_id,
f.label AS feature_label,
fv.value AS feature_value
FROM internet_plans p
LEFT JOIN internet_plan_prices pr
ON pr.plan_id = p.id
AND pr.building_type = ?
AND pr.contract_type = ?
LEFT JOIN internet_plan_feature_values fv
ON fv.plan_id = p.id
LEFT JOIN internet_features f
ON f.id = fv.feature_id
ORDER BY p.id ASC, f.id ASC;
`.trim()
);
const rows = stmt.all(building, contract);
// grupowanie do struktury: jeden plan = jedna karta
const byPlan = new Map();
for (const row of rows) {
if (!byPlan.has(row.plan_id)) {
byPlan.set(row.plan_id, {
id: row.plan_id,
code: row.plan_code,
name: row.plan_name,
popular: !!row.plan_popular,
price_monthly: row.price_monthly,
price_installation: row.price_installation,
features: [], // później wypełniamy
});
}
if (row.feature_id) {
byPlan.get(row.plan_id).features.push({
id: row.feature_id,
label: row.feature_label,
value: row.feature_value,
});
}
}
const data = Array.from(byPlan.values());
return new Response(
JSON.stringify({
ok: true,
building,
contract,
count: data.length,
data,
}),
{
status: 200,
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "public, max-age=30",
},
}
);
} catch (err) {
console.error("❌ Błąd w /api/internet/plans:", err);
return new Response(
JSON.stringify({ ok: false, error: "DB_ERROR" }),
{
status: 500,
headers: { "Content-Type": "application/json; charset=utf-8" },
}
);
} finally {
db.close();
}
}

View File

@@ -0,0 +1,152 @@
// src/pages/api/jambox/base-packages.js
import Database from "better-sqlite3";
const DB_PATH = "./src/data/ServicesRange.db"; // dostosuj, jeśli masz gdzie indziej
function getDb() {
return new Database(DB_PATH, { readonly: true });
}
/**
* GET /api/jambox/base-packages
* ?source=PLUS|EVIO
* &building=1|2 (1=jednorodzinny, 2=wielorodzinny)
* &contract=1|2 (1=24m, 2=bezterminowa)
*/
export function GET({ url }) {
const sourceParam = url.searchParams.get("source");
const source = sourceParam ? sourceParam.toUpperCase() : null;
const buildingParam = url.searchParams.get("building");
const contractParam = url.searchParams.get("contract");
const building = buildingParam ? Number(buildingParam) : null;
const contract = contractParam ? Number(contractParam) : null;
const db = getDb();
try {
let rows = [];
const hasVariant =
Number.isInteger(building) && Number.isInteger(contract);
if (source === "PLUS" || source === "EVIO") {
if (hasVariant) {
// pakiety + ceny dla danego budynku/umowy
const stmt = db.prepare(
`
SELECT
p.id,
p.source,
p.tid,
p.name,
p.slug,
p.sort_order,
p.updated_at,
pr.price_monthly,
pr.price_installation,
pr.currency
FROM jambox_base_packages p
LEFT JOIN jambox_base_package_prices pr
ON pr.package_id = p.id
AND pr.building_type = ?
AND pr.contract_type = ?
WHERE p.source = ?
ORDER BY p.sort_order ASC, p.name ASC;
`.trim()
);
rows = stmt.all(building, contract, source);
} else {
// tylko pakiety, bez cen
const stmt = db.prepare(
`
SELECT
p.id,
p.source,
p.tid,
p.name,
p.slug,
p.sort_order,
p.updated_at
FROM jambox_base_packages p
WHERE p.source = ?
ORDER BY p.sort_order ASC, p.name ASC;
`.trim()
);
rows = stmt.all(source);
}
} else {
// bez filtra source (raczej nie użyjesz, ale niech będzie poprawnie)
if (hasVariant) {
const stmt = db.prepare(
`
SELECT
p.id,
p.source,
p.tid,
p.name,
p.slug,
p.sort_order,
p.updated_at,
pr.price_monthly,
pr.price_installation,
pr.currency
FROM jambox_base_packages p
LEFT JOIN jambox_base_package_prices pr
ON pr.package_id = p.id
AND pr.building_type = ?
AND pr.contract_type = ?
ORDER BY p.source ASC, p.sort_order ASC, p.name ASC;
`.trim()
);
rows = stmt.all(building, contract);
} else {
const stmt = db.prepare(
`
SELECT
p.id,
p.source,
p.tid,
p.name,
p.slug,
p.sort_order,
p.updated_at
FROM jambox_base_packages p
ORDER BY p.source ASC, p.sort_order ASC, p.name ASC;
`.trim()
);
rows = stmt.all();
}
}
return new Response(
JSON.stringify({
ok: true,
source: source ?? "ALL",
building,
contract,
count: rows.length,
data: rows,
}),
{
status: 200,
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "public, max-age=60",
},
}
);
} catch (err) {
console.error("❌ Błąd odczytu z bazy jambox_base_packages:", err);
return new Response(
JSON.stringify({ ok: false, error: "DB_ERROR" }),
{
status: 500,
headers: {
"Content-Type": "application/json; charset=utf-8",
},
}
);
} finally {
db.close();
}
}

View File

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

View File

@@ -1,9 +1,8 @@
---
import DefaultLayout from "../../layouts/DefaultLayout.astro";
import Hero from "../../components/hero/Hero.astro";
import OffersSwitches from "../../islands/Offers/OffersSwitches.jsx";
import InternetDbOffersCards from "../../islands/OffersInternetCards.jsx";
import SectionRenderer from "../../components/sections/SectionRenderer.astro";
import Markdown from "../../islands/Markdown.jsx";
import OffersIsland from "../../islands/OffersIsland.jsx";
import yaml from "js-yaml";
import fs from "fs";
@@ -11,52 +10,19 @@ import fs from "fs";
const seo = yaml.load(
fs.readFileSync("./src/content/internet-swiatlowodowy/seo.yaml", "utf8"),
);
const hero = yaml.load(
fs.readFileSync("./src/content/internet-swiatlowodowy/hero.yaml", "utf8"),
);
const page = yaml.load(
fs.readFileSync("./src/content/internet-swiatlowodowy/page.yaml", "utf8"),
);
type Paragraph = {
title?: string;
content: string;
};
const data = yaml.load(
fs.readFileSync("./src/content/internet-swiatlowodowy/offers.yaml", "utf8"),
);
const first = page.paragraphs[0];
const rest = page.paragraphs.slice(1);
---
<DefaultLayout seo={seo}>
<Hero {...hero} />
<section class="f-section">
<div class="f-section-grid-single md:grid-cols-1">
{page.title.map((line: any) => <h1 class="f-section-title">{line}</h1>)}
{first.title && <h3>{first.title}</h3>}
<Markdown text={first.content} />
<h1 class="f-section-title">Internet światłowodowy</h1>
<div class="fuz-markdown max-w-none">
<p>Wybierz rodzaj budynku i czas trwania umowy</p>
</div>
<OffersSwitches client:load />
<InternetDbOffersCards client:load />
</div>
</section>
<section class="f-section">
<div class="f-section-grid-single md:grid-cols-1">
<OffersIsland client:load data={data} />
</div>
</section>
{
rest.map((p: Paragraph) => (
<section class="f-section">
<div class="f-section-grid-single md:grid-cols-1">
{p.title && <h3 class="f-section-title">{p.title}</h3>}
<Markdown text={p.content.replace(/\n/g, "\n\n")} />
</div>
</section>
))
}
<SectionRenderer src="./src/content/internet-swiatlowodowy/section.yaml" />
</DefaultLayout>

View File

@@ -6,6 +6,7 @@ import Markdown from "../../islands/Markdown.jsx";
import Modal from "../../islands/Modal.jsx";
import OffersIsland from "../../islands/OffersIsland.jsx";
import JamboxMozliwosci from "../../components/sections/SectionJamboxMozliwosci.astro";
import JamboxBasePackages from "../../islands/Offers/JamboxBasePackages.jsx";
import yaml from "js-yaml";
import fs from "fs";
@@ -48,12 +49,18 @@ const rest = page.paragraphs.slice(1);
<section class="f-section">
<div class="f-section-grid-single md:grid-cols-1 max-w-6xl mx-auto">
<JamboxBasePackages
client:load
source=""
title=""/>
<OffersIsland client:load data={data} />
</div>
</section>
<!-- <OffersIsland client:load data={data} /> -->
{
rest.map((p: Paragraph) => (
<section class="f-section">

View File

@@ -146,7 +146,7 @@ const mapStyleId = "8e0a97af9476f2d3";
<div class="f-info-header">
<div class="f-info-heading">
${
result.availableFiber
result.available
? `<span class="ok">✔</span> Internet światłowodowy dostępny`
: `<span class="no">✖</span> Światłowód niedostępny`
}

View File

@@ -1,9 +1,7 @@
---
import DefaultLayout from "../../layouts/DefaultLayout.astro";
import Hero from "../../components/hero/Hero.astro";
import SectionRenderer from "../../components/sections/SectionRenderer.astro";
import Markdown from "../../islands/Markdown.jsx";
import OffersIsland from "../../islands/OffersIsland.jsx";
import OffersPhoneCards from "../../islands/OffersPhoneCards.jsx";
import yaml from "js-yaml";
import fs from "fs";
@@ -11,44 +9,18 @@ import fs from "fs";
const seo = yaml.load(
fs.readFileSync("./src/content/telefon/seo.yaml", "utf8"),
);
const hero = yaml.load(
fs.readFileSync("./src/content/telefon/hero.yaml", "utf8"),
);
const page = yaml.load(
fs.readFileSync("./src/content/telefon/page.yaml", "utf8"),
);
type Paragraph = {
title?: string;
content: string;
};
const data = yaml.load(
fs.readFileSync("./src/content/telefon/offers.yaml", "utf8"),
);
const first = page.paragraphs[0];
const rest = page.paragraphs.slice(1);
---
<DefaultLayout seo={seo}>
<Hero {...hero} />
<section class="f-section">
<div class="f-section-grid-single md:grid-cols-1">
{page.title.map((line: any) => <h1 class="f-section-title">{line}</h1>)}
{first.title && <h3>{first.title}</h3>}
<Markdown text={first.content} />
</div>
</section>
<section class="f-section">
<div class="f-section-grid-single md:grid-cols-1">
<OffersIsland client:load data={data} />
<h1 class="f-section-title">Usługa telefonu</h1>
<OffersPhoneCards client:load />
</div>
</section>
<!-- <OffersIsland client:load data={data} /> -->
{
<!-- {
rest.map((p: Paragraph) => (
<section class="f-section">
<div class="f-section-grid-single md:grid-cols-1">
@@ -57,7 +29,7 @@ const rest = page.paragraphs.slice(1);
</div>
</section>
))
}
} -->
<SectionRenderer src="./src/content/telefon/section.yaml" />
</DefaultLayout>

View File

@@ -0,0 +1,134 @@
import Database from "better-sqlite3";
import { XMLParser } from "fast-xml-parser";
const DB_PATH = "./src/data/ServicesRange.db";
const SOURCES = [
{
source: "EVIO",
url: "https://www.jambox.pl/xml/slownik-pakietypodstawoweevio.xml",
},
{
source: "PLUS",
url: "https://www.jambox.pl/xml/slownik-pakietypodstawoweplus.xml",
},
];
function slugify(str) {
if (!str) return null;
return str
.toString()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
async function fetchXml(url) {
const res = await fetch(url);
if (!res.ok) {
throw new Error(`Błąd pobierania XML z ${url}: ${res.status} ${res.statusText}`);
}
return await res.text();
}
function parseNodesFromXml(xmlText) {
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: "@_",
});
const json = parser.parse(xmlText);
const nodes = json.xml?.node ?? json.node ?? [];
if (Array.isArray(nodes)) return nodes;
if (!nodes) return [];
return [nodes];
}
function ensureSchema(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS jambox_base_packages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL,
tid INTEGER NOT NULL,
name TEXT NOT NULL,
slug TEXT,
sort_order INTEGER,
updated_at TEXT NOT NULL,
UNIQUE(source, tid)
);
`);
}
function upsertBasePackages(db, source, nodes) {
const now = new Date().toISOString();
const upsert = db.prepare(`
INSERT INTO jambox_base_packages (source, tid, name, slug, sort_order, updated_at)
VALUES (@source, @tid, @name, @slug, @sort_order, @updated_at)
ON CONFLICT(source, tid) DO UPDATE SET
name = excluded.name,
slug = excluded.slug,
sort_order = excluded.sort_order,
updated_at = excluded.updated_at;
`);
const tx = db.transaction((rows) => {
rows.forEach((node, index) => {
const name = node.name ?? node["#text"] ?? "";
const tidRaw = node.tid ?? node.id ?? null;
if (!tidRaw || !name) {
console.warn(`⚠ Pomijam node bez tid/name (source=${source}):`, node);
return;
}
const tid = Number(tidRaw);
const record = {
source,
tid,
name: String(name).trim(),
slug: slugify(String(name)),
sort_order: index + 1,
updated_at: now,
};
upsert.run(record);
});
});
tx(nodes);
console.log(`${source}: zapisano/zmieniono ${nodes.length} pakietów.`);
}
async function main() {
console.log(`Używam bazy: ${DB_PATH}`);
const db = new Database(DB_PATH);
ensureSchema(db);
for (const { source, url } of SOURCES) {
console.log(`\n=== Przetwarzam ${source} (${url}) ===`);
try {
const xml = await fetchXml(url);
const nodes = parseNodesFromXml(xml);
console.log(`📦 Znaleziono ${nodes.length} node'ów dla ${source}`);
upsertBasePackages(db, source, nodes);
} catch (err) {
console.error(`❌ Błąd przy źródle ${source}:`, err);
}
}
db.close();
console.log("\n🎉 Import pakietów podstawowych zakończony.");
}
main().catch((err) => {
console.error("❌ Krytyczny błąd:", err);
process.exit(1);
});

View File

@@ -29,21 +29,10 @@ body {
color: var(--f-text);
}
/* Theme Toggle */
/* .theme-toggle-btn {
@apply text-xl p-2 rounded-full cursor-pointer transition-colors;
color: var(--f-text);
} */
.theme-toggle-btn:hover {
opacity: 0.8;
}
.grecaptcha-badge {
display: none !important;
}
a {
@apply text-[--f-link-text];
}

View File

@@ -17,7 +17,7 @@
}
.f-section-grid-single {
@apply grid items-center gap-5 max-w-6xl mx-auto;
@apply grid items-center gap-5 max-w-7xl mx-auto;
}
.f-section-grid-single-center {