Refaktoryzacja Card, Modali
This commit is contained in:
64
src/components/ui/BaseOfferCards.jsx
Normal file
64
src/components/ui/BaseOfferCards.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
src/components/ui/OfferCard.jsx
Normal file
84
src/components/ui/OfferCard.jsx
Normal 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
96
src/hooks/useAccordion.js
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
50
src/hooks/useCardPricing.js
Normal file
50
src/hooks/useCardPricing.js
Normal 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)} zł` : '—'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return {
|
||||||
|
match,
|
||||||
|
basePrice,
|
||||||
|
installPrice,
|
||||||
|
hasPrice,
|
||||||
|
dynamicParams
|
||||||
|
};
|
||||||
|
}, [card, selected, labels]);
|
||||||
|
}
|
||||||
135
src/hooks/useChannelSearch.js
Normal file
135
src/hooks/useChannelSearch.js
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
89
src/hooks/useLocalSearch.js
Normal file
89
src/hooks/useLocalSearch.js
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
79
src/hooks/usePackageChannels.js
Normal file
79
src/hooks/usePackageChannels.js
Normal 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
74
src/hooks/usePricing.js
Normal 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()
|
||||||
|
};
|
||||||
|
}
|
||||||
36
src/hooks/useSwitchState.js
Normal file
36
src/hooks/useSwitchState.js
Normal 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 };
|
||||||
|
}
|
||||||
@@ -9,6 +9,11 @@ import FloatingTotal from "../modals/sections/FloatingTotal.jsx";
|
|||||||
|
|
||||||
import { mapPhoneYamlToPlans, normalizeAddons } from "../../lib/offer-normalize.js";
|
import { mapPhoneYamlToPlans, normalizeAddons } from "../../lib/offer-normalize.js";
|
||||||
import { saveOfferToLocalStorage } from "../../lib/offer-payload.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/modal.css";
|
||||||
import "../../styles/addons.css";
|
import "../../styles/addons.css";
|
||||||
@@ -21,137 +26,134 @@ export default function InternetAddonsModal({
|
|||||||
addons = [],
|
addons = [],
|
||||||
cenaOpis = "zł / mies.",
|
cenaOpis = "zł / mies.",
|
||||||
}) {
|
}) {
|
||||||
|
// Normalizacja danych z YAML
|
||||||
const phonePlans = useMemo(() => mapPhoneYamlToPlans(phoneCards), [phoneCards]);
|
const phonePlans = useMemo(() => mapPhoneYamlToPlans(phoneCards), [phoneCards]);
|
||||||
const addonsList = useMemo(() => normalizeAddons(addons), [addons]);
|
const addonsList = useMemo(() => normalizeAddons(addons), [addons]);
|
||||||
|
|
||||||
|
// Stan wyboru użytkownika
|
||||||
const [selectedPhoneId, setSelectedPhoneId] = useState(null);
|
const [selectedPhoneId, setSelectedPhoneId] = useState(null);
|
||||||
const [selectedQty, setSelectedQty] = useState({});
|
const [selectedQty, setSelectedQty] = useState({});
|
||||||
|
|
||||||
const [openSections, setOpenSections] = useState({
|
// ✅ NOWY: Hook accordion
|
||||||
internet: true,
|
const accordion = useAccordion(
|
||||||
phone: false,
|
['internet', 'phone', 'addons', 'summary'],
|
||||||
addons: false,
|
'internet', // domyślnie otwarta
|
||||||
summary: false,
|
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) => {
|
// Reset przy otwarciu
|
||||||
setOpenSections((prev) => {
|
|
||||||
const nextOpen = !prev[key];
|
|
||||||
return { internet: false, phone: false, addons: false, summary: false, [key]: nextOpen };
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
setSelectedPhoneId(null);
|
setSelectedPhoneId(null);
|
||||||
setSelectedQty({});
|
setSelectedQty({});
|
||||||
setOpenSections({ internet: true, phone: false, addons: false, summary: false });
|
accordion.openSection('internet');
|
||||||
}, [isOpen, plan?.id]);
|
}, [isOpen, plan?.id]);
|
||||||
|
|
||||||
if (!isOpen || !plan) return null;
|
if (!isOpen || !plan) return null;
|
||||||
|
|
||||||
const basePrice = Number(plan.price_monthly || 0);
|
// ✅ UPROSZCZONE: Payload z calculatora
|
||||||
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const onSend = () => {
|
const onSend = () => {
|
||||||
const payload = buildOfferPayload();
|
saveOfferToLocalStorage(pricing.payload, cenaOpis);
|
||||||
saveOfferToLocalStorage(payload, cenaOpis);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OfferModalShell isOpen={isOpen} onClose={onClose} title={`${plan.name} — konfiguracja usług`}>
|
<OfferModalShell isOpen={isOpen} onClose={onClose} title={`${plan.name} – konfiguracja usług`}>
|
||||||
|
|
||||||
<PlanSection
|
<PlanSection
|
||||||
title={plan.name}
|
title={plan.name}
|
||||||
open={openSections.internet}
|
open={accordion.isOpen('internet')}
|
||||||
onToggle={() => toggleSection("internet")}
|
onToggle={() => accordion.toggle('internet')}
|
||||||
price={basePrice}
|
price={pricing.basePrice}
|
||||||
cenaOpis={cenaOpis}
|
cenaOpis={cenaOpis}
|
||||||
features={plan.features || []}
|
features={plan.features || []}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PhoneSection
|
<PhoneSection
|
||||||
open={openSections.phone}
|
open={accordion.isOpen('phone')}
|
||||||
onToggle={() => toggleSection("phone")}
|
onToggle={() => accordion.toggle('phone')}
|
||||||
cenaOpis={cenaOpis}
|
cenaOpis={cenaOpis}
|
||||||
phonePlans={phonePlans}
|
phonePlans={phonePlans}
|
||||||
selectedPhoneId={selectedPhoneId}
|
selectedPhoneId={selectedPhoneId}
|
||||||
setSelectedPhoneId={setSelectedPhoneId}
|
setSelectedPhoneId={setSelectedPhoneId}
|
||||||
phonePrice={phonePrice}
|
phonePrice={pricing.phonePrice}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AddonsSection
|
<AddonsSection
|
||||||
open={openSections.addons}
|
open={accordion.isOpen('addons')}
|
||||||
onToggle={() => toggleSection("addons")}
|
onToggle={() => accordion.toggle('addons')}
|
||||||
cenaOpis={cenaOpis}
|
cenaOpis={cenaOpis}
|
||||||
addonsList={addonsList}
|
addonsList={addonsList}
|
||||||
selectedQty={selectedQty}
|
selectedQty={selectedQty}
|
||||||
setSelectedQty={setSelectedQty}
|
setSelectedQty={setSelectedQty}
|
||||||
addonsPrice={addonsPrice}
|
addonsPrice={pricing.addonsPrice}
|
||||||
getUnitPrice={(a) => Number(a.cena || 0)}
|
getUnitPrice={(a) => getAddonUnitPrice(a, plan, null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SummarySection
|
<SummarySection
|
||||||
open={openSections.summary}
|
open={accordion.isOpen('summary')}
|
||||||
onToggle={() => toggleSection("summary")}
|
onToggle={() => accordion.toggle('summary')}
|
||||||
cenaOpis={cenaOpis}
|
cenaOpis={cenaOpis}
|
||||||
totalMonthly={totalMonthly}
|
totalMonthly={pricing.totalMonthly}
|
||||||
ctaHref="/kontakt#form"
|
ctaHref="/kontakt#form"
|
||||||
onSend={onSend}
|
onSend={onSend}
|
||||||
rows={[
|
rows={pricing.summaryRows}
|
||||||
{ label: "Pakiet", value: basePrice, showDashIfZero: false },
|
|
||||||
{ label: "Telefon", value: phonePrice, showDashIfZero: true },
|
|
||||||
{ label: "Dodatkowe usługi", value: addonsPrice, showDashIfZero: true },
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FloatingTotal
|
<FloatingTotal
|
||||||
storageKey="fuz_floating_total_pos_internet_v1"
|
storageKey="fuz_floating_total_pos_internet_v1"
|
||||||
totalMonthly={totalMonthly}
|
totalMonthly={pricing.totalMonthly}
|
||||||
cenaOpis={cenaOpis}
|
cenaOpis={cenaOpis}
|
||||||
/>
|
/>
|
||||||
</OfferModalShell>
|
</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 { useState } from "preact/hooks";
|
||||||
import Markdown from "../Markdown.jsx";
|
|
||||||
import OffersSwitches from "../Switches.jsx";
|
|
||||||
import InternetAddonsModal from "./InternetAddonsModal.jsx";
|
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";
|
/**
|
||||||
|
* Helper: mapper karty Internet + match + labels -> plan dla modala
|
||||||
// ✅ mapper: InternetCard(YAML) + match + labels -> plan (dla modala)
|
*/
|
||||||
function mapCardToPlan(card, match, labels, cenaOpis) {
|
function mapCardToPlan(card, match, labels, cenaOpis) {
|
||||||
const baseParams = Array.isArray(card?.parametry) ? card.parametry : [];
|
const baseParams = Array.isArray(card?.parametry) ? card.parametry : [];
|
||||||
|
|
||||||
const features = baseParams.map((p) => ({
|
const features = baseParams.map((p) => ({
|
||||||
label: p.label,
|
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: "Umowa", value: labels?.umowa || "—" });
|
||||||
features.push({
|
features.push({
|
||||||
label: "Aktywacja",
|
label: "Aktywacja",
|
||||||
value: typeof match?.aktywacja === "number" ? `${money(match.aktywacja)} zł` : "—",
|
value: typeof match?.aktywacja === "number" ? `${money(match.aktywacja)} zł` : "—"
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -27,93 +28,45 @@ function mapCardToPlan(card, match, labels, cenaOpis) {
|
|||||||
price_monthly: typeof match?.miesiecznie === "number" ? match.miesiecznie : 0,
|
price_monthly: typeof match?.miesiecznie === "number" ? match.miesiecznie : 0,
|
||||||
price_installation: typeof match?.aktywacja === "number" ? match.aktywacja : 0,
|
price_installation: typeof match?.aktywacja === "number" ? match.aktywacja : 0,
|
||||||
features,
|
features,
|
||||||
cenaOpis,
|
cenaOpis
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {{
|
* Karty pakietów Internet
|
||||||
* title?: string,
|
|
||||||
* description?: string,
|
|
||||||
* cards?: any[],
|
|
||||||
* waluta?: string,
|
|
||||||
* cenaOpis?: string,
|
|
||||||
* phoneCards?: any[],
|
|
||||||
* addons?: any[],
|
|
||||||
* addonsCenaOpis?: string,
|
|
||||||
* switches?: any[]
|
|
||||||
* }} props
|
|
||||||
*/
|
*/
|
||||||
export default function InternetCards({
|
export default function InternetCards({
|
||||||
title = "",
|
title = "",
|
||||||
description = "",
|
description = "",
|
||||||
cards = [],
|
cards = [],
|
||||||
waluta = "PLN", // zostawiamy, bo może się przydać dalej (np. w modalu), ale tu nie jest używana
|
|
||||||
cenaOpis = "zł/mies.",
|
cenaOpis = "zł/mies.",
|
||||||
phoneCards = [],
|
phoneCards = [],
|
||||||
addons = [],
|
addons = [],
|
||||||
addonsCenaOpis = "zł/mies.",
|
addonsCenaOpis = "zł/mies.",
|
||||||
switches = [],
|
switches = []
|
||||||
}) {
|
}) {
|
||||||
const visibleCards = Array.isArray(cards) ? cards : [];
|
// Modal
|
||||||
|
|
||||||
// switch state (idzie z OffersSwitches na podstawie YAML)
|
|
||||||
const [selected, setSelected] = useState({});
|
|
||||||
const [labels, setLabels] = useState({});
|
|
||||||
|
|
||||||
// modal
|
|
||||||
const [addonsModalOpen, setAddonsModalOpen] = useState(false);
|
const [addonsModalOpen, setAddonsModalOpen] = useState(false);
|
||||||
const [activePlan, setActivePlan] = useState(null);
|
const [activePlan, setActivePlan] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
// ✅ Funkcja renderująca pojedynczą kartę
|
||||||
if (typeof window !== "undefined" && window.fuzSwitchState) {
|
const renderCard = (card, context) => (
|
||||||
const { selected: sel, labels: labs } = window.fuzSwitchState;
|
<InternetOfferCard
|
||||||
if (sel) setSelected(sel);
|
key={card.nazwa}
|
||||||
if (labs) setLabels(labs);
|
card={card}
|
||||||
}
|
selected={context.selected}
|
||||||
|
labels={context.labels}
|
||||||
function handler(e) {
|
cenaOpis={cenaOpis}
|
||||||
const detail = e?.detail || {};
|
onConfigureAddons={(plan) => {
|
||||||
if (detail.selected) setSelected(detail.selected);
|
setActivePlan(plan);
|
||||||
if (detail.labels) setLabels(detail.labels);
|
setAddonsModalOpen(true);
|
||||||
}
|
}}
|
||||||
|
/>
|
||||||
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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
// ✅ Modal jako komponent
|
||||||
|
const modals = [
|
||||||
|
() => (
|
||||||
<InternetAddonsModal
|
<InternetAddonsModal
|
||||||
isOpen={addonsModalOpen}
|
isOpen={addonsModalOpen}
|
||||||
onClose={() => setAddonsModalOpen(false)}
|
onClose={() => setAddonsModalOpen(false)}
|
||||||
@@ -122,74 +75,90 @@ export default function InternetCards({
|
|||||||
addons={addons}
|
addons={addons}
|
||||||
cenaOpis={addonsCenaOpis || cenaOpis}
|
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 (
|
return (
|
||||||
<div class={`f-card ${card.popularny ? "f-card-popular" : ""}`}>
|
<BaseOfferCards
|
||||||
{card.popularny && <div class="f-card-badge">Najczęściej wybierany</div>}
|
title={title}
|
||||||
|
description={description}
|
||||||
<div class="f-card-header">
|
cards={cards}
|
||||||
<div class="f-card-name">{card.nazwa}</div>
|
switches={switches}
|
||||||
|
renderCard={renderCard}
|
||||||
<div class="f-card-price">
|
modals={modals}
|
||||||
{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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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,57 +1,39 @@
|
|||||||
import { useEffect, useMemo, useState } from "preact/hooks";
|
import { useEffect, useMemo } from "preact/hooks";
|
||||||
|
import { usePackageChannels } from "../../hooks/usePackageChannels.js";
|
||||||
function cleanPkgName(v) {
|
|
||||||
const s = String(v || "").trim();
|
|
||||||
if (!s) return null;
|
|
||||||
if (s.length > 64) return null;
|
|
||||||
return s;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: znajdź najbliższą sekcję z atrybutem data-addon-section
|
||||||
|
*/
|
||||||
function getNearestSectionEl(el) {
|
function getNearestSectionEl(el) {
|
||||||
return el?.closest?.("[data-addon-section]") ?? null;
|
return el?.closest?.("[data-addon-section]") ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AddonChannelsGrid(props) {
|
/**
|
||||||
const packageName = cleanPkgName(props?.packageName);
|
* Komponent wyświetlający grid kanałów dla pakietu dodatku TV
|
||||||
const fallbackImage = String(props?.fallbackImage || "").trim();
|
*
|
||||||
const title = String(props?.title || "").trim();
|
* @param {Object} props
|
||||||
const aboveFold = props?.aboveFold === true;
|
* @param {string} props.packageName - Nazwa pakietu (np. "SPORT MAX")
|
||||||
|
* @param {string} props.fallbackImage - Obrazek fallback jeśli brak kanałów
|
||||||
const [loading, setLoading] = useState(false);
|
* @param {string} props.title - Tytuł dla accessibility
|
||||||
const [err, setErr] = useState("");
|
* @param {boolean} props.aboveFold - Czy widoczny od razu (eager loading)
|
||||||
const [items, setItems] = useState([]);
|
*/
|
||||||
|
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 }), []);
|
const rootRef = useMemo(() => ({ current: null }), []);
|
||||||
|
|
||||||
|
// Kanały z logo (do wyświetlenia)
|
||||||
const channelsWithLogo = useMemo(() => {
|
const channelsWithLogo = useMemo(() => {
|
||||||
return (items || []).filter((x) => String(x?.logo_url || "").trim());
|
return channels.filter((ch) => String(ch?.logo_url || "").trim());
|
||||||
}, [items]);
|
}, [channels]);
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
|
// Efekt: ustaw atrybut data-has-media na najbliższej sekcji
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = rootRef.current;
|
const el = rootRef.current;
|
||||||
const section = getNearestSectionEl(el);
|
const section = getNearestSectionEl(el);
|
||||||
@@ -69,43 +51,73 @@ export default function AddonChannelsGrid(props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={(el) => (rootRef.current = el)}>
|
<div ref={(el) => (rootRef.current = el)}>
|
||||||
|
{/* Grid kanałów */}
|
||||||
{hasIcons ? (
|
{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) => {
|
{visible.map((ch, idx) => {
|
||||||
const logo = String(ch?.logo_url || "").trim();
|
const logo = String(ch?.logo_url || "").trim();
|
||||||
const name = String(ch?.name || "").trim();
|
const name = String(ch?.name || "").trim();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="f-channel-item" title={name}>
|
<div className="f-channel-item" title={name} key={`${name}-${idx}`}>
|
||||||
{logo ? (
|
{logo ? (
|
||||||
<img
|
<img
|
||||||
class="f-channel-logo"
|
className="f-channel-logo"
|
||||||
src={logo}
|
src={logo}
|
||||||
alt=""
|
alt=""
|
||||||
loading={aboveFold && idx < 12 ? "eager" : "lazy"}
|
loading={aboveFold && idx < 12 ? "eager" : "lazy"}
|
||||||
decoding="async"
|
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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : fallbackImage ? (
|
) : fallbackImage ? (
|
||||||
|
/* Fallback image */
|
||||||
<img
|
<img
|
||||||
class="f-addon-fallback-image"
|
className="f-addon-fallback-image"
|
||||||
src={fallbackImage}
|
src={fallbackImage}
|
||||||
alt={title || packageName || ""}
|
alt={title || packageName || ""}
|
||||||
loading={aboveFold ? "eager" : "lazy"}
|
loading={aboveFold ? "eager" : "lazy"}
|
||||||
decoding="async"
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div class="sr-only">
|
/* Screen reader info */
|
||||||
{loading ? "Ładowanie kanałów" : err ? `Błąd: ${err}` : "Brak kanałów"}
|
<div className="sr-only">
|
||||||
|
{loading ? "Ładowanie kanałów" : error ? `Błąd: ${error}` : "Brak kanałów"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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 FloatingTotal from "../modals/sections/FloatingTotal.jsx";
|
||||||
|
|
||||||
import { mapPhoneYamlToPlans, normalizeAddons, normalizeDecoders } from "../../lib/offer-normalize.js";
|
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";
|
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/modal.css";
|
||||||
import "../../styles/addons.css";
|
import "../../styles/addons.css";
|
||||||
|
|
||||||
@@ -28,45 +32,55 @@ export default function JamboxAddonsModal({
|
|||||||
|
|
||||||
cenaOpis = "zł/mies.",
|
cenaOpis = "zł/mies.",
|
||||||
}) {
|
}) {
|
||||||
|
// Normalizacja danych z YAML
|
||||||
const phonePlans = useMemo(() => mapPhoneYamlToPlans(phoneCards), [phoneCards]);
|
const phonePlans = useMemo(() => mapPhoneYamlToPlans(phoneCards), [phoneCards]);
|
||||||
const tvAddonsList = useMemo(() => normalizeAddons(tvAddons), [tvAddons]);
|
const tvAddonsList = useMemo(() => normalizeAddons(tvAddons), [tvAddons]);
|
||||||
const addonsList = useMemo(() => normalizeAddons(addons), [addons]);
|
const addonsList = useMemo(() => normalizeAddons(addons), [addons]);
|
||||||
const decodersList = useMemo(() => normalizeDecoders(decoders), [decoders]);
|
const decodersList = useMemo(() => normalizeDecoders(decoders), [decoders]);
|
||||||
|
|
||||||
|
// Filtruj dodatki TV dla tego pakietu
|
||||||
const tvAddonsVisible = useMemo(() => {
|
const tvAddonsVisible = useMemo(() => {
|
||||||
if (!pkg) return [];
|
if (!pkg) return [];
|
||||||
return tvAddonsList.filter((a) => isTvAddonAvailableForPkg(a, pkg));
|
return tvAddonsList.filter((a) => isTvAddonAvailableForPkg(a, pkg));
|
||||||
}, [tvAddonsList, pkg]);
|
}, [tvAddonsList, pkg]);
|
||||||
|
|
||||||
|
// Stan wyboru użytkownika
|
||||||
const [selectedPhoneId, setSelectedPhoneId] = useState(null);
|
const [selectedPhoneId, setSelectedPhoneId] = useState(null);
|
||||||
const [selectedDecoderId, setSelectedDecoderId] = useState(null);
|
const [selectedDecoderId, setSelectedDecoderId] = useState(null);
|
||||||
const [selectedQty, setSelectedQty] = useState({});
|
const [selectedQty, setSelectedQty] = useState({});
|
||||||
const [tvTerm, setTvTerm] = useState({}); // { [id]: "12m" | "bezterminowo" }
|
const [tvTerm, setTvTerm] = useState({}); // { [id]: "12m" | "bezterminowo" }
|
||||||
|
|
||||||
const [openSections, setOpenSections] = useState({
|
// ✅ NOWY: Hook accordion zamiast ręcznego zarządzania
|
||||||
base: true,
|
const accordion = useAccordion(
|
||||||
decoder: false,
|
['base', 'decoder', 'tv', 'phone', 'addons', 'summary'],
|
||||||
tv: false,
|
'base', // domyślnie otwarta sekcja
|
||||||
phone: false,
|
false // tylko jedna sekcja otwarta jednocześnie
|
||||||
addons: false,
|
);
|
||||||
summary: false,
|
|
||||||
|
// 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) => {
|
// Reset przy otwarciu modala
|
||||||
setOpenSections((prev) => {
|
|
||||||
const nextOpen = !prev[key];
|
|
||||||
return {
|
|
||||||
base: false,
|
|
||||||
decoder: false,
|
|
||||||
tv: false,
|
|
||||||
phone: false,
|
|
||||||
addons: false,
|
|
||||||
summary: false,
|
|
||||||
[key]: nextOpen,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
|
|
||||||
@@ -74,146 +88,46 @@ export default function JamboxAddonsModal({
|
|||||||
setSelectedQty({});
|
setSelectedQty({});
|
||||||
setTvTerm({});
|
setTvTerm({});
|
||||||
|
|
||||||
const d0 =
|
// Ustaw domyślny dekoder (darmowy lub pierwszy)
|
||||||
(Array.isArray(decodersList) && decodersList.find((d) => Number(d.cena) === 0)) ||
|
const defaultDecoder = decodersList.find(d => Number(d.cena) === 0) || decodersList[0];
|
||||||
(Array.isArray(decodersList) ? decodersList[0] : null);
|
setSelectedDecoderId(defaultDecoder ? String(defaultDecoder.id) : null);
|
||||||
setSelectedDecoderId(d0 ? String(d0.id) : null);
|
|
||||||
|
|
||||||
setOpenSections({
|
// Reset accordionu do pierwszej sekcji
|
||||||
base: true,
|
accordion.openSection('base');
|
||||||
decoder: false,
|
|
||||||
tv: false,
|
|
||||||
phone: false,
|
|
||||||
addons: false,
|
|
||||||
summary: false,
|
|
||||||
});
|
|
||||||
}, [isOpen, pkg?.id, decodersList]);
|
}, [isOpen, pkg?.id, decodersList]);
|
||||||
|
|
||||||
if (!isOpen || !pkg) return null;
|
if (!isOpen || !pkg) return null;
|
||||||
|
|
||||||
const basePrice = Number(pkg.price_monthly || 0);
|
// ✅ UPROSZCZONE: Payload z calculatora
|
||||||
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const onSend = () => {
|
const onSend = () => {
|
||||||
const payload = buildOfferPayload();
|
saveOfferToLocalStorage(pricing.payload, cenaOpis);
|
||||||
saveOfferToLocalStorage(payload, cenaOpis);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<OfferModalShell isOpen={isOpen} onClose={onClose} title={`${pkg.name} — konfiguracja usług`}>
|
<OfferModalShell isOpen={isOpen} onClose={onClose} title={`${pkg.name} – konfiguracja usług`}>
|
||||||
|
|
||||||
<PlanSection
|
<PlanSection
|
||||||
title={pkg.name}
|
title={pkg.name}
|
||||||
open={openSections.base}
|
open={accordion.isOpen('base')}
|
||||||
onToggle={() => toggleSection("base")}
|
onToggle={() => accordion.toggle('base')}
|
||||||
price={basePrice}
|
price={pricing.basePrice}
|
||||||
cenaOpis={cenaOpis}
|
cenaOpis={cenaOpis}
|
||||||
features={pkg.features || []}
|
features={pkg.features || []}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DecoderSection
|
<DecoderSection
|
||||||
open={openSections.decoder}
|
open={accordion.isOpen('decoder')}
|
||||||
onToggle={() => toggleSection("decoder")}
|
onToggle={() => accordion.toggle('decoder')}
|
||||||
cenaOpis={cenaOpis}
|
cenaOpis={cenaOpis}
|
||||||
decoders={decodersList}
|
decoders={decodersList}
|
||||||
selectedDecoderId={selectedDecoderId}
|
selectedDecoderId={selectedDecoderId}
|
||||||
setSelectedDecoderId={setSelectedDecoderId}
|
setSelectedDecoderId={setSelectedDecoderId}
|
||||||
decoderPrice={decoderPrice}
|
decoderPrice={pricing.decoderPrice}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<TvAddonsSection
|
<TvAddonsSection
|
||||||
open={openSections.tv}
|
open={accordion.isOpen('tv')}
|
||||||
onToggle={() => toggleSection("tv")}
|
onToggle={() => accordion.toggle('tv')}
|
||||||
cenaOpis={cenaOpis}
|
cenaOpis={cenaOpis}
|
||||||
pkg={pkg}
|
pkg={pkg}
|
||||||
tvAddonsVisible={tvAddonsVisible}
|
tvAddonsVisible={tvAddonsVisible}
|
||||||
@@ -221,51 +135,85 @@ export default function JamboxAddonsModal({
|
|||||||
setSelectedQty={setSelectedQty}
|
setSelectedQty={setSelectedQty}
|
||||||
tvTerm={tvTerm}
|
tvTerm={tvTerm}
|
||||||
setTvTerm={setTvTerm}
|
setTvTerm={setTvTerm}
|
||||||
tvAddonsPrice={tvAddonsPrice}
|
tvAddonsPrice={pricing.tvAddonsPrice}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<PhoneSection
|
<PhoneSection
|
||||||
open={openSections.phone}
|
open={accordion.isOpen('phone')}
|
||||||
onToggle={() => toggleSection("phone")}
|
onToggle={() => accordion.toggle('phone')}
|
||||||
cenaOpis={cenaOpis}
|
cenaOpis={cenaOpis}
|
||||||
phonePlans={phonePlans}
|
phonePlans={phonePlans}
|
||||||
selectedPhoneId={selectedPhoneId}
|
selectedPhoneId={selectedPhoneId}
|
||||||
setSelectedPhoneId={setSelectedPhoneId}
|
setSelectedPhoneId={setSelectedPhoneId}
|
||||||
phonePrice={phonePrice}
|
phonePrice={pricing.phonePrice}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AddonsSection
|
<AddonsSection
|
||||||
open={openSections.addons}
|
open={accordion.isOpen('addons')}
|
||||||
onToggle={() => toggleSection("addons")}
|
onToggle={() => accordion.toggle('addons')}
|
||||||
cenaOpis={cenaOpis}
|
cenaOpis={cenaOpis}
|
||||||
addonsList={addonsList}
|
addonsList={addonsList}
|
||||||
selectedQty={selectedQty}
|
selectedQty={selectedQty}
|
||||||
setSelectedQty={setSelectedQty}
|
setSelectedQty={setSelectedQty}
|
||||||
addonsPrice={addonsOnlyPrice}
|
addonsPrice={pricing.addonsPrice}
|
||||||
getUnitPrice={(a) => getAddonUnitPrice(a, pkg, null)}
|
getUnitPrice={(a) => getAddonUnitPrice(a, pkg, null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SummarySection
|
<SummarySection
|
||||||
open={openSections.summary}
|
open={accordion.isOpen('summary')}
|
||||||
onToggle={() => toggleSection("summary")}
|
onToggle={() => accordion.toggle('summary')}
|
||||||
cenaOpis={cenaOpis}
|
cenaOpis={cenaOpis}
|
||||||
totalMonthly={totalMonthly}
|
totalMonthly={pricing.totalMonthly}
|
||||||
ctaHref="/kontakt"
|
ctaHref="/kontakt"
|
||||||
onSend={onSend}
|
onSend={onSend}
|
||||||
rows={[
|
rows={pricing.summaryRows}
|
||||||
{ 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 },
|
|
||||||
]}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FloatingTotal
|
<FloatingTotal
|
||||||
storageKey="fuz_floating_total_pos_tv_v1"
|
storageKey="fuz_floating_total_pos_tv_v1"
|
||||||
totalMonthly={totalMonthly}
|
totalMonthly={pricing.totalMonthly}
|
||||||
cenaOpis={cenaOpis}
|
cenaOpis={cenaOpis}
|
||||||
/>
|
/>
|
||||||
</OfferModalShell>
|
</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,54 +1,26 @@
|
|||||||
import { useEffect, useState } from "preact/hooks";
|
import { useState } from "preact/hooks";
|
||||||
import "../../styles/cards.css";
|
|
||||||
|
|
||||||
import OffersSwitches from "../Switches.jsx";
|
|
||||||
import JamboxChannelsModal from "./JamboxChannelsModal.jsx";
|
import JamboxChannelsModal from "./JamboxChannelsModal.jsx";
|
||||||
import JamboxAddonsModal from "./JamboxAddonsModal.jsx";
|
import JamboxAddonsModal from "./JamboxAddonsModal.jsx";
|
||||||
import Markdown from "../Markdown.jsx";
|
import OfferCard from "../../components/ui/OfferCard.jsx";
|
||||||
|
import BaseOfferCards from "../../components/ui/BaseOfferCards.jsx";
|
||||||
import { moneyWithLabel, money } from "../../lib/money.js";
|
import { useCardPricing } from "../../hooks/useCardPricing.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: konwertuj parametry na features dla karty
|
||||||
|
*/
|
||||||
function toFeatureRows(params) {
|
function toFeatureRows(params) {
|
||||||
const list = Array.isArray(params) ? params : [];
|
const list = Array.isArray(params) ? params : [];
|
||||||
return list.map((p) => ({ label: p.label, value: p.value }));
|
return list.map((p) => ({ label: p.label, value: p.value }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {{ label: string, value: any, klucz?: string }} Param
|
* Karty pakietów Jambox TV
|
||||||
* @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
|
|
||||||
*/
|
*/
|
||||||
export default function JamboxCards({
|
export default function JamboxCards({
|
||||||
title = "",
|
title = "",
|
||||||
description = "",
|
description = "",
|
||||||
cards = [],
|
cards = [],
|
||||||
internetWspolne = [],
|
internetWspolne = [],
|
||||||
waluta = "PLN", // zostawiamy (może być potrzebne w modalach), ale tu nie jest używane do formatowania
|
|
||||||
cenaOpis = "zł/mies.",
|
cenaOpis = "zł/mies.",
|
||||||
|
|
||||||
phoneCards = [],
|
phoneCards = [],
|
||||||
@@ -56,81 +28,46 @@ export default function JamboxCards({
|
|||||||
addons = [],
|
addons = [],
|
||||||
decoders = [],
|
decoders = [],
|
||||||
channels = [],
|
channels = [],
|
||||||
switches = [],
|
switches = []
|
||||||
}) {
|
}) {
|
||||||
const visibleCards = Array.isArray(cards) ? cards : [];
|
|
||||||
const wsp = Array.isArray(internetWspolne) ? internetWspolne : [];
|
const wsp = Array.isArray(internetWspolne) ? internetWspolne : [];
|
||||||
|
|
||||||
// stan switchera (window.fuzSwitchState + event)
|
// Modale
|
||||||
const [selected, setSelected] = useState({});
|
|
||||||
const [labels, setLabels] = useState({});
|
|
||||||
|
|
||||||
// modale
|
|
||||||
const [channelsModalOpen, setChannelsModalOpen] = useState(false);
|
const [channelsModalOpen, setChannelsModalOpen] = useState(false);
|
||||||
const [addonsModalOpen, setAddonsModalOpen] = useState(false);
|
const [addonsModalOpen, setAddonsModalOpen] = useState(false);
|
||||||
const [activePkg, setActivePkg] = useState(null);
|
const [activePkg, setActivePkg] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
// ✅ Funkcja renderująca pojedynczą kartę
|
||||||
if (typeof window !== "undefined" && window.fuzSwitchState) {
|
const renderCard = (card, context) => (
|
||||||
const { selected: sel, labels: labs } = window.fuzSwitchState;
|
<JamboxPackageCard
|
||||||
if (sel) setSelected(sel);
|
key={card.id || card.nazwa}
|
||||||
if (labs) setLabels(labs);
|
card={card}
|
||||||
}
|
wsp={wsp}
|
||||||
|
selected={context.selected}
|
||||||
const handler = (e) => {
|
labels={context.labels}
|
||||||
const detail = e?.detail || {};
|
cenaOpis={cenaOpis}
|
||||||
if (detail.selected) setSelected(detail.selected);
|
onShowChannels={(pkg) => {
|
||||||
if (detail.labels) setLabels(detail.labels);
|
setActivePkg(pkg);
|
||||||
};
|
setChannelsModalOpen(true);
|
||||||
|
}}
|
||||||
window.addEventListener("fuz:switch-change", handler);
|
onConfigureAddons={(pkg) => {
|
||||||
return () => window.removeEventListener("fuz:switch-change", handler);
|
setActivePkg(pkg);
|
||||||
}, []);
|
setAddonsModalOpen(true);
|
||||||
|
}}
|
||||||
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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
|
// ✅ Modale jako komponenty
|
||||||
|
const modals = [
|
||||||
|
() => (
|
||||||
<JamboxChannelsModal
|
<JamboxChannelsModal
|
||||||
isOpen={channelsModalOpen}
|
isOpen={channelsModalOpen}
|
||||||
onClose={() => setChannelsModalOpen(false)}
|
onClose={() => setChannelsModalOpen(false)}
|
||||||
pkg={activePkg}
|
pkg={activePkg}
|
||||||
allChannels={channels}
|
allChannels={channels}
|
||||||
/>
|
/>
|
||||||
|
),
|
||||||
|
() => (
|
||||||
<JamboxAddonsModal
|
<JamboxAddonsModal
|
||||||
isOpen={addonsModalOpen}
|
isOpen={addonsModalOpen}
|
||||||
onClose={() => setAddonsModalOpen(false)}
|
onClose={() => setAddonsModalOpen(false)}
|
||||||
@@ -141,10 +78,24 @@ export default function JamboxCards({
|
|||||||
decoders={decoders}
|
decoders={decoders}
|
||||||
cenaOpis={cenaOpis}
|
cenaOpis={cenaOpis}
|
||||||
/>
|
/>
|
||||||
</section>
|
)
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BaseOfferCards
|
||||||
|
title={title}
|
||||||
|
description={description}
|
||||||
|
cards={cards}
|
||||||
|
switches={switches}
|
||||||
|
renderCard={renderCard}
|
||||||
|
modals={modals}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pojedyncza karta pakietu Jambox
|
||||||
|
*/
|
||||||
function JamboxPackageCard({
|
function JamboxPackageCard({
|
||||||
card,
|
card,
|
||||||
wsp,
|
wsp,
|
||||||
@@ -152,87 +103,83 @@ function JamboxPackageCard({
|
|||||||
labels,
|
labels,
|
||||||
cenaOpis,
|
cenaOpis,
|
||||||
onShowChannels,
|
onShowChannels,
|
||||||
onConfigureAddons,
|
onConfigureAddons
|
||||||
}) {
|
}) {
|
||||||
|
// ✅ Hook do obliczania ceny
|
||||||
|
const pricing = useCardPricing(card, selected, labels);
|
||||||
|
|
||||||
const baseParams = Array.isArray(card?.parametry) ? card.parametry : [];
|
const baseParams = Array.isArray(card?.parametry) ? card.parametry : [];
|
||||||
const ceny = Array.isArray(card?.ceny) ? card.ceny : [];
|
|
||||||
|
|
||||||
const budynek = selected?.budynek;
|
// Merge parametrów: wspólne + z karty + dynamiczne
|
||||||
const umowa = selected?.umowa;
|
const mergedParams = [...wsp, ...baseParams, ...pricing.dynamicParams];
|
||||||
|
|
||||||
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];
|
|
||||||
|
|
||||||
|
// Obiekt pakietu dla modali
|
||||||
const pkgForModals = {
|
const pkgForModals = {
|
||||||
id: card?.id,
|
id: card?.id,
|
||||||
tid: card?.tid,
|
tid: card?.tid,
|
||||||
source: card?.source,
|
source: card?.source,
|
||||||
name: card?.nazwa,
|
name: card?.nazwa,
|
||||||
slug: card?.slug,
|
slug: card?.slug,
|
||||||
price_monthly: typeof basePrice === "number" ? basePrice : null,
|
price_monthly: pricing.basePrice,
|
||||||
price_installation: typeof installPrice === "number" ? installPrice : null,
|
price_installation: pricing.installPrice,
|
||||||
features: toFeatureRows(mergedParams),
|
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 (
|
return (
|
||||||
<div class="f-card" id={`pkg-${card?.nazwa}`} data-pkg={card?.nazwa}>
|
<OfferCard
|
||||||
<div class="f-card-header">
|
card={card}
|
||||||
<div class="f-card-name">{card.nazwa}</div>
|
cardName={card.nazwa}
|
||||||
|
isPopular={card.popularny}
|
||||||
<div class="f-card-price">
|
price={pricing.basePrice}
|
||||||
{hasPrice ? (
|
cenaOpis={cenaOpis}
|
||||||
<>{moneyWithLabel(basePrice, cenaOpis, false)}</>
|
features={mergedParams}
|
||||||
) : (
|
actions={actions}
|
||||||
<span class="opacity-70">Wybierz opcje</span>
|
cardId={`pkg-${card?.nazwa}`}
|
||||||
)}
|
/>
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
✅ 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/modal.css";
|
||||||
import "../../styles/jambox-modal-channel.css";
|
import "../../styles/jambox-modal-channel.css";
|
||||||
import "../../styles/jambox-search.css";
|
import "../../styles/jambox-search.css";
|
||||||
@@ -15,11 +15,12 @@ export default function JamboxChannelsModal({ isOpen, onClose, pkg }) {
|
|||||||
? channels
|
? channels
|
||||||
: channels.filter((ch) => (ch.name || "").toLowerCase().includes(q));
|
: channels.filter((ch) => (ch.name || "").toLowerCase().includes(q));
|
||||||
|
|
||||||
const meta = useMemo(() => {
|
// ✅ Uproszczony meta - nie potrzebujemy useMemo dla prostego warunku
|
||||||
if (loading) return "Ładowanie…";
|
const meta = loading
|
||||||
if (error) return error;
|
? "Ładowanie…"
|
||||||
return `Wyniki: ${filtered.length} / ${channels.length}`;
|
: error
|
||||||
}, [loading, error, filtered.length, channels.length]);
|
? error
|
||||||
|
: `Wyniki: ${filtered.length} / ${channels.length}`;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen || !pkg?.name) return;
|
if (!isOpen || !pkg?.name) return;
|
||||||
@@ -33,7 +34,6 @@ export default function JamboxChannelsModal({ isOpen, onClose, pkg }) {
|
|||||||
setQuery("");
|
setQuery("");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// ✅ NOWE API: po nazwie pakietu
|
|
||||||
const params = new URLSearchParams({ package: String(pkg.name) });
|
const params = new URLSearchParams({ package: String(pkg.name) });
|
||||||
const res = await fetch(`/api/jambox/jambox-channels-package?${params.toString()}`);
|
const res = await fetch(`/api/jambox/jambox-channels-package?${params.toString()}`);
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
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 : [];
|
const list = Array.isArray(json.data) ? json.data : [];
|
||||||
|
|
||||||
// ✅ Normalizacja do UI (żeby reszta modala się nie sypała)
|
// Normalizacja do UI
|
||||||
// - number: nie ma w DB, więc dajemy null/"—"
|
|
||||||
const normalized = list.map((ch, i) => ({
|
const normalized = list.map((ch, i) => ({
|
||||||
name: ch?.name ?? "",
|
name: ch?.name ?? "",
|
||||||
description: ch?.description ?? "",
|
description: ch?.description ?? "",
|
||||||
@@ -55,7 +54,7 @@ export default function JamboxChannelsModal({ isOpen, onClose, pkg }) {
|
|||||||
|
|
||||||
if (!cancelled) setChannels(normalized);
|
if (!cancelled) setChannels(normalized);
|
||||||
} catch (err) {
|
} 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.");
|
if (!cancelled) setError("Nie udało się załadować listy kanałów.");
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
@@ -164,8 +163,6 @@ export default function JamboxChannelsModal({ isOpen, onClose, pkg }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="jmb-channel-name">{ch.name}</div>
|
<div class="jmb-channel-name">{ch.name}</div>
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="jmb-channel-face jmb-channel-back">
|
<div class="jmb-channel-face jmb-channel-back">
|
||||||
@@ -189,3 +186,19 @@ export default function JamboxChannelsModal({ isOpen, onClose, pkg }) {
|
|||||||
</div>
|
</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";
|
import "../../styles/jambox-search.css";
|
||||||
|
|
||||||
export default function JamboxChannelsSearch() {
|
export default function JamboxChannelsSearch() {
|
||||||
const [q, setQ] = useState("");
|
// ✅ NOWY: Hook useChannelSearch zamiast ręcznego zarządzania
|
||||||
const [items, setItems] = useState([]);
|
const search = useChannelSearch('/api/jambox/jambox-channels-search', {
|
||||||
const [loading, setLoading] = useState(false);
|
debounceMs: 250,
|
||||||
const [err, setErr] = useState("");
|
minQueryLength: 1,
|
||||||
|
limit: 80
|
||||||
|
});
|
||||||
|
|
||||||
// ✅ koszyk kanałów
|
// Koszyk kanałów ("Chciałbym mieć te kanały")
|
||||||
const [wanted, setWanted] = useState([]); // [{ name, logo_url, packages:[{id,name}], thematic_packages:[{tid,name}] }]
|
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) =>
|
const isWanted = (c) =>
|
||||||
wanted.some(
|
wanted.some(
|
||||||
(w) =>
|
(w) =>
|
||||||
@@ -126,17 +54,16 @@ export default function JamboxChannelsSearch() {
|
|||||||
setWanted([]);
|
setWanted([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sugestie pakietów na podstawie wybranych kanałów
|
||||||
const packageSuggestions = useMemo(() => {
|
const packageSuggestions = useMemo(() => {
|
||||||
if (!wanted.length) return { exact: [], ranked: [], thematic: [], baseWantedLen: 0, wantedLen: 0 };
|
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 baseWanted = wanted.filter((ch) => Array.isArray(ch.packages) && ch.packages.length > 0);
|
||||||
const baseWantedLen = baseWanted.length;
|
const baseWantedLen = baseWanted.length;
|
||||||
|
|
||||||
// ======= GŁÓWNE =======
|
// Jeśli nie ma żadnego kanału "bazowego", zwracamy tylko tematyczne
|
||||||
// jeśli nie ma żadnego kanału "bazowego", nie ma co liczyć dopasowania bazowych
|
|
||||||
if (baseWantedLen === 0) {
|
if (baseWantedLen === 0) {
|
||||||
// nadal zwracamy tematyczne
|
|
||||||
const thematicMap = new Map();
|
const thematicMap = new Map();
|
||||||
for (const ch of wanted) {
|
for (const ch of wanted) {
|
||||||
const tp = Array.isArray(ch.thematic_packages) ? ch.thematic_packages : [];
|
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 };
|
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) {
|
for (const ch of baseWanted) {
|
||||||
const pkgs = Array.isArray(ch.packages) ? ch.packages : [];
|
const pkgs = Array.isArray(ch.packages) ? ch.packages : [];
|
||||||
for (const p of pkgs) {
|
for (const p of pkgs) {
|
||||||
@@ -166,17 +94,19 @@ export default function JamboxChannelsSearch() {
|
|||||||
|
|
||||||
const all = Array.from(counts.values());
|
const all = Array.from(counts.values());
|
||||||
|
|
||||||
|
// Pakiety zawierające wszystkie wybrane kanały
|
||||||
const exact = all
|
const exact = all
|
||||||
.filter((p) => p.count === baseWantedLen)
|
.filter((p) => p.count === baseWantedLen)
|
||||||
.sort((a, b) => a.name.localeCompare(b.name, "pl"));
|
.sort((a, b) => a.name.localeCompare(b.name, "pl"));
|
||||||
|
|
||||||
|
// Pakiety zawierające część kanałów (posortowane po ilości dopasowań)
|
||||||
const ranked = all
|
const ranked = all
|
||||||
.filter((p) => p.count < baseWantedLen)
|
.filter((p) => p.count < baseWantedLen)
|
||||||
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name, "pl"))
|
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name, "pl"))
|
||||||
.slice(0, 12);
|
.slice(0, 12);
|
||||||
|
|
||||||
// ======= TEMATYCZNE (dodatki) =======
|
// Pakiety tematyczne (dodatki)
|
||||||
const thematicMap = new Map(); // key = tid
|
const thematicMap = new Map();
|
||||||
for (const ch of wanted) {
|
for (const ch of wanted) {
|
||||||
const tp = Array.isArray(ch.thematic_packages) ? ch.thematic_packages : [];
|
const tp = Array.isArray(ch.thematic_packages) ? ch.thematic_packages : [];
|
||||||
for (const p of tp) {
|
for (const p of tp) {
|
||||||
@@ -194,12 +124,26 @@ export default function JamboxChannelsSearch() {
|
|||||||
return { exact, ranked, thematic, baseWantedLen, wantedLen: wanted.length };
|
return { exact, ranked, thematic, baseWantedLen, wantedLen: wanted.length };
|
||||||
}, [wanted]);
|
}, [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 (
|
return (
|
||||||
<div class="f-chsearch">
|
<div class="f-chsearch">
|
||||||
<h1 class="f-section-title">Wyszukiwanie kanałów w pakietach telewizji</h1>
|
<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="f-chsearch__wanted">
|
||||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||||
<div class="text-lg font-semibold">Chciałbym mieć te kanały</div>
|
<div class="text-lg font-semibold">Chciałbym mieć te kanały</div>
|
||||||
@@ -213,7 +157,7 @@ export default function JamboxChannelsSearch() {
|
|||||||
|
|
||||||
{wanted.length === 0 ? (
|
{wanted.length === 0 ? (
|
||||||
<div class="opacity-80 mt-2">
|
<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.
|
wszystkie wybrane kanały.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -242,13 +186,13 @@ export default function JamboxChannelsSearch() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ✅ SUGESTIE PAKIETÓW */}
|
{/* SUGESTIE PAKIETÓW */}
|
||||||
<div class="f-chsearch__wanted-packages">
|
<div class="f-chsearch__wanted-packages">
|
||||||
<div class="font-semibold">
|
<div class="font-semibold">
|
||||||
Pakiety pasujące do wybranych kanałów:
|
Pakiety pasujące do wybranych kanałów:
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ======= GŁÓWNE (jak było) ======= */}
|
{/* Pakiety główne */}
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
<div class="opacity-80">Pakiety główne:</div>
|
<div class="opacity-80">Pakiety główne:</div>
|
||||||
|
|
||||||
@@ -283,7 +227,7 @@ export default function JamboxChannelsSearch() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ======= TEMATYCZNE — dodatki (bez liczenia) ======= */}
|
{/* Pakiety tematyczne – dodatki */}
|
||||||
{packageSuggestions.thematic.length > 0 && (
|
{packageSuggestions.thematic.length > 0 && (
|
||||||
<div class="mt-4">
|
<div class="mt-4">
|
||||||
<div class="opacity-80">
|
<div class="opacity-80">
|
||||||
@@ -294,10 +238,10 @@ export default function JamboxChannelsSearch() {
|
|||||||
{packageSuggestions.thematic.map((p) => (
|
{packageSuggestions.thematic.map((p) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
key={p.tid}
|
||||||
class="f-chsearch-pkg"
|
class="f-chsearch-pkg"
|
||||||
onClick={() => window.open(
|
onClick={() => window.open(
|
||||||
// `/internet-telewizja/pakiety-tematyczne#tid-${encodeURIComponent(p.tid)}`,
|
`/premium/${p.tid}`,
|
||||||
`/premium/${p.tid}`,
|
|
||||||
"_blank",
|
"_blank",
|
||||||
"noopener,noreferrer"
|
"noopener,noreferrer"
|
||||||
)}
|
)}
|
||||||
@@ -314,37 +258,38 @@ export default function JamboxChannelsSearch() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* SEARCH */}
|
{/* WYSZUKIWARKA */}
|
||||||
<div class="f-chsearch__top">
|
<div class="f-chsearch__top">
|
||||||
<div class="f-chsearch__inputwrap">
|
<div class="f-chsearch__inputwrap">
|
||||||
<input
|
<input
|
||||||
id="channel-search"
|
id="channel-search"
|
||||||
class="f-chsearch__input"
|
class="f-chsearch__input"
|
||||||
type="search"
|
type="search"
|
||||||
value={q}
|
value={search.query}
|
||||||
onInput={(e) => setQ(e.currentTarget.value)}
|
onInput={(e) => search.setQuery(e.currentTarget.value)}
|
||||||
placeholder="Szukaj kanału po nazwie…"
|
placeholder="Szukaj kanału po nazwie…"
|
||||||
aria-label="Szukaj kanału po nazwie"
|
aria-label="Szukaj kanału po nazwie"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{q && (
|
{search.query && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="f-chsearch__clear"
|
class="f-chsearch__clear"
|
||||||
aria-label="Wyczyść wyszukiwanie"
|
aria-label="Wyczyść wyszukiwanie"
|
||||||
onClick={() => setQ("")}
|
onClick={() => search.clear()}
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="f-chsearch-meta">{meta}</div>
|
{/* ✅ Meta z hooka zamiast ręcznego useMemo */}
|
||||||
|
<div class="f-chsearch-meta">{search.meta}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* LIST */}
|
{/* LISTA WYNIKÓW */}
|
||||||
<div class="f-chsearch__list" role="list">
|
<div class="f-chsearch__list" role="list">
|
||||||
{items.map((c) => {
|
{search.items.map((c) => {
|
||||||
const selected = isWanted(c);
|
const selected = isWanted(c);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -353,7 +298,7 @@ export default function JamboxChannelsSearch() {
|
|||||||
role="listitem"
|
role="listitem"
|
||||||
key={`${c.name}-${c.logo_url || ""}`}
|
key={`${c.name}-${c.logo_url || ""}`}
|
||||||
>
|
>
|
||||||
{/* kolumna 1 */}
|
{/* Kolumna lewa */}
|
||||||
<div class="f-chsearch__left">
|
<div class="f-chsearch__left">
|
||||||
{c.logo_url && (
|
{c.logo_url && (
|
||||||
<img
|
<img
|
||||||
@@ -366,7 +311,6 @@ export default function JamboxChannelsSearch() {
|
|||||||
|
|
||||||
<div class="f-chsearch__channel-name">{c.name}</div>
|
<div class="f-chsearch__channel-name">{c.name}</div>
|
||||||
|
|
||||||
{/* ✅ przycisk dodaj/usuń */}
|
|
||||||
<div class="mt-2">
|
<div class="mt-2">
|
||||||
{!selected ? (
|
{!selected ? (
|
||||||
<button
|
<button
|
||||||
@@ -374,7 +318,7 @@ export default function JamboxChannelsSearch() {
|
|||||||
class="btn btn-outline"
|
class="btn btn-outline"
|
||||||
onClick={() => addWanted(c)}
|
onClick={() => addWanted(c)}
|
||||||
>
|
>
|
||||||
Dodaj do “Chciałbym mieć”
|
Dodaj do "Chciałbym mieć"
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
@@ -388,7 +332,7 @@ export default function JamboxChannelsSearch() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* kolumna 2 */}
|
{/* Kolumna prawa */}
|
||||||
<div class="f-chsearch__right">
|
<div class="f-chsearch__right">
|
||||||
<div
|
<div
|
||||||
class="f-chsearch__desc f-chsearch__desc--html"
|
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 && (
|
{Array.isArray(c.packages) && c.packages.length > 0 && (
|
||||||
<div class="f-chsearch__packages">
|
<div class="f-chsearch__packages">
|
||||||
Dostępny w pakietach:
|
Dostępny w pakietach:
|
||||||
@@ -415,6 +360,7 @@ export default function JamboxChannelsSearch() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Pakiety tematyczne */}
|
||||||
{Array.isArray(c.thematic_packages) &&
|
{Array.isArray(c.thematic_packages) &&
|
||||||
c.thematic_packages.length > 0 && (
|
c.thematic_packages.length > 0 && (
|
||||||
<div class="f-chsearch__packages">
|
<div class="f-chsearch__packages">
|
||||||
@@ -425,7 +371,6 @@ export default function JamboxChannelsSearch() {
|
|||||||
type="button"
|
type="button"
|
||||||
class="f-chsearch-pkg"
|
class="f-chsearch-pkg"
|
||||||
onClick={() => window.open(
|
onClick={() => window.open(
|
||||||
// `/premium#tid-${encodeURIComponent(p.tid)}`,
|
|
||||||
`/premium/${p.tid}`,
|
`/premium/${p.tid}`,
|
||||||
"_blank",
|
"_blank",
|
||||||
"noopener,noreferrer"
|
"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">
|
<div class="f-chsearch-empty">
|
||||||
Brak wyników dla: <strong>{q}</strong>
|
Brak wyników dla: <strong>{search.query}</strong>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 { marked } from "marked";
|
||||||
|
import { useLocalSearch } from "../../hooks/useLocalSearch.js";
|
||||||
|
import { highlightText, highlightHtml } from "../../lib/highlightUtils.js";
|
||||||
import "../../styles/jambox-search.css";
|
import "../../styles/jambox-search.css";
|
||||||
|
|
||||||
function norm(s) {
|
/**
|
||||||
return String(s || "")
|
* Komponent renderujący markdown z podświetleniem wyszukiwanych fraz
|
||||||
.toLowerCase()
|
*/
|
||||||
.replace(/\u00a0/g, " ")
|
function HighlightedMarkdown({ text, query }) {
|
||||||
.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 }) {
|
|
||||||
const html = useMemo(() => {
|
const html = useMemo(() => {
|
||||||
// markdown -> html
|
// Markdown -> HTML
|
||||||
const raw = marked.parse(String(text || ""), {
|
const rawHtml = marked.parse(String(text || ""), {
|
||||||
gfm: true,
|
gfm: true,
|
||||||
breaks: true,
|
breaks: true,
|
||||||
headerIds: false,
|
headerIds: false,
|
||||||
mangle: false,
|
mangle: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
// highlight w HTML
|
// Podświetl query w HTML
|
||||||
return highlightHtml(raw, q);
|
return highlightHtml(rawHtml, query);
|
||||||
}, [text, q]);
|
}, [text, query]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
class="fuz-markdown"
|
className="fuz-markdown"
|
||||||
dangerouslySetInnerHTML={{ __html: html }}
|
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 = [] }) {
|
export default function JamboxMozliwosciSearch({ items = [] }) {
|
||||||
const [q, setQ] = useState("");
|
// ✅ Hook do lokalnego wyszukiwania
|
||||||
|
const search = useLocalSearch(items, ['title', 'content']);
|
||||||
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]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="f-chsearch">
|
<div className="f-chsearch">
|
||||||
<div class="f-chsearch__top">
|
{/* Search input */}
|
||||||
<div class="f-chsearch__inputwrap">
|
<div className="f-chsearch__top">
|
||||||
|
<div className="f-chsearch__inputwrap">
|
||||||
<input
|
<input
|
||||||
class="f-chsearch__input"
|
className="f-chsearch__input"
|
||||||
type="search"
|
type="search"
|
||||||
value={q}
|
value={search.query}
|
||||||
onInput={(e) => setQ(e.currentTarget.value)}
|
onInput={(e) => search.setQuery(e.currentTarget.value)}
|
||||||
placeholder="Szukaj funkcji po nazwie lub opisie…"
|
placeholder="Szukaj funkcji po nazwie lub opisie…"
|
||||||
aria-label="Szukaj funkcji po nazwie lub opisie"
|
aria-label="Szukaj funkcji po nazwie lub opisie"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{q && (
|
{search.hasQuery && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="f-chsearch__clear"
|
className="f-chsearch__clear"
|
||||||
aria-label="Wyczyść wyszukiwanie"
|
aria-label="Wyczyść wyszukiwanie"
|
||||||
onClick={() => setQ("")}
|
onClick={() => search.setQuery("")}
|
||||||
>
|
>
|
||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="f-chsearch-meta">{meta}</div>
|
<div className="f-chsearch-meta">{search.meta}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filtered.map((it, index) => {
|
{/* Results */}
|
||||||
|
{search.filtered.map((item, index) => {
|
||||||
const reverse = index % 2 === 1;
|
const reverse = index % 2 === 1;
|
||||||
const imageUrl = it.image || "";
|
const imageUrl = item.image || "";
|
||||||
const hasImage = !!imageUrl;
|
const hasImage = !!imageUrl;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section class="f-section" id={it.id} key={it.id}>
|
<section className="f-section" id={item.id} key={item.id}>
|
||||||
<div class={`f-section-grid ${hasImage ? "md:grid-cols-2" : "md:grid-cols-1"}`}>
|
<div className={`f-section-grid ${hasImage ? "md:grid-cols-2" : "md:grid-cols-1"}`}>
|
||||||
{hasImage && (
|
{hasImage && (
|
||||||
<img
|
<img
|
||||||
src={imageUrl}
|
src={imageUrl}
|
||||||
alt={it.title}
|
alt={item.title}
|
||||||
class={`f-section-image ${reverse ? "md:order-1" : "md:order-2"}`}
|
className={`f-section-image ${reverse ? "md:order-1" : "md:order-2"}`}
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div class={`${hasImage ? (reverse ? "md:order-2" : "md:order-1") : ""}`}>
|
<div className={`${hasImage ? (reverse ? "md:order-2" : "md:order-1") : ""}`}>
|
||||||
<h2 class="f-section-title">{highlightText(it.title, q)}</h2>
|
<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">
|
<div className="f-section-nav">
|
||||||
<a href="#top" class="btn btn-outline">Do góry ↑</a>
|
<a href="#top" className="btn btn-outline">Do góry ↑</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -190,11 +104,40 @@ export default function JamboxMozliwosciSearch({ items = [] }) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{q.length > 0 && filtered.length === 0 && (
|
{/* Empty state */}
|
||||||
<div class="f-chsearch-empty">
|
{search.isEmpty && (
|
||||||
Brak wyników dla: <strong>{q}</strong>
|
<div className="f-chsearch-empty">
|
||||||
|
Brak wyników dla: <strong>{search.query}</strong>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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";
|
import { money } from "../../../lib/money.js";
|
||||||
|
|
||||||
export default function FloatingTotal({ storageKey, totalMonthly, cenaOpis }) {
|
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)
|
||||||
|
*/
|
||||||
134
src/lib/highlightUtils.js
Normal file
134
src/lib/highlightUtils.js
Normal 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;
|
||||||
|
}
|
||||||
356
src/lib/pricing-calculator.js
Normal file
356
src/lib/pricing-calculator.js
Normal 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
|
||||||
|
*/
|
||||||
@@ -3,7 +3,7 @@ import path from "node:path";
|
|||||||
|
|
||||||
import DefaultLayout from "../../layouts/DefaultLayout.astro";
|
import DefaultLayout from "../../layouts/DefaultLayout.astro";
|
||||||
import SectionRenderer from "../../components/sections/SectionRenderer.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";
|
import { loadYamlFile } from "../../lib/loadYaml";
|
||||||
|
|
||||||
|
|||||||
@@ -126,3 +126,60 @@
|
|||||||
.f-card.is-target:hover {
|
.f-card.is-target:hover {
|
||||||
transform: translateY(-6px) scale(1.01);
|
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 */
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user