Razem
{money(totalMonthly)}
diff --git a/src/islands/Internet/InternetCards.jsx b/src/islands/Internet/InternetCards.jsx
index 51e753d..1477a73 100644
--- a/src/islands/Internet/InternetCards.jsx
+++ b/src/islands/Internet/InternetCards.jsx
@@ -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 "";
diff --git a/src/islands/hooks/useDraggableFloating.js b/src/islands/hooks/useDraggableFloating.js
new file mode 100644
index 0000000..26bb00d
--- /dev/null
+++ b/src/islands/hooks/useDraggableFloating.js
@@ -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 };
+}
diff --git a/src/islands/jambox/JamboxAddonsModal.jsx b/src/islands/jambox/JamboxAddonsModal.jsx
index 2581108..7e3d7bb 100644
--- a/src/islands/jambox/JamboxAddonsModal.jsx
+++ b/src/islands/jambox/JamboxAddonsModal.jsx
@@ -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 ? (
-
Brak dostępnych dekoderów.
-) : (
-
- {decodersList.map((d) => {
- const isSelected = String(selectedDecoderId) === String(d.id);
- return (
-
+ );
+ })}
+
+ )}
@@ -666,77 +669,77 @@ function saveOfferToLocalStorage() {
open={openSections.phone}
onToggle={() => toggleSection("phone")}
>
- {phonePlans.length === 0 ? (
-
Brak dostępnych pakietów telefonicznych.
-) : (
-
- {/* brak telefonu */}
-
-
- handlePhoneSelect(null)}
- />
-
+ {phonePlans.length === 0 ? (
+ Brak dostępnych pakietów telefonicznych.
+ ) : (
+
+ {/* brak telefonu */}
+
+
+ handlePhoneSelect(null)}
+ />
+
-
-
Nie potrzebuję telefonu
-
+
+
Nie potrzebuję telefonu
+
- 0,00 {cenaOpis}
-
+
0,00 {cenaOpis}
+
- {/* pakiety */}
- {phonePlans.map((p) => {
- const isSelected = String(selectedPhoneId) === String(p.id);
+ {/* pakiety */}
+ {phonePlans.map((p) => {
+ const isSelected = String(selectedPhoneId) === String(p.id);
- return (
-
-
-
- handlePhoneSelect(p.id)}
- />
-
+ return (
+
+
+
+ handlePhoneSelect(p.id)}
+ />
+
-
+
-
- {money(p.price_monthly)} {cenaOpis}
-
-
+
+ {money(p.price_monthly)} {cenaOpis}
+
+
- {/* ✅ 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)}
-
- ))}
-
-
- )}
-
- );
- })}
-
+ {/* ✅ 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)}
+
+ ))}
+
+
+ )}
+
+ );
+ })}
+
-)}
+ )}
+ href="/kontakt"
+ class="btn btn-primary w-full mt-4"
+ onClick={() => saveOfferToLocalStorage()}
+ >
+ Wyślij zapytanie z tym wyborem
+