Offers, tabelka z uslugami

This commit is contained in:
dm
2025-11-21 21:01:47 +01:00
parent 8c0e59b173
commit c09f12f305
14 changed files with 699 additions and 17 deletions

View File

@@ -0,0 +1,124 @@
przelaczniki:
- id: "budynek"
etykieta: "Rodzaj budynku"
domyslny: "jednorodzinny"
opcje:
- id: "jednorodzinny"
nazwa: "Jednorodzinny"
- id: "wielorodzinny"
nazwa: "Wielorodzinny"
- id: "umowa"
etykieta: "Okres umowy"
domyslny: "24m"
opcje:
- id: "24m"
nazwa: "24 miesiące"
- id: "bezterminowa"
nazwa: "Bezterminowa"
# WIERSZE TABELI
funkcje:
- id: "pobieranie"
etykieta: "Prędkość pobierania"
- id: "wysylanie"
etykieta: "Prędkość wysyłania"
- id: "router"
etykieta: "Router Wi-Fi"
# - id: "tv"
# etykieta: "Pakiet TV"
- id: "umowa_info"
etykieta: "Umowa"
- id: "instalacja"
etykieta: "Aktywacja"
- id: "adres_ip"
etykieta: "Adres IP"
# PLANY jedna lista, ceny zależą od kombinacji przełączników
plany:
- id: "fiber100"
nazwa: "FIBER 100"
predkosc: "100 Mb/s"
popularny: false
ceny:
jednorodzinny:
# 12m: "59 zł/mies."
24m: "64 zł/mies."
bezterminowa: "84 zł/mies."
wielorodzinny:
# 12m: "55 zł/mies."
24m: "54 zł/mies."
bezterminowa: "74 zł/mies."
funkcje:
pobieranie: "do 100 Mb/s"
wysylanie: "do 50 Mb/s"
router: true
adres_ip: "Dynamiczny"
tv: false
umowa_info: "12 / 24 / bez umowy"
instalacja: "149 zł"
- id: "fiber300"
nazwa: "FIBER 300"
predkosc: "300 Mb/s"
popularny: true
ceny:
jednorodzinny:
# 12m: "79 zł/mies."
24m: "75 zł/mies."
bezterminowa: "95 zł/mies."
wielorodzinny:
# 12m: "69 zł/mies."
24m: "65 zł/mies."
bezterminowa: "85 zł/mies."
funkcje:
pobieranie: "do 300 Mb/s"
wysylanie: "do 150 Mb/s"
router: true
umowa_info: "12 / 24 / bez umowy"
instalacja: "149,00 zł"
adres_ip: "Dynamiczny"
- id: "fiber600"
nazwa: "FIBER 600"
predkosc: "600 Mb/s"
popularny: false
ceny:
jednorodzinny:
24m: "85 zł/mies."
bezterminowa: "105 zł/mies."
wielorodzinny:
24m: "75 zł/mies."
bezterminowa: "95 zł/mies."
funkcje:
pobieranie: "do 600 Mb/s"
wysylanie: "do 300 Mb/s"
router: true
umowa_info: "24 / bez umowy"
instalacja: "149 zł"
adres_ip: "Dynamiczny"
uslugi_dodatkowe:
- id: "public_ip"
nazwa: "Publiczny adres IP"
cena: "18,45 zł miesięcznie."
opis: |
Otrzymujesz unikalny, publiczny adres przypisany na stałe do Twojego łącza, który pozwala na:
- Monitoring domu
- Dostęp zdalny do urządzeń
- Hostowanie serwera lub aplikacji
Jest to przydatne dla firm, graczy i zaawansowanych użytkowników, którzy potrzebują stabilnej identyfikacji swojej sieci w internecie.
- id: telefon
nazwa: Telefon
cena: od wybranej opcji
opis: |
Profesjonalna telefonia VoIP jako dodatek do internetu
- Niższe koszty połączeń - szczególnie na komórki i za granicę
- Zachowaj dotychczasowy numer lub otrzymaj nowy lokalny
- Bez dodatkowych kabli - działa przez internet
- Jeden numer dostępny na wielu urządzeniach jednocześnie
- Krystalicznie czysty dźwięk HD
[Poznaj szczegóły oferty telefonii →](/telefon)

View File

