sms & SMTP
This commit is contained in:
@@ -0,0 +1,974 @@
|
||||
/**
|
||||
* 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user