Przebudowa strony

This commit is contained in:
dm
2025-12-11 17:33:34 +01:00
parent 0cf7c45131
commit 7361debc52
6 changed files with 651 additions and 29 deletions

Binary file not shown.

View File

@@ -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 (
<div class="fuz-modal-overlay" onClick={onClose}>
<button
class="fuz-modal-close"
onClick={(e) => {
e.stopPropagation();
onClose();
}}
>
</button>
<div
class="fuz-modal-panel fuz-modal-panel--compact"
onClick={(e) => e.stopPropagation()}
>
<div class="fuz-modal-inner">
<h2 class="fuz-modal-title">Konfiguracja usług dodatkowych</h2>
{/* INTERNET (fiber) jako akordeon */}
<div class="fuz-modal-section">
<div class={`fuz-accordion-item ${baseOpen ? "is-open" : ""}`}>
<button
type="button"
class="fuz-accordion-header"
onClick={() => setBaseOpen((prev) => !prev)}
>
<span class="fuz-modal-phone-name">{plan.name}</span>
<span class="fuz-modal-phone-price">
{basePrice.toFixed(2)} /mies.
</span>
</button>
{baseOpen && plan.features && plan.features.length > 0 && (
<div class="fuz-accordion-body">
<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">{f.value}</span>
</li>
))}
</ul>
</div>
)}
</div>
</div>
{loading && <p>Ładowanie danych...</p>}
{error && <p class="text-red-600">{error}</p>}
{!loading && !error && (
<>
{/* Sekcja: wybór telefonu (akordeon + opcja bez telefonu) */}
<div class="fuz-modal-section">
<h3>Usługa telefoniczna</h3>
{phonePlans.length === 0 ? (
<p>Brak dostępnych pakietów telefonicznych.</p>
) : (
<div class="fuz-modal-phone-list fuz-accordion">
{/* OPCJA: brak telefonu */}
<div class="fuz-accordion-item fuz-accordion-item--no-phone">
<button
type="button"
class="fuz-accordion-header"
onClick={() => {
setSelectedPhoneId(null);
setOpenPhoneId(null);
}}
>
<span class="fuz-accordion-header-left">
<input
type="radio"
name="phone-plan"
checked={selectedPhoneId === null}
onChange={(e) => {
e.stopPropagation();
setSelectedPhoneId(null);
setOpenPhoneId(null);
}}
onClick={(e) => e.stopPropagation()}
/>
<span class="fuz-modal-phone-name">
Nie potrzebuję telefonu
</span>
</span>
<span class="fuz-modal-phone-price">0,00 /mies.</span>
</button>
</div>
{/* LISTA PAKIETÓW TELEFONICZNYCH */}
{phonePlans.map((p) => {
const isSelected = selectedPhoneId === p.id;
const isOpen = openPhoneId === p.id;
return (
<div
class={`fuz-accordion-item ${
isOpen ? "is-open" : ""
}`}
key={p.id}
>
<button
type="button"
class="fuz-accordion-header"
onClick={() => togglePhoneOpen(p.id)}
>
<span class="fuz-accordion-header-left">
<input
type="radio"
name="phone-plan"
checked={isSelected}
onChange={(e) => {
e.stopPropagation();
setSelectedPhoneId(p.id);
}}
onClick={(e) => e.stopPropagation()}
/>
<span class="fuz-modal-phone-name">
{p.name}
</span>
</span>
<span class="fuz-modal-phone-price">
{p.price_monthly.toFixed(2)} /mies.
</span>
</button>
{isOpen && (
<div class="fuz-accordion-body">
{p.features && p.features.length > 0 && (
<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">
{f.value}
</span>
</li>
))}
</ul>
)}
</div>
)}
</div>
);
})}
</div>
)}
</div>
{/* Sekcja: dodatki internetowe */}
<div class="fuz-modal-section">
<h3>Dodatkowe usługi</h3>
{addons.length === 0 ? (
<p>Brak dodatkowych usług.</p>
) : (
<div class="fuz-addon-list">
{addons.map((addon) =>
addon.options.map((opt) => {
const checked = selectedAddons.some(
(x) =>
x.addonId === addon.id &&
x.optionId === opt.id
);
return (
<label
class="fuz-addon-item"
key={`${addon.id}-${opt.id}`}
>
<div class="fuz-addon-checkbox">
<input
type="checkbox"
checked={checked}
onChange={() =>
handleAddonToggle(addon.id, opt.id)
}
/>
</div>
<div class="fuz-addon-main">
<div class="fuz-addon-name">{addon.name}</div>
{addon.description && (
<div class="fuz-addon-desc">
{addon.description}
</div>
)}
</div>
<div class="fuz-addon-price">
{opt.price.toFixed(2)} /mies.
</div>
</label>
);
})
)}
</div>
)}
</div>
{/* Podsumowanie */}
<div class="fuz-modal-section fuz-summary">
<h3>Podsumowanie miesięczne</h3>
<div class="fuz-summary-list">
<div class="fuz-summary-row">
<span>Internet</span>
<span>{basePrice.toFixed(2)} /mies.</span>
</div>
<div class="fuz-summary-row">
<span>Telefon</span>
<span>
{phonePrice
? `${phonePrice.toFixed(2)} zł/mies.`
: "—"}
</span>
</div>
<div class="fuz-summary-row">
<span>Dodatki</span>
<span>
{addonsPrice
? `${addonsPrice.toFixed(2)} zł/mies.`
: "—"}
</span>
</div>
<div class="fuz-summary-total">
<span>Łącznie</span>
<span>{totalMonthly.toFixed(2)} /mies.</span>
</div>
</div>
</div>
</>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,15 +1,20 @@
import { useEffect, useState } from "preact/hooks"; 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({ export default function InternetDbOffersCards({
title = "Oferty Internetu FUZ", title = "Oferty Internetu FUZ",
}) { }) {
const [selected, setSelected] = useState({}); const [selected, setSelected] = useState({});
const [labels, setLabels] = useState({}); const [labels, setLabels] = useState({});
const [plans, setPlans] = useState([]); const [plans, setPlans] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(""); const [error, setError] = useState("");
// 🔹 stan modala z dodatkami
const [addonsModalOpen, setAddonsModalOpen] = useState(false);
const [activePlan, setActivePlan] = useState(null);
// nasłuchuj globalnego eventu z OffersSwitches // nasłuchuj globalnego eventu z OffersSwitches
useEffect(() => { useEffect(() => {
function handler(e) { function handler(e) {
@@ -18,7 +23,7 @@ export default function InternetDbOffersCards({
setSelected(detail.selected); setSelected(detail.selected);
} }
if (detail.labels) { if (detail.labels) {
setLabels(detail.labels); setLabels(detail.labels);
} }
} }
@@ -65,10 +70,11 @@ export default function InternetDbOffersCards({
}; };
}, [buildingCode, contractCode]); }, [buildingCode, contractCode]);
const contractLabel = labels.umowa || ""; const contractLabel = labels.umowa || "";
return ( return (
<section class="f-offers"> <section class="f-offers">
{loading && <p>Ładowanie ofert...</p>} {loading && <p>Ładowanie ofert...</p>}
{error && <p class="text-red-600">{error}</p>} {error && <p class="text-red-600">{error}</p>}
@@ -79,26 +85,45 @@ export default function InternetDbOffersCards({
key={plan.id} key={plan.id}
plan={plan} plan={plan}
contractLabel={contractLabel} contractLabel={contractLabel}
onConfigureAddons={() => {
setActivePlan(plan);
setAddonsModalOpen(true);
}}
/> />
))} ))}
</div> </div>
)} )}
{/* 🔹 Modal z usługami dodatkowymi (internet + telefon + addony) */}
<InternetAddonsModal
isOpen={addonsModalOpen}
onClose={() => setAddonsModalOpen(false)}
plan={activePlan}
/>
</section> </section>
); );
} }
function OfferCard({ plan, contractLabel }) { function OfferCard({ plan, contractLabel, onConfigureAddons }) {
const basePrice = plan.price_monthly; const basePrice = plan.price_monthly;
const installPrice = plan.price_installation; 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" (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 ( return (
<div class={`f-card ${plan.popular ? "f-card-popular" : ""}`}> <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-header">
<div class="f-card-name">{plan.name}</div> <div class="f-card-name">{plan.name}</div>
<div class="f-card-price">{basePrice} /mies.</div> <div class="f-card-price">{basePrice} /mies.</div>
@@ -123,7 +148,7 @@ function OfferCard({ plan, contractLabel }) {
<li class="f-card-row"> <li class="f-card-row">
<span class="f-card-label">Umowa</span> <span class="f-card-label">Umowa</span>
<span class="f-card-value">{contractLabel}</span> <span class="f-card-value">{effectiveContract}</span>
</li> </li>
<li class="f-card-row"> <li class="f-card-row">
@@ -133,6 +158,14 @@ function OfferCard({ plan, contractLabel }) {
</span> </span>
</li> </li>
</ul> </ul>
<button
type="button"
class="btn btn-primary mt-4"
onClick={onConfigureAddons}
>
Skonfiguruj usługi dodatkowe
</button>
</div> </div>
); );
} }