@@ -18,4 +18,11 @@ paragraphs:
Sprawdź naszą pełną ofertę i wybierz rozwiązanie dopasowane do Twoich potrzeb. Sprawdź naszą pełną ofertę i wybierz rozwiązanie dopasowane do Twoich potrzeb.
- title:
content:
Router WiFi AC1200 w cenie instalacji, zapewnia jeszcze większą moc, szybkość, zasięg i bezpieczeństwo dla Twoich stale rosnących potrzeb sieciowych.
Dzięki funkcji sieci gościnnej Twoi goście mają dostęp do internetu, a Twoje urządzenia i dane pozostają bezpieczne.
Technologia dwuzakresowa eliminuje zakłócenia i gwarantuje płynne działanie sieci dla całej rodziny.
# Kolejne sekcje mozna dodawać poja wiać się bedą pod tabela produktów # Kolejne sekcje mozna dodawać poja wiać się bedą pod tabela produktów

View File

@@ -0,0 +1,6 @@
export function getActiveLabel(switches, selected, switchId) {
const sw = switches.find((s) => s.id === switchId);
if (!sw) return "";
const opt = sw.opcje.find((o) => o.id === selected[switchId]);
return opt?.nazwa ?? "";
}

16
src/helpers/getPrice.js Normal file
View File

@@ -0,0 +1,16 @@
export function getPrice(plan, switches, selected) {
try {
if (plan.cena) return plan.cena;
if (!switches.length) return plan.ceny || "-";
let v = plan.ceny;
for (const sw of switches) {
const key = selected[sw.id];
if (!v || !(key in v)) return "-";
v = v[key];
}
return v;
} catch {
return "-";
}
}

View File

