Rezygnacja z bazy, przeniesienie danych do plików yamla

This commit is contained in:
dm
2025-12-15 06:30:39 +01:00
parent 00d6a57d74
commit 0b6bbbdce7
55 changed files with 3558 additions and 1545 deletions

View File

@@ -1,42 +0,0 @@
import type { APIRoute } from "astro";
import { JSDOM } from "jsdom";
interface Channel {
title: string;
logo: string;
}
const cache = new Map<string, { time: number; data: Channel[] }>();
const CACHE_TIME = 1000 * 60 * 60 * 24 * 30; //miesiąc
export const GET: APIRoute = async ({ params }) => {
const id = params.id!;
const cached = cache.get(id);
if (cached && Date.now() - cached.time < CACHE_TIME) {
return new Response(JSON.stringify(cached.data), {
headers: { "Content-Type": "application/json" }
});
}
const url = `https://www.jambox.pl/iframe-pakiet-logo?p=${id}`;
const resp = await fetch(url, { headers: { "User-Agent": "Mozilla/5.0" } });
const html = await resp.text();
const dom = new JSDOM(html);
const images = [
...dom.window.document.querySelectorAll("img.imagefield-field_logo")
];
const channels = images.map((img) => ({
title: img.getAttribute("alt")?.trim() ?? "",
logo: img.getAttribute("src") ?? "",
}));
cache.set(id, { time: Date.now(), data: channels });
return new Response(JSON.stringify(channels), {
headers: { "Content-Type": "application/json" }
});
};

View File

@@ -1,55 +0,0 @@
import Database from "better-sqlite3";
const DB_PATH = process.env.FUZ_DB_PATH || "./src/data/ServicesRange.db";
export async function GET({ url }) {
const db = new Database(DB_PATH, { readonly: true });
try {
const packageId = Number(url.searchParams.get("packageId") || 0);
if (!packageId) {
return new Response(
JSON.stringify({ ok: false, error: "MISSING_PACKAGE_ID" }),
{ status: 400, headers: { "Content-Type": "application/json; charset=utf-8" } }
);
}
const rows = db
.prepare(
`
SELECT
a.id AS id,
a.name AS name,
a.type AS type,
a.description AS description,
CAST(o.price AS REAL) AS price
FROM jambox_package_addon_options o
JOIN internet_addons a
ON a.id = o.addon_id
WHERE o.package_id = ?
ORDER BY a.type, a.name
`
)
.all(packageId);
return new Response(
JSON.stringify({
ok: true,
count: rows.length,
data: rows,
}),
{
status: 200,
headers: { "Content-Type": "application/json; charset=utf-8" },
}
);
} catch (err) {
console.error("❌ Błąd w /api/jambox/addons:", err);
return new Response(
JSON.stringify({ ok: false, error: err.message || "DB_ERROR" }),
{ status: 500, headers: { "Content-Type": "application/json; charset=utf-8" } }
);
} finally {
db.close();
}
}

View File

@@ -1,125 +0,0 @@
// src/pages/api/jambox/base-packages.js
//import { getDb } from "../db.js";
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 });
}
/**
* GET /api/jambox/base-packages?source=PLUS|EVIO|ALL&building=1|2&contract=1|2
*/
export function GET({ url }) {
const sourceParam = url.searchParams.get("source") || "PLUS";
const buildingParam = url.searchParams.get("building");
const contractParam = url.searchParams.get("contract");
const building = buildingParam ? Number(buildingParam) : 1;
const contract = contractParam ? Number(contractParam) : 1;
const db = getDb();
try {
const rows = db
.prepare(
`
SELECT
p.id AS package_id,
p.source AS package_source,
p.tid AS package_tid,
p.name AS package_name,
p.slug AS package_slug,
p.sort_order AS package_sort_order,
p.updated_at AS package_updated_at,
pr.price_monthly AS price_monthly,
pr.price_installation AS price_installation,
f.id AS feature_id,
f.label AS feature_label,
fv.value AS feature_value
FROM jambox_base_packages p
LEFT JOIN jambox_base_package_prices pr
ON pr.package_id = p.id
AND pr.building_type = ?
AND pr.contract_type = ?
LEFT JOIN jambox_package_feature_values fv
ON fv.package_id = p.id
LEFT JOIN internet_features f
ON f.id = fv.feature_id
WHERE (? = 'ALL' OR p.source = ?)
ORDER BY p.sort_order ASC, p.id ASC, f.id ASC;
`.trim()
)
.all(building, contract, sourceParam, sourceParam);
// grupowanie jak w /api/internet/plans
const byPackage = new Map();
for (const row of rows) {
if (!byPackage.has(row.package_id)) {
byPackage.set(row.package_id, {
id: row.package_id,
source: row.package_source,
tid: row.package_tid,
name: row.package_name,
slug: row.package_slug,
sort_order: row.package_sort_order,
updated_at: row.package_updated_at,
price_monthly: row.price_monthly,
price_installation: row.price_installation,
features: [],
});
}
if (row.feature_id) {
byPackage.get(row.package_id).features.push({
id: row.feature_id,
label: row.feature_label,
value: row.feature_value,
});
}
}
const data = Array.from(byPackage.values());
return new Response(
JSON.stringify({
ok: true,
source: sourceParam,
building,
contract,
count: data.length,
data,
}),
{
status: 200,
headers: {
"Content-Type": "application/json; charset=utf-8",
"Cache-Control": "public, max-age=30",
},
}
);
} catch (err) {
console.error("❌ Błąd w /api/jambox/base-packages:", err);
return new Response(
JSON.stringify({ ok: false, error: "DB_ERROR" }),
{
status: 500,
headers: {
"Content-Type": "application/json; charset=utf-8",
},
}
);
}
}

