Porządkowanie kodu, dodanie sekcji wyszukiwania kanałów

This commit is contained in:
dm
2025-12-12 19:48:53 +01:00
parent bf67147cf5
commit 5822237745
47 changed files with 17203 additions and 15686 deletions

View File

@@ -25,16 +25,13 @@ const {
ctas = []
} = Astro.props;
// Wyciągnij nazwę bazową bez rozszerzenia
const imageBase = imageUrl.replace(/\.(webp|png|jpg|jpeg)$/i, '');
// Importuj wszystkie obrazki
const images = import.meta.glob<{ default: ImageMetadata }>(
'/src/assets/hero/**/*.webp',
{ eager: true }
);
// Funkcja do znajdowania obrazka dla danego rozmiaru
function findImage(folder: string): ImageMetadata | null {
const key = `/src/assets/hero/${folder}/${imageBase}-${folder}.webp`;
return images[key]?.default || null;

View File

@@ -8,6 +8,7 @@ const links = [
{ name: "TELEFON", href: "/telefon" },
{ name: "ZASIĘG SIECI", href: "/mapa-zasiegu" },
{ name: "KONTAKT", href: "/kontakt" },
{ name: "DOKUMENTY", href: "/dokumenty" },
{
name: "BOK",
href: "https://panel.fuz.pl/userpanel/auth",

View File

@@ -0,0 +1,58 @@
---
import { Image } from "astro:assets";
import type { ImageMetadata } from "astro";
import Markdown from "../../islands/Markdown.jsx";
import TvChannelsSearch from "../../islands/jambox/JamboxChannelsSearch.jsx";
const props = Astro.props ?? {};
const section = props.section ?? {};
const index = Number(props.index ?? 0);
const hasImage = !!section.image;
const reverse = index % 2 === 1;
const sectionImages = import.meta.glob<{ default: ImageMetadata }>(
"/src/assets/sections/**/*.{png,jpg,jpeg,webp,avif}",
{ eager: true }
);
let sectionImage: ImageMetadata | null = null;
if (section.image) {
const path = `/src/assets/sections/${section.image}`;
const mod = sectionImages[path];
if (mod) sectionImage = mod.default;
}
---
<section class="f-section">
<div class={`f-section-grid ${hasImage ? "md:grid-cols-2" : "md:grid-cols-1"}`}>
{sectionImage && (
<Image
src={sectionImage}
alt={section.title ?? "Kanały TV"}
class={`f-section-image ${reverse ? "md:order-1" : "md:order-2"} ${section.dimmed ? "f-image-dimmed" : ""}`}
loading="lazy"
decoding="async"
format="webp"
widths={[480, 768, 1024, 1440]}
sizes="100vw"
/>
)}
<div class={`${hasImage ? (reverse ? "md:order-2" : "md:order-1") : ""}`}>
{section.title && <h2 class="f-section-title">{section.title}</h2>}
{section.content && <Markdown text={section.content} />}
<TvChannelsSearch client:load />
{section.button && (
<div class="f-section-nav">
<a href={section.button.url} class="btn btn-primary" title={section.button.title}>
{section.button.text}
</a>
</div>
)}
</div>
</div>
</section>

View File

@@ -1,125 +0,0 @@
---
import yaml from "js-yaml";
import fs from "fs";
import MapGoogle from "../../components/maps/MapGoogle.astro";
const data = yaml.load(
fs.readFileSync("./src/content/contact/contact.yaml", "utf8")
);
const apiKey = import.meta.env.PUBLIC_GOOGLE_MAPS_KEY;
const form = data.form;
---
<section id="kontakt" class="f-section">
<div class="f-contact-grid">
<!-- Kolumna lewa -->
<div class="f-contact-col-1">
<h2>{data.title}</h2>
<div class="f-contact-item" set:html={data.description}></div>
</div>
<div class="f-contact-col-2">
<h2>{data.contactFormTitle}</h2>
<form id="contactForm" class="f-contact-form">
<div class="f-contact-form-inner">
<input type="text" name="firstName" placeholder={form.firstName.placeholder} class="f-input" required />
<input type="text" name="lastName" placeholder={form.lastName.placeholder} class="f-input" required />
</div>
<div class="grid grid-cols-2 gap-4">
<input type="email" name="email" placeholder={form.email.placeholder} class="f-input" required autocomplete="email" />
<input type="tel" name="phone" placeholder={form.phone.placeholder} class="f-input" required autocomplete="tel" />
</div>
<input type="text" name="subject" placeholder={form.subject.placeholder} class="f-input" required />
<textarea name="message" rows={form.message.rows} placeholder={form.message.placeholder} class="f-input" required></textarea>
<label class="f-rodo">
<input type="checkbox" name="rodo" required />
<span>
{form.rodo.label}
<a href={form.rodo.policyLink} title={form.rodo.policyTitle}>{form.rodo.policyText}</a>.
</span>
</label>
<button title={form.submit.title}>{form.submit.label}</button>
</form>
</div>
</div>
<div class="f-contact-map">
<MapGoogle
apiKey={apiKey}
lat={data.lat}
lon={data.lng}
zoom={16}
title={data.markerTitle}
description={data.markerAddress}
showMarker={true}
mode="contact"
mapStyleId={data.maps.mapId}
/>
</div>
<div id="toast" class="f-toast"></div>
</section>
<!-- ReCaptcha v3 -->
<script is:inline define:vars={{ siteKey: import.meta.env.PUBLIC_RECAPTCHA_SITE_KEY }}>
window.FUZ_RECAPTCHA_KEY = siteKey;
const s = document.createElement("script");
s.src = "https://www.google.com/recaptcha/api.js?render=" + siteKey;
s.async = true;
document.head.appendChild(s);
</script>
<script
is:inline
define:vars={{
successMsg: form.successMessage,
errorMsg: form.errorMessage
}}
>
document.addEventListener("DOMContentLoaded", () => {
const form = document.getElementById("contactForm");
const toast = document.getElementById("toast");
if (!form) return;
form.addEventListener("submit", async (e) => {
if (!form.reportValidity()) return;
e.preventDefault();
const data = Object.fromEntries(new FormData(form).entries());
data.rodo = form.rodo.checked;
const token = await grecaptcha.execute(window.FUZ_RECAPTCHA_KEY, { action: "submit" });
data.recaptcha = token;
const resp = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data)
});
const json = await resp.json();
showToast(json.ok ? successMsg : errorMsg, json.ok ? "success" : "error");
if (json.ok) form.reset();
});
function showToast(msg, type) {
toast.innerHTML = `<div class="f-toast-msg ${type}">${msg}</div>`;
toast.classList.remove("visible");
void toast.offsetWidth;
toast.classList.add("visible");
setTimeout(() => toast.classList.remove("visible"), 3000);
}
});
</script>

View File

