diff --git a/src/components/sections/SectionAreaServed.astro b/src/components/sections/SectionAreaServed.astro new file mode 100644 index 0000000..ef5929c --- /dev/null +++ b/src/components/sections/SectionAreaServed.astro @@ -0,0 +1,98 @@ +--- +import { Image } from "astro:assets"; +import type { ImageMetadata } from "astro"; +import Markdown from "../../islands/Markdown.jsx"; +import { findSectionImage, loadGlobalSeo } from "../../lib/astro-helpers"; + +const { section, index } = Astro.props; + +// ✅ Załaduj global-seo.yaml +const globalSeo = loadGlobalSeo(); +const cities = globalSeo?.schema?.areaServed || []; + +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 }, +); + +const sectionImage = section.image ? findSectionImage(sectionImages, section.image) : null; +const isAboveFold = index === 0; + +// ✅ Konfiguracja wyświetlania +const showTitle = section.showTitle !== false; // domyślnie true + +// ✅ Formatowanie listy miejscowości jako string +const citiesText = cities.join(", "); +--- + +
+
+ { + sectionImage && ( + {section.title + ) + } + +
+ {showTitle && section.title && ( +

{section.title}

+ )} + + {section.content && } + + {/* ✅ Lista miejscowości jako paragraf */} + {cities.length > 0 && ( +

+ {citiesText}. + {section.button.text} + +

+ )} + + +
+
+
+ + \ No newline at end of file diff --git a/src/components/sections/SectionRenderer.astro b/src/components/sections/SectionRenderer.astro index ec56d72..021003f 100644 --- a/src/components/sections/SectionRenderer.astro +++ b/src/components/sections/SectionRenderer.astro @@ -1,13 +1,28 @@ --- -import { loadYaml, processMarkdownSections } from "../../lib/astro-helpers"; +import yaml from "js-yaml"; +import fs from "fs"; +import { marked } from "marked"; + import SectionDefault from "./SectionDefault.astro"; +import SectionAreaServed from "./SectionAreaServed.astro"; // ✅ Import nowego komponentu const { src } = Astro.props; -const data = loadYaml(src) ?? { sections: [] }; -const sections = processMarkdownSections(data.sections as any[]); +const data = yaml.load(fs.readFileSync(src, "utf8")) ?? { sections: [] }; + +const sections = (data.sections as any[]).map((s: any) => ({ + ...s, + html: marked(s.content || "") +})); --- {sections.map((section: any, index: number) => { + const type = section.type || "default"; + + // ✅ Routing do właściwego komponentu + if (type === "area-served") { + return ; + } + return ; })} \ No newline at end of file diff --git a/src/content/internet-swiatlowodowy/seo.yaml b/src/content/internet-swiatlowodowy/seo.yaml index cc40957..da23869 100644 --- a/src/content/internet-swiatlowodowy/seo.yaml +++ b/src/content/internet-swiatlowodowy/seo.yaml @@ -1,6 +1,6 @@ page: - title: "Internet Światłowodowy Wyszków - Szybkie Łącze bez Limitów | FUZ" - description: "Internet światłowodowy do 1 Gb/s w Wyszkowie. Bez limitów danych, stabilne połączenie, montaż w 48h. Lokalny operator z profesjonalnym serwisem. Sprawdź ceny!" + title: "Internet Światłowodowy Wyszków - Szybkie Łącze | FUZ" + description: "Internet światłowodowy w Wyszkowie. Bez limitów danych, stabilne połączenie, szybki montaż. Lokalny operator z profesjonalnym serwisem. Sprawdź ceny!" image: "/og/internet-og.png" url: "/internet-swiatlowodowy" keywords: diff --git a/src/content/internet-telewizja/seo.yaml b/src/content/internet-telewizja/seo.yaml index fb6bba6..13b26b2 100644 --- a/src/content/internet-telewizja/seo.yaml +++ b/src/content/internet-telewizja/seo.yaml @@ -1,6 +1,6 @@ page: - title: "Internet i Telewizja Wyszków - Pakiety Światłowodowe 2w1 | FUZ" - description: "Pakiety Internet + TV w Wyszkowie. Światłowód do 1 Gb/s + ponad 200 kanałów. Jeden rachunek, niższa cena, lokalny serwis. Sprawdź ofertę pakietów 2w1!" + title: "Internet i Telewizja Wyszków - Pakiety Telewizyjne | FUZ" + description: "Internet i telewizja w Wyszkowie. Jeden rachunek, niższa cena, lokalny serwis. Sprawdź ofertę pakietów internetu i telewizji!" image: "/og/telewizja-og.png" url: "/internet-telewizja" keywords: diff --git a/src/content/site/area-section.yaml b/src/content/site/area-section.yaml new file mode 100644 index 0000000..af48644 --- /dev/null +++ b/src/content/site/area-section.yaml @@ -0,0 +1,12 @@ +sections: + - type: area-served + title: + content: | + Świadczymy usługi internetu światłowodowego oraz telewizji w następujących miejscowościach + image: + showTitle: true + columns: "grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5" + button: + text: "Sprawdź dostępność usługi pod Twoim adresem" + url: "/mapa-zasiegu" + title: "Sprawdź czy jesteś w zasięgu" \ No newline at end of file diff --git a/src/content/site/global-seo.yaml b/src/content/site/global-seo.yaml index 40475e4..05186f7 100644 --- a/src/content/site/global-seo.yaml +++ b/src/content/site/global-seo.yaml @@ -1,25 +1,63 @@ site: - name: "FUZ Adam Rojek" - description: "Lokalny operator internetu światłowodowego w Wyszkowie i okolicach. Stabilne łącze, szybki serwis, konkurencyjne ceny." - url: "https://www.fuz.pl" - lang: "pl" - + name: FUZ Adam Rojek + description: Lokalny operator internetu światłowodowego w Wyszkowie i okolicach. Stabilne łącze, szybki serwis, konkurencyjne ceny. + 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" + 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: "/logo.webp" - + logo: /logo.webp schema: - openingHours: "Mo-Fr 09:00-17:00" - priceRange: "$" + openingHours: Mo-Fr 09:00-17:00 + priceRange: $ areaServed: - - Wyszków + - Dąbrowa + - Gulczewo + - Kamieńczyk + - Komorowo + - Kręgi + - Kręgi Nowe + - Leszczydół Stary + - Leszczydół-działki + - Leszczydół-nowiny + - Leszczydół-podwielątki + - Leszczydół-pustki + - Lucynów + - Lucynów Duży + - Łosinno + - Natalin + - Nowe Kozłowo + - Nowe Wielątki + - Nowe Wypychy + - Ochudno + - Olszanka + - Ostrowy + - Porządzie + - Pułtusk + - Puste Łąki + - Rybienko Nowe + - Rybienko Stare + - Rybno - Rząśnik - - Pułtusk \ No newline at end of file + - Sitno + - Skuszew + - Stare Kozłowo + - Stary Mystkówiec + - Strachów + - Świniotop + - Tulewo + - Tulewo Górne + - Tumanek + - Wielątki + - Wielątki Rosochate + - Wielątki-folwark + - Wólka-folwark + - Wólka-przekory + - Wyszków diff --git a/src/lib/astro-helpers.ts b/src/lib/astro-helpers.ts index 03f1882..c53e82d 100644 --- a/src/lib/astro-helpers.ts +++ b/src/lib/astro-helpers.ts @@ -111,6 +111,12 @@ export type GlobalSeo = { lat: number; lon: number; }; + schema?: { + openingHours?: string; + priceRange?: string; + areaServed?: string[]; + [key: string]: any; + }; }; // ✅ DODANY BRAKUJĄCY TYP @@ -158,6 +164,11 @@ export function buildSeoMeta( }, }; + const mergedSchema = { + ...globalSeo.schema, // ← Globalna schema (openingHours, priceRange, areaServed) + ...page.schema, // ← Page-specific schema (override jeśli istnieje) + }; + const schemaLocalBusiness = { "@context": "https://schema.org", "@type": "LocalBusiness", @@ -178,6 +189,14 @@ export function buildSeoMeta( longitude: company.lon, }, url: baseUrl, + ...(mergedSchema.openingHours && { openingHours: mergedSchema.openingHours }), + ...(mergedSchema.priceRange && { priceRange: mergedSchema.priceRange }), + ...(mergedSchema.areaServed && { areaServed: mergedSchema.areaServed }), + ...Object.fromEntries( + Object.entries(mergedSchema).filter( + ([key]) => !['openingHours', 'priceRange', 'areaServed'].includes(key) + ) + ), }; return { @@ -294,4 +313,86 @@ export const DEFAULT_FAVICON_CONFIG: FaviconConfig = { export function mergeFaviconConfig(custom?: Partial): FaviconConfig { return { ...DEFAULT_FAVICON_CONFIG, ...custom }; +} + +// ==================== LOCALE HELPERS ==================== + +/** + * Title Case dla tekstu (każde słowo z wielkiej) + * UWAGA: Może mieć problemy z polskimi znakami z SQLite + */ +export function titleCaseWords(input: string): string { + return String(input ?? "") + .trim() + .toLowerCase() + .replace(/\s+/g, " ") + .replace(/\b\p{L}/gu, (c) => c.toUpperCase()); +} + +/** + * Normalizacja nazw miast dla polskiego locale + * Bezpieczne dla danych z SQLite + * + * @example + * normalizeCityName("dąbrowa") => "Dąbrowa" + * normalizeCityName("DĄBROWA") => "Dąbrowa" + * normalizeCityName("DĄBrowa") => "Dąbrowa" + */ +export function normalizeCityName(input: string): string { + const text = String(input ?? "").trim(); + + if (!text) return ""; + + // 1. Normalizuj do NFD (dekompozycja znaków diakrytycznych) + const normalized = text.normalize("NFD"); + + // 2. Lowercase całość + const lower = normalized.toLowerCase(); + + // 3. Title Case słowo po słowie + const words = lower.split(/\s+/); + + const titleCased = words.map(word => { + if (!word) return ""; + + // Pierwsza litera wielka, reszta mała + return word.charAt(0).toUpperCase() + word.slice(1); + }).join(" "); + + // 4. Normalizuj z powrotem do NFC (kompozycja) + return titleCased.normalize("NFC"); +} + +// ==================== ENV HELPERS ==================== + +/** + * Pobierz zmienną środowiskową (działa zarówno w Astro jak i Node) + * @param key - Nazwa zmiennej + * @param fallback - Wartość domyślna jeśli brak + */ +export function getEnv(key: string, fallback?: string): string | undefined { + // Próbuj import.meta.env (Astro) + if (typeof import.meta !== 'undefined' && import.meta.env?.[key]) { + return import.meta.env[key]; + } + + // Próbuj process.env (Node.js) + if (typeof process !== 'undefined' && process.env?.[key]) { + return process.env[key]; + } + + return fallback; +} + +/** + * Pobierz wymaganą zmienną środowiskową (rzuca błąd jeśli brak) + */ +export function getRequiredEnv(key: string): string { + const value = getEnv(key); + + if (!value) { + throw new Error(`Brak wymaganej zmiennej środowiskowej: ${key}`); + } + + return value; } \ No newline at end of file diff --git a/src/pages/api/update-area-served.ts b/src/pages/api/update-area-served.ts new file mode 100644 index 0000000..f7d1fdc --- /dev/null +++ b/src/pages/api/update-area-served.ts @@ -0,0 +1,188 @@ +import path from "node:path"; +import fs from "node:fs/promises"; +import yaml from "js-yaml"; + +import { getDb } from "./db.js"; +import { loadYaml, sortByLocale, normalizeCityName, getEnv } from "../../lib/astro-helpers"; + +/** + * API do automatycznej aktualizacji schema.areaServed w global-seo.yaml + * + * Wymaga: .env z AREA_ADMIN_TOKEN + * Opcjonalnie: SEO_YAML_PATH + */ + +// ==================== AUTHORIZATION ==================== + +function isAuthorized(request: Request): boolean { + const expected = getEnv('JAMBOX_ADMIN_TOKEN'); // ✅ Zamiast import.meta.env + + if (!expected) { + console.warn("⚠️ AREA_ADMIN_TOKEN nie jest ustawiony w .env"); + return false; + } + + const token = request.headers.get("x-admin-token"); + + if (!token) { + console.warn("⚠️ Brak nagłówka x-admin-token w request"); + return false; + } + + const isValid = token === expected; + + if (!isValid) { + console.warn("⚠️ Nieprawidłowy token:", { + received: token.substring(0, 10) + '...', + expected: expected.substring(0, 10) + '...' + }); + } + + return isValid; +} + +// ==================== YAML PATH ==================== + +function getYamlPath(): string { + return ( + getEnv('SEO_YAML_PATH') || // ✅ Zamiast import.meta.env + path.join(process.cwd(), "src", "content", "site", "global-seo.yaml") + ); +} + +// ==================== DATABASE QUERIES ==================== + +/** + * Pobierz unikalne miasta z bazy danych + * @returns Array - Posortowane alfabetycznie (locale: pl) + */ +function getCitiesFromDatabase(): string[] { + const db = getDb(); + + const rows = db + .prepare("SELECT DISTINCT city FROM ranges WHERE city IS NOT NULL ORDER BY city") + .all() as Array<{ city: string }>; + + // ✅ Używamy normalizeCityName zamiast titleCaseWords + const cities = rows + .map((r) => normalizeCityName(r.city)) + .filter((s) => s.length > 0); + + const uniqueCities = [...new Set(cities)]; + + return sortByLocale(uniqueCities, "pl"); +} + +// ==================== YAML OPERATIONS ==================== + +async function updateAreaServedInYaml( + yamlPath: string, + cities: string[], + dryRun: boolean +): Promise<{ changed: boolean; before: string[]; after: string[] }> { + const doc = loadYaml(yamlPath) || {}; + + if (!doc.schema) { + doc.schema = {}; + } + + const before = Array.isArray(doc.schema.areaServed) ? doc.schema.areaServed : []; + const after = cities; + + const changed = JSON.stringify(before) !== JSON.stringify(after); + + if (!dryRun && changed) { + doc.schema.areaServed = after; + + const newYaml = yaml.dump(doc, { + lineWidth: -1, + noRefs: true, + sortKeys: false, + }); + + await fs.writeFile(yamlPath, newYaml, "utf8"); + } + + return { changed, before, after }; +} + +// ==================== API HANDLER ==================== + +export async function POST({ request }: { request: Request }) { + try { + // Debug logging + console.log("📥 Request received at /api/area/update-area-served"); + console.log(" Headers:", { + 'content-type': request.headers.get('content-type'), + 'x-admin-token': request.headers.get('x-admin-token') ? '***' : 'missing' + }); + console.log(" ENV AREA_ADMIN_TOKEN:", getEnv('AREA_ADMIN_TOKEN') ? '***' : 'MISSING!'); + + // Authorization + if (!isAuthorized(request)) { + console.error("❌ Unauthorized request"); + return new Response( + JSON.stringify({ ok: false, error: "Unauthorized" }), + { + status: 401, + headers: { "Content-Type": "application/json; charset=utf-8" }, + } + ); + } + + console.log("✅ Authorization successful"); + + // Parse body + const body = await request.json().catch(() => ({})); + const dryRun = body?.dryRun === true; + + console.log(" DryRun:", dryRun); + + // Get cities from DB + const cities = getCitiesFromDatabase(); + console.log(" Cities from DB:", cities.length); + + // Update YAML + const yamlPath = getYamlPath(); + console.log(" YAML path:", yamlPath); + + const { changed, before, after } = await updateAreaServedInYaml( + yamlPath, + cities, + dryRun + ); + + console.log(" Changed:", changed); + console.log("✅ Success"); + + // Response + return new Response( + JSON.stringify({ + ok: true, + dryRun, + yamlPath, + changed, + count: after.length, + before, + after, + }), + { + status: 200, + headers: { "Content-Type": "application/json; charset=utf-8" }, + } + ); + } catch (err) { + console.error("❌ update-area-served error:", err); + + return new Response( + JSON.stringify({ + ok: false, + error: String((err as Error)?.message ?? err), + }), + { + status: 500, + headers: { "Content-Type": "application/json; charset=utf-8" }, + } + ); + } +} \ No newline at end of file diff --git a/src/pages/index.astro b/src/pages/index.astro index 199357a..3fab3ad 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -13,4 +13,5 @@ const hero = yaml.load(fs.readFileSync("./src/content/home/hero.yaml", "utf8")); + diff --git a/src/pages/internet-swiatlowodowy/index.astro b/src/pages/internet-swiatlowodowy/index.astro index 51f32a9..9e5aa0f 100644 --- a/src/pages/internet-swiatlowodowy/index.astro +++ b/src/pages/internet-swiatlowodowy/index.astro @@ -110,4 +110,5 @@ const addonsCenaOpis = addonsData?.cena_opis ?? cenaOpis; + diff --git a/src/pages/internet-telewizja/index.astro b/src/pages/internet-telewizja/index.astro index 7b70df7..6ab9dc6 100644 --- a/src/pages/internet-telewizja/index.astro +++ b/src/pages/internet-telewizja/index.astro @@ -129,4 +129,5 @@ const addonsCenaOpis = addonsYaml?.cena_opis ?? cenaOpis; + diff --git a/src/pages/mapa-zasiegu/index.astro b/src/pages/mapa-zasiegu/index.astro index 0152fd3..8228575 100644 --- a/src/pages/mapa-zasiegu/index.astro +++ b/src/pages/mapa-zasiegu/index.astro @@ -1,6 +1,7 @@ --- import DefaultLayout from "../../layouts/DefaultLayout.astro"; import MapGoogle from "../../components/maps/MapGoogle.astro"; +import SectionRenderer from "../../components/sections/SectionRenderer.astro"; import RangeForm from "../../islands/RangeForm.jsx"; import { loadYaml, safeArray } from "../../lib/astro-helpers"; import "../../styles/map-google.css"; @@ -48,7 +49,7 @@ const seo = loadYaml("./src/content/mapa-zasiegu/seo.yaml"); /> - + - \ No newline at end of file + diff --git a/src/styles/addons.css b/src/styles/addons.css index 86d7613..d1da160 100644 --- a/src/styles/addons.css +++ b/src/styles/addons.css @@ -368,7 +368,7 @@ .f-note-acc { - @apply mx-20 + @apply md:mx-20 } .f-note-acc-summary { diff --git a/src/styles/contact.css b/src/styles/contact.css index 0c6c2a4..8583bd9 100644 --- a/src/styles/contact.css +++ b/src/styles/contact.css @@ -4,7 +4,6 @@ @apply items-start; } - .f-contact-item { h3, h4 { @@ -32,12 +31,10 @@ } } - .f-contact-map { @apply mx-auto mt-6 w-full max-w-7xl; } - .f-toast { @apply fixed left-1/2 z-[999999] pointer-events-none; top: calc(var(--nav-height, 80px) + 20px);