Refaktoryzacja Card, Modali

This commit is contained in:
dm
2025-12-19 13:59:03 +01:00
parent f390eed402
commit eb07e520b4
25 changed files with 2020 additions and 944 deletions

View File

@@ -0,0 +1,64 @@
import { useState } from "preact/hooks";
import Markdown from "../../islands/Markdown.jsx";
import OffersSwitches from "../../islands/Switches.jsx";
import { useSwitchState } from "../../hooks/useSwitchState.js";
import "../../styles/cards.css";
/**
* Bazowy komponent dla wszystkich kart ofert (Jambox, Internet, Phone)
* Zawiera wspólną logikę: header, switches, grid layout, modale
*
* @param {Object} props
* @param {string} props.title - Tytuł sekcji
* @param {string} props.description - Opis (markdown)
* @param {Array} props.cards - Lista kart do wyświetlenia
* @param {Array} props.switches - Konfiguracja switchy
* @param {Function} props.renderCard - Funkcja renderująca pojedynczą kartę
* @param {Array} props.modals - Lista modali do wyświetlenia
*/
export default function BaseOfferCards({
title = "",
description = "",
cards = [],
switches = [],
renderCard,
modals = []
}) {
const visibleCards = Array.isArray(cards) ? cards : [];
// ✅ Wspólny hook do zarządzania stanem switchy
const { selected, labels } = useSwitchState();
return (
<section className="f-offers">
{/* Header */}
{title && <h1 className="f-section-header">{title}</h1>}
{/* Description */}
{description && (
<div className="mb-4">
<Markdown text={description} />
</div>
)}
{/* Switches */}
<OffersSwitches switches={switches} />
{/* 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) =>
renderCard(card, { selected, labels })
)}
</div>
)}
{/* Modals */}
{modals.map((Modal, idx) => (
<Modal key={idx} />
))}
</section>
);
}

View File

@@ -0,0 +1,84 @@
import { moneyWithLabel } from "../../lib/money.js";
/**
* Reużywalna karta oferty
* Wspólna dla JamboxCards, InternetCards
*
* @param {Object} props
* @param {Object} props.card - Dane karty
* @param {string} props.cardName - Nazwa karty
* @param {boolean} props.isPopular - Czy karta popularna
* @param {number|null} props.price - Cena miesięczna
* @param {string} props.cenaOpis - Opis ceny (np. "zł/mies.")
* @param {Array} props.features - Lista cech [{label, value}]
* @param {Array} props.actions - Lista akcji (przycisków)
* @param {string} props.cardId - ID dla scrollowania (opcjonalne)
*/
export default function OfferCard({
card,
cardName,
isPopular = false,
price,
cenaOpis,
features = [],
actions = [],
cardId = null
}) {
const hasPrice = typeof price === 'number';
return (
<div
className={`f-card ${isPopular ? 'f-card-popular' : ''}`}
id={cardId}
data-pkg={cardName}
>
{/* Badge popularności */}
{isPopular && (
<div className="f-card-badge">Najczęściej wybierany</div>
)}
{/* Header z nazwą i ceną */}
<div className="f-card-header">
<div className="f-card-name">{cardName}</div>
<div className="f-card-price">
{hasPrice ? (
<>{moneyWithLabel(price, cenaOpis, false)}</>
) : (
<span className="opacity-70">Wybierz opcje</span>
)}
</div>
</div>
{/* Lista cech */}
{features.length > 0 && (
<ul className="f-card-features">
{features.map((feature, idx) => (
<li className="f-card-row" key={feature.klucz || feature.label || idx}>
<span className="f-card-label">{feature.label}</span>
<span className="f-card-value">{feature.value}</span>
</li>
))}
</ul>
)}
{/* Akcje (przyciski) */}
{actions.length > 0 && (
<div className="f-card-actions">
{actions.map((action, idx) => (
<button
key={idx}
type="button"
className={action.className || "btn btn-primary mt-2"}
disabled={action.disabled}
onClick={action.onClick}
title={action.title}
>
{action.label}
</button>
))}
</div>
)}
</div>
);
}

96
src/hooks/useAccordion.js Normal file
View File

