Files
polskalokalnie/frontend/src/admin/marketing.tsx
T
2026-07-23 23:15:15 +02:00

975 lines
36 KiB
TypeScript

/**
* Widoki panelu administratora dla modułu marketingowego: Leady + grupy docelowe, Kampanie oraz
* konfiguracja poczty SMTP i bramki SMS. Wydzielone z App.tsx (monolit 22k linii), aby go nie
* powiększać. Komponent `Icon` przekazywany jest propem, żeby uniknąć cyklicznego importu.
*/
import { useCallback, useEffect, useState } from 'react';
import { apiFetch } from '../auth';
type IconComponent = (props: { name: string }) => JSX.Element | null;
type LeadSource = 'REGISTERED_USER' | 'MANUAL' | 'IMPORT' | 'FORM';
type LeadStatus = 'NEW' | 'CONTACTED' | 'RESPONDED' | 'UNSUBSCRIBED';
type Lead = {
id: number;
name: string | null;
email: string | null;
phone: string | null;
city: string | null;
source: LeadSource;
userId: number | null;
status: LeadStatus;
tags: string[];
emailConsent: boolean;
smsConsent: boolean;
emailOptOut: boolean;
smsOptOut: boolean;
notes: string | null;
createdAt: string;
};
type TargetGroup = {
id: number;
name: string;
description: string | null;
sources: LeadSource[];
city: string | null;
tag: string | null;
status: LeadStatus | null;
requireEmail: boolean;
requirePhone: boolean;
requireEmailConsent: boolean;
requireSmsConsent: boolean;
memberCount: number;
createdAt: string;
};
type Channel = 'EMAIL' | 'SMS';
type CampaignStatus = 'DRAFT' | 'SCHEDULED' | 'SENDING' | 'PAUSED' | 'COMPLETED' | 'CANCELLED';
type Campaign = {
id: number;
name: string;
channel: Channel;
subject: string | null;
body: string;
targetGroupId: number;
scheduledAt: string | null;
dailyLimit: number;
status: CampaignStatus;
sentToday: number;
createdAt: string;
createdBy: string | null;
totalRecipients: number;
sentCount: number;
};
type CampaignStats = {
total: number;
queued: number;
sent: number;
delivered: number;
failed: number;
bounced: number;
optedOut: number;
};
const SOURCE_LABEL: Record<LeadSource, string> = {
REGISTERED_USER: 'Użytkownik',
MANUAL: 'Ręczny',
IMPORT: 'Import',
FORM: 'Formularz',
};
const STATUS_LABEL: Record<LeadStatus, string> = {
NEW: 'Nowy',
CONTACTED: 'Kontakt',
RESPONDED: 'Odpowiedział',
UNSUBSCRIBED: 'Wypisany',
};
const CAMPAIGN_STATUS_LABEL: Record<CampaignStatus, string> = {
DRAFT: 'Robocza',
SCHEDULED: 'Zaplanowana',
SENDING: 'Wysyłka',
PAUSED: 'Wstrzymana',
COMPLETED: 'Zakończona',
CANCELLED: 'Anulowana',
};
const ALL_SOURCES: LeadSource[] = ['REGISTERED_USER', 'MANUAL', 'IMPORT', 'FORM'];
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : 'Wystąpił błąd. Spróbuj ponownie.';
}
/* ============================ LEADY + GRUPY DOCELOWE ============================ */
export function AdminLeadsView({ Icon }: { Icon: IconComponent }) {
const [section, setSection] = useState<'leads' | 'groups'>('leads');
return (
<div className="mkt-wrap">
<div className="mkt-subnav">
<button type="button" className={section === 'leads' ? 'active' : ''} onClick={() => setSection('leads')}>
<Icon name="user" /> Baza leadów
</button>
<button type="button" className={section === 'groups' ? 'active' : ''} onClick={() => setSection('groups')}>
<Icon name="list" /> Grupy docelowe
</button>
</div>
{section === 'leads' ? <LeadsSection Icon={Icon} /> : <TargetGroupsSection Icon={Icon} />}
</div>
);
}
function LeadsSection({ Icon }: { Icon: IconComponent }) {
const [leads, setLeads] = useState<Lead[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [sourceFilter, setSourceFilter] = useState<'' | LeadSource>('');
const [search, setSearch] = useState('');
const [busyId, setBusyId] = useState<number | null>(null);
// Formularz nowego leada.
const [form, setForm] = useState({ name: '', email: '', phone: '', city: '', tags: '' });
const [saving, setSaving] = useState(false);
// Import.
const [csv, setCsv] = useState('');
const [importTag, setImportTag] = useState('');
const [importing, setImporting] = useState(false);
const [importResult, setImportResult] = useState<string | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams();
if (sourceFilter) params.set('source', sourceFilter);
if (search.trim()) params.set('search', search.trim());
const data = await apiFetch<Lead[]>(`/admin/leads${params.toString() ? `?${params.toString()}` : ''}`);
setLeads(data);
} catch (err) {
setError(errorMessage(err));
} finally {
setLoading(false);
}
}, [sourceFilter, search]);
useEffect(() => {
const timer = setTimeout(load, 200);
return () => clearTimeout(timer);
}, [load]);
const addLead = async () => {
if (!form.email.trim() && !form.phone.trim()) {
setError('Podaj co najmniej e-mail lub telefon.');
return;
}
setSaving(true);
setError(null);
try {
await apiFetch<Lead>('/admin/leads', {
method: 'POST',
body: JSON.stringify({
name: form.name.trim() || null,
email: form.email.trim() || null,
phone: form.phone.trim() || null,
city: form.city.trim() || null,
tags: form.tags.split(',').map((t) => t.trim()).filter(Boolean),
}),
});
setForm({ name: '', email: '', phone: '', city: '', tags: '' });
await load();
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
};
const runImport = async () => {
if (!csv.trim()) return;
setImporting(true);
setImportResult(null);
setError(null);
try {
const res = await apiFetch<{ imported: number; skipped: number; messages: string[] }>(
'/admin/leads/import',
{ method: 'POST', body: JSON.stringify({ csv, defaultTag: importTag.trim() || null }) },
);
setImportResult(`Dodano ${res.imported}, pominięto ${res.skipped}.`);
setCsv('');
await load();
} catch (err) {
setError(errorMessage(err));
} finally {
setImporting(false);
}
};
const optOut = async (lead: Lead) => {
setBusyId(lead.id);
try {
await apiFetch(`/admin/leads/${lead.id}`, {
method: 'PATCH',
body: JSON.stringify({ emailOptOut: true, smsOptOut: true }),
});
await load();
} catch (err) {
setError(errorMessage(err));
} finally {
setBusyId(null);
}
};
const remove = async (lead: Lead) => {
setBusyId(lead.id);
try {
await apiFetch(`/admin/leads/${lead.id}`, { method: 'DELETE' });
await load();
} catch (err) {
setError(errorMessage(err));
} finally {
setBusyId(null);
}
};
return (
<div className="admin-card mkt-card">
<div className="admin-card-head">
<h2>Baza leadów</h2>
<span className="mkt-count">{leads.length} kontaktów</span>
</div>
<div className="mkt-toolbar">
<div className="mkt-search">
<Icon name="search" />
<input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Szukaj po nazwie, e-mailu, telefonie, mieście..."
/>
</div>
<select value={sourceFilter} onChange={(e) => setSourceFilter(e.target.value as '' | LeadSource)}>
<option value="">Wszystkie źródła</option>
{ALL_SOURCES.map((s) => (
<option key={s} value={s}>{SOURCE_LABEL[s]}</option>
))}
</select>
</div>
<div className="mkt-addrow">
<input placeholder="Nazwa / imię" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
<input placeholder="E-mail" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} />
<input placeholder="Telefon" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} />
<input placeholder="Miasto" value={form.city} onChange={(e) => setForm({ ...form, city: e.target.value })} />
<input placeholder="Tagi (po przecinku)" value={form.tags} onChange={(e) => setForm({ ...form, tags: e.target.value })} />
<button type="button" className="admin-btn approve" onClick={addLead} disabled={saving}>
{saving ? 'Zapisywanie...' : 'Dodaj lead'}
</button>
</div>
{error && <p className="mkt-error">{error}</p>}
{loading ? (
<p className="admin-empty">Ładowanie...</p>
) : leads.length === 0 ? (
<p className="admin-empty">Brak leadów. Dodaj kontakt lub zaimportuj listę.</p>
) : (
<div className="mkt-table-scroll">
<table className="mkt-table">
<thead>
<tr>
<th>Nazwa</th><th>Kontakt</th><th>Miasto</th><th>Źródło</th><th>Status</th><th>Zgody</th><th></th>
</tr>
</thead>
<tbody>
{leads.map((lead) => (
<tr key={lead.id}>
<td>{lead.name || '—'}</td>
<td>
<div className="mkt-contact">
{lead.email && <span>{lead.email}</span>}
{lead.phone && <span className="mkt-muted">{lead.phone}</span>}
</div>
</td>
<td>{lead.city || '—'}</td>
<td><span className="mkt-chip">{SOURCE_LABEL[lead.source]}</span></td>
<td>{STATUS_LABEL[lead.status]}</td>
<td>
<div className="mkt-consents">
<span className={`mkt-dot ${lead.emailConsent && !lead.emailOptOut ? 'on' : 'off'}`} title="Zgoda e-mail">@</span>
<span className={`mkt-dot ${lead.smsConsent && !lead.smsOptOut ? 'on' : 'off'}`} title="Zgoda SMS">SMS</span>
</div>
</td>
<td className="mkt-actions">
{!(lead.emailOptOut && lead.smsOptOut) && (
<button type="button" title="Wypisz z kampanii" disabled={busyId === lead.id} onClick={() => optOut(lead)}>
<Icon name="lock" />
</button>
)}
<button type="button" title="Usuń lead" disabled={busyId === lead.id} onClick={() => remove(lead)}>
<Icon name="trash" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="mkt-import">
<h3><Icon name="document" /> Import kontaktów (CSV)</h3>
<p className="mkt-muted">Kolumny: <code>nazwa;email;telefon;miasto</code> jeden kontakt w wierszu, nagłówek opcjonalny.</p>
<textarea
rows={4}
value={csv}
onChange={(e) => setCsv(e.target.value)}
placeholder={'Jan Kowalski;jan@example.pl;600100200;Warszawa'}
/>
<div className="mkt-import-actions">
<input placeholder="Tag dla importu (opcjonalnie)" value={importTag} onChange={(e) => setImportTag(e.target.value)} />
<button type="button" className="admin-btn approve" onClick={runImport} disabled={importing || !csv.trim()}>
{importing ? 'Importowanie...' : 'Importuj'}
</button>
</div>
{importResult && <p className="mkt-ok">{importResult}</p>}
</div>
</div>
);
}
function TargetGroupsSection({ Icon }: { Icon: IconComponent }) {
const emptyForm = {
name: '',
description: '',
sources: [] as LeadSource[],
city: '',
tag: '',
requireEmail: false,
requirePhone: false,
requireEmailConsent: false,
requireSmsConsent: false,
};
const [groups, setGroups] = useState<TargetGroup[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [form, setForm] = useState(emptyForm);
const [saving, setSaving] = useState(false);
const [busyId, setBusyId] = useState<number | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
setGroups(await apiFetch<TargetGroup[]>('/admin/target-groups'));
} catch (err) {
setError(errorMessage(err));
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const toggleSource = (s: LeadSource) => {
setForm((f) => ({
...f,
sources: f.sources.includes(s) ? f.sources.filter((x) => x !== s) : [...f.sources, s],
}));
};
const create = async () => {
if (!form.name.trim()) {
setError('Podaj nazwę grupy.');
return;
}
setSaving(true);
setError(null);
try {
await apiFetch<TargetGroup>('/admin/target-groups', {
method: 'POST',
body: JSON.stringify({
name: form.name.trim(),
description: form.description.trim() || null,
sources: form.sources,
city: form.city.trim() || null,
tag: form.tag.trim() || null,
requireEmail: form.requireEmail,
requirePhone: form.requirePhone,
requireEmailConsent: form.requireEmailConsent,
requireSmsConsent: form.requireSmsConsent,
}),
});
setForm(emptyForm);
await load();
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
};
const remove = async (group: TargetGroup) => {
setBusyId(group.id);
try {
await apiFetch(`/admin/target-groups/${group.id}`, { method: 'DELETE' });
await load();
} catch (err) {
setError(errorMessage(err));
} finally {
setBusyId(null);
}
};
return (
<div className="mkt-grid-2">
<div className="admin-card mkt-card">
<div className="admin-card-head"><h2>Nowa grupa docelowa</h2></div>
<p className="mkt-muted">Grupa to dynamiczny segment odbiorcy wyliczają się na bieżąco z kryteriów.</p>
<div className="mkt-form">
<label>Nazwa
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="np. Deweloperzy Warszawa" />
</label>
<label>Opis
<input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="Opcjonalny opis" />
</label>
<fieldset className="mkt-fieldset">
<legend>Źródła (puste = wszystkie)</legend>
<div className="mkt-checks">
{ALL_SOURCES.map((s) => (
<label key={s} className="mkt-check">
<input type="checkbox" checked={form.sources.includes(s)} onChange={() => toggleSource(s)} />
{SOURCE_LABEL[s]}
</label>
))}
</div>
</fieldset>
<div className="mkt-form-row">
<label>Miasto
<input value={form.city} onChange={(e) => setForm({ ...form, city: e.target.value })} placeholder="dowolne" />
</label>
<label>Tag
<input value={form.tag} onChange={(e) => setForm({ ...form, tag: e.target.value })} placeholder="dowolny" />
</label>
</div>
<div className="mkt-checks mkt-checks-col">
<label className="mkt-check"><input type="checkbox" checked={form.requireEmail} onChange={(e) => setForm({ ...form, requireEmail: e.target.checked })} /> Wymagany e-mail</label>
<label className="mkt-check"><input type="checkbox" checked={form.requirePhone} onChange={(e) => setForm({ ...form, requirePhone: e.target.checked })} /> Wymagany telefon</label>
<label className="mkt-check"><input type="checkbox" checked={form.requireEmailConsent} onChange={(e) => setForm({ ...form, requireEmailConsent: e.target.checked })} /> Tylko ze zgodą e-mail</label>
<label className="mkt-check"><input type="checkbox" checked={form.requireSmsConsent} onChange={(e) => setForm({ ...form, requireSmsConsent: e.target.checked })} /> Tylko ze zgodą SMS</label>
</div>
{error && <p className="mkt-error">{error}</p>}
<button type="button" className="admin-btn approve" onClick={create} disabled={saving}>
{saving ? 'Zapisywanie...' : 'Utwórz grupę'}
</button>
</div>
</div>
<div className="admin-card mkt-card">
<div className="admin-card-head">
<h2>Grupy docelowe</h2>
<span className="mkt-count">{groups.length}</span>
</div>
{loading ? (
<p className="admin-empty">Ładowanie...</p>
) : groups.length === 0 ? (
<p className="admin-empty">Brak grup. Utwórz pierwszy segment.</p>
) : (
<ul className="mkt-list">
{groups.map((g) => (
<li key={g.id}>
<div>
<strong>{g.name}</strong>
{g.description && <span className="mkt-muted"> {g.description}</span>}
<div className="mkt-group-meta">
<span className="mkt-badge">{g.memberCount} odbiorców</span>
{g.sources.length > 0 && <span className="mkt-muted">{g.sources.map((s) => SOURCE_LABEL[s]).join(', ')}</span>}
{g.city && <span className="mkt-muted">miasto: {g.city}</span>}
{g.requireEmailConsent && <span className="mkt-muted">zgoda e-mail</span>}
{g.requireSmsConsent && <span className="mkt-muted">zgoda SMS</span>}
</div>
</div>
<button type="button" className="mkt-icon-btn" title="Usuń grupę" disabled={busyId === g.id} onClick={() => remove(g)}>
<Icon name="trash" />
</button>
</li>
))}
</ul>
)}
</div>
</div>
);
}
/* ============================ KAMPANIE ============================ */
export function AdminCampaignsView({ Icon }: { Icon: IconComponent }) {
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
const [groups, setGroups] = useState<TargetGroup[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [busyId, setBusyId] = useState<number | null>(null);
const [statsFor, setStatsFor] = useState<{ id: number; stats: CampaignStats } | null>(null);
const empty = { name: '', channel: 'EMAIL' as Channel, subject: '', body: '', targetGroupId: '', dailyLimit: 200, scheduledAt: '' };
const [form, setForm] = useState(empty);
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [c, g] = await Promise.all([
apiFetch<Campaign[]>('/admin/campaigns'),
apiFetch<TargetGroup[]>('/admin/target-groups'),
]);
setCampaigns(c);
setGroups(g);
} catch (err) {
setError(errorMessage(err));
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const create = async () => {
if (!form.name.trim() || !form.body.trim() || !form.targetGroupId) {
setError('Uzupełnij nazwę, treść i grupę docelową.');
return;
}
if (form.channel === 'EMAIL' && !form.subject.trim()) {
setError('Kampania e-mail wymaga tematu.');
return;
}
setSaving(true);
setError(null);
try {
await apiFetch<Campaign>('/admin/campaigns', {
method: 'POST',
body: JSON.stringify({
name: form.name.trim(),
channel: form.channel,
subject: form.channel === 'EMAIL' ? form.subject.trim() : null,
body: form.body,
targetGroupId: Number(form.targetGroupId),
dailyLimit: Number(form.dailyLimit) || 1,
scheduledAt: form.scheduledAt ? new Date(form.scheduledAt).toISOString() : null,
}),
});
setForm(empty);
await load();
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
};
const action = async (id: number, path: string) => {
setBusyId(id);
setError(null);
try {
await apiFetch(`/admin/campaigns/${id}/${path}`, { method: 'POST' });
await load();
} catch (err) {
setError(errorMessage(err));
} finally {
setBusyId(null);
}
};
const remove = async (id: number) => {
setBusyId(id);
try {
await apiFetch(`/admin/campaigns/${id}`, { method: 'DELETE' });
if (statsFor?.id === id) setStatsFor(null);
await load();
} catch (err) {
setError(errorMessage(err));
} finally {
setBusyId(null);
}
};
const showStats = async (id: number) => {
try {
const stats = await apiFetch<CampaignStats>(`/admin/campaigns/${id}/stats`);
setStatsFor({ id, stats });
} catch (err) {
setError(errorMessage(err));
}
};
return (
<div className="mkt-grid-2">
<div className="admin-card mkt-card">
<div className="admin-card-head"><h2>Nowa kampania</h2></div>
<div className="mkt-form">
<label>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>
{form.channel === 'EMAIL' && (
<label>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>
<textarea rows={5} 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>}
<button type="button" className="admin-btn approve" onClick={create} disabled={saving}>
{saving ? 'Zapisywanie...' : 'Utwórz kampanię (robocza)'}
</button>
</div>
</div>
<div className="admin-card mkt-card">
<div className="admin-card-head">
<h2>Kampanie</h2>
<span className="mkt-count">{campaigns.length}</span>
</div>
{loading ? (
<p className="admin-empty">Ładowanie...</p>
) : campaigns.length === 0 ? (
<p className="admin-empty">Brak kampanii.</p>
) : (
<ul className="mkt-list">
{campaigns.map((c) => (
<li key={c.id} className="mkt-campaign">
<div>
<strong>{c.name}</strong>
<span className={`mkt-status mkt-status-${c.status.toLowerCase()}`}>{CAMPAIGN_STATUS_LABEL[c.status]}</span>
<div className="mkt-group-meta">
<span className="mkt-chip">{c.channel === 'EMAIL' ? 'E-mail' : 'SMS'}</span>
<span className="mkt-muted">{c.sentCount}/{c.totalRecipients} wysłanych</span>
<span className="mkt-muted">limit/dzień: {c.dailyLimit}</span>
</div>
{statsFor?.id === c.id && (
<div className="mkt-stats">
w kolejce: {statsFor.stats.queued} · wysłane: {statsFor.stats.sent} · dostarczone: {statsFor.stats.delivered} · błędy: {statsFor.stats.failed}
</div>
)}
</div>
<div className="mkt-campaign-actions">
{(c.status === 'DRAFT' || c.status === 'PAUSED') && (
<button type="button" className="admin-btn approve" disabled={busyId === c.id} onClick={() => action(c.id, 'schedule')}>
Uruchom
</button>
)}
{(c.status === 'SENDING' || c.status === 'SCHEDULED') && (
<button type="button" className="admin-btn" disabled={busyId === c.id} onClick={() => action(c.id, 'pause')}>
Wstrzymaj
</button>
)}
<button type="button" className="mkt-icon-btn" title="Statystyki" onClick={() => showStats(c.id)}><Icon name="chart" /></button>
<button type="button" className="mkt-icon-btn" title="Usuń" disabled={busyId === c.id} onClick={() => remove(c.id)}><Icon name="trash" /></button>
</div>
</li>
))}
</ul>
)}
</div>
</div>
);
}
/* ============================ KONFIGURACJA SMTP + SMS ============================ */
type SmtpConfig = {
host: string | null;
port: number | null;
sslEnabled: boolean;
username: string | null;
fromName: string | null;
contactFormRecipient: string | null;
enabled: boolean;
passwordSet: boolean;
};
type SmsConfig = {
endpointUrl: string | null;
creator: string | null;
timeoutSeconds: number | null;
enabled: boolean;
apiKeySet: boolean;
};
export function AdminMailConfigView({ Icon }: { Icon: IconComponent }) {
return (
<div className="mkt-wrap">
<SmtpCard Icon={Icon} />
<SmsCard Icon={Icon} />
</div>
);
}
function SmtpCard({ Icon }: { Icon: IconComponent }) {
const [config, setConfig] = useState<SmtpConfig | null>(null);
const [password, setPassword] = useState('');
const [saving, setSaving] = useState(false);
const [note, setNote] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [testTo, setTestTo] = useState('');
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<string | null>(null);
const load = useCallback(async () => {
try {
setConfig(await apiFetch<SmtpConfig>('/admin/mail-config/smtp'));
} catch (err) {
setError(errorMessage(err));
}
}, []);
useEffect(() => { load(); }, [load]);
if (!config) return <div className="admin-card mkt-card"><p className="admin-empty">Ładowanie konfiguracji SMTP...</p></div>;
const set = (patch: Partial<SmtpConfig>) => setConfig({ ...config, ...patch });
const save = async () => {
setSaving(true);
setNote(null);
setError(null);
try {
const updated = await apiFetch<SmtpConfig>('/admin/mail-config/smtp', {
method: 'PUT',
body: JSON.stringify({
host: config.host,
port: config.port,
sslEnabled: config.sslEnabled,
username: config.username,
fromName: config.fromName,
contactFormRecipient: config.contactFormRecipient,
enabled: config.enabled,
password: password || null,
}),
});
setConfig(updated);
setPassword('');
setNote('Zapisano ustawienia SMTP.');
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
};
const test = async () => {
if (!testTo.trim()) return;
setTesting(true);
setTestResult(null);
try {
const res = await apiFetch<{ ok: boolean; error: string | null }>('/admin/mail-config/smtp/test', {
method: 'POST',
body: JSON.stringify({ recipient: testTo.trim() }),
});
setTestResult(res.ok ? 'Wiadomość testowa wysłana.' : `Błąd: ${res.error}`);
} catch (err) {
setTestResult(`Błąd: ${errorMessage(err)}`);
} finally {
setTesting(false);
}
};
return (
<div className="admin-card mkt-card mkt-config">
<div className="admin-card-head mkt-config-head">
<span className="mkt-config-icon"><Icon name="mail" /></span>
<h2>Konto e-mail (SMTP)</h2>
</div>
<div className="mkt-config-grid">
<label>Adres odbiorcy wiadomości z formularza kontaktowego
<input value={config.contactFormRecipient ?? ''} onChange={(e) => set({ contactFormRecipient: e.target.value })} placeholder="np. kontakt@twojadomena.pl" />
</label>
<label>Wyświetlana nazwa nadawcy
<input value={config.fromName ?? ''} onChange={(e) => set({ fromName: e.target.value })} placeholder="np. Marketing" />
</label>
<label>Serwer SMTP
<input value={config.host ?? ''} onChange={(e) => set({ host: e.target.value })} placeholder="np. smtp.twojadomena.pl" />
</label>
<label>Hasło do konta e-mail {config.passwordSet && <span className="mkt-badge-set"><Icon name="lock" /> ustawione</span>}
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="wpisz, aby zmienić" />
</label>
<label>Port SMTP (465 = SSL)
<input type="number" value={config.port ?? ''} onChange={(e) => set({ port: e.target.value ? Number(e.target.value) : null })} placeholder="465" />
</label>
<label>Użyj SSL dla połączenia SMTP
<select value={config.sslEnabled ? 'on' : 'off'} onChange={(e) => set({ sslEnabled: e.target.value === 'on' })}>
<option value="on">Włączone</option>
<option value="off">Wyłączone</option>
</select>
</label>
<label>Login SMTP / adres nadawcy
<input value={config.username ?? ''} onChange={(e) => set({ username: e.target.value })} placeholder="np. marketing@twojadomena.pl" />
</label>
<label>Wysyłka kampanii e-mail aktywna
<select value={config.enabled ? 'on' : 'off'} onChange={(e) => set({ enabled: e.target.value === 'on' })}>
<option value="off">Wyłączone (sandbox)</option>
<option value="on">Włączone</option>
</select>
</label>
</div>
{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...' : 'Zapisz ustawienia'}
</button>
</div>
<div className="mkt-test">
<h3>Test konfiguracji</h3>
<div className="mkt-test-row">
<input value={testTo} onChange={(e) => setTestTo(e.target.value)} placeholder="Adres e-mail do testu" />
<button type="button" className="admin-btn" onClick={test} disabled={testing || !testTo.trim()}>
<Icon name="mail" /> {testing ? 'Wysyłanie...' : 'Wyślij (e-mail)'}
</button>
</div>
{testResult && <p className={testResult.startsWith('Błąd') ? 'mkt-error' : 'mkt-ok'}>{testResult}</p>}
</div>
</div>
);
}
function SmsCard({ Icon }: { Icon: IconComponent }) {
const [config, setConfig] = useState<SmsConfig | null>(null);
const [apiKey, setApiKey] = useState('');
const [saving, setSaving] = useState(false);
const [note, setNote] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [testTo, setTestTo] = useState('');
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<string | null>(null);
const load = useCallback(async () => {
try {
setConfig(await apiFetch<SmsConfig>('/admin/mail-config/sms'));
} catch (err) {
setError(errorMessage(err));
}
}, []);
useEffect(() => { load(); }, [load]);
if (!config) return <div className="admin-card mkt-card"><p className="admin-empty">Ładowanie konfiguracji SMS...</p></div>;
const set = (patch: Partial<SmsConfig>) => setConfig({ ...config, ...patch });
const save = async () => {
setSaving(true);
setNote(null);
setError(null);
try {
const updated = await apiFetch<SmsConfig>('/admin/mail-config/sms', {
method: 'PUT',
body: JSON.stringify({
endpointUrl: config.endpointUrl,
creator: config.creator,
timeoutSeconds: config.timeoutSeconds,
enabled: config.enabled,
apiKey: apiKey || null,
}),
});
setConfig(updated);
setApiKey('');
setNote('Zapisano ustawienia bramki SMS.');
} catch (err) {
setError(errorMessage(err));
} finally {
setSaving(false);
}
};
const test = async () => {
if (!testTo.trim()) return;
setTesting(true);
setTestResult(null);
try {
const res = await apiFetch<{ ok: boolean; error: string | null }>('/admin/mail-config/sms/test', {
method: 'POST',
body: JSON.stringify({ recipient: testTo.trim() }),
});
setTestResult(res.ok ? 'SMS testowy wysłany.' : `Błąd: ${res.error}`);
} catch (err) {
setTestResult(`Błąd: ${errorMessage(err)}`);
} finally {
setTesting(false);
}
};
return (
<div className="admin-card mkt-card mkt-config">
<div className="admin-card-head mkt-config-head">
<span className="mkt-config-icon"><Icon name="message" /></span>
<h2>Bramka SMS</h2>
</div>
<div className="mkt-config-grid">
<label>Klucz API bramki SMS {config.apiKeySet && <span className="mkt-badge-set"><Icon name="lock" /> ustawione</span>}
<input type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="wpisz, aby zmienić" />
</label>
<label>Znacznik źródła wiadomości (pole creator, max 50 znaków)
<input maxLength={50} value={config.creator ?? ''} onChange={(e) => set({ creator: e.target.value })} placeholder="np. Marketing" />
</label>
<label>Czy wysyłka SMS jest aktywna
<select value={config.enabled ? 'on' : 'off'} onChange={(e) => set({ enabled: e.target.value === 'on' })}>
<option value="off">Wyłączone (sandbox)</option>
<option value="on">Włączone</option>
</select>
</label>
<label>Pełny URL endpointu bramki SMS
<input value={config.endpointUrl ?? ''} onChange={(e) => set({ endpointUrl: e.target.value })} placeholder="https://api.softspm.pl/send_sms_api.php" />
</label>
<label>Timeout żądania HTTP do bramki (s)
<input type="number" value={config.timeoutSeconds ?? ''} onChange={(e) => set({ timeoutSeconds: e.target.value ? Number(e.target.value) : null })} placeholder="10" />
</label>
</div>
{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...' : 'Zapisz ustawienia'}
</button>
</div>
<div className="mkt-test">
<h3>Test konfiguracji</h3>
<div className="mkt-test-row">
<input value={testTo} onChange={(e) => setTestTo(e.target.value)} placeholder="Numer telefonu do testu" />
<button type="button" className="admin-btn" onClick={test} disabled={testing || !testTo.trim()}>
<Icon name="message" /> {testing ? 'Wysyłanie...' : 'Wyślij (SMS)'}
</button>
</div>
{testResult && <p className={testResult.startsWith('Błąd') ? 'mkt-error' : 'mkt-ok'}>{testResult}</p>}
</div>
</div>
);
}