Rezygnacja z bazy, przeniesienie danych do plików yamla

This commit is contained in:
dm
2025-12-15 06:30:39 +01:00
parent 00d6a57d74
commit 0b6bbbdce7
55 changed files with 3558 additions and 1545 deletions

View File

@@ -1,28 +1,109 @@
import { useEffect, useState } from "preact/hooks";
import { useEffect, useMemo, 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([]);
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),
}));
}
export default function InternetAddonsModal({
isOpen,
onClose,
plan,
// ✅ nowe: z YAML
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 [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [selectedPhoneId, setSelectedPhoneId] = useState(null);
const [selectedAddons, setSelectedAddons] = useState([]);
// który pakiet telefoniczny jest rozwinięty
// zamiast selectedAddons (DB) -> mapka ilości
// { public_ip: 1, ip_v4_extra: 3 }
const [selectedQty, setSelectedQty] = useState({});
// akordeony
const [openPhoneId, setOpenPhoneId] = useState(null);
// czy akordeon internetu (fiber) jest rozwinięty
const [baseOpen, setBaseOpen] = useState(true);
const formatFeatureValue = (val) => {
if (val === true || val === "true") return "✓";
if (val === false || val === "false" || val == null) return "✕";
return val;
};
// reset wyborów po otwarciu / zmianie planu
useEffect(() => {
if (!isOpen) return;
setError("");
setSelectedPhoneId(null);
setSelectedQty({});
setOpenPhoneId(null);
setBaseOpen(true);
}, [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) {
@@ -32,98 +113,22 @@ export default function InternetAddonsModal({ isOpen, onClose, plan }) {
}
setSelectedPhoneId(id);
setOpenPhoneId((prev) => (prev === id ? null : id));
setOpenPhoneId((prev) => (String(prev) === String(id) ? null : id));
};
// 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 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
@@ -155,7 +160,7 @@ export default function InternetAddonsModal({ isOpen, onClose, plan }) {
>
<span class="f-modal-phone-name">{plan.name}</span>
<span class="f-modal-phone-price">
{basePrice.toFixed(2)} /mies.
{money(basePrice)} {cenaOpis}
</span>
</button>
@@ -176,188 +181,233 @@ export default function InternetAddonsModal({ isOpen, onClose, plan }) {
</div>
</div>
{loading && <p>Ładowanie danych...</p>}
{error && <p class="text-red-600">{error}</p>}
{!loading && !error && (
<>
{/* Telefon */}
<div class="f-modal-section">
<h3>Usługa telefoniczna</h3>
{/* Telefon */}
<div class="f-modal-section">
<h3>Usługa telefoniczna</h3>
{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">
{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);
const isOpen = String(openPhoneId) === String(p.id);
return (
<div
class={`f-accordion-item ${isOpen ? "is-open" : ""}`}
key={p.id}
>
<button
type="button"
class="f-accordion-header"
onClick={() => handlePhoneSelect(null)}
onClick={() => handlePhoneSelect(p.id)}
>
<span class="f-accordion-header-left">
<input
type="radio"
name="phone-plan"
checked={selectedPhoneId === null}
checked={isSelected}
onChange={(e) => {
e.stopPropagation();
handlePhoneSelect(null);
handlePhoneSelect(p.id);
}}
onClick={(e) => e.stopPropagation()}
/>
<span class="f-modal-phone-name">
Nie potrzebuję telefonu
</span>
<span class="f-modal-phone-name">{p.name}</span>
</span>
<span class="f-modal-phone-price">
{money(p.price_monthly)} {cenaOpis}
</span>
<span class="f-modal-phone-price">0,00 /mies.</span>
</button>
</div>
{/* lista pakietów telefonu */}
{phonePlans.map((p) => {
const isSelected = selectedPhoneId === p.id;
const isOpen = openPhoneId === p.id;
return (
<div
class={`f-accordion-item ${isOpen ? "is-open" : ""}`}
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">
{p.price_monthly.toFixed(2)} /mies.
</span>
</button>
{isOpen && (
<div class="f-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>
{isOpen && (
<div class="f-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">
{formatFeatureValue(f.value)}
</span>
</li>
))}
</ul>
)}
</div>
);
})}
</div>
)}
)}
</div>
);
})}
</div>
)}
</div>
{/* Dodatki internetowe */}
<div class="f-modal-section">
<h3>Dodatkowe usługi</h3>
{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}>
{/* slot na checkbox (dla wyrównania kolumn) */}
<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>
{/* licznik ilości bliżej prawej */}
<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>
{/* cena po prawej + suma pod spodem */}
<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>
)}
</div>
{/* Podsumowanie */}
<div class="f-modal-section f-summary">
<h3>Podsumowanie miesięczne</h3>
<div class="f-summary-list">
<div class="f-summary-row">
<span>Internet</span>
<span>{money(basePrice)} {cenaOpis}</span>
</div>
{/* Dodatki internetowe */}
<div class="f-modal-section">
<h3>Dodatkowe usługi</h3>
{addons.length === 0 ? (
<p>Brak dodatkowych usług.</p>
) : (
<div class="f-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="f-addon-item" key={`${addon.id}-${opt.id}`}>
<div class="f-addon-checkbox">
<input
type="checkbox"
checked={checked}
onChange={() => handleAddonToggle(addon.id, opt.id)}
/>
</div>
<div class="f-addon-main">
<div class="f-addon-name">{addon.name}</div>
{addon.description && (
<div class="f-addon-desc">{addon.description}</div>
)}
</div>
<div class="f-addon-price">
{opt.price.toFixed(2)} {opt.currency}
</div>
</label>
);
})
)}
</div>
)}
<div class="f-summary-row">
<span>Telefon</span>
<span>{phonePrice ? `${money(phonePrice)} ${cenaOpis}` : "—"}</span>
</div>
{/* Podsumowanie */}
<div class="f-modal-section f-summary">
<h3>Podsumowanie miesięczne</h3>
<div class="f-summary-list">
<div class="f-summary-row">
<span>Internet</span>
<span>{basePrice.toFixed(2)} /mies.</span>
</div>
<div class="f-summary-row">
<span>Telefon</span>
<span>
{phonePrice ? `${phonePrice.toFixed(2)} zł/mies.` : "—"}
</span>
</div>
<div class="f-summary-row">
<span>Dodatki</span>
<span>
{addonsPrice ? `${addonsPrice.toFixed(2)} zł/mies.` : "—"}
</span>
</div>
<div class="f-summary-total">
<span>Łącznie</span>
<span>{totalMonthly.toFixed(2)} /mies.</span>
</div>
</div>
<div class="f-summary-row">
<span>Dodatki</span>
<span>{addonsPrice ? `${money(addonsPrice)} ${cenaOpis}` : "—"}</span>
</div>
<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">{totalMonthly.toFixed(2)} /mies.</span>
</div>
<div class="f-summary-total">
<span>Łącznie</span>
<span>{money(totalMonthly)} {cenaOpis}</span>
</div>
</>
)}
</div>
</div>
<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>