@@ -0,0 +1,96 @@
import { useState, useCallback } from 'preact/hooks';
/**
* Hook do zarządzania stanem accordionu (sekcje otwarte/zamknięte)
* Domyślnie tylko jedna sekcja może być otwarta naraz
*
* @param {Array<string>} sectionKeys - Klucze sekcji
* @param {string} defaultOpen - Domyślnie otwarta sekcja (pierwsza jeśli nie podano)
* @param {boolean} multiOpen - Czy wiele sekcji może być otwartych jednocześnie
*
* @returns {Object} - { open, toggle, isOpen, openSection, closeAll }
*/
export function useAccordion(sectionKeys, defaultOpen = null, multiOpen = false) {
const initialState = {};
for (let i = 0; i < sectionKeys.length; i++) {
const key = sectionKeys[i];
if (defaultOpen) {
initialState[key] = key === defaultOpen;
} else {
initialState[key] = i === 0; // Pierwsza sekcja otwarta
}
}
const [open, setOpen] = useState(initialState);
/**
* Toggle sekcji
*/
const toggle = useCallback((key) => {
setOpen(prev => {
if (multiOpen) {
// Tryb multi - toggle tylko tej sekcji
return { ...prev, [key]: !prev[key] };
} else {
// Tryb single - zamknij wszystkie inne
const nextOpen = !prev[key];
const allClosed = Object.fromEntries(
Object.keys(prev).map(k => [k, false])
);
return { ...allClosed, [key]: nextOpen };
}
});
}, [multiOpen]);
/**
* Sprawdź czy sekcja jest otwarta
*/
const isOpen = useCallback((key) => {
return !!open[key];
}, [open]);
/**
* Otwórz konkretną sekcję (zamyka inne w trybie single)
*/
const openSection = useCallback((key) => {
setOpen(prev => {
if (multiOpen) {
return { ...prev, [key]: true };
} else {
const allClosed = Object.fromEntries(
Object.keys(prev).map(k => [k, false])
);
return { ...allClosed, [key]: true };
}
});
}, [multiOpen]);
/**
* Zamknij wszystkie sekcje
*/
const closeAll = useCallback(() => {
setOpen(prev =>
Object.fromEntries(Object.keys(prev).map(k => [k, false]))
);
}, []);
/**
* Otwórz wszystkie sekcje (tylko w trybie multi)
*/
const openAll = useCallback(() => {
if (!multiOpen) return;
setOpen(prev =>
Object.fromEntries(Object.keys(prev).map(k => [k, true]))
);
}, [multiOpen]);
return {
open,
toggle,
isOpen,
openSection,
closeAll,
openAll
};
}

View File

@@ -0,0 +1,50 @@
import { useMemo } from 'preact/hooks';
import { money } from '../lib/money.js';
/**
* Hook do obliczania cen na podstawie wyboru switchy (budynek + umowa)
* Wspólny dla JamboxCards i InternetCards
*
* @param {Object} card - Obiekt karty z YAML
* @param {Object} selected - Stan wybranych opcji switchy
* @param {Object} labels - Etykiety wybranych opcji
* @returns {Object} - { match, basePrice, installPrice, hasPrice, dynamicParams }
*/
export function useCardPricing(card, selected, labels) {
return useMemo(() => {
const ceny = Array.isArray(card?.ceny) ? card.ceny : [];
const budynek = selected?.budynek;
const umowa = selected?.umowa;
// Znajdź dopasowanie w cenach
const match = ceny.find(
(c) => String(c?.budynek) === String(budynek) && String(c?.umowa) === String(umowa)
);
const basePrice = match?.miesiecznie;
const installPrice = match?.aktywacja;
const hasPrice = typeof basePrice === 'number';
// Parametry dynamiczne (umowa + aktywacja)
const dynamicParams = [
{
klucz: 'umowa',
label: 'Umowa',
value: labels?.umowa || '—'
},
{
klucz: 'aktywacja',
label: 'Aktywacja',
value: typeof installPrice === 'number' ? `${money(installPrice)}` : '—'
}
];
return {
match,
basePrice,
installPrice,
hasPrice,
dynamicParams
};
}, [card, selected, labels]);
}

View File

