diff --git a/src/data/ServicesRange.db b/src/data/ServicesRange.db index 9310e44..d187eb1 100644 Binary files a/src/data/ServicesRange.db and b/src/data/ServicesRange.db differ diff --git a/src/islands/Internet/InternetAddonsModal.jsx b/src/islands/Internet/InternetAddonsModal.jsx new file mode 100644 index 0000000..7f52216 --- /dev/null +++ b/src/islands/Internet/InternetAddonsModal.jsx @@ -0,0 +1,365 @@ +import { useEffect, useState } from "preact/hooks"; +import "../../styles/modal.css"; +import "../../styles/offers/offers-table.css"; + +export default function InternetAddonsModal({ isOpen, onClose, plan }) { + const [phonePlans, setPhonePlans] = useState([]); + const [addons, setAddons] = useState([]); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + const [selectedPhoneId, setSelectedPhoneId] = useState(null); + const [selectedAddons, setSelectedAddons] = useState([]); + + // który pakiet telefoniczny jest rozwinięty + const [openPhoneId, setOpenPhoneId] = useState(null); + + // czy akordeon internetu (fiber) jest rozwinięty + const [baseOpen, setBaseOpen] = useState(true); + + // reset wyborów po otwarciu nowego planu + useEffect(() => { + if (!isOpen) return; + setSelectedPhoneId(null); + setSelectedAddons([]); + setOpenPhoneId(null); + setBaseOpen(true); + }, [isOpen, plan]); + + // ładowanie danych + useEffect(() => { + if (!isOpen) return; + + let cancelled = false; + + async function loadData() { + setLoading(true); + setError(""); + + try { + // telefon + const phoneRes = await fetch("/api/phone/plans"); + if (!phoneRes.ok) throw new Error(`HTTP ${phoneRes.status} (phone)`); + + const phoneJson = await phoneRes.json(); + const phoneData = Array.isArray(phoneJson.data) ? phoneJson.data : []; + + // dodatki + const addonsRes = await fetch("/api/internet/addons"); + if (!addonsRes.ok) throw new Error(`HTTP ${addonsRes.status} (addons)`); + + const addonsJson = await addonsRes.json(); + const addonsData = Array.isArray(addonsJson.data) ? addonsJson.data : []; + + if (!cancelled) { + setPhonePlans(phoneData); + setAddons(addonsData); + } + } catch (err) { + console.error("❌ Błąd ładowania danych do InternetAddonsModal:", err); + if (!cancelled) { + setError("Nie udało się załadować danych dodatkowych usług."); + } + } finally { + if (!cancelled) setLoading(false); + } + } + + loadData(); + return () => { + cancelled = true; + }; + }, [isOpen]); + + if (!isOpen || !plan) return null; + + const basePrice = plan.price_monthly || 0; + + const phonePrice = (() => { + if (!selectedPhoneId) return 0; + const p = phonePlans.find((p) => p.id === selectedPhoneId); + return p?.price_monthly || 0; + })(); + + const addonsPrice = selectedAddons.reduce((sum, sel) => { + const addon = addons.find((a) => a.id === sel.addonId); + if (!addon) return sum; + const opt = addon.options.find((o) => o.id === sel.optionId); + if (!opt) return sum; + return sum + (opt.price || 0); + }, 0); + + const totalMonthly = basePrice + phonePrice + addonsPrice; + + const handleAddonToggle = (addonId, optionId) => { + setSelectedAddons((prev) => { + const exists = prev.some( + (x) => x.addonId === addonId && x.optionId === optionId + ); + if (exists) { + return prev.filter( + (x) => !(x.addonId === addonId && x.optionId === optionId) + ); + } else { + return [...prev, { addonId, optionId }]; + } + }); + }; + + const togglePhoneOpen = (id) => { + setOpenPhoneId((prev) => (prev === id ? null : id)); + }; + + return ( +
+ + +
e.stopPropagation()} + > +
+

Konfiguracja usług dodatkowych

