Modal usług doddatkowych podział na komponenty
This commit is contained in:
@@ -1,85 +1,31 @@
|
|||||||
import { useEffect, useMemo, useState } from "preact/hooks";
|
import { useEffect, useMemo, useState } from "preact/hooks";
|
||||||
import useDraggableFloating from "../hooks/useDraggableFloating.js";
|
import OfferModalShell from "../modals/OfferModalShell.jsx";
|
||||||
|
|
||||||
|
import PlanSection from "../modals/sections/PlanSection.jsx";
|
||||||
|
import PhoneSection from "../modals/sections/PhoneSection.jsx";
|
||||||
|
import AddonsSection from "../modals/sections/AddonsSection.jsx";
|
||||||
|
import SummarySection from "../modals/sections/SummarySection.jsx";
|
||||||
|
import FloatingTotal from "../modals/sections/FloatingTotal.jsx";
|
||||||
|
|
||||||
|
import { mapPhoneYamlToPlans, normalizeAddons } from "../../lib/offer-normalize.js";
|
||||||
|
import { saveOfferToLocalStorage } from "../../lib/offer-payload.js";
|
||||||
|
|
||||||
import "../../styles/modal.css";
|
import "../../styles/modal.css";
|
||||||
import "../../styles/addons.css";
|
import "../../styles/addons.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(".", ",");
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
})),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
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: 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({
|
export default function InternetAddonsModal({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
plan,
|
plan,
|
||||||
|
|
||||||
phoneCards = [],
|
phoneCards = [],
|
||||||
addons = [],
|
addons = [],
|
||||||
|
|
||||||
cenaOpis = "zł / mies.",
|
cenaOpis = "zł / mies.",
|
||||||
}) {
|
}) {
|
||||||
const floating = useDraggableFloating("fuz_floating_total_pos_internet_v1");
|
|
||||||
const phonePlans = useMemo(() => mapPhoneYamlToPlans(phoneCards), [phoneCards]);
|
const phonePlans = useMemo(() => mapPhoneYamlToPlans(phoneCards), [phoneCards]);
|
||||||
const addonsList = useMemo(() => normalizeAddons(addons), [addons]);
|
const addonsList = useMemo(() => normalizeAddons(addons), [addons]);
|
||||||
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
|
|
||||||
const [selectedPhoneId, setSelectedPhoneId] = useState(null);
|
const [selectedPhoneId, setSelectedPhoneId] = useState(null);
|
||||||
const [selectedQty, setSelectedQty] = useState({});
|
const [selectedQty, setSelectedQty] = useState({});
|
||||||
|
|
||||||
@@ -93,19 +39,12 @@ export default function InternetAddonsModal({
|
|||||||
const toggleSection = (key) => {
|
const toggleSection = (key) => {
|
||||||
setOpenSections((prev) => {
|
setOpenSections((prev) => {
|
||||||
const nextOpen = !prev[key];
|
const nextOpen = !prev[key];
|
||||||
return {
|
return { internet: false, phone: false, addons: false, summary: false, [key]: nextOpen };
|
||||||
internet: false,
|
|
||||||
phone: false,
|
|
||||||
addons: false,
|
|
||||||
summary: false,
|
|
||||||
[key]: nextOpen,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
setError("");
|
|
||||||
setSelectedPhoneId(null);
|
setSelectedPhoneId(null);
|
||||||
setSelectedQty({});
|
setSelectedQty({});
|
||||||
setOpenSections({ internet: true, phone: false, addons: false, summary: false });
|
setOpenSections({ internet: true, phone: false, addons: false, summary: false });
|
||||||
@@ -124,36 +63,13 @@ export default function InternetAddonsModal({
|
|||||||
const addonsPrice = useMemo(() => {
|
const addonsPrice = useMemo(() => {
|
||||||
return addonsList.reduce((sum, a) => {
|
return addonsList.reduce((sum, a) => {
|
||||||
const qty = Number(selectedQty[a.id] || 0);
|
const qty = Number(selectedQty[a.id] || 0);
|
||||||
return sum + qty * Number(a.cena || 0);
|
const unit = Number(a.cena || 0);
|
||||||
|
return sum + qty * unit;
|
||||||
}, 0);
|
}, 0);
|
||||||
}, [selectedQty, addonsList]);
|
}, [selectedQty, addonsList]);
|
||||||
|
|
||||||
const totalMonthly = basePrice + phonePrice + addonsPrice;
|
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 }));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Zapis do localStorage wyborów
|
|
||||||
const LS_KEY = "fuz_offer_config_v1";
|
|
||||||
|
|
||||||
function buildOfferPayload() {
|
function buildOfferPayload() {
|
||||||
const phone = selectedPhoneId
|
const phone = selectedPhoneId
|
||||||
? phonePlans.find((p) => String(p.id) === String(selectedPhoneId))
|
? phonePlans.find((p) => String(p.id) === String(selectedPhoneId))
|
||||||
@@ -170,11 +86,8 @@ export default function InternetAddonsModal({
|
|||||||
return {
|
return {
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
pkg: { id: plan?.id ?? null, name: plan?.name ?? "", price: basePrice },
|
pkg: { id: plan?.id ?? null, name: plan?.name ?? "", price: basePrice },
|
||||||
|
|
||||||
phone: phone ? { id: phone.id, name: phone.name, price: phone.price_monthly } : null,
|
phone: phone ? { id: phone.id, name: phone.name, price: phone.price_monthly } : null,
|
||||||
|
|
||||||
addons: addonsChosen,
|
addons: addonsChosen,
|
||||||
|
|
||||||
totals: {
|
totals: {
|
||||||
base: basePrice,
|
base: basePrice,
|
||||||
phone: phonePrice,
|
phone: phonePrice,
|
||||||
@@ -185,322 +98,62 @@ export default function InternetAddonsModal({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function moneyWithLabel(v) {
|
const onSend = () => {
|
||||||
return `${money(v)} ${cenaOpis}`;
|
const payload = buildOfferPayload();
|
||||||
}
|
saveOfferToLocalStorage(payload, cenaOpis);
|
||||||
|
};
|
||||||
function buildOfferMessage(payload) {
|
|
||||||
const lines = [];
|
|
||||||
|
|
||||||
lines.push(`Internet światłowodowy ${payload?.pkg?.name}: ${moneyWithLabel(payload?.totals?.base ?? 0)}`);
|
|
||||||
lines.push(`Usługa Telefon: ${payload?.phone ? moneyWithLabel(payload.totals.phone) : "—"}`);
|
|
||||||
lines.push(`Dodatkowe usługi: ${payload?.addons?.length ? moneyWithLabel(payload.totals.addons) : "—"}`);
|
|
||||||
lines.push(`Łącznie: ${moneyWithLabel(payload?.totals?.total ?? 0)}`);
|
|
||||||
|
|
||||||
if (payload?.phone) {
|
|
||||||
lines.push("");
|
|
||||||
lines.push(`Telefon: ${payload.phone.name} (${moneyWithLabel(payload.phone.price)})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(payload?.addons) && payload.addons.length) {
|
|
||||||
lines.push("");
|
|
||||||
lines.push("Dodatkowe usługi:");
|
|
||||||
for (const it of payload.addons) {
|
|
||||||
lines.push(`- ${it.nazwa} x${it.qty} @ ${moneyWithLabel(it.unit)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveOfferToLocalStorage() {
|
|
||||||
try {
|
|
||||||
const payload = buildOfferPayload();
|
|
||||||
payload.message = buildOfferMessage(payload);
|
|
||||||
localStorage.setItem(LS_KEY, JSON.stringify(payload));
|
|
||||||
} catch { }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="f-modal-overlay" onClick={onClose}>
|
<OfferModalShell isOpen={isOpen} onClose={onClose} title={`${plan.name} — konfiguracja usług`}>
|
||||||
<button
|
<PlanSection
|
||||||
class="f-modal-close"
|
title={plan.name}
|
||||||
type="button"
|
open={openSections.internet}
|
||||||
aria-label="Zamknij"
|
onToggle={() => toggleSection("internet")}
|
||||||
onClick={(e) => {
|
price={basePrice}
|
||||||
e.stopPropagation();
|
cenaOpis={cenaOpis}
|
||||||
onClose();
|
features={plan.features || []}
|
||||||
}}
|
/>
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="f-modal-panel f-modal-panel--compact" onClick={(e) => e.stopPropagation()}>
|
<PhoneSection
|
||||||
<div class="f-modal-inner">
|
open={openSections.phone}
|
||||||
<h2 class="f-modal-title">{plan.name} — konfiguracja usług</h2>
|
onToggle={() => toggleSection("phone")}
|
||||||
|
cenaOpis={cenaOpis}
|
||||||
|
phonePlans={phonePlans}
|
||||||
|
selectedPhoneId={selectedPhoneId}
|
||||||
|
setSelectedPhoneId={setSelectedPhoneId}
|
||||||
|
phonePrice={phonePrice}
|
||||||
|
/>
|
||||||
|
|
||||||
{error && <p class="text-red-600">{error}</p>}
|
<AddonsSection
|
||||||
|
open={openSections.addons}
|
||||||
|
onToggle={() => toggleSection("addons")}
|
||||||
|
cenaOpis={cenaOpis}
|
||||||
|
addonsList={addonsList}
|
||||||
|
selectedQty={selectedQty}
|
||||||
|
setSelectedQty={setSelectedQty}
|
||||||
|
addonsPrice={addonsPrice}
|
||||||
|
getUnitPrice={(a) => Number(a.cena || 0)}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* INTERNET */}
|
<SummarySection
|
||||||
<div class="f-modal-section">
|
open={openSections.summary}
|
||||||
<SectionAccordion
|
onToggle={() => toggleSection("summary")}
|
||||||
title={plan.name}
|
cenaOpis={cenaOpis}
|
||||||
right={<span class="f-modal-phone-price">{money(basePrice)} {cenaOpis}</span>}
|
totalMonthly={totalMonthly}
|
||||||
open={openSections.internet}
|
ctaHref="/kontakt#form"
|
||||||
onToggle={() => toggleSection("internet")}
|
onSend={onSend}
|
||||||
>
|
rows={[
|
||||||
{plan.features?.length ? (
|
{ label: "Pakiet", value: basePrice, showDashIfZero: false },
|
||||||
<ul class="f-card-features">
|
{ label: "Telefon", value: phonePrice, showDashIfZero: true },
|
||||||
{plan.features.map((f, idx) => (
|
{ label: "Dodatkowe usługi", value: addonsPrice, showDashIfZero: true },
|
||||||
<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 */}
|
<FloatingTotal
|
||||||
<div class="f-modal-section">
|
storageKey="fuz_floating_total_pos_internet_v1"
|
||||||
<SectionAccordion
|
totalMonthly={totalMonthly}
|
||||||
title="Usługa telefoniczna"
|
cenaOpis={cenaOpis}
|
||||||
right={
|
/>
|
||||||
<span class="f-modal-phone-price">
|
</OfferModalShell>
|
||||||
{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">
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{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>
|
|
||||||
|
|
||||||
{/* Szczegóły telefony rozwinięte */}
|
|
||||||
{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>
|
|
||||||
|
|
||||||
{/* USLUGI DODATKOWE */}
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usługa z ilośćią
|
|
||||||
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>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>Dodatkowe usługi</span>
|
|
||||||
<span>{addonsPrice ? `${money(addonsPrice)} ${cenaOpis}` : "—"}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="f-summary-total">
|
|
||||||
<span>Łącznie</span>
|
|
||||||
<span>{money(totalMonthly)} {cenaOpis}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="/kontakt#form"
|
|
||||||
class="btn btn-primary w-full mt-4"
|
|
||||||
onClick={() => saveOfferToLocalStorage()}
|
|
||||||
>
|
|
||||||
Wyślij zapytanie z tym wyborem
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SectionAccordion>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
ref={floating.ref}
|
|
||||||
class="f-floating-total"
|
|
||||||
style={floating.style}
|
|
||||||
{...floating.handlers}
|
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,171 +1,21 @@
|
|||||||
import { useEffect, useMemo, useState } from "preact/hooks";
|
import { useEffect, useMemo, useState } from "preact/hooks";
|
||||||
import useDraggableFloating from "../hooks/useDraggableFloating.js";
|
import OfferModalShell from "../modals/OfferModalShell.jsx";
|
||||||
|
|
||||||
|
import PlanSection from "../modals/sections/PlanSection.jsx";
|
||||||
|
import DecoderSection from "../modals/sections/DecoderSection.jsx";
|
||||||
|
import TvAddonsSection from "../modals/sections/TvAddonsSection.jsx";
|
||||||
|
import PhoneSection from "../modals/sections/PhoneSection.jsx";
|
||||||
|
import AddonsSection from "../modals/sections/AddonsSection.jsx";
|
||||||
|
import SummarySection from "../modals/sections/SummarySection.jsx";
|
||||||
|
import FloatingTotal from "../modals/sections/FloatingTotal.jsx";
|
||||||
|
|
||||||
|
import { mapPhoneYamlToPlans, normalizeAddons, normalizeDecoders } from "../../lib/offer-normalize.js";
|
||||||
|
import { isTvAddonAvailableForPkg, hasTvTermPricing, getAddonUnitPrice } from "../../lib/offer-pricing.js";
|
||||||
|
import { saveOfferToLocalStorage } from "../../lib/offer-payload.js";
|
||||||
|
|
||||||
import "../../styles/modal.css";
|
import "../../styles/modal.css";
|
||||||
import "../../styles/addons.css";
|
import "../../styles/addons.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,
|
|
||||||
tid: String(a.tid),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
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({
|
export default function JamboxAddonsModal({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -179,29 +29,20 @@ export default function JamboxAddonsModal({
|
|||||||
cenaOpis = "zł/mies.",
|
cenaOpis = "zł/mies.",
|
||||||
}) {
|
}) {
|
||||||
const phonePlans = useMemo(() => mapPhoneYamlToPlans(phoneCards), [phoneCards]);
|
const phonePlans = useMemo(() => mapPhoneYamlToPlans(phoneCards), [phoneCards]);
|
||||||
|
|
||||||
const tvAddonsList = useMemo(() => normalizeAddons(tvAddons), [tvAddons]);
|
const tvAddonsList = useMemo(() => normalizeAddons(tvAddons), [tvAddons]);
|
||||||
const addonsList = useMemo(() => normalizeAddons(addons), [addons]);
|
const addonsList = useMemo(() => normalizeAddons(addons), [addons]);
|
||||||
|
|
||||||
const decodersList = useMemo(() => normalizeDecoders(decoders), [decoders]);
|
const decodersList = useMemo(() => normalizeDecoders(decoders), [decoders]);
|
||||||
const floating = useDraggableFloating("fuz_floating_total_pos_tv_v1");
|
|
||||||
|
|
||||||
const tvAddonsVisible = useMemo(() => {
|
const tvAddonsVisible = useMemo(() => {
|
||||||
if (!pkg) return [];
|
if (!pkg) return [];
|
||||||
return tvAddonsList.filter((a) => isTvAddonAvailableForPkg(a, pkg));
|
return tvAddonsList.filter((a) => isTvAddonAvailableForPkg(a, pkg));
|
||||||
}, [tvAddonsList, pkg]);
|
}, [tvAddonsList, pkg]);
|
||||||
|
|
||||||
// wybory
|
|
||||||
const [selectedPhoneId, setSelectedPhoneId] = useState(null);
|
const [selectedPhoneId, setSelectedPhoneId] = useState(null);
|
||||||
const [openPhoneId, setOpenPhoneId] = useState(null);
|
|
||||||
|
|
||||||
const [selectedDecoderId, setSelectedDecoderId] = useState(null);
|
const [selectedDecoderId, setSelectedDecoderId] = useState(null);
|
||||||
|
|
||||||
const [selectedQty, setSelectedQty] = useState({});
|
const [selectedQty, setSelectedQty] = useState({});
|
||||||
|
|
||||||
const [tvTerm, setTvTerm] = useState({}); // { [id]: "12m" | "bezterminowo" }
|
const [tvTerm, setTvTerm] = useState({}); // { [id]: "12m" | "bezterminowo" }
|
||||||
|
|
||||||
// ✅ sekcje (jedna otwarta naraz)
|
|
||||||
const [openSections, setOpenSections] = useState({
|
const [openSections, setOpenSections] = useState({
|
||||||
base: true,
|
base: true,
|
||||||
decoder: false,
|
decoder: false,
|
||||||
@@ -226,16 +67,18 @@ export default function JamboxAddonsModal({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// reset po otwarciu / zmianie pakietu
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
|
|
||||||
setSelectedPhoneId(null);
|
setSelectedPhoneId(null);
|
||||||
setOpenPhoneId(null);
|
|
||||||
setSelectedDecoderId(null);
|
|
||||||
setSelectedQty({});
|
setSelectedQty({});
|
||||||
setTvTerm({});
|
setTvTerm({});
|
||||||
|
|
||||||
|
const d0 =
|
||||||
|
(Array.isArray(decodersList) && decodersList.find((d) => Number(d.cena) === 0)) ||
|
||||||
|
(Array.isArray(decodersList) ? decodersList[0] : null);
|
||||||
|
setSelectedDecoderId(d0 ? String(d0.id) : null);
|
||||||
|
|
||||||
setOpenSections({
|
setOpenSections({
|
||||||
base: true,
|
base: true,
|
||||||
decoder: false,
|
decoder: false,
|
||||||
@@ -244,12 +87,6 @@ export default function JamboxAddonsModal({
|
|||||||
addons: false,
|
addons: false,
|
||||||
summary: 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]);
|
}, [isOpen, pkg?.id, decodersList]);
|
||||||
|
|
||||||
if (!isOpen || !pkg) return null;
|
if (!isOpen || !pkg) return null;
|
||||||
@@ -289,150 +126,7 @@ export default function JamboxAddonsModal({
|
|||||||
}, 0);
|
}, 0);
|
||||||
}, [selectedQty, addonsList, pkg]);
|
}, [selectedQty, addonsList, pkg]);
|
||||||
|
|
||||||
const addonsPrice = tvAddonsPrice + addonsOnlyPrice;
|
const totalMonthly = basePrice + phonePrice + decoderPrice + 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>
|
|
||||||
|
|
||||||
{isTv && a.tid ? (
|
|
||||||
<a
|
|
||||||
class="f-addon-more"
|
|
||||||
href={`/internet-telewizja/pakiety-tematyczne#tid-${encodeURIComponent(a.tid)}`}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
aria-label={`Więcej informacji o pakiecie ${a.nazwa ?? ""} (otwiera się w nowej karcie)`}
|
|
||||||
title={`Więcej o pakiecie ${a.nazwa ?? ""}`}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
Przejdź do szczegółowych informacji pakietu {a.nazwa ?? ""}
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
a.opis ? <div class="f-addon-desc">{a.opis}</div> : null
|
|
||||||
)}
|
|
||||||
|
|
||||||
{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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---------
|
|
||||||
const LS_KEY = "fuz_offer_config_v1";
|
|
||||||
|
|
||||||
function buildOfferPayload() {
|
function buildOfferPayload() {
|
||||||
const phone = selectedPhoneId
|
const phone = selectedPhoneId
|
||||||
@@ -491,371 +185,87 @@ export default function JamboxAddonsModal({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveOfferToLocalStorage() {
|
const onSend = () => {
|
||||||
try {
|
const payload = buildOfferPayload();
|
||||||
const payload = buildOfferPayload();
|
saveOfferToLocalStorage(payload, cenaOpis);
|
||||||
localStorage.setItem(LS_KEY, JSON.stringify(payload));
|
};
|
||||||
} catch { }
|
|
||||||
}
|
|
||||||
|
|
||||||
//-- dopisane
|
|
||||||
function moneyWithLabel(v) {
|
|
||||||
return `${money(v)} ${cenaOpis}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildOfferMessage(payload) {
|
|
||||||
const lines = [];
|
|
||||||
|
|
||||||
// nagłówek
|
|
||||||
lines.push(`Wybrana oferta: ${payload?.pkg?.name || "—"}`);
|
|
||||||
lines.push("");
|
|
||||||
|
|
||||||
// ✅ WSZYSTKIE linie jak w podsumowaniu
|
|
||||||
lines.push(`Pakiet: ${moneyWithLabel(payload?.totals?.base ?? 0)}`);
|
|
||||||
lines.push(`Telefon: ${payload?.phone ? moneyWithLabel(payload.totals.phone) : "—"}`);
|
|
||||||
lines.push(`Dekoder: ${payload?.decoder ? moneyWithLabel(payload.totals.decoder) : "—"}`);
|
|
||||||
lines.push(`Dodatki TV: ${payload?.tvAddons?.length ? moneyWithLabel(payload.totals.tv) : "—"}`);
|
|
||||||
lines.push(`Dodatkowe usługi: ${payload?.addons?.length ? moneyWithLabel(payload.totals.addons) : "—"}`);
|
|
||||||
lines.push(`Łącznie: ${moneyWithLabel(payload?.totals?.total ?? 0)}`);
|
|
||||||
|
|
||||||
// szczegóły (pozycje)
|
|
||||||
if (payload?.phone) {
|
|
||||||
lines.push("");
|
|
||||||
lines.push(`Telefon: ${payload.phone.name} (${moneyWithLabel(payload.phone.price)})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (payload?.decoder) {
|
|
||||||
lines.push("");
|
|
||||||
lines.push(`Dekoder: ${payload.decoder.name} (${moneyWithLabel(payload.decoder.price)})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(payload?.tvAddons) && payload.tvAddons.length) {
|
|
||||||
lines.push("");
|
|
||||||
lines.push("Pakiety dodatkowe TV:");
|
|
||||||
for (const it of payload.tvAddons) {
|
|
||||||
const termTxt = it.term ? `, ${it.term}` : "";
|
|
||||||
lines.push(
|
|
||||||
`- ${it.nazwa} x${it.qty}${termTxt} @ ${moneyWithLabel(it.unit)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(payload?.addons) && payload.addons.length) {
|
|
||||||
lines.push("");
|
|
||||||
lines.push("Dodatkowe usługi:");
|
|
||||||
for (const it of payload.addons) {
|
|
||||||
lines.push(`- ${it.nazwa} x${it.qty} @ ${moneyWithLabel(it.unit)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveOfferToLocalStorage() {
|
|
||||||
try {
|
|
||||||
const payload = buildOfferPayload();
|
|
||||||
payload.message = buildOfferMessage(payload); // ✅ gotowy tekst
|
|
||||||
localStorage.setItem(LS_KEY, JSON.stringify(payload));
|
|
||||||
} catch { }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ---------
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="f-modal-overlay" onClick={onClose}>
|
<OfferModalShell isOpen={isOpen} onClose={onClose} title={`${pkg.name} — konfiguracja usług`}>
|
||||||
<button
|
<PlanSection
|
||||||
class="f-modal-close"
|
title={pkg.name}
|
||||||
type="button"
|
open={openSections.base}
|
||||||
aria-label="Zamknij"
|
onToggle={() => toggleSection("base")}
|
||||||
onClick={(e) => {
|
price={basePrice}
|
||||||
e.stopPropagation();
|
cenaOpis={cenaOpis}
|
||||||
onClose();
|
features={pkg.features || []}
|
||||||
}}
|
/>
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="f-modal-panel f-modal-panel--compact" onClick={(e) => e.stopPropagation()}>
|
<DecoderSection
|
||||||
<div class="f-modal-inner">
|
open={openSections.decoder}
|
||||||
<h2 class="f-modal-title">{pkg.name} — konfiguracja usług</h2>
|
onToggle={() => toggleSection("decoder")}
|
||||||
|
cenaOpis={cenaOpis}
|
||||||
|
decoders={decodersList}
|
||||||
|
selectedDecoderId={selectedDecoderId}
|
||||||
|
setSelectedDecoderId={setSelectedDecoderId}
|
||||||
|
decoderPrice={decoderPrice}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* ✅ PAKIET (sekcja) */}
|
<TvAddonsSection
|
||||||
<div class="f-modal-section">
|
open={openSections.tv}
|
||||||
<SectionAccordion
|
onToggle={() => toggleSection("tv")}
|
||||||
title={pkg.name}
|
cenaOpis={cenaOpis}
|
||||||
right={<span class="f-modal-phone-price">{money(basePrice)} {cenaOpis}</span>}
|
pkg={pkg}
|
||||||
open={openSections.base}
|
tvAddonsVisible={tvAddonsVisible}
|
||||||
onToggle={() => toggleSection("base")}
|
selectedQty={selectedQty}
|
||||||
>
|
setSelectedQty={setSelectedQty}
|
||||||
{pkg.features?.length ? (
|
tvTerm={tvTerm}
|
||||||
<ul class="f-card-features">
|
setTvTerm={setTvTerm}
|
||||||
{pkg.features.map((f, idx) => (
|
tvAddonsPrice={tvAddonsPrice}
|
||||||
<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 */}
|
<PhoneSection
|
||||||
<div class="f-modal-section">
|
open={openSections.phone}
|
||||||
<SectionAccordion
|
onToggle={() => toggleSection("phone")}
|
||||||
title="Wybór dekodera"
|
cenaOpis={cenaOpis}
|
||||||
right={
|
phonePlans={phonePlans}
|
||||||
<span class="f-modal-phone-price">
|
selectedPhoneId={selectedPhoneId}
|
||||||
{decoderPrice ? `${money(decoderPrice)} ${cenaOpis}` : "—"}
|
setSelectedPhoneId={setSelectedPhoneId}
|
||||||
</span>
|
phonePrice={phonePrice}
|
||||||
}
|
/>
|
||||||
open={openSections.decoder}
|
|
||||||
onToggle={() => toggleSection("decoder")}
|
|
||||||
>
|
|
||||||
|
|
||||||
{decodersList.length === 0 ? (
|
<AddonsSection
|
||||||
<p>Brak dostępnych dekoderów.</p>
|
open={openSections.addons}
|
||||||
) : (
|
onToggle={() => toggleSection("addons")}
|
||||||
<div class="f-radio-list">
|
cenaOpis={cenaOpis}
|
||||||
{decodersList.map((d) => {
|
addonsList={addonsList}
|
||||||
const isSelected = String(selectedDecoderId) === String(d.id);
|
selectedQty={selectedQty}
|
||||||
|
setSelectedQty={setSelectedQty}
|
||||||
|
addonsPrice={addonsOnlyPrice}
|
||||||
|
getUnitPrice={(a) => getAddonUnitPrice(a, pkg, null)}
|
||||||
|
/>
|
||||||
|
|
||||||
return (
|
<SummarySection
|
||||||
<label class={`f-radio-item ${isSelected ? "is-selected" : ""}`} key={d.id}>
|
open={openSections.summary}
|
||||||
<div class="f-radio-check">
|
onToggle={() => toggleSection("summary")}
|
||||||
<input
|
cenaOpis={cenaOpis}
|
||||||
type="radio"
|
totalMonthly={totalMonthly}
|
||||||
name="decoder"
|
ctaHref="/kontakt"
|
||||||
checked={isSelected}
|
onSend={onSend}
|
||||||
onChange={() => setSelectedDecoderId(String(d.id))}
|
rows={[
|
||||||
/>
|
{ label: "Pakiet", value: basePrice, showDashIfZero: false },
|
||||||
</div>
|
{ label: "Telefon", value: phonePrice, showDashIfZero: true },
|
||||||
|
{ label: "Dekoder", value: decoderPrice, showDashIfZero: true },
|
||||||
|
{ label: "Dodatki TV", value: tvAddonsPrice, showDashIfZero: true },
|
||||||
|
{ label: "Dodatkowe usługi", value: addonsOnlyPrice, showDashIfZero: true },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="f-radio-main">
|
<FloatingTotal
|
||||||
<div class="f-radio-name">{d.nazwa}</div>
|
storageKey="fuz_floating_total_pos_tv_v1"
|
||||||
{d.opis && <div class="f-addon-desc">{d.opis}</div>}
|
totalMonthly={totalMonthly}
|
||||||
</div>
|
cenaOpis={cenaOpis}
|
||||||
|
/>
|
||||||
<div class="f-radio-price">
|
</OfferModalShell>
|
||||||
{money(d.cena)} {cenaOpis}
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</SectionAccordion>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* TV ADDONS */}
|
|
||||||
<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 */}
|
|
||||||
<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 */}
|
|
||||||
<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 */}
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<a
|
|
||||||
href="/kontakt"
|
|
||||||
class="btn btn-primary w-full mt-4"
|
|
||||||
onClick={() => saveOfferToLocalStorage()}
|
|
||||||
>
|
|
||||||
Wyślij zapytanie z tym wyborem
|
|
||||||
</a>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SectionAccordion>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
ref={floating.ref}
|
|
||||||
class="f-floating-total"
|
|
||||||
style={floating.style}
|
|
||||||
{...floating.handlers}
|
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
26
src/islands/modals/OfferModalShell.jsx
Normal file
26
src/islands/modals/OfferModalShell.jsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
export default function OfferModalShell({ isOpen, onClose, title, children }) {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
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">{title}</h2>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
123
src/islands/modals/sections/AddonsSection.jsx
Normal file
123
src/islands/modals/sections/AddonsSection.jsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import SectionAccordion from "./SectionAccordion.jsx";
|
||||||
|
import { money } from "../../../lib/money.js";
|
||||||
|
|
||||||
|
export default function AddonsSection({
|
||||||
|
open,
|
||||||
|
onToggle,
|
||||||
|
title = "Dodatkowe usługi",
|
||||||
|
cenaOpis,
|
||||||
|
addonsList = [],
|
||||||
|
selectedQty,
|
||||||
|
setSelectedQty,
|
||||||
|
addonsPrice,
|
||||||
|
// pricing:
|
||||||
|
getUnitPrice, // (addon) => number
|
||||||
|
}) {
|
||||||
|
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 renderRow = (a) => {
|
||||||
|
const qty = Number(selectedQty[a.id] || 0);
|
||||||
|
const isQty = a.typ === "quantity" || a.ilosc === true;
|
||||||
|
|
||||||
|
const unit = Number(getUnitPrice?.(a) ?? 0);
|
||||||
|
|
||||||
|
if (!isQty) {
|
||||||
|
return (
|
||||||
|
<label class="f-addon-item" key={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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="f-addon-price">{money(unit)} {cenaOpis}</div>
|
||||||
|
|
||||||
|
{/* ✅ opis jako osobny wiersz na pełną szerokość */}
|
||||||
|
{a.opis ? (
|
||||||
|
<div class="f-addon-below" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div class="f-addon-desc">{a.opis}</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</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={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-section">
|
||||||
|
<SectionAccordion
|
||||||
|
title={title}
|
||||||
|
right={<span class="f-modal-phone-price">{addonsPrice ? `${money(addonsPrice)} ${cenaOpis}` : "—"}</span>}
|
||||||
|
open={open}
|
||||||
|
onToggle={onToggle}
|
||||||
|
>
|
||||||
|
{addonsList.length === 0 ? (
|
||||||
|
<p>Brak usług dodatkowych.</p>
|
||||||
|
) : (
|
||||||
|
<div class="f-addon-list">{addonsList.map(renderRow)}</div>
|
||||||
|
)}
|
||||||
|
</SectionAccordion>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
59
src/islands/modals/sections/DecoderSection.jsx
Normal file
59
src/islands/modals/sections/DecoderSection.jsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import SectionAccordion from "./SectionAccordion.jsx";
|
||||||
|
import { money } from "../../../lib/money.js";
|
||||||
|
|
||||||
|
export default function DecoderSection({
|
||||||
|
open,
|
||||||
|
onToggle,
|
||||||
|
cenaOpis,
|
||||||
|
decoders = [],
|
||||||
|
selectedDecoderId,
|
||||||
|
setSelectedDecoderId,
|
||||||
|
decoderPrice,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div class="f-modal-section">
|
||||||
|
<SectionAccordion
|
||||||
|
title="Wybór dekodera"
|
||||||
|
right={<span class="f-modal-phone-price">{decoderPrice ? `${money(decoderPrice)} ${cenaOpis}` : "—"}</span>}
|
||||||
|
open={open}
|
||||||
|
onToggle={onToggle}
|
||||||
|
>
|
||||||
|
{decoders.length === 0 ? (
|
||||||
|
<p>Brak dostępnych dekoderów.</p>
|
||||||
|
) : (
|
||||||
|
<div class="f-radio-list">
|
||||||
|
{decoders.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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="f-radio-price">{money(d.cena)} {cenaOpis}</div>
|
||||||
|
|
||||||
|
{d.opis ? (
|
||||||
|
<div class="f-radio-below">
|
||||||
|
<div class="f-addon-desc">{d.opis}</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SectionAccordion>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
src/islands/modals/sections/FloatingTotal.jsx
Normal file
22
src/islands/modals/sections/FloatingTotal.jsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import useDraggableFloating from "../../hooks/useDraggableFloating.js";
|
||||||
|
import { money } from "../../../lib/money.js";
|
||||||
|
|
||||||
|
export default function FloatingTotal({ storageKey, totalMonthly, cenaOpis }) {
|
||||||
|
const floating = useDraggableFloating(storageKey);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={floating.ref}
|
||||||
|
class="f-floating-total"
|
||||||
|
style={floating.style}
|
||||||
|
{...floating.handlers}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
100
src/islands/modals/sections/PhoneSection.jsx
Normal file
100
src/islands/modals/sections/PhoneSection.jsx
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import SectionAccordion from "./SectionAccordion.jsx";
|
||||||
|
import { money } from "../../../lib/money.js";
|
||||||
|
|
||||||
|
function formatFeatureValue(val) {
|
||||||
|
if (val === true || val === "true") return "✓";
|
||||||
|
if (val === false || val === "false" || val == null) return "✕";
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PhoneSection({
|
||||||
|
open,
|
||||||
|
onToggle,
|
||||||
|
cenaOpis,
|
||||||
|
phonePlans = [],
|
||||||
|
selectedPhoneId,
|
||||||
|
setSelectedPhoneId,
|
||||||
|
phonePrice,
|
||||||
|
}) {
|
||||||
|
const handlePhoneSelect = (id) => {
|
||||||
|
if (id === null) {
|
||||||
|
setSelectedPhoneId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedPhoneId(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="f-modal-section">
|
||||||
|
<SectionAccordion
|
||||||
|
title="Usługa telefoniczna"
|
||||||
|
right={<span class="f-modal-phone-price">{phonePrice ? `${money(phonePrice)} ${cenaOpis}` : "—"}</span>}
|
||||||
|
open={open}
|
||||||
|
onToggle={onToggle}
|
||||||
|
>
|
||||||
|
{phonePlans.length === 0 ? (
|
||||||
|
<p>Brak dostępnych pakietów telefonicznych.</p>
|
||||||
|
) : (
|
||||||
|
<div class="f-radio-list">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
src/islands/modals/sections/PlanSection.jsx
Normal file
34
src/islands/modals/sections/PlanSection.jsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import SectionAccordion from "./SectionAccordion.jsx";
|
||||||
|
import { money } from "../../../lib/money.js";
|
||||||
|
|
||||||
|
function formatFeatureValue(val) {
|
||||||
|
if (val === true || val === "true") return "✓";
|
||||||
|
if (val === false || val === "false" || val == null) return "✕";
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlanSection({ title, open, onToggle, price, cenaOpis, features = [] }) {
|
||||||
|
return (
|
||||||
|
<div class="f-modal-section">
|
||||||
|
<SectionAccordion
|
||||||
|
title={title}
|
||||||
|
right={<span class="f-modal-phone-price">{money(price)} {cenaOpis}</span>}
|
||||||
|
open={open}
|
||||||
|
onToggle={onToggle}
|
||||||
|
>
|
||||||
|
{features?.length ? (
|
||||||
|
<ul class="f-card-features">
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
src/islands/modals/sections/SectionAccordion.jsx
Normal file
19
src/islands/modals/sections/SectionAccordion.jsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
export default 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
src/islands/modals/sections/SummarySection.jsx
Normal file
52
src/islands/modals/sections/SummarySection.jsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import SectionAccordion from "./SectionAccordion.jsx";
|
||||||
|
import { money } from "../../../lib/money.js";
|
||||||
|
|
||||||
|
export default function SummarySection({
|
||||||
|
open,
|
||||||
|
onToggle,
|
||||||
|
cenaOpis,
|
||||||
|
rows = [], // [{label, valueNumberOrNull, showDashIfZero?}]
|
||||||
|
totalMonthly,
|
||||||
|
ctaHref = "/kontakt#form",
|
||||||
|
onSend,
|
||||||
|
ctaLabel = "Wyślij zapytanie z tym wyborem",
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div class="f-modal-section">
|
||||||
|
<SectionAccordion
|
||||||
|
title="Podsumowanie miesięczne"
|
||||||
|
right={<span class="f-modal-phone-price">{money(totalMonthly)} {cenaOpis}</span>}
|
||||||
|
open={open}
|
||||||
|
onToggle={onToggle}
|
||||||
|
>
|
||||||
|
<div class="f-summary">
|
||||||
|
<div class="f-summary-list">
|
||||||
|
{rows.map((r) => {
|
||||||
|
const v = Number(r.value ?? 0);
|
||||||
|
const showDash = r.showDashIfZero !== false; // domyślnie true
|
||||||
|
return (
|
||||||
|
<div class="f-summary-row" key={r.label}>
|
||||||
|
<span>{r.label}</span>
|
||||||
|
<span>{(v > 0 || !showDash) ? `${money(v)} ${cenaOpis}` : "—"}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<div class="f-summary-total">
|
||||||
|
<span>Łącznie</span>
|
||||||
|
<span>{money(totalMonthly)} {cenaOpis}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={ctaHref}
|
||||||
|
class="btn btn-primary w-full mt-4"
|
||||||
|
onClick={() => onSend?.()}
|
||||||
|
>
|
||||||
|
{ctaLabel}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SectionAccordion>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
163
src/islands/modals/sections/TvAddonsSection.jsx
Normal file
163
src/islands/modals/sections/TvAddonsSection.jsx
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import SectionAccordion from "./SectionAccordion.jsx";
|
||||||
|
import { money } from "../../../lib/money.js";
|
||||||
|
import { hasTvTermPricing, getAddonUnitPrice } from "../../../lib/offer-pricing.js";
|
||||||
|
|
||||||
|
export default function TvAddonsSection({
|
||||||
|
open,
|
||||||
|
onToggle,
|
||||||
|
cenaOpis,
|
||||||
|
pkg,
|
||||||
|
tvAddonsVisible = [],
|
||||||
|
selectedQty,
|
||||||
|
setSelectedQty,
|
||||||
|
tvTerm,
|
||||||
|
setTvTerm,
|
||||||
|
tvAddonsPrice,
|
||||||
|
}) {
|
||||||
|
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 renderRow = (a) => {
|
||||||
|
const qty = Number(selectedQty[a.id] || 0);
|
||||||
|
const isQty = a.typ === "quantity" || a.ilosc === true;
|
||||||
|
|
||||||
|
const termPricing = 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 f-addon-item--tv" key={"tv-" + 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>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
|
||||||
|
{/* ✅ osobny wiersz na pełną szerokość */}
|
||||||
|
{(a.tid || a.opis) && (
|
||||||
|
<div class="f-addon-below" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{a.tid ? (
|
||||||
|
<a
|
||||||
|
class="f-addon-more"
|
||||||
|
href={`/internet-telewizja/pakiety-tematyczne#tid-${encodeURIComponent(a.tid)}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
aria-label={`Więcej informacji o pakiecie ${a.nazwa ?? ""} (otwiera się w nowej karcie)`}
|
||||||
|
title={`Więcej o pakiecie ${a.nazwa ?? ""}`}
|
||||||
|
>
|
||||||
|
Przejdź do szczegółowych informacji pakietu {a.nazwa ?? ""}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
a.opis ? <div class="f-addon-desc">{a.opis}</div> : null
|
||||||
|
)}
|
||||||
|
</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 f-addon-item--tv" key={"tvq-" + 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-section">
|
||||||
|
<SectionAccordion
|
||||||
|
title="Pakiety dodatkowe TV"
|
||||||
|
right={<span class="f-modal-phone-price">{tvAddonsPrice ? `${money(tvAddonsPrice)} ${cenaOpis}` : "—"}</span>}
|
||||||
|
open={open}
|
||||||
|
onToggle={onToggle}
|
||||||
|
>
|
||||||
|
{tvAddonsVisible.length === 0 ? (
|
||||||
|
<p>Brak pakietów dodatkowych TV.</p>
|
||||||
|
) : (
|
||||||
|
<div class="f-addon-list">{tvAddonsVisible.map(renderRow)}</div>
|
||||||
|
)}
|
||||||
|
</SectionAccordion>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
src/lib/money.js
Normal file
8
src/lib/money.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export function money(amount) {
|
||||||
|
const n = Number(amount || 0);
|
||||||
|
return n.toFixed(2).replace(".", ",");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moneyWithLabel(v, cenaOpis) {
|
||||||
|
return `${money(v)} ${cenaOpis}`;
|
||||||
|
}
|
||||||
47
src/lib/offer-normalize.js
Normal file
47
src/lib/offer-normalize.js
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
/** telefon z YAML (phone/cards.yaml -> cards[]) => { id, name, price_monthly, features[] } */
|
||||||
|
export 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 */
|
||||||
|
export 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) */
|
||||||
|
export 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,
|
||||||
|
tid: a.tid != null ? String(a.tid) : "",
|
||||||
|
}));
|
||||||
|
}
|
||||||
57
src/lib/offer-payload.js
Normal file
57
src/lib/offer-payload.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { moneyWithLabel } from "./money.js";
|
||||||
|
|
||||||
|
export const LS_KEY = "fuz_offer_config_v1";
|
||||||
|
|
||||||
|
export function buildOfferMessage(payload, cenaOpis) {
|
||||||
|
const m = (v) => moneyWithLabel(v, cenaOpis);
|
||||||
|
const lines = [];
|
||||||
|
|
||||||
|
lines.push(`Wybrana oferta: ${payload?.pkg?.name || "—"}`);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
const t = payload?.totals || {};
|
||||||
|
lines.push(`Pakiet: ${m(t.base ?? 0)}`);
|
||||||
|
lines.push(`Telefon: ${payload?.phone ? m(t.phone ?? 0) : "—"}`);
|
||||||
|
|
||||||
|
if ("decoder" in t) lines.push(`Dekoder: ${payload?.decoder ? m(t.decoder ?? 0) : "—"}`);
|
||||||
|
if ("tv" in t) lines.push(`Dodatki TV: ${payload?.tvAddons?.length ? m(t.tv ?? 0) : "—"}`);
|
||||||
|
|
||||||
|
lines.push(`Dodatkowe usługi: ${payload?.addons?.length ? m(t.addons ?? 0) : "—"}`);
|
||||||
|
lines.push(`Łącznie: ${m(t.total ?? 0)}`);
|
||||||
|
|
||||||
|
if (payload?.phone) {
|
||||||
|
lines.push("");
|
||||||
|
lines.push(`Telefon: ${payload.phone.name} (${m(payload.phone.price)})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload?.decoder) {
|
||||||
|
lines.push("");
|
||||||
|
lines.push(`Dekoder: ${payload.decoder.name} (${m(payload.decoder.price)})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(payload?.tvAddons) && payload.tvAddons.length) {
|
||||||
|
lines.push("");
|
||||||
|
lines.push("Pakiety dodatkowe TV:");
|
||||||
|
for (const it of payload.tvAddons) {
|
||||||
|
const termTxt = it.term ? `, ${it.term}` : "";
|
||||||
|
lines.push(`- ${it.nazwa} x${it.qty}${termTxt} @ ${m(it.unit)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(payload?.addons) && payload.addons.length) {
|
||||||
|
lines.push("");
|
||||||
|
lines.push("Dodatkowe usługi:");
|
||||||
|
for (const it of payload.addons) {
|
||||||
|
lines.push(`- ${it.nazwa} x${it.qty} @ ${m(it.unit)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveOfferToLocalStorage(payload, cenaOpis) {
|
||||||
|
try {
|
||||||
|
payload.message = buildOfferMessage(payload, cenaOpis);
|
||||||
|
localStorage.setItem(LS_KEY, JSON.stringify(payload));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
83
src/lib/offer-pricing.js
Normal file
83
src/lib/offer-pricing.js
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
function normKey(s) {
|
||||||
|
return String(s || "").trim().toLowerCase().replace(/\s+/g, " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** TV: wybór wariantu ceny po pkg.name, albo fallback "*" */
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTvAddonAvailableForPkg(addon, pkg) {
|
||||||
|
if (!pkg) return false;
|
||||||
|
const v = pickTvVariant(addon, String(pkg?.name ?? ""));
|
||||||
|
return !!v;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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
|
||||||
|
*/
|
||||||
|
export 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);
|
||||||
|
|
||||||
|
// tv-addons.yaml
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// legacy object
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,466 +1,352 @@
|
|||||||
.f-section-acc .f-accordion-header {
|
.f-section-acc .f-accordion-header {
|
||||||
@apply flex items-center justify-between gap-3;
|
@apply flex items-center justify-between gap-3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.f-accordion-header-right {
|
.f-accordion-header-right {
|
||||||
@apply flex items-center gap-3;
|
@apply flex items-center gap-3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.f-acc-chevron {
|
.f-acc-chevron {
|
||||||
@apply opacity-60 text-sm;
|
@apply opacity-60 text-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.f-accordion-item {
|
||||||
|
@apply rounded-xl border overflow-hidden bg-[--f-background];
|
||||||
|
border-color: rgba(148, 163, 184, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f-accordion-header {
|
||||||
|
@apply w-full flex items-center justify-between gap-4 px-4 py-2 cursor-pointer;
|
||||||
|
background: rgba(148, 163, 184, 0.06);
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f-accordion-header-left {
|
||||||
|
@apply flex items-center gap-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f-modal-phone-name {
|
||||||
|
@apply font-medium ml-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f-modal-phone-price {
|
||||||
|
@apply font-semibold whitespace-nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f-accordion-body {
|
||||||
|
@apply px-4 pt-2 pb-3;
|
||||||
|
border-top: 1px solid rgba(148, 163, 184, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f-accordion-item.is-open .f-accordion-header {
|
||||||
|
background: color-mix(in srgb, var(--fuz-accent, #2563eb) 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f-modal-section {
|
||||||
|
@apply mb-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f-radio-item {
|
||||||
|
@apply grid items-start gap-3 px-3 py-2 cursor-pointer;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
grid-template-areas:
|
||||||
|
"check main price"
|
||||||
|
"below below below";
|
||||||
|
border-bottom: 1px solid rgba(148, 163, 184, 0.4);
|
||||||
|
background: var(--f-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.f-floating-total {
|
.f-radio-item:last-child {
|
||||||
@apply fixed bottom-5 right-5 z-[10000];
|
border-bottom: none;
|
||||||
@apply pointer-events-auto;
|
}
|
||||||
@apply select-none;
|
|
||||||
touch-action: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* kółko */
|
.f-radio-check input {
|
||||||
.f-floating-total-circle {
|
@apply mt-1;
|
||||||
@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-[--f-addons-background];
|
|
||||||
@apply backdrop-blur-md;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* kwota */
|
.f-radio-name {
|
||||||
.f-floating-total-amount {
|
@apply font-medium;
|
||||||
@apply text-lg md:text-xl font-bold leading-none text-[--f-addons-text];
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/* jednostka */
|
.f-radio-price {
|
||||||
.f-floating-total-unit {
|
@apply whitespace-nowrap font-semibold;
|
||||||
@apply my-1 text-xs md:text-sm opacity-70 text-[--f-addons-text];
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
.f-radio-details {
|
||||||
|
@apply pl-10 pr-3 pb-3 -mt-1 text-sm;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes fuz-bounce {
|
.f-addon-list {
|
||||||
0%, 100% { transform: translateY(0); }
|
@apply flex flex-col gap-2;
|
||||||
50% { transform: translateY(-6px); }
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.f-floating-total:hover .f-floating-total-circle {
|
.f-addon-item {
|
||||||
animation: fuz-bounce 420ms ease-in-out;
|
@apply grid items-start gap-3 px-3 py-2 cursor-pointer;
|
||||||
}
|
border-bottom: 1px solid rgba(148, 163, 184, 0.4);
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
background: var(--f-background);
|
||||||
|
}
|
||||||
|
|
||||||
.f-floating-total:active .f-floating-total-circle {
|
.f-addon-item:last-child {
|
||||||
transform: translateY(-4px) scale(1.02);
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 3D */
|
.f-addon-checkbox {
|
||||||
.f-floating-total-circle {
|
@apply flex items-center justify-center;
|
||||||
@apply relative overflow-hidden;
|
align-items: center;
|
||||||
@apply shadow-xl;
|
margin-top: 0.1rem;
|
||||||
@apply bg-[--f-addons-background];
|
}
|
||||||
@apply backdrop-blur-md;
|
|
||||||
|
|
||||||
/* 3D feel */
|
.f-addon-checkbox input[type="checkbox"] {
|
||||||
box-shadow:
|
width: 1.05rem;
|
||||||
0 18px 35px hsla(221 47% 11% / 0.28),
|
height: 1.05rem;
|
||||||
0 6px 14px hsla(221 47% 11% / 0.18),
|
transform: scale(1.05);
|
||||||
inset 0 1px 0 hsla(0 0% 100% / 0.22),
|
accent-color: var(--fuz-accent, #2563eb);
|
||||||
inset 0 -10px 18px hsla(221 47% 11% / 0.25);
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
/* subtelna “kopuła” */
|
.f-addon-main {
|
||||||
background-image:
|
@apply flex flex-col gap-0.5;
|
||||||
radial-gradient(120% 120% at 30% 20%, hsla(0 0% 100% / 0.22) 0%, transparent 55%),
|
min-width: 0;
|
||||||
radial-gradient(140% 140% at 70% 80%, hsla(221 47% 11% / 0.22) 0%, transparent 60%);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/* połysk */
|
.f-addon-name {
|
||||||
.f-floating-total-circle::before {
|
@apply font-medium;
|
||||||
content: "";
|
}
|
||||||
position: absolute;
|
|
||||||
inset: -30% -30% auto -30%;
|
|
||||||
height: 70%;
|
|
||||||
border-radius: 9999px;
|
|
||||||
background: radial-gradient(
|
|
||||||
closest-side,
|
|
||||||
hsla(0 0% 100% / 0.28),
|
|
||||||
transparent 70%
|
|
||||||
);
|
|
||||||
transform: rotate(-12deg);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* “rim”/krawędź */
|
.f-addon-desc {
|
||||||
.f-floating-total-circle::after {
|
@apply text-sm opacity-85;
|
||||||
content: "";
|
}
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
border-radius: 9999px;
|
|
||||||
border: 1px solid hsla(0 0% 100% / 0.16);
|
|
||||||
box-shadow: inset 0 0 0 1px hsla(221 47% 11% / 0.18);
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* lekka reakcja 3D na hover */
|
.f-addon-more {
|
||||||
@media (hover: hover) and (pointer: fine) {
|
@apply text-sm underline opacity-80;
|
||||||
.f-floating-total:hover .f-floating-total-circle {
|
}
|
||||||
transform: translateY(-2px) scale(1.02);
|
|
||||||
box-shadow:
|
|
||||||
0 22px 44px hsla(221 47% 11% / 0.32),
|
|
||||||
0 8px 18px hsla(221 47% 11% / 0.20),
|
|
||||||
inset 0 1px 0 hsla(0 0% 100% / 0.24),
|
|
||||||
inset 0 -12px 20px hsla(221 47% 11% / 0.28);
|
|
||||||
transition: transform 180ms ease, box-shadow 180ms ease;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
.f-addon-price {
|
||||||
|
@apply font-semibold whitespace-nowrap;
|
||||||
|
justify-self: end;
|
||||||
|
text-align: right;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f-addon-price-total {
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
font-size: 0.9em;
|
||||||
|
font-weight: 600;
|
||||||
|
opacity: 0.85;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--fuz-accent, #2563eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f-addon-item--qty {
|
||||||
|
grid-template-columns: auto 1fr auto auto;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f-addon-item--qty .f-addon-checkbox {
|
||||||
|
visibility: hidden;
|
||||||
|
width: 1.05rem;
|
||||||
|
height: 1.05rem;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
/* ----------- Uporządkować ------ */
|
.f-addon-item--qty .f-addon-qty {
|
||||||
/* ===========================
|
justify-self: end;
|
||||||
TELEFON — AKORDEON
|
display: inline-flex;
|
||||||
=========================== */
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
.f-modal-phone-list.f-accordion {
|
.f-addon-qty-value {
|
||||||
@apply flex flex-col gap-2;
|
min-width: 2ch;
|
||||||
}
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
.f-accordion-item {
|
@media (max-width: 640px) {
|
||||||
@apply rounded-xl border overflow-hidden bg-[--f-background];
|
|
||||||
border-color: rgba(148, 163, 184, 0.6);
|
|
||||||
}
|
|
||||||
|
|
||||||
.f-accordion-header {
|
|
||||||
@apply w-full flex items-center justify-between gap-4 px-4 py-2 cursor-pointer;
|
|
||||||
background: rgba(148, 163, 184, 0.06);
|
|
||||||
border: none;
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.f-accordion-header-left {
|
|
||||||
@apply flex items-center gap-1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.f-modal-phone-name {
|
|
||||||
@apply font-medium ml-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.f-modal-phone-price {
|
|
||||||
@apply font-semibold whitespace-nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.f-accordion-body {
|
|
||||||
@apply px-4 pt-2 pb-3;
|
|
||||||
border-top: 1px solid rgba(148, 163, 184, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* wyróżnienie otwartego pakietu */
|
|
||||||
.f-accordion-item.is-open .f-accordion-header {
|
|
||||||
background: color-mix(in srgb, var(--fuz-accent, #2563eb) 8%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ===========================
|
|
||||||
DODATKI — KOLUMNOWA LISTA (GRID)
|
|
||||||
checkbox: checkbox | main | price
|
|
||||||
quantity: slot | main | qty | price
|
|
||||||
=========================== */
|
|
||||||
|
|
||||||
.f-addon-list {
|
|
||||||
@apply flex flex-col gap-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* BAZA: checkbox | main | price */
|
|
||||||
.f-addon-item {
|
|
||||||
@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); */
|
|
||||||
background: var(--f-background);
|
|
||||||
@apply cursor-pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.f-addon-item * {
|
|
||||||
@apply cursor-pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.f-addon-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.f-addon-item:hover {
|
|
||||||
/* border-color: color-mix(
|
|
||||||
in srgb,
|
|
||||||
var(--fuz-accent, #2563eb) 70%,
|
|
||||||
rgba(148, 163, 184, 0.5) 30%
|
|
||||||
); */
|
|
||||||
}
|
|
||||||
|
|
||||||
.f-addon-item input[type="checkbox"] {
|
|
||||||
@apply cursor-pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* kolumna 1 */
|
|
||||||
.f-addon-checkbox {
|
|
||||||
@apply flex items-center justify-center;
|
|
||||||
align-items: center;
|
|
||||||
margin-top: 0.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.f-addon-checkbox input[type="checkbox"] {
|
|
||||||
width: 1.05rem;
|
|
||||||
height: 1.05rem;
|
|
||||||
transform: scale(1.05);
|
|
||||||
accent-color: var(--fuz-accent, #2563eb);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* kolumna 2 */
|
|
||||||
.f-addon-main {
|
|
||||||
@apply flex flex-col gap-0.5;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.f-addon-name {
|
|
||||||
@apply font-medium;
|
|
||||||
}
|
|
||||||
|
|
||||||
.f-addon-desc {
|
|
||||||
@apply text-sm opacity-85;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* kolumna 3 (cena) */
|
|
||||||
.f-addon-price {
|
|
||||||
@apply font-semibold whitespace-nowrap;
|
|
||||||
justify-self: end;
|
|
||||||
text-align: right;
|
|
||||||
min-width: 140px;
|
|
||||||
/* stała kolumna cen */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* suma pod ceną (quantity) */
|
|
||||||
.f-addon-price-total {
|
|
||||||
margin-top: 0.15rem;
|
|
||||||
font-size: 0.9em;
|
|
||||||
font-weight: 600;
|
|
||||||
opacity: 0.85;
|
|
||||||
white-space: nowrap;
|
|
||||||
color: var(--fuz-accent, #2563eb);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* WARIANT: quantity -> slot | main | qty | price */
|
|
||||||
.f-addon-item--qty {
|
.f-addon-item--qty {
|
||||||
grid-template-columns: auto 1fr auto auto;
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* “pusty” slot w kolumnie 1 (żeby wyrównać do checkboxa) */
|
|
||||||
.f-addon-item--qty .f-addon-checkbox {
|
|
||||||
visibility: hidden;
|
|
||||||
/* zajmuje miejsce, ale nie widać */
|
|
||||||
width: 1.05rem;
|
|
||||||
height: 1.05rem;
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* kolumna qty (3) – bliżej prawej */
|
|
||||||
.f-addon-item--qty .f-addon-qty {
|
|
||||||
justify-self: end;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
margin-top: 0.05rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* wartość qty */
|
|
||||||
.f-addon-qty-value {
|
|
||||||
min-width: 2ch;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* mobile: w razie ciasnoty przenosimy qty pod main, cena zostaje po prawej */
|
|
||||||
@media (max-width: 640px) {
|
|
||||||
.f-addon-item--qty {
|
|
||||||
grid-template-columns: auto 1fr auto;
|
|
||||||
grid-template-areas:
|
|
||||||
"slot main price"
|
|
||||||
"slot qty price";
|
|
||||||
}
|
|
||||||
|
|
||||||
.f-addon-item--qty .f-addon-checkbox {
|
|
||||||
grid-area: slot;
|
|
||||||
}
|
|
||||||
|
|
||||||
.f-addon-item--qty .f-addon-main {
|
|
||||||
grid-area: main;
|
|
||||||
}
|
|
||||||
|
|
||||||
.f-addon-item--qty .f-addon-qty {
|
|
||||||
grid-area: qty;
|
|
||||||
justify-self: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.f-addon-item--qty .f-addon-price {
|
|
||||||
grid-area: price;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* ===========================
|
|
||||||
PODSUMOWANIE MIESIĘCZNE
|
|
||||||
=========================== */
|
|
||||||
|
|
||||||
.f-summary {
|
|
||||||
@apply pt-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.f-summary-list {
|
|
||||||
@apply flex flex-col gap-1 mt-2 p-4 rounded-xl;
|
|
||||||
background: rgba(148, 163, 184, 0.07);
|
|
||||||
}
|
|
||||||
|
|
||||||
.f-summary-row,
|
|
||||||
.f-summary-total {
|
|
||||||
@apply flex items-center justify-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.f-summary-row span:last-child {
|
|
||||||
@apply font-medium whitespace-nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.f-summary-total {
|
|
||||||
@apply mt-1 pt-2;
|
|
||||||
border-top: 1px solid rgba(148, 163, 184, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.f-summary-total span:last-child {
|
|
||||||
@apply font-bold;
|
|
||||||
font-size: 1.25rem;
|
|
||||||
color: var(--fuz-accent, #2563eb);
|
|
||||||
}
|
|
||||||
|
|
||||||
.f-modal-section {
|
|
||||||
@apply mb-6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.f-modal-section h3 {
|
|
||||||
@apply text-xl md:text-2xl font-semibold mb-3;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* opcja "bez telefonu" */
|
|
||||||
.f-accordion-item--no-phone .f-accordion-header {
|
|
||||||
background: rgba(148, 163, 184, 0.03);
|
|
||||||
}
|
|
||||||
|
|
||||||
.f-accordion-header-left input[type="radio"] {
|
|
||||||
width: 1.05rem;
|
|
||||||
height: 1.05rem;
|
|
||||||
transform: scale(1.05);
|
|
||||||
accent-color: var(--fuz-accent, #2563eb);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.f-addon-checkbox input[type="checkbox"] {
|
|
||||||
width: 1.05rem;
|
|
||||||
height: 1.05rem;
|
|
||||||
transform: scale(1.05);
|
|
||||||
accent-color: var(--fuz-accent, #2563eb);
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.f-accordion-header-left,
|
|
||||||
.f-addon-checkbox {
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* ===========================
|
|
||||||
FLOATING TOTAL (dymek jak czat)
|
|
||||||
=========================== */
|
|
||||||
|
|
||||||
.f-floating-total {
|
|
||||||
position: fixed;
|
|
||||||
right: 1rem;
|
|
||||||
bottom: 1rem;
|
|
||||||
z-index: 10000;
|
|
||||||
/* wyżej niż overlay (9999) */
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.f-floating-total-inner {
|
|
||||||
@apply flex items-center gap-3;
|
|
||||||
@apply px-4 py-3 rounded-2xl shadow-xl border;
|
|
||||||
border-color: rgba(148, 163, 184, 0.5);
|
|
||||||
background: color-mix(in srgb, var(--f-background) 92%, transparent);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.f-floating-total-label {
|
|
||||||
@apply text-sm opacity-80;
|
|
||||||
}
|
|
||||||
|
|
||||||
.f-floating-total-value {
|
|
||||||
@apply font-bold whitespace-nowrap;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
color: var(--fuz-accent, #2563eb);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* na bardzo małych ekranach lekko mniejszy dymek */
|
|
||||||
@media (max-width: 420px) {
|
|
||||||
.f-floating-total-inner {
|
|
||||||
@apply px-3 py-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.f-floating-total-value {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.f-addon-price-total {
|
|
||||||
margin-top: 0.15rem;
|
|
||||||
font-size: 0.9em;
|
|
||||||
font-weight: 600;
|
|
||||||
opacity: 0.85;
|
|
||||||
white-space: nowrap;
|
|
||||||
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;
|
grid-template-columns: auto 1fr auto;
|
||||||
border-bottom: 1px solid rgba(148, 163, 184, 0.4);
|
grid-template-areas:
|
||||||
background: var(--f-background);
|
"slot main price"
|
||||||
|
"slot qty price";
|
||||||
}
|
}
|
||||||
|
|
||||||
.f-radio-item:last-child {
|
.f-addon-item--qty .f-addon-checkbox {
|
||||||
border-bottom: none;
|
grid-area: slot;
|
||||||
}
|
}
|
||||||
|
|
||||||
.f-radio-check input {
|
.f-addon-item--qty .f-addon-main {
|
||||||
@apply mt-1;
|
grid-area: main;
|
||||||
}
|
}
|
||||||
|
|
||||||
.f-radio-name {
|
.f-addon-item--qty .f-addon-qty {
|
||||||
@apply font-medium;
|
grid-area: qty;
|
||||||
|
justify-self: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.f-radio-price {
|
.f-addon-item--qty .f-addon-price {
|
||||||
@apply whitespace-nowrap font-semibold;
|
grid-area: price;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.f-summary {
|
||||||
|
@apply pt-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f-summary-list {
|
||||||
|
@apply flex flex-col gap-1 mt-2 p-4 rounded-xl;
|
||||||
|
background: rgba(148, 163, 184, 0.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f-summary-row,
|
||||||
|
.f-summary-total {
|
||||||
|
@apply flex items-center justify-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f-summary-row span:last-child {
|
||||||
|
@apply font-medium whitespace-nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f-summary-total {
|
||||||
|
@apply mt-1 pt-2;
|
||||||
|
border-top: 1px solid rgba(148, 163, 184, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f-summary-total span:last-child {
|
||||||
|
@apply font-bold;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--fuz-accent, #2563eb);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.f-floating-total {
|
||||||
|
@apply fixed bottom-5 right-5 z-[10000];
|
||||||
|
@apply pointer-events-auto select-none;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 relative overflow-hidden;
|
||||||
|
@apply bg-[--f-addons-background];
|
||||||
|
@apply backdrop-blur-md;
|
||||||
|
|
||||||
|
box-shadow:
|
||||||
|
0 18px 35px hsla(221 47% 11% / 0.28),
|
||||||
|
0 6px 14px hsla(221 47% 11% / 0.18),
|
||||||
|
inset 0 1px 0 hsla(0 0% 100% / 0.22),
|
||||||
|
inset 0 -10px 18px hsla(221 47% 11% / 0.25);
|
||||||
|
|
||||||
|
background-image:
|
||||||
|
radial-gradient(120% 120% at 30% 20%, hsla(0 0% 100% / 0.22) 0%, transparent 55%),
|
||||||
|
radial-gradient(140% 140% at 70% 80%, hsla(221 47% 11% / 0.22) 0%, transparent 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.f-floating-total-circle::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: -30% -30% auto -30%;
|
||||||
|
height: 70%;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: radial-gradient(closest-side, hsla(0 0% 100% / 0.28), transparent 70%);
|
||||||
|
transform: rotate(-12deg);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f-floating-total-circle::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 1px solid hsla(0 0% 100% / 0.16);
|
||||||
|
box-shadow: inset 0 0 0 1px hsla(221 47% 11% / 0.18);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f-floating-total-amount {
|
||||||
|
@apply text-lg md:text-xl font-bold leading-none text-[--f-addons-text];
|
||||||
|
}
|
||||||
|
|
||||||
|
.f-floating-total-unit {
|
||||||
|
@apply my-1 text-xs md:text-sm opacity-70 text-[--f-addons-text];
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fuz-bounce {
|
||||||
|
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.f-radio-item.is-selected {
|
50% {
|
||||||
/* delikatne wyróżnienie wybranego */
|
transform: translateY(-6px);
|
||||||
/* @apply rounded-xl; */
|
}
|
||||||
/* background: rgba(148, 163, 184, 0.12); */
|
}
|
||||||
|
|
||||||
|
.f-floating-total:hover .f-floating-total-circle {
|
||||||
|
animation: fuz-bounce 420ms ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f-floating-total:active .f-floating-total-circle {
|
||||||
|
transform: translateY(-4px) scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
.f-floating-total:hover .f-floating-total-circle {
|
||||||
|
transform: translateY(-2px) scale(1.02);
|
||||||
|
box-shadow:
|
||||||
|
0 22px 44px hsla(221 47% 11% / 0.32),
|
||||||
|
0 8px 18px hsla(221 47% 11% / 0.20),
|
||||||
|
inset 0 1px 0 hsla(0 0% 100% / 0.24),
|
||||||
|
inset 0 -12px 20px hsla(221 47% 11% / 0.28);
|
||||||
|
transition: transform 180ms ease, box-shadow 180ms ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.f-addon-below {
|
||||||
|
grid-column: 1 / -1; /* pełna szerokość */
|
||||||
|
@apply pt-1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f-addon-below {
|
||||||
|
grid-column: 1 / -1; /* od kolumny main */
|
||||||
|
}
|
||||||
|
|
||||||
|
.f-radio-check { grid-area: check; }
|
||||||
|
.f-radio-main { grid-area: main; min-width: 0; }
|
||||||
|
.f-radio-price { grid-area: price; justify-self: end; text-align: right; }
|
||||||
|
.f-radio-below {
|
||||||
|
grid-area: below;
|
||||||
|
@apply text-sm opacity-85;
|
||||||
|
justify-self: start;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.f-addon-price {
|
||||||
|
@apply font-semibold whitespace-nowrap;
|
||||||
|
justify-self: end;
|
||||||
|
text-align: right;
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ✅ DLA QTY — nie trzymaj 140px, bo na mobile wypycha */
|
||||||
|
.f-addon-item--qty .f-addon-price {
|
||||||
|
min-width: 110px; /* było 140px */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ✅ DLA QTY na małych ekranach jeszcze ciaśniej */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.f-addon-item--qty .f-addon-price {
|
||||||
|
min-width: 96px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.f-radio-details {
|
/* opcjonalnie: minimalnie mniejsza czcionka w cenie w QTY */
|
||||||
@apply pl-10 pr-3 pb-3 -mt-1 text-sm;
|
.f-addon-item--qty .f-addon-price,
|
||||||
/* pl-10 = przesunięcie w prawo (radio + gap) */
|
.f-addon-item--qty .f-addon-price-total {
|
||||||
|
font-size: 0.95em;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user