@@ -0,0 +1,135 @@
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
/**
* Hook do wyszukiwania kanałów z debouncing i zarządzaniem stanem
*
* @param {string} apiUrl - URL endpointu API
* @param {Object} options - Opcje
* @param {number} options.debounceMs - Opóźnienie debounce (domyślnie 250ms)
* @param {number} options.minQueryLength - Minimalna długość zapytania (domyślnie 1)
* @param {number} options.limit - Limit wyników (domyślnie 80)
*
* @returns {Object} - { query, setQuery, items, loading, error, meta, clear }
*/
export function useChannelSearch(apiUrl, options = {}) {
const {
debounceMs = 250,
minQueryLength = 1,
limit = 80
} = options;
const [query, setQuery] = useState('');
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const abortRef = useRef(null);
/**
* Wyczyść wyniki i zapytanie
*/
const clear = () => {
setQuery('');
setItems([]);
setError('');
setLoading(false);
};
/**
* Fetch z debouncing
*/
useEffect(() => {
const trimmedQuery = query.trim();
setError('');
// Jeśli zapytanie za krótkie - wyczyść wyniki
if (trimmedQuery.length < minQueryLength) {
setItems([]);
setLoading(false);
return;
}
const timeoutId = setTimeout(async () => {
try {
// Przerwij poprzednie zapytanie
if (abortRef.current) {
abortRef.current.abort();
}
const controller = new AbortController();
abortRef.current = controller;
setLoading(true);
// Buduj URL z parametrami
const params = new URLSearchParams();
params.set('q', trimmedQuery);
if (limit) params.set('limit', String(limit));
const url = `${apiUrl}?${params.toString()}`;
const response = await fetch(url, {
signal: controller.signal,
headers: { Accept: 'application/json' }
});
const json = await response.json();
if (!response.ok || !json.ok) {
throw new Error(json?.error || 'API_ERROR');
}
setItems(Array.isArray(json.data) ? json.data : []);
setError('');
} catch (err) {
// Ignoruj błędy AbortError (to normalne przy debounce)
if (err?.name !== 'AbortError') {
console.error('Channel search error:', err);
setError('Błąd wyszukiwania kanałów');
setItems([]);
}
} finally {
setLoading(false);
}
}, debounceMs);
// Cleanup
return () => {
clearTimeout(timeoutId);
if (abortRef.current) {
abortRef.current.abort();
}
};
}, [query, apiUrl, debounceMs, minQueryLength, limit]);
/**
* Meta informacja o stanie wyszukiwania
*/
const meta = useMemo(() => {
const trimmedQuery = query.trim();
if (trimmedQuery.length < minQueryLength) {
return '';
}
if (loading) {
return 'Szukam…';
}
if (error) {
return error;
}
return `Znaleziono: ${items.length}`;
}, [query, loading, error, items.length, minQueryLength]);
return {
query,
setQuery,
items,
loading,
error,
meta,
clear
};
}

View File

@@ -0,0 +1,89 @@
import { useMemo, useState } from 'preact/hooks';
/**
* Normalizacja tekstu do wyszukiwania
*/
function normalizeText(text) {
return String(text || '')
.toLowerCase()
.replace(/\u00a0/g, ' ') // non-breaking space
.replace(/\s+/g, ' ')
.trim();
}
/**
* Hook do lokalnego wyszukiwania w liście elementów
* Filtruje listę na podstawie query w określonych polach
*
* @param {Array} items - Lista elementów do przeszukania
* @param {string|Array} searchFields - Pole lub pola do przeszukania
* @returns {{ query: string, setQuery: Function, filtered: Array, meta: string }}
*
* @example
* // Wyszukiwanie w jednym polu:
* const { query, setQuery, filtered, meta } = useLocalSearch(items, 'title');
*
* // Wyszukiwanie w wielu polach:
* const search = useLocalSearch(items, ['title', 'content', 'description']);
*
* return (
* <div>
* <input value={search.query} onInput={(e) => search.setQuery(e.target.value)} />
* <div>{search.meta}</div>
* {search.filtered.map(item => <div>{item.title}</div>)}
* </div>
* );
*/
export function useLocalSearch(items = [], searchFields = []) {
const [query, setQuery] = useState('');
// Normalizuj pola do przeszukania (string -> array)
const fields = useMemo(() => {
if (typeof searchFields === 'string') return [searchFields];
if (Array.isArray(searchFields)) return searchFields;
return [];
}, [searchFields]);
// Filtruj listę
const filtered = useMemo(() => {
const normalizedQuery = normalizeText(query);
// Puste query - zwróć wszystko
if (normalizedQuery.length === 0) {
return items;
}
// Filtruj po wskazanych polach
return items.filter((item) => {
// Zbuduj tekst do przeszukania z wszystkich pól
const searchText = fields
.map((field) => {
const value = item[field];
return value != null ? String(value) : '';
})
.join('\n');
return normalizeText(searchText).includes(normalizedQuery);
});
}, [items, query, fields]);
// Meta info (ile znaleziono)
const meta = useMemo(() => {
const trimmedQuery = query.trim();
if (trimmedQuery.length === 0) return '';
const count = filtered.length;
const unit = count === 1 ? 'wynik' : count < 5 ? 'wyniki' : 'wyników';
return `Znaleziono: ${count} ${unit}`;
}, [query, filtered.length]);
return {
query,
setQuery,
filtered,
meta,
isEmpty: filtered.length === 0 && query.trim().length > 0,
hasQuery: query.trim().length > 0
};
}

