// ─────────────────────────────────────────────────────────────────────────
// admin.jsx — Comprehensive admin panel with secure login
// Tile manager, articles CMS, bank rates editor, FX editor, AI generator,
// theme/colors, navigation, footer content, ad slots, inbox, auto-update
// ─────────────────────────────────────────────────────────────────────────
const { useState: useStateA, useEffect: useEffectA, useRef: useRefA, useMemo: useMemoA } = React;
const ADMIN_API_BASE = "/api/admin";
let adminCsrfToken = null;
async function getAdminCsrf() {
if (adminCsrfToken) return adminCsrfToken;
const res = await fetch(`${ADMIN_API_BASE}/csrf.php`, { credentials: "include" });
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.csrf) {
throw new Error(data.error || "Nie udalo sie pobrac tokenu bezpieczenstwa.");
}
adminCsrfToken = data.csrf;
return adminCsrfToken;
}
async function adminFetch(path, options = {}) {
const method = (options.method || "GET").toUpperCase();
const headers = { ...(options.headers || {}) };
if (method !== "GET") {
headers["Content-Type"] = "application/json";
headers["X-CSRF-Token"] = await getAdminCsrf();
}
const res = await fetch(`${ADMIN_API_BASE}${path}`, {
...options,
method,
headers,
credentials: "include",
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.error || `Blad API (${res.status}).`);
}
return data;
}
function isStrongAdminPassword(password) {
return password.length >= 16
&& /[A-Z]/.test(password)
&& /[a-z]/.test(password)
&& /[0-9]/.test(password)
&& /[^A-Za-z0-9]/.test(password);
}
function AdminSetup({ onInstalled }) {
const [form, setForm] = useStateA({ username: "admin", password: "", confirm: "" });
const [error, setError] = useStateA("");
const [pending, setPending] = useStateA(false);
const [showPw, setShowPw] = useStateA(false);
const submit = async (e) => {
e.preventDefault();
if (pending) return;
setError("");
if (!isStrongAdminPassword(form.password)) {
setError("Haslo musi miec min. 16 znakow oraz duza litere, mala litere, cyfre i znak specjalny.");
return;
}
if (form.password !== form.confirm) {
setError("Hasla nie sa takie same.");
return;
}
setPending(true);
try {
await adminFetch("/install.php", {
method: "POST",
body: JSON.stringify({
username: form.username,
password: form.password,
password_confirm: form.confirm,
}),
});
onInstalled(form.username);
} catch (err) {
setError(err.message || "Nie udalo sie utworzyc konta admina.");
} finally {
setPending(false);
}
};
return (
Pierwsza konfiguracja
Utworz konto admina
Konto zostanie zapisane w bazie MySQL jako bcrypt + pepper. Po zapisaniu endpoint instalacyjny sam sie zablokuje.
);
}
function AdminLogin({ onLogin, initialUsername = "admin" }) {
const [username, setUsername] = useStateA(initialUsername);
const [password, setPassword] = useStateA("");
const [totp, setTotp] = useStateA("");
const [totpRequired, setTotpRequired] = useStateA(false);
const [error, setError] = useStateA("");
const [pending, setPending] = useStateA(false);
const [showPw, setShowPw] = useStateA(false);
const [attempts, setAttempts] = useStateA(0);
const submit = async (e) => {
e.preventDefault();
if (pending) return;
setPending(true);
setError("");
try {
const data = await adminFetch("/login.php", {
method: "POST",
body: JSON.stringify({ username, password, totp }),
});
if (data.totp_required) {
setTotpRequired(true);
setError("Podaj kod 2FA z aplikacji uwierzytelniajacej.");
return;
}
onLogin();
} catch (err) {
setAttempts(a => a + 1);
setError(err.message || "Blad uwierzytelniania.");
setPassword("");
} finally {
setPending(false);
}
};
return (
Panel admina
KreditEU
Logowanie dziala przez bezpieczna sesje backendu, CSRF i haslo bcrypt zapisane w MySQL.
{attempts >= 3 && (
Pozostale proby przed blokada IP: {Math.max(0, 5 - attempts)}
)}
);
}
function AdminPage({ navigate, tiles, setTiles, tweaks, setTweak }) {
const [auth, setAuth] = useStateA({ loading: true, authed: false, installed: true, username: "admin", error: "" });
const refreshAuth = async () => {
setAuth(a => ({ ...a, loading: true, error: "" }));
try {
const status = await adminFetch("/status.php");
if (!status.installed) {
setAuth({ loading: false, authed: false, installed: false, username: "admin", error: "" });
return;
}
const me = await adminFetch("/me.php");
setAuth({
loading: false,
authed: !!me.authenticated,
installed: true,
username: me.user?.username || "admin",
error: "",
});
} catch (err) {
setAuth({ loading: false, authed: false, installed: true, username: "admin", error: err.message || "Nie udalo sie sprawdzic sesji." });
}
};
useEffectA(() => { refreshAuth(); }, []);
if (auth.loading) {
return (
Sprawdzam sesje admina...
);
}
if (!auth.installed) {
return setAuth({ loading: false, authed: false, installed: true, username, error: "" })} />;
}
if (!auth.authed) {
return (
<>
{auth.error && (
)}
>
);
}
const logout = async () => {
try {
await adminFetch("/logout.php", { method: "POST", body: "{}" });
} catch {}
adminCsrfToken = null;
setAuth(a => ({ ...a, authed: false }));
};
return ;
}
function AdminShell({ navigate, tiles, setTiles, tweaks, setTweak, logout }) {
const [tab, setTab] = useStateA("dashboard");
const [inbox, setInbox] = useStateA(() => {
try { return JSON.parse(localStorage.getItem("kr_inbox") || "[]"); } catch { return []; }
});
// Refresh inbox count periodically
useEffectA(() => {
const sync = () => {
try { setInbox(JSON.parse(localStorage.getItem("kr_inbox") || "[]")); } catch {}
};
const id = setInterval(sync, 2000);
window.addEventListener("storage", sync);
return () => { clearInterval(id); window.removeEventListener("storage", sync); };
}, []);
const unreadCount = inbox.filter(m => m.status === "new").length;
const tabs = [
{ k: "dashboard", l: "📊 Dashboard", desc: "Przegląd serwisu" },
{ k: "tiles", l: "🧩 Kafelki", desc: "Drag & drop, edycja" },
{ k: "articles", l: "📝 Artykuły", desc: "CMS, generator AI obrazów" },
{ k: "ai", l: "🤖 Generator AI", desc: "Claude / Gemini" },
{ k: "banks", l: "🏦 Oprocentowanie", desc: "Edycja stóp banków" },
{ k: "fx", l: "💱 Kursy walut", desc: "NBP / ECB" },
{ k: "autoupdate",l: "🔄 Auto-update", desc: "Tygodniowy harmonogram" },
{ k: "inbox", l: "📬 Skrzynka", desc: "Wiadomości od użytkowników", badge: unreadCount },
{ k: "theme", l: "🎨 Motyw", desc: "Kolory i layout" },
{ k: "ads", l: "📢 Reklamy", desc: "Slot AdSense" },
{ k: "i18n", l: "🌐 Języki", desc: "PL · EN · DE · FR …" },
{ k: "settings", l: "⚙️ Ustawienia", desc: "Hasło, API keys" },
];
return (
⚙️ Panel admina
Zarządzaj KreditEU
Sesja aktywna
navigate("home")}>← Podgląd
🚪 Wyloguj
{/* Stats strip */}
0 ? "accent" : "default"} />
0 ? "warn" : "muted"} />
{/* Layout: sidebar + content */}
{tabs.map(t => (
setTab(t.k)} style={{
display: "flex", width: "100%",
flexDirection: "column", alignItems: "flex-start",
padding: "10px 12px",
background: tab === t.k ? "var(--surface-2)" : "transparent",
border: "1px solid " + (tab === t.k ? "var(--border)" : "transparent"),
borderRadius: 10,
color: tab === t.k ? "var(--text)" : "var(--text-2)",
cursor: "pointer", marginBottom: 2,
textAlign: "left", position: "relative",
transition: "all 0.14s",
}}>
{t.l}
{t.badge > 0 && (
{t.badge}
)}
{t.desc}
))}
{tab === "dashboard" && }
{tab === "tiles" && }
{tab === "articles" && }
{tab === "ai" && setTab("articles")} />}
{tab === "banks" && }
{tab === "fx" && }
{tab === "autoupdate" && }
{tab === "inbox" && }
{tab === "theme" && }
{tab === "ads" && }
{tab === "i18n" && }
{tab === "settings" && }
);
}
function AdminStat({ value, label, tone }) {
const colors = {
accent: { bg: "var(--accent-soft)", text: "var(--accent)" },
warn: { bg: "color-mix(in oklch, var(--warn) 14%, var(--surface))", text: "var(--warn)" },
default: { bg: "var(--surface)", text: "var(--text)" },
muted: { bg: "var(--surface)", text: "var(--text-3)" },
};
const c = colors[tone] || colors.default;
return (
);
}
/* ────────── Dashboard ────────── */
function AdminDashboard({ tiles, navigate, setTab, unread = 0 }) {
return (
{unread > 0 && (
📬 Nowe wiadomości
Masz {unread} {unread === 1 ? "nieprzeczytaną wiadomość" : "nieprzeczytanych wiadomości"} w skrzynce
setTab("inbox")}>Otwórz skrzynkę →
)}
{[
{ i: "📝", t: "Dodaj artykuł", d: "Ręcznie + zdjęcie AI", a: () => setTab("articles") },
{ i: "🧩", t: "Edytuj kafelki", d: "Zmień układ home page", a: () => setTab("tiles") },
{ i: "🏦", t: "Zaktualizuj oprocentowanie", d: "Stopy banków", a: () => setTab("banks") },
{ i: "🔄", t: "Auto-update", d: "Harmonogram tygodniowy", a: () => setTab("autoupdate") },
{ i: "💱", t: "Pobierz kursy NBP", d: "Aktualne dane FX", a: () => setTab("fx") },
{ i: "📬", t: "Skrzynka", d: unread > 0 ? `${unread} nowych wiadomości` : "Wszystko przeczytane", a: () => setTab("inbox") },
{ i: "🎨", t: "Zmień motyw", d: "Kolory, ciemny/jasny", a: () => setTab("theme") },
{ i: "🤖", t: "Generuj AI", d: "Artykuł z Claude/Gemini", a: () => setTab("ai") },
].map((s, i) => (
{s.i}
{s.t}
{s.d}
))}
📰 Ostatnia aktualizacja: 12.05.2026 06:00
🔄 Odśwież AI
PKO BP obniżył oprocentowanie kredytów hipotecznych o 0.10 pp do 6.49%
EBC pozostawił stopy bez zmian — EURIBOR 3M ustabilizował się na 2.61%
Santander podwyższył marżę kredytów gotówkowych o 0.15 pp
Nowy program: Bezpieczny Kredyt 2% wystartuje od 1 lipca 2026
);
}
/* ────────── Tiles editor — drag & drop ────────── */
function AdminTiles({ tiles, setTiles }) {
const [editing, setEditing] = useStateA(null);
const dragIdx = useRefA(null);
const handleDragStart = (i) => { dragIdx.current = i; };
const handleDragOver = (e) => e.preventDefault();
const handleDrop = (i) => {
if (dragIdx.current === null) return;
const next = [...tiles];
const [moved] = next.splice(dragIdx.current, 1);
next.splice(i, 0, moved);
setTiles(next);
dragIdx.current = null;
};
const updateTile = (id, patch) => setTiles(tiles.map(t => t.id === id ? { ...t, ...patch } : t));
const deleteTile = (id) => setTiles(tiles.filter(t => t.id !== id));
const addTile = () => {
const id = "custom-" + Math.random().toString(36).slice(2, 7);
setTiles([...tiles, { id, kind: "ai", size: "md", title: "Nowy kafelek", sub: "Edytuj treść" }]);
setEditing(id);
};
const resetTiles = () => setTiles([...window.DEFAULT_TILES]);
return (
↺ Reset
+ Dodaj kafelek
}
/>
{tiles.map((t, i) => (
handleDragStart(i)}
onDragOver={handleDragOver}
onDrop={() => handleDrop(i)}
style={{
display: "grid",
gridTemplateColumns: "auto 1fr auto auto auto auto",
gap: 12, alignItems: "center",
padding: 14,
background: editing === t.id ? "var(--surface-2)" : "var(--surface)",
border: "1px solid " + (editing === t.id ? "var(--accent)" : "var(--border-soft)"),
borderRadius: 12, cursor: "grab",
}}
>
⋮⋮
{editing === t.id ? (
updateTile(t.id, { title: e.target.value })}
style={{ padding: "6px 10px", fontSize: 13 }} />
) : (
{t.title}
{t.sub &&
{t.sub}
}
)}
{t.kind}
updateTile(t.id, { size: e.target.value })}
style={{ padding: "6px 10px", fontSize: 12, width: "auto" }}>
SM
MD
LG
XL
setEditing(editing === t.id ? null : t.id)} style={iconBtn2()}>{editing === t.id ? "✓" : "✎"}
deleteTile(t.id)} style={{ ...iconBtn2(), color: "var(--bad)" }}>🗑
))}
💡 Zmiany zapisują się automatycznie i widać je na stronie głównej w czasie rzeczywistym.
);
}
function iconBtn2() {
return {
width: 32, height: 32, display: "inline-flex", alignItems: "center", justifyContent: "center",
background: "var(--bg-2)", border: "1px solid var(--border-soft)", borderRadius: 8,
color: "var(--text-2)", cursor: "pointer", fontSize: 12,
};
}
/* ────────── Inbox (internal messages from contact form) ────────── */
function AdminInbox({ inbox, setInbox }) {
const [selected, setSelected] = useStateA(inbox[0] || null);
const [filter, setFilter] = useStateA("all");
const [search, setSearch] = useStateA("");
// Sync to localStorage
const updateMessages = (next) => {
setInbox(next);
localStorage.setItem("kr_inbox", JSON.stringify(next));
};
const markStatus = (id, status) => {
updateMessages(inbox.map(m => m.id === id ? { ...m, status } : m));
if (selected && selected.id === id) setSelected({ ...selected, status });
};
const deleteMessage = (id) => {
if (!confirm("Usunąć wiadomość?")) return;
const next = inbox.filter(m => m.id !== id);
updateMessages(next);
if (selected && selected.id === id) setSelected(next[0] || null);
};
const insertDemo = () => {
const demos = [
{ id: Date.now() + 1, name: "Anna Kowalska", email: "anna@example.com", topic: "general", msg: "Dzień dobry, mam pytanie odnośnie kalkulatora hipotecznego. Jak wprowadzić oprocentowanie zmienne? Pozdrawiam.", date: new Date().toISOString(), status: "new" },
{ id: Date.now() + 2, name: "Tomasz Nowak", email: "tomasz.nowak@firma.pl", topic: "ads", msg: "Witam, jesteśmy zainteresowani współpracą reklamową na Państwa stronie. Prosimy o kontakt zwrotny w sprawie cennika.", date: new Date(Date.now() - 3600000).toISOString(), status: "new" },
{ id: Date.now() + 3, name: "Piotr Wiśniewski", email: "p.wisniewski@gmail.com", topic: "bug", msg: "Kalkulator nieprawidłowo przelicza ratę dla okresu 30 lat. Sprawdziłem w Excelu i wychodzą inne wartości.", date: new Date(Date.now() - 86400000).toISOString(), status: "read" },
];
const next = [...demos, ...inbox];
updateMessages(next);
setSelected(demos[0]);
};
const filtered = inbox.filter(m => {
if (filter !== "all" && m.status !== filter) return false;
if (search && !(m.name.toLowerCase().includes(search.toLowerCase()) || m.email.toLowerCase().includes(search.toLowerCase()) || m.msg.toLowerCase().includes(search.toLowerCase()))) return false;
return true;
});
const topicLabels = {
general: "Pytanie ogólne",
bug: "Zgłoszenie błędu",
ads: "Reklama / partnerstwo",
rodo: "Usunięcie danych (RODO)",
};
return (
+ Demo
{ if (confirm("Wyczyścić całą skrzynkę?")) updateMessages([]); }}>🗑 Wyczyść
}
/>
{/* List */}
setSearch(e.target.value)}
style={{ flex: "1 1 100%", padding: "8px 12px", fontSize: 12, marginBottom: 8 }} />
{[["all", "Wszystkie", inbox.length],
["new", "Nowe", inbox.filter(m => m.status === "new").length],
["read", "Przeczytane", inbox.filter(m => m.status === "read").length],
["replied", "Odpowiedziane", inbox.filter(m => m.status === "replied").length]].map(([k, l, n]) => (
setFilter(k)} style={{
padding: "4px 10px", fontSize: 11, fontWeight: 700,
background: filter === k ? "var(--accent)" : "var(--bg-2)",
color: filter === k ? "var(--bg)" : "var(--text-2)",
border: "none", borderRadius: 999, cursor: "pointer",
}}>{l} ({n})
))}
{filtered.length === 0 && (
📭
{inbox.length === 0 ? "Skrzynka pusta" : "Brak wyników"}
{inbox.length === 0 &&
Wiadomości z formularza pojawią się tu automatycznie.
}
)}
{filtered.map(m => (
{ setSelected(m); if (m.status === "new") markStatus(m.id, "read"); }}
style={{
display: "block", width: "100%", textAlign: "left",
padding: "14px 16px",
background: selected && selected.id === m.id ? "var(--surface-2)" : "transparent",
border: "none", borderBottom: "1px solid var(--border-soft)",
cursor: "pointer",
position: "relative",
}}>
{m.status === "new" && }
{m.name}
{formatRel(m.date)}
{topicLabels[m.topic] || m.topic}
{m.msg}
))}
{/* Preview */}
{!selected ? (
✉️
Wybierz wiadomość, aby ją przeczytać
) : (
<>
{topicLabels[selected.topic] || selected.topic}
{selected.name}
{selected.status === "new" ? "🟡 Nowa" : selected.status === "read" ? "🔵 Przeczytana" : "🟢 Odpowiedziana"}
{selected.msg}
markStatus(selected.id, "replied")}>
↩ Odpowiedz przez email
{selected.status !== "replied" && (
markStatus(selected.id, "replied")}>✓ Oznacz jako odpowiedziane
)}
{selected.status === "new" && (
markStatus(selected.id, "read")}>👁 Oznacz jako przeczytane
)}
deleteMessage(selected.id)} style={{ color: "var(--bad)" }}>🗑 Usuń
>
)}
);
}
function formatRel(iso) {
const ms = Date.now() - new Date(iso).getTime();
const m = Math.floor(ms / 60000);
if (m < 1) return "teraz";
if (m < 60) return m + " min";
const h = Math.floor(m / 60);
if (h < 24) return h + " godz.";
const d = Math.floor(h / 24);
if (d < 7) return d + " dni";
return new Date(iso).toLocaleDateString("pl-PL");
}
/* ────────── Auto-update of bank rates and FX ────────── */
function AdminAutoUpdate() {
const [running, setRunning] = useStateA(false);
const [log, setLog] = useStateA([
{ time: "2026-05-12 06:00:14", msg: "✅ Pobrano kursy NBP — 6 par zaktualizowanych", ok: true },
{ time: "2026-05-12 06:00:18", msg: "✅ EURIBOR / ECB — pobrano z api.ecb.europa.eu", ok: true },
{ time: "2026-05-12 06:00:25", msg: "✅ WIBOR 3M zaktualizowany do 5.86%", ok: true },
{ time: "2026-05-12 06:01:02", msg: "✅ Banki PL — 10 cenników odczytanych", ok: true },
{ time: "2026-05-12 06:01:38", msg: "✅ Banki EU — 96 cenników odczytanych", ok: true },
{ time: "2026-05-12 06:01:42", msg: "📬 Raport wysłany na kontakt@krediteu.com", ok: true },
]);
const [schedule, setSchedule] = useStateA({
enabled: true, frequency: "weekly", day: "monday", hour: "06:00",
sources: { nbp: true, ecb: true, banks: true, ai_news: true },
});
const appendLog = (msg, ok = true) => {
const now = new Date().toLocaleTimeString("pl-PL");
setLog(l => [{ time: now, msg, ok }, ...l]);
};
const runNow = async () => {
setRunning(true);
const steps = [
"Lacze z api.nbp.pl...",
"Lacze z ECB / Stooq...",
"Sprawdzam stopy referencyjne...",
"Przeliczam oprocentowanie bankow z aktualnych benchmarkow...",
];
steps.forEach((step, idx) => {
window.setTimeout(() => appendLog(step, true), idx * 180);
});
try {
const data = await adminFetch("/refresh.php", { method: "POST", body: "{}" });
const fx = data.results?.fx;
const refs = data.results?.reference_rates;
const banks = data.results?.bank_rates;
appendLog(`OK: kursy walut ${fx?.ok ? "pobrane" : "sprawdzone"} (${fx?.elapsed ?? 0}s)`, !!fx?.ok);
appendLog(`OK: stopy referencyjne ${refs?.ok ? "pobrane" : "sprawdzone"} (${refs?.elapsed ?? 0}s)`, !!refs?.ok);
appendLog(`OK: przeliczono ${banks?.updated ?? 0} stawek bankowych`, true);
appendLog("Aktualizacja zakonczona. Odswiez ranking bankow, aby zobaczyc nowe wartosci.", true);
} catch (err) {
appendLog(`Blad aktualizacji: ${err.message || "nieznany blad"}`, false);
} finally {
setRunning(false);
}
};
const next = useMemoA(() => {
const now = new Date();
const day = { monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6, sunday: 0 }[schedule.day];
const next = new Date(now);
let diff = (day - now.getDay() + 7) % 7 || 7;
next.setDate(now.getDate() + diff);
const [h, m] = schedule.hour.split(":");
next.setHours(+h, +m, 0, 0);
return next.toLocaleString("pl-PL", { dateStyle: "medium", timeStyle: "short" });
}, [schedule]);
return (
{running ? "⏳ Aktualizuję…" : "⚡ Uruchom teraz"}
}
/>
{/* Schedule card */}
Status
{schedule.enabled ? "Aktywny" : "Wstrzymany"}
Ostatnia aktualizacja
12.05.2026 06:00
Następna aktualizacja
{next}
Częstotliwość
1× w tygodniu
{/* Settings */}
⚙️ Harmonogram
setSchedule({ ...schedule, enabled: !schedule.enabled })}
style={{
width: "100%", padding: "12px 14px", textAlign: "left",
background: "var(--surface)", border: "1px solid var(--border)", borderRadius: 10,
color: "var(--text)", fontWeight: 700, cursor: "pointer",
display: "flex", justifyContent: "space-between", alignItems: "center",
}}>
{schedule.enabled ? "✅ Włączony" : "⏸️ Wstrzymany"}
setSchedule({ ...schedule, frequency: e.target.value })}>
Codziennie
Co tydzień (zalecane)
Co 2 tygodnie
Co miesiąc
setSchedule({ ...schedule, day: e.target.value })}>
Poniedziałek
Wtorek
Środa
Czwartek
Piątek
Sobota
Niedziela
setSchedule({ ...schedule, hour: e.target.value })} style={{ fontFamily: "var(--font-mono)" }} />
📡 Źródła danych
{[
["nbp", "Kursy NBP", "api.nbp.pl/api/exchangerates"],
["ecb", "ECB Data", "data.ecb.europa.eu/api"],
["banks", "Cenniki banków", "scraper (10 PL + 96 EU)"],
["ai_news", "AI podsumowanie", "Claude / Gemini"],
].map(([k, name, src]) => (
setSchedule({ ...schedule, sources: { ...schedule.sources, [k]: e.target.checked } })} />
))}
{/* Log */}
📜 Log aktualizacji
{log.map((l, i) => (
{l.time}
{l.msg}
))}
{/* Tech setup */}
🛠️ Jak to działa technicznie
W produkcji harmonogram realizuje cron job, który uruchamia skrypt aktualizacyjny w wybrane dni i godziny. Skrypt pobiera dane z API NBP/ECB i parsuje cenniki banków, a następnie zapisuje do bazy.
{`# /etc/cron.d/krediteu
# Co poniedziałek o 06:00 — aktualizacja wszystkiego
0 6 * * 1 www-data /usr/local/bin/krediteu-update.sh
# /usr/local/bin/krediteu-update.sh
#!/bin/bash
node /var/www/krediteu/scripts/fetch-nbp.js
node /var/www/krediteu/scripts/fetch-ecb.js
node /var/www/krediteu/scripts/scrape-banks.js
node /var/www/krediteu/scripts/ai-summary.js
curl -X POST https://krediteu.com/api/admin/refresh \\
-H "X-API-Key: $KREDIT_API_KEY"`}
);
}
function AdminArticles({ navigate }) {
const emptyForm = { id: null, title: "", cat: "Kredyt hipoteczny", desc: "", body: "", img: "", imgAlt: "", aiPrompt: "" };
const [form, setForm] = useStateA(emptyForm);
const [list, setList] = useStateA(() => readStoredArticles());
const [aiGen, setAiGen] = useStateA(false);
const [preview, setPreview] = useStateA(false);
const [notice, setNotice] = useStateA("");
const fileRef = useRefA(null);
useEffectA(() => {
const loadDraft = () => {
const raw = localStorage.getItem("kr_admin_article_draft");
if (!raw) return;
try {
const draft = JSON.parse(raw);
setForm(f => ({ ...f, ...draft, id: null }));
setPreview(true);
setNotice("Wczytano artykul z generatora AI. Sprawdz tresc, dodaj zdjecie i opublikuj.");
localStorage.removeItem("kr_admin_article_draft");
} catch (err) {
setNotice("Nie udalo sie wczytac szkicu z generatora AI.");
}
};
loadDraft();
window.addEventListener("kr-admin-article-draft", loadDraft);
return () => window.removeEventListener("kr-admin-article-draft", loadDraft);
}, []);
function persist(next) {
setList(next);
localStorage.setItem("kr_admin_articles", JSON.stringify(next));
window.ARTICLES = mergeArticleLists(next, window.ARTICLES || []);
}
const publish = () => {
if (!form.title.trim()) {
setNotice("Dodaj tytul artykulu przed publikacja.");
return;
}
const existing = list.find(a => a.id === form.id);
const article = buildArticle(form, existing?.id || nextArticleId(list));
const next = existing ? list.map(a => a.id === article.id ? article : a) : [article, ...list];
persist(next);
setForm(emptyForm);
setPreview(false);
setNotice("Artykul opublikowany. Otwieram podglad na stronie.");
navigate?.({ name: "article", id: article.id });
};
const editArticle = (article) => {
setForm({
id: article.id,
title: article.title || "",
cat: article.cat || article.tag || "Kredyt hipoteczny",
desc: article.excerpt || "",
body: article.body || "",
img: article.img || "",
imgAlt: article.imgAlt || "",
aiPrompt: article.aiPrompt || "",
});
setPreview(true);
setNotice("Tryb edycji: po poprawkach kliknij Opublikuj zmiany.");
};
const removeArticle = (article) => {
if (!article.custom) {
setNotice("Artykuly systemowe nie sa usuwane z panelu lokalnego. Mozesz edytowac tylko nowe wpisy.");
return;
}
persist(list.filter(x => x.id !== article.id));
};
const generateAiImage = () => {
setAiGen(true);
window.setTimeout(() => {
setAiGen(false);
setForm(f => ({ ...f, img: randomGradient(f.aiPrompt || f.title || "kredyt") }));
}, 700);
};
const randomStock = () => {
const id = Math.floor(Math.random() * 1000);
setForm(f => ({ ...f, img: "center/cover url(https://picsum.photos/seed/krediteu-" + id + "/1200/720)" }));
};
const handleUpload = (e) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => setForm(f => ({ ...f, img: "center/cover url(" + ev.target.result + ")" }));
reader.readAsDataURL(file);
};
return (
{notice && (
{notice}
)}
{form.id ? "Edytuj artykul" : "Nowy artykul"}
setForm({ ...form, title: e.target.value })} placeholder="np. Jak obnizyc rate kredytu w 2026 r." />
setForm({ ...form, cat: e.target.value })}>
Kredyt hipoteczny
Kredyt gotowkowy
Programy rzadowe
Finanse osobiste
BIK i Scoring
Kredyt w EUR
{aiGen && (
)}
{!form.img && !aiGen && (
Bez zdjecia - wybierz zrodlo ponizej
)}
{
const v = e.target.value;
setForm(f => ({ ...f, img: v ? "center/cover url(" + v + ")" : "" }));
}}
style={{ marginBottom: 10, fontSize: 12 }} />
{aiGen ? "Generuje..." : "Generuj grafike"}
Losowe zdjecie
fileRef.current?.click()} style={{ fontSize: 11, padding: "8px 12px" }}>
Upload
{form.img && (
setForm(f => ({ ...f, img: "" }))} style={{ fontSize: 11, padding: "8px 12px", color: "var(--bad)" }}>
Usun
)}
setForm(f => ({ ...f, aiPrompt: e.target.value }))}
style={{ marginTop: 8, fontSize: 12 }} />
{form.id ? "Opublikuj zmiany" : "Opublikuj"}
setPreview(p => !p)}>{preview ? "Ukryj podglad" : "Podglad"}
{ setForm(emptyForm); setPreview(false); }}>Wyczysc
{preview && (
Podglad przed publikacja
{form.cat}
{form.title || "Tytul artykulu"}
{form.desc || "Opis SEO i wstep artykulu."}
{renderArticleMarkdown(form.body || "Tresc artykulu pojawi sie tutaj.")}
)}
Lista artykulow
{list.map(a => (
{a.title}
{a.cat} - {a.date}
editArticle(a)}>Ed
navigate?.({ name: "article", id: a.id })}>PV
removeArticle(a)}>Del
))}
);
}
function readStoredArticles() {
const base = window.ARTICLES || [];
try {
const custom = JSON.parse(localStorage.getItem("kr_admin_articles") || "[]");
return mergeArticleLists(Array.isArray(custom) ? custom : [], base);
} catch (err) {
return base;
}
}
function mergeArticleLists(custom, base) {
const customIds = new Set(custom.map(a => a.id));
return [...custom, ...base.filter(a => !customIds.has(a.id))];
}
function nextArticleId(list) {
const max = list.reduce((m, a) => Math.max(m, Number(a.id) || 0), 0);
return Math.max(1000, max + 1);
}
function slugifyArticle(title, id) {
return (title || "artykul-" + id)
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 64) || ("artykul-" + id);
}
function buildArticle(form, id) {
const body = (form.body || "").trim();
return {
id,
custom: true,
slug: slugifyArticle(form.title, id),
title: form.title.trim(),
cat: form.cat || "Finanse osobiste",
excerpt: form.desc || body.slice(0, 155),
body,
date: new Date().toISOString().slice(0, 10),
read: Math.max(3, Math.round((body.split(/\s+/).filter(Boolean).length || 500) / 220)),
author: "Redakcja KreditEU",
img: form.img || randomGradient(form.title),
imgAlt: form.imgAlt || form.title,
tag: form.cat || "Finanse osobiste",
aiPrompt: form.aiPrompt || "",
};
}
function renderArticleMarkdown(markdown) {
const blocks = String(markdown || "").split(/\n{2,}/).filter(Boolean);
return blocks.map((block, idx) => {
const trimmed = block.trim();
if (trimmed.startsWith("## ")) return {trimmed.slice(3)} ;
if (trimmed.startsWith("- ")) {
return {trimmed.split(/\n/).map((line, i) => {line.replace(/^-\s*/, "")} )} ;
}
return {trimmed}
;
});
}
function randomGradient(seed) {
const s = (seed || "x").length;
const palettes = [
["oklch(0.55 0.18 248)", "oklch(0.40 0.20 270)"],
["oklch(0.72 0.14 80)", "oklch(0.55 0.18 60)"],
["oklch(0.65 0.15 155)", "oklch(0.45 0.18 175)"],
["oklch(0.65 0.18 25)", "oklch(0.45 0.20 350)"],
];
const p = palettes[s % palettes.length];
return `linear-gradient(135deg, ${p[0]}, ${p[1]})`;
}
function chipBtn3() {
return {
padding: "4px 10px", background: "var(--surface)", border: "1px solid var(--border-soft)",
borderRadius: 6, color: "var(--text-2)", fontSize: 11, fontWeight: 700, cursor: "pointer",
};
}
/* ────────── AI Generator ────────── */
function AdminAI({ openArticles }) {
const [provider, setProvider] = useStateA("claude");
const [topic, setTopic] = useStateA("");
const [gen, setGen] = useStateA(false);
const [out, setOut] = useStateA(null);
const generate = () => {
if (!topic.trim()) return;
setGen(true);
setTimeout(() => {
setGen(false);
setOut({
title: topic.split(" ").slice(0, 8).join(" "),
excerpt: "Artykul SEO-friendly z FAQ i CTA do kalkulatora, wygenerowany przez " + (provider === "claude" ? "Claude" : "Gemini") + ".",
body: [
"## Wprowadzenie",
"Ten szkic wymaga redakcyjnej weryfikacji, ale zawiera gotowa strukture artykulu finansowego dla KreditEU.",
"",
"## Najwazniejsze informacje",
"- Wyjasnij aktualny kontekst stop procentowych i kosztu kredytu.",
"- Pokaz, jak porownac oferty bankow w kalkulatorze.",
"- Dodaj konkretne CTA do kontaktu albo porownywarki.",
"",
"## FAQ",
"Czy warto refinansowac kredyt? To zalezy od marzy, prowizji i kosztow dodatkowych. Porownaj calkowity koszt przed decyzja.",
].join("\n"),
cat: "Kredyt hipoteczny",
words: 1200,
time: provider === "claude" ? "$0.012" : "FREE",
});
}, 1800);
};
return (
{[["claude", "🤖 Claude (Anthropic)"], ["gemini", "✨ Gemini (Google · FREE)"]].map(([k, l]) => (
setProvider(k)} style={{
flex: 1, padding: "10px 12px",
background: provider === k ? "var(--surface)" : "transparent",
color: provider === k ? "var(--text)" : "var(--text-2)",
border: "none", borderRadius: 8, fontSize: 12, fontWeight: 700, cursor: "pointer",
}}>{l}
))}
setTopic(e.target.value)}
placeholder="np. Jak obniżyć ratę kredytu hipotecznego w 2026 r." />
AI wybierze automatycznie
Kredyt hipoteczny
Kredyt gotówkowy
Programy rządowe
Średni (800-1200 słów)
Krótki (500 słów)
Długi (2000+ słów)
{gen ? "Generuję…" : "🤖 Generuj artykuł"}
Koszt: Claude Haiku ~$0.01/art. · Gemini Flash DARMOWY
{gen && (
{provider === "claude" ? "Claude" : "Gemini"} pisze artykuł…
)}
{!gen && !out && (
📝
Wpisz temat i kliknij „Generuj artykuł"
)}
{out && (
✅ Wygenerowano · {out.words} słów · {out.time}
{out.title}…
{out.excerpt}
{
localStorage.setItem("kr_admin_article_draft", JSON.stringify({
title: out.title,
desc: out.excerpt,
body: out.body,
cat: out.cat || "Kredyt hipoteczny",
aiPrompt: topic,
}));
window.dispatchEvent(new Event("kr-admin-article-draft"));
openArticles?.();
}}>Otworz w edytorze
setOut(null)}>Odrzuc
)}
);
}
/* ────────── Banks editor ────────── */
function AdminBanks() {
const [list, setList] = useStateA(window.BANKS || []);
const [centralRate, setCentralRate] = useStateA({ ECB: 2.40, NBP: 5.75, CNB: 4.00, MNB: 6.50 });
const [status, setStatus] = useStateA("");
const [loading, setLoading] = useStateA(false);
const [saving, setSaving] = useStateA(false);
const mapBackendBank = (row) => ({
id: Number(row.id),
name: row.name,
country: row.country_code,
mortgage: Number(row.mortgage_rate || 0),
cash: Number(row.cash_rate || 0),
rrso: Number(row.mortgage_rrso || row.cash_rrso || 0),
cashRrso: Number(row.cash_rrso || 0),
prov: Number(row.provision_pct || 0),
trend: Number(row.trend || 0),
active: Number(row.is_active ?? 1) === 1,
website: row.website || "",
});
const refreshFromBackend = async () => {
setLoading(true);
setStatus("Pobieram banki z bazy...");
try {
const data = await adminFetch("/banks.php");
const rows = (data.banks || []).map(mapBackendBank);
setList(rows);
window.BANKS = rows;
setStatus("Pobrano " + rows.length + " bankow z bazy.");
} catch (err) {
setStatus("Nie udalo sie pobrac bankow z bazy: " + (err.message || "blad"));
} finally {
setLoading(false);
}
};
useEffectA(() => {
refreshFromBackend();
}, []);
const update = (id, field, val) => setList(list.map(b => b.id === id ? { ...b, [field]: parseFloat(val) || 0 } : b));
const recalcAll = async () => {
setSaving(true);
setStatus("Uruchamiam aktualizacje stawek i benchmarkow...");
try {
const data = await adminFetch("/refresh.php", { method: "POST", body: "{}" });
const updated = data.results?.bank_rates?.updated ?? 0;
setStatus("Przeliczono " + updated + " stawek. Odswiezam tabele bankow...");
await refreshFromBackend();
} catch (err) {
setStatus("Blad przeliczania: " + (err.message || "nieznany blad"));
} finally {
setSaving(false);
}
};
const saveVisible = async () => {
setSaving(true);
setStatus("Zapisuje widoczne zmiany w bazie...");
try {
for (const bank of list) {
await adminFetch("/banks.php", {
method: "PUT",
body: JSON.stringify({
id: bank.id,
mortgage_rate: bank.mortgage,
cash_rate: bank.cash,
mortgage_rrso: bank.rrso,
cash_rrso: bank.cashRrso || bank.rrso,
provision_pct: bank.prov,
trend: bank.trend || 0,
}),
});
}
setStatus("Zmiany zapisane. Historia oprocentowania zostala dopisana w bazie.");
await refreshFromBackend();
} catch (err) {
setStatus("Blad zapisu: " + (err.message || "nieznany blad"));
} finally {
setSaving(false);
}
};
return (
);
}
function editCellStyle() {
return { padding: "6px 8px", fontSize: 12, fontFamily: "var(--font-mono)", textAlign: "center", width: 80 };
}
/* ────────── FX editor ────────── */
function AdminFX() {
const [list, setList] = useStateA(window.FX);
return (
🌐 Pobierz NBP + ECB}
/>
💾 Zapisz kursy
);
}
/* ────────── Theme editor ────────── */
function AdminTheme({ tweaks, setTweak }) {
return (
{["aurora", "daylight", "bold"].map(th => (
setTweak("theme", th)} style={{
padding: 16, background: tweaks.theme === th ? "var(--accent-soft)" : "var(--bg-2)",
border: "1px solid " + (tweaks.theme === th ? "var(--accent)" : "var(--border-soft)"),
borderRadius: 12, cursor: "pointer", textAlign: "center",
}}>
{th === "aurora" ? "✨" : th === "daylight" ? "☀️" : "🔥"}
{th}
))}
{["light", "dark"].map(m => (
setTweak("mode", m)} style={{
flex: 1, padding: "10px 12px",
background: tweaks.mode === m ? "var(--surface)" : "transparent",
color: tweaks.mode === m ? "var(--text)" : "var(--text-2)",
border: "none", borderRadius: 8, fontSize: 13, fontWeight: 700, cursor: "pointer",
}}>{m === "light" ? "☀️ Jasny" : "🌙 Ciemny"}
))}
{[["bento", "Bento (asymetryczne)"], ["grid", "Siatka (równe)"]].map(([k, l]) => (
setTweak("tileLayout", k)} style={{
flex: 1, padding: "10px 12px",
background: tweaks.tileLayout === k ? "var(--surface)" : "transparent",
color: tweaks.tileLayout === k ? "var(--text)" : "var(--text-2)",
border: "none", borderRadius: 8, fontSize: 13, fontWeight: 700, cursor: "pointer",
}}>{l}
))}
);
}
/* ────────── Ads slot editor ────────── */
function AdminAds() {
const slots = [
{ id: "header", name: "Pasek pod hero", size: "leaderboard 728×90", enabled: true },
{ id: "in-grid", name: "Wewnątrz bento", size: "in-grid 300×250", enabled: true },
{ id: "sidebar", name: "Sidebar przy artykule", size: "300×600 sticky", enabled: true },
{ id: "footer", name: "Pasek przed stopką", size: "leaderboard 728×90", enabled: true },
{ id: "mobile-bottom", name: "Mobile dolny", size: "320×50", enabled: false },
];
const [list, setList] = useStateA(slots);
return (
📐 Reklamy są wpisane w design, nie krzyczą.
Wszystkie sloty są oznaczone „Reklama", mają nasze obramowanie i stylistykę.
AdSense ca-pub-4474448761999805
{list.map((s, i) => (
))}
);
}
/* ────────── i18n ────────── */
function AdminI18n() {
return (
{window.LANGS.map(l => (
))}
💡 Tłumaczenia obejmują nawigację, hero, kalkulator i stopkę. Artykuły są w języku polskim — możesz wygenerować wersje obcojęzyczne przez Generator AI.
);
}
function AdminSettings() {
const [pw, setPw] = useStateA({ current: "", next: "", confirm: "" });
const [pwMsg, setPwMsg] = useStateA(null);
const [pwBusy, setPwBusy] = useStateA(false);
const changePassword = async (e) => {
e.preventDefault();
setPwMsg(null);
if (!pw.current || !pw.next || !pw.confirm) {
setPwMsg({ ok: false, text: "Wypelnij wszystkie pola." });
return;
}
if (pw.next !== pw.confirm) {
setPwMsg({ ok: false, text: "Nowe hasla nie sa takie same." });
return;
}
if (!isStrongAdminPassword(pw.next)) {
setPwMsg({ ok: false, text: "Nowe haslo musi miec min. 16 znakow, duza litere, mala litere, cyfre i znak specjalny." });
return;
}
setPwBusy(true);
try {
await adminFetch("/password.php", {
method: "POST",
body: JSON.stringify({
current_password: pw.current,
new_password: pw.next,
new_password_confirm: pw.confirm,
}),
});
setPwMsg({ ok: true, text: "Haslo zmienione. Sesja zostala odswiezona." });
setPw({ current: "", next: "", confirm: "" });
} catch (err) {
setPwMsg({ ok: false, text: err.message || "Nie udalo sie zmienic hasla." });
} finally {
setPwBusy(false);
}
};
return (
Zmiana hasla admina
Haslo nie jest zapisywane w przegladarce. Backend zapisuje tylko bcrypt + pepper w MySQL.
Integracje
{[
["Google Analytics", "G-XXXXX", true],
["Google AdSense", "ca-pub-4474448761999805", true],
["NBP API", "api.nbp.pl (bez klucza)", true],
["ECB Data", "ecb.europa.eu (bez klucza)", true],
].map(([n, v, on]) => (
{on ? "Polaczone" : "Wylaczone"}
))}
);
}
function SectionHeader({ title, desc, action }) {
return (
);
}
function subHead() { return { margin: 0, marginBottom: 16, fontSize: 13, fontWeight: 800, letterSpacing: "-0.01em" }; }
Object.assign(window, { AdminPage });