SMPT, SMS, Rejestracja + GUS

This commit is contained in:
2026-08-09 21:12:16 +02:00
parent a0c9d7947b
commit c464c5f708
78 changed files with 5841 additions and 258 deletions
+329
View File
@@ -0,0 +1,329 @@
// Panel admina: zarzadzanie banerami i promocjami (/admin/promocje).
// CRUD banerow przypisanych do slotow serwisu, z uploadem grafiki (kompresja) i harmonogramem.
import { useCallback, useEffect, useState, type ChangeEvent } from 'react';
import { apiFetch } from '../auth';
import { compressImageFile } from '../media';
type IconComponent = (props: { name: string }) => JSX.Element | null;
type BannerSlotName = 'HERO' | 'HOME_TOP' | 'HOME_MIDDLE' | 'SIDEBAR';
type AdminBanner = {
id: number;
slot: BannerSlotName;
title: string;
subtitle: string | null;
imageUrl: string | null;
linkUrl: string | null;
ctaLabel: string | null;
active: boolean;
position: number;
startsAt: string | null;
endsAt: string | null;
impressions: number;
clicks: number;
createdAt: string;
};
const SLOT_OPTIONS: { value: BannerSlotName; label: string; hint: string }[] = [
{ value: 'HERO', label: 'Strona główna - hero (góra)', hint: 'Szeroki baner nad wyszukiwarką.' },
{ value: 'HOME_TOP', label: 'Strona główna - pod wyszukiwarką', hint: 'Pasek tuż pod wyszukiwarką.' },
{ value: 'HOME_MIDDLE', label: 'Strona główna - między ofertami', hint: 'Między sekcjami ofert.' },
{ value: 'SIDEBAR', label: 'Karta oferty - panel boczny', hint: 'Kolumna boczna na stronie oferty.' },
];
const slotLabel = (slot: BannerSlotName) => SLOT_OPTIONS.find((o) => o.value === slot)?.label ?? slot;
type FormState = {
id: number | null;
slot: BannerSlotName;
title: string;
subtitle: string;
imageUrl: string;
linkUrl: string;
ctaLabel: string;
active: boolean;
position: number;
startsAt: string;
endsAt: string;
};
const emptyForm = (): FormState => ({
id: null,
slot: 'HERO',
title: '',
subtitle: '',
imageUrl: '',
linkUrl: '',
ctaLabel: '',
active: true,
position: 0,
startsAt: '',
endsAt: '',
});
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : 'Wystąpił błąd.';
}
// ISO (UTC) -> wartosc dla <input type="datetime-local"> w czasie lokalnym.
function isoToLocalInput(iso: string | null): string {
if (!iso) {
return '';
}
const d = new Date(iso);
if (Number.isNaN(d.getTime())) {
return '';
}
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
// datetime-local (czas lokalny) -> ISO (UTC) albo null.
function localInputToIso(value: string): string | null {
if (!value.trim()) {
return null;
}
const d = new Date(value);
return Number.isNaN(d.getTime()) ? null : d.toISOString();
}
export function AdminBannersView({ Icon }: { Icon: IconComponent }) {
const [banners, setBanners] = useState<AdminBanner[]>([]);
const [loading, setLoading] = useState(true);
const [form, setForm] = useState<FormState>(emptyForm());
const [saving, setSaving] = useState(false);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [note, setNote] = useState<string | null>(null);
const load = useCallback(async () => {
try {
setBanners(await apiFetch<AdminBanner[]>('/admin/banners'));
} catch (err) {
setError(errorMessage(err));
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const set = (patch: Partial<FormState>) => setForm((current) => ({ ...current, ...patch }));
const resetForm = () => {
setForm(emptyForm());
setError(null);
};
const startEdit = (banner: AdminBanner) => {
setForm({
id: banner.id,
slot: banner.slot,
title: banner.title,
subtitle: banner.subtitle ?? '',
imageUrl: banner.imageUrl ?? '',
linkUrl: banner.linkUrl ?? '',
ctaLabel: banner.ctaLabel ?? '',
active: banner.active,
position: banner.position,
startsAt: isoToLocalInput(banner.startsAt),
endsAt: isoToLocalInput(banner.endsAt),
});
setNote(null);
setError(null);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const onPickImage = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) {
return;
}
if (!['image/jpeg', 'image/png'].includes(file.type) || file.size > 10 * 1024 * 1024) {
setError('Dozwolone JPG/PNG do 10 MB.');
return;
}
setUploading(true);
setError(null);
try {
// Banery sa szerokie - kompresujemy do 1600 px.
set({ imageUrl: await compressImageFile(file, 1600) });
} finally {
setUploading(false);
}
};
const save = async () => {
if (!form.title.trim()) {
setError('Podaj tytuł banera.');
return;
}
setSaving(true);
setError(null);
setNote(null);
try {
const payload = {
slot: form.slot,
title: form.title.trim(),
subtitle: form.subtitle.trim() || null,
imageUrl: form.imageUrl || null,
linkUrl: form.linkUrl.trim() || null,
ctaLabel: form.ctaLabel.trim() || null,
active: form.active,
position: Number.isFinite(form.position) ? form.position : 0,
startsAt: localInputToIso(form.startsAt),
endsAt: localInputToIso(form.endsAt),
};
if (form.id) {
await apiFetch(`/admin/banners/${form.id}`, { method: 'PUT', body: JSON.stringify(payload) });
setNote('Zapisano zmiany banera.');
} else {
await apiFetch('/admin/banners', { method: 'POST', body: JSON.stringify(payload) });
setNote('Dodano nowy baner.');
}
resetForm();
await load();
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
};
const toggle = async (banner: AdminBanner) => {
try {
await apiFetch(`/admin/banners/${banner.id}/toggle`, { method: 'PATCH' });
await load();
} catch (err) {
setError(errorMessage(err));
}
};
const remove = async (banner: AdminBanner) => {
if (!window.confirm(`Usunąć baner "${banner.title}"?`)) {
return;
}
try {
await apiFetch(`/admin/banners/${banner.id}`, { method: 'DELETE' });
if (form.id === banner.id) {
resetForm();
}
await load();
} catch (err) {
setError(errorMessage(err));
}
};
const activeSlot = SLOT_OPTIONS.find((o) => o.value === form.slot);
return (
<div className="mkt-wrap">
<div className="admin-card mkt-card">
<div className="admin-card-head">
<h2>{form.id ? 'Edytuj baner' : 'Nowy baner'}</h2>
{form.id && <button type="button" className="admin-btn" onClick={resetForm}>Anuluj edycję</button>}
</div>
<div className="mkt-config-grid">
<label>Miejsce (slot)
<select value={form.slot} onChange={(e) => set({ slot: e.target.value as BannerSlotName })}>
{SLOT_OPTIONS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</label>
<label>Kolejność (mniejsza = wyżej)
<input type="number" value={form.position} onChange={(e) => set({ position: Number(e.target.value) })} />
</label>
<label>Tytuł
<input value={form.title} maxLength={160} onChange={(e) => set({ title: e.target.value })} placeholder="np. Wyróżnij swoje ogłoszenie" />
</label>
<label>Etykieta przycisku (CTA)
<input value={form.ctaLabel} maxLength={60} onChange={(e) => set({ ctaLabel: e.target.value })} placeholder="np. Dodaj ogłoszenie" />
</label>
<label className="mkt-col-span">Podtytuł / opis
<input value={form.subtitle} maxLength={400} onChange={(e) => set({ subtitle: e.target.value })} placeholder="Krótki tekst zachęcający do kliknięcia." />
</label>
<label className="mkt-col-span">Link po kliknięciu
<input value={form.linkUrl} onChange={(e) => set({ linkUrl: e.target.value })} placeholder="np. /dodaj-ogloszenie lub https://twojafirma.pl" />
</label>
<label>Emisja od (opcjonalnie)
<input type="datetime-local" value={form.startsAt} onChange={(e) => set({ startsAt: e.target.value })} />
</label>
<label>Emisja do (opcjonalnie)
<input type="datetime-local" value={form.endsAt} onChange={(e) => set({ endsAt: e.target.value })} />
</label>
<label>Status
<select value={form.active ? 'on' : 'off'} onChange={(e) => set({ active: e.target.value === 'on' })}>
<option value="on">Aktywny</option>
<option value="off">Wstrzymany</option>
</select>
</label>
</div>
<div className="banner-image-row">
<div className="banner-image-preview">
{form.imageUrl ? <img src={form.imageUrl} alt="Podgląd banera" /> : <span className="banner-image-empty"><Icon name="camera" /> Bez grafiki</span>}
</div>
<div className="banner-image-actions">
<label className="admin-btn banner-upload-btn">
<Icon name="upload" /> {uploading ? 'Przetwarzanie...' : (form.imageUrl ? 'Zmień grafikę' : 'Dodaj grafikę')}
<input type="file" accept="image/png,image/jpeg" onChange={onPickImage} hidden />
</label>
{form.imageUrl && <button type="button" className="admin-btn ghost" onClick={() => set({ imageUrl: '' })}>Usuń grafikę</button>}
<small className="banner-image-hint">JPG/PNG. Szersze niż 1600 px zmniejszymy automatycznie. Bez grafiki baner pokaże się jako kafelek z tekstem.</small>
</div>
</div>
{activeSlot && <p className="banner-slot-hint"><Icon name="pin" /> {activeSlot.hint}</p>}
{error && <p className="mkt-error">{error}</p>}
{note && <p className="mkt-ok">{note}</p>}
<div className="mkt-config-save">
<button type="button" className="admin-btn approve" onClick={save} disabled={saving}>
{saving ? 'Zapisywanie...' : (form.id ? 'Zapisz zmiany' : 'Dodaj baner')}
</button>
</div>
</div>
<div className="admin-card mkt-card">
<div className="admin-card-head">
<h2>Banery w serwisie</h2>
<span className="mkt-count">{banners.length}</span>
</div>
{loading ? (
<p className="admin-empty">Ładowanie...</p>
) : banners.length === 0 ? (
<p className="admin-empty">Brak banerów. Dodaj pierwszy w formularzu powyżej.</p>
) : (
<div className="banner-admin-list">
{SLOT_OPTIONS.filter((o) => banners.some((b) => b.slot === o.value)).map((o) => (
<div key={o.value} className="banner-admin-group">
<h3 className="banner-admin-group-title">{o.label}</h3>
{banners.filter((b) => b.slot === o.value).map((b) => (
<div key={b.id} className={`banner-admin-row ${b.active ? '' : 'is-off'}`}>
<div className="banner-admin-thumb">
{b.imageUrl ? <img src={b.imageUrl} alt="" /> : <span className="banner-admin-thumb-text"><Icon name="spark" /></span>}
</div>
<div className="banner-admin-info">
<strong>{b.title}</strong>
{b.subtitle && <span>{b.subtitle}</span>}
<div className="banner-admin-meta">
<span className={`banner-status ${b.active ? 'on' : 'off'}`}>{b.active ? 'Aktywny' : 'Wstrzymany'}</span>
{b.linkUrl && <span className="banner-admin-link">{b.linkUrl}</span>}
<span className="banner-admin-stats">{b.impressions} wyświetleń · {b.clicks} kliknięć</span>
</div>
</div>
<div className="banner-admin-actions">
<button type="button" className="admin-btn" onClick={() => startEdit(b)}>Edytuj</button>
<button type="button" className="admin-btn ghost" onClick={() => toggle(b)}>{b.active ? 'Wstrzymaj' : 'Aktywuj'}</button>
<button type="button" className="admin-btn ghost danger" onClick={() => remove(b)}>Usuń</button>
</div>
</div>
))}
</div>
))}
</div>
)}
</div>
</div>
);
}
+38 -32
View File
@@ -609,50 +609,48 @@ export function AdminCampaignsView({ Icon }: { Icon: IconComponent }) {
};
return (
<div className="mkt-grid-2">
<div className="mkt-wrap">
<div className="admin-card mkt-card">
<div className="admin-card-head"><h2>Nowa kampania</h2></div>
<div className="mkt-form">
<label>Nazwa
<div className="mkt-config-grid">
<label className="mkt-col-span">Nazwa
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="np. Newsletter lipiec" />
</label>
<div className="mkt-form-row">
<label>Kanał
<select value={form.channel} onChange={(e) => setForm({ ...form, channel: e.target.value as Channel })}>
<option value="EMAIL">E-mail</option>
<option value="SMS">SMS</option>
</select>
</label>
<label>Grupa docelowa
<select value={form.targetGroupId} onChange={(e) => setForm({ ...form, targetGroupId: e.target.value })}>
<option value="">- wybierz -</option>
{groups.map((g) => (
<option key={g.id} value={g.id}>{g.name} ({g.memberCount})</option>
))}
</select>
</label>
</div>
<label>Kanał
<select value={form.channel} onChange={(e) => setForm({ ...form, channel: e.target.value as Channel })}>
<option value="EMAIL">E-mail</option>
<option value="SMS">SMS</option>
</select>
</label>
<label>Grupa docelowa
<select value={form.targetGroupId} onChange={(e) => setForm({ ...form, targetGroupId: e.target.value })}>
<option value="">- wybierz -</option>
{groups.map((g) => (
<option key={g.id} value={g.id}>{g.name} ({g.memberCount})</option>
))}
</select>
</label>
{form.channel === 'EMAIL' && (
<label>Temat
<label className="mkt-col-span">Temat
<input value={form.subject} onChange={(e) => setForm({ ...form, subject: e.target.value })} placeholder="Temat wiadomości" />
</label>
)}
<label>Treść <span className="mkt-muted">(użyj {'{{name}}'} dla personalizacji; stopka z rezygnacją dodawana automatycznie)</span>
<label className="mkt-col-span">Treść <span className="mkt-muted">(użyj {'{{name}}'} dla personalizacji; stopka z rezygnacją dodawana automatycznie)</span>
{form.channel === 'EMAIL' ? (
<RichTextEditor value={form.body} onChange={(html) => setForm({ ...form, body: html })} ariaLabel="Treść kampanii e-mail" placeholder="Cześć {{name}}, ..." />
) : (
<textarea rows={5} value={form.body} onChange={(e) => setForm({ ...form, body: e.target.value })} placeholder="Cześć {{name}}, ..." />
<textarea rows={6} value={form.body} onChange={(e) => setForm({ ...form, body: e.target.value })} placeholder="Cześć {{name}}, ..." />
)}
</label>
<div className="mkt-form-row">
<label>Dzienny limit
<input type="number" min={1} value={form.dailyLimit} onChange={(e) => setForm({ ...form, dailyLimit: Number(e.target.value) })} />
</label>
<label>Zaplanuj na (opcjonalnie)
<input type="datetime-local" value={form.scheduledAt} onChange={(e) => setForm({ ...form, scheduledAt: e.target.value })} />
</label>
</div>
{error && <p className="mkt-error">{error}</p>}
<label>Dzienny limit
<input type="number" min={1} value={form.dailyLimit} onChange={(e) => setForm({ ...form, dailyLimit: Number(e.target.value) })} />
</label>
<label>Zaplanuj na (opcjonalnie)
<input type="datetime-local" value={form.scheduledAt} onChange={(e) => setForm({ ...form, scheduledAt: e.target.value })} />
</label>
</div>
{error && <p className="mkt-error">{error}</p>}
<div className="mkt-config-save">
<button type="button" className="admin-btn approve" onClick={create} disabled={saving}>
{saving ? 'Zapisywanie...' : 'Utwórz kampanię (robocza)'}
</button>
@@ -730,10 +728,18 @@ type SmsConfig = {
apiKeySet: boolean;
};
export function AdminMailConfigView({ Icon }: { Icon: IconComponent }) {
// Rozdzielone obszary w panelu admina: osobno konfiguracja SMTP, osobno bramka SMS.
export function AdminSmtpConfigView({ Icon }: { Icon: IconComponent }) {
return (
<div className="mkt-wrap">
<SmtpCard Icon={Icon} />
</div>
);
}
export function AdminSmsConfigView({ Icon }: { Icon: IconComponent }) {
return (
<div className="mkt-wrap">
<SmsCard Icon={Icon} />
</div>
);
+283
View File
@@ -0,0 +1,283 @@
// Panel admina: promowanie ofert - plany, pakiety, konfiguracja AutoPay i historia platnosci.
import { useCallback, useEffect, useState } from 'react';
import { apiFetch } from '../auth';
type IconComponent = (props: { name: string }) => JSX.Element | null;
type Plan = { id: number; name: string; durationDays: number; price: number; active: boolean; position: number };
type Pack = { id: number; name: string; quantity: number; price: number; active: boolean; position: number };
type Order = {
id: number; userEmail: string; kind: string; itemName: string; amount: number;
method: string; listingId: number | null; status: string; createdAt: string; paidAt: string | null;
};
type PayCfg = { serviceId: string | null; secretKeySet: boolean; hashSeparator: string; sandbox: boolean; enabled: boolean };
const zl = (n: number) => `${new Intl.NumberFormat('pl-PL', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(n)} zł`;
const em = (e: unknown) => (e instanceof Error ? e.message : 'Wystąpił błąd.');
const dt = (iso: string | null) => (iso ? new Date(iso).toLocaleString('pl-PL') : '-');
const emptyPlan = () => ({ id: null as number | null, name: '', durationDays: 14, price: '', active: true, position: 0 });
const emptyPack = () => ({ id: null as number | null, name: '', quantity: 5, price: '', active: true, position: 0 });
export function AdminPromotionView({ Icon }: { Icon: IconComponent }) {
const [plans, setPlans] = useState<Plan[]>([]);
const [packs, setPacks] = useState<Pack[]>([]);
const [orders, setOrders] = useState<Order[]>([]);
const [error, setError] = useState<string | null>(null);
const [note, setNote] = useState<string | null>(null);
const [planForm, setPlanForm] = useState(emptyPlan());
const [packForm, setPackForm] = useState(emptyPack());
const [pay, setPay] = useState({ serviceId: '', secretKey: '', hashSeparator: '|', sandbox: true, enabled: false });
const [secretSet, setSecretSet] = useState(false);
const load = useCallback(async () => {
try {
const [pl, pk, ord, cfg] = await Promise.all([
apiFetch<Plan[]>('/admin/promotion/plans'),
apiFetch<Pack[]>('/admin/promotion/packages'),
apiFetch<Order[]>('/admin/promotion/orders'),
apiFetch<PayCfg>('/admin/payment-config'),
]);
setPlans(pl);
setPacks(pk);
setOrders(ord);
setSecretSet(cfg.secretKeySet);
setPay((f) => ({ ...f, serviceId: cfg.serviceId ?? '', hashSeparator: cfg.hashSeparator || '|', sandbox: cfg.sandbox, enabled: cfg.enabled }));
} catch (e) {
setError(em(e));
}
}, []);
useEffect(() => { load(); }, [load]);
const flash = (msg: string) => { setNote(msg); setError(null); };
const savePlan = async () => {
try {
const body = JSON.stringify({
name: planForm.name, durationDays: Number(planForm.durationDays), price: Number(planForm.price),
active: planForm.active, position: Number(planForm.position),
});
if (planForm.id) {
await apiFetch(`/admin/promotion/plans/${planForm.id}`, { method: 'PUT', body });
} else {
await apiFetch('/admin/promotion/plans', { method: 'POST', body });
}
setPlanForm(emptyPlan());
flash('Zapisano plan.');
await load();
} catch (e) { setError(em(e)); }
};
const savePack = async () => {
try {
const body = JSON.stringify({
name: packForm.name, quantity: Number(packForm.quantity), price: Number(packForm.price),
active: packForm.active, position: Number(packForm.position),
});
if (packForm.id) {
await apiFetch(`/admin/promotion/packages/${packForm.id}`, { method: 'PUT', body });
} else {
await apiFetch('/admin/promotion/packages', { method: 'POST', body });
}
setPackForm(emptyPack());
flash('Zapisano pakiet.');
await load();
} catch (e) { setError(em(e)); }
};
const savePay = async () => {
try {
const body = JSON.stringify({
serviceId: pay.serviceId || null, secretKey: pay.secretKey || null,
hashSeparator: pay.hashSeparator, sandbox: pay.sandbox, enabled: pay.enabled,
});
const cfg = await apiFetch<PayCfg>('/admin/payment-config', { method: 'PUT', body });
setSecretSet(cfg.secretKeySet);
setPay((f) => ({ ...f, secretKey: '' }));
flash('Zapisano konfigurację AutoPay.');
} catch (e) { setError(em(e)); }
};
const toggle = async (kind: 'plans' | 'packages', id: number) => {
try { await apiFetch(`/admin/promotion/${kind}/${id}/toggle`, { method: 'PATCH' }); await load(); } catch (e) { setError(em(e)); }
};
const remove = async (kind: 'plans' | 'packages', id: number, label: string) => {
if (!window.confirm(`Usunąć „${label}"?`)) { return; }
try { await apiFetch(`/admin/promotion/${kind}/${id}`, { method: 'DELETE' }); await load(); } catch (e) { setError(em(e)); }
};
return (
<div className="mkt-wrap">
{(error || note) && (error ? <p className="mkt-error">{error}</p> : <p className="mkt-ok">{note}</p>)}
{/* Plany promowania */}
<div className="admin-card mkt-card">
<div className="admin-card-head">
<h2>{planForm.id ? 'Edytuj plan promowania' : 'Plany promowania'}</h2>
{planForm.id && <button type="button" className="admin-btn" onClick={() => setPlanForm(emptyPlan())}>Anuluj</button>}
</div>
<div className="mkt-config-grid">
<label className="mkt-col-span">Nazwa
<input value={planForm.name} onChange={(e) => setPlanForm({ ...planForm, name: e.target.value })} placeholder="np. Wyróżnienie 14 dni" />
</label>
<label>Czas trwania (dni)
<input type="number" min={1} value={planForm.durationDays} onChange={(e) => setPlanForm({ ...planForm, durationDays: Number(e.target.value) })} />
</label>
<label>Cena (zł)
<input type="number" min={0} step="0.01" value={planForm.price} onChange={(e) => setPlanForm({ ...planForm, price: e.target.value })} placeholder="49.99" />
</label>
<label>Kolejność
<input type="number" value={planForm.position} onChange={(e) => setPlanForm({ ...planForm, position: Number(e.target.value) })} />
</label>
<label>Status
<select value={planForm.active ? 'on' : 'off'} onChange={(e) => setPlanForm({ ...planForm, active: e.target.value === 'on' })}>
<option value="on">Aktywny</option>
<option value="off">Nieaktywny</option>
</select>
</label>
</div>
<div className="mkt-config-save">
<button type="button" className="admin-btn approve" onClick={savePlan} disabled={!planForm.name.trim()}>
{planForm.id ? 'Zapisz plan' : 'Dodaj plan'}
</button>
</div>
<div className="promo-admin-list">
{plans.map((p) => (
<div key={p.id} className={`promo-admin-row ${p.active ? '' : 'is-off'}`}>
<div className="promo-admin-info">
<strong>{p.name}</strong>
<span>{p.durationDays} dni · {zl(p.price)}</span>
</div>
<span className={`banner-status ${p.active ? 'on' : 'off'}`}>{p.active ? 'Aktywny' : 'Nieaktywny'}</span>
<div className="promo-admin-actions">
<button type="button" className="admin-btn" onClick={() => setPlanForm({ id: p.id, name: p.name, durationDays: p.durationDays, price: String(p.price), active: p.active, position: p.position })}>Edytuj</button>
<button type="button" className="admin-btn ghost" onClick={() => toggle('plans', p.id)}>{p.active ? 'Wyłącz' : 'Włącz'}</button>
<button type="button" className="admin-btn ghost danger" onClick={() => remove('plans', p.id, p.name)}>Usuń</button>
</div>
</div>
))}
{plans.length === 0 && <p className="admin-empty">Brak planów.</p>}
</div>
</div>
{/* Pakiety promowania */}
<div className="admin-card mkt-card">
<div className="admin-card-head">
<h2>{packForm.id ? 'Edytuj pakiet' : 'Pakiety promowań'}</h2>
{packForm.id && <button type="button" className="admin-btn" onClick={() => setPackForm(emptyPack())}>Anuluj</button>}
</div>
<div className="mkt-config-grid">
<label className="mkt-col-span">Nazwa
<input value={packForm.name} onChange={(e) => setPackForm({ ...packForm, name: e.target.value })} placeholder="np. Pakiet 10 promowań" />
</label>
<label>Liczba promowań (kredytów)
<input type="number" min={1} value={packForm.quantity} onChange={(e) => setPackForm({ ...packForm, quantity: Number(e.target.value) })} />
</label>
<label>Cena pakietu (zł)
<input type="number" min={0} step="0.01" value={packForm.price} onChange={(e) => setPackForm({ ...packForm, price: e.target.value })} placeholder="349.99" />
</label>
<label>Kolejność
<input type="number" value={packForm.position} onChange={(e) => setPackForm({ ...packForm, position: Number(e.target.value) })} />
</label>
<label>Status
<select value={packForm.active ? 'on' : 'off'} onChange={(e) => setPackForm({ ...packForm, active: e.target.value === 'on' })}>
<option value="on">Aktywny</option>
<option value="off">Nieaktywny</option>
</select>
</label>
</div>
<div className="mkt-config-save">
<button type="button" className="admin-btn approve" onClick={savePack} disabled={!packForm.name.trim()}>
{packForm.id ? 'Zapisz pakiet' : 'Dodaj pakiet'}
</button>
</div>
<div className="promo-admin-list">
{packs.map((p) => (
<div key={p.id} className={`promo-admin-row ${p.active ? '' : 'is-off'}`}>
<div className="promo-admin-info">
<strong>{p.name}</strong>
<span>{p.quantity} szt. · {zl(p.price)} ({zl(p.price / p.quantity)} / szt.)</span>
</div>
<span className={`banner-status ${p.active ? 'on' : 'off'}`}>{p.active ? 'Aktywny' : 'Nieaktywny'}</span>
<div className="promo-admin-actions">
<button type="button" className="admin-btn" onClick={() => setPackForm({ id: p.id, name: p.name, quantity: p.quantity, price: String(p.price), active: p.active, position: p.position })}>Edytuj</button>
<button type="button" className="admin-btn ghost" onClick={() => toggle('packages', p.id)}>{p.active ? 'Wyłącz' : 'Włącz'}</button>
<button type="button" className="admin-btn ghost danger" onClick={() => remove('packages', p.id, p.name)}>Usuń</button>
</div>
</div>
))}
{packs.length === 0 && <p className="admin-empty">Brak pakietów.</p>}
</div>
</div>
{/* Konfiguracja AutoPay */}
<div className="admin-card mkt-card mkt-config">
<div className="admin-card-head mkt-config-head">
<span className="mkt-config-icon"><Icon name="credit-card" /></span>
<h2>Bramka szybkich płatności - AutoPay</h2>
</div>
<p className="banner-slot-hint"><Icon name="lock" /> Bez danych dostępowych działa tryb sandbox (płatność potwierdzana automatycznie).</p>
<div className="mkt-config-grid">
<label>ServiceID
<input value={pay.serviceId} onChange={(e) => setPay({ ...pay, serviceId: e.target.value })} placeholder="np. 123456" />
</label>
<label>Klucz współpracy (sekret) {secretSet && <span className="mkt-badge-set"><Icon name="lock" /> ustawiony</span>}
<input type="password" value={pay.secretKey} onChange={(e) => setPay({ ...pay, secretKey: e.target.value })} placeholder="wpisz, aby zmienić" />
</label>
<label>Separator hasha
<input value={pay.hashSeparator} maxLength={5} onChange={(e) => setPay({ ...pay, hashSeparator: e.target.value })} placeholder="|" />
</label>
<label>Środowisko
<select value={pay.sandbox ? 'sandbox' : 'prod'} onChange={(e) => setPay({ ...pay, sandbox: e.target.value === 'sandbox' })}>
<option value="sandbox">Testowe (sandbox)</option>
<option value="prod">Produkcyjne</option>
</select>
</label>
<label>Bramka aktywna
<select value={pay.enabled ? 'on' : 'off'} onChange={(e) => setPay({ ...pay, enabled: e.target.value === 'on' })}>
<option value="off">Wyłączona (symulacja)</option>
<option value="on">Włączona</option>
</select>
</label>
</div>
<div className="mkt-config-save">
<button type="button" className="admin-btn approve" onClick={savePay}>Zapisz konfigurację</button>
</div>
</div>
{/* Historia platnosci */}
<div className="admin-card mkt-card">
<div className="admin-card-head">
<h2>Historia płatności</h2>
<span className="mkt-count">{orders.length}</span>
</div>
{orders.length === 0 ? (
<p className="admin-empty">Brak płatności.</p>
) : (
<div className="mkt-table-scroll">
<table className="mkt-table promo-orders-table">
<thead>
<tr>
<th>Data</th><th>Użytkownik</th><th>Pozycja</th><th>Kwota</th><th>Metoda</th><th>Status</th>
</tr>
</thead>
<tbody>
{orders.map((o) => (
<tr key={o.id}>
<td>{dt(o.createdAt)}</td>
<td>{o.userEmail}</td>
<td>{o.itemName}</td>
<td>{zl(o.amount)}</td>
<td>{o.method}</td>
<td><span className={`banner-status ${o.status === 'PAID' ? 'on' : 'off'}`}>{o.status === 'PAID' ? 'Opłacone' : o.status === 'PENDING' ? 'Oczekuje' : 'Błąd'}</span></td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
// Panel admina: ustawienia serwisu - integracja z rejestrem REGON (GUS BIR1.1).
import { useCallback, useEffect, useState } from 'react';
import { apiFetch } from '../auth';
type IconComponent = (props: { name: string }) => JSX.Element | null;
type GusCfg = { userKeySet: boolean; sandbox: boolean };
const em = (e: unknown) => (e instanceof Error ? e.message : 'Wystąpił błąd.');
export function AdminSettingsView({ Icon }: { Icon: IconComponent }) {
const [error, setError] = useState<string | null>(null);
const [note, setNote] = useState<string | null>(null);
const [gus, setGus] = useState({ userKey: '', sandbox: true });
const [keySet, setKeySet] = useState(false);
const load = useCallback(async () => {
try {
const cfg = await apiFetch<GusCfg>('/admin/gus-config');
setKeySet(cfg.userKeySet);
setGus({ userKey: '', sandbox: cfg.sandbox });
} catch (e) {
setError(em(e));
}
}, []);
useEffect(() => { load(); }, [load]);
const save = async () => {
try {
const body = JSON.stringify({ userKey: gus.userKey || null, sandbox: gus.sandbox });
const cfg = await apiFetch<GusCfg>('/admin/gus-config', { method: 'PUT', body });
setKeySet(cfg.userKeySet);
setGus({ userKey: '', sandbox: cfg.sandbox });
setError(null);
setNote('Zapisano konfigurację GUS.');
} catch (e) {
setError(em(e));
}
};
return (
<div className="mkt-wrap">
{(error || note) && (error ? <p className="mkt-error">{error}</p> : <p className="mkt-ok">{note}</p>)}
<div className="admin-card mkt-card mkt-config">
<div className="admin-card-head mkt-config-head">
<span className="mkt-config-icon"><Icon name="document" /></span>
<h2>Rejestr REGON (GUS) - pobieranie danych firmy po NIP</h2>
</div>
<p className="banner-slot-hint">
<Icon name="lock" /> Klucz użytkownika otrzymasz od GUS (usługa BIR1.1). W trybie testowym używamy środowiska
testowego GUS i klucza testowego - zwraca ono wyłącznie dane przykładowe.
</p>
<div className="mkt-config-grid">
<label>Klucz użytkownika {keySet && <span className="mkt-badge-set"><Icon name="lock" /> ustawiony</span>}
<input
type="password"
value={gus.userKey}
onChange={(e) => setGus({ ...gus, userKey: e.target.value })}
placeholder={keySet ? 'wpisz, aby zmienić' : 'klucz produkcyjny z GUS'}
/>
</label>
<label>Środowisko
<select value={gus.sandbox ? 'sandbox' : 'prod'} onChange={(e) => setGus({ ...gus, sandbox: e.target.value === 'sandbox' })}>
<option value="sandbox">Testowe (dane przykładowe)</option>
<option value="prod">Produkcyjne</option>
</select>
</label>
</div>
<div className="mkt-config-save">
<button type="button" className="admin-btn approve" onClick={save}>Zapisz konfigurację</button>
</div>
</div>
</div>
);
}