diff --git a/src/islands/Internet/InternetAddonsModal.jsx b/src/islands/Internet/InternetAddonsModal.jsx index 28bcdcc..4f991f0 100644 --- a/src/islands/Internet/InternetAddonsModal.jsx +++ b/src/islands/Internet/InternetAddonsModal.jsx @@ -1,85 +1,31 @@ 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/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 ( -
- - - {open &&
{children}
} -
- ); -} - export default function InternetAddonsModal({ isOpen, onClose, plan, + phoneCards = [], addons = [], + cenaOpis = "zł / mies.", }) { - const floating = useDraggableFloating("fuz_floating_total_pos_internet_v1"); const phonePlans = useMemo(() => mapPhoneYamlToPlans(phoneCards), [phoneCards]); const addonsList = useMemo(() => normalizeAddons(addons), [addons]); - const [error, setError] = useState(""); - const [selectedPhoneId, setSelectedPhoneId] = useState(null); const [selectedQty, setSelectedQty] = useState({}); @@ -93,19 +39,12 @@ export default function InternetAddonsModal({ const toggleSection = (key) => { setOpenSections((prev) => { const nextOpen = !prev[key]; - return { - internet: false, - phone: false, - addons: false, - summary: false, - [key]: nextOpen, - }; + return { internet: false, phone: false, addons: false, summary: false, [key]: nextOpen }; }); }; useEffect(() => { if (!isOpen) return; - setError(""); setSelectedPhoneId(null); setSelectedQty({}); setOpenSections({ internet: true, phone: false, addons: false, summary: false }); @@ -124,36 +63,13 @@ export default function InternetAddonsModal({ const addonsPrice = useMemo(() => { return addonsList.reduce((sum, a) => { 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); }, [selectedQty, addonsList]); 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() { const phone = selectedPhoneId ? phonePlans.find((p) => String(p.id) === String(selectedPhoneId)) @@ -170,11 +86,8 @@ export default function InternetAddonsModal({ return { createdAt: new Date().toISOString(), pkg: { id: plan?.id ?? null, name: plan?.name ?? "", price: basePrice }, - phone: phone ? { id: phone.id, name: phone.name, price: phone.price_monthly } : null, - addons: addonsChosen, - totals: { base: basePrice, phone: phonePrice, @@ -185,322 +98,62 @@ export default function InternetAddonsModal({ }; } - function moneyWithLabel(v) { - return `${money(v)} ${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 { } - } - + const onSend = () => { + const payload = buildOfferPayload(); + saveOfferToLocalStorage(payload, cenaOpis); + }; return ( -
- + + toggleSection("internet")} + price={basePrice} + cenaOpis={cenaOpis} + features={plan.features || []} + /> -
e.stopPropagation()}> -
-

{plan.name} — konfiguracja usług

+ toggleSection("phone")} + cenaOpis={cenaOpis} + phonePlans={phonePlans} + selectedPhoneId={selectedPhoneId} + setSelectedPhoneId={setSelectedPhoneId} + phonePrice={phonePrice} + /> - {error &&

{error}

} + toggleSection("addons")} + cenaOpis={cenaOpis} + addonsList={addonsList} + selectedQty={selectedQty} + setSelectedQty={setSelectedQty} + addonsPrice={addonsPrice} + getUnitPrice={(a) => Number(a.cena || 0)} + /> - {/* INTERNET */} -
- {money(basePrice)} {cenaOpis}} - open={openSections.internet} - onToggle={() => toggleSection("internet")} - > - {plan.features?.length ? ( -
    - {plan.features.map((f, idx) => ( -
  • - {f.label} - {formatFeatureValue(f.value)} -
  • - ))} -
- ) : ( -

Brak szczegółów.

- )} -
-
+ toggleSection("summary")} + cenaOpis={cenaOpis} + totalMonthly={totalMonthly} + ctaHref="/kontakt#form" + onSend={onSend} + rows={[ + { label: "Pakiet", value: basePrice, showDashIfZero: false }, + { label: "Telefon", value: phonePrice, showDashIfZero: true }, + { label: "Dodatkowe usługi", value: addonsPrice, showDashIfZero: true }, + ]} + /> - {/* TELEFON */} -
- - {phonePrice ? `${money(phonePrice)} ${cenaOpis}` : "—"} - - } - open={openSections.phone} - onToggle={() => toggleSection("phone")} - > - {phonePlans.length === 0 ? ( -

Brak dostępnych pakietów telefonicznych.

- ) : ( -
- - - {phonePlans.map((p) => { - const isSelected = String(selectedPhoneId) === String(p.id); - - return ( -
- - - {/* Szczegóły telefony rozwinięte */} - {p.features?.length > 0 && ( -
-
    - {p.features - .filter( - (f) => !String(f.label || "").toLowerCase().includes("aktyw"), - ) - .map((f, idx) => ( -
  • - {f.label} - {formatFeatureValue(f.value)} -
  • - ))} -
-
- )} -
- ); - })} -
- )} -
-
- - {/* USLUGI DODATKOWE */} -
- - {addonsPrice ? `${money(addonsPrice)} ${cenaOpis}` : "—"} - - } - open={openSections.addons} - onToggle={() => toggleSection("addons")} - > - {addonsList.length === 0 ? ( -

Brak dodatkowych usług.

- ) : ( -
- {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 ( - - ); - } - - // 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 ( -
- - -
-
{a.nazwa}
- {a.opis &&
{a.opis}
} -
- -
e.stopPropagation()}> - - - {qty} - - -
- -
-
- {money(a.cena)} {cenaOpis} -
-
- {qty > 0 ? `${money(lineTotal)} ${cenaOpis}` : "—"} -
-
-
- ); - })} -
- )} -
-
- - {/* PODSUMOWANIE */} -
- {money(totalMonthly)} {cenaOpis}} - open={openSections.summary} - onToggle={() => toggleSection("summary")} - > -
-
-
- Pakiet - {money(basePrice)} {cenaOpis} -
- -
- Telefon - {phonePrice ? `${money(phonePrice)} ${cenaOpis}` : "—"} -
- -
- Dodatkowe usługi - {addonsPrice ? `${money(addonsPrice)} ${cenaOpis}` : "—"} -
- -
- Łącznie - {money(totalMonthly)} {cenaOpis} -
- - saveOfferToLocalStorage()} - > - Wyślij zapytanie z tym wyborem - -
-
-
-
- -
e.stopPropagation()} - > -
- Razem - {money(totalMonthly)} - {cenaOpis} -
-
-
-
-
+ + ); } diff --git a/src/islands/jambox/JamboxAddonsModal.jsx b/src/islands/jambox/JamboxAddonsModal.jsx index 2b225bf..185f82c 100644 --- a/src/islands/jambox/JamboxAddonsModal.jsx +++ b/src/islands/jambox/JamboxAddonsModal.jsx @@ -1,171 +1,21 @@ 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/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 ( -
- - - {open &&
{children}
} -
- ); -} - export default function JamboxAddonsModal({ isOpen, onClose, @@ -179,29 +29,20 @@ export default function JamboxAddonsModal({ cenaOpis = "zł/mies.", }) { const phonePlans = useMemo(() => mapPhoneYamlToPlans(phoneCards), [phoneCards]); - const tvAddonsList = useMemo(() => normalizeAddons(tvAddons), [tvAddons]); const addonsList = useMemo(() => normalizeAddons(addons), [addons]); - const decodersList = useMemo(() => normalizeDecoders(decoders), [decoders]); - const floating = useDraggableFloating("fuz_floating_total_pos_tv_v1"); const tvAddonsVisible = useMemo(() => { if (!pkg) return []; return tvAddonsList.filter((a) => isTvAddonAvailableForPkg(a, pkg)); }, [tvAddonsList, pkg]); - // wybory const [selectedPhoneId, setSelectedPhoneId] = useState(null); - const [openPhoneId, setOpenPhoneId] = useState(null); - const [selectedDecoderId, setSelectedDecoderId] = useState(null); - const [selectedQty, setSelectedQty] = useState({}); - const [tvTerm, setTvTerm] = useState({}); // { [id]: "12m" | "bezterminowo" } - // ✅ sekcje (jedna otwarta naraz) const [openSections, setOpenSections] = useState({ base: true, decoder: false, @@ -226,16 +67,18 @@ export default function JamboxAddonsModal({ }); }; - // reset po otwarciu / zmianie pakietu useEffect(() => { if (!isOpen) return; setSelectedPhoneId(null); - setOpenPhoneId(null); - setSelectedDecoderId(null); setSelectedQty({}); 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({ base: true, decoder: false, @@ -244,12 +87,6 @@ export default function JamboxAddonsModal({ addons: 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]); if (!isOpen || !pkg) return null; @@ -289,150 +126,7 @@ export default function JamboxAddonsModal({ }, 0); }, [selectedQty, addonsList, pkg]); - const addonsPrice = 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 ( - - ); - } - - 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 ( -
- - -
-
{a.nazwa}
- {a.opis &&
{a.opis}
} -
- -
e.stopPropagation()}> - - - {qty} - - -
- -
-
- {money(unit)} {cenaOpis} -
-
{qty > 0 ? `${money(lineTotal)} ${cenaOpis}` : "—"}
-
-
- ); - }; - - // --------- - const LS_KEY = "fuz_offer_config_v1"; + const totalMonthly = basePrice + phonePrice + decoderPrice + tvAddonsPrice + addonsOnlyPrice; function buildOfferPayload() { const phone = selectedPhoneId @@ -491,371 +185,87 @@ export default function JamboxAddonsModal({ }; } - function saveOfferToLocalStorage() { - try { - const payload = buildOfferPayload(); - 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 { } - } - - - // --------- + const onSend = () => { + const payload = buildOfferPayload(); + saveOfferToLocalStorage(payload, cenaOpis); + }; return ( -
- + + toggleSection("base")} + price={basePrice} + cenaOpis={cenaOpis} + features={pkg.features || []} + /> -
e.stopPropagation()}> -
-

{pkg.name} — konfiguracja usług

+ toggleSection("decoder")} + cenaOpis={cenaOpis} + decoders={decodersList} + selectedDecoderId={selectedDecoderId} + setSelectedDecoderId={setSelectedDecoderId} + decoderPrice={decoderPrice} + /> - {/* ✅ PAKIET (sekcja) */} -
- {money(basePrice)} {cenaOpis}} - open={openSections.base} - onToggle={() => toggleSection("base")} - > - {pkg.features?.length ? ( -
    - {pkg.features.map((f, idx) => ( -
  • - {f.label} - {formatFeatureValue(f.value)} -
  • - ))} -
- ) : ( -

Brak szczegółów pakietu.

- )} -
-
+ toggleSection("tv")} + cenaOpis={cenaOpis} + pkg={pkg} + tvAddonsVisible={tvAddonsVisible} + selectedQty={selectedQty} + setSelectedQty={setSelectedQty} + tvTerm={tvTerm} + setTvTerm={setTvTerm} + tvAddonsPrice={tvAddonsPrice} + /> - {/* DEKODER */} -
- - {decoderPrice ? `${money(decoderPrice)} ${cenaOpis}` : "—"} - - } - open={openSections.decoder} - onToggle={() => toggleSection("decoder")} - > + toggleSection("phone")} + cenaOpis={cenaOpis} + phonePlans={phonePlans} + selectedPhoneId={selectedPhoneId} + setSelectedPhoneId={setSelectedPhoneId} + phonePrice={phonePrice} + /> - {decodersList.length === 0 ? ( -

Brak dostępnych dekoderów.

- ) : ( -
- {decodersList.map((d) => { - const isSelected = String(selectedDecoderId) === String(d.id); + toggleSection("addons")} + cenaOpis={cenaOpis} + addonsList={addonsList} + selectedQty={selectedQty} + setSelectedQty={setSelectedQty} + addonsPrice={addonsOnlyPrice} + getUnitPrice={(a) => getAddonUnitPrice(a, pkg, null)} + /> - return ( - - ); - })} -
- )} - - - -
-
- - {/* TV ADDONS */} -
- - {tvAddonsPrice ? `${money(tvAddonsPrice)} ${cenaOpis}` : "—"} - - } - open={openSections.tv} - onToggle={() => toggleSection("tv")} - > - {tvAddonsVisible.length === 0 ? ( -

Brak pakietów dodatkowych TV.

- ) : ( -
{tvAddonsVisible.map((a) => renderAddonRow(a, true))}
- )} -
-
- - {/* TELEFON */} -
- - {phonePrice ? `${money(phonePrice)} ${cenaOpis}` : "—"} - - } - open={openSections.phone} - onToggle={() => toggleSection("phone")} - > - {phonePlans.length === 0 ? ( -

Brak dostępnych pakietów telefonicznych.

- ) : ( -
- {/* brak telefonu */} - - - {/* pakiety */} - {phonePlans.map((p) => { - const isSelected = String(selectedPhoneId) === String(p.id); - - return ( -
- - - {/* ✅ detale ZAWSZE widoczne */} - {p.features?.length > 0 && ( -
-
    - {p.features - .filter( - (f) => - !String(f.label || "").toLowerCase().includes("aktyw"), - ) - .map((f, idx) => ( -
  • - {f.label} - {formatFeatureValue(f.value)} -
  • - ))} -
-
- )} -
- ); - })} -
- - )} - -
-
- - {/* DODATKI */} -
- - {addonsOnlyPrice ? `${money(addonsOnlyPrice)} ${cenaOpis}` : "—"} - - } - open={openSections.addons} - onToggle={() => toggleSection("addons")} - > - {addonsList.length === 0 ? ( -

Brak usług dodatkowych.

- ) : ( -
{addonsList.map((a) => renderAddonRow(a, false))}
- )} -
-
- - {/* PODSUMOWANIE */} -
- {money(totalMonthly)} {cenaOpis}} - open={openSections.summary} - onToggle={() => toggleSection("summary")} - > -
-
-
- Pakiet - {money(basePrice)} {cenaOpis} -
- -
- Telefon - {phonePrice ? `${money(phonePrice)} ${cenaOpis}` : "—"} -
- -
- Dekoder - {decoderPrice ? `${money(decoderPrice)} ${cenaOpis}` : "—"} -
- -
- Dodatki TV - {tvAddonsPrice ? `${money(tvAddonsPrice)} ${cenaOpis}` : "—"} -
- -
- Dodatkowe usługi - {addonsOnlyPrice ? `${money(addonsOnlyPrice)} ${cenaOpis}` : "—"} -
- -
- Łącznie - {money(totalMonthly)} {cenaOpis} - -
- - saveOfferToLocalStorage()} - > - Wyślij zapytanie z tym wyborem - - -
-
-
-
- -
e.stopPropagation()} - > -
- - Razem - - - {money(totalMonthly)} - - - {cenaOpis} - -
-
- -
-
-
+ + ); } diff --git a/src/islands/modals/OfferModalShell.jsx b/src/islands/modals/OfferModalShell.jsx new file mode 100644 index 0000000..c81330f --- /dev/null +++ b/src/islands/modals/OfferModalShell.jsx @@ -0,0 +1,26 @@ +export default function OfferModalShell({ isOpen, onClose, title, children }) { + if (!isOpen) return null; + + return ( +
+ + +
e.stopPropagation()}> +
+

{title}

+ {children} +
+
+
+ ); +} diff --git a/src/islands/modals/sections/AddonsSection.jsx b/src/islands/modals/sections/AddonsSection.jsx new file mode 100644 index 0000000..7b6d16f --- /dev/null +++ b/src/islands/modals/sections/AddonsSection.jsx @@ -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 ( + + ); + } + + + 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 ( +
+ + +
+
{a.nazwa}
+ {a.opis &&
{a.opis}
} +
+ +
e.stopPropagation()}> + + + {qty} + + +
+ +
+
{money(unit)} {cenaOpis}
+
{qty > 0 ? `${money(lineTotal)} ${cenaOpis}` : "—"}
+
+
+ ); + }; + + return ( +
+ {addonsPrice ? `${money(addonsPrice)} ${cenaOpis}` : "—"}} + open={open} + onToggle={onToggle} + > + {addonsList.length === 0 ? ( +

Brak usług dodatkowych.

+ ) : ( +
{addonsList.map(renderRow)}
+ )} +
+
+ ); +} diff --git a/src/islands/modals/sections/DecoderSection.jsx b/src/islands/modals/sections/DecoderSection.jsx new file mode 100644 index 0000000..8fb9d60 --- /dev/null +++ b/src/islands/modals/sections/DecoderSection.jsx @@ -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 ( +
+ {decoderPrice ? `${money(decoderPrice)} ${cenaOpis}` : "—"}} + open={open} + onToggle={onToggle} + > + {decoders.length === 0 ? ( +

Brak dostępnych dekoderów.

+ ) : ( +
+ {decoders.map((d) => { + const isSelected = String(selectedDecoderId) === String(d.id); + + return ( + + + ); + })} +
+ )} +
+
+ ); +} diff --git a/src/islands/modals/sections/FloatingTotal.jsx b/src/islands/modals/sections/FloatingTotal.jsx new file mode 100644 index 0000000..fcedde9 --- /dev/null +++ b/src/islands/modals/sections/FloatingTotal.jsx @@ -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 ( +
e.stopPropagation()} + > +
+ Razem + {money(totalMonthly)} + {cenaOpis} +
+
+ ); +} diff --git a/src/islands/modals/sections/PhoneSection.jsx b/src/islands/modals/sections/PhoneSection.jsx new file mode 100644 index 0000000..077ceee --- /dev/null +++ b/src/islands/modals/sections/PhoneSection.jsx @@ -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 ( +
+ {phonePrice ? `${money(phonePrice)} ${cenaOpis}` : "—"}} + open={open} + onToggle={onToggle} + > + {phonePlans.length === 0 ? ( +

Brak dostępnych pakietów telefonicznych.

+ ) : ( +
+ + + {phonePlans.map((p) => { + const isSelected = String(selectedPhoneId) === String(p.id); + + return ( +
+ + + {p.features?.length > 0 && ( +
+
    + {p.features + .filter((f) => !String(f.label || "").toLowerCase().includes("aktyw")) + .map((f, idx) => ( +
  • + {f.label} + {formatFeatureValue(f.value)} +
  • + ))} +
+
+ )} +
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/src/islands/modals/sections/PlanSection.jsx b/src/islands/modals/sections/PlanSection.jsx new file mode 100644 index 0000000..5280a6b --- /dev/null +++ b/src/islands/modals/sections/PlanSection.jsx @@ -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 ( +
+ {money(price)} {cenaOpis}} + open={open} + onToggle={onToggle} + > + {features?.length ? ( +
    + {features.map((f, idx) => ( +
  • + {f.label} + {formatFeatureValue(f.value)} +
  • + ))} +
+ ) : ( +

Brak szczegółów.

+ )} +
+
+ ); +} diff --git a/src/islands/modals/sections/SectionAccordion.jsx b/src/islands/modals/sections/SectionAccordion.jsx new file mode 100644 index 0000000..a19d3ea --- /dev/null +++ b/src/islands/modals/sections/SectionAccordion.jsx @@ -0,0 +1,19 @@ +export default function SectionAccordion({ title, right, open, onToggle, children }) { + return ( +
+ + + {open &&
{children}
} +
+ ); +} diff --git a/src/islands/modals/sections/SummarySection.jsx b/src/islands/modals/sections/SummarySection.jsx new file mode 100644 index 0000000..cf5a589 --- /dev/null +++ b/src/islands/modals/sections/SummarySection.jsx @@ -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 ( +
+ {money(totalMonthly)} {cenaOpis}} + open={open} + onToggle={onToggle} + > +
+
+ {rows.map((r) => { + const v = Number(r.value ?? 0); + const showDash = r.showDashIfZero !== false; // domyślnie true + return ( +
+ {r.label} + {(v > 0 || !showDash) ? `${money(v)} ${cenaOpis}` : "—"} +
+ ); + })} + +
+ Łącznie + {money(totalMonthly)} {cenaOpis} +
+ + onSend?.()} + > + {ctaLabel} + +
+
+
+
+ ); +} diff --git a/src/islands/modals/sections/TvAddonsSection.jsx b/src/islands/modals/sections/TvAddonsSection.jsx new file mode 100644 index 0000000..08269bf --- /dev/null +++ b/src/islands/modals/sections/TvAddonsSection.jsx @@ -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 ( + + + ); + } + + 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 ( +
+ + +
+
{a.nazwa}
+ {a.opis &&
{a.opis}
} +
+ +
e.stopPropagation()}> + + + {qty} + + +
+ +
+
{money(unit)} {cenaOpis}
+
{qty > 0 ? `${money(lineTotal)} ${cenaOpis}` : "—"}
+
+
+ ); + }; + + return ( +
+ {tvAddonsPrice ? `${money(tvAddonsPrice)} ${cenaOpis}` : "—"}} + open={open} + onToggle={onToggle} + > + {tvAddonsVisible.length === 0 ? ( +

Brak pakietów dodatkowych TV.

+ ) : ( +
{tvAddonsVisible.map(renderRow)}
+ )} +
+
+ ); +} diff --git a/src/lib/money.js b/src/lib/money.js new file mode 100644 index 0000000..0f37fbf --- /dev/null +++ b/src/lib/money.js @@ -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}`; +} diff --git a/src/lib/offer-normalize.js b/src/lib/offer-normalize.js new file mode 100644 index 0000000..8257d84 --- /dev/null +++ b/src/lib/offer-normalize.js @@ -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) : "", + })); +} diff --git a/src/lib/offer-payload.js b/src/lib/offer-payload.js new file mode 100644 index 0000000..b6ba45e --- /dev/null +++ b/src/lib/offer-payload.js @@ -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 {} +} diff --git a/src/lib/offer-pricing.js b/src/lib/offer-pricing.js new file mode 100644 index 0000000..d1fa1f5 --- /dev/null +++ b/src/lib/offer-pricing.js @@ -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; +} diff --git a/src/styles/addons.css b/src/styles/addons.css index 19df908..77ab1c5 100644 --- a/src/styles/addons.css +++ b/src/styles/addons.css @@ -1,466 +1,352 @@ - .f-section-acc .f-accordion-header { - @apply flex items-center justify-between gap-3; - } +.f-section-acc .f-accordion-header { + @apply flex items-center justify-between gap-3; +} - .f-accordion-header-right { - @apply flex items-center gap-3; - } +.f-accordion-header-right { + @apply flex items-center gap-3; +} - .f-acc-chevron { - @apply opacity-60 text-sm; - } +.f-acc-chevron { + @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 { - @apply fixed bottom-5 right-5 z-[10000]; - @apply pointer-events-auto; - @apply select-none; - touch-action: none; - } +.f-radio-item:last-child { + border-bottom: none; +} - /* kółko */ - .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 shadow-xl; - @apply bg-[--f-addons-background]; - @apply backdrop-blur-md; - } +.f-radio-check input { + @apply mt-1; +} - /* kwota */ - .f-floating-total-amount { - @apply text-lg md:text-xl font-bold leading-none text-[--f-addons-text]; - } +.f-radio-name { + @apply font-medium; +} - /* jednostka */ - .f-floating-total-unit { - @apply my-1 text-xs md:text-sm opacity-70 text-[--f-addons-text]; - } +.f-radio-price { + @apply whitespace-nowrap font-semibold; +} +.f-radio-details { + @apply pl-10 pr-3 pb-3 -mt-1 text-sm; +} - @keyframes fuz-bounce { - 0%, 100% { transform: translateY(0); } - 50% { transform: translateY(-6px); } - } +.f-addon-list { + @apply flex flex-col gap-2; +} - .f-floating-total:hover .f-floating-total-circle { - animation: fuz-bounce 420ms ease-in-out; - } +.f-addon-item { + @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 { - transform: translateY(-4px) scale(1.02); - } +.f-addon-item:last-child { + border-bottom: none; +} -/* 3D */ -.f-floating-total-circle { - @apply relative overflow-hidden; - @apply shadow-xl; - @apply bg-[--f-addons-background]; - @apply backdrop-blur-md; +.f-addon-checkbox { + @apply flex items-center justify-center; + align-items: center; + margin-top: 0.1rem; +} - /* 3D feel */ - 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); +.f-addon-checkbox input[type="checkbox"] { + width: 1.05rem; + height: 1.05rem; + transform: scale(1.05); + accent-color: var(--fuz-accent, #2563eb); + cursor: pointer; +} - /* subtelna “kopuła” */ - 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-addon-main { + @apply flex flex-col gap-0.5; + min-width: 0; +} - /* połysk */ - .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-addon-name { + @apply font-medium; +} - /* “rim”/krawędź */ - .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-addon-desc { + @apply text-sm opacity-85; +} - /* lekka reakcja 3D na hover */ - @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-more { + @apply text-sm underline opacity-80; +} +.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ć ------ */ - /* =========================== - TELEFON — AKORDEON - =========================== */ +.f-addon-item--qty .f-addon-qty { + justify-self: end; + display: inline-flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.05rem; +} - .f-modal-phone-list.f-accordion { - @apply flex flex-col gap-2; - } +.f-addon-qty-value { + min-width: 2ch; + text-align: center; +} - .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); - } - - /* 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 */ +@media (max-width: 640px) { .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; - border-bottom: 1px solid rgba(148, 163, 184, 0.4); - background: var(--f-background); + grid-template-areas: + "slot main price" + "slot qty price"; } - .f-radio-item:last-child { - border-bottom: none; + .f-addon-item--qty .f-addon-checkbox { + grid-area: slot; } - .f-radio-check input { - @apply mt-1; + .f-addon-item--qty .f-addon-main { + grid-area: main; } - .f-radio-name { - @apply font-medium; + .f-addon-item--qty .f-addon-qty { + grid-area: qty; + justify-self: start; } - .f-radio-price { - @apply whitespace-nowrap font-semibold; + .f-addon-item--qty .f-addon-price { + 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 { - /* delikatne wyróżnienie wybranego */ - /* @apply rounded-xl; */ - /* background: rgba(148, 163, 184, 0.12); */ + 50% { + transform: translateY(-6px); + } +} + +.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 { - @apply pl-10 pr-3 pb-3 -mt-1 text-sm; - /* pl-10 = przesunięcie w prawo (radio + gap) */ + /* opcjonalnie: minimalnie mniejsza czcionka w cenie w QTY */ + .f-addon-item--qty .f-addon-price, + .f-addon-item--qty .f-addon-price-total { + font-size: 0.95em; } - - - \ No newline at end of file +} \ No newline at end of file