View File

@@ -10,17 +10,33 @@ function clamp(n, min, max) {
return Math.max(min, Math.min(max, n));
}
function uniq(arr) {
return Array.from(new Set(arr));
}
// jeśli chcesz id do scrollowania (pkg-smart), to możesz dać slug
function slugifyPkg(name) {
return String(name || "")
.toLowerCase()
.replace(/\s+/g, " ")
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
}
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 < 0) {
if (q.length < 1) {
return new Response(JSON.stringify({ ok: true, data: [] }), {
status: 200,
headers: { "Content-Type": "application/json; charset=utf-8" },
});
}
// escape LIKE wildcardów
const safe = q.replace(/[%_]/g, (m) => `\\${m}`);
const like = `%${safe}%`;
@@ -28,48 +44,43 @@ export function GET({ url }) {
try {
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);
.prepare(
`
SELECT
nazwa AS name,
MAX(image) AS logo_url,
MAX(opis) AS description,
GROUP_CONCAT(pckg_name, '||') AS packages_blob
FROM jambox_channels
WHERE nazwa LIKE ? ESCAPE '\\'
GROUP BY nazwa
ORDER BY name COLLATE NOCASE ASC
LIMIT ?;
`.trim()
)
.all(like, limit);
const data = rows.map((r) => {
const packages = String(r.packages_blob || "")
const pkgsRaw = 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);
.map((x) => x.trim())
.filter(Boolean);
const packages = uniq(pkgsRaw)
.map((p) => ({
// jeśli UI wymaga ID do scrolla, to to jest najbezpieczniejsze:
id: slugifyPkg(p), // np. "smart" -> użyjesz jako pkg-smart
name: p,
number: "—", // brak w nowej tabeli
guaranteed: false, // brak w nowej tabeli
}))
.sort((a, b) => a.name.localeCompare(b.name, "pl"));
return {
name: r.name,
logo_url: r.logo_url,
logo_url: r.logo_url || "", // base64 data-url albo ""
description: r.description || "",
min_number: Number(r.min_number || 0),
min_number: 0, // brak numerów
packages,
};
});
@@ -84,5 +95,9 @@ export function GET({ url }) {
status: 500,
headers: { "Content-Type": "application/json; charset=utf-8" },
});
} finally {
try {
db.close();
} catch {}
}
}

View File