View File

@@ -0,0 +1,79 @@
import { useEffect, useState } from 'preact/hooks';
/**
* Hook do pobierania listy kanałów dla konkretnego pakietu
* Używany w AddonChannelsModal (grid kanałów w sekcji dodatków)
*
* @param {string} packageName - Nazwa pakietu
* @returns {{ channels: Array, loading: boolean, error: string, reload: Function }}
*
* @example
* const { channels, loading, error } = usePackageChannels('SPORT MAX');
*
* if (loading) return <div>Ładowanie...</div>;
* if (error) return <div>Błąd: {error}</div>;
* return <ChannelsGrid channels={channels} />;
*/
export function usePackageChannels(packageName) {
const [channels, setChannels] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const cleanName = String(packageName || '').trim();
async function load() {
if (!cleanName) {
setChannels([]);
setLoading(false);
setError('');
return;
}
if (cleanName.length > 64) {
setChannels([]);
setLoading(false);
setError('Nazwa pakietu zbyt długa');
return;
}
setLoading(true);
setError('');
try {
const url = `/api/jambox/jambox-channels-package?package=${encodeURIComponent(cleanName)}`;
const res = await fetch(url);
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
const json = await res.json().catch(() => null);
if (!json?.ok) {
throw new Error(json?.error || 'API_ERROR');
}
const data = Array.isArray(json.data) ? json.data : [];
setChannels(data);
setError('');
} catch (err) {
console.error('usePackageChannels error:', err);
setError(String(err?.message || err));
setChannels([]);
} finally {
setLoading(false);
}
}
useEffect(() => {
load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cleanName]);
return {
channels,
loading,
error,
reload: load
};
}

74
src/hooks/usePricing.js Normal file
View File

@@ -0,0 +1,74 @@
import { useMemo } from 'preact/hooks';
import { PricingCalculator } from '../lib/pricing-calculator.js';
/**
* Hook do reaktywnego zarządzania obliczeniami cenowymi
*
* @param {Object} config - Konfiguracja
* @param {Object} config.pkg - Pakiet bazowy
* @param {string} config.cenaOpis - Opis ceny (np. "zł/mies.")
* @param {Object|null} config.phone - Wybrany telefon
* @param {Object|null} config.decoder - Wybrany dekoder
* @param {Array} config.tvAddonsList - Lista dodatków TV
* @param {Object} config.selectedQty - Ilości wybranych dodatków
* @param {Object} config.tvTerms - Terminy dla dodatków TV
* @param {Array} config.addonsList - Lista dodatków
*
* @returns {Object} Obiekt z cenami i kalkulatorem
*/
export function usePricing({
pkg,
cenaOpis = 'zł/mies.',
phone = null,
decoder = null,
tvAddonsList = [],
selectedQty = {},
tvTerms = {},
addonsList = []
}) {
const calculator = useMemo(() => {
const basePrice = Number(pkg?.price_monthly || 0);
const calc = new PricingCalculator(basePrice, cenaOpis);
calc.setPackage(pkg);
if (phone) {
calc.setPhone(phone);
}
if (decoder) {
calc.setDecoder(decoder);
}
if (tvAddonsList.length > 0) {
calc.setTvAddons(tvAddonsList, selectedQty, tvTerms);
}
if (addonsList.length > 0) {
calc.setAddons(addonsList, selectedQty);
}
return calc;
}, [
pkg,
cenaOpis,
phone,
decoder,
tvAddonsList,
selectedQty,
tvTerms,
addonsList
]);
return {
calculator,
basePrice: calculator.basePrice,
phonePrice: calculator.getPhonePrice(),
decoderPrice: calculator.getDecoderPrice(),
tvAddonsPrice: calculator.getTvAddonsPrice(),
addonsPrice: calculator.getAddonsPrice(),
totalMonthly: calculator.getTotalMonthly(),
summaryRows: calculator.getSummaryRows(),
payload: calculator.toPayload()
};
}

