diff --git a/src/components/ui/BaseOfferCards.jsx b/src/components/ui/BaseOfferCards.jsx
new file mode 100644
index 0000000..61b277c
--- /dev/null
+++ b/src/components/ui/BaseOfferCards.jsx
@@ -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 (
+ Brak dostępnych pakietów.{title}
}
+
+ {/* Description */}
+ {description && (
+
Brak dostępnych pakietów.
- ) : ( -Brak pakietów do wyświetlenia.
- ) : ( -Brak dostępnych pakietów.
- ) : ( -Brak dostępnych pakietów.
+ ) : ( +Hello World
"; + * highlightHtml(html, "world") + * // => "Hello World
" + */ +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 + 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; +} \ No newline at end of file diff --git a/src/lib/pricing-calculator.js b/src/lib/pricing-calculator.js new file mode 100644 index 0000000..9351f92 --- /dev/null +++ b/src/lib/pricing-calculator.js @@ -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 + */ \ No newline at end of file diff --git a/src/pages/telefon/index.astro b/src/pages/telefon/index.astro index 0a965bd..bcc4799 100644 --- a/src/pages/telefon/index.astro +++ b/src/pages/telefon/index.astro @@ -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"; diff --git a/src/styles/cards.css b/src/styles/cards.css index 64c15f9..2c57982 100644 --- a/src/styles/cards.css +++ b/src/styles/cards.css @@ -125,4 +125,61 @@ .f-card.is-target:hover { transform: translateY(-6px) scale(1.01); +} + + +/* + * Dodatkowe style dla komponentu OfferCard + * Plik: src/styles/card-actions.css + * + * Dodaj do cards.css lub jako osobny import + */ + +/* Kontener akcji (przycisków) w karcie */ +.f-card-actions { + display: flex; + flex-direction: column; + gap: 0.5rem; /* 8px */ + margin-top: 1rem; /* 16px */ + width: 100%; +} + +/* Wszystkie przyciski w kontenerze akcji rozciągają się na pełną szerokość */ +.f-card-actions button, +.f-card-actions .btn { + width: 100%; +} + +/* Opcjonalnie: Wariant z przyciskami w rzędzie (dla 2 przycisków obok siebie) */ +.f-card-actions--row { + flex-direction: row; +} + +.f-card-actions--row button, +.f-card-actions--row .btn { + flex: 1; /* równa szerokość */ +} + +/* Opcjonalnie: Wariant z przyciskami w grid (dla 3+ przycisków) */ +.f-card-actions--grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 0.5rem; +} + +/* Responsywność: Na mobile zawsze kolumna */ +@media (max-width: 640px) { + .f-card-actions--row { + flex-direction: column; + } + + .f-card-actions--grid { + grid-template-columns: 1fr; + } +} + +/* Spacing dla przycisków które mają mt-2, mt-4 itp */ +.f-card-actions .mt-2, +.f-card-actions .mt-4 { + margin-top: 0; /* Reset individual margins */ } \ No newline at end of file