+ + {/* INTERNET (fiber) jako akordeon */} +
+
+ + + {baseOpen && plan.features && plan.features.length > 0 && ( +
+
    + {plan.features.map((f, idx) => ( +
  • + {f.label} + {f.value} +
  • + ))} +
+
+ )} +
+
+ + {loading &&

Ładowanie danych...

} + {error &&

{error}

} + + {!loading && !error && ( + <> + {/* Sekcja: wybór telefonu (akordeon + opcja bez telefonu) */} +
+

Usługa telefoniczna

+ {phonePlans.length === 0 ? ( +

Brak dostępnych pakietów telefonicznych.

+ ) : ( +
+ {/* OPCJA: brak telefonu */} +
+ +
+ + {/* LISTA PAKIETÓW TELEFONICZNYCH */} + {phonePlans.map((p) => { + const isSelected = selectedPhoneId === p.id; + const isOpen = openPhoneId === p.id; + + return ( +
+ + + {isOpen && ( +
+ {p.features && p.features.length > 0 && ( +
    + {p.features + .filter( + (f) => + !String( + f.label || "" + ) + .toLowerCase() + .includes("aktyw") + ) + .map((f, idx) => ( +
  • + + {f.label} + + + {f.value} + +
  • + ))} +
+ )} +
+ )} +
+ ); + })} +
+ )} +
+ + {/* Sekcja: dodatki internetowe */} +
+

Dodatkowe usługi

+ {addons.length === 0 ? ( +

Brak dodatkowych usług.

+ ) : ( +
+ {addons.map((addon) => + addon.options.map((opt) => { + const checked = selectedAddons.some( + (x) => + x.addonId === addon.id && + x.optionId === opt.id + ); + + return ( + + ); + }) + )} +
+ )} +
+ + {/* Podsumowanie */} +
+

Podsumowanie miesięczne

+ +
+
+ Internet + {basePrice.toFixed(2)} zł/mies. +
+ +
+ Telefon + + {phonePrice + ? `${phonePrice.toFixed(2)} zł/mies.` + : "—"} + +
+ +
+ Dodatki + + {addonsPrice + ? `${addonsPrice.toFixed(2)} zł/mies.` + : "—"} + +
+ +
+ Łącznie + {totalMonthly.toFixed(2)} zł/mies. +
+
+
+ + )} +
+
+
+ ); +} diff --git a/src/islands/OffersInternetCards.jsx b/src/islands/Internet/OffersInternetCards.jsx similarity index 69% rename from src/islands/OffersInternetCards.jsx rename to src/islands/Internet/OffersInternetCards.jsx index ea1ba71..13bd8ea 100644 --- a/src/islands/OffersInternetCards.jsx +++ b/src/islands/Internet/OffersInternetCards.jsx @@ -1,15 +1,20 @@ import { useEffect, useState } from "preact/hooks"; -import "../styles/offers/offers-table.css"; +import "../../styles/offers/offers-table.css"; +import InternetAddonsModal from "./InternetAddonsModal.jsx"; // 🔹 dostosuj ścieżkę, jeśli inna export default function InternetDbOffersCards({ title = "Oferty Internetu FUZ", }) { const [selected, setSelected] = useState({}); - const [labels, setLabels] = useState({}); + const [labels, setLabels] = useState({}); const [plans, setPlans] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); + // 🔹 stan modala z dodatkami + const [addonsModalOpen, setAddonsModalOpen] = useState(false); + const [activePlan, setActivePlan] = useState(null); + // nasłuchuj globalnego eventu z OffersSwitches useEffect(() => { function handler(e) { @@ -18,7 +23,7 @@ export default function InternetDbOffersCards({ setSelected(detail.selected); } if (detail.labels) { - setLabels(detail.labels); + setLabels(detail.labels); } } @@ -65,10 +70,11 @@ export default function InternetDbOffersCards({ }; }, [buildingCode, contractCode]); - const contractLabel = labels.umowa || ""; + const contractLabel = labels.umowa || ""; return (
+ {loading &&

Ładowanie ofert...

} {error &&

{error}

} @@ -79,26 +85,45 @@ export default function InternetDbOffersCards({ key={plan.id} plan={plan} contractLabel={contractLabel} + onConfigureAddons={() => { + setActivePlan(plan); + setAddonsModalOpen(true); + }} /> ))} )} + + {/* 🔹 Modal z usługami dodatkowymi (internet + telefon + addon’y) */} + setAddonsModalOpen(false)} + plan={activePlan} + />
); } -function OfferCard({ plan, contractLabel }) { +function OfferCard({ plan, contractLabel, onConfigureAddons }) { const basePrice = plan.price_monthly; const installPrice = plan.price_installation; - const featureRows = (plan.features || []).filter( + const allFeatures = plan.features || []; + + // 🔹 to są inne cechy (bez umowy i instalacji) + const featureRows = allFeatures.filter( (f) => f.id !== "umowa_info" && f.id !== "instalacja" ); + // 🔹 cecha opisująca umowę (z backendu) + const contractFeature = allFeatures.find((f) => f.id === "umowa_info"); + + // 🔹 tekst, który faktycznie pokażemy w wierszu "Umowa" + const effectiveContract = + contractLabel || contractFeature?.value || contractFeature?.label || "—"; + return (
- {/* {plan.popular &&
Najczęściej wybierany
} */} -
{plan.name}
{basePrice} zł/mies.
@@ -123,7 +148,7 @@ function OfferCard({ plan, contractLabel }) {
  • Umowa - {contractLabel} + {effectiveContract}
  • @@ -133,6 +158,14 @@ function OfferCard({ plan, contractLabel }) {
  • + +
    ); } diff --git a/src/pages/api/internet/addons.js b/src/pages/api/internet/addons.js new file mode 100644 index 0000000..26c3aca --- /dev/null +++ b/src/pages/api/internet/addons.js @@ -0,0 +1,82 @@ +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 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(); + } +} diff --git a/src/pages/internet-swiatlowodowy/index.astro b/src/pages/internet-swiatlowodowy/index.astro index 297004a..4e2adb5 100644 --- a/src/pages/internet-swiatlowodowy/index.astro +++ b/src/pages/internet-swiatlowodowy/index.astro @@ -1,7 +1,7 @@ --- import DefaultLayout from "../../layouts/DefaultLayout.astro"; import OffersSwitches from "../../islands/Offers/OffersSwitches.jsx"; -import InternetDbOffersCards from "../../islands/OffersInternetCards.jsx"; +import InternetDbOffersCards from "../../islands/Internet/OffersInternetCards.jsx"; import SectionRenderer from "../../components/sections/SectionRenderer.astro"; import yaml from "js-yaml"; diff --git a/src/styles/modal.css b/src/styles/modal.css index 7c6dd0f..166765d 100644 --- a/src/styles/modal.css +++ b/src/styles/modal.css @@ -1,47 +1,189 @@ -/* MODAL — FULLSCREEN OVERLAY */ +/* =========================== + MODAL — FULLSCREEN OVERLAY + =========================== */ .fuz-modal-overlay { - @apply fixed inset-0 z-[9999] flex flex-col; - background: rgba(0, 0, 0, 0.65); - backdrop-filter: blur(6px); - animation: fadeIn 0.25s ease-out forwards; + @apply fixed inset-0 z-[9999] flex flex-col; + background: rgba(0, 0, 0, 0.65); + backdrop-filter: blur(6px); + animation: fadeIn 0.25s ease-out forwards; } -/* CLOSE BUTTON */ .fuz-modal-close { - @apply absolute top-4 right-6 text-3xl font-bold cursor-pointer transition-opacity text-[--f-text] opacity-[0.7]; + @apply absolute top-4 right-6 text-3xl font-bold cursor-pointer transition-opacity; + @apply text-[--f-text] opacity-70; } .fuz-modal-close:hover { - @apply opacity-100; + @apply opacity-100; } +/* panel – pełny ekran, ale treść centrowana max-w */ .fuz-modal-panel { - @apply w-full h-full overflow-y-auto px-6 py-8 md:px-12 md:py-12 bg-[--f-background] text-[--f-text]; + @apply w-full h-full overflow-y-auto bg-[--f-background] text-[--f-text]; + @apply px-6 py-8 md:px-12 md:py-12; +} + +/* wersja "kompaktowa" z mniejszym max-width (używana w dodatkach) */ +.fuz-modal-panel.fuz-modal-panel--compact { + @apply flex justify-center items-start; } .fuz-modal-inner { - @apply max-w-4xl mx-auto; + @apply w-full max-w-4xl mx-auto; } .fuz-modal-title { - @apply text-4xl font-bold mb-8 text-center text-[--f-text]; + @apply text-3xl md:text-4xl font-bold mb-8 text-center text-[--f-text]; } .fuz-modal-content p { - @apply leading-relaxed text-2xl text-center; + @apply leading-relaxed text-2xl text-center; } .fuz-modal-content p img { - @apply mt-2 leading-relaxed; + @apply mt-2; } @keyframes fadeIn { - from { - opacity: 0; - } + from { + opacity: 0; + } - to { - opacity: 2; - } + to { + opacity: 1; + } +} + +/* =========================== + TELEFON — AKORDEON + =========================== */ + +.fuz-modal-phone-list.fuz-accordion { + @apply flex flex-col gap-3; +} + +.fuz-accordion-item { + @apply rounded-xl border overflow-hidden bg-[--f-background]; + border-color: rgba(148, 163, 184, 0.6); /* neutralna szarość — ok w obu motywach */ +} + +.fuz-accordion-header { + @apply w-full flex items-center justify-between gap-4 px-4 py-3 cursor-pointer; + background: rgba(148, 163, 184, 0.06); + border: none; + outline: none; +} + +.fuz-accordion-header-left { + @apply flex items-center gap-2; +} + +.fuz-modal-phone-name { + @apply font-medium; +} + +.fuz-modal-phone-price { + @apply font-semibold whitespace-nowrap; +} + +.fuz-accordion-body { + @apply px-4 pt-2 pb-3; + border-top: 1px solid rgba(148, 163, 184, 0.4); +} + +/* wyróżnienie otwartego pakietu – lekki „accent wash” na tle */ +.fuz-accordion-item.is-open .fuz-accordion-header { + background: color-mix(in srgb, var(--fuz-accent, #2563eb) 8%, transparent); +} + +/* =========================== + DODATKI — KOLUMNOWA LISTA + =========================== */ + +.fuz-addon-list { + @apply flex flex-col gap-2; +} + +.fuz-addon-item { + @apply grid items-center gap-3 px-3 py-2 rounded-xl border cursor-pointer; + grid-template-columns: auto 1fr auto; /* [checkbox] [opis] [cena] */ + border-color: rgba(148, 163, 184, 0.5); + background: var(--f-background); +} + +/* kliknięcie w środek też zaznacza checkboxa */ +.fuz-addon-item input[type="checkbox"] { + @apply cursor-pointer; +} + +.fuz-addon-checkbox { + @apply flex items-center justify-center; +} + +.fuz-addon-main { + @apply flex flex-col gap-0.5; +} + +.fuz-addon-name { + @apply font-medium; +} + +.fuz-addon-desc { + @apply text-sm opacity-85; +} + +.fuz-addon-price { + @apply font-semibold whitespace-nowrap; +} + +/* lekkie podświetlenie przy hover */ +.fuz-addon-item:hover { + border-color: color-mix(in srgb, var(--fuz-accent, #2563eb) 70%, rgba(148, 163, 184, 0.5) 30%); +} + +/* =========================== + PODSUMOWANIE MIESIĘCZNE + =========================== */ + +.fuz-summary { + @apply pt-2; +} + +.fuz-summary-list { + @apply flex flex-col gap-1 mt-2 p-4 rounded-xl; + background: rgba(148, 163, 184, 0.07); +} + +.fuz-summary-row, +.fuz-summary-total { + @apply flex items-center justify-between; +} + +.fuz-summary-row span:last-child { + @apply font-medium whitespace-nowrap; +} + +.fuz-summary-total { + @apply mt-1 pt-2; + border-top: 1px solid rgba(148, 163, 184, 0.4); +} + +.fuz-summary-total span:last-child { + @apply font-bold; + font-size: 1.25rem; + color: var(--fuz-accent, #2563eb); +} + +.fuz-modal-section { + @apply mb-6; +} + +.fuz-modal-section h3 { + @apply text-xl md:text-2xl font-semibold mb-3; +} + +/* opcja "bez telefonu" — trochę lżejsze tło */ +.fuz-accordion-item--no-phone .fuz-accordion-header { + background: rgba(148, 163, 184, 0.03); } \ No newline at end of file