View File

@@ -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();
}
}

View File

@@ -1,7 +1,7 @@
--- ---
import DefaultLayout from "../../layouts/DefaultLayout.astro"; import DefaultLayout from "../../layouts/DefaultLayout.astro";
import OffersSwitches from "../../islands/Offers/OffersSwitches.jsx"; 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 SectionRenderer from "../../components/sections/SectionRenderer.astro";
import yaml from "js-yaml"; import yaml from "js-yaml";

View File

@@ -1,47 +1,189 @@
/* MODAL — FULLSCREEN OVERLAY */ /* ===========================
MODAL — FULLSCREEN OVERLAY
=========================== */
.fuz-modal-overlay { .fuz-modal-overlay {
@apply fixed inset-0 z-[9999] flex flex-col; @apply fixed inset-0 z-[9999] flex flex-col;
background: rgba(0, 0, 0, 0.65); background: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(6px); backdrop-filter: blur(6px);
animation: fadeIn 0.25s ease-out forwards; animation: fadeIn 0.25s ease-out forwards;
} }
/* CLOSE BUTTON */
.fuz-modal-close { .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 { .fuz-modal-close:hover {
@apply opacity-100; @apply opacity-100;
} }
/* panel pełny ekran, ale treść centrowana max-w */
.fuz-modal-panel { .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 { .fuz-modal-inner {
@apply max-w-4xl mx-auto; @apply w-full max-w-4xl mx-auto;
} }
.fuz-modal-title { .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 { .fuz-modal-content p {
@apply leading-relaxed text-2xl text-center; @apply leading-relaxed text-2xl text-center;
} }
.fuz-modal-content p img { .fuz-modal-content p img {
@apply mt-2 leading-relaxed; @apply mt-2;
} }
@keyframes fadeIn { @keyframes fadeIn {
from { from {
opacity: 0; opacity: 0;
} }
to { to {
opacity: 2; 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);
} }