SMPT, SMS, Rejestracja + GUS
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user