@@ -43,11 +43,9 @@ if (section.image) {
<div
class={`f-section-grid ${hasImage ? (reverse ? "md:order-2" : "md:order-1") : ""}`}
>
<!-- TODO: Styl nagłowka powinien byc trochę niżej -->
<h2 class="f-section-title">{section.title}</h2>
<Markdown text={section.content} />
{
section.button && (
<div class="f-section-nav">

View File

@@ -1,26 +0,0 @@
---
import ChannelSwitcher from "../../islands/ChannelSwitcher.jsx";
const { section } = Astro.props;
---
<section class="f-section">
<div class="max-w-7xl mx-auto text-center">
{section.title && (
<h2 class="f-section-title">{section.title}</h2>
)}
{section.content && (
<div class="f-markdown mb-10" set:html={section.html} />
)}
{section.iframe_sets && section.iframe_sets.length > 0 && (
<ChannelSwitcher
client:load
sets={section.iframe_sets}
title={section.title}
/>
)}
</div>
</section>

View File

@@ -1,67 +0,0 @@
---
import Markdown from "../../islands/Markdown.jsx";
// Pobranie XML Jambox
const url = "https://www.jambox.pl/xml/mozliwosci.xml";
const xmlText: string = await fetch(url).then(r => r.text());
// Parser wszystkich <node>
function parseNodes(xml: string) {
return [...xml.matchAll(/<node>([\s\S]*?)<\/node>/g)].map((match) => {
const block = match[1];
const get = (tag: string) => {
const m = block.match(new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`));
return m ? m[1].trim() : "";
};
return {
title: get("title"),
teaser: get("teaser"),
description: get("description"),
icon: get("icon"),
};
});
}
const nodes = parseNodes(xmlText);
---
<section class="f-section">
<h2 class="f-section-title mb-10">Dodatkowe możliwości telewizji JAMBOX</h2>
{nodes.map((item, i) => {
const reverse = i % 2 === 1;
return (
<div class={`f-section-item py-14`}>
<div class={`f-section-grid md:grid-cols-2`}>
<!-- OBRAZ -->
<div class={`${reverse ? "md:order-2" : "md:order-1"}`}>
<img
src={item.icon}
alt={item.title}
loading="lazy"
class="f-section-image rounded-xl shadow-lg"
/>
</div>
<!-- TEKST -->
<div class={`f-section-grid ${reverse ? "md:order-1" : "md:order-2"}`}>
<h3 class="f-section-title text-2xl mb-4">{item.title}</h3>
{item.teaser && (
<p class="text-lg font-medium opacity-80 mb-4">
{item.teaser}
</p>
)}
<Markdown text={item.description} />
</div>
</div>
</div>
);
})}
</section>

View File

@@ -4,7 +4,7 @@ import fs from "fs";
import { marked } from "marked";
import SectionDefault from "./SectionDefault.astro";
import SectionIframeChannels from "./SectionIframeChannels.astro";
const { src } = Astro.props;
@@ -19,9 +19,5 @@ const sections = (data.sections as any[]).map((s: any) => ({
{sections.map((section: any, index: number) => {
const type = section.type || "default";
if (type === "iframe-channels") {
return <SectionIframeChannels section={section} index={index} />;
}
return <SectionDefault section={section} index={index} />;
})}

View File

@@ -1,21 +0,0 @@
---
const { href, variant = "primary" } = Astro.props;
const base =
"inline-flex items-center justify-center rounded-full px-4 py-2 text-sm font-medium transition";
const variants = {
primary: "bg-sky-600 text-white hover:bg-sky-700 dark:bg-sky-500 dark:hover:bg-sky-400",
secondary: "bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-100"
};
const classes = `${base} ${variants[variant] ?? variants.primary}`;
---
{href ? (
<a href={href} class={classes}>
<slot />
</a>
) : (
<button type="button" class={classes}>
<slot />
</button>
)}

View File

@@ -1,15 +0,0 @@
---
const { message, type = "success" } = Astro.props;
const base = "fixed right-4 top-20 z-50 rounded-xl px-4 py-3 text-sm shadow-lg border backdrop-blur";
const variants = {
success: "bg-emerald-50/90 border-emerald-200 text-emerald-900 dark:bg-emerald-900/70 dark:border-emerald-700 dark:text-emerald-50",
error: "bg-rose-50/90 border-rose-200 text-rose-900 dark:bg-rose-900/70 dark:border-rose-700 dark:text-rose-50",
info: "bg-sky-50/90 border-sky-200 text-sky-900 dark:bg-sky-900/70 dark:border-sky-700 dark:text-sky-50"
};
const classes = `${base} ${variants[type] ?? variants.success}`;
---
<div class={classes}>
{message}
</div>

View File

@@ -0,0 +1,29 @@
site:
name: "FUZ Internet światłowodowy w Wyszkowie"
description: "Stabilny i szybki internet w Wyszkowie i okolicach"
url: "https://www.fuz.pl"
lang: "pl"
company:
name: "FUZ Adam Rojek"
phone: "+48 (29) 643 80 55"
email: "biuro@fuz.pl"
street: "ul. Świętojańska 46"
city: "Wyszków"
postal: "07-200"
country: "PL"
lat: 52.597385
lon: 21.456797
logo: "/images/logo-fuz.webp"
page:
title: "FUZ Internet światłowodowy w Wyszkowie"
description: "Szybki, stabilny internet światłowodowy w Wyszkowie. Lokalny operator, realny serwis, błyskawiczne wsparcie."
image: "/images/og-home.webp"
url: "/"
keywords:
- internet Wyszków
- światłowód Wyszków
- internet światłowodowy Wyszków
- lokalny operator internetu
schema: {}

View File

@@ -18,8 +18,3 @@ ctas:
href: "/internet-telewizja"
title: "Przejdź do oferty Internet + Telewizja w FUZ"
primary: false
# - label: "Sprawdź dostępność usługi"
# href: "/mapa-zasiegu"
# title: "Sprawdź zasięg Internetu światłowodowego FUZ"
# primary: false

View File

@@ -1,14 +1,4 @@
sections:
# - title: Sprawdź dostępność usługi
# image:
# button:
# text: "Sprawdź dostępność pod Twoim adresem →"
# url: "/mapa-zasiegu"
# title: "Sprawdź zasięg Internetu światłowodowego FUZ"
# content: |
# Naszą sieć światłowodową systematycznie rozbudowujemy, ale infrastruktura nie dociera jeszcze do wszystkich adresów.
# Sprawdź zasięg naszego Internetu na interaktywnej mapie, czy internet światłowodowy jest już dostępny pod Twoim adresem.
- title: Router WiFi HL-4BX3V-F
image: "HL-4BX3V-F.webp"
content: |

View File

@@ -1,26 +0,0 @@
title:
- Internet plus telewizja w pakiecie
subtitle:
- Internet bez kompromisów
- Bogata oferta kanałów telewizyjnych
- Wszystko, czego potrzebujesz w jednym pakiecie
# description: |
# Szybki i stabilny Internet światłowodowy w pakiecie z telewizją w Wyszkowie oraz okolicach.
# Sprawdź zasięg usług i wybierz najlepsze łącze dla swojego domu.
imageUrl: "section-tv.webp"
ctas:
- label: "Zobacz ofertę Internetu"
href: "/internet-swiatlowodowy"
title: "Przejdź do oferty Internetu światłowodowego"
primary: false
- label: "Zobacz ofertę Telefonu"
href: "/telefon"
title: "Przejdź do oferty telefonu"
primary: false
# - label: "Sprawdź dostępność usługi"
# href: "/mapa-zasiegu"
# title: "Sprawdź zasięg Internetu światłowodowego FUZ"
# primary: false

View File

@@ -1,50 +0,0 @@
catchup:
title: CATCHUP ARCHIWUM TV
content: |
Funkcja CatchUp pozwala na oglądanie archiwalnych audycji na wybranych kanałach do 7 dni wstecz.
Przegapiłeś jakiś program? Zapomniałeś nagrać?
Odnajdź go w EPG, cofając się w lewo na osi czasu.
![Catchup screen](/images/jambox-kyanit-catchup1.webp)
nagrywarka:
title: NAGRYWARKA
content: |
Nasze dekodery posiadają możliwość nagrywania wybranych lub wszystkich kanałów, w zależności od modelu dekodera.
Nagrywarka sieciowa pozwala na nagrywanie i oglądanie audycji bez dysku.
Dzięki tej wersji nagrywarki możesz zlecać w tym samym czasie 3 niezależne nagrania.
Korzyści jakie daje nagrywarka to między innymi możliwość oglądania nagrania od początku, nawet jak włączyłeś je w trakcie emisji na żywo.
Przydatna funkcja to możliwość zaprogramowania nagrywarki także z aplikacji JAMBOX go! Nagrania są dostępne na dekoderze przez 7 dni.
Nagrywanie w chmurze dostępne jest na dekoderach Abox M15, Hybroad Z123 oraz Arris VIP 4302.
![Nagrywarka](/images/jambox-kyanit-jpvr.webp)
startover:
title: STARTOVER OGLĄDAJ OD POCZĄTKU
content: |
Funkcja StartOver pozwala na oglądanie od początku tych audycji, które już się rozpoczęły, lecz jeszcze nie skończyły.
Spóźniłeś się na emisję na żywo?
Nic nie szkodzi! Kliknij "Oglądaj od początku" i już nic Ci nie umknie!
![STARTOVER](/images/jambox-kyanit-startover1.webp)
nagrywanie_cykliczne:
title: NAGRYWANIE CZASOWE/CYKLICZNE
content: |
Jeśli lubisz oglądać każdego dnia wiadomości, albo chcesz nagrać Twojemu dziecku wszystkie wieczorynki to możesz teraz w prosty sposób zaplanować nagrania.
Nagrywanie według czasu to funkcja, która umożliwia Ci zaprogramowanie nagrań powtarzających się.
Zaplanuj nagrania codzienne, weekendowe czy w wybranych przez Ciebie dniach. Dodatkowo możesz nagrać dowolny przedział czasu na kanale.
Nagrywanie czasowe jest dostępne na dekoderze wyposażonym w dysk dedykowany lub USB.

View File

@@ -1,211 +0,0 @@
przelaczniki:
- id: "budynek"
etykieta: "Rodzaj budynku"
domyslny: "jednorodzinny"
title: "Zmień rodzaj budynku by zobaczyć odpowiednie ceny"
opcje:
- id: "jednorodzinny"
nazwa: "Jednorodzinny"
- id: "wielorodzinny"
nazwa: "Wielorodzinny"
- id: "umowa"
etykieta: "Okres umowy"
domyslny: "24m"
title: "Wybierz okres umowy by zobaczyć odpowiednie ceny"
opcje:
- id: "24m"
nazwa: "24 miesiące"
- id: "bezterminowa"
nazwa: "Bezterminowa"
funkcje:
- id: "pobieranie"
etykieta: "Pobieranie"
- id: "wysylanie"
etykieta: "Wysyłanie"
- id: "router"
etykieta: "Router Wi-Fi"
- id: "kanaly"
etykieta: "Liczba kanałów"
- id: "hd"
etykieta: "Kanały HD"
- id: "umowa_info"
etykieta: "Umowa"
- id: "instalacja"
etykieta: "Aktywacja"
plany:
- id: "pakiet-1"
nazwa: "SMART"
popularny: false
ceny:
jednorodzinny:
24m: 109
bezterminowa: 129
wielorodzinny:
24m: 99
bezterminowa: 119
koszty:
instalacja:
jednorodzinny:
24m: 149
bezterminowa: 199
wielorodzinny:
24m: 99
bezterminowa: 149
funkcje:
pobieranie: "300 Mb/s"
wysylanie: "150 Mb/s"
router: true
kanaly: "127"
hd: "99"
umowa_info: "24 / Bezterminowa"
- id: "pakiet-2"
nazwa: "OPTIMUM"
popularny: false
ceny:
jednorodzinny:
24m: 125
bezterminowa: 145
wielorodzinny:
24m: 115
bezterminowa: 135
koszty:
instalacja:
jednorodzinny:
24m: 149
bezterminowa: 199
wielorodzinny:
24m: 99
bezterminowa: 149
funkcje:
pobieranie: "300 Mb/s"
wysylanie: "150 Mb/s"
router: true
kanaly: "171"
hd: "129"
umowa_info: "24 / Bezterminowa"
- id: "pakiet-3"
nazwa: "PLATINUM"
popularny: true
ceny:
jednorodzinny:
24m: 158
bezterminowa: 178
wielorodzinny:
24m: 148
bezterminowa: 168
koszty:
instalacja:
jednorodzinny:
24m: 149
bezterminowa: 199
wielorodzinny:
24m: 99
bezterminowa: 149
funkcje:
pobieranie: "300 Mb/s"
wysylanie: "150 Mb/s"
router: true
kanaly: "207"
hd: "157"
umowa_info: "24 / Bezterminowa"
- id: "pakiet-4"
nazwa: "PODSTAWOWY"
popularny: false
ceny:
jednorodzinny:
24m: 88
bezterminowa: 108
wielorodzinny:
24m: 78
bezterminowa: 98
koszty:
instalacja:
jednorodzinny:
24m: 149
bezterminowa: 199
wielorodzinny:
24m: 99
bezterminowa: 149
funkcje:
pobieranie: "300 Mb/s"
wysylanie: "150 Mb/s"
router: true
kanaly: "83"
hd: "63"
umowa_info: "24 / Bezterminowa"
- id: "pakiet-5"
nazwa: "KORZYSTNY"
popularny: false
ceny:
jednorodzinny:
24m: 110
bezterminowa: 130
wielorodzinny:
24m: 100
bezterminowa: 120
koszty:
instalacja:
jednorodzinny:
24m: 149
bezterminowa: 199
wielorodzinny:
24m: 99
bezterminowa: 149
funkcje:
pobieranie: "300 Mb/s"
wysylanie: "150 Mb/s"
router: true
kanaly: "142"
hd: "107"
umowa_info: "24 / Bezterminowa"
- id: "pakiet-6"
nazwa: "BOGATY"
popularny: false
ceny:
jednorodzinny:
24m: 120
bezterminowa: 140
wielorodzinny:
24m: 110
bezterminowa: 130
koszty:
instalacja:
jednorodzinny:
24m: 149
bezterminowa: 199
wielorodzinny:
24m: 99
bezterminowa: 149
funkcje:
pobieranie: "300 Mb/s"
wysylanie: "150 Mb/s"
router: true
kanaly: "184"
hd: "139"
umowa_info: "24 / Bezterminowa"

View File

@@ -1,15 +0,0 @@
title:
- PAKIET INTERNET + TELEWIZJA
paragraphs:
- title:
# content: |
# Przygotowaliśmy sześć pakietów dopasowanych do różnych potrzeb.
# Wybierając którykolwiek z nich, zyskujesz nie tylko korzystną cenę, ale przede wszystkim wygodę i oszczędność czasu.
# Jedna umowa, jeden rachunek, jedno miejsce kontaktu. Internet, telewizja i telefon wszystko w jednym miejscu. Jeśli masz pytania lub potrzebujesz pomocy, jesteśmy do Twojej dyspozycji.
# Oszczędzaj czas i ciesz się prostotą, wszystko czego potrzebujesz, w jednym miejscu.
# Kolejne sekcje mozna dodawać poja wiać się bedą pod tabela produktów

View File

@@ -1,19 +1,4 @@
sections:
# - title: Dodatkowe możliwości naszej telewizji"
# image: "ekosystem-kyanit.webp"
# content: |
# - **Catchup** — na wybranych kanałach możesz obejrzeć audycję z ostatnich 7 dni. [Więcej →](#catchup "Przeczytaj o usłudze CatchUp")
# - **Nagrywanie** — nagraj interesującą Cię audycję i obejrzyj ją kiedy chcesz. [Więcej →](#nagrywarka "Przeczytaj o nagrywaniu audycji")
# - **StartOver** — obejrzyj od początku audycję, która już się rozpoczęła (do 3h wstecz). [Więcej →](#startover "Przeczytaj o usłudze StartOver")
# - **Nagrywanie serii** — zaplanuj nagrywanie kolejnych odcinków ulubionego serialu. [Więcej →](#nagrywanie_cykliczne "Przeczytaj o nagrywaniu cyklicznym")
# - **Pauzowanie** — zatrzymuj i cofaj audycje.
# - **Wyszukiwarka tekstowa** — wyszukaj dowolną frazę audycji i zaplanuj nagranie.
- title: "Dekoder telewizyjny"
image: "VIP4302.png"
content: |
@@ -32,27 +17,3 @@ sections:
- Interfejsy tylnego panelu obejmują m.in. Ethernet, USB, HDMI, CVBS, Optyczny i analogowy audio 3,5mm
- Przedni panel zawiera m.in. diodę LED i odbiornik podczerwieni
- Wymiary modelu (szer/dł/wys): 130 x 130 x 26 mm
# - type: "iframe-channels"
# title: Sprawdź listę kanałów w interesującym Cię pakiecie
# content: ""
# iframe_sets:
# - id: "canal_smart"
# name: "SMART"
# p: 86
# - id: "canal_optimum"
# name: "OPTIMUM"
# p: 87
# - id: "canal_platinum"
# name: "PLATINUM"
# p: 88
# - id: "canal_podstawowy"
# name: "PODSTAWOWY"
# p: 75
# - id: "canal_korzystny"
# name: "KORZYSTNY"
# p: 76
# - id: "canal_bogaty"
# name: "BOGATY"
# p: 77

View File

@@ -29,7 +29,7 @@ sections:
dimmed: true
button:
text: "Zobacz ofertę telefonu →"
url: "/internet-telewizja/"
url: "/telefon/"
title: "Przejdź do oferty telefonu"
content: |
Nasza telefonia wykorzystuje zaawansowaną technologię VoIP, dzięki której dźwięk jest wyraźny, a połączenia stabilne.

File diff suppressed because it is too large Load Diff

8281
src/data/ServiceRange2.csv Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +0,0 @@
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 ?? "";
}

View File

@@ -1,22 +0,0 @@
export function getInstallationPrice(plan, switches, selected) {
// Dla telefonii instalacja to po prostu funkcja wpisana w plan
if (!plan.koszty) {
// instalacja jest zapisana jako tekst "1,23 zł"
const raw = plan.funkcje?.instalacja;
if (!raw) return 0;
const num = parseFloat(raw.replace(",", "."));
return isNaN(num) ? 0 : num;
}
// Internet / TV
const budynek = selected.budynek;
const umowa = selected.umowa;
return (
plan.koszty?.instalacja?.[budynek]?.[umowa] ??
plan.koszty?.instalacja?.[budynek]?.default ??
plan.koszty?.instalacja?.default ??
0
);
}

View File

@@ -1,16 +0,0 @@
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

@@ -1,68 +0,0 @@
import { useState, useEffect } from "preact/hooks";
export default function ChannelSwitcher({ sets = [], title = "" }) {
const [activeId, setActiveId] = useState(sets[0]?.id);
const [channels, setChannels] = useState([]);
const active = sets.find((x) => x.id === activeId);
useEffect(() => {
if (!active) return;
fetch(`/api/jambox/${active.p}`)
.then((r) => r.json())
.then((data) => {
setChannels(data);
})
.catch(() => setChannels([]));
}, [active]);
return (
<div class="w-full">
{/* SWITCHER */}
<div class="flex justify-center mb-10">
<div class="f-switch-group">
{sets.map((s) => (
<button
type="button"
class={`f-switch ${activeId === s.id ? "active" : ""}`}
onClick={() => setActiveId(s.id)}
title={title}
>
{s.name}
</button>
))}
</div>
</div>
{/* LISTA KANAŁÓW */}
<div class="f-section-channel">
{channels.length === 0 && (
<p class="text-center col-span-full py-1">
Ładowanie
</p>
)}
{channels.map((ch) => (
<div
class="f-channel-box"
>
<img
src={ch.logo}
alt={ch.title}
class="h-14 object-contain "
loading="lazy"
/>
<p class="text-center text-sm text-[var(--fuz-text)] mt-2">
{ch.title}
</p>
</div>
))}
</div>
</div>
);
}

View File

@@ -1,3 +1,4 @@
//InternetCards.jsx
import { useEffect, useState } from "preact/hooks";
import InternetAddonsModal from "./InternetAddonsModal.jsx";
import "../../styles/offers/offers-table.css";

View File

@@ -46,7 +46,6 @@ export default function FuzMarkdown({ text, ctx = {} }) {
let processed = applyShortcodes(text, ctx);
// Konwersja kinków na modal linki
processed = processed.replace(
/\[([^\]]+)\]\(#([^) "]+)(?:\s+"([^"]+)")?\)/g,
(match, label, modalId, title) => {

View File

@@ -1,69 +0,0 @@
import { marked } from "marked";
import { useEffect, useState } from "preact/hooks";
import "../styles/modal.css";
marked.setOptions({
gfm: true,
breaks: true,
headerIds: false,
mangle: false,
smartLists: true,
});
export default function Modal({ modalData }) {
const [open, setOpen] = useState(false);
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
useEffect(() => {
const clickHandler = (e) => {
const el = e.target.closest("[data-modal]");
if (!el) return;
// 🚀 BLOKADA WSZYSTKICH DOMYŚLNYCH ZACHOWAŃ
e.preventDefault();
e.stopPropagation();
const id = el.getAttribute("data-modal");
const modal = modalData[id];
if (!modal) return;
setTitle(modal.title || "");
setContent((modal.content || "").replace(/\n(?!\n)/g, "\n\n"));
setOpen(true);
};
document.addEventListener("click", clickHandler, { capture: true });
return () =>
document.removeEventListener("click", clickHandler, { capture: true });
}, [modalData]);
if (!open) return null;
return (
<div class="fuz-modal-overlay" onClick={() => setOpen(false)}>
<button
class="fuz-modal-close"
onClick={(e) => {
e.stopPropagation();
setOpen(false);
}}
>
</button>
<div class="fuz-modal-panel" onClick={(e) => e.stopPropagation()}>
<div class="fuz-modal-inner">
<h2 class="fuz-modal-title">{title}</h2>
<div
class="fuz-modal-content"
dangerouslySetInnerHTML={{
__html: marked(content),
}}
/>
</div>
</div>
</div>
);
}

View File

@@ -1,75 +0,0 @@
import { getPrice } from "../../helpers/getPrice";
import { getActiveLabel } from "../../helpers/getActiveLabel";
import { getInstallationPrice } from "../../helpers/getInstallationPrice";
import "../../styles/offers/offers-table.css";
export default function OffersCards({ data, switches, selected }) {
const plans = data.plany;
return (
<section class="f-offers">
{data.plany_title && (
<h2 class="f-offers-title">{data.plany_title}</h2>
)}
<div class={`f-offers-grid f-count-${plans.length}`}>
{plans.map((plan) => (
<OfferCard
key={plan.id}
plan={plan}
features={data.funkcje}
switches={switches}
selected={selected}
/>
))}
</div>
</section>
);
}
function OfferCard({ plan, features, switches, selected }) {
const basePrice = getPrice(plan, switches, selected);
// NOWE: cena instalacji dynamiczna
const installPrice = getInstallationPrice(plan, switches, selected);
return (
<div class={`f-card ${plan.popularny ? "f-card-popular" : ""}`}>
{plan.popularny && (
<div class="f-card-badge">Najczęściej wybierany</div>
)}
<div class="f-card-header">
<div class="f-card-name">{plan.nazwa}</div>
<div class="f-card-price">{basePrice} /mies.</div>
</div>
<ul class="f-card-features">
{features.map((f) => {
let val =
f.id === "umowa_info"
? getActiveLabel(switches, selected, "umowa")
: plan.funkcje?.[f.id];
// PODMIANA pola instalacji — nie ze statycznego YAML tylko dynamicznie
if (f.id === "instalacja") {
val = installPrice + " zł";
}
return (
<li class="f-card-row">
<span class="f-card-label">{f.etykieta}</span>
<span class="f-card-value">
{val === true
? "✓"
: val === false || val == null
? "✕"
: val}
</span>
</li>
);
})}
</ul>
</div>
);
}

View File

@@ -1,56 +0,0 @@
import FuzMarkdown from "../Markdown.jsx";
import "../../styles/offers/offers-extra.css";
export default function OffersExtraServices({
extraServices,
openId,
toggle,
services
}) {
if (!extraServices.length) return null;
return (
<div class="f-extra-services">
<h1 class="f-services-title">{services.title}</h1>
<p class="f-services-body">{services.description}</p>
<div class="f-table-wrapper">
<table class="f-table">
<thead class="f-table-head">
<tr>
<th class="f-table-heading">{services.items[0]}</th>
<th class="f-table-heading center">{services.items[1]}</th>
<th class="f-table-heading center w-32">{services.items[2]}</th>
</tr>
</thead>
<tbody>
{extraServices.map((srv, i) => (
<>
<tr class={i % 2 === 0 ? "f-row-even" : "f-row-odd"}>
<td class="f-feature-name">{srv.nazwa}</td>
<td class="f-feature-cell center">{srv.cena}</td>
<td class="f-feature-cell-btn center">
<button class="f-feature-link" onClick={() => toggle(srv.id)} title={openId === srv.id ? "Zwiń opis usługi" : "Rozwiń opis usługi"}>
{openId === srv.id ? "Zwiń" : "Przeczytaj ..."}
</button>
</td>
</tr>
{openId === srv.id && (
<tr>
<td colSpan={3} class="f-expand-details">
<FuzMarkdown text={srv.opis} ctx={{ kanaly: srv.kanaly }} />
</td>
</tr>
)}
</>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -1,59 +0,0 @@
import { useState } from "preact/hooks";
import OffersSwitches from "./Offers/OffersSwitches.jsx";
import OffersCards from "./Offers/OffersCards.jsx"; // <-- WAŻNE!!
// import OffersExtraServices from "./Offers/OffersExtraServices.jsx";
export default function OffersIsland({ data }) {
const switches = data.przelaczniki ?? [];
// selected state
const [selected, setSelected] = useState(() => {
const init = {};
switches.forEach((sw) => (init[sw.id] = sw.domyslny));
return init;
});
// services accordion (jeśli potrzebne)
const [openId, setOpenId] = useState(null);
const handleSwitch = (switchId, value) => {
setSelected((prev) => ({
...prev,
[switchId]: value
}));
};
const toggleExtra = (id) => {
setOpenId((prev) => (prev === id ? null : id));
};
return (
<div class="f-offers-wrapper">
{/* SWITCHERY */}
<OffersSwitches
switches={switches}
selected={selected}
onSwitch={handleSwitch}
/>
{/* KARTY OFERT */}
<OffersCards
data={data}
switches={switches}
selected={selected}
/>
{/* USŁUGI DODATKOWE */}
{data.uslugi_dodatkowe && (
<OffersExtraServices
extraServices={data.uslugi_dodatkowe}
services={data.uslugi}
openId={openId}
toggle={toggleExtra}
/>
)}
</div>
);
}

View File

@@ -1,4 +1,4 @@
// src/islands/JamboxBasePackages.jsx
// src/islands/JamboxCards.jsx
import { useEffect, useState } from "preact/hooks";
import "../../styles/offers/offers-table.css";
import JamboxChannelsModal from "./JamboxChannelsModal.jsx";

View File

@@ -0,0 +1,137 @@
import { useEffect, useMemo, useRef, useState } from "preact/hooks";
import "../../styles/channels-search.css";
export default function JamboxChannelsSearch() {
const [q, setQ] = useState("");
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState("");
const abortRef = useRef(null);
useEffect(() => {
const qq = q.trim();
setErr("");
if (qq.length < 2) {
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/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("❌ 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 < 2) return "Wpisz min. 2 znaki";
if (loading) return "Szukam…";
if (err) return err;
return `Znaleziono: ${items.length}`;
}, [q, loading, err, items]);
return (
<div class="fuz-chsearch">
<h1 class="f-section-title">Wyszukiwanie kanałów w pakietach telewizji</h1>
<div class="fuz-chsearch__top">
<input
class="fuz-chsearch__input"
type="search"
value={q}
onInput={(e) => setQ(e.currentTarget.value)}
placeholder="Szukaj kanału po nazwie…"
aria-label="Szukaj kanału po nazwie"
/>
<div class="fuz-chsearch__meta">
{meta}
</div>
</div>
<div class="fuz-chsearch__list" role="list">
{items.map((c) => (
<div class="fuz-chsearch__row" role="listitem" key={`${c.name}-${c.logo_url || ""}`}>
{/* kolumna 1 */}
<div class="fuz-chsearch__left">
{c.logo_url && (
<img
src={c.logo_url}
alt={c.name}
class="fuz-chsearch__logo"
loading="lazy"
/>
)}
<div class="fuz-chsearch__channel-name">
{c.name}
</div>
<div class="fuz-chsearch__channel-number">
kanał {c.min_number || "—"}
</div>
</div>
{/* kolumna 2 */}
<div class="fuz-chsearch__right">
<div
class="fuz-chsearch__desc fuz-chsearch__desc--html"
dangerouslySetInnerHTML={{ __html: c.description || "<em>—</em>" }}
/>
{Array.isArray(c.packages) && c.packages.length > 0 && (
<div class="fuz-chsearch__packages">
Dostępny w:&nbsp;
{c.packages.map((p, i) => (
<span class="fuz-chsearch__pkg" key={p.id}>
{p.name}{" "}
<span class="fuz-chsearch__pkgnum">({p.number})</span>
{i < c.packages.length - 1 ? ", " : ""}
</span>
))}
</div>
)}
</div>
</div>
))}
{q.trim().length >= 2 && !loading && items.length === 0 && (
<div class="fuz-chsearch__empty">
Brak wyników dla: <strong>{q}</strong>
</div>
)}
</div>
</div>
);
}

View File

@@ -23,7 +23,7 @@ export default function PhoneDbOffersCards({
setPlans(Array.isArray(json.data) ? json.data : []);
}
} catch (err) {
console.error("Błąd pobierania planów telefonii:", err);
console.error("Błąd pobierania planów telefonii:", err);
if (!cancelled) {
setError("Nie udało się załadować pakietów telefonicznych.");
}

View File

@@ -2,27 +2,21 @@
import yaml from "js-yaml";
import fs from "fs";
// SEO z YAML (np. import seo from "../content/seo/home.yaml")
const seo = Astro.props.seo ?? {};
// Global SEO (site + company)
const globalSeo = yaml.load(
fs.readFileSync("./src/content/seo/home.yaml", "utf8")
fs.readFileSync("./src/content/home/seo.yaml", "utf8")
);
const { site, company } = globalSeo;
// Page SEO (sekcja "page" w YAML)
const page = seo.page ?? {};
// FINAL VALUES
const title = page.title ?? site.name;
const description = page.description ?? site.description;
const image = page.image ?? site.logo;
const canonical = site.url + (page.url ?? "/");
const keywords = page.keywords ?? [];
// Extra structured data (optional)
const extraSchema = page.schema ?? null;
// JSON-LD objects

View File

@@ -1,19 +1,21 @@
import type { APIRoute } from "astro";
import nodemailer from "nodemailer";
export const POST: APIRoute = async ({ request }) => {
export async function POST({ request }) {
try {
const form = await request.json();
const transporter = nodemailer.createTransport({
host: import.meta.env.SMTP_HOST,
port: Number(import.meta.env.SMTP_PORT),
secure: true,
secure: true, // true = 465, false = 587
auth: {
user: import.meta.env.SMTP_USER,
pass: import.meta.env.SMTP_PASS,
},
tls: { rejectUnauthorized: false }
// ⚠️ tylko jeśli masz self-signed / dziwny cert
tls: {
rejectUnauthorized: false,
},
});
await transporter.sendMail({
@@ -32,10 +34,17 @@ ${form.message}
`.trim(),
});
return new Response(JSON.stringify({ ok: true }), { status: 200 });
return new Response(
JSON.stringify({ ok: true }),
{ status: 200, headers: { "Content-Type": "application/json" } }
);
} catch (error) {
console.error("MAIL ERROR:", error);
return new Response(JSON.stringify({ ok: false }), { status: 500 });
return new Response(
JSON.stringify({ ok: false }),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
}
}
};

View File

@@ -0,0 +1,91 @@
import Database from "better-sqlite3";
const DB_PATH = process.env.FUZ_DB_PATH || "./src/data/ServicesRange.db";
function getDb() {
return new Database(DB_PATH, { readonly: true });
}
function clamp(n, min, max) {
return Math.max(min, Math.min(max, n));
}
export function GET({ url }) {
const q = (url.searchParams.get("q") || "").trim();
const limit = clamp(Number(url.searchParams.get("limit") || 50), 1, 200);
if (q.length < 2) {
return new Response(JSON.stringify({ ok: true, data: [] }), {
status: 200,
headers: { "Content-Type": "application/json; charset=utf-8" },
});
}
const safe = q.replace(/[%_]/g, (m) => `\\${m}`);
const like = `%${safe}%`;
const db = getDb();
try {
// ✅ 1 rekord na kanał (grupujemy po name+logo_url)
// ✅ pakiety zbieramy do jednego pola packages_blob
// UWAGA: zakładam, że jambox_base_packages ma kolumnę "name"
const rows = db
.prepare(
`
SELECT
c.name,
c.logo_url,
MAX(c.description) AS description,
MIN(c.number) AS min_number,
GROUP_CONCAT(
p.id || '::' || p.name || '::' || c.number || '::' || c.guaranteed,
'||'
) AS packages_blob
FROM jambox_package_channels c
JOIN jambox_base_packages p ON p.id = c.package_id
WHERE
c.name LIKE ? ESCAPE '\\'
GROUP BY c.name, c.logo_url
ORDER BY min_number ASC, c.name ASC
LIMIT ?;
`.trim()
)
.all(like, limit);
const data = rows.map((r) => {
const packages = String(r.packages_blob || "")
.split("||")
.filter(Boolean)
.map((s) => {
const [id, name, number, guaranteed] = s.split("::");
return {
id: Number(id),
name,
number: Number(number),
guaranteed: Number(guaranteed) === 1,
};
})
.sort((a, b) => a.id - b.id);
return {
name: r.name,
logo_url: r.logo_url,
description: r.description || "",
min_number: Number(r.min_number || 0),
packages,
};
});
return new Response(JSON.stringify({ ok: true, data }), {
status: 200,
headers: { "Content-Type": "application/json; charset=utf-8" },
});
} catch (err) {
console.error("❌ Błąd w /api/jambox/channels-search:", err);
return new Response(JSON.stringify({ ok: false, error: "DB_ERROR" }), {
status: 500,
headers: { "Content-Type": "application/json; charset=utf-8" },
});
}
}

View File

@@ -0,0 +1,23 @@
---
import DefaultLayout from "../../layouts/DefaultLayout.astro";
const seo = {
title: "Oferta FUZ",
description: "Oferta FUZ",
canonical: "/oferta",
};
---
<DefaultLayout seo={seo}>
<section class="f-section">
<div class="f-section-grid-single md:grid-cols-1">
<h1 class="f-section-title">Dokumenty</h1>
<div class="fuz-markdown max-w-none">
<p>
Ta podstrona jest na razie szkieletem. Możemy tu później wczytać
treść.
</p>
</div>
</div>
</section>
</DefaultLayout>

View File

@@ -2,17 +2,15 @@
import DefaultLayout from "../layouts/DefaultLayout.astro";
import Hero from "../components/hero/Hero.astro";
import SectionRenderer from "../components/sections/SectionRenderer.astro"
import SectionContact from "../components/sections/SectionContact.astro";
import yaml from "js-yaml";
import fs from "fs";
const seo = yaml.load(fs.readFileSync("./src/content/seo/home.yaml", "utf8"));
const seo = yaml.load(fs.readFileSync("./src/content/home/seo.yaml", "utf8"));
const hero = yaml.load(fs.readFileSync("./src/content/home/hero.yaml", "utf8"));
---
<DefaultLayout seo={seo}>
<Hero {...hero} />
<SectionRenderer src="./src/content/site/site.section.yaml" />
<!-- <SectionContact /> -->
</DefaultLayout>

View File

@@ -1,7 +1,7 @@
---
import DefaultLayout from "../../layouts/DefaultLayout.astro";
import OffersSwitches from "../../islands/Offers/OffersSwitches.jsx";
import InternetDbOffersCards from "../../islands/Internet/OffersInternetCards.jsx";
import OffersSwitches from "../../islands/OffersSwitches.jsx";
import InternetCards from "../../islands/Internet/InternetCards.jsx";
import SectionRenderer from "../../components/sections/SectionRenderer.astro";
import yaml from "js-yaml";
@@ -20,7 +20,7 @@ const seo = yaml.load(
<p>Wybierz rodzaj budynku i czas trwania umowy</p>
</div>
<OffersSwitches client:load />
<InternetDbOffersCards client:load />
<InternetCards client:load />
</div>
</section>

View File

@@ -1,13 +1,9 @@
---
import DefaultLayout from "../../layouts/DefaultLayout.astro";
import OffersSwitches from "../../islands/Offers/OffersSwitches.jsx";
import OffersJamboxCards from "../../islands/jambox/OffersJamboxCards.jsx";
import Hero from "../../components/hero/Hero.astro";
import OffersSwitches from "../../islands/OffersSwitches.jsx";
import JamboxCards from "../../islands/jambox/JamboxCards.jsx";
import SectionRenderer from "../../components/sections/SectionRenderer.astro";
import Markdown from "../../islands/Markdown.jsx";
import Modal from "../../islands/Modal.jsx";
import JamboxMozliwosci from "../../components/sections/SectionJamboxMozliwosci.astro";
import SectionChannelsSearch from "../../components/sections/SectionChannelsSearch.astro"
import yaml from "js-yaml";
import fs from "fs";
@@ -15,39 +11,9 @@ import fs from "fs";
const seo = yaml.load(
fs.readFileSync("./src/content/internet-telewizja/seo.yaml", "utf8"),
);
const hero = yaml.load(
fs.readFileSync("./src/content/internet-telewizja/hero.yaml", "utf8"),
);
const page = yaml.load(
fs.readFileSync("./src/content/internet-telewizja/page.yaml", "utf8"),
);
const modalData = yaml.load(
fs.readFileSync("./src/content/internet-telewizja/modal.yaml", "utf8"),
);
type Paragraph = {
title?: string;
content: string;
};
const data = yaml.load(
fs.readFileSync("./src/content/internet-telewizja/offers.yaml", "utf8"),
);
const first = page.paragraphs[0];
const rest = page.paragraphs.slice(1);
---
<DefaultLayout seo={seo}>
<!-- <Hero {...hero} /> -->
<!-- <section class="f-section">
<div class="f-section-grid-single md:grid-cols-1">
{page.title.map((line: any) => <h1 class="f-section-title">{line}</h1>)}
{first.title && <h3>{first.title}</h3>}
<Markdown text={first.content} />
</div>
</section> -->
<section class="f-section">
<div class="f-section-grid-single md:grid-cols-1">
<h1 class="f-section-title">Telewizja z interentem</h1>
@@ -55,26 +21,10 @@ const rest = page.paragraphs.slice(1);
<p>Wybierz rodzaj budynku i czas trwania umowy</p>
</div>
<OffersSwitches client:load />
<OffersJamboxCards client:load />
<!-- <OffersIsland client:load data={data} /> -->
<JamboxCards client:load />
</div>
</section>
<!-- {
rest.map((p: Paragraph) => (
<section class="f-section">
<div class="f-section-grid-single md:grid-cols-1">
{p.title && <h3 class="f-section-title">{p.title}</h3>}
<Markdown text={p.content.replace(/\n/g, "\n\n")} />
</div>
</section>
))
} -->
<SectionRenderer src="./src/content/internet-telewizja/section.yaml" />
<!-- <JamboxMozliwosci /> -->
<!-- <Modal client:load modalData={modalData} /> -->
<SectionChannelsSearch/>
</DefaultLayout>

View File

@@ -1,14 +1,179 @@
---
import DefaultLayout from "../../layouts/DefaultLayout.astro";
import SectionContact from "../../components/sections/SectionContact.astro";
import MapGoogle from "../../components/maps/MapGoogle.astro";
const seo = {
title: "Kontakt FUZ",
description: "Kontakt FUZ",
canonical: "/kontakt"
};
import yaml from "js-yaml";
import fs from "fs";
const data = yaml.load(
fs.readFileSync("./src/content/contact/contact.yaml", "utf8"),
);
const seo = yaml.load(
fs.readFileSync("./src/content/internet-swiatlowodowy/seo.yaml", "utf8"),
);
const apiKey = import.meta.env.PUBLIC_GOOGLE_MAPS_KEY;
const form = data.form;
---
<DefaultLayout seo={seo}>
<SectionContact />
<section id="kontakt" class="f-section">
<div class="f-contact-grid">
<!-- Kolumna lewa -->
<div class="f-contact-col-1">
<h2>{data.title}</h2>
<div class="f-contact-item" set:html={data.description} />
</div>
<div class="f-contact-col-2">
<h2>{data.contactFormTitle}</h2>
<form id="contactForm" class="f-contact-form">
<div class="f-contact-form-inner">
<input
type="text"
name="firstName"
placeholder={form.firstName.placeholder}
class="f-input"
required
/>
<input
type="text"
name="lastName"
placeholder={form.lastName.placeholder}
class="f-input"
required
/>
</div>
<div class="grid grid-cols-2 gap-4">
<input
type="email"
name="email"
placeholder={form.email.placeholder}
class="f-input"
required
autocomplete="email"
/>
<input
type="tel"
name="phone"
placeholder={form.phone.placeholder}
class="f-input"
required
autocomplete="tel"
/>
</div>
<input
type="text"
name="subject"
placeholder={form.subject.placeholder}
class="f-input"
required
/>
<textarea
name="message"
rows={form.message.rows}
placeholder={form.message.placeholder}
class="f-input"
required></textarea>
<label class="f-rodo">
<input type="checkbox" name="rodo" required />
<span>
{form.rodo.label}
<a href={form.rodo.policyLink} title={form.rodo.policyTitle}
>{form.rodo.policyText}</a
>.
</span>
</label>
<button title={form.submit.title}>{form.submit.label}</button>
</form>
</div>
</div>
<div class="f-contact-map">
<MapGoogle
apiKey={apiKey}
lat={data.lat}
lon={data.lng}
zoom={16}
title={data.markerTitle}
description={data.markerAddress}
showMarker={true}
mode="contact"
mapStyleId={data.maps.mapId}
/>
</div>
<div id="toast" class="f-toast"></div>
</section>
<!-- ReCaptcha v3 -->
<script
is:inline
define:vars={{ siteKey: import.meta.env.PUBLIC_RECAPTCHA_SITE_KEY }}
>
window.FUZ_RECAPTCHA_KEY = siteKey;
const s = document.createElement("script");
s.src = "https://www.google.com/recaptcha/api.js?render=" + siteKey;
s.async = true;
document.head.appendChild(s);
</script>
<script
is:inline
define:vars={{
successMsg: form.successMessage,
errorMsg: form.errorMessage,
}}
>
document.addEventListener("DOMContentLoaded", () => {
const form = document.getElementById("contactForm");
const toast = document.getElementById("toast");
if (!form) return;
form.addEventListener("submit", async (e) => {
if (!form.reportValidity()) return;
e.preventDefault();
const data = Object.fromEntries(new FormData(form).entries());
data.rodo = form.rodo.checked;
const token = await grecaptcha.execute(window.FUZ_RECAPTCHA_KEY, {
action: "submit",
});
data.recaptcha = token;
const resp = await fetch("/api/contact/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
const json = await resp.json();
showToast(
json.ok ? successMsg : errorMsg,
json.ok ? "success" : "error",
);
if (json.ok) form.reset();
});
function showToast(msg, type) {
toast.innerHTML = `<div class="f-toast-msg ${type}">${msg}</div>`;
toast.classList.remove("visible");
void toast.offsetWidth;
toast.classList.add("visible");
setTimeout(() => toast.classList.remove("visible"), 3000);
}
});
</script>
</DefaultLayout>

View File

@@ -1,20 +0,0 @@
---
import DefaultLayout from "../../layouts/DefaultLayout.astro";
const seo = {
title: "Oferta FUZ",
description: "Oferta FUZ",
canonical: "/oferta"
};
---
<DefaultLayout seo={seo}>
<section class="fuz-section">
<div class="fuz-container">
<h1 class="fuz-hero-title">Oferta FUZ</h1>
<p class="mt-4 text-gray-600 dark:text-gray-300">
Ta podstrona jest na razie szkieletem. Możemy tu później wczytać treść z YAML.
</p>
</div>
</section>
</DefaultLayout>

View File

@@ -0,0 +1,94 @@
.fuz-chsearch {
@apply mt-6;
}
.fuz-chsearch__top {
@apply flex flex-col gap-1 mb-4;
}
.fuz-chsearch__input {
@apply w-full md:flex-1 px-4 py-3 rounded-xl border border-[--f-input-border] bg-[--f-background] text-[--f-text] outline-none focus:ring-2 focus:ring-[--btn-background];
}
.fuz-chsearch__meta {
@apply text-sm opacity-70 pl-1;
}
/* =========================
List + Row layout
========================== */
.fuz-chsearch__list {
@apply flex flex-col gap-2;
}
/* ✅ węższa pierwsza kolumna */
.fuz-chsearch__row {
@apply grid grid-cols-1 md:grid-cols-[220px_1fr] gap-6 rounded-2xl border border-[--f-input-border] bg-[--f-background] p-4;
}
/* =========================
Column 1: Channel card
========================== */
.fuz-chsearch__left {
@apply flex flex-col items-center text-center gap-1 px-2;
}
.fuz-chsearch__logo {
@apply w-14 h-14 object-contain bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-xl p-1 mb-1;
}
.fuz-chsearch__channel-name {
@apply font-semibold text-[--fuz-header] leading-tight;
}
.fuz-chsearch__channel-number {
@apply text-sm opacity-70;
}
/* =========================
Column 2: Description + Packages
========================== */
.fuz-chsearch__right {
@apply flex flex-col gap-2;
}
/* opis NIE ucinany */
.fuz-chsearch__desc {
@apply text-sm md:text-base opacity-90;
overflow-wrap: anywhere;
word-break: break-word;
}
/* HTML jak w modalu */
.fuz-chsearch__desc--html :global(p) {
@apply mb-2;
}
.fuz-chsearch__desc--html :global(ol),
.fuz-chsearch__desc--html :global(ul) {
@apply pl-6 mb-2;
}
.fuz-chsearch__desc--html :global(li) {
@apply mb-1;
}
/* pakiety (bez gwarantowanych) */
.fuz-chsearch__packages {
@apply text-sm opacity-80;
}
.fuz-chsearch__pkg {
@apply inline text-[--btn-background];
}
.fuz-chsearch__pkgnum {
@apply opacity-70;
}
/* =========================
Empty state
========================== */
.fuz-chsearch__empty {
@apply mt-2 p-4 rounded-2xl border border-slate-200 dark:border-slate-700 opacity-80;
}