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