@@ -1,64 +0,0 @@
// src/pages/api/jambox/channels.js
//import { getDb } from "../db.js";
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 });
}
export function GET({ url }) {
const packageIdParam = url.searchParams.get("packageId");
const packageId = Number(packageIdParam);
if (!packageId) {
return new Response(
JSON.stringify({ ok: false, error: "INVALID_PACKAGE_ID" }),
{
status: 400,
headers: { "Content-Type": "application/json; charset=utf-8" },
}
);
}
const db = getDb();
try {
const rows = db
.prepare(
`
SELECT
number,
name,
description,
logo_url,
guaranteed
FROM jambox_package_channels
WHERE package_id = ?
ORDER BY number ASC;
`.trim()
)
.all(packageId);
return new Response(
JSON.stringify({ ok: true, data: rows }),
{
status: 200,
headers: { "Content-Type": "application/json; charset=utf-8" },
}
);
} catch (err) {
console.error("❌ Błąd w /api/jambox/channels:", 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,231 @@
import path from "node:path";
import { XMLParser } from "fast-xml-parser";
import Database from "better-sqlite3";
/* =====================
KONFIG
===================== */
const FEEDS = [
{ url: "https://www.jambox.pl/xml/listakanalow-smart.xml", name: "Smart" },
{ url: "https://www.jambox.pl/xml/listakanalow-optimum.xml", name: "Optimum" },
{ url: "https://www.jambox.pl/xml/listakanalow-platinum.xml", name: "Platinum" },
{ url: "https://www.jambox.pl/xml/listakanalow-pluspodstawowy.xml", name: "Podstawowy" },
{ url: "https://www.jambox.pl/xml/listakanalow-pluskorzystny.xml", name: "Korzystny" },
{ url: "https://www.jambox.pl/xml/listakanalow-plusbogaty.xml", name: "Bogaty" },
];
// 👉 ustaw jeśli chcesz inną bazę
const DB_PATH =
process.env.FUZ_DB_PATH ||
path.join(process.cwd(), "src", "data", "ServicesRange.db");
/* =====================
DB
===================== */
function getDb() {
const db = new Database(DB_PATH);
db.pragma("journal_mode = WAL");
return db;
}
/* =====================
XML / HTML HELPERS
===================== */
async function fetchXml(url) {
const res = await fetch(url, {
headers: { accept: "application/xml,text/xml,*/*" },
});
if (!res.ok) throw new Error(`XML HTTP ${res.status} for ${url}`);
return await res.text();
}
function parseNodes(xmlText) {
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: "@_",
});
const json = parser.parse(xmlText);
const nodes = json?.xml?.node ?? json?.node ?? [];
return Array.isArray(nodes) ? nodes : [nodes];
}
function decodeEntities(input) {
if (!input) return "";
let s = String(input)
.replace(/&nbsp;/g, " ")
.replace(/&ndash;/g, "")
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&#39;/g, "'")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">");
s = s.replace(/&#(\d+);/g, (_, d) =>
String.fromCodePoint(Number(d))
);
s = s.replace(/&#x([0-9a-fA-F]+);/g, (_, h) =>
String.fromCodePoint(parseInt(h, 16))
);
return s;
}
function htmlToMarkdown(input) {
if (!input) return "";
let html = "";
if (typeof input === "string") html = input;
else if (input?.p) {
if (typeof input.p === "string") html = `<p>${input.p}</p>`;
else if (Array.isArray(input.p))
html = input.p.map((p) => `<p>${p}</p>`).join("");
} else html = String(input);
let s = decodeEntities(html);
s = s
.replace(/<\s*(ul|ol)[^>]*>/gi, "\n__LIST_START__\n")
.replace(/<\/\s*(ul|ol)\s*>/gi, "\n__LIST_END__\n")
.replace(/<\s*li[^>]*>/gi, "__LI__")
.replace(/<\/\s*li\s*>/gi, "\n")
.replace(/<\s*br\s*\/?\s*>/gi, "\n")
.replace(/<\/\s*(p|div)\s*>/gi, "\n")
.replace(/<\s*(p|div)[^>]*>/gi, "")
.replace(/<[^>]+>/g, "")
.replace(/\r/g, "")
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
const lines = s.split("\n").map((x) => x.trim());
const out = [];
let inList = false;
for (const line of lines) {
if (!line) {
if (!inList) out.push("");
continue;
}
if (line === "__LIST_START__") {
inList = true;
continue;
}
if (line === "__LIST_END__") {
inList = false;
out.push("");
continue;
}
if (inList && line.startsWith("__LI__")) {
out.push(`- ${line.replace("__LI__", "").trim()}`);
continue;
}
out.push(line);
}
return out.join("\n").trim();
}
function extractLogoUrl(node) {
const logo = node?.field_logo_fid;
if (!logo) return null;
if (typeof logo === "string") {
const m = logo.match(/src="([^"]+)"/);
return m?.[1] ?? null;
}
if (logo?.img?.["@_src"]) return logo.img["@_src"];
return null;
}
async function downloadLogoAsBase64(url) {
try {
const res = await fetch(url);
if (!res.ok) return null;
const ct = res.headers.get("content-type") || "image/png";
const buf = Buffer.from(await res.arrayBuffer());
if (!buf.length) return null;
return `data:${ct};base64,${buf.toString("base64")}`;
} catch {
return null;
}
}
/* =====================
API ROUTE
===================== */
export async function POST() {
const db = getDb();
// ⚠️ WYMAGANE:
// CREATE UNIQUE INDEX ux_jambox_channels_nazwa_pckg
// ON jambox_channels(nazwa, pckg_name);
const upsert = db.prepare(`
INSERT INTO jambox_channels (nazwa, pckg_name, image, opis)
VALUES (@nazwa, @pckg_name, @image, @opis)
ON CONFLICT(nazwa, pckg_name) DO UPDATE SET
image = COALESCE(excluded.image, jambox_channels.image),
opis = COALESCE(excluded.opis, jambox_channels.opis)
`);
const logoCache = new Map(); // nazwa(lower) -> base64 | null
const rows = [];
try {
for (const feed of FEEDS) {
const xml = await fetchXml(feed.url);
const nodes = parseNodes(xml);
for (const node of nodes) {
const name = (node?.nazwa_kanalu ?? node?.nazwa ?? "").trim();
if (!name) continue;
const opis = htmlToMarkdown(node?.opis) || null;
const key = name.toLowerCase();
let img = logoCache.get(key);
if (img === undefined) {
const logoUrl = extractLogoUrl(node);
img = logoUrl ? await downloadLogoAsBase64(logoUrl) : null;
logoCache.set(key, img);
}
rows.push({
nazwa: name,
pckg_name: feed.name,
image: img,
opis,
});
}
}
const trx = db.transaction((data) => {
for (const r of data) upsert.run(r);
});
trx(rows);
return new Response(
JSON.stringify({
ok: true,
rows: rows.length,
db: DB_PATH,
}),
{ headers: { "content-type": "application/json; charset=utf-8" } }
);
} catch (e) {
console.error("❌ import jambox_channels:", e);
return new Response(
JSON.stringify({ ok: false, error: String(e.message || e) }),
{ status: 500 }
);
} finally {
try { db.close(); } catch {}
}
}

View File

