Porządkowanie styli Card, addons

This commit is contained in:
dm
2025-12-16 13:15:07 +01:00
parent 91b0518a63
commit ff05d0dee9
15 changed files with 1045 additions and 812 deletions

View File

@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "preact/hooks";
import useDraggableFloating from "../hooks/useDraggableFloating.js";
import "../../styles/modal.css";
import "../../styles/addons.css";
@@ -73,6 +74,7 @@ export default function InternetAddonsModal({
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]);
@@ -484,7 +486,13 @@ export default function InternetAddonsModal({
</SectionAccordion>
</div>
<div class="f-floating-total" onClick={(e) => e.stopPropagation()}>
<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>

View File

@@ -2,7 +2,7 @@ import { useEffect, useState } from "preact/hooks";
import Markdown from "../Markdown.jsx";
import OffersSwitches from "../Switches.jsx";
import InternetAddonsModal from "./InternetAddonsModal.jsx";
import "../../styles/addons.css";
import "../../styles/cards.css";
function formatMoney(amount, currency = "PLN") {
if (typeof amount !== "number" || Number.isNaN(amount)) return "";

View File

@@ -0,0 +1,140 @@
import { useEffect, useRef, useState } from "preact/hooks";
function clamp(v, min, max) {
return Math.max(min, Math.min(max, v));
}
export default function useDraggableFloating(storageKey, opts = {}) {
const margin = Number.isFinite(opts.margin) ? opts.margin : 12;
const ref = useRef(null);
const [pos, setPos] = useState(() => {
if (typeof window === "undefined") return { x: null, y: null };
try {
const raw = localStorage.getItem(storageKey);
return raw ? JSON.parse(raw) : { x: null, y: null };
} catch {
return { x: null, y: null };
}
});
const stateRef = useRef({
dragging: false,
pointerId: null,
startX: 0,
startY: 0,
originX: 0,
originY: 0,
});
function getBounds(el) {
const rect = el.getBoundingClientRect();
const maxX = window.innerWidth - rect.width - margin;
const maxY = window.innerHeight - rect.height - margin;
return { maxX, maxY };
}
function persist(next) {
try {
localStorage.setItem(storageKey, JSON.stringify(next));
} catch {}
}
function onPointerDown(e) {
if (e.pointerType === "mouse" && e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
const el = ref.current;
if (!el) return;
el.setPointerCapture?.(e.pointerId);
const st = stateRef.current;
st.dragging = true;
st.pointerId = e.pointerId;
st.startX = e.clientX;
st.startY = e.clientY;
const rect = el.getBoundingClientRect();
st.originX = pos.x == null ? rect.left : pos.x;
st.originY = pos.y == null ? rect.top : pos.y;
}
function onPointerMove(e) {
const st = stateRef.current;
if (!st.dragging || st.pointerId !== e.pointerId) return;
const el = ref.current;
if (!el) return;
const dx = e.clientX - st.startX;
const dy = e.clientY - st.startY;
const { maxX, maxY } = getBounds(el);
const next = {
x: clamp(st.originX + dx, margin, maxX),
y: clamp(st.originY + dy, margin, maxY),
};
setPos(next);
}
function onPointerUp(e) {
const st = stateRef.current;
if (!st.dragging || st.pointerId !== e.pointerId) return;
st.dragging = false;
st.pointerId = null;
// zapis po puszczeniu
persist(pos);
}
function reset() {
const next = { x: null, y: null };
setPos(next);
try {
localStorage.removeItem(storageKey);
} catch {}
}
// docinanie na resize
useEffect(() => {
function onResize() {
const el = ref.current;
if (!el || pos.x == null || pos.y == null) return;
const { maxX, maxY } = getBounds(el);
const next = {
x: clamp(pos.x, margin, maxX),
y: clamp(pos.y, margin, maxY),
};
if (next.x !== pos.x || next.y !== pos.y) {
setPos(next);
persist(next);
}
}
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, [pos.x, pos.y]);
const style =
pos.x == null || pos.y == null
? undefined
: `left:${pos.x}px; top:${pos.y}px; right:auto; bottom:auto;`;
const handlers = {
onPointerDown,
onPointerMove,
onPointerUp,
onPointerCancel: onPointerUp,
};
return { ref, style, handlers, pos, setPos, reset };
}

View File

@@ -1,4 +1,6 @@
import { useEffect, useMemo, useState } from "preact/hooks";
import useDraggableFloating from "../hooks/useDraggableFloating.js";
import "../../styles/modal.css";
import "../../styles/addons.css";
@@ -181,6 +183,7 @@ export default function JamboxAddonsModal({
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 [];
@@ -415,130 +418,130 @@ export default function JamboxAddonsModal({
// ---------
const LS_KEY = "fuz_offer_config_v1";
function buildOfferPayload() {
const phone = selectedPhoneId
? phonePlans.find((p) => String(p.id) === String(selectedPhoneId))
: null;
function buildOfferPayload() {
const phone = selectedPhoneId
? phonePlans.find((p) => String(p.id) === String(selectedPhoneId))
: null;
const decoder = selectedDecoderId
? decodersList.find((d) => String(d.id) === String(selectedDecoderId))
: null;
const decoder = selectedDecoderId
? decodersList.find((d) => String(d.id) === String(selectedDecoderId))
: null;
const tvChosen = tvAddonsVisible
.map((a) => {
const qty = Number(selectedQty[a.id] || 0);
if (qty <= 0) return null;
const tvChosen = tvAddonsVisible
.map((a) => {
const qty = Number(selectedQty[a.id] || 0);
if (qty <= 0) return null;
const termPricing = hasTvTermPricing(a, pkg);
const term = tvTerm[a.id] || "12m";
const unit = getAddonUnitPrice(a, pkg, termPricing ? term : null);
const termPricing = hasTvTermPricing(a, pkg);
const term = tvTerm[a.id] || "12m";
const unit = getAddonUnitPrice(a, pkg, termPricing ? term : null);
return {
id: a.id,
nazwa: a.nazwa,
qty,
term: termPricing ? term : null,
unit,
};
})
.filter(Boolean);
return {
id: a.id,
nazwa: a.nazwa,
qty,
term: termPricing ? term : null,
unit,
};
})
.filter(Boolean);
const addonsChosen = addonsList
.map((a) => {
const qty = Number(selectedQty[a.id] || 0);
if (qty <= 0) return null;
const addonsChosen = addonsList
.map((a) => {
const qty = Number(selectedQty[a.id] || 0);
if (qty <= 0) return null;
const unit = getAddonUnitPrice(a, pkg, null);
return { id: a.id, nazwa: a.nazwa, qty, unit };
})
.filter(Boolean);
const unit = getAddonUnitPrice(a, pkg, null);
return { id: a.id, nazwa: a.nazwa, qty, unit };
})
.filter(Boolean);
return {
createdAt: new Date().toISOString(),
pkg: { id: pkg?.id ?? null, name: pkg?.name ?? "", price: basePrice },
phone: phone ? { id: phone.id, name: phone.name, price: phone.price_monthly } : null,
decoder: decoder ? { id: decoder.id, name: decoder.nazwa, price: decoder.cena } : null,
tvAddons: tvChosen,
addons: addonsChosen,
totals: {
base: basePrice,
phone: phonePrice,
decoder: decoderPrice,
tv: tvAddonsPrice,
addons: addonsOnlyPrice,
total: totalMonthly,
currencyLabel: cenaOpis,
},
};
}
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)})`);
return {
createdAt: new Date().toISOString(),
pkg: { id: pkg?.id ?? null, name: pkg?.name ?? "", price: basePrice },
phone: phone ? { id: phone.id, name: phone.name, price: phone.price_monthly } : null,
decoder: decoder ? { id: decoder.id, name: decoder.nazwa, price: decoder.cena } : null,
tvAddons: tvChosen,
addons: addonsChosen,
totals: {
base: basePrice,
phone: phonePrice,
decoder: decoderPrice,
tv: tvAddonsPrice,
addons: addonsOnlyPrice,
total: totalMonthly,
currencyLabel: cenaOpis,
},
};
}
if (payload?.decoder) {
lines.push("");
lines.push(`Dekoder: ${payload.decoder.name} (${moneyWithLabel(payload.decoder.price)})`);
function saveOfferToLocalStorage() {
try {
const payload = buildOfferPayload();
localStorage.setItem(LS_KEY, JSON.stringify(payload));
} catch { }
}
if (Array.isArray(payload?.tvAddons) && payload.tvAddons.length) {
//-- dopisane
function moneyWithLabel(v) {
return `${money(v)} ${cenaOpis}`;
}
function buildOfferMessage(payload) {
const lines = [];
// nagłówek
lines.push(`Wybrana oferta: ${payload?.pkg?.name || "—"}`);
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)}`
);
// ✅ 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 (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)}`);
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");
}
return lines.join("\n");
}
function saveOfferToLocalStorage() {
try {
const payload = buildOfferPayload();
payload.message = buildOfferMessage(payload); // ✅ gotowy tekst
localStorage.setItem(LS_KEY, JSON.stringify(payload));
} catch {}
}
function saveOfferToLocalStorage() {
try {
const payload = buildOfferPayload();
payload.message = buildOfferMessage(payload); // ✅ gotowy tekst
localStorage.setItem(LS_KEY, JSON.stringify(payload));
} catch { }
}
// ---------
@@ -596,38 +599,38 @@ function saveOfferToLocalStorage() {
open={openSections.decoder}
onToggle={() => toggleSection("decoder")}
>
{decodersList.length === 0 ? (
<p>Brak dostępnych dekoderów.</p>
) : (
<div class="f-radio-list">
{decodersList.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>
{decodersList.length === 0 ? (
<p>Brak dostępnych dekoderów.</p>
) : (
<div class="f-radio-list">
{decodersList.map((d) => {
const isSelected = String(selectedDecoderId) === String(d.id);
<div class="f-radio-main">
<div class="f-radio-name">{d.nazwa}</div>
{d.opis && <div class="f-addon-desc">{d.opis}</div>}
</div>
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-price">
{money(d.cena)} {cenaOpis}
</div>
</label>
);
})}
</div>
)}
<div class="f-radio-main">
<div class="f-radio-name">{d.nazwa}</div>
{d.opis && <div class="f-addon-desc">{d.opis}</div>}
</div>
<div class="f-radio-price">
{money(d.cena)} {cenaOpis}
</div>
</label>
);
})}
</div>
)}
@@ -666,77 +669,77 @@ function saveOfferToLocalStorage() {
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>
{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-main">
<div class="f-radio-name">Nie potrzebuję telefonu</div>
</div>
<div class="f-radio-price">0,00 {cenaOpis}</div>
</label>
<div class="f-radio-price">0,00 {cenaOpis}</div>
</label>
{/* pakiety */}
{phonePlans.map((p) => {
const isSelected = String(selectedPhoneId) === String(p.id);
{/* 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>
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-main">
<div class="f-radio-name">{p.name}</div>
</div>
<div class="f-radio-price">
{money(p.price_monthly)} {cenaOpis}
</div>
</label>
<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>
{/* ✅ 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>
@@ -799,16 +802,16 @@ function saveOfferToLocalStorage() {
<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>
href="/kontakt"
class="btn btn-primary w-full mt-4"
onClick={() => saveOfferToLocalStorage()}
>
Wyślij zapytanie z tym wyborem
</a>
</div>
</div>
@@ -824,19 +827,25 @@ function saveOfferToLocalStorage() {
</span>
</div>
</div> */}
<div class="f-floating-total" 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
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>

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from "preact/hooks";
import "../../styles/addons.css";
import "../../styles/cards.css";
import OffersSwitches from "../Switches.jsx";
import JamboxChannelsModal from "./JamboxChannelsModal.jsx";

View File

@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from "preact/hooks";
import "../../styles/modal.css";
import "../../styles/addons.css";
import "../../styles/channels-search.css";
import "../../styles/jambox-modal-channel.css";
import "../../styles/jambox-search.css";
export default function JamboxChannelsModal({ isOpen, onClose, pkg }) {
const [channels, setChannels] = useState([]);

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import "../../styles/channels-search.css";
import "../../styles/jambox-search.css";
export default function JamboxChannelsSearch() {
const [q, setQ] = useState("");

View File

@@ -1,6 +1,6 @@
import { useMemo, useState } from "preact/hooks";
import { marked } from "marked";
import "../../styles/channels-search.css";
import "../../styles/jambox-search.css";
function norm(s) {
return String(s || "")

View File

@@ -1,5 +1,5 @@
import Markdown from "../../islands/Markdown.jsx";
import "../../styles/addons.css";
import "../../styles/cards.css";
/**
* @typedef {{ klucz: string, label: string, value: (string|number) }} PhoneParam