View File

@@ -0,0 +1,36 @@
import { useEffect, useState } from 'preact/hooks';
/**
* Hook do synchronizacji stanu switchy z globalnym window.fuzSwitchState
* Używany przez wszystkie komponenty cards (Jambox, Internet, Phone)
*
* @returns {{ selected: Object, labels: Object }} - Stan switchy
*/
export function useSwitchState() {
const [selected, setSelected] = useState({});
const [labels, setLabels] = useState({});
useEffect(() => {
// Inicjalizacja z globalnego stanu (jeśli istnieje)
if (typeof window !== 'undefined' && window.fuzSwitchState) {
const { selected: sel, labels: labs } = window.fuzSwitchState;
if (sel) setSelected(sel);
if (labs) setLabels(labs);
}
// Nasłuchiwanie na zmiany switchy
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 { selected, labels };
}

View File

@@ -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)
*/

View File

@@ -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)}` : "—",
value: typeof match?.aktywacja === "number" ? `${money(match.aktywacja)}` : "—"
});
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)}` : "—",
},
)
];
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
*/

View File

@@ -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
*/

View File

@@ -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
*/

View File

@@ -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)}` : "—",
},
];
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
*/

View File

@@ -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
*/

View File

@@ -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:&nbsp;
@@ -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
*/

View File

@@ -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
*/

View File

@@ -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 }) {

View File

@@ -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>
);
}

View 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 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)
*/

134
src/lib/highlightUtils.js Normal file
View File

@@ -0,0 +1,134 @@
/**
* Utility functions do podświetlania wyszukiwanych fraz
* Używane przez komponenty search
*/
/**
* Escape RegExp special characters
*/
export function escapeRegExp(str) {
return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Podświetlenie w czystym tekście (np. title)
*
* @param {string} text - Tekst do podświetlenia
* @param {string} query - Fraza do wyszukania
* @returns {string|Array} - Tekst lub array z elementami JSX
*
* @example
* highlightText("Hello World", "world")
* // => ["Hello ", <mark class="f-hl">World</mark>]
*/
export function highlightText(text, query) {
const trimmedQuery = (query || '').trim();
if (!trimmedQuery) return text;
const re = new RegExp(escapeRegExp(trimmedQuery), 'ig');
const parts = String(text || '').split(re);
if (parts.length === 1) return text;
// split() gubi match więc budujemy przez exec/match
const matches = String(text || '').match(re) || [];
const result = [];
for (let i = 0; i < parts.length; i++) {
result.push(parts[i]);
if (i < matches.length) {
result.push(<mark class="f-hl">{matches[i]}</mark>);
}
}
return result;
}
/**
* Podświetlenie wewnątrz HTML (po markdown)
* Omija tagi PRE, CODE, SCRIPT, STYLE
*
* @param {string} html - HTML do podświetlenia
* @param {string} query - Fraza do wyszukania
* @returns {string} - HTML z podświetleniem
*
* @example
* const html = "<p>Hello World</p>";
* highlightHtml(html, "world")
* // => "<p>Hello <mark class="f-hl">World</mark></p>"
*/
export function highlightHtml(html, query) {
const trimmedQuery = (query || '').trim();
if (!trimmedQuery) return html;
const re = new RegExp(escapeRegExp(trimmedQuery), 'ig');
const doc = new DOMParser().parseFromString(html, 'text/html');
const root = doc.body;
const walker = doc.createTreeWalker(root, NodeFilter.SHOW_TEXT, null);
// Funkcja sprawdzająca czy node jest w tagu do pominięcia
const shouldSkipNode = (node) => {
const parent = node.parentElement;
if (!parent) return true;
const tagName = parent.tagName;
return tagName === 'SCRIPT' ||
tagName === 'STYLE' ||
tagName === 'CODE' ||
tagName === 'PRE';
};
// Zbierz wszystkie text nodes
const textNodes = [];
let node;
while ((node = walker.nextNode())) {
textNodes.push(node);
}
// Podświetl każdy text node
for (const textNode of textNodes) {
if (shouldSkipNode(textNode)) continue;
const text = textNode.nodeValue || '';
if (!re.test(text)) continue;
// Reset RegExp state (test() z /g/ przesuwa lastIndex)
re.lastIndex = 0;
const fragment = doc.createDocumentFragment();
let lastIndex = 0;
let match;
while ((match = re.exec(text))) {
const matchStart = match.index;
const matchEnd = matchStart + match[0].length;
// Tekst przed match
if (matchStart > lastIndex) {
fragment.appendChild(
doc.createTextNode(text.slice(lastIndex, matchStart))
);
}
// Match w <mark>
const mark = doc.createElement('mark');
mark.className = 'f-hl';
mark.textContent = text.slice(matchStart, matchEnd);
fragment.appendChild(mark);
lastIndex = matchEnd;
}
// Tekst po ostatnim match
if (lastIndex < text.length) {
fragment.appendChild(doc.createTextNode(text.slice(lastIndex)));
}
// Zastąp text node fragmentem
textNode.parentNode?.replaceChild(fragment, textNode);
}
return root.innerHTML;
}

View File

@@ -0,0 +1,356 @@
import { getAddonUnitPrice } from './offer-pricing.js';
/**
* Centralna klasa do zarządzania obliczeniami cenowymi w modałach konfiguracji
*
* ELASTYCZNOŚĆ:
* Klasa obsługuje różne typy ofert (Internet, TV, Telefon) dynamicznie:
*
* Internet:
* - basePrice (pakiet) ✅
* - phone (opcjonalnie) ✅
* - addons (dodatkowe usługi) ✅
* - decoder ❌ (nie używany)
* - tvAddons ❌ (nie używany)
*
* TV (Jambox):
* - basePrice (pakiet) ✅
* - phone (opcjonalnie) ✅
* - decoder (wymagany) ✅
* - tvAddons (pakiety premium) ✅
* - addons (dodatkowe usługi) ✅
*
* Telefon:
* - basePrice (pakiet) ✅
* - phone/decoder/addons ❌ (nie używane)
*
* WAŻNE:
* getSummaryRows() i toPayload() dynamicznie budują output
* tylko z pól które są faktycznie ustawione (nie null/undefined/empty).
* Dzięki temu Internet nie zwraca decoder/tv, a Phone nie zwraca addons.
*/
export class PricingCalculator {
constructor(basePrice = 0, cenaOpis = 'zł/mies.') {
this.basePrice = Number(basePrice);
this.cenaOpis = cenaOpis;
// State
this.pkg = null;
this.phone = null;
this.decoder = null;
this.tvAddons = []; // [{id, name, qty, term, unitPrice, total}]
this.addons = []; // [{id, name, qty, unitPrice, total}]
}
/**
* Ustaw pakiet bazowy
*/
setPackage(pkg) {
this.pkg = pkg;
this.basePrice = Number(pkg?.price_monthly || 0);
return this;
}
/**
* Ustaw telefon
* @param {Object|null} phone - Obiekt telefonu z normalizePhones lub null
*/
setPhone(phone) {
this.phone = phone ? {
id: phone.id,
name: phone.name,
price: Number(phone.price_monthly || 0)
} : null;
return this;
}
/**
* Ustaw dekoder (tylko dla TV)
* @param {Object|null} decoder - Obiekt dekodera z normalizeDecoders lub null
*/
setDecoder(decoder) {
this.decoder = decoder ? {
id: decoder.id,
name: decoder.nazwa,
price: Number(decoder.cena || 0),
description: decoder.opis || ''
} : null;
return this;
}
/**
* Dodaj/zaktualizuj dodatki TV (tylko dla TV)
* @param {Array} addonsList - Lista dodatków TV z normalizeAddons
* @param {Object} selectedQty - Stan ilości: {addonId: qty}
* @param {Object} tvTerms - Stan terminów: {addonId: "12m"|"bezterminowo"}
*/
setTvAddons(addonsList, selectedQty, tvTerms = {}) {
this.tvAddons = addonsList
.map(addon => {
const qty = Number(selectedQty[addon.id] || 0);
if (qty <= 0) return null;
const term = tvTerms[addon.id] || '12m';
const unitPrice = getAddonUnitPrice(addon, this.pkg, term);
return {
id: addon.id,
name: addon.nazwa,
qty,
term,
unitPrice,
total: qty * unitPrice
};
})
.filter(Boolean);
return this;
}
/**
* Dodaj/zaktualizuj dodatki (nie-TV) - dla Internet i TV
* @param {Array} addonsList - Lista dodatków z normalizeAddons
* @param {Object} selectedQty - Stan ilości: {addonId: qty}
*/
setAddons(addonsList, selectedQty) {
this.addons = addonsList
.map(addon => {
const qty = Number(selectedQty[addon.id] || 0);
if (qty <= 0) return null;
const unitPrice = getAddonUnitPrice(addon, this.pkg, null);
return {
id: addon.id,
name: addon.nazwa,
qty,
unitPrice,
total: qty * unitPrice
};
})
.filter(Boolean);
return this;
}
/**
* Pobierz cenę telefonu
*/
getPhonePrice() {
return this.phone?.price || 0;
}
/**
* Pobierz cenę dekodera
*/
getDecoderPrice() {
return this.decoder?.price || 0;
}
/**
* Pobierz sumę dodatków TV
*/
getTvAddonsPrice() {
return this.tvAddons.reduce((sum, item) => sum + item.total, 0);
}
/**
* Pobierz sumę dodatków (nie-TV)
*/
getAddonsPrice() {
return this.addons.reduce((sum, item) => sum + item.total, 0);
}
/**
* Pobierz całkowitą cenę miesięczną
*/
getTotalMonthly() {
return (
this.basePrice +
this.getPhonePrice() +
this.getDecoderPrice() +
this.getTvAddonsPrice() +
this.getAddonsPrice()
);
}
/**
* Pobierz wiersze do sekcji Summary
*
* ✅ ELASTYCZNE - zwraca tylko te wiersze, które są używane:
* - Internet: Pakiet, Telefon (jeśli jest), Dodatkowe usługi (jeśli są)
* - TV: Pakiet, Telefon (jeśli jest), Dekoder, Pakiety premium (jeśli są), Dodatkowe usługi (jeśli są)
* - Phone: Tylko Pakiet
*/
getSummaryRows() {
const rows = [
{ label: 'Pakiet', value: this.basePrice, showDashIfZero: false }
];
// Telefon - dodaj tylko jeśli jest ustawiony
if (this.phone !== null) {
rows.push({
label: 'Telefon',
value: this.getPhonePrice(),
showDashIfZero: true
});
}
// Dekoder - dodaj tylko jeśli jest ustawiony (tylko TV)
if (this.decoder !== null) {
rows.push({
label: 'Dekoder',
value: this.getDecoderPrice(),
showDashIfZero: true
});
}
// Pakiety premium (TV addons) - dodaj tylko jeśli są jakieś (tylko TV)
if (this.tvAddons.length > 0) {
rows.push({
label: 'Pakiety premium',
value: this.getTvAddonsPrice(),
showDashIfZero: true
});
}
// Dodatkowe usługi - dodaj tylko jeśli są jakieś (Internet + TV)
if (this.addons.length > 0) {
rows.push({
label: 'Dodatkowe usługi',
value: this.getAddonsPrice(),
showDashIfZero: true
});
}
return rows;
}
/**
* Zbuduj payload do zapisu w localStorage
*
* ✅ ELASTYCZNE - zwraca tylko te pola, które są używane:
*
* Internet payload:
* {
* pkg, phone, addons,
* totals: { base, phone?, addons?, total }
* }
*
* TV payload:
* {
* pkg, phone, decoder, tvAddons, addons,
* totals: { base, phone?, decoder?, tv?, addons?, total }
* }
*/
toPayload() {
// Buduj totals dynamicznie - tylko pola które faktycznie są używane
const totals = {
base: this.basePrice,
total: this.getTotalMonthly(),
currencyLabel: this.cenaOpis
};
// Dodaj telefon tylko jeśli jest ustawiony
if (this.phone !== null) {
totals.phone = this.getPhonePrice();
}
// Dodaj dekoder tylko jeśli jest ustawiony (tylko TV)
if (this.decoder !== null) {
totals.decoder = this.getDecoderPrice();
}
// Dodaj TV addons tylko jeśli są jakieś (tylko TV)
if (this.tvAddons.length > 0) {
totals.tv = this.getTvAddonsPrice();
}
// Dodaj addons tylko jeśli są jakieś (Internet + TV)
if (this.addons.length > 0) {
totals.addons = this.getAddonsPrice();
}
return {
createdAt: new Date().toISOString(),
// Pakiet bazowy
pkg: this.pkg ? {
id: this.pkg.id || null,
name: this.pkg.name || '',
price: this.basePrice
} : null,
// Telefon (może być null)
phone: this.phone,
// Dekoder (może być null - tylko TV)
decoder: this.decoder,
// TV addons (może być [] - tylko TV)
tvAddons: this.tvAddons.map(item => ({
id: item.id,
nazwa: item.name,
qty: item.qty,
term: item.term || null,
unit: item.unitPrice
})),
// Addons (może być [] - Internet + TV)
addons: this.addons.map(item => ({
id: item.id,
nazwa: item.name,
qty: item.qty,
unit: item.unitPrice
})),
// Totals - dynamicznie zbudowany
totals
};
}
}
/**
* PRZYKŁADY UŻYCIA:
*
* // Internet (bez dekodera i TV addons):
* const calc = new PricingCalculator(50, 'zł/mies.');
* calc
* .setPackage(internetPkg)
* .setPhone(phoneOrNull)
* .setAddons(addonsList, selectedQty);
*
* calc.getSummaryRows();
* // => [
* // { label: 'Pakiet', value: 50 },
* // { label: 'Telefon', value: 20 }, // jeśli jest
* // { label: 'Dodatkowe usługi', value: 10 } // jeśli są
* // ]
*
* calc.toPayload().totals;
* // => { base: 50, phone: 20, addons: 10, total: 80 }
* // ✅ NIE MA decoder ani tv
*
*
* // TV (z dekoderem i TV addons):
* const calc = new PricingCalculator(80, 'zł/mies.');
* calc
* .setPackage(tvPkg)
* .setPhone(phoneOrNull)
* .setDecoder(decoder)
* .setTvAddons(tvAddonsList, selectedQty, tvTerms)
* .setAddons(addonsList, selectedQty);
*
* calc.getSummaryRows();
* // => [
* // { label: 'Pakiet', value: 80 },
* // { label: 'Telefon', value: 20 }, // jeśli jest
* // { label: 'Dekoder', value: 15 },
* // { label: 'Pakiety premium', value: 30 }, // jeśli są
* // { label: 'Dodatkowe usługi', value: 10 } // jeśli są
* // ]
*
* calc.toPayload().totals;
* // => { base: 80, phone: 20, decoder: 15, tv: 30, addons: 10, total: 155 }
* // ✅ WSZYSTKIE POLA
*/

View File

@@ -3,7 +3,7 @@ import path from "node:path";
import DefaultLayout from "../../layouts/DefaultLayout.astro";
import SectionRenderer from "../../components/sections/SectionRenderer.astro";
import OffersPhoneCards from "../../islands/phone/OffersPhoneCards.jsx";
import OffersPhoneCards from "../../islands/phone/PhoneCards.jsx";
import { loadYamlFile } from "../../lib/loadYaml";

View File

@@ -125,4 +125,61 @@
.f-card.is-target:hover {
transform: translateY(-6px) scale(1.01);
}
/*
* Dodatkowe style dla komponentu OfferCard
* Plik: src/styles/card-actions.css
*
* Dodaj do cards.css lub jako osobny import
*/
/* Kontener akcji (przycisków) w karcie */
.f-card-actions {
display: flex;
flex-direction: column;
gap: 0.5rem; /* 8px */
margin-top: 1rem; /* 16px */
width: 100%;
}
/* Wszystkie przyciski w kontenerze akcji rozciągają się na pełną szerokość */
.f-card-actions button,
.f-card-actions .btn {
width: 100%;
}
/* Opcjonalnie: Wariant z przyciskami w rzędzie (dla 2 przycisków obok siebie) */
.f-card-actions--row {
flex-direction: row;
}
.f-card-actions--row button,
.f-card-actions--row .btn {
flex: 1; /* równa szerokość */
}
/* Opcjonalnie: Wariant z przyciskami w grid (dla 3+ przycisków) */
.f-card-actions--grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 0.5rem;
}
/* Responsywność: Na mobile zawsze kolumna */
@media (max-width: 640px) {
.f-card-actions--row {
flex-direction: column;
}
.f-card-actions--grid {
grid-template-columns: 1fr;
}
}
/* Spacing dla przycisków które mają mt-2, mt-4 itp */
.f-card-actions .mt-2,
.f-card-actions .mt-4 {
margin-top: 0; /* Reset individual margins */
}