Files
polskalokalnie/frontend/src/admin/promotion.tsx
T
debian 3a555e3b24
Deploy Polska Lokalnie / deploy (push) Successful in 11s
AutoPay wg rozwiazania z Gieldy Komunalnej
- ITN: XML w base64 (pole transactions), bezpieczny parser (bez XXE), podpis
  ze stalej listy pol, zgodnosc ServiceID, kwoty i waluty, idempotencja,
  podpisana odpowiedz XML CONFIRMED/NOTCONFIRMED.
- Start transakcji podpisanym formularzem POST ze strony /platnosc/:orderId
  zamiast linku GET z podpisem w adresie; tytul przelewu w ASCII.
- Losowy OrderID (UUID) zamiast Math.random.
- Adres ITN do wpisania w panelu AutoPay widoczny w panelu admina.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-23 13:22:20 +02:00

284 lines
14 KiB
TypeScript

// 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 i włączonej bramki zakup promowania jest niedostępny. Adres ITN w panelu AutoPay (Ustawienia serwisu → Konfiguracja techniczna → Edytuj): {window.location.origin}/api/payments/autopay/itn</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>
);
}