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