@@ -0,0 +1,263 @@
import type { APIRoute } from "astro";
import { XMLParser } from "fast-xml-parser";
import path from "node:path";
import fs from "node:fs/promises";
const URL = "https://www.jambox.pl/xml/mozliwosci.xml";
type Section = {
title: string;
image?: string;
content: string;
};
type ContentBlock =
| { type: "text"; value: string }
| { type: "list"; items: string[] };
function toArray<T>(v: T | T[] | undefined | null): T[] {
if (!v) return [];
return Array.isArray(v) ? v : [v];
}
/* =======================
HTML / XML HELPERS
======================= */
function decodeEntities(input: string): string {
if (!input) return "";
let s = String(input)
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">");
s = s.replace(/&#(\d+);/g, (_, d) =>
String.fromCodePoint(Number(d))
);
s = s.replace(/&#x([0-9a-fA-F]+);/g, (_, h) =>
String.fromCodePoint(parseInt(h, 16))
);
return s;
}
/**
* Parsuje HTML:
* - <p>, <div>, <br> → zwykłe nowe linie
* - <ul>/<ol><li> → markdown lista
*/
function parseHtmlContent(input?: string): ContentBlock[] {
if (!input) return [];
let s = decodeEntities(String(input));
// znaczniki list
s = s
.replace(/<\s*(ul|ol)[^>]*>/gi, "\n__LIST_START__\n")
.replace(/<\/\s*(ul|ol)\s*>/gi, "\n__LIST_END__\n")
.replace(/<\s*li[^>]*>/gi, "__LI__")
.replace(/<\/\s*li\s*>/gi, "\n");
// normalne bloki
s = s
.replace(/<\s*br\s*\/?\s*>/gi, "\n")
.replace(/<\/\s*(p|div)\s*>/gi, "\n")
.replace(/<\s*(p|div)[^>]*>/gi, "");
// usuń resztę tagów
s = s.replace(/<[^>]+>/g, "");
s = s
.replace(/\r/g, "")
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
const blocks: ContentBlock[] = [];
const lines = s.split("\n");
let textBuf: string[] = [];
let listBuf: string[] | null = null;
const flushText = () => {
const txt = textBuf.join("\n").trim();
if (txt) blocks.push({ type: "text", value: txt });
textBuf = [];
};
const flushList = () => {
if (listBuf && listBuf.length) {
blocks.push({
type: "list",
items: listBuf.map((x) => x.trim()).filter(Boolean),
});
}
listBuf = null;
};
for (const raw of lines) {
const line = raw.trim();
if (line === "__LIST_START__") {
flushText();
listBuf = [];
continue;
}
if (line === "__LIST_END__") {
flushList();
continue;
}
if (listBuf && line.startsWith("__LI__")) {
listBuf.push(line.replace("__LI__", "").trim());
continue;
}
if (!line) {
if (!listBuf) textBuf.push("");
continue;
}
textBuf.push(line);
}
flushText();
flushList();
return blocks;
}
function blocksToMarkdown(blocks: ContentBlock[]): string {
const out: string[] = [];
for (const b of blocks) {
if (b.type === "text") {
// 👉 każde zdanie zakończone kropką = nowa linia
const lines = b.value
.replace(/\.\s+/g, ".\n")
.split("\n")
.map((x) => x.trim())
.filter(Boolean);
out.push(lines.join("\n"));
}
if (b.type === "list") {
for (const item of b.items) {
out.push(`- ${item}`);
}
}
}
return out.join("\n\n").trim();
}
/* =======================
SCREEN / YAML
======================= */
function extractUrlsFromString(s: string): string[] {
return s.match(/https?:\/\/[^\s<"]+/g) ?? [];
}
function extractScreens(screen: any): string[] {
if (!screen) return [];
if (typeof screen === "string") return extractUrlsFromString(screen);
const divs = (screen as any)?.div;
if (divs) {
return toArray(divs)
.map((d) => (typeof d === "string" ? d : d?.["#text"] ?? ""))
.flatMap(extractUrlsFromString);
}
return extractUrlsFromString(JSON.stringify(screen));
}
function yamlQuote(v: string): string {
return `"${String(v).replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
}
function toYaml(sections: Section[]): string {
const out: string[] = ["sections:"];
for (const s of sections) {
out.push(` - title: ${yamlQuote(s.title)}`);
if (s.image) out.push(` image: ${yamlQuote(s.image)}`);
out.push(" content: |");
for (const line of s.content.split("\n")) {
out.push(` ${line}`);
}
out.push("");
}
return out.join("\n").trimEnd() + "\n";
}
/* =======================
API
======================= */
export const POST: APIRoute = async () => {
const res = await fetch(URL, {
headers: { accept: "application/xml,text/xml,*/*" },
});
if (!res.ok) {
return new Response(`JAMBOX XML: HTTP ${res.status}`, { status: 502 });
}
const xml = await res.text();
const parser = new XMLParser({ trimValues: true });
const parsed = parser.parse(xml);
const nodes = toArray((parsed as any)?.xml?.node ?? (parsed as any)?.node);
const sections: Section[] = nodes
.map((n: any) => {
const title = parseHtmlContent(n?.title)
.map((b) => (b.type === "text" ? b.value : ""))
.join(" ")
.trim();
if (!title) return null;
const blocks = [
...parseHtmlContent(n?.teaser),
...parseHtmlContent(n?.description),
];
const content = blocksToMarkdown(blocks);
if (!content) return null;
const screens = extractScreens(n?.screen);
const image = screens?.[0];
return { title, image, content };
})
.filter(Boolean) as Section[];
const outDir = path.join(
process.cwd(),
"src",
"content",
"internet-telewizja"
);
const outFile = path.join(outDir, "telewizja-mozliwosci.yaml");
await fs.mkdir(outDir, { recursive: true });
await fs.writeFile(outFile, toYaml(sections), "utf8");
return new Response(
JSON.stringify({ ok: true, count: sections.length }),
{ headers: { "content-type": "application/json" } }
);
};

View File

@@ -0,0 +1,67 @@
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 cleanPkgName(v) {
const s = String(v || "").trim();
// prosta sanity: niepuste, nieprzesadnie długie
if (!s) return null;
if (s.length > 64) return null;
return s;
}
export function GET({ url }) {
const pkg =
cleanPkgName(url.searchParams.get("package")) ||
cleanPkgName(url.searchParams.get("pckg_name"));
if (!pkg) {
return new Response(
JSON.stringify({ ok: false, error: "MISSING_PACKAGE_NAME" }),
{
status: 400,
headers: { "Content-Type": "application/json; charset=utf-8" },
}
);
}
const db = getDb();
try {
const rows = db
.prepare(
`
SELECT
nazwa AS name,
opis AS description,
image AS logo_url
FROM jambox_channels
WHERE pckg_name = ?
ORDER BY nazwa COLLATE NOCASE ASC;
`.trim()
)
.all(pkg);
return new Response(JSON.stringify({ ok: true, data: rows }), {
status: 200,
headers: { "Content-Type": "application/json; charset=utf-8" },
});
} catch (err) {
console.error("❌ Błąd w /api/jambox/channels:", err);
return new Response(
JSON.stringify({ ok: false, error: "DB_ERROR" }),
{
status: 500,
headers: { "Content-Type": "application/json; charset=utf-8" },
}
);
} finally {
try {
db.close();
} catch {}
}
}

View File

@@ -1,51 +0,0 @@
import Database from "better-sqlite3";
const DB_PATH = process.env.FUZ_DB_PATH || "./src/data/ServicesRange.db";
export async function GET({ url }) {
const db = new Database(DB_PATH, { readonly: true });
try {
const packageId = Number(url.searchParams.get("packageId") || 0);
if (!packageId) {
return new Response(
JSON.stringify({ ok: false, error: "MISSING_PACKAGE_ID" }),
{ status: 400, headers: { "Content-Type": "application/json; charset=utf-8" } }
);
}
const rows = db
.prepare(
`
SELECT
a.tid AS tid,
a.name AS name,
a.kind AS kind,
a.is_active AS is_active,
CAST(p.price AS REAL) AS price,
p.currency AS currency,
a.description AS description
FROM jambox_tv_addon_prices p
JOIN jambox_tv_addons a
ON a.tid = p.addon_tid
WHERE p.package_id = ?
AND a.is_active = 1
ORDER BY a.kind, a.name
`
)
.all(packageId);
return new Response(
JSON.stringify({ ok: true, count: rows.length, data: rows }),
{ status: 200, headers: { "Content-Type": "application/json; charset=utf-8" } }
);
} catch (err) {
console.error("❌ Błąd w /api/jambox/tv-addons:", err);
return new Response(
JSON.stringify({ ok: false, error: err.message || "DB_ERROR" }),
{ status: 500, headers: { "Content-Type": "application/json; charset=utf-8" } }
);
} finally {
db.close();
}
}

View File

@@ -1,85 +0,0 @@
import Database from "better-sqlite3";
const DB_PATH = process.env.FUZ_DB_PATH || "./src/data/ServicesRange.db";
export async function GET() {
const db = new Database(DB_PATH, { readonly: true });
try {
const stmt = db.prepare(`
SELECT
p.id AS plan_id,
p.name AS plan_name,
IFNULL(p.popular, 0) AS plan_popular,
p.price_monthly AS price_monthly,
p.currency AS currency,
f.id AS feature_id,
f.label AS feature_label,
fv.value AS feature_value
FROM phone_plans p
LEFT JOIN phone_plan_feature_values fv
ON fv.plan_id = p.id
LEFT JOIN phone_features f
ON f.id = fv.feature_id
ORDER BY p.id ASC, f.id ASC
`);
const rows = stmt.all();
const byPlan = new Map();
for (const row of rows) {
if (!byPlan.has(row.plan_id)) {
byPlan.set(row.plan_id, {
id: row.plan_id,
code: row.plan_code,
name: row.plan_name,
popular: !!row.plan_popular,
price_monthly: row.price_monthly,
currency: row.currency || "PLN",
features: [],
});
}
if (row.feature_id) {
byPlan.get(row.plan_id).features.push({
id: row.feature_id,
label: row.feature_label,
value: row.feature_value,
});
}
}
const data = Array.from(byPlan.values());
return new Response(
JSON.stringify({
ok: true,
count: data.length,
data,
}),
{
status: 200,
headers: {
"Content-Type": "application/json; charset=utf-8",
},
}
);
} catch (err) {
console.error("Błąd w /api/phone/plans:", err);
return new Response(
JSON.stringify({
ok: false,
error: err.message || "DB_ERROR",
}),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
);
} finally {
db.close();
}
}

View File

@@ -1,26 +1,86 @@
---
import path from "node:path";
import DefaultLayout from "../../layouts/DefaultLayout.astro";
import OffersSwitches from "../../islands/OffersSwitches.jsx";
import InternetCards from "../../islands/Internet/InternetCards.jsx";
import SectionRenderer from "../../components/sections/SectionRenderer.astro";
import InternetCards from "../../islands/Internet/InternetCards.jsx";
import yaml from "js-yaml";
import fs from "fs";
import { loadYamlFile } from "../../lib/loadYaml";
const seo = yaml.load(
fs.readFileSync("./src/content/internet-swiatlowodowy/seo.yaml", "utf8"),
type SeoYaml = any;
type InternetParam = { klucz: string; label: string; value: string | number };
type InternetCena = { budynek: number | string; umowa: number | string; miesiecznie: number; aktywacja?: number };
type InternetCard = { nazwa: string; widoczny?: boolean; popularny?: boolean; parametry?: InternetParam[]; ceny?: InternetCena[] };
type InternetCardsYaml = {
tytul?: string;
opis?: string;
waluta?: string;
cena_opis?: string;
cards?: InternetCard[];
};
// TELEFON YAML (twój format)
type PhoneParam = { klucz: string; label: string; value: string | number };
type PhoneCard = { nazwa: string; widoczny?: boolean; popularny?: boolean; cena?: { wartosc: number; opis?: string }; parametry?: PhoneParam[] };
type PhoneCardsYaml = { cards?: PhoneCard[] };
// ADDONS YAML (twój format)
type Addon = { id: string; nazwa: string; typ?: string; ilosc?: boolean; min?: number; max?: number; krok?: number; opis?: string; cena: number };
type AddonsYaml = { cena_opis?: string; dodatki?: Addon[] };
const seo = loadYamlFile<SeoYaml>(
path.join(process.cwd(), "src", "content", "internet-swiatlowodowy", "seo.yaml"),
);
const data = loadYamlFile<InternetCardsYaml>(
path.join(process.cwd(), "src", "content", "internet-swiatlowodowy", "cards.yaml"),
);
const phoneData = loadYamlFile<PhoneCardsYaml>(
path.join(process.cwd(), "src", "content", "telefon", "cards.yaml"),
);
const addonsData = loadYamlFile<AddonsYaml>(
path.join(process.cwd(), "src", "content", "internet-swiatlowodowy", "addons.yaml"),
);
const tytul = data?.tytul ?? "";
const opis = data?.opis ?? "Wybierz rodzaj budynku i czas trwania umowy";
const waluta = data?.waluta ?? "PLN";
const cenaOpis = data?.cena_opis ?? "zł/mies.";
const cards: InternetCard[] = Array.isArray(data?.cards)
? data.cards.filter((c) => c?.widoczny === true)
: [];
const phoneCards: PhoneCard[] = Array.isArray(phoneData?.cards)
? phoneData.cards.filter((c) => c?.widoczny === true)
: [];
const addons: Addon[] = Array.isArray(addonsData?.dodatki)
? addonsData.dodatki
: [];
// jeśli chcesz, możesz nadpisać cenaOpis w modalu z addons.yaml:
const addonsCenaOpis = addonsData?.cena_opis ?? cenaOpis;
---
<DefaultLayout seo={seo}>
<section class="f-section">
<div class="f-section-grid-single md:grid-cols-1">
<h1 class="f-section-title">Internet światłowodowy</h1>
<div class="fuz-markdown max-w-none">
<p>Wybierz rodzaj budynku i czas trwania umowy</p>
</div>
<OffersSwitches client:load />
<InternetCards client:load />
<InternetCards
client:load
title={tytul}
description={opis}
cards={cards}
waluta={waluta}
cenaOpis={cenaOpis}
phoneCards={phoneCards}
addons={addons}
addonsCenaOpis={addonsCenaOpis}
/>
</div>
</section>

View File

@@ -1,27 +1,165 @@
---
import path from "node:path";
import DefaultLayout from "../../layouts/DefaultLayout.astro";
import OffersSwitches from "../../islands/OffersSwitches.jsx";
import JamboxCards from "../../islands/jambox/JamboxCards.jsx";
import SectionRenderer from "../../components/sections/SectionRenderer.astro";
import JamboxCards from "../../islands/jambox/JamboxCards.jsx";
import SectionChannelsSearch from "../../components/sections/SectionChannelsSearch.astro";
import yaml from "js-yaml";
import fs from "fs";
import { loadYamlFile } from "../../lib/loadYaml";
const seo = yaml.load(
fs.readFileSync("./src/content/internet-telewizja/seo.yaml", "utf8"),
type SeoYaml = any;
type Param = { klucz: string; label: string; value: string | number };
type Cena = {
budynek: number | string;
umowa: number | string;
miesiecznie: number;
aktywacja?: number;
};
type Card = {
id?: string;
source?: string;
tid?: number;
nazwa: string;
slug?: string;
widoczny?: boolean;
popularny?: boolean;
parametry?: Param[];
ceny?: Cena[];
};
type CardsYaml = {
tytul?: string;
opis?: string;
waluta?: string;
cena_opis?: string;
internet_parametry_wspolne?: Param[];
cards?: Card[];
};
// ✅ telefon z YAML (do modala)
type PhoneParam = { klucz: string; label: string; value: string | number };
type PhoneCard = {
id?: string;
nazwa: string;
widoczny?: boolean;
popularny?: boolean;
cena?: { wartosc: number; opis?: string };
parametry?: PhoneParam[];
};
type PhoneYaml = { cards?: PhoneCard[] };
type Decoder = { id: string; nazwa: string; cena: number };
// ✅ dodatki z YAML (do modala)
type Addon = {
id: string;
nazwa: string;
typ?: "checkbox" | "quantity";
ilosc?: boolean;
min?: number;
max?: number;
krok?: number;
opis?: string;
cena: number;
};
type AddonsYaml = {
tytul?: string;
opis?: string;
cena_opis?: string;
dekodery?: Decoder[];
dodatki?: Addon[];
};
type ChannelsYaml = {
title?: string;
updated_at?: string;
channels?: Array<{
nazwa: string;
opis?: string;
image?: string;
pakiety?: string[];
}>;
};
const seo = loadYamlFile<SeoYaml>(
path.join(process.cwd(), "src", "content", "internet-telewizja", "seo.yaml"),
);
const data = loadYamlFile<CardsYaml>(
path.join(
process.cwd(),
"src",
"content",
"internet-telewizja",
"cards.yaml",
),
);
const tytul = data?.tytul ?? "";
const opis = data?.opis ?? "Wybierz rodzaj budynku i czas trwania umowy";
const waluta = data?.waluta ?? "PLN";
const cenaOpis = data?.cena_opis ?? "zł/mies.";
const internetWspolne: Param[] = Array.isArray(data?.internet_parametry_wspolne)
? data.internet_parametry_wspolne
: [];
const cards: Card[] = Array.isArray(data?.cards)
? data.cards.filter((c) => c?.widoczny === true)
: [];
// ✅ NOWE: dane do modala dodatków (bez ruszania reszty)
const phoneYaml = loadYamlFile<PhoneYaml>(
path.join(process.cwd(), "src", "content", "telefon", "cards.yaml"),
);
const tvAddonsYaml = loadYamlFile<any>(
path.join(process.cwd(), "src", "content", "internet-telewizja", "tv-addons.yaml"),
);
const phoneCards = Array.isArray(phoneYaml?.cards) ? phoneYaml.cards : [];
const tvAddons = Array.isArray(tvAddonsYaml?.dodatki) ? tvAddonsYaml.dodatki : [];
const addonsYaml = loadYamlFile<AddonsYaml>(
path.join(
process.cwd(),
"src",
"content",
"internet-telewizja",
"addons.yaml",
),
);
const addons: Addon[] = Array.isArray(addonsYaml?.dodatki)
? addonsYaml.dodatki
: [];
const decoders: Decoder[] = Array.isArray(addonsYaml?.dekodery)
? addonsYaml.dekodery
: [];
const addonsCenaOpis = addonsYaml?.cena_opis ?? cenaOpis;
---
<DefaultLayout seo={seo}>
<section class="f-section">
<div class="f-section-grid-single md:grid-cols-1">
<h1 class="f-section-title">Telewizja z interentem</h1>
<div class="fuz-markdown max-w-none">
<p>Wybierz rodzaj budynku i czas trwania umowy</p>
</div>
<OffersSwitches client:load />
<JamboxCards client:load />
<JamboxCards
client:load
title={tytul}
description={opis}
cards={cards}
internetWspolne={internetWspolne}
waluta={waluta}
cenaOpis={cenaOpis}
tvAddons={tvAddons}
phoneCards={phoneCards}
decoders={decoders}
addons={addons}
addonsCenaOpis={addonsCenaOpis}
/>
</div>
</section>
<SectionChannelsSearch />

View File

@@ -1,96 +0,0 @@
---
export const prerender = false;
import DefaultLayout from "../../layouts/DefaultLayout.astro";
import Markdown from "../../islands/Markdown.jsx";
import { fetchMozliwosci, type Feature } from "../../lib/mozliwosci";
let items: Feature[] = [];
let err = "";
try {
items = await fetchMozliwosci(60_000);
} catch (e) {
err = e instanceof Error ? e.message : String(e);
}
function buildMarkdown(it: Feature) {
const parts: string[] = [];
if (it.teaser) parts.push(`> ${it.teaser}\n`);
if (it.description) parts.push(it.description);
return parts.join("\n\n").trim();
}
---
<DefaultLayout title="Możliwości JAMBOX">
<!-- NAGŁÓWEK STRONY zgodny z FUZ -->
<section class="f-section" id="top">
<div class="f-section-grid-single">
<h1 class="f-section-title">Możliwości JAMBOX</h1>
<div class="fuz-markdown max-w-none">
<p>Funkcje i udogodnienia dostępne w JAMBOX.</p>
</div>
</div>
{
err && (
<div class="mt-6 max-w-7xl mx-auto text-left rounded-2xl border border-red-300 bg-red-50 p-4">
<p class="font-bold">Nie udało się pobrać danych</p>
<p class="opacity-80">{err}</p>
</div>
)
}
</section>
{
!err && (
<>
{items.map((it, index) => {
const reverse = index % 2 === 1;
const imageUrl = it.screens?.[0] || "";
const hasImage = !!imageUrl;
return (
<section class="f-section" id={it.id}>
<div
class={`f-section-grid ${
hasImage
? "md:grid-cols-2"
: "md:grid-cols-1"
}`}
>
{hasImage && (
<img
src={imageUrl}
alt={it.title}
class={`f-section-image ${
reverse
? "md:order-1"
: "md:order-2"
} rounded-2xl border border-[--f-border-color]`}
loading="lazy"
decoding="async"
/>
)}
<div
class={`${hasImage ? (reverse ? "md:order-2" : "md:order-1") : ""}`}
>
<h2 class="f-section-title">{it.title}</h2>
<Markdown text={buildMarkdown(it)} />
<div class="f-section-nav">
<a href="#top" class="btn btn-outline">
Do góry ↑
</a>
</div>
</div>
</div>
</section>
);
})}
</>
)
}
</DefaultLayout>

View File

@@ -0,0 +1,94 @@
---
export const prerender = false;
import path from "node:path";
import DefaultLayout from "../../layouts/DefaultLayout.astro";
import { loadYamlFile } from "../../lib/loadYaml";
import MozliwosciSearch from "../../islands/jambox/JamboxMozliwosciSearch.jsx";
type YamlSection = {
title: string;
image?: string;
content?: string;
};
type YamlData = {
sections?: YamlSection[];
};
function slugify(s: string) {
return String(s || "")
.toLowerCase()
.replace(/[\u0105]/g, "a")
.replace(/[\u0107]/g, "c")
.replace(/[\u0119]/g, "e")
.replace(/[\u0142]/g, "l")
.replace(/[\u0144]/g, "n")
.replace(/[\u00f3]/g, "o")
.replace(/[\u015b]/g, "s")
.replace(/[\u017a\u017c]/g, "z")
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
}
let items: Array<{
id: string;
title: string;
image?: string;
content: string;
}> = [];
let err = "";
try {
const data = loadYamlFile<YamlData>(
path.join(
process.cwd(),
"src",
"content",
"internet-telewizja",
"telewizja-mozliwosci.yaml"
)
);
const sections = Array.isArray(data?.sections) ? data.sections : [];
items = sections
.filter((s) => s?.title)
.map((s) => ({
id: slugify(s.title),
title: s.title,
image: s.image,
content: (s.content || "").trim(),
}))
.filter((x) => x.content);
} catch (e) {
err = e instanceof Error ? e.message : String(e);
}
---
<DefaultLayout title="Możliwości JAMBOX">
<!-- NAGŁÓWEK STRONY zgodny z FUZ -->
<section class="f-section" id="top">
<div class="f-section-grid-single">
<h1 class="f-section-title">Możliwości JAMBOX</h1>
<div class="fuz-markdown max-w-none">
<p>Funkcje i udogodnienia dostępne w JAMBOX.</p>
</div>
{!err && <MozliwosciSearch client:load items={items} />}
</div>
{
err && (
<div class="mt-6 max-w-7xl mx-auto text-left rounded-2xl border border-red-300 bg-red-50 p-4">
<p class="font-bold">Nie udało się wczytać danych</p>
<p class="opacity-80">{err}</p>
</div>
)
}
</section>
{/* UWAGA: render sekcji przeniesiony do wyspy, żeby filtr działał */}
</DefaultLayout>

View File

@@ -1,21 +1,56 @@
---
import path from "node:path";
import DefaultLayout from "../../layouts/DefaultLayout.astro";
import SectionRenderer from "../../components/sections/SectionRenderer.astro";
import OffersPhoneCards from "../../islands/phone/OffersPhoneCards.jsx";
import yaml from "js-yaml";
import fs from "fs";
import { loadYamlFile } from "../../lib/loadYaml";
const seo = yaml.load(
fs.readFileSync("./src/content/telefon/seo.yaml", "utf8"),
type SeoYaml = any;
type PhoneParam = {
klucz: string;
label: string;
value: string | number;
};
type PhoneCard = {
nazwa: string;
widoczny?: boolean;
popularny?: boolean;
cena?: { wartosc: number; opis?: string };
parametry?: PhoneParam[];
};
type PhoneCardsYaml = {
tytul?: string;
opis?: string;
cards?: PhoneCard[];
};
const seo = loadYamlFile<SeoYaml>(
path.join(process.cwd(), "src", "content", "telefon", "seo.yaml"),
);
const phoneCards = loadYamlFile<PhoneCardsYaml>(
path.join(process.cwd(), "src", "content", "telefon", "cards.yaml"),
);
const tytul = phoneCards?.tytul ?? "";
const opis = phoneCards?.opis ?? "";
const cards: PhoneCard[] = Array.isArray(phoneCards?.cards)
? phoneCards.cards.filter((c) => c?.widoczny === true)
: [];
---
<DefaultLayout seo={seo}>
<section class="f-section">
<div class="f-section-grid-single md:grid-cols-1">
<h1 class="f-section-title">Usługa telefonu</h1>
<OffersPhoneCards client:load />
<OffersPhoneCards client:load title={tytul} description={opis} cards={cards} />
</div>
</section>