@@ -0,0 +1,54 @@
import FuzMarkdown from "../Markdown.jsx";
import "../../styles/offers/offers-extra.css";
export default function OffersExtraServices({
extraServices,
openId,
toggle,
}) {
if (!extraServices.length) return null;
return (
<div class="fuz-extra-services">
<h3 class="fuz-title-small">Usługi dodatkowe</h3>
<div class="fuz-table-wrapper">
<table class="fuz-table">
<thead class="fuz-table-head">
<tr>
<th class="fuz-table-heading">Usługa</th>
<th class="fuz-table-heading center">Cena</th>
<th class="fuz-table-heading center w-32">Szczegóły</th>
</tr>
</thead>
<tbody>
{extraServices.map((srv, i) => (
<>
<tr class={i % 2 === 0 ? "fuz-row-even" : "fuz-row-odd"}>
<td class="fuz-feature-name">{srv.nazwa}</td>
<td class="fuz-feature-cell center">{srv.cena}</td>
<td class="fuz-feature-cell-btn center">
<button class="btn-link" onClick={() => toggle(srv.id)}>
{openId === srv.id ? "Zwiń" : "Szczegóły"}
</button>
</td>
</tr>
{openId === srv.id && (
<tr>
<td colSpan={3} class="fuz-expand-details">
<FuzMarkdown text={srv.opis} ctx={{ kanaly: srv.kanaly }} />
</td>
</tr>
)}
</>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,27 @@
import "../../styles/offers/offers-switches.css";
export default function OffersSwitches({ switches, selected, onSwitch }) {
if (!switches.length) return null;
return (
<div class="fuz-switches-wrapper">
{switches.map((sw) => (
<div class="fuz-switch-block">
<div class="fuz-switch-group">
{sw.opcje.map((op) => (
<button
type="button"
class={`fuz-switch ${
selected[sw.id] === op.id ? "active" : ""
}`}
onClick={() => onSwitch(sw.id, op.id)}
>
{op.nazwa}
</button>
))}
</div>
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,88 @@
import { getPrice } from "../../helpers/getPrice";
import { getActiveLabel } from "../../helpers/getActiveLabel";
import "../../styles/offers/offers-table.css";
export default function OffersTable({ switches, selected, plans, features }) {
return (
<div class="fuz-table-wrapper">
<table class="fuz-table">
<thead class="fuz-table-head">
<tr>
<th class="fuz-table-heading">Parametr</th>
{plans.map((plan) => (
<th
class={`fuz-plan-heading ${
plan.popularny ? "is-popular fuz-popular-top" : ""
}`}
>
<div class="fuz-plan-title">{plan.nazwa}</div>
<div class="fuz-plan-price">
{getPrice(plan, switches, selected)}
</div>
<div class="fuz-plan-speed">{plan.predkosc}</div>
</th>
))}
</tr>
</thead>
<tbody>
{features.map((f, rowIndex) => (
<tr class={rowIndex % 2 === 0 ? "fuz-row-even" : "fuz-row-odd"}>
<td class="fuz-feature-name">{f.etykieta}</td>
{plans.map((plan) => {
const isPopular = plan.popularny;
const isLastRow = rowIndex === features.length - 1;
if (f.id === "umowa_info") {
return (
<td
class={`fuz-feature-cell ${
isPopular
? `is-popular ${
isLastRow ? "fuz-popular-bottom" : ""
}`
: ""
}`}
>
{getActiveLabel(switches, selected, "umowa")}
</td>
);
}
const val = plan.funkcje?.[f.id];
const baseClass =
val === true
? "fuz-feature-yes"
: val === false || val == null
? "fuz-feature-no"
: "fuz-feature-cell";
return (
<td
class={`${baseClass} ${
isPopular
? `is-popular ${
isLastRow ? "fuz-popular-bottom" : ""
}`
: ""
}`}
>
{val === true
? "✓"
: val === false || val == null
? "✕"
: val}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
);
}

View File

@@ -0,0 +1,65 @@
import { useState } from "preact/hooks";
import FuzMarkdown from "./Markdown.jsx";
import OffersSwitches from "./Offers/OffersSwitches.jsx";
import OffersTable from "./Offers/OffersTable.jsx";
import OffersExtraServices from "./Offers/OffersExtraServices.jsx";
import "../styles/offers/offers-main.css";
export default function InternetOffersIsland({ data }) {
const switches = data.przelaczniki ?? [];
const features = data.funkcje ?? [];
const plans = data.plany ?? [];
const extraServices = data.uslugi_dodatkowe ?? [];
const initialSelected = {};
switches.forEach((sw) => {
initialSelected[sw.id] = sw.domyslny;
});
const [selected, setSelected] = useState(initialSelected);
const [openServiceId, setOpenServiceId] = useState(null);
const toggleService = (id) =>
setOpenServiceId((prev) => (prev === id ? null : id));
const handleSwitchClick = (switchId, optionId) =>
setSelected((prev) => ({ ...prev, [switchId]: optionId }));
return (
<section class="fuz-offers-section">
<div class="fuz-offers-container">
{data.opis_gorny && (
<div class="fuz-offers-description">
<FuzMarkdown text={data.opis_gorny} />
</div>
)}
<OffersSwitches
switches={switches}
selected={selected}
onSwitch={handleSwitchClick}
/>
<OffersTable
switches={switches}
selected={selected}
plans={plans}
features={features}
/>
<OffersExtraServices
extraServices={extraServices}
openId={openServiceId}
toggle={toggleService}
/>
{data.opis_dolny && (
<div class="fuz-offers-description">
<FuzMarkdown text={data.opis_dolny} />
</div>
)}
</div>
</section>
);
}

View File

@@ -3,6 +3,7 @@ import DefaultLayout from "../../layouts/DefaultLayout.astro";
import Hero from "../../components/hero/Hero.astro"; import Hero from "../../components/hero/Hero.astro";
import SectionRenderer from "../../components/sections/SectionRenderer.astro"; import SectionRenderer from "../../components/sections/SectionRenderer.astro";
import Markdown from "../../islands/Markdown.jsx"; import Markdown from "../../islands/Markdown.jsx";
import OffersIsland from "../../islands/OffersIsland.jsx";
import yaml from "js-yaml"; import yaml from "js-yaml";
import fs from "fs"; import fs from "fs";
@@ -17,7 +18,16 @@ const page = yaml.load(
fs.readFileSync("./src/content/internet-swiatlowodowy/page.yaml", "utf8"), fs.readFileSync("./src/content/internet-swiatlowodowy/page.yaml", "utf8"),
); );
type Paragraph = {
title?: string;
content: string;
};
const data = yaml.load(
fs.readFileSync("./src/content/internet-swiatlowodowy/offers.yaml", "utf8"),
);
const first = page.paragraphs[0]; const first = page.paragraphs[0];
const rest = page.paragraphs.slice(1);
--- ---
<DefaultLayout seo={seo}> <DefaultLayout seo={seo}>
@@ -26,28 +36,21 @@ const first = page.paragraphs[0];
<section class="fuz-section text-center"> <section class="fuz-section text-center">
<div class="fuz-section-grid md:grid-cols-1"> <div class="fuz-section-grid md:grid-cols-1">
{page.title.map((line: any) => <h1 class="fuz-section-title">{line}</h1>)} {page.title.map((line: any) => <h1 class="fuz-section-title">{line}</h1>)}
{first.title && <h3>{first.title}</h3>} {first.title && <h3>{first.title}</h3>}
<Markdown text={first.content} /> <Markdown text={first.content} />
</div> </div>
</section> </section>
{ <OffersIsland client:load data={data} />
page.paragraphs.slice(1).map((p: { title: string; content: string }) => (
<section class="fuz-section text-center">
<div class="fuz-section-grid md:grid-cols-1">
{p.title && <h3 class="fuz-section-title">{p.title}</h3>}
{p.content {rest.map((p: Paragraph) => (
.trim() <section class="fuz-section text-center">
.split(/\n\n+/) <div class="fuz-section-grid md:grid-cols-1">
.map((par: string) => ( {p.title && <h3 class="fuz-section-title">{p.title}</h3>}
<Markdown text={par} /> <Markdown text={p.content.replace(/\n/g, "\n\n")} />
))} </div>
</div> </section>
</section> ))}
))
}
<SectionRenderer src="./src/content/internet-swiatlowodowy/section.yaml" /> <SectionRenderer src="./src/content/internet-swiatlowodowy/section.yaml" />
<!-- <section class="fuz-section"> <!-- <section class="fuz-section">

View File

@@ -21,7 +21,7 @@
} }
.fuz-markdown ul { .fuz-markdown ul {
@apply list-disc pl-6 mb-4; @apply list-disc pl-10 mb-4;
} }
.fuz-markdown ol { .fuz-markdown ol {
@@ -32,6 +32,14 @@
@apply mb-1; @apply mb-1;
} }
.fuz-markdown ul li::marker {
color: var(--fuz-accent);
}
.dark .fuz-markdown ul li::marker {
color: var(--fuz-accent);
}
.fuz-markdown a { .fuz-markdown a {
@apply text-blue-600 dark:text-blue-400 underline hover:no-underline; @apply text-blue-600 dark:text-blue-400 underline hover:no-underline;
} }

View File

@@ -0,0 +1,27 @@
.fuz-extra-services {
@apply mt-16;
color: var(--fuz-text);
}
.fuz-title-small {
@apply text-xl font-semibold mb-4;
color: var(--fuz-text);
}
.fuz-expand-details {
@apply px-4 py-4;
background: rgba(0, 0, 0, 0.04);
}
:root.dark .fuz-expand-details {
background: rgba(255, 255, 255, 0.04);
}
.btn-link {
@apply underline cursor-pointer;
color: var(--fuz-accent);
}
.btn-link:hover {
opacity: 0.7;
}

View File

@@ -0,0 +1,14 @@
.fuz-offers-section {
@apply py-12;
background: var(--fuz-bg);
color: var(--fuz-text);
}
.fuz-offers-container {
@apply max-w-7xl mx-auto px-6;
}
.fuz-offers-description {
@apply mb-10 text-base leading-relaxed;
color: var(--fuz-text);
}

View File

@@ -0,0 +1,31 @@
.fuz-switches-wrapper {
@apply flex flex-wrap justify-center gap-6 mb-12;
}
.fuz-switch-group {
@apply inline-flex rounded-full overflow-hidden relative;
background: rgba(0, 0, 0, 0.08);
}
:root.dark .fuz-switch-group {
background: rgba(255, 255, 255, 0.12);
}
.fuz-switch {
@apply px-6 py-2 text-sm font-semibold cursor-pointer select-none transition-all;
color: var(--fuz-text);
opacity: 0.7;
}
.fuz-switch:hover {
opacity: 0.9;
}
.fuz-switch.active {
background: var(--fuz-accent);
color: var(--btn-text);
opacity: 1;
box-shadow: 0 2px 8px rgba(0,0,0,0.18);
}

View File

@@ -0,0 +1,212 @@
/* =========================================
TABELA — KONTENER
========================================= */
.fuz-table-wrapper {
@apply overflow-x-auto rounded-3xl shadow-lg mb-0;
background: var(--fuz-bg);
border: 1px solid rgba(0,0,0,0.07);
}
:root.dark .fuz-table-wrapper {
border: 1px solid rgba(255,255,255,0.12);
}
.fuz-table {
@apply min-w-full border-collapse;
color: var(--fuz-text);
}
/* =========================================
NAGŁÓWEK
========================================= */
.fuz-table-head {
background: color-mix(in srgb, var(--fuz-text) 6%, transparent);
}
:root.dark .fuz-table-head {
background: color-mix(in srgb, var(--fuz-text) 12%, transparent);
}
.fuz-table-heading {
@apply text-center font-semibold py-4 px-4 text-lg;
color: var(--fuz-text);
}
/* =========================================
NAGŁÓWEK PLANU
========================================= */
.fuz-plan-heading {
@apply text-center py-4 px-4 align-bottom;
position: relative;
}
.fuz-plan-title {
@apply text-lg font-semibold mb-1;
color: var(--fuz-text);
}
.fuz-plan-price {
@apply text-2xl font-extrabold mb-1;
color: var(--fuz-accent);
}
.fuz-plan-speed {
@apply text-xs;
opacity: 0.7;
}
/* Badge popularności jeśli używasz */
.fuz-plan-badge {
@apply text-[10px] px-2 py-0.5 rounded-full uppercase tracking-wide inline-block mb-2;
background: var(--fuz-accent);
color: var(--fuz-accent-text);
}
/* =========================================
WIERSZE
========================================= */
.fuz-row-even {
background: color-mix(in srgb, var(--fuz-text) 4%, transparent);
}
:root.dark .fuz-row-even {
background: color-mix(in srgb, var(--fuz-text) 10%, transparent);
}
.fuz-row-odd {
background: transparent;
}
.fuz-feature-name {
@apply py-3 px-4 text-lg font-medium;
color: var(--fuz-text);
border-top: 1px solid rgba(0,0,0,0.07);
}
:root.dark .fuz-feature-name {
border-top: 1px solid rgba(255,255,255,0.12);
}
.fuz-feature-cell {
@apply py-3 px-4 text-center text-lg;
color: var(--fuz-text);
border-top: 1px solid rgba(0,0,0,0.07);
}
:root.dark .fuz-feature-cell {
border-top: 1px solid rgba(255,255,255,0.12);
}
.fuz-feature-cell-btn {
@apply py-3 px-4 text-center text-sm;
}
/* =========================================
CHECKMARKS
========================================= */
/* ✔ = kolor accent */
.fuz-feature-yes {
@apply py-3 px-4 text-center font-bold text-base;
color: var(--fuz-accent);
border-top: 1px solid rgba(0,0,0,0.07);
}
:root.dark .fuz-feature-yes {
border-top: 1px solid rgba(255,255,255,0.12);
}
/* ✕ = szary / low opacity */
.fuz-feature-no {
@apply py-3 px-4 text-center font-bold text-base;
opacity: 0.45;
border-top: 1px solid rgba(0,0,0,0.07);
}
:root.dark .fuz-feature-no {
border-top: 1px solid rgba(255,255,255,0.12);
}
/* =========================================
POPULARNY PLAN — WARIANT C (MOCNY)
========================================= */
/* pełne tło pastel na ACCENT (mocniejsze C) */
.is-popular,
.fuz-popular-col {
background: color-mix(in srgb, var(--fuz-accent) 22%, transparent) !important;
border-left: 2px solid var(--fuz-accent);
border-right: 2px solid var(--fuz-accent);
position: relative;
z-index: 10;
}
:root.dark .is-popular,
:root.dark .fuz-popular-col {
background: color-mix(in srgb, var(--fuz-accent) 32%, transparent) !important;
}
/* górny border */
.fuz-popular-top {
border-top: 2px solid var(--fuz-accent);
}
/* dolny border */
.fuz-popular-bottom {
border-bottom: 2px solid var(--fuz-accent);
}
/* zbijamy border wewnątrz popularnych */
.fuz-popular-col.fuz-feature-cell,
.fuz-popular-col.fuz-feature-yes,
.fuz-popular-col.fuz-feature-no {
border-top: none !important;
}
/* USŁUGI DODATKOWE */
/* Górny border pierwszego wiersza sekcji */
.fuz-extra-services table tbody tr:first-child td {
border-top: 1px solid rgba(0,0,0,0.07);
}
:root.dark .fuz-extra-services table tbody tr:first-child td {
border-top: 1px solid rgba(255,255,255,0.15);
}
/* Wiersz "Szczegóły" (opis) — zawsze pełny border-top */
.fuz-expand-details {
border-top: 0px solid rgba(0,0,0,0.1);
}
:root.dark .fuz-expand-details {
border-top: 1px solid rgba(255,255,255,0.15);
}
/* I zawsze ładny border-bottom na końcu sekcji */
.fuz-extra-services table tbody tr:last-child td {
border-bottom: 1px solid rgba(0,0,0,0.1);
}
:root.dark .fuz-extra-services table tbody tr:last-child td {
border-bottom: 1px solid rgba(255,255,255,0.15);
}
/* Usunięcie "pustych" borderów kolumny szczegóły */
.fuz-feature-cell-btn {
border-top: 1px solid rgba(0,0,0,0.07);
}
:root.dark .fuz-feature-cell-btn {
border-top: 1px solid rgba(255,255,255,0.12);
}