Seo - generowanie schema, api lista miejscowości , sekcja z listą miejscowości

This commit is contained in:
dm
2025-12-21 11:58:53 +01:00
parent 22673ff8d2
commit 4e44fff8c0
14 changed files with 485 additions and 32 deletions

View File

@@ -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(", ");
---
<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 || "Obszar działania"}
class={`f-section-image ${reverse ? "md:order-1" : "md:order-2"} ${section.dimmed ? "f-image-dimmed" : ""}`}
loading={isAboveFold ? "eager" : "lazy"}
fetchpriority={isAboveFold ? "high" : "auto"}
decoding="async"
format="webp"
widths={[480, 768, 1024, 1440]}
sizes="(min-width: 768px) 50vw, 100vw"
/>
)
}
<div class={`${hasImage ? (reverse ? "md:order-2" : "md:order-1") : ""}`}>
{showTitle && section.title && (
<h2 class="f-section-title">{section.title}</h2>
)}
{section.content && <Markdown text={section.content} />}
{/* ✅ Lista miejscowości jako paragraf */}
{cities.length > 0 && (
<p class="f-cities-paragraph">
{citiesText}. <a href={section.button.url}
class=""
title={section.button.title}
>
{section.button.text}
</a>
</p>
)}
<!-- {
section.button && (
<div class="f-section-nav mt-6">
<a href={section.button.url}
class="btn btn-primary"
title={section.button.title}
>
{section.button.text}
</a>
</div>
)
} -->
</div>
</div>
</section>
<!-- <style>
.f-cities-paragraph {
margin-top: 1.5rem;
font-size: 1rem;
line-height: 1.8;
color: var(--f-text, #212529);
}
:global(.dark) .f-cities-paragraph {
color: var(--f-text-dark, #f7fafc);
}
</style> -->

View File

@@ -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 <SectionAreaServed section={section} index={index} />;
}
return <SectionDefault section={section} index={index} />;
})}

View File

@@ -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:

View File

@@ -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:

View File

@@ -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"

View File

@@ -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
- 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

View File

@@ -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>): 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;
}

View File

@@ -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<string> - 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<any>(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" },
}
);
}
}

View File

@@ -13,4 +13,5 @@ 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" />
<SectionRenderer src="./src/content/site/area-section.yaml" />
</DefaultLayout>

View File

@@ -110,4 +110,5 @@ const addonsCenaOpis = addonsData?.cena_opis ?? cenaOpis;
</section>
<SectionRenderer src="./src/content/internet-swiatlowodowy/section.yaml" />
<SectionRenderer src="./src/content/site/area-section.yaml" />
</DefaultLayout>

View File

@@ -129,4 +129,5 @@ const addonsCenaOpis = addonsYaml?.cena_opis ?? cenaOpis;
</section>
<SectionChannelsSearch />
<SectionRenderer src="./src/content/internet-telewizja/section.yaml" />
<SectionRenderer src="./src/content/site/area-section.yaml" />
</DefaultLayout>

View File

@@ -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");
/>
</div>
</section>
<SectionRenderer src="./src/content/site/area-section.yaml" />
<script is:inline>
let fiberLayer = null;
@@ -122,7 +123,7 @@ const seo = loadYaml("./src/content/mapa-zasiegu/seo.yaml");
map.getDiv().appendChild(overlay);
map.panTo(pos);
let targetZoom = 16;
let targetZoom = 16;
setTimeout(() => {
map.setZoom(targetZoom);
@@ -163,4 +164,4 @@ const seo = loadYaml("./src/content/mapa-zasiegu/seo.yaml");
info.open({ map, anchor: marker });
};
</script>
</DefaultLayout>
</DefaultLayout>

View File

@@ -368,7 +368,7 @@
.f-note-acc {
@apply mx-20
@apply md:mx-20
}
.f-note-acc-summary {

View File

@@ -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);