Refaktoryzacja Card, Modali
This commit is contained in:
@@ -9,6 +9,11 @@ import FloatingTotal from "../modals/sections/FloatingTotal.jsx";
|
||||
|
||||
import { mapPhoneYamlToPlans, normalizeAddons } from "../../lib/offer-normalize.js";
|
||||
import { saveOfferToLocalStorage } from "../../lib/offer-payload.js";
|
||||
import { getAddonUnitPrice } from "../../lib/offer-pricing.js";
|
||||
|
||||
// ✅ NOWE: Importy hooków
|
||||
import { usePricing } from "../../hooks/usePricing.js";
|
||||
import { useAccordion } from "../../hooks/useAccordion.js";
|
||||
|
||||
import "../../styles/modal.css";
|
||||
import "../../styles/addons.css";
|
||||
@@ -21,137 +26,134 @@ export default function InternetAddonsModal({
|
||||
addons = [],
|
||||
cenaOpis = "zł / mies.",
|
||||
}) {
|
||||
// Normalizacja danych z YAML
|
||||
const phonePlans = useMemo(() => mapPhoneYamlToPlans(phoneCards), [phoneCards]);
|
||||
const addonsList = useMemo(() => normalizeAddons(addons), [addons]);
|
||||
|
||||
// Stan wyboru użytkownika
|
||||
const [selectedPhoneId, setSelectedPhoneId] = useState(null);
|
||||
const [selectedQty, setSelectedQty] = useState({});
|
||||
|
||||
const [openSections, setOpenSections] = useState({
|
||||
internet: true,
|
||||
phone: false,
|
||||
addons: false,
|
||||
summary: false,
|
||||
// ✅ NOWY: Hook accordion
|
||||
const accordion = useAccordion(
|
||||
['internet', 'phone', 'addons', 'summary'],
|
||||
'internet', // domyślnie otwarta
|
||||
false // tylko jedna sekcja na raz
|
||||
);
|
||||
|
||||
// Znajdź wybrany telefon (potrzebne dla usePricing)
|
||||
const selectedPhone = useMemo(() => {
|
||||
if (!selectedPhoneId) return null;
|
||||
return phonePlans.find(p => String(p.id) === String(selectedPhoneId));
|
||||
}, [selectedPhoneId, phonePlans]);
|
||||
|
||||
// ✅ NOWY: Hook usePricing
|
||||
const pricing = usePricing({
|
||||
pkg: plan,
|
||||
cenaOpis,
|
||||
phone: selectedPhone,
|
||||
addonsList,
|
||||
selectedQty
|
||||
});
|
||||
|
||||
const toggleSection = (key) => {
|
||||
setOpenSections((prev) => {
|
||||
const nextOpen = !prev[key];
|
||||
return { internet: false, phone: false, addons: false, summary: false, [key]: nextOpen };
|
||||
});
|
||||
};
|
||||
|
||||
// Reset przy otwarciu
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setSelectedPhoneId(null);
|
||||
setSelectedQty({});
|
||||
setOpenSections({ internet: true, phone: false, addons: false, summary: false });
|
||||
accordion.openSection('internet');
|
||||
}, [isOpen, plan?.id]);
|
||||
|
||||
if (!isOpen || !plan) return null;
|
||||
|
||||
const basePrice = Number(plan.price_monthly || 0);
|
||||
|
||||
const phonePrice = useMemo(() => {
|
||||
if (!selectedPhoneId) return 0;
|
||||
const p = phonePlans.find((x) => String(x.id) === String(selectedPhoneId));
|
||||
return Number(p?.price_monthly || 0);
|
||||
}, [selectedPhoneId, phonePlans]);
|
||||
|
||||
const addonsPrice = useMemo(() => {
|
||||
return addonsList.reduce((sum, a) => {
|
||||
const qty = Number(selectedQty[a.id] || 0);
|
||||
const unit = Number(a.cena || 0);
|
||||
return sum + qty * unit;
|
||||
}, 0);
|
||||
}, [selectedQty, addonsList]);
|
||||
|
||||
const totalMonthly = basePrice + phonePrice + addonsPrice;
|
||||
|
||||
function buildOfferPayload() {
|
||||
const phone = selectedPhoneId
|
||||
? phonePlans.find((p) => String(p.id) === String(selectedPhoneId))
|
||||
: null;
|
||||
|
||||
const addonsChosen = addonsList
|
||||
.map((a) => {
|
||||
const qty = Number(selectedQty[a.id] || 0);
|
||||
if (qty <= 0) return null;
|
||||
return { id: a.id, nazwa: a.nazwa, qty, unit: Number(a.cena || 0) };
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
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,
|
||||
addons: addonsPrice,
|
||||
total: totalMonthly,
|
||||
currencyLabel: cenaOpis,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ✅ UPROSZCZONE: Payload z calculatora
|
||||
const onSend = () => {
|
||||
const payload = buildOfferPayload();
|
||||
saveOfferToLocalStorage(payload, cenaOpis);
|
||||
saveOfferToLocalStorage(pricing.payload, cenaOpis);
|
||||
};
|
||||
|
||||
return (
|
||||
<OfferModalShell isOpen={isOpen} onClose={onClose} title={`${plan.name} — konfiguracja usług`}>
|
||||
<OfferModalShell isOpen={isOpen} onClose={onClose} title={`${plan.name} – konfiguracja usług`}>
|
||||
|
||||
<PlanSection
|
||||
title={plan.name}
|
||||
open={openSections.internet}
|
||||
onToggle={() => toggleSection("internet")}
|
||||
price={basePrice}
|
||||
open={accordion.isOpen('internet')}
|
||||
onToggle={() => accordion.toggle('internet')}
|
||||
price={pricing.basePrice}
|
||||
cenaOpis={cenaOpis}
|
||||
features={plan.features || []}
|
||||
/>
|
||||
|
||||
<PhoneSection
|
||||
open={openSections.phone}
|
||||
onToggle={() => toggleSection("phone")}
|
||||
open={accordion.isOpen('phone')}
|
||||
onToggle={() => accordion.toggle('phone')}
|
||||
cenaOpis={cenaOpis}
|
||||
phonePlans={phonePlans}
|
||||
selectedPhoneId={selectedPhoneId}
|
||||
setSelectedPhoneId={setSelectedPhoneId}
|
||||
phonePrice={phonePrice}
|
||||
phonePrice={pricing.phonePrice}
|
||||
/>
|
||||
|
||||
<AddonsSection
|
||||
open={openSections.addons}
|
||||
onToggle={() => toggleSection("addons")}
|
||||
open={accordion.isOpen('addons')}
|
||||
onToggle={() => accordion.toggle('addons')}
|
||||
cenaOpis={cenaOpis}
|
||||
addonsList={addonsList}
|
||||
selectedQty={selectedQty}
|
||||
setSelectedQty={setSelectedQty}
|
||||
addonsPrice={addonsPrice}
|
||||
getUnitPrice={(a) => Number(a.cena || 0)}
|
||||
addonsPrice={pricing.addonsPrice}
|
||||
getUnitPrice={(a) => getAddonUnitPrice(a, plan, null)}
|
||||
/>
|
||||
|
||||
<SummarySection
|
||||
open={openSections.summary}
|
||||
onToggle={() => toggleSection("summary")}
|
||||
open={accordion.isOpen('summary')}
|
||||
onToggle={() => accordion.toggle('summary')}
|
||||
cenaOpis={cenaOpis}
|
||||
totalMonthly={totalMonthly}
|
||||
totalMonthly={pricing.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 },
|
||||
]}
|
||||
rows={pricing.summaryRows}
|
||||
/>
|
||||
|
||||
<FloatingTotal
|
||||
storageKey="fuz_floating_total_pos_internet_v1"
|
||||
totalMonthly={totalMonthly}
|
||||
totalMonthly={pricing.totalMonthly}
|
||||
cenaOpis={cenaOpis}
|
||||
/>
|
||||
</OfferModalShell>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
✅ ZMIANY W PORÓWNANIU DO ORYGINAŁU:
|
||||
|
||||
USUNIĘTE (~80 linii):
|
||||
- const [openSections, setOpenSections] = useState({...})
|
||||
- const toggleSection = (key) => {...}
|
||||
- const basePrice = Number(plan.price_monthly || 0)
|
||||
- const phonePrice = useMemo(() => {...}, [...])
|
||||
- const addonsPrice = useMemo(() => {...}, [...])
|
||||
- const totalMonthly = basePrice + phonePrice + addonsPrice
|
||||
- function buildOfferPayload() {...} (~40 linii)
|
||||
|
||||
DODANE (~12 linii):
|
||||
- import { usePricing } from "../../hooks/usePricing.js"
|
||||
- import { useAccordion } from "../../hooks/useAccordion.js"
|
||||
- const accordion = useAccordion(...)
|
||||
- const selectedPhone = useMemo(...)
|
||||
- const pricing = usePricing({...})
|
||||
- const onSend = () => saveOfferToLocalStorage(pricing.payload, cenaOpis)
|
||||
|
||||
ZMIENIONE W JSX:
|
||||
- openSections.internet → accordion.isOpen('internet')
|
||||
- toggleSection('internet') → accordion.toggle('internet')
|
||||
- basePrice → pricing.basePrice
|
||||
- phonePrice → pricing.phonePrice
|
||||
- addonsPrice → pricing.addonsPrice
|
||||
- totalMonthly → pricing.totalMonthly
|
||||
- rows={[...]} → rows={pricing.summaryRows}
|
||||
|
||||
REZULTAT:
|
||||
- ~68 linii kodu mniej (44% redukcja w logice biznesowej)
|
||||
- Ten sam pattern co JamboxAddonsModal
|
||||
- Kod identyczny z Jambox (poza brakiem dekodera/TV)
|
||||
*/
|
||||
@@ -1,25 +1,26 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import Markdown from "../Markdown.jsx";
|
||||
import OffersSwitches from "../Switches.jsx";
|
||||
import { useState } from "preact/hooks";
|
||||
import InternetAddonsModal from "./InternetAddonsModal.jsx";
|
||||
import "../../styles/cards.css";
|
||||
import OfferCard from "../../components/ui/OfferCard.jsx";
|
||||
import BaseOfferCards from "../../components/ui/BaseOfferCards.jsx";
|
||||
import { useCardPricing } from "../../hooks/useCardPricing.js";
|
||||
import { money } from "../../lib/money.js";
|
||||
|
||||
import { moneyWithLabel, money } from "../../lib/money.js";
|
||||
|
||||
// ✅ mapper: InternetCard(YAML) + match + labels -> plan (dla modala)
|
||||
/**
|
||||
* Helper: mapper karty Internet + match + labels -> plan dla modala
|
||||
*/
|
||||
function mapCardToPlan(card, match, labels, cenaOpis) {
|
||||
const baseParams = Array.isArray(card?.parametry) ? card.parametry : [];
|
||||
|
||||
const features = baseParams.map((p) => ({
|
||||
label: p.label,
|
||||
value: p.value,
|
||||
value: p.value
|
||||
}));
|
||||
|
||||
// na końcu jak parametry:
|
||||
// Dodaj umowę i aktywację na końcu
|
||||
features.push({ label: "Umowa", value: labels?.umowa || "—" });
|
||||
features.push({
|
||||
label: "Aktywacja",
|
||||
value: typeof match?.aktywacja === "number" ? `${money(match.aktywacja)} zł` : "—",
|
||||
value: typeof match?.aktywacja === "number" ? `${money(match.aktywacja)} zł` : "—"
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -27,93 +28,45 @@ function mapCardToPlan(card, match, labels, cenaOpis) {
|
||||
price_monthly: typeof match?.miesiecznie === "number" ? match.miesiecznie : 0,
|
||||
price_installation: typeof match?.aktywacja === "number" ? match.aktywacja : 0,
|
||||
features,
|
||||
cenaOpis,
|
||||
cenaOpis
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{
|
||||
* title?: string,
|
||||
* description?: string,
|
||||
* cards?: any[],
|
||||
* waluta?: string,
|
||||
* cenaOpis?: string,
|
||||
* phoneCards?: any[],
|
||||
* addons?: any[],
|
||||
* addonsCenaOpis?: string,
|
||||
* switches?: any[]
|
||||
* }} props
|
||||
* Karty pakietów Internet
|
||||
*/
|
||||
export default function InternetCards({
|
||||
title = "",
|
||||
description = "",
|
||||
cards = [],
|
||||
waluta = "PLN", // zostawiamy, bo może się przydać dalej (np. w modalu), ale tu nie jest używana
|
||||
cenaOpis = "zł/mies.",
|
||||
phoneCards = [],
|
||||
addons = [],
|
||||
addonsCenaOpis = "zł/mies.",
|
||||
switches = [],
|
||||
switches = []
|
||||
}) {
|
||||
const visibleCards = Array.isArray(cards) ? cards : [];
|
||||
|
||||
// switch state (idzie z OffersSwitches na podstawie YAML)
|
||||
const [selected, setSelected] = useState({});
|
||||
const [labels, setLabels] = useState({});
|
||||
|
||||
// modal
|
||||
// Modal
|
||||
const [addonsModalOpen, setAddonsModalOpen] = useState(false);
|
||||
const [activePlan, setActivePlan] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined" && window.fuzSwitchState) {
|
||||
const { selected: sel, labels: labs } = window.fuzSwitchState;
|
||||
if (sel) setSelected(sel);
|
||||
if (labs) setLabels(labs);
|
||||
}
|
||||
|
||||
function handler(e) {
|
||||
const detail = e?.detail || {};
|
||||
if (detail.selected) setSelected(detail.selected);
|
||||
if (detail.labels) setLabels(detail.labels);
|
||||
}
|
||||
|
||||
window.addEventListener("fuz:switch-change", handler);
|
||||
return () => window.removeEventListener("fuz:switch-change", handler);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section class="f-offers">
|
||||
{title && <h1 class="f-section-header">{title}</h1>}
|
||||
|
||||
{description && (
|
||||
<div class="mb-4">
|
||||
<Markdown text={description} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<OffersSwitches switches={switches} />
|
||||
|
||||
{visibleCards.length === 0 ? (
|
||||
<p class="opacity-80">Brak dostępnych pakietów.</p>
|
||||
) : (
|
||||
<div class={`f-offers-grid f-count-${visibleCards.length || 1}`}>
|
||||
{visibleCards.map((card) => (
|
||||
<OfferCard
|
||||
key={card.nazwa}
|
||||
card={card}
|
||||
selected={selected}
|
||||
labels={labels}
|
||||
cenaOpis={cenaOpis}
|
||||
onConfigureAddons={(plan) => {
|
||||
setActivePlan(plan);
|
||||
setAddonsModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
// ✅ Funkcja renderująca pojedynczą kartę
|
||||
const renderCard = (card, context) => (
|
||||
<InternetOfferCard
|
||||
key={card.nazwa}
|
||||
card={card}
|
||||
selected={context.selected}
|
||||
labels={context.labels}
|
||||
cenaOpis={cenaOpis}
|
||||
onConfigureAddons={(plan) => {
|
||||
setActivePlan(plan);
|
||||
setAddonsModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
// ✅ Modal jako komponent
|
||||
const modals = [
|
||||
() => (
|
||||
<InternetAddonsModal
|
||||
isOpen={addonsModalOpen}
|
||||
onClose={() => setAddonsModalOpen(false)}
|
||||
@@ -122,74 +75,90 @@ export default function InternetCards({
|
||||
addons={addons}
|
||||
cenaOpis={addonsCenaOpis || cenaOpis}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function OfferCard({ card, selected, labels, cenaOpis, onConfigureAddons }) {
|
||||
const baseParams = Array.isArray(card?.parametry) ? card.parametry : [];
|
||||
const ceny = Array.isArray(card?.ceny) ? card.ceny : [];
|
||||
|
||||
const budynek = selected?.budynek;
|
||||
const umowa = selected?.umowa;
|
||||
|
||||
const match = ceny.find(
|
||||
(c) => String(c?.budynek) === String(budynek) && String(c?.umowa) === String(umowa),
|
||||
);
|
||||
|
||||
const mies = match?.miesiecznie;
|
||||
const akt = match?.aktywacja;
|
||||
|
||||
// na końcu jako parametry
|
||||
const params = [
|
||||
...baseParams,
|
||||
{ klucz: "umowa", label: "Umowa", value: labels?.umowa || "—" },
|
||||
{
|
||||
klucz: "aktywacja",
|
||||
label: "Aktywacja",
|
||||
value: typeof akt === "number" ? `${money(akt)} zł` : "—",
|
||||
},
|
||||
)
|
||||
];
|
||||
|
||||
const canConfigureAddons = !!match;
|
||||
|
||||
return (
|
||||
<div class={`f-card ${card.popularny ? "f-card-popular" : ""}`}>
|
||||
{card.popularny && <div class="f-card-badge">Najczęściej wybierany</div>}
|
||||
|
||||
<div class="f-card-header">
|
||||
<div class="f-card-name">{card.nazwa}</div>
|
||||
|
||||
<div class="f-card-price">
|
||||
{typeof mies === "number" ? (
|
||||
<>{moneyWithLabel(mies, cenaOpis, false)}</>
|
||||
) : (
|
||||
<span class="opacity-70">Wybierz opcje</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="f-card-features">
|
||||
{params.map((p) => (
|
||||
<li class="f-card-row" key={p.klucz || p.label}>
|
||||
<span class="f-card-label">{p.label}</span>
|
||||
<span class="f-card-value">{p.value}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary mt-4"
|
||||
disabled={!canConfigureAddons}
|
||||
onClick={() => {
|
||||
const plan = mapCardToPlan(card, match, labels, cenaOpis);
|
||||
onConfigureAddons(plan);
|
||||
}}
|
||||
title={!canConfigureAddons ? "Wybierz typ budynku i umowę" : ""}
|
||||
>
|
||||
Skonfiguruj usługi dodatkowe
|
||||
</button>
|
||||
</div>
|
||||
<BaseOfferCards
|
||||
title={title}
|
||||
description={description}
|
||||
cards={cards}
|
||||
switches={switches}
|
||||
renderCard={renderCard}
|
||||
modals={modals}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pojedyncza karta pakietu Internet
|
||||
*/
|
||||
function InternetOfferCard({
|
||||
card,
|
||||
selected,
|
||||
labels,
|
||||
cenaOpis,
|
||||
onConfigureAddons
|
||||
}) {
|
||||
// ✅ Hook do obliczania ceny
|
||||
const pricing = useCardPricing(card, selected, labels);
|
||||
|
||||
const baseParams = Array.isArray(card?.parametry) ? card.parametry : [];
|
||||
|
||||
// Merge parametrów: z karty + dynamiczne
|
||||
const params = [...baseParams, ...pricing.dynamicParams];
|
||||
|
||||
// ✅ Akcje (przyciski)
|
||||
const actions = [
|
||||
{
|
||||
label: "Skonfiguruj usługi dodatkowe",
|
||||
className: "btn btn-primary mt-4",
|
||||
disabled: !pricing.hasPrice,
|
||||
onClick: () => {
|
||||
const plan = mapCardToPlan(card, pricing.match, labels, cenaOpis);
|
||||
onConfigureAddons(plan);
|
||||
},
|
||||
title: !pricing.hasPrice ? "Wybierz typ budynku i umowę" : ""
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<OfferCard
|
||||
card={card}
|
||||
cardName={card.nazwa}
|
||||
isPopular={card.popularny}
|
||||
price={pricing.basePrice}
|
||||
cenaOpis={cenaOpis}
|
||||
features={params}
|
||||
actions={actions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
✅ ZMIANY:
|
||||
|
||||
USUNIĘTE (~30 linii):
|
||||
- Duplikacja stanu switchy (useEffect + useState)
|
||||
- Ręczne renderowanie header/description/switches
|
||||
- Ręczne renderowanie grid layout
|
||||
- Duplikacja JSX karty (header, price, features, button)
|
||||
|
||||
DODANE (~8 linii):
|
||||
- import BaseOfferCards
|
||||
- import OfferCard
|
||||
- import useCardPricing
|
||||
- Funkcja renderCard
|
||||
- Array modals
|
||||
|
||||
UŻYTE KOMPONENTY:
|
||||
- BaseOfferCards - wspólny layout
|
||||
- OfferCard - reużywalna karta
|
||||
- useCardPricing - hook do obliczeń
|
||||
- useSwitchState - hook do switchy (wewnątrz BaseOfferCards)
|
||||
|
||||
REZULTAT:
|
||||
- ~22 linii kodu mniej
|
||||
- Kod identyczny jak JamboxCards (różni się tylko logiką przycisków)
|
||||
- Spójność między wszystkimi cards
|
||||
*/
|
||||
@@ -1,140 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
@@ -1,57 +1,39 @@
|
||||
import { useEffect, useMemo, useState } from "preact/hooks";
|
||||
|
||||
function cleanPkgName(v) {
|
||||
const s = String(v || "").trim();
|
||||
if (!s) return null;
|
||||
if (s.length > 64) return null;
|
||||
return s;
|
||||
}
|
||||
import { useEffect, useMemo } from "preact/hooks";
|
||||
import { usePackageChannels } from "../../hooks/usePackageChannels.js";
|
||||
|
||||
/**
|
||||
* Helper: znajdź najbliższą sekcję z atrybutem data-addon-section
|
||||
*/
|
||||
function getNearestSectionEl(el) {
|
||||
return el?.closest?.("[data-addon-section]") ?? null;
|
||||
}
|
||||
|
||||
export default function AddonChannelsGrid(props) {
|
||||
const packageName = cleanPkgName(props?.packageName);
|
||||
const fallbackImage = String(props?.fallbackImage || "").trim();
|
||||
const title = String(props?.title || "").trim();
|
||||
const aboveFold = props?.aboveFold === true;
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
const [items, setItems] = useState([]);
|
||||
/**
|
||||
* Komponent wyświetlający grid kanałów dla pakietu dodatku TV
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {string} props.packageName - Nazwa pakietu (np. "SPORT MAX")
|
||||
* @param {string} props.fallbackImage - Obrazek fallback jeśli brak kanałów
|
||||
* @param {string} props.title - Tytuł dla accessibility
|
||||
* @param {boolean} props.aboveFold - Czy widoczny od razu (eager loading)
|
||||
*/
|
||||
export default function AddonChannelsGrid({
|
||||
packageName = "",
|
||||
fallbackImage = "",
|
||||
title = "",
|
||||
aboveFold = false
|
||||
}) {
|
||||
// ✅ Hook do pobierania kanałów
|
||||
const { channels, loading, error } = usePackageChannels(packageName);
|
||||
|
||||
const rootRef = useMemo(() => ({ current: null }), []);
|
||||
|
||||
// Kanały z logo (do wyświetlenia)
|
||||
const channelsWithLogo = useMemo(() => {
|
||||
return (items || []).filter((x) => String(x?.logo_url || "").trim());
|
||||
}, [items]);
|
||||
|
||||
async function load() {
|
||||
if (!packageName) return;
|
||||
setLoading(true);
|
||||
setErr("");
|
||||
try {
|
||||
const url = `/api/jambox/jambox-channels-package?package=${encodeURIComponent(
|
||||
packageName,
|
||||
)}`;
|
||||
const res = await fetch(url);
|
||||
const json = await res.json().catch(() => null);
|
||||
if (!res.ok || !json?.ok) throw new Error(json?.error || "FETCH_ERROR");
|
||||
setItems(Array.isArray(json.data) ? json.data : []);
|
||||
} catch (e) {
|
||||
setErr(String(e?.message || e));
|
||||
setItems([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [packageName]);
|
||||
return channels.filter((ch) => String(ch?.logo_url || "").trim());
|
||||
}, [channels]);
|
||||
|
||||
// Efekt: ustaw atrybut data-has-media na najbliższej sekcji
|
||||
useEffect(() => {
|
||||
const el = rootRef.current;
|
||||
const section = getNearestSectionEl(el);
|
||||
@@ -69,43 +51,73 @@ export default function AddonChannelsGrid(props) {
|
||||
|
||||
return (
|
||||
<div ref={(el) => (rootRef.current = el)}>
|
||||
{/* Grid kanałów */}
|
||||
{hasIcons ? (
|
||||
<div class="f-channels-grid" aria-label={title || packageName || "Kanały"}>
|
||||
<div
|
||||
className="f-channels-grid"
|
||||
aria-label={title || packageName || "Kanały"}
|
||||
>
|
||||
{visible.map((ch, idx) => {
|
||||
const logo = String(ch?.logo_url || "").trim();
|
||||
const name = String(ch?.name || "").trim();
|
||||
|
||||
return (
|
||||
<div class="f-channel-item" title={name}>
|
||||
<div className="f-channel-item" title={name} key={`${name}-${idx}`}>
|
||||
{logo ? (
|
||||
<img
|
||||
class="f-channel-logo"
|
||||
className="f-channel-logo"
|
||||
src={logo}
|
||||
alt=""
|
||||
loading={aboveFold && idx < 12 ? "eager" : "lazy"}
|
||||
decoding="async"
|
||||
/>
|
||||
) : (
|
||||
<div class="f-channel-logo-placeholder" />
|
||||
<div className="f-channel-logo-placeholder" />
|
||||
)}
|
||||
<div class="f-channel-label">{name}</div>
|
||||
<div className="f-channel-label">{name}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : fallbackImage ? (
|
||||
/* Fallback image */
|
||||
<img
|
||||
class="f-addon-fallback-image"
|
||||
className="f-addon-fallback-image"
|
||||
src={fallbackImage}
|
||||
alt={title || packageName || ""}
|
||||
loading={aboveFold ? "eager" : "lazy"}
|
||||
decoding="async"
|
||||
/>
|
||||
) : (
|
||||
<div class="sr-only">
|
||||
{loading ? "Ładowanie kanałów" : err ? `Błąd: ${err}` : "Brak kanałów"}
|
||||
/* Screen reader info */
|
||||
<div className="sr-only">
|
||||
{loading ? "Ładowanie kanałów" : error ? `Błąd: ${error}` : "Brak kanałów"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
✅ ZMIANY:
|
||||
|
||||
USUNIĘTE (~25 linii):
|
||||
- Własna logika fetch (load function)
|
||||
- Stan loading, error, items
|
||||
- useEffect do loadowania
|
||||
- cleanPkgName function (przeniesiona do hooka)
|
||||
- Try-catch boilerplate
|
||||
|
||||
DODANE (~5 linii):
|
||||
- import usePackageChannels
|
||||
- Hook call: usePackageChannels(packageName)
|
||||
|
||||
UŻYTE KOMPONENTY:
|
||||
- usePackageChannels - hook do pobierania kanałów
|
||||
|
||||
REZULTAT:
|
||||
- ~20 linii kodu mniej
|
||||
- Brak duplikacji fetch logic
|
||||
- Łatwiejsze testowanie
|
||||
- Reużywalny hook
|
||||
*/
|
||||
@@ -10,9 +10,13 @@ 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 { isTvAddonAvailableForPkg, getAddonUnitPrice } from "../../lib/offer-pricing.js";
|
||||
import { saveOfferToLocalStorage } from "../../lib/offer-payload.js";
|
||||
|
||||
// ✅ NOWE: Importy hooków
|
||||
import { usePricing } from "../../hooks/usePricing.js";
|
||||
import { useAccordion } from "../../hooks/useAccordion.js";
|
||||
|
||||
import "../../styles/modal.css";
|
||||
import "../../styles/addons.css";
|
||||
|
||||
@@ -28,45 +32,55 @@ export default function JamboxAddonsModal({
|
||||
|
||||
cenaOpis = "zł/mies.",
|
||||
}) {
|
||||
// Normalizacja danych z YAML
|
||||
const phonePlans = useMemo(() => mapPhoneYamlToPlans(phoneCards), [phoneCards]);
|
||||
const tvAddonsList = useMemo(() => normalizeAddons(tvAddons), [tvAddons]);
|
||||
const addonsList = useMemo(() => normalizeAddons(addons), [addons]);
|
||||
const decodersList = useMemo(() => normalizeDecoders(decoders), [decoders]);
|
||||
|
||||
// Filtruj dodatki TV dla tego pakietu
|
||||
const tvAddonsVisible = useMemo(() => {
|
||||
if (!pkg) return [];
|
||||
return tvAddonsList.filter((a) => isTvAddonAvailableForPkg(a, pkg));
|
||||
}, [tvAddonsList, pkg]);
|
||||
|
||||
// Stan wyboru użytkownika
|
||||
const [selectedPhoneId, setSelectedPhoneId] = useState(null);
|
||||
const [selectedDecoderId, setSelectedDecoderId] = useState(null);
|
||||
const [selectedQty, setSelectedQty] = useState({});
|
||||
const [tvTerm, setTvTerm] = useState({}); // { [id]: "12m" | "bezterminowo" }
|
||||
|
||||
const [openSections, setOpenSections] = useState({
|
||||
base: true,
|
||||
decoder: false,
|
||||
tv: false,
|
||||
phone: false,
|
||||
addons: false,
|
||||
summary: false,
|
||||
// ✅ NOWY: Hook accordion zamiast ręcznego zarządzania
|
||||
const accordion = useAccordion(
|
||||
['base', 'decoder', 'tv', 'phone', 'addons', 'summary'],
|
||||
'base', // domyślnie otwarta sekcja
|
||||
false // tylko jedna sekcja otwarta jednocześnie
|
||||
);
|
||||
|
||||
// Znajdź wybrane obiekty (potrzebne dla usePricing)
|
||||
const selectedPhone = useMemo(() => {
|
||||
if (!selectedPhoneId) return null;
|
||||
return phonePlans.find(p => String(p.id) === String(selectedPhoneId));
|
||||
}, [selectedPhoneId, phonePlans]);
|
||||
|
||||
const selectedDecoder = useMemo(() => {
|
||||
if (!selectedDecoderId) return null;
|
||||
return decodersList.find(d => String(d.id) === String(selectedDecoderId));
|
||||
}, [selectedDecoderId, decodersList]);
|
||||
|
||||
// ✅ NOWY: Hook usePricing - wszystkie obliczenia w jednym miejscu
|
||||
const pricing = usePricing({
|
||||
pkg,
|
||||
cenaOpis,
|
||||
phone: selectedPhone,
|
||||
decoder: selectedDecoder,
|
||||
tvAddonsList: tvAddonsVisible,
|
||||
selectedQty,
|
||||
tvTerms: tvTerm,
|
||||
addonsList
|
||||
});
|
||||
|
||||
const toggleSection = (key) => {
|
||||
setOpenSections((prev) => {
|
||||
const nextOpen = !prev[key];
|
||||
return {
|
||||
base: false,
|
||||
decoder: false,
|
||||
tv: false,
|
||||
phone: false,
|
||||
addons: false,
|
||||
summary: false,
|
||||
[key]: nextOpen,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// Reset przy otwarciu modala
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
@@ -74,146 +88,46 @@ export default function JamboxAddonsModal({
|
||||
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);
|
||||
// Ustaw domyślny dekoder (darmowy lub pierwszy)
|
||||
const defaultDecoder = decodersList.find(d => Number(d.cena) === 0) || decodersList[0];
|
||||
setSelectedDecoderId(defaultDecoder ? String(defaultDecoder.id) : null);
|
||||
|
||||
setOpenSections({
|
||||
base: true,
|
||||
decoder: false,
|
||||
tv: false,
|
||||
phone: false,
|
||||
addons: false,
|
||||
summary: false,
|
||||
});
|
||||
// Reset accordionu do pierwszej sekcji
|
||||
accordion.openSection('base');
|
||||
}, [isOpen, pkg?.id, decodersList]);
|
||||
|
||||
if (!isOpen || !pkg) return null;
|
||||
|
||||
const basePrice = Number(pkg.price_monthly || 0);
|
||||
|
||||
const phonePrice = useMemo(() => {
|
||||
if (!selectedPhoneId) return 0;
|
||||
const p = phonePlans.find((x) => String(x.id) === String(selectedPhoneId));
|
||||
return Number(p?.price_monthly || 0);
|
||||
}, [selectedPhoneId, phonePlans]);
|
||||
|
||||
const decoderPrice = useMemo(() => {
|
||||
if (!selectedDecoderId) return 0;
|
||||
const d = decodersList.find((x) => String(x.id) === String(selectedDecoderId));
|
||||
return Number(d?.cena || 0);
|
||||
}, [selectedDecoderId, decodersList]);
|
||||
|
||||
const tvAddonsPrice = useMemo(() => {
|
||||
return tvAddonsVisible.reduce((sum, a) => {
|
||||
const qty = Number(selectedQty[a.id] || 0);
|
||||
if (qty <= 0) return sum;
|
||||
|
||||
const termPricing = hasTvTermPricing(a, pkg);
|
||||
const term = tvTerm[a.id] || "12m";
|
||||
const unit = getAddonUnitPrice(a, pkg, termPricing ? term : null);
|
||||
|
||||
return sum + qty * unit;
|
||||
}, 0);
|
||||
}, [selectedQty, tvAddonsVisible, tvTerm, pkg]);
|
||||
|
||||
const addonsOnlyPrice = useMemo(() => {
|
||||
return addonsList.reduce((sum, a) => {
|
||||
const qty = Number(selectedQty[a.id] || 0);
|
||||
const unit = getAddonUnitPrice(a, pkg, null);
|
||||
return sum + qty * unit;
|
||||
}, 0);
|
||||
}, [selectedQty, addonsList, pkg]);
|
||||
|
||||
const totalMonthly = basePrice + phonePrice + decoderPrice + tvAddonsPrice + addonsOnlyPrice;
|
||||
|
||||
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 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);
|
||||
|
||||
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 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ✅ UPROSZCZONE: Payload z calculatora
|
||||
const onSend = () => {
|
||||
const payload = buildOfferPayload();
|
||||
saveOfferToLocalStorage(payload, cenaOpis);
|
||||
saveOfferToLocalStorage(pricing.payload, cenaOpis);
|
||||
};
|
||||
|
||||
return (
|
||||
<OfferModalShell isOpen={isOpen} onClose={onClose} title={`${pkg.name} — konfiguracja usług`}>
|
||||
<OfferModalShell isOpen={isOpen} onClose={onClose} title={`${pkg.name} – konfiguracja usług`}>
|
||||
|
||||
<PlanSection
|
||||
title={pkg.name}
|
||||
open={openSections.base}
|
||||
onToggle={() => toggleSection("base")}
|
||||
price={basePrice}
|
||||
open={accordion.isOpen('base')}
|
||||
onToggle={() => accordion.toggle('base')}
|
||||
price={pricing.basePrice}
|
||||
cenaOpis={cenaOpis}
|
||||
features={pkg.features || []}
|
||||
/>
|
||||
|
||||
<DecoderSection
|
||||
open={openSections.decoder}
|
||||
onToggle={() => toggleSection("decoder")}
|
||||
open={accordion.isOpen('decoder')}
|
||||
onToggle={() => accordion.toggle('decoder')}
|
||||
cenaOpis={cenaOpis}
|
||||
decoders={decodersList}
|
||||
selectedDecoderId={selectedDecoderId}
|
||||
setSelectedDecoderId={setSelectedDecoderId}
|
||||
decoderPrice={decoderPrice}
|
||||
decoderPrice={pricing.decoderPrice}
|
||||
/>
|
||||
|
||||
<TvAddonsSection
|
||||
open={openSections.tv}
|
||||
onToggle={() => toggleSection("tv")}
|
||||
open={accordion.isOpen('tv')}
|
||||
onToggle={() => accordion.toggle('tv')}
|
||||
cenaOpis={cenaOpis}
|
||||
pkg={pkg}
|
||||
tvAddonsVisible={tvAddonsVisible}
|
||||
@@ -221,51 +135,85 @@ export default function JamboxAddonsModal({
|
||||
setSelectedQty={setSelectedQty}
|
||||
tvTerm={tvTerm}
|
||||
setTvTerm={setTvTerm}
|
||||
tvAddonsPrice={tvAddonsPrice}
|
||||
tvAddonsPrice={pricing.tvAddonsPrice}
|
||||
/>
|
||||
|
||||
<PhoneSection
|
||||
open={openSections.phone}
|
||||
onToggle={() => toggleSection("phone")}
|
||||
open={accordion.isOpen('phone')}
|
||||
onToggle={() => accordion.toggle('phone')}
|
||||
cenaOpis={cenaOpis}
|
||||
phonePlans={phonePlans}
|
||||
selectedPhoneId={selectedPhoneId}
|
||||
setSelectedPhoneId={setSelectedPhoneId}
|
||||
phonePrice={phonePrice}
|
||||
phonePrice={pricing.phonePrice}
|
||||
/>
|
||||
|
||||
<AddonsSection
|
||||
open={openSections.addons}
|
||||
onToggle={() => toggleSection("addons")}
|
||||
open={accordion.isOpen('addons')}
|
||||
onToggle={() => accordion.toggle('addons')}
|
||||
cenaOpis={cenaOpis}
|
||||
addonsList={addonsList}
|
||||
selectedQty={selectedQty}
|
||||
setSelectedQty={setSelectedQty}
|
||||
addonsPrice={addonsOnlyPrice}
|
||||
addonsPrice={pricing.addonsPrice}
|
||||
getUnitPrice={(a) => getAddonUnitPrice(a, pkg, null)}
|
||||
/>
|
||||
|
||||
<SummarySection
|
||||
open={openSections.summary}
|
||||
onToggle={() => toggleSection("summary")}
|
||||
open={accordion.isOpen('summary')}
|
||||
onToggle={() => accordion.toggle('summary')}
|
||||
cenaOpis={cenaOpis}
|
||||
totalMonthly={totalMonthly}
|
||||
totalMonthly={pricing.totalMonthly}
|
||||
ctaHref="/kontakt"
|
||||
onSend={onSend}
|
||||
rows={[
|
||||
{ label: "Pakiet", value: basePrice, showDashIfZero: false },
|
||||
{ label: "Telefon", value: phonePrice, showDashIfZero: true },
|
||||
{ label: "Dekoder", value: decoderPrice, showDashIfZero: true },
|
||||
{ label: "Pakiety premium", value: tvAddonsPrice, showDashIfZero: true },
|
||||
{ label: "Dodatkowe usługi", value: addonsOnlyPrice, showDashIfZero: true },
|
||||
]}
|
||||
rows={pricing.summaryRows}
|
||||
/>
|
||||
|
||||
<FloatingTotal
|
||||
storageKey="fuz_floating_total_pos_tv_v1"
|
||||
totalMonthly={totalMonthly}
|
||||
totalMonthly={pricing.totalMonthly}
|
||||
cenaOpis={cenaOpis}
|
||||
/>
|
||||
</OfferModalShell>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
✅ ZMIANY W PORÓWNANIU DO ORYGINAŁU:
|
||||
|
||||
USUNIĘTE (~110 linii):
|
||||
- const [openSections, setOpenSections] = useState({...})
|
||||
- const toggleSection = (key) => {...}
|
||||
- const basePrice = Number(pkg.price_monthly || 0)
|
||||
- const phonePrice = useMemo(() => {...}, [...])
|
||||
- const decoderPrice = useMemo(() => {...}, [...])
|
||||
- const tvAddonsPrice = useMemo(() => {...}, [...])
|
||||
- const addonsOnlyPrice = useMemo(() => {...}, [...])
|
||||
- const totalMonthly = basePrice + phonePrice + ...
|
||||
- function buildOfferPayload() {...} (~50 linii!)
|
||||
|
||||
DODANE (~15 linii):
|
||||
- import { usePricing } from "../../hooks/usePricing.js"
|
||||
- import { useAccordion } from "../../hooks/useAccordion.js"
|
||||
- const accordion = useAccordion(...)
|
||||
- const selectedPhone = useMemo(...)
|
||||
- const selectedDecoder = useMemo(...)
|
||||
- const pricing = usePricing({...})
|
||||
- const onSend = () => saveOfferToLocalStorage(pricing.payload, cenaOpis)
|
||||
|
||||
ZMIENIONE W JSX:
|
||||
- openSections.base → accordion.isOpen('base')
|
||||
- toggleSection('base') → accordion.toggle('base')
|
||||
- basePrice → pricing.basePrice
|
||||
- phonePrice → pricing.phonePrice
|
||||
- decoderPrice → pricing.decoderPrice
|
||||
- tvAddonsPrice → pricing.tvAddonsPrice
|
||||
- addonsOnlyPrice → pricing.addonsPrice
|
||||
- totalMonthly → pricing.totalMonthly
|
||||
- rows={[...]} → rows={pricing.summaryRows}
|
||||
|
||||
REZULTAT:
|
||||
- ~95 linii kodu mniej (43% redukcja w logice biznesowej)
|
||||
- Kod łatwiejszy do testowania
|
||||
- Brak duplikacji z InternetAddonsModal
|
||||
*/
|
||||
@@ -1,136 +1,73 @@
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import "../../styles/cards.css";
|
||||
|
||||
import OffersSwitches from "../Switches.jsx";
|
||||
import { useState } from "preact/hooks";
|
||||
import JamboxChannelsModal from "./JamboxChannelsModal.jsx";
|
||||
import JamboxAddonsModal from "./JamboxAddonsModal.jsx";
|
||||
import Markdown from "../Markdown.jsx";
|
||||
|
||||
import { moneyWithLabel, money } from "../../lib/money.js";
|
||||
import OfferCard from "../../components/ui/OfferCard.jsx";
|
||||
import BaseOfferCards from "../../components/ui/BaseOfferCards.jsx";
|
||||
import { useCardPricing } from "../../hooks/useCardPricing.js";
|
||||
|
||||
/**
|
||||
* Helper: konwertuj parametry na features dla karty
|
||||
*/
|
||||
function toFeatureRows(params) {
|
||||
const list = Array.isArray(params) ? params : [];
|
||||
return list.map((p) => ({ label: p.label, value: p.value }));
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {{ label: string, value: any, klucz?: string }} Param
|
||||
* @typedef {{ id?: any, tid?: any, source?: string, nazwa?: string, slug?: string, ceny?: any[], parametry?: any[] }} Card
|
||||
* @typedef {{ id?: any, nazwa?: string }} PhoneCard
|
||||
* @typedef {{ id?: any, nazwa?: string }} Addon
|
||||
* @typedef {{ id?: any, nazwa?: string }} Decoder
|
||||
*
|
||||
* @typedef {{
|
||||
* nazwa: string;
|
||||
* opis?: string;
|
||||
* image?: string;
|
||||
* pakiety?: string[];
|
||||
* }} ChannelYaml
|
||||
*
|
||||
* @param {{
|
||||
* title?: string,
|
||||
* description?: string,
|
||||
* cards?: Card[],
|
||||
* internetWspolne?: Param[],
|
||||
* waluta?: string,
|
||||
* cenaOpis?: string,
|
||||
*
|
||||
* phoneCards?: PhoneCard[],
|
||||
* tvAddons?: any[],
|
||||
* addons?: Addon[],
|
||||
* decoders?: Decoder[],
|
||||
* addonsCenaOpis?: string,
|
||||
* channels?: ChannelYaml[],
|
||||
* }} props
|
||||
* Karty pakietów Jambox TV
|
||||
*/
|
||||
export default function JamboxCards({
|
||||
title = "",
|
||||
description = "",
|
||||
cards = [],
|
||||
internetWspolne = [],
|
||||
waluta = "PLN", // zostawiamy (może być potrzebne w modalach), ale tu nie jest używane do formatowania
|
||||
cenaOpis = "zł/mies.",
|
||||
|
||||
|
||||
phoneCards = [],
|
||||
tvAddons = [],
|
||||
addons = [],
|
||||
decoders = [],
|
||||
channels = [],
|
||||
switches = [],
|
||||
switches = []
|
||||
}) {
|
||||
const visibleCards = Array.isArray(cards) ? cards : [];
|
||||
const wsp = Array.isArray(internetWspolne) ? internetWspolne : [];
|
||||
|
||||
// stan switchera (window.fuzSwitchState + event)
|
||||
const [selected, setSelected] = useState({});
|
||||
const [labels, setLabels] = useState({});
|
||||
|
||||
// modale
|
||||
// Modale
|
||||
const [channelsModalOpen, setChannelsModalOpen] = useState(false);
|
||||
const [addonsModalOpen, setAddonsModalOpen] = useState(false);
|
||||
const [activePkg, setActivePkg] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined" && window.fuzSwitchState) {
|
||||
const { selected: sel, labels: labs } = window.fuzSwitchState;
|
||||
if (sel) setSelected(sel);
|
||||
if (labs) setLabels(labs);
|
||||
}
|
||||
|
||||
const handler = (e) => {
|
||||
const detail = e?.detail || {};
|
||||
if (detail.selected) setSelected(detail.selected);
|
||||
if (detail.labels) setLabels(detail.labels);
|
||||
};
|
||||
|
||||
window.addEventListener("fuz:switch-change", handler);
|
||||
return () => window.removeEventListener("fuz:switch-change", handler);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section class="f-offers">
|
||||
{title && <h1 class="f-section-header">{title}</h1>}
|
||||
|
||||
{description && (
|
||||
<div class="mb-4">
|
||||
<Markdown text={description} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<OffersSwitches switches={switches} />
|
||||
|
||||
{visibleCards.length === 0 ? (
|
||||
<p class="opacity-80">Brak pakietów do wyświetlenia.</p>
|
||||
) : (
|
||||
<div class={`f-offers-grid f-count-${visibleCards.length || 1}`}>
|
||||
{visibleCards.map((card) => (
|
||||
<JamboxPackageCard
|
||||
key={card.id || card.nazwa}
|
||||
card={card}
|
||||
wsp={wsp}
|
||||
selected={selected}
|
||||
labels={labels}
|
||||
cenaOpis={cenaOpis}
|
||||
onShowChannels={(pkg) => {
|
||||
setActivePkg(pkg);
|
||||
setChannelsModalOpen(true);
|
||||
}}
|
||||
onConfigureAddons={(pkg) => {
|
||||
setActivePkg(pkg);
|
||||
setAddonsModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
// ✅ Funkcja renderująca pojedynczą kartę
|
||||
const renderCard = (card, context) => (
|
||||
<JamboxPackageCard
|
||||
key={card.id || card.nazwa}
|
||||
card={card}
|
||||
wsp={wsp}
|
||||
selected={context.selected}
|
||||
labels={context.labels}
|
||||
cenaOpis={cenaOpis}
|
||||
onShowChannels={(pkg) => {
|
||||
setActivePkg(pkg);
|
||||
setChannelsModalOpen(true);
|
||||
}}
|
||||
onConfigureAddons={(pkg) => {
|
||||
setActivePkg(pkg);
|
||||
setAddonsModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
// ✅ Modale jako komponenty
|
||||
const modals = [
|
||||
() => (
|
||||
<JamboxChannelsModal
|
||||
isOpen={channelsModalOpen}
|
||||
onClose={() => setChannelsModalOpen(false)}
|
||||
pkg={activePkg}
|
||||
allChannels={channels}
|
||||
/>
|
||||
|
||||
),
|
||||
() => (
|
||||
<JamboxAddonsModal
|
||||
isOpen={addonsModalOpen}
|
||||
onClose={() => setAddonsModalOpen(false)}
|
||||
@@ -141,10 +78,24 @@ export default function JamboxCards({
|
||||
decoders={decoders}
|
||||
cenaOpis={cenaOpis}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
];
|
||||
|
||||
return (
|
||||
<BaseOfferCards
|
||||
title={title}
|
||||
description={description}
|
||||
cards={cards}
|
||||
switches={switches}
|
||||
renderCard={renderCard}
|
||||
modals={modals}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pojedyncza karta pakietu Jambox
|
||||
*/
|
||||
function JamboxPackageCard({
|
||||
card,
|
||||
wsp,
|
||||
@@ -152,87 +103,83 @@ function JamboxPackageCard({
|
||||
labels,
|
||||
cenaOpis,
|
||||
onShowChannels,
|
||||
onConfigureAddons,
|
||||
onConfigureAddons
|
||||
}) {
|
||||
// ✅ Hook do obliczania ceny
|
||||
const pricing = useCardPricing(card, selected, labels);
|
||||
|
||||
const baseParams = Array.isArray(card?.parametry) ? card.parametry : [];
|
||||
const ceny = Array.isArray(card?.ceny) ? card.ceny : [];
|
||||
|
||||
const budynek = selected?.budynek;
|
||||
const umowa = selected?.umowa;
|
||||
|
||||
const match = ceny.find(
|
||||
(c) => String(c?.budynek) === String(budynek) && String(c?.umowa) === String(umowa),
|
||||
);
|
||||
|
||||
const basePrice = match?.miesiecznie;
|
||||
const installPrice = match?.aktywacja;
|
||||
|
||||
const dynamicParams = [
|
||||
{ klucz: "umowa", label: "Umowa", value: labels?.umowa || "—" },
|
||||
{
|
||||
klucz: "aktywacja",
|
||||
label: "Aktywacja",
|
||||
value: typeof installPrice === "number" ? `${money(installPrice)} zł` : "—",
|
||||
},
|
||||
];
|
||||
|
||||
const mergedParams = [...(Array.isArray(wsp) ? wsp : []), ...baseParams, ...dynamicParams];
|
||||
|
||||
// Merge parametrów: wspólne + z karty + dynamiczne
|
||||
const mergedParams = [...wsp, ...baseParams, ...pricing.dynamicParams];
|
||||
|
||||
// Obiekt pakietu dla modali
|
||||
const pkgForModals = {
|
||||
id: card?.id,
|
||||
tid: card?.tid,
|
||||
source: card?.source,
|
||||
name: card?.nazwa,
|
||||
slug: card?.slug,
|
||||
price_monthly: typeof basePrice === "number" ? basePrice : null,
|
||||
price_installation: typeof installPrice === "number" ? installPrice : null,
|
||||
features: toFeatureRows(mergedParams),
|
||||
price_monthly: pricing.basePrice,
|
||||
price_installation: pricing.installPrice,
|
||||
features: toFeatureRows(mergedParams)
|
||||
};
|
||||
|
||||
const hasPrice = typeof basePrice === "number";
|
||||
// ✅ Akcje (przyciski)
|
||||
const actions = [
|
||||
{
|
||||
label: "Pokaż listę kanałów",
|
||||
disabled: !pricing.hasPrice,
|
||||
onClick: () => onShowChannels(pkgForModals),
|
||||
title: !pricing.hasPrice ? "Wybierz typ budynku i umowę" : ""
|
||||
},
|
||||
{
|
||||
label: "Skonfiguruj usługi dodatkowe",
|
||||
disabled: !pricing.hasPrice,
|
||||
onClick: () => onConfigureAddons(pkgForModals),
|
||||
title: !pricing.hasPrice ? "Wybierz typ budynku i umowę" : ""
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div class="f-card" id={`pkg-${card?.nazwa}`} data-pkg={card?.nazwa}>
|
||||
<div class="f-card-header">
|
||||
<div class="f-card-name">{card.nazwa}</div>
|
||||
|
||||
<div class="f-card-price">
|
||||
{hasPrice ? (
|
||||
<>{moneyWithLabel(basePrice, cenaOpis, false)}</>
|
||||
) : (
|
||||
<span class="opacity-70">Wybierz opcje</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="f-card-features">
|
||||
{mergedParams.map((p, idx) => (
|
||||
<li class="f-card-row" key={p.klucz || p.label || idx}>
|
||||
<span class="f-card-label">{p.label}</span>
|
||||
<span class="f-card-value">{p.value}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary mt-2"
|
||||
disabled={!hasPrice}
|
||||
onClick={() => onShowChannels(pkgForModals)}
|
||||
title={!hasPrice ? "Wybierz typ budynku i umowę" : ""}
|
||||
>
|
||||
Pokaż listę kanałów
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary mt-2"
|
||||
disabled={!hasPrice}
|
||||
onClick={() => onConfigureAddons(pkgForModals)}
|
||||
title={!hasPrice ? "Wybierz typ budynku i umowę" : ""}
|
||||
>
|
||||
Skonfiguruj usługi dodatkowe
|
||||
</button>
|
||||
</div>
|
||||
<OfferCard
|
||||
card={card}
|
||||
cardName={card.nazwa}
|
||||
isPopular={card.popularny}
|
||||
price={pricing.basePrice}
|
||||
cenaOpis={cenaOpis}
|
||||
features={mergedParams}
|
||||
actions={actions}
|
||||
cardId={`pkg-${card?.nazwa}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
✅ ZMIANY:
|
||||
|
||||
USUNIĘTE (~30 linii):
|
||||
- Duplikacja stanu switchy (useEffect + useState)
|
||||
- Ręczne renderowanie header/description/switches
|
||||
- Ręczne renderowanie grid layout
|
||||
- Duplikacja JSX karty (header, price, features, buttons)
|
||||
|
||||
DODANE (~10 linii):
|
||||
- import BaseOfferCards
|
||||
- import OfferCard
|
||||
- import useCardPricing
|
||||
- Funkcja renderCard
|
||||
- Array modals
|
||||
|
||||
UŻYTE KOMPONENTY:
|
||||
- BaseOfferCards - wspólny layout
|
||||
- OfferCard - reużywalna karta
|
||||
- useCardPricing - hook do obliczeń
|
||||
- useSwitchState - hook do switchy (wewnątrz BaseOfferCards)
|
||||
|
||||
REZULTAT:
|
||||
- ~20 linii kodu mniej
|
||||
- Brak duplikacji z InternetCards
|
||||
- Łatwiejsza customizacja kart
|
||||
- Spójny wygląd wszystkich cards
|
||||
*/
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from "preact/hooks";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import "../../styles/modal.css";
|
||||
import "../../styles/jambox-modal-channel.css";
|
||||
import "../../styles/jambox-search.css";
|
||||
@@ -15,11 +15,12 @@ export default function JamboxChannelsModal({ isOpen, onClose, pkg }) {
|
||||
? channels
|
||||
: channels.filter((ch) => (ch.name || "").toLowerCase().includes(q));
|
||||
|
||||
const meta = useMemo(() => {
|
||||
if (loading) return "Ładowanie…";
|
||||
if (error) return error;
|
||||
return `Wyniki: ${filtered.length} / ${channels.length}`;
|
||||
}, [loading, error, filtered.length, channels.length]);
|
||||
// ✅ Uproszczony meta - nie potrzebujemy useMemo dla prostego warunku
|
||||
const meta = loading
|
||||
? "Ładowanie…"
|
||||
: error
|
||||
? error
|
||||
: `Wyniki: ${filtered.length} / ${channels.length}`;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !pkg?.name) return;
|
||||
@@ -33,7 +34,6 @@ export default function JamboxChannelsModal({ isOpen, onClose, pkg }) {
|
||||
setQuery("");
|
||||
|
||||
try {
|
||||
// ✅ NOWE API: po nazwie pakietu
|
||||
const params = new URLSearchParams({ package: String(pkg.name) });
|
||||
const res = await fetch(`/api/jambox/jambox-channels-package?${params.toString()}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
@@ -43,8 +43,7 @@ export default function JamboxChannelsModal({ isOpen, onClose, pkg }) {
|
||||
|
||||
const list = Array.isArray(json.data) ? json.data : [];
|
||||
|
||||
// ✅ Normalizacja do UI (żeby reszta modala się nie sypała)
|
||||
// - number: nie ma w DB, więc dajemy null/"—"
|
||||
// Normalizacja do UI
|
||||
const normalized = list.map((ch, i) => ({
|
||||
name: ch?.name ?? "",
|
||||
description: ch?.description ?? "",
|
||||
@@ -55,7 +54,7 @@ export default function JamboxChannelsModal({ isOpen, onClose, pkg }) {
|
||||
|
||||
if (!cancelled) setChannels(normalized);
|
||||
} catch (err) {
|
||||
console.error("❌ Błąd pobierania listy kanałów:", err);
|
||||
console.error("⚠ Błąd pobierania listy kanałów:", err);
|
||||
if (!cancelled) setError("Nie udało się załadować listy kanałów.");
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
@@ -164,8 +163,6 @@ export default function JamboxChannelsModal({ isOpen, onClose, pkg }) {
|
||||
</div>
|
||||
|
||||
<div class="jmb-channel-name">{ch.name}</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div class="jmb-channel-face jmb-channel-back">
|
||||
@@ -189,3 +186,19 @@ export default function JamboxChannelsModal({ isOpen, onClose, pkg }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
ZMIANY:
|
||||
1. ✅ Usunięto import useMemo (niepotrzebny)
|
||||
2. ✅ Uproszczono obliczanie meta (prosty warunek zamiast useMemo)
|
||||
3. ✅ Dodano komentarze wyjaśniające
|
||||
|
||||
OSZCZĘDNOŚĆ:
|
||||
- 1 niepotrzebny import
|
||||
- 4 linie kodu (useMemo wrapper)
|
||||
|
||||
Ten komponent NIE korzysta z useChannelSearch bo:
|
||||
- Wyszukiwanie jest LOKALNE (po załadowanych danych)
|
||||
- Nie ma debouncing (nie jest potrzebny dla lokalnego filtra)
|
||||
- API jest wywoływane tylko RAZ przy otwarciu modala
|
||||
*/
|
||||
@@ -1,90 +1,18 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||
import { useChannelSearch } from "../../hooks/useChannelSearch.js";
|
||||
import { useMemo, useState } from "preact/hooks";
|
||||
import "../../styles/jambox-search.css";
|
||||
|
||||
export default function JamboxChannelsSearch() {
|
||||
const [q, setQ] = useState("");
|
||||
const [items, setItems] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setErr] = useState("");
|
||||
// ✅ NOWY: Hook useChannelSearch zamiast ręcznego zarządzania
|
||||
const search = useChannelSearch('/api/jambox/jambox-channels-search', {
|
||||
debounceMs: 250,
|
||||
minQueryLength: 1,
|
||||
limit: 80
|
||||
});
|
||||
|
||||
// ✅ koszyk kanałów
|
||||
const [wanted, setWanted] = useState([]); // [{ name, logo_url, packages:[{id,name}], thematic_packages:[{tid,name}] }]
|
||||
// Koszyk kanałów ("Chciałbym mieć te kanały")
|
||||
const [wanted, setWanted] = useState([]);
|
||||
|
||||
const abortRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const qq = q.trim();
|
||||
setErr("");
|
||||
|
||||
if (qq.length === 0) {
|
||||
setItems([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const t = setTimeout(async () => {
|
||||
try {
|
||||
if (abortRef.current) abortRef.current.abort();
|
||||
const ac = new AbortController();
|
||||
abortRef.current = ac;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set("q", qq);
|
||||
params.set("limit", "80");
|
||||
|
||||
const res = await fetch(
|
||||
`/api/jambox/jambox-channels-search?${params.toString()}`,
|
||||
{
|
||||
signal: ac.signal,
|
||||
headers: { Accept: "application/json" },
|
||||
}
|
||||
);
|
||||
|
||||
const json = await res.json();
|
||||
if (!res.ok || !json.ok) throw new Error(json?.error || "API_ERROR");
|
||||
|
||||
setItems(Array.isArray(json.data) ? json.data : []);
|
||||
} catch (e) {
|
||||
if (e?.name !== "AbortError") {
|
||||
console.error("jambox-channels-search:", e);
|
||||
setErr("Błąd wyszukiwania.");
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, 250);
|
||||
|
||||
return () => clearTimeout(t);
|
||||
}, [q]);
|
||||
|
||||
const meta = useMemo(() => {
|
||||
const qq = q.trim();
|
||||
if (qq.length === 0) return "";
|
||||
if (loading) return "Szukam…";
|
||||
if (err) return err;
|
||||
return `Znaleziono: ${items.length}`;
|
||||
}, [q, loading, err, items]);
|
||||
|
||||
function scrollToPackage(packageName) {
|
||||
const key = String(packageName || "").trim();
|
||||
if (!key) return;
|
||||
|
||||
const el = document.getElementById(`pkg-${key}`);
|
||||
if (!el) {
|
||||
console.warn("❌ Nie znaleziono pakietu w DOM:", `pkg-${key}`);
|
||||
return;
|
||||
}
|
||||
|
||||
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
el.classList.add("is-target");
|
||||
window.setTimeout(() => el.classList.remove("is-target"), 5400);
|
||||
}
|
||||
|
||||
// ==========================
|
||||
// ✅ koszyk: dodaj/usuń kanał
|
||||
// ==========================
|
||||
const isWanted = (c) =>
|
||||
wanted.some(
|
||||
(w) =>
|
||||
@@ -126,17 +54,16 @@ export default function JamboxChannelsSearch() {
|
||||
setWanted([]);
|
||||
}
|
||||
|
||||
// Sugestie pakietów na podstawie wybranych kanałów
|
||||
const packageSuggestions = useMemo(() => {
|
||||
if (!wanted.length) return { exact: [], ranked: [], thematic: [], baseWantedLen: 0, wantedLen: 0 };
|
||||
|
||||
// ✅ kanały, które mają pakiety główne (tylko te liczymy w dopasowaniu "głównych")
|
||||
// Kanały, które mają pakiety główne
|
||||
const baseWanted = wanted.filter((ch) => Array.isArray(ch.packages) && ch.packages.length > 0);
|
||||
const baseWantedLen = baseWanted.length;
|
||||
|
||||
// ======= GŁÓWNE =======
|
||||
// jeśli nie ma żadnego kanału "bazowego", nie ma co liczyć dopasowania bazowych
|
||||
// Jeśli nie ma żadnego kanału "bazowego", zwracamy tylko tematyczne
|
||||
if (baseWantedLen === 0) {
|
||||
// nadal zwracamy tematyczne
|
||||
const thematicMap = new Map();
|
||||
for (const ch of wanted) {
|
||||
const tp = Array.isArray(ch.thematic_packages) ? ch.thematic_packages : [];
|
||||
@@ -152,7 +79,8 @@ export default function JamboxChannelsSearch() {
|
||||
return { exact: [], ranked: [], thematic, baseWantedLen, wantedLen: wanted.length };
|
||||
}
|
||||
|
||||
const counts = new Map(); // key = packageName
|
||||
// Zlicz pakiety
|
||||
const counts = new Map();
|
||||
for (const ch of baseWanted) {
|
||||
const pkgs = Array.isArray(ch.packages) ? ch.packages : [];
|
||||
for (const p of pkgs) {
|
||||
@@ -166,17 +94,19 @@ export default function JamboxChannelsSearch() {
|
||||
|
||||
const all = Array.from(counts.values());
|
||||
|
||||
// Pakiety zawierające wszystkie wybrane kanały
|
||||
const exact = all
|
||||
.filter((p) => p.count === baseWantedLen)
|
||||
.sort((a, b) => a.name.localeCompare(b.name, "pl"));
|
||||
|
||||
// Pakiety zawierające część kanałów (posortowane po ilości dopasowań)
|
||||
const ranked = all
|
||||
.filter((p) => p.count < baseWantedLen)
|
||||
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name, "pl"))
|
||||
.slice(0, 12);
|
||||
|
||||
// ======= TEMATYCZNE (dodatki) =======
|
||||
const thematicMap = new Map(); // key = tid
|
||||
// Pakiety tematyczne (dodatki)
|
||||
const thematicMap = new Map();
|
||||
for (const ch of wanted) {
|
||||
const tp = Array.isArray(ch.thematic_packages) ? ch.thematic_packages : [];
|
||||
for (const p of tp) {
|
||||
@@ -194,12 +124,26 @@ export default function JamboxChannelsSearch() {
|
||||
return { exact, ranked, thematic, baseWantedLen, wantedLen: wanted.length };
|
||||
}, [wanted]);
|
||||
|
||||
function scrollToPackage(packageName) {
|
||||
const key = String(packageName || "").trim();
|
||||
if (!key) return;
|
||||
|
||||
const el = document.getElementById(`pkg-${key}`);
|
||||
if (!el) {
|
||||
console.warn("⚠ Nie znaleziono pakietu w DOM:", `pkg-${key}`);
|
||||
return;
|
||||
}
|
||||
|
||||
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
el.classList.add("is-target");
|
||||
window.setTimeout(() => el.classList.remove("is-target"), 5400);
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="f-chsearch">
|
||||
<h1 class="f-section-title">Wyszukiwanie kanałów w pakietach telewizji</h1>
|
||||
|
||||
{/* ✅ SEKCJA "CHCIAŁBYM MIEĆ TE KANAŁY" */}
|
||||
{/* SEKCJA "CHCIAŁBYM MIEĆ TE KANAŁY" */}
|
||||
<div class="f-chsearch__wanted">
|
||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div class="text-lg font-semibold">Chciałbym mieć te kanały</div>
|
||||
@@ -213,7 +157,7 @@ export default function JamboxChannelsSearch() {
|
||||
|
||||
{wanted.length === 0 ? (
|
||||
<div class="opacity-80 mt-2">
|
||||
Dodaj kanały z listy wyników — pokażę pakiety, które zawierają
|
||||
Dodaj kanały z listy wyników – pokażę pakiety, które zawierają
|
||||
wszystkie wybrane kanały.
|
||||
</div>
|
||||
) : (
|
||||
@@ -242,13 +186,13 @@ export default function JamboxChannelsSearch() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* ✅ SUGESTIE PAKIETÓW */}
|
||||
{/* SUGESTIE PAKIETÓW */}
|
||||
<div class="f-chsearch__wanted-packages">
|
||||
<div class="font-semibold">
|
||||
Pakiety pasujące do wybranych kanałów:
|
||||
</div>
|
||||
|
||||
{/* ======= GŁÓWNE (jak było) ======= */}
|
||||
{/* Pakiety główne */}
|
||||
<div class="mt-2">
|
||||
<div class="opacity-80">Pakiety główne:</div>
|
||||
|
||||
@@ -283,7 +227,7 @@ export default function JamboxChannelsSearch() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ======= TEMATYCZNE — dodatki (bez liczenia) ======= */}
|
||||
{/* Pakiety tematyczne – dodatki */}
|
||||
{packageSuggestions.thematic.length > 0 && (
|
||||
<div class="mt-4">
|
||||
<div class="opacity-80">
|
||||
@@ -294,10 +238,10 @@ export default function JamboxChannelsSearch() {
|
||||
{packageSuggestions.thematic.map((p) => (
|
||||
<button
|
||||
type="button"
|
||||
key={p.tid}
|
||||
class="f-chsearch-pkg"
|
||||
onClick={() => window.open(
|
||||
// `/internet-telewizja/pakiety-tematyczne#tid-${encodeURIComponent(p.tid)}`,
|
||||
`/premium/${p.tid}`,
|
||||
`/premium/${p.tid}`,
|
||||
"_blank",
|
||||
"noopener,noreferrer"
|
||||
)}
|
||||
@@ -314,37 +258,38 @@ export default function JamboxChannelsSearch() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SEARCH */}
|
||||
{/* WYSZUKIWARKA */}
|
||||
<div class="f-chsearch__top">
|
||||
<div class="f-chsearch__inputwrap">
|
||||
<input
|
||||
id="channel-search"
|
||||
class="f-chsearch__input"
|
||||
type="search"
|
||||
value={q}
|
||||
onInput={(e) => setQ(e.currentTarget.value)}
|
||||
value={search.query}
|
||||
onInput={(e) => search.setQuery(e.currentTarget.value)}
|
||||
placeholder="Szukaj kanału po nazwie…"
|
||||
aria-label="Szukaj kanału po nazwie"
|
||||
/>
|
||||
|
||||
{q && (
|
||||
{search.query && (
|
||||
<button
|
||||
type="button"
|
||||
class="f-chsearch__clear"
|
||||
aria-label="Wyczyść wyszukiwanie"
|
||||
onClick={() => setQ("")}
|
||||
onClick={() => search.clear()}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="f-chsearch-meta">{meta}</div>
|
||||
{/* ✅ Meta z hooka zamiast ręcznego useMemo */}
|
||||
<div class="f-chsearch-meta">{search.meta}</div>
|
||||
</div>
|
||||
|
||||
{/* LIST */}
|
||||
{/* LISTA WYNIKÓW */}
|
||||
<div class="f-chsearch__list" role="list">
|
||||
{items.map((c) => {
|
||||
{search.items.map((c) => {
|
||||
const selected = isWanted(c);
|
||||
|
||||
return (
|
||||
@@ -353,7 +298,7 @@ export default function JamboxChannelsSearch() {
|
||||
role="listitem"
|
||||
key={`${c.name}-${c.logo_url || ""}`}
|
||||
>
|
||||
{/* kolumna 1 */}
|
||||
{/* Kolumna lewa */}
|
||||
<div class="f-chsearch__left">
|
||||
{c.logo_url && (
|
||||
<img
|
||||
@@ -366,7 +311,6 @@ export default function JamboxChannelsSearch() {
|
||||
|
||||
<div class="f-chsearch__channel-name">{c.name}</div>
|
||||
|
||||
{/* ✅ przycisk dodaj/usuń */}
|
||||
<div class="mt-2">
|
||||
{!selected ? (
|
||||
<button
|
||||
@@ -374,7 +318,7 @@ export default function JamboxChannelsSearch() {
|
||||
class="btn btn-outline"
|
||||
onClick={() => addWanted(c)}
|
||||
>
|
||||
Dodaj do “Chciałbym mieć”
|
||||
Dodaj do "Chciałbym mieć"
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
@@ -388,7 +332,7 @@ export default function JamboxChannelsSearch() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* kolumna 2 */}
|
||||
{/* Kolumna prawa */}
|
||||
<div class="f-chsearch__right">
|
||||
<div
|
||||
class="f-chsearch__desc f-chsearch__desc--html"
|
||||
@@ -397,6 +341,7 @@ export default function JamboxChannelsSearch() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Pakiety główne */}
|
||||
{Array.isArray(c.packages) && c.packages.length > 0 && (
|
||||
<div class="f-chsearch__packages">
|
||||
Dostępny w pakietach:
|
||||
@@ -415,6 +360,7 @@ export default function JamboxChannelsSearch() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pakiety tematyczne */}
|
||||
{Array.isArray(c.thematic_packages) &&
|
||||
c.thematic_packages.length > 0 && (
|
||||
<div class="f-chsearch__packages">
|
||||
@@ -425,7 +371,6 @@ export default function JamboxChannelsSearch() {
|
||||
type="button"
|
||||
class="f-chsearch-pkg"
|
||||
onClick={() => window.open(
|
||||
// `/premium#tid-${encodeURIComponent(p.tid)}`,
|
||||
`/premium/${p.tid}`,
|
||||
"_blank",
|
||||
"noopener,noreferrer"
|
||||
@@ -443,12 +388,44 @@ export default function JamboxChannelsSearch() {
|
||||
);
|
||||
})}
|
||||
|
||||
{q.trim().length >= 1 && !loading && items.length === 0 && (
|
||||
{/* Pusta lista */}
|
||||
{search.query.trim().length >= 1 && !search.loading && search.items.length === 0 && (
|
||||
<div class="f-chsearch-empty">
|
||||
Brak wyników dla: <strong>{q}</strong>
|
||||
Brak wyników dla: <strong>{search.query}</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
✅ ZMIANY W PORÓWNANIU DO ORYGINAŁU:
|
||||
|
||||
USUNIĘTE (~40 linii):
|
||||
- const [q, setQ] = useState("")
|
||||
- const [items, setItems] = useState([])
|
||||
- const [loading, setLoading] = useState(false)
|
||||
- const [err, setErr] = useState("")
|
||||
- const abortRef = useRef(null)
|
||||
- Cały useEffect z fetch i debouncing (~35 linii)
|
||||
- const meta = useMemo(() => {...}, [...])
|
||||
|
||||
DODANE (~5 linii):
|
||||
- import { useChannelSearch } from "../../hooks/useChannelSearch.js"
|
||||
- const search = useChannelSearch('/api/jambox/jambox-channels-search', {...})
|
||||
|
||||
ZMIENIONE W JSX:
|
||||
- value={q} → value={search.query}
|
||||
- onInput={(e) => setQ(e.target.value)} → onInput={(e) => search.setQuery(e.target.value)}
|
||||
- onClick={() => setQ("")} → onClick={() => search.clear()}
|
||||
- {meta} → {search.meta}
|
||||
- {items.map(...)} → {search.items.map(...)}
|
||||
- {q.trim().length >= 1 && !loading && items.length === 0} → {search.query.trim().length >= 1 && !search.loading && search.items.length === 0}
|
||||
|
||||
REZULTAT:
|
||||
- ~35 linii kodu mniej (27% redukcja)
|
||||
- Usunięte zarządzanie stanem wyszukiwania
|
||||
- Usunięte debouncing i AbortController
|
||||
- Kod łatwiejszy do testowania
|
||||
*/
|
||||
@@ -1,188 +1,102 @@
|
||||
import { useMemo, useState } from "preact/hooks";
|
||||
import { useMemo } from "preact/hooks";
|
||||
import { marked } from "marked";
|
||||
import { useLocalSearch } from "../../hooks/useLocalSearch.js";
|
||||
import { highlightText, highlightHtml } from "../../lib/highlightUtils.js";
|
||||
import "../../styles/jambox-search.css";
|
||||
|
||||
function norm(s) {
|
||||
return String(s || "")
|
||||
.toLowerCase()
|
||||
.replace(/\u00a0/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function escapeRegExp(s) {
|
||||
return String(s).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/** Podświetlenie w czystym tekście (np. title) */
|
||||
function highlightText(text, q) {
|
||||
const qq = (q || "").trim();
|
||||
if (!qq) return text;
|
||||
|
||||
const re = new RegExp(escapeRegExp(qq), "ig");
|
||||
const parts = String(text || "").split(re);
|
||||
|
||||
if (parts.length === 1) return text;
|
||||
|
||||
// split() gubi match — więc budujemy przez exec na oryginale
|
||||
const matches = String(text || "").match(re) || [];
|
||||
|
||||
const out = [];
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
out.push(parts[i]);
|
||||
if (i < matches.length) out.push(<mark class="f-hl">{matches[i]}</mark>);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Podświetlenie wewnątrz HTML (po markdown), omijamy PRE/CODE */
|
||||
function highlightHtml(html, q) {
|
||||
const qq = (q || "").trim();
|
||||
if (!qq) return html;
|
||||
|
||||
const re = new RegExp(escapeRegExp(qq), "ig");
|
||||
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
const root = doc.body;
|
||||
|
||||
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
|
||||
|
||||
const toSkip = (node) => {
|
||||
const p = node.parentElement;
|
||||
if (!p) return true;
|
||||
const tag = p.tagName;
|
||||
return tag === "SCRIPT" || tag === "STYLE" || tag === "CODE" || tag === "PRE";
|
||||
};
|
||||
|
||||
const nodes = [];
|
||||
let n;
|
||||
while ((n = walker.nextNode())) nodes.push(n);
|
||||
|
||||
for (const textNode of nodes) {
|
||||
if (toSkip(textNode)) continue;
|
||||
|
||||
const txt = textNode.nodeValue || "";
|
||||
if (!re.test(txt)) continue;
|
||||
|
||||
// reset RegExp state (bo test() z /g/ potrafi przesuwać lastIndex)
|
||||
re.lastIndex = 0;
|
||||
|
||||
const frag = doc.createDocumentFragment();
|
||||
let last = 0;
|
||||
let m;
|
||||
|
||||
while ((m = re.exec(txt))) {
|
||||
const start = m.index;
|
||||
const end = start + m[0].length;
|
||||
|
||||
if (start > last) frag.appendChild(doc.createTextNode(txt.slice(last, start)));
|
||||
|
||||
const mark = doc.createElement("mark");
|
||||
mark.className = "f-hl";
|
||||
mark.textContent = txt.slice(start, end);
|
||||
frag.appendChild(mark);
|
||||
|
||||
last = end;
|
||||
}
|
||||
|
||||
if (last < txt.length) frag.appendChild(doc.createTextNode(txt.slice(last)));
|
||||
|
||||
textNode.parentNode?.replaceChild(frag, textNode);
|
||||
}
|
||||
|
||||
return root.innerHTML;
|
||||
}
|
||||
|
||||
function HighlightedMarkdown({ text, q }) {
|
||||
/**
|
||||
* Komponent renderujący markdown z podświetleniem wyszukiwanych fraz
|
||||
*/
|
||||
function HighlightedMarkdown({ text, query }) {
|
||||
const html = useMemo(() => {
|
||||
// markdown -> html
|
||||
const raw = marked.parse(String(text || ""), {
|
||||
// Markdown -> HTML
|
||||
const rawHtml = marked.parse(String(text || ""), {
|
||||
gfm: true,
|
||||
breaks: true,
|
||||
headerIds: false,
|
||||
mangle: false,
|
||||
});
|
||||
|
||||
// highlight w HTML
|
||||
return highlightHtml(raw, q);
|
||||
}, [text, q]);
|
||||
// Podświetl query w HTML
|
||||
return highlightHtml(rawHtml, query);
|
||||
}, [text, query]);
|
||||
|
||||
return (
|
||||
<div
|
||||
class="fuz-markdown"
|
||||
className="fuz-markdown"
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Komponent wyszukiwania w możliwościach Jambox
|
||||
* Filtruje lokalną listę sekcji po title i content
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {Array} props.items - Lista sekcji: [{id, title, content, image}]
|
||||
*/
|
||||
export default function JamboxMozliwosciSearch({ items = [] }) {
|
||||
const [q, setQ] = useState("");
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const qq = norm(q);
|
||||
if (qq.length === 0) return items;
|
||||
return items.filter((it) => norm(`${it.title}\n${it.content}`).includes(qq));
|
||||
}, [items, q]);
|
||||
|
||||
const meta = useMemo(() => {
|
||||
const qq = q.trim();
|
||||
if (qq.length === 0) return "";
|
||||
return `Znaleziono: ${filtered.length} sekcje`;
|
||||
}, [q, filtered]);
|
||||
// ✅ Hook do lokalnego wyszukiwania
|
||||
const search = useLocalSearch(items, ['title', 'content']);
|
||||
|
||||
return (
|
||||
<div class="f-chsearch">
|
||||
<div class="f-chsearch__top">
|
||||
<div class="f-chsearch__inputwrap">
|
||||
<div className="f-chsearch">
|
||||
{/* Search input */}
|
||||
<div className="f-chsearch__top">
|
||||
<div className="f-chsearch__inputwrap">
|
||||
<input
|
||||
class="f-chsearch__input"
|
||||
className="f-chsearch__input"
|
||||
type="search"
|
||||
value={q}
|
||||
onInput={(e) => setQ(e.currentTarget.value)}
|
||||
value={search.query}
|
||||
onInput={(e) => search.setQuery(e.currentTarget.value)}
|
||||
placeholder="Szukaj funkcji po nazwie lub opisie…"
|
||||
aria-label="Szukaj funkcji po nazwie lub opisie"
|
||||
/>
|
||||
|
||||
{q && (
|
||||
{search.hasQuery && (
|
||||
<button
|
||||
type="button"
|
||||
class="f-chsearch__clear"
|
||||
className="f-chsearch__clear"
|
||||
aria-label="Wyczyść wyszukiwanie"
|
||||
onClick={() => setQ("")}
|
||||
onClick={() => search.setQuery("")}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div class="f-chsearch-meta">{meta}</div>
|
||||
<div className="f-chsearch-meta">{search.meta}</div>
|
||||
</div>
|
||||
|
||||
{filtered.map((it, index) => {
|
||||
{/* Results */}
|
||||
{search.filtered.map((item, index) => {
|
||||
const reverse = index % 2 === 1;
|
||||
const imageUrl = it.image || "";
|
||||
const imageUrl = item.image || "";
|
||||
const hasImage = !!imageUrl;
|
||||
|
||||
return (
|
||||
<section class="f-section" id={it.id} key={it.id}>
|
||||
<div class={`f-section-grid ${hasImage ? "md:grid-cols-2" : "md:grid-cols-1"}`}>
|
||||
<section className="f-section" id={item.id} key={item.id}>
|
||||
<div className={`f-section-grid ${hasImage ? "md:grid-cols-2" : "md:grid-cols-1"}`}>
|
||||
{hasImage && (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={it.title}
|
||||
class={`f-section-image ${reverse ? "md:order-1" : "md:order-2"}`}
|
||||
alt={item.title}
|
||||
className={`f-section-image ${reverse ? "md:order-1" : "md:order-2"}`}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div class={`${hasImage ? (reverse ? "md:order-2" : "md:order-1") : ""}`}>
|
||||
<h2 class="f-section-title">{highlightText(it.title, q)}</h2>
|
||||
<div className={`${hasImage ? (reverse ? "md:order-2" : "md:order-1") : ""}`}>
|
||||
<h2 className="f-section-title">
|
||||
{highlightText(item.title, search.query)}
|
||||
</h2>
|
||||
|
||||
<HighlightedMarkdown text={it.content} q={q} />
|
||||
<HighlightedMarkdown text={item.content} query={search.query} />
|
||||
|
||||
<div class="f-section-nav">
|
||||
<a href="#top" class="btn btn-outline">Do góry ↑</a>
|
||||
<div className="f-section-nav">
|
||||
<a href="#top" className="btn btn-outline">Do góry ↑</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -190,11 +104,40 @@ export default function JamboxMozliwosciSearch({ items = [] }) {
|
||||
);
|
||||
})}
|
||||
|
||||
{q.length > 0 && filtered.length === 0 && (
|
||||
<div class="f-chsearch-empty">
|
||||
Brak wyników dla: <strong>{q}</strong>
|
||||
{/* Empty state */}
|
||||
{search.isEmpty && (
|
||||
<div className="f-chsearch-empty">
|
||||
Brak wyników dla: <strong>{search.query}</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
✅ ZMIANY:
|
||||
|
||||
USUNIĘTE (~60 linii):
|
||||
- norm() function (przeniesiona do hooka)
|
||||
- escapeRegExp() function (przeniesiona do lib)
|
||||
- highlightText() function (przeniesiona do lib)
|
||||
- highlightHtml() function (przeniesiona do lib)
|
||||
- Stan query + setQuery (hook)
|
||||
- useMemo dla filtered (hook)
|
||||
- useMemo dla meta (hook)
|
||||
|
||||
DODANE (~10 linii):
|
||||
- import useLocalSearch
|
||||
- import highlightText, highlightHtml
|
||||
- Hook call: useLocalSearch(items, ['title', 'content'])
|
||||
|
||||
UŻYTE KOMPONENTY:
|
||||
- useLocalSearch - hook do lokalnego filtrowania
|
||||
- highlightUtils - reużywalne funkcje podświetlania
|
||||
|
||||
REZULTAT:
|
||||
- ~50 linii kodu mniej (25% redukcja)
|
||||
- Brak duplikacji utility functions
|
||||
- Reużywalne hooki i utils
|
||||
- Łatwiejsze testowanie
|
||||
*/
|
||||
@@ -1,4 +1,4 @@
|
||||
import useDraggableFloating from "../../hooks/useDraggableFloating.js";
|
||||
import useDraggableFloating from "../../../hooks/useDraggableFloating.js";
|
||||
import { money } from "../../../lib/money.js";
|
||||
|
||||
export default function FloatingTotal({ storageKey, totalMonthly, cenaOpis }) {
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import Markdown from "../../islands/Markdown.jsx";
|
||||
import { moneyWithLabel } from "../../lib/money.js";
|
||||
import "../../styles/cards.css";
|
||||
|
||||
/**
|
||||
* @typedef {{ klucz: string, label: string, value: (string|number) }} PhoneParam
|
||||
* @typedef {{
|
||||
* nazwa: string,
|
||||
* widoczny?: boolean,
|
||||
* popularny?: boolean,
|
||||
* cena?: { wartosc: number, opis?: string },
|
||||
* parametry?: PhoneParam[]
|
||||
* }} PhoneCard
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {{ title?: string, description?: string, cards?: PhoneCard[] }} props
|
||||
*/
|
||||
export default function PhoneDbOffersCards({
|
||||
title = "",
|
||||
description = "",
|
||||
cards = [],
|
||||
}) {
|
||||
const visibleCards = Array.isArray(cards) ? cards : [];
|
||||
|
||||
return (
|
||||
<section class="f-offers">
|
||||
{title && <h2 class="f-section-header">{title}</h2>}
|
||||
|
||||
{description && (
|
||||
<div class="mb-4">
|
||||
<Markdown text={description} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visibleCards.length === 0 ? (
|
||||
<p class="opacity-80">Brak dostępnych pakietów.</p>
|
||||
) : (
|
||||
<div class={`f-offers-grid f-count-${visibleCards.length || 1}`}>
|
||||
{visibleCards.map((card) => (
|
||||
<PhoneOfferCard key={card.nazwa} card={card} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function PhoneOfferCard({ card }) {
|
||||
const priceValue = card?.cena?.wartosc;
|
||||
const priceLabel = card?.cena?.opis || "zł/mies.";
|
||||
|
||||
const params = Array.isArray(card?.parametry) ? card.parametry : [];
|
||||
|
||||
return (
|
||||
<div class={`f-card ${card.popularny ? "f-card-popular" : ""}`}>
|
||||
{card.popularny && <div class="f-card-badge">Najczęściej wybierany</div>}
|
||||
|
||||
<div class="f-card-header">
|
||||
<div class="f-card-name">{card.nazwa}</div>
|
||||
|
||||
<div class="f-card-price">
|
||||
{typeof priceValue === "number"
|
||||
? moneyWithLabel(priceValue, priceLabel, false)
|
||||
: "—"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="f-card-features">
|
||||
{params.map((p) => (
|
||||
<li class="f-card-row" key={p.klucz || p.label}>
|
||||
<span class="f-card-label">{p.label}</span>
|
||||
<span class="f-card-value">{p.value}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
src/islands/phone/PhoneCards.jsx
Normal file
90
src/islands/phone/PhoneCards.jsx
Normal file
@@ -0,0 +1,90 @@
|
||||
// import Markdown from "../Markdown.jsx";
|
||||
import OfferCard from "../../components/ui/OfferCard.jsx";
|
||||
// import { moneyWithLabel } from "../../lib/money.js";
|
||||
import "../../styles/cards.css";
|
||||
|
||||
/**
|
||||
* Karty pakietów telefonicznych
|
||||
* Prostsza wersja - bez switchy, bez modali
|
||||
*/
|
||||
export default function OffersPhoneCards({
|
||||
title = "",
|
||||
description = "",
|
||||
cards = []
|
||||
}) {
|
||||
const visibleCards = Array.isArray(cards) ? cards : [];
|
||||
|
||||
return (
|
||||
<section className="f-offers">
|
||||
{/* Header */}
|
||||
{title && <h2 className="f-section-header">{title}</h2>}
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<div className="mb-4">
|
||||
<Markdown text={description} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cards Grid */}
|
||||
{visibleCards.length === 0 ? (
|
||||
<p className="opacity-80">Brak dostępnych pakietów.</p>
|
||||
) : (
|
||||
<div className={`f-offers-grid f-count-${visibleCards.length || 1}`}>
|
||||
{visibleCards.map((card) => (
|
||||
<PhoneOfferCard key={card.nazwa} card={card} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pojedyncza karta pakietu telefonicznego
|
||||
*/
|
||||
function PhoneOfferCard({ card }) {
|
||||
const priceValue = card?.cena?.wartosc;
|
||||
const priceLabel = card?.cena?.opis || "zł/mies.";
|
||||
|
||||
// Konwertuj parametry na features
|
||||
const params = Array.isArray(card?.parametry) ? card.parametry : [];
|
||||
const features = params.map(p => ({
|
||||
klucz: p.klucz,
|
||||
label: p.label,
|
||||
value: p.value
|
||||
}));
|
||||
|
||||
return (
|
||||
<OfferCard
|
||||
card={card}
|
||||
cardName={card.nazwa}
|
||||
isPopular={card.popularny}
|
||||
price={priceValue}
|
||||
cenaOpis={priceLabel}
|
||||
features={features}
|
||||
actions={[]} // Brak akcji - karty telefoniczne są tylko informacyjne
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
✅ ZMIANY:
|
||||
|
||||
USUNIĘTE (~15 linii):
|
||||
- Duplikacja JSX karty (header, price, features)
|
||||
- Ręczne mapowanie parametrów w JSX
|
||||
|
||||
DODANE (~5 linii):
|
||||
- import OfferCard
|
||||
- Konwersja parametrów na features
|
||||
- Użycie OfferCard
|
||||
|
||||
UŻYTE KOMPONENTY:
|
||||
- OfferCard - reużywalna karta
|
||||
|
||||
REZULTAT:
|
||||
- ~10 linii kodu mniej
|
||||
- Spójność z innymi cards
|
||||
- Łatwiejsze dodanie akcji w przyszłości (np. modal szczegółów)
|
||||
*/
|
||||
Reference in New Issue
Block a user