Initial BizOne portal setup
This commit is contained in:
489
frontend/src/components/audit-trail-board.tsx
Normal file
489
frontend/src/components/audit-trail-board.tsx
Normal file
@ -0,0 +1,489 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { type AuditTrailEntry, seedAuditTrailEntries } from '../lib/audit-trail';
|
||||
|
||||
function formatDate(value: string) {
|
||||
const date = new Date(value);
|
||||
return {
|
||||
day: date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }),
|
||||
time: date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }),
|
||||
};
|
||||
}
|
||||
|
||||
function downloadCsv(rows: AuditTrailEntry[]) {
|
||||
const header = ['Timestamp', 'Admin User', 'Action Type', 'Module', 'IP Address', 'Severity', 'Details'];
|
||||
const csvRows = rows.map((row) =>
|
||||
[
|
||||
row.timestamp,
|
||||
row.adminUser,
|
||||
row.actionType,
|
||||
row.module,
|
||||
row.ipAddress,
|
||||
row.severity,
|
||||
row.details,
|
||||
]
|
||||
.map((cell) => `"${String(cell).replaceAll('"', '""')}"`)
|
||||
.join(','),
|
||||
);
|
||||
|
||||
const blob = new Blob([[header.join(','), ...csvRows].join('\n')], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = 'audit-trail-export.csv';
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
initialEntries: AuditTrailEntry[];
|
||||
initialTotal?: number;
|
||||
initialPage?: number;
|
||||
initialPageSize?: number;
|
||||
initialTotalPages?: number;
|
||||
};
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
|
||||
|
||||
function buildVisiblePages(page: number, totalPages: number) {
|
||||
if (totalPages <= 5) {
|
||||
return Array.from({ length: totalPages }, (_, index) => index + 1);
|
||||
}
|
||||
|
||||
const start = Math.max(1, Math.min(page - 2, totalPages - 4));
|
||||
return Array.from({ length: 5 }, (_, index) => start + index);
|
||||
}
|
||||
|
||||
export function AuditTrailBoard({
|
||||
initialEntries,
|
||||
initialTotal,
|
||||
initialPage,
|
||||
initialPageSize,
|
||||
initialTotalPages,
|
||||
}: Props) {
|
||||
const [allEntries] = useState<AuditTrailEntry[]>(initialEntries.length > 0 ? initialEntries : seedAuditTrailEntries);
|
||||
const [entries, setEntries] = useState<AuditTrailEntry[]>(initialEntries.length > 0 ? initialEntries : seedAuditTrailEntries);
|
||||
const [total, setTotal] = useState(initialTotal ?? initialEntries.length);
|
||||
const [page, setPage] = useState(initialPage ?? 1);
|
||||
const [pageSize, setPageSize] = useState(initialPageSize ?? 50);
|
||||
const [totalPages, setTotalPages] = useState(initialTotalPages ?? 1);
|
||||
const [range, setRange] = useState('7d');
|
||||
const [adminUser, setAdminUser] = useState('all');
|
||||
const [actionType, setActionType] = useState('all');
|
||||
const [moduleName, setModuleName] = useState('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedEntryId(initialEntries[0]?.id ?? seedAuditTrailEntries[0]?.id ?? null);
|
||||
}, [initialEntries]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
const timeout = window.setTimeout(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', String(page));
|
||||
params.set('limit', String(pageSize));
|
||||
if (range !== 'all') params.set('range', range);
|
||||
if (adminUser !== 'all') params.set('user', adminUser);
|
||||
if (actionType !== 'all') params.set('actionType', actionType);
|
||||
if (moduleName !== 'all') params.set('module', moduleName);
|
||||
if (search.trim()) params.set('search', search.trim());
|
||||
|
||||
const response = await fetch(`/api/audit-trail?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
items: Array<{
|
||||
id: string;
|
||||
actorName: string;
|
||||
actionType: string;
|
||||
module: string;
|
||||
ipAddress: string | null;
|
||||
severity: 'default' | 'alert';
|
||||
details: string;
|
||||
createdAt: string;
|
||||
}>;
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
};
|
||||
|
||||
const normalized = payload.items.map((entry) => ({
|
||||
id: entry.id,
|
||||
timestamp: entry.createdAt,
|
||||
adminUser: entry.actorName,
|
||||
actionType: entry.actionType,
|
||||
module: entry.module,
|
||||
ipAddress: entry.ipAddress || '-',
|
||||
severity: entry.severity,
|
||||
details: entry.details,
|
||||
}));
|
||||
|
||||
setEntries(normalized);
|
||||
setTotal(payload.total);
|
||||
setPage(payload.page);
|
||||
setPageSize(payload.pageSize);
|
||||
setTotalPages(payload.totalPages);
|
||||
} catch (error) {
|
||||
if ((error as Error).name !== 'AbortError') {
|
||||
console.error(error);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, 250);
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
window.clearTimeout(timeout);
|
||||
};
|
||||
}, [actionType, adminUser, moduleName, page, pageSize, range, search]);
|
||||
|
||||
const selectedEntry =
|
||||
entries.find((entry) => entry.id === selectedEntryId) ?? entries[0] ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedEntryId && entries[0]?.id) {
|
||||
setSelectedEntryId(entries[0].id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedEntryId && !entries.some((entry) => entry.id === selectedEntryId)) {
|
||||
setSelectedEntryId(entries[0]?.id ?? null);
|
||||
}
|
||||
}, [entries, selectedEntryId]);
|
||||
|
||||
const users = Array.from(new Set(allEntries.map((entry) => entry.adminUser)));
|
||||
const actions = Array.from(new Set(allEntries.map((entry) => entry.actionType)));
|
||||
const modules = Array.from(new Set(allEntries.map((entry) => entry.module)));
|
||||
|
||||
const alertsCount = allEntries.filter((entry) => entry.severity === 'alert').length;
|
||||
const mostActiveAdmin =
|
||||
users
|
||||
.map((user) => ({
|
||||
user,
|
||||
count: allEntries.filter((entry) => entry.adminUser === user).length,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count)[0] ?? { user: 'Admin User', count: 0 };
|
||||
const visiblePages = useMemo(() => buildVisiblePages(page, totalPages), [page, totalPages]);
|
||||
const pageStart = entries.length === 0 ? 0 : (page - 1) * pageSize + 1;
|
||||
const pageEnd = entries.length === 0 ? 0 : (page - 1) * pageSize + entries.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="page-header">
|
||||
<div>
|
||||
<p className="page-eyebrow">Settings</p>
|
||||
<h1 className="page-heading">Audit Trail</h1>
|
||||
<p className="page-copy">Monitor administrative actions, role changes, and system modifications from one place.</p>
|
||||
</div>
|
||||
<button type="button" className="audit-export-button" onClick={() => downloadCsv(entries)}>
|
||||
<span className="material-symbols-outlined">download</span>
|
||||
Export to Excel
|
||||
</button>
|
||||
<a
|
||||
className="audit-export-button secondary"
|
||||
href={`/api/audit-trail/export?${new URLSearchParams({
|
||||
...(range !== 'all' ? { range } : {}),
|
||||
...(adminUser !== 'all' ? { user: adminUser } : {}),
|
||||
...(actionType !== 'all' ? { actionType } : {}),
|
||||
...(moduleName !== 'all' ? { module: moduleName } : {}),
|
||||
...(search.trim() ? { search: search.trim() } : {}),
|
||||
}).toString()}`}
|
||||
>
|
||||
<span className="material-symbols-outlined">download</span>
|
||||
Export Server CSV
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<section className="audit-kpi-grid">
|
||||
<article className="audit-kpi-card">
|
||||
<div className="audit-kpi-head">
|
||||
<span>Total Actions (Last 24H)</span>
|
||||
<span className="material-symbols-outlined">history</span>
|
||||
</div>
|
||||
<div className="audit-kpi-metric">
|
||||
<strong>{entries.length.toLocaleString('en-US')}</strong>
|
||||
<span className="audit-kpi-trend is-positive">12.5%</span>
|
||||
</div>
|
||||
<div className="audit-kpi-bar">
|
||||
<div style={{ width: `${Math.min(100, 35 + entries.length * 8)}%` }} />
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="audit-kpi-card">
|
||||
<div className="audit-kpi-head">
|
||||
<span>Security Alerts</span>
|
||||
<span className="material-symbols-outlined audit-danger">security</span>
|
||||
</div>
|
||||
<div className="audit-kpi-metric">
|
||||
<strong>{alertsCount.toString().padStart(2, '0')}</strong>
|
||||
<span className="audit-kpi-trend is-danger">Critical</span>
|
||||
</div>
|
||||
<p>Failed login bursts and suspicious access activity are surfaced here.</p>
|
||||
</article>
|
||||
|
||||
<article className="audit-kpi-card">
|
||||
<div className="audit-kpi-head">
|
||||
<span>Most Active Admin</span>
|
||||
<span className="material-symbols-outlined audit-secondary">person</span>
|
||||
</div>
|
||||
<div className="audit-kpi-user">
|
||||
<div className="audit-avatar">{mostActiveAdmin.user.slice(0, 1)}</div>
|
||||
<div>
|
||||
<strong>{mostActiveAdmin.user}</strong>
|
||||
<span>{mostActiveAdmin.count} actions performed</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="audit-filter-bar">
|
||||
<div className="audit-filter-title">
|
||||
<span className="material-symbols-outlined">filter_list</span>
|
||||
<span>Filters</span>
|
||||
</div>
|
||||
|
||||
<label className="audit-filter-field">
|
||||
<span>Date Range</span>
|
||||
<select value={range} onChange={(event) => {
|
||||
setRange(event.target.value);
|
||||
setPage(1);
|
||||
}}>
|
||||
<option value="24h">Last 24 Hours</option>
|
||||
<option value="7d">Last 7 Days</option>
|
||||
<option value="30d">Last 30 Days</option>
|
||||
<option value="all">All Time</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="audit-filter-field">
|
||||
<span>Admin User</span>
|
||||
<select value={adminUser} onChange={(event) => {
|
||||
setAdminUser(event.target.value);
|
||||
setPage(1);
|
||||
}}>
|
||||
<option value="all">All Admins</option>
|
||||
{users.map((user) => (
|
||||
<option key={user} value={user}>
|
||||
{user}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="audit-filter-field">
|
||||
<span>Action Type</span>
|
||||
<select value={actionType} onChange={(event) => {
|
||||
setActionType(event.target.value);
|
||||
setPage(1);
|
||||
}}>
|
||||
<option value="all">All Actions</option>
|
||||
{actions.map((action) => (
|
||||
<option key={action} value={action}>
|
||||
{action}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="audit-filter-field">
|
||||
<span>Module</span>
|
||||
<select value={moduleName} onChange={(event) => {
|
||||
setModuleName(event.target.value);
|
||||
setPage(1);
|
||||
}}>
|
||||
<option value="all">All Modules</option>
|
||||
{modules.map((module) => (
|
||||
<option key={module} value={module}>
|
||||
{module}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="audit-filter-search">
|
||||
<span>Search</span>
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
setSearch(event.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="Search logs, admin names..."
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="audit-reset-button"
|
||||
onClick={() => {
|
||||
setRange('7d');
|
||||
setAdminUser('all');
|
||||
setActionType('all');
|
||||
setModuleName('all');
|
||||
setSearch('');
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
Reset All
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="audit-layout">
|
||||
<article className="audit-table-card">
|
||||
{isLoading ? <div className="audit-loading-bar" /> : null}
|
||||
<table className="audit-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>Admin User</th>
|
||||
<th>Action Type</th>
|
||||
<th>Module</th>
|
||||
<th>IP Address</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => {
|
||||
const stamp = formatDate(entry.timestamp);
|
||||
const isSelected = entry.id === selectedEntry?.id;
|
||||
return (
|
||||
<tr
|
||||
key={entry.id}
|
||||
className={`${entry.severity === 'alert' ? 'is-alert' : ''} ${isSelected ? 'is-selected' : ''}`}
|
||||
>
|
||||
<td>
|
||||
<div className="audit-stamp">
|
||||
<strong>{stamp.day}</strong>
|
||||
<span>{stamp.time}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="audit-user-cell">
|
||||
<div className={`audit-avatar small ${entry.severity === 'alert' ? 'is-alert' : ''}`}>
|
||||
{entry.adminUser.slice(0, 1)}
|
||||
</div>
|
||||
<span>{entry.adminUser}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`audit-action-pill tone-${entry.severity === 'alert' ? 'alert' : 'default'}`}>
|
||||
{entry.actionType}
|
||||
</span>
|
||||
</td>
|
||||
<td>{entry.module}</td>
|
||||
<td className="audit-mono">{entry.ipAddress}</td>
|
||||
<td className="audit-actions-cell">
|
||||
<button type="button" onClick={() => setSelectedEntryId(entry.id)}>
|
||||
View Details
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="audit-pagination">
|
||||
<div className="audit-pagination-meta">
|
||||
<span>
|
||||
Showing {pageStart} to {pageEnd} of {total} results
|
||||
</span>
|
||||
<label className="audit-page-size">
|
||||
<span>Show</span>
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(event) => {
|
||||
setPageSize(Number(event.target.value));
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{PAGE_SIZE_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span>rows</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="audit-pagination-buttons">
|
||||
<button type="button" disabled={page <= 1} onClick={() => setPage((current) => Math.max(1, current - 1))}>
|
||||
<span className="material-symbols-outlined">chevron_left</span>
|
||||
</button>
|
||||
{visiblePages.map((pageNumber) => (
|
||||
<button
|
||||
key={pageNumber}
|
||||
type="button"
|
||||
className={pageNumber === page ? 'is-active' : ''}
|
||||
onClick={() => setPage(pageNumber)}
|
||||
>
|
||||
{pageNumber}
|
||||
</button>
|
||||
))}
|
||||
{visiblePages[visiblePages.length - 1] < totalPages ? <span>...</span> : null}
|
||||
{visiblePages[visiblePages.length - 1] < totalPages ? (
|
||||
<button type="button" onClick={() => setPage(totalPages)}>
|
||||
{totalPages}
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" disabled={page >= totalPages} onClick={() => setPage((current) => Math.min(totalPages, current + 1))}>
|
||||
<span className="material-symbols-outlined">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<aside className="audit-detail-card">
|
||||
{selectedEntry ? (
|
||||
<>
|
||||
<div className="card-head">
|
||||
<div>
|
||||
<p className="card-kicker">Selected Event</p>
|
||||
<h3>{selectedEntry.actionType}</h3>
|
||||
</div>
|
||||
<span className={`audit-action-pill tone-${selectedEntry.severity === 'alert' ? 'alert' : 'default'}`}>
|
||||
{selectedEntry.module}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-stack">
|
||||
<div>
|
||||
<strong>{selectedEntry.adminUser}</strong>
|
||||
<span>Actor</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{formatDate(selectedEntry.timestamp).day} {formatDate(selectedEntry.timestamp).time}</strong>
|
||||
<span>Timestamp</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{selectedEntry.ipAddress}</strong>
|
||||
<span>IP Address</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{selectedEntry.details}</strong>
|
||||
<span>Details</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p>No audit entries found.</p>
|
||||
)}
|
||||
</aside>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
324
frontend/src/components/campaign-detail-actions.tsx
Normal file
324
frontend/src/components/campaign-detail-actions.tsx
Normal file
@ -0,0 +1,324 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
|
||||
type Props = {
|
||||
campaign: {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
totalRecipients: number;
|
||||
templateName: string;
|
||||
language: string;
|
||||
messageTitle: string;
|
||||
messageBody: string;
|
||||
primaryButton: string;
|
||||
secondaryButton: string;
|
||||
bannerImageUrl: string;
|
||||
};
|
||||
};
|
||||
|
||||
function toDateTimeLocal(value: string | null | undefined) {
|
||||
if (!value) return '';
|
||||
const date = new Date(value);
|
||||
const offset = date.getTimezoneOffset();
|
||||
const local = new Date(date.getTime() - offset * 60000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
export function CampaignDetailActions({ campaign }: Props) {
|
||||
const router = useRouter();
|
||||
const [isDuplicating, setIsDuplicating] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isSendingNow, setIsSendingNow] = useState(false);
|
||||
const [isScheduling, setIsScheduling] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [scheduledAt, setScheduledAt] = useState('');
|
||||
const [form, setForm] = useState({
|
||||
name: campaign.name,
|
||||
status: campaign.status,
|
||||
totalRecipients: String(campaign.totalRecipients),
|
||||
templateName: campaign.templateName,
|
||||
language: campaign.language,
|
||||
messageTitle: campaign.messageTitle,
|
||||
messageBody: campaign.messageBody,
|
||||
primaryButton: campaign.primaryButton,
|
||||
secondaryButton: campaign.secondaryButton,
|
||||
bannerImageUrl: campaign.bannerImageUrl,
|
||||
});
|
||||
|
||||
async function readPayload(response: Response) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(typeof payload?.message === 'string' ? payload.message : 'Request failed');
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="campaign-detail-header-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="campaign-detail-secondary-button"
|
||||
onClick={() => {
|
||||
setMessage(null);
|
||||
setIsEditing((value) => !value);
|
||||
}}
|
||||
>
|
||||
<span className="material-symbols-outlined">edit</span>
|
||||
{isEditing ? 'Close Edit' : 'Edit'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="campaign-detail-secondary-button"
|
||||
disabled={isDuplicating}
|
||||
onClick={async () => {
|
||||
try {
|
||||
setMessage(null);
|
||||
setIsDuplicating(true);
|
||||
const response = await fetch(`/api/campaigns/${campaign.id}/duplicate`, {
|
||||
method: 'POST',
|
||||
});
|
||||
const payload = await readPayload(response);
|
||||
router.push(`/dashboard/campaigns/${payload.id}`);
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : 'Failed to duplicate campaign');
|
||||
} finally {
|
||||
setIsDuplicating(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="material-symbols-outlined">content_copy</span>
|
||||
{isDuplicating ? 'Duplicating...' : 'Duplicate'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="campaign-detail-secondary-button"
|
||||
disabled={isSendingNow}
|
||||
onClick={async () => {
|
||||
try {
|
||||
setMessage(null);
|
||||
setIsSendingNow(true);
|
||||
const response = await fetch(`/api/campaigns/${campaign.id}/send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mode: 'now' }),
|
||||
});
|
||||
await readPayload(response);
|
||||
setMessage('Campaign send queued.');
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : 'Failed to queue campaign');
|
||||
} finally {
|
||||
setIsSendingNow(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="material-symbols-outlined">send</span>
|
||||
{isSendingNow ? 'Queuing...' : 'Send Now'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="campaign-detail-secondary-button"
|
||||
disabled={isScheduling || !scheduledAt}
|
||||
onClick={async () => {
|
||||
try {
|
||||
setMessage(null);
|
||||
setIsScheduling(true);
|
||||
const response = await fetch(`/api/campaigns/${campaign.id}/send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mode: 'scheduled', scheduledAt: new Date(scheduledAt).toISOString() }),
|
||||
});
|
||||
await readPayload(response);
|
||||
setMessage('Campaign scheduled successfully.');
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : 'Failed to schedule campaign');
|
||||
} finally {
|
||||
setIsScheduling(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="material-symbols-outlined">schedule</span>
|
||||
{isScheduling ? 'Scheduling...' : 'Schedule'}
|
||||
</button>
|
||||
<a className="campaign-detail-secondary-button" href={`/api/campaigns/${campaign.id}/export?format=csv`}>
|
||||
<span className="material-symbols-outlined">download</span>
|
||||
Export CSV
|
||||
</a>
|
||||
<a className="campaign-detail-primary-button" href={`/api/campaigns/${campaign.id}/export?format=xlsx`}>
|
||||
<span className="material-symbols-outlined">download</span>
|
||||
Export XLSX
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="campaign-detail-inline-tools">
|
||||
<label className="campaign-detail-schedule-field">
|
||||
<span>Schedule at</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={scheduledAt}
|
||||
onChange={(event) => setScheduledAt(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="campaign-detail-danger-button"
|
||||
disabled={isDeleting}
|
||||
onClick={async () => {
|
||||
if (!window.confirm(`Delete campaign "${campaign.name}"? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setMessage(null);
|
||||
setIsDeleting(true);
|
||||
const response = await fetch(`/api/campaigns/${campaign.id}`, { method: 'DELETE' });
|
||||
if (!response.ok && response.status !== 204) {
|
||||
await readPayload(response);
|
||||
}
|
||||
router.push('/dashboard/campaigns');
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : 'Failed to delete campaign');
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="material-symbols-outlined">delete</span>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{message ? <p className="campaign-detail-inline-message">{message}</p> : null}
|
||||
|
||||
{isEditing ? (
|
||||
<form
|
||||
className="campaign-detail-edit-panel"
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
try {
|
||||
setMessage(null);
|
||||
setIsSaving(true);
|
||||
const response = await fetch(`/api/campaigns/${campaign.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...form,
|
||||
totalRecipients: Number(form.totalRecipients || '0'),
|
||||
}),
|
||||
});
|
||||
await readPayload(response);
|
||||
setMessage('Campaign updated successfully.');
|
||||
setIsEditing(false);
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : 'Failed to update campaign');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="campaign-detail-edit-grid">
|
||||
<label className="campaign-detail-field">
|
||||
<span>Name</span>
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="campaign-detail-field">
|
||||
<span>Status</span>
|
||||
<select
|
||||
value={form.status}
|
||||
onChange={(event) => setForm((current) => ({ ...current, status: event.target.value }))}
|
||||
>
|
||||
<option value="Draft">Draft</option>
|
||||
<option value="Scheduled">Scheduled</option>
|
||||
<option value="Sent">Sent</option>
|
||||
<option value="Failed">Failed</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="campaign-detail-field">
|
||||
<span>Total Recipients</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={form.totalRecipients}
|
||||
onChange={(event) => setForm((current) => ({ ...current, totalRecipients: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="campaign-detail-field">
|
||||
<span>Template Name</span>
|
||||
<input
|
||||
value={form.templateName}
|
||||
onChange={(event) => setForm((current) => ({ ...current, templateName: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="campaign-detail-field">
|
||||
<span>Language</span>
|
||||
<input
|
||||
value={form.language}
|
||||
onChange={(event) => setForm((current) => ({ ...current, language: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="campaign-detail-field">
|
||||
<span>Banner URL</span>
|
||||
<input
|
||||
value={form.bannerImageUrl}
|
||||
onChange={(event) => setForm((current) => ({ ...current, bannerImageUrl: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="campaign-detail-field">
|
||||
<span>Primary Button</span>
|
||||
<input
|
||||
value={form.primaryButton}
|
||||
onChange={(event) => setForm((current) => ({ ...current, primaryButton: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="campaign-detail-field">
|
||||
<span>Secondary Button</span>
|
||||
<input
|
||||
value={form.secondaryButton}
|
||||
onChange={(event) => setForm((current) => ({ ...current, secondaryButton: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="campaign-detail-field is-full">
|
||||
<span>Message Title</span>
|
||||
<input
|
||||
value={form.messageTitle}
|
||||
onChange={(event) => setForm((current) => ({ ...current, messageTitle: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="campaign-detail-field is-full">
|
||||
<span>Message Body</span>
|
||||
<textarea
|
||||
rows={4}
|
||||
value={form.messageBody}
|
||||
onChange={(event) => setForm((current) => ({ ...current, messageBody: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="campaign-detail-edit-actions">
|
||||
<button type="button" className="campaign-detail-secondary-button" onClick={() => setIsEditing(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="campaign-detail-primary-button" disabled={isSaving}>
|
||||
<span className="material-symbols-outlined">save</span>
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
557
frontend/src/components/campaigns-management-board.tsx
Normal file
557
frontend/src/components/campaigns-management-board.tsx
Normal file
@ -0,0 +1,557 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
type Campaign = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
audience: string;
|
||||
audienceGroup: string;
|
||||
sent: string;
|
||||
opened: string;
|
||||
status: 'Sent' | 'Scheduled' | 'Draft' | 'Failed';
|
||||
deliveryRate: number | null;
|
||||
dateLabel: string;
|
||||
timeLabel: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
campaigns: Campaign[];
|
||||
metrics?: {
|
||||
totalMessages: number;
|
||||
averageDeliveryRate: number;
|
||||
scheduledCount: number;
|
||||
failedDeliveries: number;
|
||||
};
|
||||
};
|
||||
|
||||
const STATUS_FILTERS = ['All Campaigns', 'Sent', 'Scheduled', 'Draft', 'Failed'] as const;
|
||||
const SORT_OPTIONS = ['newest', 'oldest', 'delivery'] as const;
|
||||
|
||||
type StatusFilter = (typeof STATUS_FILTERS)[number];
|
||||
type SortOption = (typeof SORT_OPTIONS)[number];
|
||||
|
||||
function parseCampaignDate(campaign: Campaign) {
|
||||
if (campaign.status === 'Scheduled') {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
if (campaign.dateLabel === 'Not set') {
|
||||
return Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
|
||||
const parsed = Date.parse(`${campaign.dateLabel} ${campaign.timeLabel}`.trim());
|
||||
return Number.isNaN(parsed) ? Number.NEGATIVE_INFINITY : parsed;
|
||||
}
|
||||
|
||||
function formatCompactNumber(value: number) {
|
||||
return new Intl.NumberFormat('en', {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function formatPercent(value: number) {
|
||||
return `${value.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function getStatusClassName(status: Campaign['status']) {
|
||||
switch (status) {
|
||||
case 'Sent':
|
||||
return 'campaign-status-pill is-sent';
|
||||
case 'Scheduled':
|
||||
return 'campaign-status-pill is-scheduled';
|
||||
case 'Draft':
|
||||
return 'campaign-status-pill is-draft';
|
||||
case 'Failed':
|
||||
return 'campaign-status-pill is-failed';
|
||||
}
|
||||
}
|
||||
|
||||
export function CampaignsManagementBoard({ campaigns, metrics }: Props) {
|
||||
const router = useRouter();
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('All Campaigns');
|
||||
const [sortBy, setSortBy] = useState<SortOption>('newest');
|
||||
const [search, setSearch] = useState('');
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState('');
|
||||
const [createForm, setCreateForm] = useState({
|
||||
name: '',
|
||||
audienceLabel: '',
|
||||
audienceGroup: '',
|
||||
totalRecipients: '1000',
|
||||
status: 'Draft',
|
||||
scheduledAt: '',
|
||||
templateName: '',
|
||||
language: 'English (US)',
|
||||
messageTitle: '',
|
||||
messageBody: '',
|
||||
primaryButton: '',
|
||||
secondaryButton: '',
|
||||
bannerImageUrl: '',
|
||||
});
|
||||
|
||||
const filteredCampaigns = useMemo(() => {
|
||||
const loweredSearch = search.trim().toLowerCase();
|
||||
|
||||
const nextRows = campaigns.filter((campaign) => {
|
||||
const matchesStatus = statusFilter === 'All Campaigns' || campaign.status === statusFilter;
|
||||
const matchesSearch =
|
||||
loweredSearch.length === 0 ||
|
||||
campaign.name.toLowerCase().includes(loweredSearch) ||
|
||||
campaign.code.toLowerCase().includes(loweredSearch) ||
|
||||
campaign.audience.toLowerCase().includes(loweredSearch);
|
||||
|
||||
return matchesStatus && matchesSearch;
|
||||
});
|
||||
|
||||
nextRows.sort((left, right) => {
|
||||
if (sortBy === 'delivery') {
|
||||
return (right.deliveryRate ?? -1) - (left.deliveryRate ?? -1);
|
||||
}
|
||||
|
||||
const leftDate = parseCampaignDate(left);
|
||||
const rightDate = parseCampaignDate(right);
|
||||
|
||||
return sortBy === 'newest' ? rightDate - leftDate : leftDate - rightDate;
|
||||
});
|
||||
|
||||
return nextRows;
|
||||
}, [campaigns, search, sortBy, statusFilter]);
|
||||
|
||||
const computedMetrics = useMemo(() => {
|
||||
const totalSent = campaigns.reduce((sum, campaign) => sum + Number(campaign.sent.replace(/,/g, '')), 0);
|
||||
const deliveryRates = campaigns
|
||||
.map((campaign) => campaign.deliveryRate)
|
||||
.filter((rate): rate is number => rate !== null && rate > 0);
|
||||
const averageDeliveryRate = deliveryRates.length
|
||||
? deliveryRates.reduce((sum, rate) => sum + rate, 0) / deliveryRates.length
|
||||
: 0;
|
||||
const scheduledCount = campaigns.filter((campaign) => campaign.status === 'Scheduled').length;
|
||||
const failedDeliveries = campaigns.reduce((sum, campaign) => {
|
||||
if (campaign.deliveryRate === null) {
|
||||
return sum;
|
||||
}
|
||||
|
||||
const sent = Number(campaign.sent.replace(/,/g, ''));
|
||||
const failed = Math.round(sent * (1 - campaign.deliveryRate / 100));
|
||||
return sum + Math.max(failed, 0);
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
totalSent,
|
||||
averageDeliveryRate,
|
||||
scheduledCount,
|
||||
failedDeliveries,
|
||||
};
|
||||
}, [campaigns]);
|
||||
|
||||
const resolvedMetrics = {
|
||||
totalMessages: metrics?.totalMessages ?? computedMetrics.totalSent,
|
||||
averageDeliveryRate: metrics?.averageDeliveryRate ?? computedMetrics.averageDeliveryRate,
|
||||
scheduledCount: metrics?.scheduledCount ?? computedMetrics.scheduledCount,
|
||||
failedDeliveries: metrics?.failedDeliveries ?? computedMetrics.failedDeliveries,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="campaigns-page">
|
||||
<section className="campaigns-header">
|
||||
<div>
|
||||
<h1 className="campaigns-title">Campaign Management</h1>
|
||||
<p className="campaigns-copy">Design, schedule, and track your WhatsApp broadcast performance.</p>
|
||||
</div>
|
||||
<button type="button" className="campaigns-primary-button" onClick={() => setIsCreateOpen(true)}>
|
||||
<span className="material-symbols-outlined">campaign</span>
|
||||
New Campaign
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{isCreateOpen ? (
|
||||
<section className="campaigns-create-modal-backdrop" onClick={() => !isCreating && setIsCreateOpen(false)}>
|
||||
<div className="campaigns-create-modal" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="campaigns-create-modal-head">
|
||||
<div>
|
||||
<p>Create Campaign</p>
|
||||
<h2>Launch a new WhatsApp broadcast</h2>
|
||||
</div>
|
||||
<button type="button" onClick={() => !isCreating && setIsCreateOpen(false)}>
|
||||
<span className="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<form
|
||||
className="campaigns-create-form"
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
setIsCreating(true);
|
||||
setCreateError('');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/campaigns', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...createForm,
|
||||
totalRecipients: Number(createForm.totalRecipients),
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(typeof payload?.message === 'string' ? payload.message : 'Failed to create campaign');
|
||||
}
|
||||
|
||||
setIsCreateOpen(false);
|
||||
router.push(`/dashboard/campaigns/${payload.id}`);
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
setCreateError(error instanceof Error ? error.message : 'Failed to create campaign');
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
<span>Campaign Name</span>
|
||||
<input
|
||||
value={createForm.name}
|
||||
onChange={(event) => setCreateForm((current) => ({ ...current, name: event.target.value }))}
|
||||
placeholder="Summer Launch Blast"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Audience Label</span>
|
||||
<input
|
||||
value={createForm.audienceLabel}
|
||||
onChange={(event) => setCreateForm((current) => ({ ...current, audienceLabel: event.target.value }))}
|
||||
placeholder="VIP Customer List"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Audience Group</span>
|
||||
<input
|
||||
value={createForm.audienceGroup}
|
||||
onChange={(event) => setCreateForm((current) => ({ ...current, audienceGroup: event.target.value }))}
|
||||
placeholder="High-value segment"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Total Recipients</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={createForm.totalRecipients}
|
||||
onChange={(event) => setCreateForm((current) => ({ ...current, totalRecipients: event.target.value }))}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Status</span>
|
||||
<select
|
||||
value={createForm.status}
|
||||
onChange={(event) => setCreateForm((current) => ({ ...current, status: event.target.value }))}
|
||||
>
|
||||
<option value="Draft">Draft</option>
|
||||
<option value="Scheduled">Scheduled</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Scheduled At</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={createForm.scheduledAt}
|
||||
onChange={(event) => setCreateForm((current) => ({ ...current, scheduledAt: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Template Name</span>
|
||||
<input
|
||||
value={createForm.templateName}
|
||||
onChange={(event) => setCreateForm((current) => ({ ...current, templateName: event.target.value }))}
|
||||
placeholder="summer_promo_v3"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Language</span>
|
||||
<input
|
||||
value={createForm.language}
|
||||
onChange={(event) => setCreateForm((current) => ({ ...current, language: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="is-wide">
|
||||
<span>Message Title</span>
|
||||
<input
|
||||
value={createForm.messageTitle}
|
||||
onChange={(event) => setCreateForm((current) => ({ ...current, messageTitle: event.target.value }))}
|
||||
placeholder="Hi {{name}}, your private offer is ready"
|
||||
/>
|
||||
</label>
|
||||
<label className="is-wide">
|
||||
<span>Message Body</span>
|
||||
<textarea
|
||||
value={createForm.messageBody}
|
||||
onChange={(event) => setCreateForm((current) => ({ ...current, messageBody: event.target.value }))}
|
||||
placeholder="Write the campaign body that will appear in the WhatsApp template preview."
|
||||
rows={4}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Primary Button</span>
|
||||
<input
|
||||
value={createForm.primaryButton}
|
||||
onChange={(event) => setCreateForm((current) => ({ ...current, primaryButton: event.target.value }))}
|
||||
placeholder="Shop Now"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Secondary Button</span>
|
||||
<input
|
||||
value={createForm.secondaryButton}
|
||||
onChange={(event) => setCreateForm((current) => ({ ...current, secondaryButton: event.target.value }))}
|
||||
placeholder="View Catalog"
|
||||
/>
|
||||
</label>
|
||||
<label className="is-wide">
|
||||
<span>Banner Image URL</span>
|
||||
<input
|
||||
value={createForm.bannerImageUrl}
|
||||
onChange={(event) => setCreateForm((current) => ({ ...current, bannerImageUrl: event.target.value }))}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</label>
|
||||
|
||||
{createError ? <p className="campaigns-create-error">{createError}</p> : null}
|
||||
|
||||
<div className="campaigns-create-actions">
|
||||
<button type="button" className="campaigns-create-cancel" onClick={() => setIsCreateOpen(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="campaigns-primary-button" disabled={isCreating}>
|
||||
<span className="material-symbols-outlined">campaign</span>
|
||||
{isCreating ? 'Creating...' : 'Create Campaign'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="campaigns-stats-grid">
|
||||
<article className="campaigns-stat-card">
|
||||
<div className="campaigns-stat-head">
|
||||
<span className="material-symbols-outlined">send</span>
|
||||
<span className="campaigns-stat-delta is-positive">
|
||||
<span className="material-symbols-outlined">trending_up</span>
|
||||
12%
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p>Total Messages</p>
|
||||
<strong>{formatCompactNumber(resolvedMetrics.totalMessages)}</strong>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="campaigns-stat-card">
|
||||
<div className="campaigns-stat-head">
|
||||
<span className="material-symbols-outlined is-secondary">done_all</span>
|
||||
<span className="campaigns-stat-delta is-positive">
|
||||
<span className="material-symbols-outlined">trending_up</span>
|
||||
8.4%
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p>Delivery Rate</p>
|
||||
<strong className="is-secondary">{formatPercent(resolvedMetrics.averageDeliveryRate)}</strong>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="campaigns-stat-card">
|
||||
<div className="campaigns-stat-head">
|
||||
<span className="material-symbols-outlined is-warning">schedule</span>
|
||||
</div>
|
||||
<div>
|
||||
<p>Scheduled</p>
|
||||
<strong>{resolvedMetrics.scheduledCount}</strong>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="campaigns-stat-card">
|
||||
<div className="campaigns-stat-head">
|
||||
<span className="material-symbols-outlined is-error">error_outline</span>
|
||||
</div>
|
||||
<div>
|
||||
<p>Failed Delivery</p>
|
||||
<strong>{resolvedMetrics.failedDeliveries}</strong>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="campaigns-table-card">
|
||||
<div className="campaigns-table-toolbar">
|
||||
<div className="campaigns-filter-tabs">
|
||||
{STATUS_FILTERS.map((filter) => (
|
||||
<button
|
||||
key={filter}
|
||||
type="button"
|
||||
className={filter === statusFilter ? 'campaigns-filter-pill is-active' : 'campaigns-filter-pill'}
|
||||
onClick={() => setStatusFilter(filter)}
|
||||
>
|
||||
{filter}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="campaigns-table-controls">
|
||||
<label className="campaigns-search-field">
|
||||
<span className="material-symbols-outlined">search</span>
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Search campaigns..."
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="campaigns-sort-field">
|
||||
<span className="material-symbols-outlined">filter_list</span>
|
||||
<select value={sortBy} onChange={(event) => setSortBy(event.target.value as SortOption)}>
|
||||
<option value="newest">Sort by: Newest</option>
|
||||
<option value="oldest">Sort by: Oldest</option>
|
||||
<option value="delivery">Sort by: Best Delivery</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="campaigns-table-wrap">
|
||||
<table className="campaigns-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Campaign Name</th>
|
||||
<th>Audience</th>
|
||||
<th>Date</th>
|
||||
<th>Status</th>
|
||||
<th>Delivery Rate</th>
|
||||
<th className="is-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredCampaigns.map((campaign) => (
|
||||
<tr key={campaign.id}>
|
||||
<td>
|
||||
<Link href={`/dashboard/campaigns/${campaign.id}`} className="campaigns-name-link">
|
||||
<span>{campaign.name}</span>
|
||||
<small>ID: {campaign.code}</small>
|
||||
</Link>
|
||||
</td>
|
||||
<td>
|
||||
<div className="campaigns-audience-cell">
|
||||
<span className="material-symbols-outlined">group</span>
|
||||
<div>
|
||||
<span>{campaign.audience}</span>
|
||||
<small>{campaign.audienceGroup}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className={campaign.status === 'Scheduled' ? 'campaigns-date-cell is-scheduled' : 'campaigns-date-cell'}>
|
||||
<span>{campaign.dateLabel}</span>
|
||||
{campaign.timeLabel ? <small>{campaign.timeLabel}</small> : null}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className={getStatusClassName(campaign.status)}>
|
||||
{campaign.status === 'Failed' ? (
|
||||
<span className="material-symbols-outlined">error</span>
|
||||
) : (
|
||||
<span className="campaigns-status-dot" />
|
||||
)}
|
||||
{campaign.status}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{campaign.deliveryRate === null ? (
|
||||
<span className="campaigns-pending-text">Pending send...</span>
|
||||
) : (
|
||||
<div className="campaigns-delivery-cell">
|
||||
<div className="campaigns-progress-track">
|
||||
<span
|
||||
className={campaign.status === 'Failed' ? 'campaigns-progress-bar is-failed' : 'campaigns-progress-bar'}
|
||||
style={{ width: `${Math.max(0, Math.min(campaign.deliveryRate, 100))}%` }}
|
||||
/>
|
||||
</div>
|
||||
<strong className={campaign.status === 'Failed' ? 'is-error' : undefined}>
|
||||
{formatPercent(campaign.deliveryRate)}
|
||||
</strong>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="is-right">
|
||||
{campaign.status === 'Failed' ? (
|
||||
<button type="button" className="campaigns-action-button is-error" aria-label={`Retry ${campaign.name}`}>
|
||||
<span className="material-symbols-outlined">refresh</span>
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="campaigns-action-button" aria-label={`Actions for ${campaign.name}`}>
|
||||
<span className="material-symbols-outlined">more_vert</span>
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="campaigns-pagination">
|
||||
<p>
|
||||
Showing <strong>{filteredCampaigns.length === 0 ? 0 : 1}-{filteredCampaigns.length}</strong> of <strong>{campaigns.length}</strong> campaigns
|
||||
</p>
|
||||
<div className="campaigns-pagination-buttons">
|
||||
<button type="button" disabled aria-label="Previous page">
|
||||
<span className="material-symbols-outlined">chevron_left</span>
|
||||
</button>
|
||||
<button type="button" className="is-active">1</button>
|
||||
<button type="button">2</button>
|
||||
<button type="button">3</button>
|
||||
<span>...</span>
|
||||
<button type="button">25</button>
|
||||
<button type="button" aria-label="Next page">
|
||||
<span className="material-symbols-outlined">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="campaigns-insight-grid">
|
||||
<article className="campaigns-insight-card">
|
||||
<div className="campaigns-insight-glow" />
|
||||
<div className="campaigns-insight-body">
|
||||
<h2>Campaign Performance Optimization</h2>
|
||||
<p>
|
||||
Based on your recent broadcasts, campaigns sent between <strong>09:00 AM - 11:00 AM</strong> on Tuesdays
|
||||
have a <strong className="is-success">15% higher</strong> read rate. Consider scheduling your next
|
||||
template for this window.
|
||||
</p>
|
||||
<button type="button">View Details</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="campaigns-health-card">
|
||||
<div className="campaigns-health-head">
|
||||
<h2>Template Health</h2>
|
||||
<span className="material-symbols-outlined">verified</span>
|
||||
</div>
|
||||
<strong>94%</strong>
|
||||
<p>High quality rating across all approved templates.</p>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
320
frontend/src/components/contact-detail-board.tsx
Normal file
320
frontend/src/components/contact-detail-board.tsx
Normal file
@ -0,0 +1,320 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
type Props = {
|
||||
contact: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
phoneNumber: string;
|
||||
company: string | null;
|
||||
status: 'Active' | 'Inactive';
|
||||
tags: string[];
|
||||
location: string;
|
||||
lastSeenLabel: string;
|
||||
isBlacklisted: boolean;
|
||||
avatarInitials: string;
|
||||
notes: Array<{
|
||||
id: string;
|
||||
author: string;
|
||||
dateLabel: string;
|
||||
body: string;
|
||||
emphasized: boolean;
|
||||
}>;
|
||||
history: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
at: string;
|
||||
summary: string;
|
||||
status: string;
|
||||
errorReason: string | null;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
function formatTimelineDate(value: string) {
|
||||
const date = new Date(value);
|
||||
return date.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
}
|
||||
|
||||
export function ContactDetailBoard({ contact }: Props) {
|
||||
const router = useRouter();
|
||||
const [tab, setTab] = useState<'all' | 'messages' | 'system'>('all');
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [form, setForm] = useState({
|
||||
name: contact.name,
|
||||
phoneNumber: contact.phoneNumber,
|
||||
email: contact.email || '',
|
||||
company: contact.company || '',
|
||||
notes: contact.notes.map((note) => note.body).join('\n'),
|
||||
isBlacklisted: contact.isBlacklisted,
|
||||
});
|
||||
|
||||
const filteredHistory = useMemo(() => {
|
||||
if (tab === 'all') return contact.history;
|
||||
if (tab === 'messages') return contact.history.filter((item) => item.type === 'message' || item.type === 'inbound');
|
||||
return contact.history.filter((item) => item.type !== 'message' && item.type !== 'inbound');
|
||||
}, [contact.history, tab]);
|
||||
|
||||
return (
|
||||
<div className="contact-detail-page">
|
||||
<nav className="contact-detail-breadcrumb">
|
||||
<Link href="/dashboard/contacts">Contacts</Link>
|
||||
<span className="material-symbols-outlined">chevron_right</span>
|
||||
<span>{contact.name}</span>
|
||||
</nav>
|
||||
|
||||
<section className="contact-detail-hero">
|
||||
<div className="contact-detail-hero-main">
|
||||
<div className="contact-detail-hero-avatar">
|
||||
<span>{contact.avatarInitials}</span>
|
||||
<i />
|
||||
</div>
|
||||
<div>
|
||||
<div className="contact-detail-hero-title">
|
||||
<h1>{contact.name}</h1>
|
||||
<span>ID: {contact.id.slice(0, 6).toUpperCase()}</span>
|
||||
</div>
|
||||
<p>{contact.status} Customer • Last seen {contact.lastSeenLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="contact-detail-hero-actions">
|
||||
<button type="button" className="contacts-secondary-button" onClick={() => setIsEditing(true)}>
|
||||
<span className="material-symbols-outlined">edit</span>
|
||||
Edit Contact
|
||||
</button>
|
||||
<Link href="/dashboard/conversations" className="contacts-primary-button">
|
||||
<span className="material-symbols-outlined">chat</span>
|
||||
Message
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="contact-detail-danger-icon"
|
||||
onClick={async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const response = await fetch(`/api/contacts/${contact.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ isBlacklisted: !form.isBlacklisted }),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(typeof payload?.message === 'string' ? payload.message : 'Failed to update contact');
|
||||
}
|
||||
router.refresh();
|
||||
} catch (actionError) {
|
||||
setError(actionError instanceof Error ? actionError.message : 'Failed to update contact');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="material-symbols-outlined">block</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error ? <p className="contacts-form-error">{error}</p> : null}
|
||||
|
||||
<section className="contact-detail-grid">
|
||||
<aside className="contact-detail-sidebar">
|
||||
<article className="contact-detail-card">
|
||||
<h2><span className="material-symbols-outlined">info</span>Contact Information</h2>
|
||||
<div className="contact-detail-info-list">
|
||||
<div>
|
||||
<label>Phone Number</label>
|
||||
<div className="contact-detail-info-row">
|
||||
<strong>{contact.phoneNumber}</strong>
|
||||
<button type="button" className="contacts-icon-button" onClick={() => navigator.clipboard.writeText(contact.phoneNumber)}>
|
||||
<span className="material-symbols-outlined">content_copy</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label>Email Address</label>
|
||||
<strong>{contact.email || 'No email on file'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<label>Location</label>
|
||||
<strong>{contact.location}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<label>Tags</label>
|
||||
<div className="contacts-tags-inline is-wrap">
|
||||
{contact.tags.map((tag) => (
|
||||
<span key={tag} className="contacts-tag-chip">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="contact-detail-card">
|
||||
<div className="contact-detail-card-head">
|
||||
<h2><span className="material-symbols-outlined">sticky_note_2</span>Internal Notes</h2>
|
||||
<button type="button" onClick={() => setIsEditing(true)}>Add Note</button>
|
||||
</div>
|
||||
<div className="contact-detail-notes">
|
||||
{contact.notes.map((note) => (
|
||||
<div key={note.id} className={note.emphasized ? 'contact-note-card is-emphasized' : 'contact-note-card'}>
|
||||
<p>{note.body}</p>
|
||||
<div>
|
||||
<span>- {note.author}</span>
|
||||
<span>{note.dateLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
</aside>
|
||||
|
||||
<section className="contact-detail-history-card">
|
||||
<div className="contact-detail-card-head">
|
||||
<h2><span className="material-symbols-outlined">history</span>Interaction History</h2>
|
||||
<div className="contact-detail-tabs">
|
||||
<button type="button" className={tab === 'all' ? 'is-active' : ''} onClick={() => setTab('all')}>All Activity</button>
|
||||
<button type="button" className={tab === 'messages' ? 'is-active' : ''} onClick={() => setTab('messages')}>Messages</button>
|
||||
<button type="button" className={tab === 'system' ? 'is-active' : ''} onClick={() => setTab('system')}>System</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="contact-history-timeline">
|
||||
{filteredHistory.map((item) => (
|
||||
<div key={item.id} className="contact-history-item">
|
||||
<div className="contact-history-icon">
|
||||
<span className="material-symbols-outlined">
|
||||
{item.type === 'message' ? 'chat' : item.type === 'inbound' ? 'call_received' : item.type === 'tag' ? 'sell' : 'person_pin'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="contact-history-body">
|
||||
<div className="contact-history-title">
|
||||
<strong>{item.title}</strong>
|
||||
<span>• {formatTimelineDate(item.at)}</span>
|
||||
</div>
|
||||
<p>{item.summary}</p>
|
||||
{item.errorReason ? <small>{item.errorReason}</small> : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
{isEditing ? (
|
||||
<div className="contacts-modal-backdrop" onClick={() => setIsEditing(false)}>
|
||||
<div className="contacts-modal-card" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="contacts-modal-head">
|
||||
<div>
|
||||
<p>Edit Contact</p>
|
||||
<h2>Update contact profile</h2>
|
||||
</div>
|
||||
<button type="button" className="contacts-icon-button" onClick={() => setIsEditing(false)}>
|
||||
<span className="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="contacts-form-grid"
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
setError(null);
|
||||
setIsSaving(true);
|
||||
const response = await fetch(`/api/contacts/${contact.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(typeof payload?.message === 'string' ? payload.message : 'Failed to update contact');
|
||||
}
|
||||
setIsEditing(false);
|
||||
router.refresh();
|
||||
} catch (submissionError) {
|
||||
setError(submissionError instanceof Error ? submissionError.message : 'Failed to update contact');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label className="contacts-form-field">
|
||||
<span>Full Name</span>
|
||||
<input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} />
|
||||
</label>
|
||||
<label className="contacts-form-field">
|
||||
<span>Phone Number</span>
|
||||
<input value={form.phoneNumber} onChange={(event) => setForm((current) => ({ ...current, phoneNumber: event.target.value }))} />
|
||||
</label>
|
||||
<label className="contacts-form-field">
|
||||
<span>Email Address</span>
|
||||
<input value={form.email} onChange={(event) => setForm((current) => ({ ...current, email: event.target.value }))} />
|
||||
</label>
|
||||
<label className="contacts-form-field">
|
||||
<span>Company</span>
|
||||
<input value={form.company} onChange={(event) => setForm((current) => ({ ...current, company: event.target.value }))} />
|
||||
</label>
|
||||
<label className="contacts-form-field is-full">
|
||||
<span>Notes</span>
|
||||
<textarea rows={5} value={form.notes} onChange={(event) => setForm((current) => ({ ...current, notes: event.target.value }))} />
|
||||
</label>
|
||||
<label className="contacts-checkbox-row is-full">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.isBlacklisted}
|
||||
onChange={(event) => setForm((current) => ({ ...current, isBlacklisted: event.target.checked }))}
|
||||
/>
|
||||
<span>Mark this contact as inactive / blocked</span>
|
||||
</label>
|
||||
<div className="contacts-form-actions">
|
||||
<button type="button" className="contacts-secondary-button" onClick={() => setIsEditing(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" className="contact-detail-delete-button" disabled={isDeleting} onClick={async () => {
|
||||
if (!window.confirm(`Delete contact "${contact.name}"?`)) return;
|
||||
try {
|
||||
setError(null);
|
||||
setIsDeleting(true);
|
||||
const response = await fetch(`/api/contacts/${contact.id}`, { method: 'DELETE' });
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(typeof payload?.message === 'string' ? payload.message : 'Failed to delete contact');
|
||||
}
|
||||
router.push('/dashboard/contacts');
|
||||
router.refresh();
|
||||
} catch (deleteError) {
|
||||
setError(deleteError instanceof Error ? deleteError.message : 'Failed to delete contact');
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}}>
|
||||
<span className="material-symbols-outlined">delete</span>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
<button type="submit" className="contacts-primary-button" disabled={isSaving}>
|
||||
<span className="material-symbols-outlined">save</span>
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
frontend/src/components/contact-form.tsx
Normal file
49
frontend/src/components/contact-form.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState, useEffect, useRef } from 'react';
|
||||
import { createContactAction } from '../app/actions';
|
||||
|
||||
type Props = {
|
||||
labels: {
|
||||
name: string;
|
||||
phoneNumber: string;
|
||||
email: string;
|
||||
company: string;
|
||||
notes: string;
|
||||
submit: string;
|
||||
success: string;
|
||||
};
|
||||
};
|
||||
|
||||
type ContactFormState = {
|
||||
error?: string;
|
||||
success?: string;
|
||||
};
|
||||
|
||||
const initialState: ContactFormState = {};
|
||||
|
||||
export function ContactForm({ labels }: Props) {
|
||||
const [state, formAction, pending] = useActionState(createContactAction, initialState);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.success) {
|
||||
formRef.current?.reset();
|
||||
}
|
||||
}, [state.success]);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="stack-form card" ref={formRef}>
|
||||
<input name="name" placeholder={labels.name} required />
|
||||
<input name="phoneNumber" placeholder={labels.phoneNumber} required />
|
||||
<input name="email" placeholder={labels.email} type="email" />
|
||||
<input name="company" placeholder={labels.company} />
|
||||
<textarea name="notes" placeholder={labels.notes} rows={4} />
|
||||
<button type="submit" disabled={pending}>
|
||||
{pending ? '...' : labels.submit}
|
||||
</button>
|
||||
{state.error ? <p className="form-error">{state.error}</p> : null}
|
||||
{state.success ? <p className="form-success">{labels.success}</p> : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
409
frontend/src/components/contacts-directory-board.tsx
Normal file
409
frontend/src/components/contacts-directory-board.tsx
Normal file
@ -0,0 +1,409 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
|
||||
type ContactRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
phoneNumber: string;
|
||||
company: string | null;
|
||||
status: 'Active' | 'Inactive';
|
||||
tags: string[];
|
||||
location: string;
|
||||
lastMessageLabel: string;
|
||||
avatarInitials: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
data: {
|
||||
items: ContactRow[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
availableTags: string[];
|
||||
statusCounts: {
|
||||
active: number;
|
||||
inactive: number;
|
||||
};
|
||||
};
|
||||
filters: {
|
||||
search: string;
|
||||
status: string;
|
||||
tag: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function ContactsDirectoryBoard({ data, filters }: Props) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
phoneNumber: '',
|
||||
email: '',
|
||||
company: '',
|
||||
notes: '',
|
||||
});
|
||||
|
||||
const pageNumbers = useMemo(() => {
|
||||
const pages = new Set<number>();
|
||||
pages.add(1);
|
||||
pages.add(data.totalPages);
|
||||
pages.add(data.page);
|
||||
pages.add(Math.max(1, data.page - 1));
|
||||
pages.add(Math.min(data.totalPages, data.page + 1));
|
||||
return Array.from(pages).sort((left, right) => left - right);
|
||||
}, [data.page, data.totalPages]);
|
||||
|
||||
function updateQuery(updates: Record<string, string | number | null>) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (value === null || value === '' || value === 'all') {
|
||||
params.delete(key);
|
||||
} else {
|
||||
params.set(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updates).some((key) => key !== 'page')) {
|
||||
params.set('page', '1');
|
||||
}
|
||||
|
||||
router.push(`/dashboard/contacts${params.toString() ? `?${params.toString()}` : ''}`);
|
||||
}
|
||||
|
||||
async function importCsv(file: File) {
|
||||
const text = await file.text();
|
||||
const rows = text
|
||||
.split(/\r?\n/)
|
||||
.map((row) => row.trim())
|
||||
.filter(Boolean);
|
||||
if (rows.length < 2) {
|
||||
throw new Error('CSV file is empty.');
|
||||
}
|
||||
|
||||
const headers = rows[0].split(',').map((value) => value.trim().toLowerCase());
|
||||
const items = rows.slice(1).map((row) => {
|
||||
const cols = row.split(',').map((value) => value.trim().replace(/^"|"$/g, ''));
|
||||
const get = (key: string) => cols[headers.indexOf(key)] || '';
|
||||
return {
|
||||
name: get('name'),
|
||||
phoneNumber: get('phonenumber') || get('phone_number') || get('phone'),
|
||||
email: get('email'),
|
||||
company: get('company'),
|
||||
notes: get('notes'),
|
||||
};
|
||||
}).filter((item) => item.name && item.phoneNumber);
|
||||
|
||||
if (items.length === 0) {
|
||||
throw new Error('CSV does not contain valid contact rows.');
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
const response = await fetch('/api/contacts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(item),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
throw new Error(typeof payload?.message === 'string' ? payload.message : `Failed to import contact ${item.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
return items.length;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="contacts-directory-page">
|
||||
<section className="contacts-directory-header">
|
||||
<div>
|
||||
<h1>Contacts Directory</h1>
|
||||
<p>Manage your customer database and communication segments.</p>
|
||||
</div>
|
||||
<div className="contacts-directory-actions">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
hidden
|
||||
onChange={async (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
setIsSubmitting(true);
|
||||
const imported = await importCsv(file);
|
||||
setNotice(`Imported ${imported} contacts successfully.`);
|
||||
router.refresh();
|
||||
} catch (importError) {
|
||||
setError(importError instanceof Error ? importError.message : 'Failed to import contacts');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
event.target.value = '';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button type="button" className="contacts-secondary-button" onClick={() => fileInputRef.current?.click()}>
|
||||
<span className="material-symbols-outlined">upload</span>
|
||||
Import
|
||||
</button>
|
||||
<a
|
||||
className="contacts-secondary-button"
|
||||
href={`/api/contacts/export?${new URLSearchParams({
|
||||
...(filters.search ? { search: filters.search } : {}),
|
||||
...(filters.status ? { status: filters.status } : {}),
|
||||
...(filters.tag ? { tag: filters.tag } : {}),
|
||||
}).toString()}`}
|
||||
>
|
||||
<span className="material-symbols-outlined">download</span>
|
||||
Export
|
||||
</a>
|
||||
<button type="button" className="contacts-primary-button" onClick={() => setIsCreateOpen(true)}>
|
||||
<span className="material-symbols-outlined">person_add</span>
|
||||
Add Contact
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{notice ? <p className="contacts-form-success">{notice}</p> : null}
|
||||
{error && !isCreateOpen ? <p className="contacts-form-error">{error}</p> : null}
|
||||
|
||||
<section className="contacts-filter-bar">
|
||||
<div className="contacts-filter-label">
|
||||
<span className="material-symbols-outlined">filter_list</span>
|
||||
<span>Filters</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
className="contacts-filter-input"
|
||||
value={filters.search}
|
||||
onChange={(event) => updateQuery({ search: event.target.value })}
|
||||
placeholder="Search contacts..."
|
||||
/>
|
||||
|
||||
<select
|
||||
className="contacts-filter-select"
|
||||
value={filters.status || 'all'}
|
||||
onChange={(event) => updateQuery({ status: event.target.value })}
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="Active">Active</option>
|
||||
<option value="Inactive">Inactive</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="contacts-filter-select"
|
||||
value={filters.tag || 'all'}
|
||||
onChange={(event) => updateQuery({ tag: event.target.value })}
|
||||
>
|
||||
<option value="all">All Tags</option>
|
||||
{data.availableTags.map((tag) => (
|
||||
<option key={tag} value={tag}>
|
||||
{tag}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<button type="button" className="contacts-clear-button" onClick={() => router.push('/dashboard/contacts')}>
|
||||
Clear all filters
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="contacts-table-card">
|
||||
<div className="contacts-table-wrap">
|
||||
<table className="contacts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Contact Name</th>
|
||||
<th>Phone Number</th>
|
||||
<th>Status</th>
|
||||
<th>Tags</th>
|
||||
<th>Last Message</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((contact) => (
|
||||
<tr key={contact.id}>
|
||||
<td>
|
||||
<Link href={`/dashboard/contacts/${contact.id}`} className="contacts-contact-cell">
|
||||
<span className="contacts-avatar">{contact.avatarInitials}</span>
|
||||
<span>
|
||||
<strong>{contact.name}</strong>
|
||||
<small>{contact.email || contact.location}</small>
|
||||
</span>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="contacts-mono-cell">{contact.phoneNumber}</td>
|
||||
<td>
|
||||
<span className={contact.status === 'Active' ? 'contacts-status-chip is-active' : 'contacts-status-chip is-inactive'}>
|
||||
{contact.status}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="contacts-tags-inline">
|
||||
{contact.tags.slice(0, 2).map((tag) => (
|
||||
<span key={tag} className="contacts-tag-chip">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td>{contact.lastMessageLabel}</td>
|
||||
<td className="contacts-table-actions">
|
||||
<Link href={`/dashboard/contacts/${contact.id}`} className="contacts-icon-button">
|
||||
<span className="material-symbols-outlined">more_vert</span>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="contacts-table-footer">
|
||||
<div>
|
||||
Showing <strong>{data.total === 0 ? 0 : (data.page - 1) * data.pageSize + 1} - {(data.page - 1) * data.pageSize + data.items.length}</strong> of{' '}
|
||||
<strong>{data.total}</strong> contacts
|
||||
</div>
|
||||
|
||||
<div className="contacts-pagination">
|
||||
<button
|
||||
type="button"
|
||||
className="contacts-page-button"
|
||||
disabled={data.page <= 1}
|
||||
onClick={() => updateQuery({ page: data.page - 1 })}
|
||||
>
|
||||
<span className="material-symbols-outlined">chevron_left</span>
|
||||
</button>
|
||||
{pageNumbers.map((pageNumber, index) => (
|
||||
<span key={pageNumber}>
|
||||
{index > 0 && pageNumber - pageNumbers[index - 1] > 1 ? <span className="contacts-page-gap">...</span> : null}
|
||||
<button
|
||||
type="button"
|
||||
className={pageNumber === data.page ? 'contacts-page-button is-active' : 'contacts-page-button'}
|
||||
onClick={() => updateQuery({ page: pageNumber })}
|
||||
>
|
||||
{pageNumber}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="contacts-page-button"
|
||||
disabled={data.page >= data.totalPages}
|
||||
onClick={() => updateQuery({ page: data.page + 1 })}
|
||||
>
|
||||
<span className="material-symbols-outlined">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="contacts-rows-label">
|
||||
<span>Rows per page:</span>
|
||||
<select
|
||||
value={String(data.pageSize)}
|
||||
onChange={(event) => updateQuery({ limit: event.target.value })}
|
||||
>
|
||||
<option value="10">10</option>
|
||||
<option value="20">20</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button type="button" className="contacts-fab" onClick={() => setIsCreateOpen(true)}>
|
||||
<span className="material-symbols-outlined">add</span>
|
||||
</button>
|
||||
|
||||
{isCreateOpen ? (
|
||||
<div className="contacts-modal-backdrop" onClick={() => setIsCreateOpen(false)}>
|
||||
<div className="contacts-modal-card" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="contacts-modal-head">
|
||||
<div>
|
||||
<p>New Contact</p>
|
||||
<h2>Add a new contact profile</h2>
|
||||
</div>
|
||||
<button type="button" className="contacts-icon-button" onClick={() => setIsCreateOpen(false)}>
|
||||
<span className="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="contacts-form-grid"
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
setError(null);
|
||||
setIsSubmitting(true);
|
||||
const response = await fetch('/api/contacts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(typeof payload?.message === 'string' ? payload.message : 'Failed to create contact');
|
||||
}
|
||||
|
||||
setIsCreateOpen(false);
|
||||
setForm({ name: '', phoneNumber: '', email: '', company: '', notes: '' });
|
||||
router.refresh();
|
||||
} catch (submissionError) {
|
||||
setError(submissionError instanceof Error ? submissionError.message : 'Failed to create contact');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label className="contacts-form-field">
|
||||
<span>Full Name</span>
|
||||
<input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} />
|
||||
</label>
|
||||
<label className="contacts-form-field">
|
||||
<span>Phone Number</span>
|
||||
<input value={form.phoneNumber} onChange={(event) => setForm((current) => ({ ...current, phoneNumber: event.target.value }))} />
|
||||
</label>
|
||||
<label className="contacts-form-field">
|
||||
<span>Email Address</span>
|
||||
<input value={form.email} onChange={(event) => setForm((current) => ({ ...current, email: event.target.value }))} />
|
||||
</label>
|
||||
<label className="contacts-form-field">
|
||||
<span>Company</span>
|
||||
<input value={form.company} onChange={(event) => setForm((current) => ({ ...current, company: event.target.value }))} />
|
||||
</label>
|
||||
<label className="contacts-form-field is-full">
|
||||
<span>Notes</span>
|
||||
<textarea rows={4} value={form.notes} onChange={(event) => setForm((current) => ({ ...current, notes: event.target.value }))} />
|
||||
</label>
|
||||
|
||||
{error ? <p className="contacts-form-error">{error}</p> : null}
|
||||
|
||||
<div className="contacts-form-actions">
|
||||
<button type="button" className="contacts-secondary-button" onClick={() => setIsCreateOpen(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="contacts-primary-button" disabled={isSubmitting}>
|
||||
<span className="material-symbols-outlined">save</span>
|
||||
{isSubmitting ? 'Saving...' : 'Save Contact'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
563
frontend/src/components/conversations-inbox.tsx
Normal file
563
frontend/src/components/conversations-inbox.tsx
Normal file
@ -0,0 +1,563 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
type FilterMode = 'All' | 'Active' | 'Pending';
|
||||
type ComposerMode = 'quick-replies' | 'templates';
|
||||
type ConversationSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
initials: string;
|
||||
time: string;
|
||||
status: string;
|
||||
tone: string;
|
||||
topic: string;
|
||||
snippet: string;
|
||||
online: boolean;
|
||||
location: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
customerSince: string;
|
||||
tags: string[];
|
||||
lastActivityAt: string;
|
||||
unreadCount: number;
|
||||
assignedAgentName: string | null;
|
||||
};
|
||||
type ConversationDetail = ConversationSummary & {
|
||||
activity: Array<{
|
||||
title: string;
|
||||
meta: string;
|
||||
tone: string;
|
||||
}>;
|
||||
messages: Array<{
|
||||
id: string;
|
||||
direction: 'incoming' | 'outgoing';
|
||||
body: string;
|
||||
time: string;
|
||||
status?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
const quickReplies = [
|
||||
'Checking this for you now.',
|
||||
'Could you share your order ID?',
|
||||
'I have escalated this to our support team.',
|
||||
] as const;
|
||||
|
||||
const templateReplies = [
|
||||
{
|
||||
label: 'Activation Follow-up',
|
||||
body: 'Your activation request is being processed. We will share the update shortly.',
|
||||
},
|
||||
{
|
||||
label: 'Shipping Update',
|
||||
body: 'Your shipment is in transit and the tracking page will refresh within a few minutes.',
|
||||
},
|
||||
{
|
||||
label: 'Discount Clarification',
|
||||
body: 'The enterprise discount is still available for qualifying annual plans.',
|
||||
},
|
||||
] as const;
|
||||
|
||||
function conversationPillClassName(tone: string) {
|
||||
if (tone === 'warning') return 'conversation-list-pill is-warning';
|
||||
if (tone === 'success') return 'conversation-list-pill is-success';
|
||||
return 'conversation-list-pill is-info';
|
||||
}
|
||||
|
||||
function getFallbackMessage(name: string) {
|
||||
return {
|
||||
id: `fallback-${name}`,
|
||||
direction: 'incoming' as const,
|
||||
body: `No active thread loaded for ${name} yet.`,
|
||||
time: '',
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeFilter(filter: FilterMode) {
|
||||
if (filter === 'Pending') return 'pending';
|
||||
if (filter === 'Active') return 'active';
|
||||
return 'all';
|
||||
}
|
||||
|
||||
export function ConversationsInbox({
|
||||
initialConversations,
|
||||
initialConversationDetail,
|
||||
initialSearch,
|
||||
}: {
|
||||
initialConversations: ConversationSummary[];
|
||||
initialConversationDetail: ConversationDetail | null;
|
||||
initialSearch: string;
|
||||
}) {
|
||||
const [items, setItems] = useState<ConversationSummary[]>(initialConversations);
|
||||
const [filter, setFilter] = useState<FilterMode>('All');
|
||||
const [activeConversationId, setActiveConversationId] = useState<string>(initialConversationDetail?.id ?? initialConversations[0]?.id ?? '');
|
||||
const [composerMode, setComposerMode] = useState<ComposerMode>('templates');
|
||||
const [composerText, setComposerText] = useState('');
|
||||
const [searchTerm] = useState(initialSearch);
|
||||
const [activeConversation, setActiveConversation] = useState<ConversationDetail | null>(initialConversationDetail);
|
||||
const [isThreadLoading, setIsThreadLoading] = useState(false);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [isAssigning, setIsAssigning] = useState(false);
|
||||
|
||||
const filteredConversations = useMemo(
|
||||
() =>
|
||||
items.filter((conversation) =>
|
||||
filter === 'All'
|
||||
? true
|
||||
: filter === 'Pending'
|
||||
? conversation.status === 'PENDING'
|
||||
: conversation.status === 'ACTIVE' || conversation.status === 'NEW',
|
||||
),
|
||||
[filter, items],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
const timeout = window.setTimeout(async () => {
|
||||
try {
|
||||
const params = new URLSearchParams({ filter: normalizeFilter(filter) });
|
||||
if (searchTerm) params.set('search', searchTerm);
|
||||
const response = await fetch(`/api/conversations?${params.toString()}`, {
|
||||
signal: controller.signal,
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as ConversationSummary[];
|
||||
setItems(payload);
|
||||
} catch (error) {
|
||||
if ((error as Error).name !== 'AbortError') {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
}, 150);
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
window.clearTimeout(timeout);
|
||||
};
|
||||
}, [filter, searchTerm]);
|
||||
|
||||
useEffect(() => {
|
||||
const nextActiveId =
|
||||
filteredConversations.find((conversation) => conversation.id === activeConversationId)?.id
|
||||
?? filteredConversations[0]?.id
|
||||
?? '';
|
||||
|
||||
if (nextActiveId && nextActiveId !== activeConversationId) {
|
||||
setActiveConversationId(nextActiveId);
|
||||
}
|
||||
if (!nextActiveId) {
|
||||
setActiveConversationId('');
|
||||
setActiveConversation(null);
|
||||
}
|
||||
}, [activeConversationId, filteredConversations]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeConversationId) {
|
||||
setActiveConversation(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
void (async () => {
|
||||
try {
|
||||
setIsThreadLoading(true);
|
||||
const response = await fetch(`/api/conversations/${activeConversationId}`, {
|
||||
signal: controller.signal,
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as ConversationDetail;
|
||||
setActiveConversation(payload);
|
||||
setItems((current) =>
|
||||
current.map((item) => (item.id === payload.id ? { ...item, ...payload } : item)),
|
||||
);
|
||||
} catch (error) {
|
||||
if ((error as Error).name !== 'AbortError') {
|
||||
console.error(error);
|
||||
}
|
||||
} finally {
|
||||
setIsThreadLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => controller.abort();
|
||||
}, [activeConversationId]);
|
||||
|
||||
function applySuggestion(text: string) {
|
||||
setComposerText(text);
|
||||
}
|
||||
|
||||
async function refreshSummaries(nextActiveId?: string) {
|
||||
const params = new URLSearchParams({ filter: normalizeFilter(filter) });
|
||||
if (searchTerm) params.set('search', searchTerm);
|
||||
const response = await fetch(`/api/conversations?${params.toString()}`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as ConversationSummary[];
|
||||
setItems(payload);
|
||||
if (nextActiveId && !payload.some((item) => item.id === nextActiveId) && payload[0]?.id) {
|
||||
setActiveConversationId(payload[0].id);
|
||||
}
|
||||
}
|
||||
|
||||
async function assignToMe() {
|
||||
if (!activeConversation || isAssigning) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsAssigning(true);
|
||||
const response = await fetch(`/api/conversations/${activeConversation.id}/assign`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
await refreshSummaries(activeConversation.id);
|
||||
const detailResponse = await fetch(`/api/conversations/${activeConversation.id}`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!detailResponse.ok) {
|
||||
return;
|
||||
}
|
||||
const payload = (await detailResponse.json()) as ConversationDetail;
|
||||
setActiveConversation(payload);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsAssigning(false);
|
||||
}
|
||||
}
|
||||
|
||||
function renderOutgoingStatus(status?: string) {
|
||||
if (status === 'failed') {
|
||||
return <span className="material-symbols-outlined">error</span>;
|
||||
}
|
||||
if (status === 'read' || status === 'delivered') {
|
||||
return <span className="material-symbols-outlined">done_all</span>;
|
||||
}
|
||||
return <span className="material-symbols-outlined">done</span>;
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const nextBody = composerText.trim();
|
||||
if (!nextBody || !activeConversation || isSending) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSending(true);
|
||||
const response = await fetch(`/api/conversations/${activeConversation.id}/messages`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ body: nextBody }),
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.message || 'Failed to send message');
|
||||
}
|
||||
|
||||
setActiveConversation((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
snippet: nextBody,
|
||||
time: 'Just now',
|
||||
status: 'ACTIVE',
|
||||
tone: 'success',
|
||||
messages: [...current.messages, payload.message],
|
||||
}
|
||||
: current,
|
||||
);
|
||||
setItems((current) =>
|
||||
current.map((conversation) =>
|
||||
conversation.id === activeConversation.id
|
||||
? {
|
||||
...conversation,
|
||||
snippet: nextBody,
|
||||
time: 'Just now',
|
||||
status: 'ACTIVE',
|
||||
tone: 'success',
|
||||
}
|
||||
: conversation,
|
||||
),
|
||||
);
|
||||
setComposerText('');
|
||||
setFilter('All');
|
||||
await refreshSummaries(activeConversation.id);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="conversations-page">
|
||||
<div className="conversations-layout">
|
||||
<aside className="conversations-sidebar surface-card">
|
||||
<div className="conversations-filter-tabs">
|
||||
{(['All', 'Active', 'Pending'] as const).map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
className={filter === item ? 'is-active' : undefined}
|
||||
onClick={() => setFilter(item)}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="conversations-list custom-scrollbar">
|
||||
{filteredConversations.map((conversation) => {
|
||||
const isActive = conversation.id === activeConversation?.id;
|
||||
|
||||
return (
|
||||
<article
|
||||
key={conversation.id}
|
||||
className={isActive ? 'conversation-list-item is-active' : 'conversation-list-item'}
|
||||
onClick={() => setActiveConversationId(conversation.id)}
|
||||
>
|
||||
{isActive ? <span className="conversation-active-rail" /> : null}
|
||||
<div className="conversation-avatar">{conversation.initials}</div>
|
||||
<div className="conversation-list-main">
|
||||
<div className="conversation-list-topline">
|
||||
<h3>{conversation.name}</h3>
|
||||
<span className={isActive ? 'is-recent' : ''}>{conversation.time}</span>
|
||||
</div>
|
||||
<p>{conversation.snippet}</p>
|
||||
<div className="conversation-list-tags">
|
||||
<span className={conversationPillClassName(conversation.tone)}>{conversation.status}</span>
|
||||
{conversation.topic ? <span className="conversation-list-pill is-muted">{conversation.topic}</span> : null}
|
||||
{conversation.unreadCount > 0 ? <span className="conversation-list-pill is-info">{conversation.unreadCount} unread</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
|
||||
{filteredConversations.length === 0 ? (
|
||||
<div className="conversations-empty-state">
|
||||
<span className="material-symbols-outlined">forum</span>
|
||||
<p>No conversations match this filter.</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="conversations-thread surface-card">
|
||||
<header className="conversations-thread-head">
|
||||
<div className="conversations-thread-contact">
|
||||
<div className="conversation-avatar is-large">{activeConversation?.initials ?? '--'}</div>
|
||||
<div>
|
||||
<h2>{activeConversation?.name ?? 'No conversation selected'}</h2>
|
||||
<div className="conversations-online-row">
|
||||
<span className={`conversations-online-dot ${activeConversation?.online ? 'is-online' : 'is-offline'}`} />
|
||||
<span>{activeConversation?.online ? 'Online' : 'Offline'}</span>
|
||||
{activeConversation?.assignedAgentName ? <span>• {activeConversation.assignedAgentName}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="conversations-thread-actions">
|
||||
<button type="button" aria-label="Start video call">
|
||||
<span className="material-symbols-outlined">videocam</span>
|
||||
</button>
|
||||
<button type="button" aria-label="Start call">
|
||||
<span className="material-symbols-outlined">call</span>
|
||||
</button>
|
||||
<button type="button" aria-label="More actions">
|
||||
<span className="material-symbols-outlined">more_vert</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="conversations-thread-body custom-scrollbar">
|
||||
{isThreadLoading ? (
|
||||
<div className="conversations-empty-state">
|
||||
<span className="material-symbols-outlined">hourglass_top</span>
|
||||
<p>Loading conversation...</p>
|
||||
</div>
|
||||
) : (
|
||||
(activeConversation?.messages.length ? activeConversation.messages : activeConversation ? [getFallbackMessage(activeConversation.name)] : []).map(
|
||||
(message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`conversation-message-wrap ${message.direction === 'outgoing' ? 'is-outgoing' : 'is-incoming'}`}
|
||||
>
|
||||
<div className={`conversation-bubble ${message.direction === 'outgoing' ? 'is-outgoing' : 'is-incoming'}`}>
|
||||
<p>{message.body}</p>
|
||||
</div>
|
||||
<div className={`conversation-meta ${message.direction === 'outgoing' ? 'is-outgoing' : 'is-incoming'}`}>
|
||||
<span>{message.time}</span>
|
||||
{message.direction === 'outgoing' ? renderOutgoingStatus(message.status) : null}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="conversations-composer">
|
||||
<div className="conversations-composer-tools">
|
||||
<button
|
||||
type="button"
|
||||
className={composerMode === 'quick-replies' ? 'is-active' : undefined}
|
||||
onClick={() => setComposerMode('quick-replies')}
|
||||
>
|
||||
<span className="material-symbols-outlined">auto_awesome</span>
|
||||
Quick Replies
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={composerMode === 'templates' ? 'is-active' : undefined}
|
||||
onClick={() => setComposerMode('templates')}
|
||||
>
|
||||
<span className="material-symbols-outlined">description</span>
|
||||
Templates
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="conversations-composer-suggestions">
|
||||
{(composerMode === 'quick-replies' ? quickReplies : templateReplies.map((template) => template.label)).map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
className="conversation-suggestion-chip"
|
||||
onClick={() =>
|
||||
applySuggestion(
|
||||
composerMode === 'quick-replies'
|
||||
? item
|
||||
: templateReplies.find((template) => template.label === item)?.body ?? item,
|
||||
)
|
||||
}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="conversations-composer-shell">
|
||||
<button type="button" aria-label="Emoji">
|
||||
<span className="material-symbols-outlined">sentiment_satisfied</span>
|
||||
</button>
|
||||
<button type="button" aria-label="Attach file">
|
||||
<span className="material-symbols-outlined">attach_file</span>
|
||||
</button>
|
||||
<textarea
|
||||
rows={1}
|
||||
placeholder="Type a message..."
|
||||
value={composerText}
|
||||
onChange={(event) => setComposerText(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void sendMessage();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="conversations-send-button"
|
||||
aria-label="Send message"
|
||||
onClick={() => void sendMessage()}
|
||||
disabled={isSending}
|
||||
>
|
||||
<span className="material-symbols-outlined">send</span>
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<aside className="conversations-profile surface-card">
|
||||
<div className="conversations-profile-top">
|
||||
<div className="conversation-avatar is-profile">{activeConversation?.initials ?? '--'}</div>
|
||||
<h3>{activeConversation?.name ?? 'No conversation selected'}</h3>
|
||||
<p>{activeConversation?.location ?? 'Unavailable'}</p>
|
||||
<p>{activeConversation?.assignedAgentName ? `Assigned to ${activeConversation.assignedAgentName}` : 'Unassigned conversation'}</p>
|
||||
<div className="conversations-profile-actions">
|
||||
<button type="button" aria-label="Assign conversation" onClick={() => void assignToMe()} disabled={isAssigning}>
|
||||
<span className="material-symbols-outlined">assignment_ind</span>
|
||||
</button>
|
||||
<button type="button" aria-label="Edit contact">
|
||||
<span className="material-symbols-outlined">edit</span>
|
||||
</button>
|
||||
<button type="button" aria-label="Favorite contact">
|
||||
<span className="material-symbols-outlined">star</span>
|
||||
</button>
|
||||
<button type="button" aria-label="Block contact">
|
||||
<span className="material-symbols-outlined">block</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="conversations-profile-scroll custom-scrollbar">
|
||||
<section className="conversations-profile-section">
|
||||
<h4>Contact Details</h4>
|
||||
<div className="conversations-detail-list">
|
||||
<div>
|
||||
<span className="material-symbols-outlined">mail</span>
|
||||
<p>{activeConversation?.email ?? 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="material-symbols-outlined">call</span>
|
||||
<p>{activeConversation?.phone ?? 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="material-symbols-outlined">calendar_today</span>
|
||||
<p>{activeConversation?.customerSince ?? 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="conversations-profile-section">
|
||||
<h4>Tags</h4>
|
||||
<div className="conversations-tags">
|
||||
{activeConversation?.tags.map((tag) => <span key={tag}>{tag}</span>)}
|
||||
<button type="button">+ Add Tag</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="conversations-profile-section">
|
||||
<h4>Recent Activity</h4>
|
||||
<div className="conversations-activity-list">
|
||||
{activeConversation?.activity.map((item) => (
|
||||
<article key={`${item.title}-${item.meta}`} className="conversations-activity-item">
|
||||
<i className={item.tone === 'primary' ? 'is-primary' : 'is-muted'} />
|
||||
<div>
|
||||
<strong>{item.title}</strong>
|
||||
<p>{item.meta}</p>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="conversations-profile-footer">
|
||||
<button type="button">View Full CRM Profile</button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
231
frontend/src/components/dashboard-shell.tsx
Normal file
231
frontend/src/components/dashboard-shell.tsx
Normal file
@ -0,0 +1,231 @@
|
||||
import Link from 'next/link';
|
||||
import type { ReactNode } from 'react';
|
||||
import { logoutAction } from '../app/actions';
|
||||
import { requireAuthToken } from '../lib/auth';
|
||||
import { getDictionary, getLocale } from '../lib/i18n';
|
||||
import { LanguageSwitcher } from './language-switcher';
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
currentPath: string;
|
||||
title?: string;
|
||||
searchPlaceholder?: string;
|
||||
searchValue?: string;
|
||||
searchActionPath?: string;
|
||||
searchQueryName?: string;
|
||||
};
|
||||
|
||||
function isActive(href: string, currentPath: string) {
|
||||
if (href === '/dashboard') {
|
||||
return currentPath === href;
|
||||
}
|
||||
|
||||
return currentPath === href || currentPath.startsWith(`${href}/`);
|
||||
}
|
||||
|
||||
function SearchIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
d="M11 5a6 6 0 1 0 0 12 6 6 0 0 0 0-12Zm0 0c3.3 0 6 2.7 6 6m-1.5 5.5L20 21"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function BellIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
d="M9.5 18a2.5 2.5 0 0 0 5 0M6 17h12l-1.4-1.6a2 2 0 0 1-.5-1.3V10a4.1 4.1 0 0 0-8.2 0v4.1a2 2 0 0 1-.5 1.3L6 17Z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function HelpIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
d="M9.6 9.1a2.9 2.9 0 1 1 4.8 2.2c-.9.7-1.7 1.3-1.7 2.4v.4M12 17.6h.01"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export async function DashboardShell({
|
||||
children,
|
||||
currentPath,
|
||||
title,
|
||||
searchPlaceholder,
|
||||
searchValue,
|
||||
searchActionPath,
|
||||
searchQueryName,
|
||||
}: Props) {
|
||||
await requireAuthToken();
|
||||
const locale = await getLocale();
|
||||
const dict = await getDictionary();
|
||||
const isConversationsRoute = currentPath.startsWith('/dashboard/conversations');
|
||||
|
||||
const navItems = [
|
||||
{ href: '/dashboard', label: dict.dashboard.navOverview, icon: 'dashboard' },
|
||||
{ href: '/dashboard/conversations', label: dict.dashboard.navConversations, icon: 'chat' },
|
||||
{ href: '/dashboard/templates', label: dict.dashboard.navTemplates, icon: 'description' },
|
||||
{ href: '/dashboard/logs', label: 'Analytics', icon: 'monitoring' },
|
||||
];
|
||||
|
||||
const utilityItems = [
|
||||
{ href: '/dashboard/contacts', label: dict.dashboard.navContacts, icon: 'contacts' },
|
||||
{ href: '/dashboard/campaigns', label: dict.dashboard.navCampaigns, icon: 'campaign' },
|
||||
{ href: '/dashboard/users', label: dict.dashboard.navUsers, icon: 'group' },
|
||||
{ href: '/dashboard/roles', label: dict.dashboard.navRoles, icon: 'admin_panel_settings' },
|
||||
];
|
||||
|
||||
const settingsItems = [
|
||||
{ href: '/dashboard/settings/whatsapp-api', label: 'WhatsApp API Setting', icon: 'link' },
|
||||
{ href: '/dashboard/settings/security', label: 'Security', icon: 'security' },
|
||||
{ href: '/dashboard/settings/webhook-logs', label: 'Webhook Logs', icon: 'webhook' },
|
||||
{ href: '/dashboard/settings/audit-trail', label: 'Audit Trail', icon: 'history' },
|
||||
];
|
||||
const isSettingsSection = currentPath.startsWith('/dashboard/settings');
|
||||
|
||||
return (
|
||||
<div className="dashboard-app">
|
||||
<aside className="dashboard-sidebar">
|
||||
<div className="dashboard-brand">
|
||||
<h1>BizOne</h1>
|
||||
<p>Enterprise API</p>
|
||||
</div>
|
||||
|
||||
<Link href="/dashboard/campaigns" className="dashboard-primary-action">
|
||||
<span className="material-symbols-outlined">add</span>
|
||||
{dict.dashboard.newMessage}
|
||||
</Link>
|
||||
|
||||
<div className="dashboard-sidebar-scroll">
|
||||
<nav className="dashboard-nav">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={isActive(item.href, currentPath) ? 'dashboard-nav-link is-active' : 'dashboard-nav-link'}
|
||||
>
|
||||
<span className="material-symbols-outlined">{item.icon}</span>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="dashboard-nav-secondary">
|
||||
{utilityItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={isActive(item.href, currentPath) ? 'dashboard-subnav-link is-active' : 'dashboard-subnav-link'}
|
||||
>
|
||||
<span className="material-symbols-outlined">{item.icon}</span>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="dashboard-sidebar-footer">
|
||||
<details className="dashboard-settings-accordion" open={isSettingsSection}>
|
||||
<summary className={isSettingsSection ? 'dashboard-nav-link is-active' : 'dashboard-nav-link'}>
|
||||
<span className="dashboard-nav-link-main">
|
||||
<span className="material-symbols-outlined">settings</span>
|
||||
{dict.dashboard.navSettings}
|
||||
</span>
|
||||
<span className="material-symbols-outlined dashboard-settings-caret">expand_more</span>
|
||||
</summary>
|
||||
<div className="dashboard-settings-subnav">
|
||||
{settingsItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={isActive(item.href, currentPath) ? 'dashboard-settings-link is-active' : 'dashboard-settings-link'}
|
||||
>
|
||||
<span className="material-symbols-outlined">{item.icon}</span>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
<form action={logoutAction}>
|
||||
<button type="submit" className="dashboard-logout-button">
|
||||
<span className="material-symbols-outlined">logout</span>
|
||||
{dict.common.logout}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className={isConversationsRoute ? 'dashboard-main dashboard-main-conversations' : 'dashboard-main'}>
|
||||
<header className="dashboard-topbar">
|
||||
<div className="dashboard-topbar-left">
|
||||
<h2>{title || dict.common.overview}</h2>
|
||||
{searchPlaceholder ? (
|
||||
<>
|
||||
<div className="dashboard-topbar-divider" />
|
||||
<form className="dashboard-searchbar" action={searchActionPath || currentPath}>
|
||||
<span className="material-symbols-outlined">search</span>
|
||||
<input
|
||||
type="search"
|
||||
name={searchQueryName || 'q'}
|
||||
placeholder={searchPlaceholder}
|
||||
defaultValue={searchValue || ''}
|
||||
/>
|
||||
</form>
|
||||
</>
|
||||
) : null}
|
||||
<LanguageSwitcher
|
||||
currentLocale={locale}
|
||||
label={dict.common.language}
|
||||
englishLabel={dict.common.english}
|
||||
indonesianLabel={dict.common.indonesian}
|
||||
submitLabel={dict.common.save}
|
||||
variant="compact"
|
||||
/>
|
||||
</div>
|
||||
<div className="dashboard-topbar-actions">
|
||||
<button type="button" className="dashboard-icon-button" aria-label={dict.common.search}>
|
||||
<SearchIcon />
|
||||
</button>
|
||||
<button type="button" className="dashboard-icon-button" aria-label="notifications">
|
||||
<BellIcon />
|
||||
</button>
|
||||
<button type="button" className="dashboard-icon-button" aria-label={dict.common.helpCenter}>
|
||||
<HelpIcon />
|
||||
</button>
|
||||
<div className="dashboard-profile">
|
||||
<div>
|
||||
<strong>{dict.common.adminUser}</strong>
|
||||
<span>{dict.common.superAdmin}</span>
|
||||
</div>
|
||||
<div className="dashboard-avatar">A</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className={isConversationsRoute ? 'dashboard-content dashboard-content-conversations' : 'dashboard-content'}>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
frontend/src/components/forgot-password-card.tsx
Normal file
99
frontend/src/components/forgot-password-card.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
type Props = {
|
||||
languageSwitcher: ReactNode;
|
||||
};
|
||||
|
||||
export function ForgotPasswordCard({ languageSwitcher }: Props) {
|
||||
const [email, setEmail] = useState('');
|
||||
const [pending, setPending] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function submit() {
|
||||
setPending(true);
|
||||
setMessage('');
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/password-reset', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
setError(payload.message || 'Failed to request password reset.');
|
||||
return;
|
||||
}
|
||||
|
||||
setMessage('If the account exists, a reset link has been sent to the email address.');
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="auth-page auth-page-enterprise auth-page-login">
|
||||
<div className="auth-enterprise-glow auth-enterprise-glow-right" />
|
||||
<div className="auth-enterprise-glow auth-enterprise-glow-left" />
|
||||
<div className="auth-page-symbol auth-page-symbol-top">
|
||||
<span className="material-symbols-outlined">mark_email_unread</span>
|
||||
</div>
|
||||
<div className="auth-page-symbol auth-page-symbol-bottom">
|
||||
<span className="material-symbols-outlined">lock_reset</span>
|
||||
</div>
|
||||
|
||||
<section className="auth-container auth-container-login">
|
||||
<div className="auth-login-toolbar">
|
||||
<div className="auth-login-locale">{languageSwitcher}</div>
|
||||
</div>
|
||||
|
||||
<header className="auth-brand">
|
||||
<div className="auth-brand-mark">
|
||||
<span className="material-symbols-outlined">lock_reset</span>
|
||||
</div>
|
||||
<div className="auth-brand-copy">
|
||||
<h1>BizOne</h1>
|
||||
<p>Reset access securely. We will send a password reset link if the account exists.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="auth-card auth-card-enterprise">
|
||||
<div className="two-factor-copy auth-public-copy">
|
||||
<h2>Forgot Password</h2>
|
||||
<p>Enter your email address and we will send you a reset link if the account exists.</p>
|
||||
</div>
|
||||
|
||||
<div className="invite-form">
|
||||
<label className="invite-field">
|
||||
<span>Email Address</span>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
placeholder="admin@example.com"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button type="button" className="invite-submit-button" onClick={submit} disabled={pending || !email.trim()}>
|
||||
{pending ? 'Sending...' : 'Send Reset Link'}
|
||||
</button>
|
||||
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
{message ? <p className="form-success">{message}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="invite-footer auth-inline-footer">
|
||||
<span>Remembered your password?</span>
|
||||
<Link href="/login">Back to login</Link>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
66
frontend/src/components/language-switcher.tsx
Normal file
66
frontend/src/components/language-switcher.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { setLocaleAction } from '../app/actions';
|
||||
import type { Locale } from '../lib/i18n';
|
||||
|
||||
type Props = {
|
||||
currentLocale: Locale;
|
||||
label: string;
|
||||
englishLabel: string;
|
||||
indonesianLabel: string;
|
||||
submitLabel: string;
|
||||
variant?: 'default' | 'compact';
|
||||
};
|
||||
|
||||
export function LanguageSwitcher({
|
||||
currentLocale,
|
||||
label,
|
||||
englishLabel,
|
||||
indonesianLabel,
|
||||
submitLabel,
|
||||
variant = 'default',
|
||||
}: Props) {
|
||||
const pathname = usePathname();
|
||||
const [locale, setLocale] = useState<Locale>(currentLocale);
|
||||
|
||||
if (variant === 'compact') {
|
||||
return (
|
||||
<form action={setLocaleAction} className="language-switcher language-switcher-compact">
|
||||
<input type="hidden" name="redirectPath" value={pathname} />
|
||||
<span className="sr-only">{label}</span>
|
||||
<button
|
||||
type="submit"
|
||||
name="locale"
|
||||
value="en"
|
||||
className={currentLocale === 'en' ? 'is-active' : ''}
|
||||
>
|
||||
EN
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
name="locale"
|
||||
value="id"
|
||||
className={currentLocale === 'id' ? 'is-active' : ''}
|
||||
>
|
||||
ID
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={setLocaleAction} className="language-switcher">
|
||||
<input type="hidden" name="redirectPath" value={pathname} />
|
||||
<label>
|
||||
<span>{label}</span>
|
||||
<select name="locale" value={locale} onChange={(event) => setLocale(event.target.value as Locale)}>
|
||||
<option value="id">{indonesianLabel}</option>
|
||||
<option value="en">{englishLabel}</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">{submitLabel}</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
175
frontend/src/components/login-form.tsx
Normal file
175
frontend/src/components/login-form.tsx
Normal file
@ -0,0 +1,175 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { useActionState, useState } from 'react';
|
||||
import { loginAction } from '../app/actions';
|
||||
|
||||
type Props = {
|
||||
languageSwitcher: ReactNode;
|
||||
labels: {
|
||||
title: string;
|
||||
description: string;
|
||||
email: string;
|
||||
password: string;
|
||||
submit: string;
|
||||
goToDashboard: string;
|
||||
emailLabel: string;
|
||||
passwordLabel: string;
|
||||
forgotPassword: string;
|
||||
rememberMe: string;
|
||||
accessVia: string;
|
||||
google: string;
|
||||
sso: string;
|
||||
newToPlatform: string;
|
||||
applyAccess: string;
|
||||
privacyPolicy: string;
|
||||
termsOfService: string;
|
||||
helpCenter: string;
|
||||
securityPreview: string;
|
||||
loginHelp: string;
|
||||
twoFactorPreview: string;
|
||||
showPassword: string;
|
||||
hidePassword: string;
|
||||
};
|
||||
appName: string;
|
||||
};
|
||||
|
||||
type LoginFormState = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const initialState: LoginFormState = {};
|
||||
|
||||
function MailIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4 6h16a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2Zm0 2 8 5 8-5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function LockIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M7 10V8a5 5 0 0 1 10 0v2m-9 0h8a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2Z" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M6 18.5V20l2.7-1.8c.4-.2.8-.3 1.2-.3h8.1A2 2 0 0 0 20 15.9V6.8A1.8 1.8 0 0 0 18.2 5H5.8A1.8 1.8 0 0 0 4 6.8v9.4c0 1.3 1 2.3 2 2.3Z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ArrowIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M5 12h14m-5-5 5 5-5 5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoginForm({ labels, appName, languageSwitcher }: Props) {
|
||||
const [state, formAction, pending] = useActionState(loginAction, initialState);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
return (
|
||||
<main className="auth-page auth-page-enterprise auth-page-login">
|
||||
<div className="auth-enterprise-glow auth-enterprise-glow-right" />
|
||||
<div className="auth-enterprise-glow auth-enterprise-glow-left" />
|
||||
<div className="auth-page-symbol auth-page-symbol-top">
|
||||
<span className="material-symbols-outlined">hub</span>
|
||||
</div>
|
||||
<div className="auth-page-symbol auth-page-symbol-bottom">
|
||||
<span className="material-symbols-outlined">security_update_good</span>
|
||||
</div>
|
||||
|
||||
<section className="auth-container auth-container-login">
|
||||
<div className="auth-login-toolbar">
|
||||
<div className="auth-login-locale">{languageSwitcher}</div>
|
||||
</div>
|
||||
|
||||
<header className="auth-brand">
|
||||
<div className="auth-brand-mark">
|
||||
<ChatIcon />
|
||||
</div>
|
||||
<div className="auth-brand-copy">
|
||||
<h1>{appName}</h1>
|
||||
<p>{labels.description}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="auth-card auth-card-enterprise">
|
||||
<form action={formAction} className="enterprise-form">
|
||||
<div className="auth-form-block">
|
||||
<label className="field-label" htmlFor="email">
|
||||
{labels.emailLabel}
|
||||
</label>
|
||||
<div className="input-shell">
|
||||
<span className="input-icon">
|
||||
<MailIcon />
|
||||
</span>
|
||||
<input id="email" name="email" type="email" placeholder="admin@enterprise.com" required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="auth-form-block">
|
||||
<div className="field-row">
|
||||
<label className="field-label" htmlFor="password">
|
||||
{labels.passwordLabel}
|
||||
</label>
|
||||
<a href="/forgot-password" className="text-link">
|
||||
{labels.forgotPassword}
|
||||
</a>
|
||||
</div>
|
||||
<div className="input-shell">
|
||||
<span className="input-icon">
|
||||
<LockIcon />
|
||||
</span>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="text-link text-link-compact"
|
||||
onClick={() => setShowPassword((current) => !current)}
|
||||
>
|
||||
{showPassword ? labels.hidePassword : labels.showPassword}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="check-row">
|
||||
<input type="checkbox" name="remember" />
|
||||
<span>{labels.rememberMe}</span>
|
||||
</label>
|
||||
|
||||
<button type="submit" disabled={pending} className="auth-submit">
|
||||
<span>{pending ? '...' : labels.submit}</span>
|
||||
<span className="button-icon">
|
||||
<ArrowIcon />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{state.error ? <p className="form-error">{state.error}</p> : null}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<footer className="auth-footer">
|
||||
<nav className="auth-footer-links">
|
||||
<button type="button">{labels.privacyPolicy}</button>
|
||||
<button type="button">{labels.termsOfService}</button>
|
||||
<button type="button">{labels.helpCenter}</button>
|
||||
</nav>
|
||||
</footer>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
177
frontend/src/components/reset-password-card.tsx
Normal file
177
frontend/src/components/reset-password-card.tsx
Normal file
@ -0,0 +1,177 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
type Props = {
|
||||
token: string;
|
||||
languageSwitcher: ReactNode;
|
||||
resetRequest: {
|
||||
email: string;
|
||||
name: string;
|
||||
expiresAt: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
function passwordChecks(password: string) {
|
||||
return {
|
||||
minLength: password.length >= 8,
|
||||
hasNumber: /\d/.test(password),
|
||||
hasSpecial: /[^A-Za-z0-9]/.test(password),
|
||||
};
|
||||
}
|
||||
|
||||
function formatExpiry(value: string | null) {
|
||||
if (!value) return 'Unavailable';
|
||||
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
export function ResetPasswordCard({ token, resetRequest, languageSwitcher }: Props) {
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
const checks = useMemo(() => passwordChecks(password), [password]);
|
||||
const strengthCount = [checks.minLength, checks.hasNumber, checks.hasSpecial].filter(Boolean).length;
|
||||
const strengthLabel = strengthCount <= 1 ? 'Weak' : strengthCount === 2 ? 'Medium' : 'Strong';
|
||||
|
||||
async function submit() {
|
||||
setError('');
|
||||
setMessage('');
|
||||
if (password !== confirmPassword) {
|
||||
setError('Password confirmation does not match.');
|
||||
return;
|
||||
}
|
||||
|
||||
setPending(true);
|
||||
try {
|
||||
const response = await fetch(`/api/auth/password-reset/${token}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
setError(payload.message || 'Failed to reset password');
|
||||
return;
|
||||
}
|
||||
|
||||
setMessage('Password updated. You can now log in with your new password.');
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="auth-page auth-page-enterprise auth-page-login">
|
||||
<div className="auth-enterprise-glow auth-enterprise-glow-right" />
|
||||
<div className="auth-enterprise-glow auth-enterprise-glow-left" />
|
||||
<div className="auth-page-symbol auth-page-symbol-top">
|
||||
<span className="material-symbols-outlined">password</span>
|
||||
</div>
|
||||
<div className="auth-page-symbol auth-page-symbol-bottom">
|
||||
<span className="material-symbols-outlined">verified_user</span>
|
||||
</div>
|
||||
|
||||
<section className="auth-container auth-container-login">
|
||||
<div className="auth-login-toolbar">
|
||||
<div className="auth-login-locale">{languageSwitcher}</div>
|
||||
</div>
|
||||
|
||||
<header className="auth-brand">
|
||||
<div className="auth-brand-mark">
|
||||
<span className="material-symbols-outlined">vpn_key</span>
|
||||
</div>
|
||||
<div className="auth-brand-copy">
|
||||
<h1>BizOne</h1>
|
||||
<p>Create a new password and secure the admin account before returning to the dashboard.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="auth-card auth-card-enterprise">
|
||||
<div className="two-factor-copy auth-public-copy">
|
||||
<h2>Reset Your Password</h2>
|
||||
<p>
|
||||
Hello, {resetRequest.name}. Choose a new password for <strong>{resetRequest.email}</strong>.
|
||||
</p>
|
||||
<p className="auth-public-meta">Reset link expires: {formatExpiry(resetRequest.expiresAt)}</p>
|
||||
</div>
|
||||
|
||||
<div className="invite-form">
|
||||
<label className="invite-field">
|
||||
<span>New Password</span>
|
||||
<div className="invite-password-wrap">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
<button type="button" onClick={() => setShowPassword((current) => !current)}>
|
||||
<span className="material-symbols-outlined">{showPassword ? 'visibility_off' : 'visibility'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="invite-field">
|
||||
<span>Confirm New Password</span>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(event) => setConfirmPassword(event.target.value)}
|
||||
placeholder="Repeat your password"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="invite-strength">
|
||||
<div className="invite-strength-head">
|
||||
<span>Password Strength</span>
|
||||
<strong>{strengthLabel}</strong>
|
||||
</div>
|
||||
<div className="invite-strength-bars">
|
||||
{[0, 1, 2, 3].map((index) => (
|
||||
<span key={index} className={index < strengthCount + 1 ? 'is-active' : ''} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="invite-checklist">
|
||||
<div className={checks.minLength ? 'is-done' : ''}>
|
||||
<span className="material-symbols-outlined">{checks.minLength ? 'check_circle' : 'circle'}</span>
|
||||
Minimum 8 characters
|
||||
</div>
|
||||
<div className={checks.hasNumber ? 'is-done' : ''}>
|
||||
<span className="material-symbols-outlined">{checks.hasNumber ? 'check_circle' : 'circle'}</span>
|
||||
At least one number
|
||||
</div>
|
||||
<div className={checks.hasSpecial ? 'is-done' : ''}>
|
||||
<span className="material-symbols-outlined">{checks.hasSpecial ? 'check_circle' : 'circle'}</span>
|
||||
One special character (@, #, $, etc.)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" className="invite-submit-button" onClick={submit} disabled={pending}>
|
||||
{pending ? 'Updating...' : 'Update Password'}
|
||||
</button>
|
||||
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
{message ? <p className="form-success">{message}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="invite-footer auth-inline-footer">
|
||||
<span>Need help?</span>
|
||||
<Link href="/login">Back to login</Link>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
530
frontend/src/components/roles-permissions-board.tsx
Normal file
530
frontend/src/components/roles-permissions-board.tsx
Normal file
@ -0,0 +1,530 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
type PermissionKey = 'view' | 'edit' | 'delete' | 'manage';
|
||||
type RoleId = string;
|
||||
type PermissionValue = boolean | null;
|
||||
|
||||
type PermissionRow = {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
values: Record<PermissionKey, PermissionValue>;
|
||||
};
|
||||
|
||||
type RoleDefinition = {
|
||||
id: RoleId;
|
||||
name: string;
|
||||
badge: string;
|
||||
tone: 'primary' | 'secondary' | 'tertiary';
|
||||
summary: string;
|
||||
usersAssigned: number;
|
||||
icon: string;
|
||||
permissionRows: PermissionRow[];
|
||||
};
|
||||
|
||||
const permissionColumns: Array<{ key: PermissionKey; label: string }> = [
|
||||
{ key: 'view', label: 'View' },
|
||||
{ key: 'edit', label: 'Create/Edit' },
|
||||
{ key: 'delete', label: 'Delete' },
|
||||
{ key: 'manage', label: 'Manage All' },
|
||||
];
|
||||
|
||||
function cloneRoleSet(roles: RoleDefinition[]) {
|
||||
return roles.map((role) => ({
|
||||
...role,
|
||||
permissionRows: role.permissionRows.map((row) => ({
|
||||
...row,
|
||||
values: { ...row.values },
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
function roleBadge(role: RoleDefinition, isEditing: boolean) {
|
||||
if (isEditing) {
|
||||
return { label: 'Editing Now', tone: 'editing' as const };
|
||||
}
|
||||
|
||||
return { label: role.badge, tone: role.tone };
|
||||
}
|
||||
|
||||
type AuditHighlight = {
|
||||
id: string;
|
||||
icon: string;
|
||||
title: string;
|
||||
description: string;
|
||||
time: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
initialRoles: RoleDefinition[];
|
||||
initialAuditHighlights: AuditHighlight[];
|
||||
};
|
||||
|
||||
export function RolesPermissionsBoard({ initialRoles, initialAuditHighlights }: Props) {
|
||||
const [roles, setRoles] = useState<RoleDefinition[]>(() => cloneRoleSet(initialRoles));
|
||||
const [savedRoles, setSavedRoles] = useState<RoleDefinition[]>(() => cloneRoleSet(initialRoles));
|
||||
const [selectedRoleId, setSelectedRoleId] = useState<RoleId>(initialRoles[0]?.id || '');
|
||||
const [editingRoleId, setEditingRoleId] = useState<RoleId | null>(null);
|
||||
const [saveMessage, setSaveMessage] = useState('');
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [newRoleName, setNewRoleName] = useState('');
|
||||
const [newRoleSummary, setNewRoleSummary] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const selectedRole = useMemo(
|
||||
() => roles.find((role) => role.id === selectedRoleId) ?? roles[0],
|
||||
[roles, selectedRoleId],
|
||||
);
|
||||
|
||||
const isEditingSelectedRole = selectedRole ? editingRoleId === selectedRole.id : false;
|
||||
|
||||
useEffect(() => {
|
||||
const nextRoles = cloneRoleSet(initialRoles);
|
||||
setRoles(nextRoles);
|
||||
setSavedRoles(nextRoles);
|
||||
setSelectedRoleId((current) =>
|
||||
nextRoles.some((role) => role.id === current) ? current : nextRoles[0]?.id || '',
|
||||
);
|
||||
setEditingRoleId((current) =>
|
||||
nextRoles.some((role) => role.id === current) ? current : null,
|
||||
);
|
||||
}, [initialRoles]);
|
||||
|
||||
function updatePermission(rowId: string, permission: PermissionKey, nextValue: boolean) {
|
||||
if (!isEditingSelectedRole) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRoles((currentRoles) =>
|
||||
currentRoles.map((role) =>
|
||||
role.id !== selectedRoleId
|
||||
? role
|
||||
: {
|
||||
...role,
|
||||
permissionRows: role.permissionRows.map((row) =>
|
||||
row.id === rowId ? { ...row, values: { ...row.values, [permission]: nextValue } } : row,
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
setSaveMessage('');
|
||||
}
|
||||
|
||||
function beginEdit(roleId: RoleId) {
|
||||
setSelectedRoleId(roleId);
|
||||
setEditingRoleId(roleId);
|
||||
setSaveMessage('');
|
||||
}
|
||||
|
||||
function resetChanges() {
|
||||
setRoles(cloneRoleSet(savedRoles));
|
||||
setEditingRoleId(null);
|
||||
setSaveMessage('Changes discarded.');
|
||||
}
|
||||
|
||||
async function saveChanges() {
|
||||
if (!selectedRole) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const response = await fetch(`/api/roles/${selectedRole.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
permissions: selectedRole.permissionRows,
|
||||
summary: selectedRole.summary,
|
||||
badge: selectedRole.badge,
|
||||
tone: selectedRole.tone,
|
||||
icon: selectedRole.icon,
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
setSaveMessage(payload.message || 'Failed to save role');
|
||||
return;
|
||||
}
|
||||
|
||||
setRoles((current) =>
|
||||
current.map((role) =>
|
||||
role.id === payload.id
|
||||
? {
|
||||
...role,
|
||||
permissionRows: payload.permissions,
|
||||
usersAssigned: payload.usersAssigned,
|
||||
summary: payload.summary,
|
||||
badge: payload.badge,
|
||||
tone: payload.tone,
|
||||
icon: payload.icon,
|
||||
}
|
||||
: role,
|
||||
),
|
||||
);
|
||||
setSavedRoles((current) =>
|
||||
current.map((role) =>
|
||||
role.id === payload.id
|
||||
? {
|
||||
...role,
|
||||
permissionRows: payload.permissions,
|
||||
usersAssigned: payload.usersAssigned,
|
||||
summary: payload.summary,
|
||||
badge: payload.badge,
|
||||
tone: payload.tone,
|
||||
icon: payload.icon,
|
||||
}
|
||||
: role,
|
||||
),
|
||||
);
|
||||
setEditingRoleId(null);
|
||||
setSaveMessage(`Permission matrix for ${payload.name} saved.`);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function createRole() {
|
||||
const trimmedName = newRoleName.trim();
|
||||
const trimmedSummary = newRoleSummary.trim();
|
||||
|
||||
if (!trimmedName) {
|
||||
setSaveMessage('Role name is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
const id =
|
||||
trimmedName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') || `role-${Date.now()}`;
|
||||
|
||||
const nextRole: RoleDefinition = {
|
||||
id,
|
||||
name: trimmedName,
|
||||
badge: 'Custom',
|
||||
tone: 'secondary',
|
||||
summary: trimmedSummary || 'Custom role for a focused operational access policy.',
|
||||
usersAssigned: 0,
|
||||
icon: 'verified_user',
|
||||
permissionRows: [
|
||||
{ id: 'campaigns', label: 'Manage Campaigns', icon: 'campaign', description: 'Broadcasts and outbound campaign controls.', values: { view: true, edit: false, delete: false, manage: false } },
|
||||
{ id: 'analytics', label: 'View Analytics', icon: 'monitoring', description: 'KPI, trends, and performance dashboards.', values: { view: true, edit: null, delete: null, manage: false } },
|
||||
{ id: 'settings', label: 'Edit Settings', icon: 'settings', description: 'Providers, secrets, and environment-facing settings.', values: { view: false, edit: false, delete: false, manage: false } },
|
||||
{ id: 'billing', label: 'Billing & Invoices', icon: 'payments', description: 'Plan usage, invoices, and billing visibility.', values: { view: false, edit: null, delete: null, manage: false } },
|
||||
],
|
||||
};
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const response = await fetch('/api/roles', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: trimmedName,
|
||||
summary: trimmedSummary || 'Custom role for a focused operational access policy.',
|
||||
badge: 'Custom',
|
||||
tone: 'secondary',
|
||||
icon: 'verified_user',
|
||||
permissions: nextRole.permissionRows,
|
||||
}),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
setSaveMessage(payload.message || 'Failed to create role');
|
||||
return;
|
||||
}
|
||||
|
||||
const createdRole = {
|
||||
id: payload.id,
|
||||
name: payload.name,
|
||||
badge: payload.badge,
|
||||
tone: payload.tone,
|
||||
summary: payload.summary,
|
||||
usersAssigned: payload.usersAssigned,
|
||||
icon: payload.icon,
|
||||
permissionRows: payload.permissions,
|
||||
} satisfies RoleDefinition;
|
||||
|
||||
const nextRoles = [createdRole, ...cloneRoleSet(roles)];
|
||||
setRoles(nextRoles);
|
||||
setSavedRoles(cloneRoleSet(nextRoles));
|
||||
setSelectedRoleId(payload.id);
|
||||
setEditingRoleId(payload.id);
|
||||
setIsCreateOpen(false);
|
||||
setNewRoleName('');
|
||||
setNewRoleSummary('');
|
||||
setSaveMessage(`Role ${payload.name} created and opened for editing.`);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectedRole) {
|
||||
return (
|
||||
<section className="permission-matrix-card">
|
||||
<div className="permission-matrix-head">
|
||||
<div>
|
||||
<p className="card-kicker">Roles</p>
|
||||
<h3>No roles available</h3>
|
||||
<p className="permission-matrix-copy">
|
||||
Create the first role to start defining access permissions.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="role-action-button"
|
||||
onClick={() => setIsCreateOpen(true)}
|
||||
>
|
||||
<span className="material-symbols-outlined">add_moderator</span>
|
||||
Create Role
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="page-section">
|
||||
<div className="header-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="roles-create-button"
|
||||
onClick={() => {
|
||||
setIsCreateOpen((current) => !current);
|
||||
setSaveMessage('');
|
||||
}}
|
||||
>
|
||||
<span className="material-symbols-outlined">{isCreateOpen ? 'close' : 'add_moderator'}</span>
|
||||
{isCreateOpen ? 'Hide Role Form' : 'Create New Role'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{isCreateOpen ? (
|
||||
<section className="roles-create-panel">
|
||||
<div className="roles-create-panel-head">
|
||||
<div>
|
||||
<p className="card-kicker">New Role</p>
|
||||
<h3>Create a custom access profile</h3>
|
||||
<p className="roles-create-copy">Define a clear role name and a short summary so your team can understand the scope immediately.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="roles-create-form">
|
||||
<label className="roles-create-field">
|
||||
<span>Role name</span>
|
||||
<input type="text" value={newRoleName} onChange={(event) => setNewRoleName(event.target.value)} placeholder="Reporting Only" />
|
||||
<small>Use a short operational name like Viewer, QA Reviewer, or Billing Admin.</small>
|
||||
</label>
|
||||
<label className="roles-create-field">
|
||||
<span>Summary</span>
|
||||
<textarea value={newRoleSummary} onChange={(event) => setNewRoleSummary(event.target.value)} placeholder="Read-only access to analytics, campaign stats, and webhook summaries." rows={3} />
|
||||
<small>Describe what this role can access and the boundaries it should keep.</small>
|
||||
</label>
|
||||
</div>
|
||||
<div className="button-row roles-create-actions">
|
||||
<button type="button" className="secondary-button role-action-button" onClick={() => {
|
||||
setIsCreateOpen(false);
|
||||
setNewRoleName('');
|
||||
setNewRoleSummary('');
|
||||
}}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" className="role-action-button" onClick={createRole} disabled={isSaving}>
|
||||
Save Role
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="roles-card-grid">
|
||||
{roles.map((role) => {
|
||||
const badge = roleBadge(role, editingRoleId === role.id);
|
||||
const isSelected = role.id === selectedRoleId;
|
||||
|
||||
return (
|
||||
<article
|
||||
key={role.id}
|
||||
className={isSelected ? 'role-card is-selected' : 'role-card'}
|
||||
onClick={() => {
|
||||
setSelectedRoleId(role.id);
|
||||
setSaveMessage('');
|
||||
}}
|
||||
aria-selected={isSelected}
|
||||
>
|
||||
<div className="role-card-head">
|
||||
<div className={`role-icon tone-${role.tone}`}>
|
||||
<span className="material-symbols-outlined">{role.icon}</span>
|
||||
</div>
|
||||
<span className={`role-badge tone-${badge.tone}`}>{badge.label}</span>
|
||||
</div>
|
||||
<h2>{role.name}</h2>
|
||||
<p>{role.summary}</p>
|
||||
<div className="role-card-meta">
|
||||
<span className="material-symbols-outlined">group</span>
|
||||
{role.usersAssigned} users assigned
|
||||
</div>
|
||||
<div className="role-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
className={editingRoleId === role.id ? 'role-inline-button is-active' : 'role-inline-button'}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
beginEdit(role.id);
|
||||
}}
|
||||
>
|
||||
<span className="material-symbols-outlined">edit</span>
|
||||
{editingRoleId === role.id ? 'Editing' : 'Edit'}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
|
||||
<section className="permission-matrix-card">
|
||||
<div className="permission-matrix-head">
|
||||
<div>
|
||||
<p className="card-kicker">Permission Matrix</p>
|
||||
<h3>
|
||||
{isEditingSelectedRole ? 'Editing' : 'Viewing'} <span>{selectedRole.name}</span>
|
||||
</h3>
|
||||
<p className="permission-matrix-copy">
|
||||
{isEditingSelectedRole
|
||||
? 'Toggle permissions below, then save the matrix to persist your changes.'
|
||||
: 'This matrix is read-only until you click Edit on the selected role card.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="button-row">
|
||||
{isEditingSelectedRole ? (
|
||||
<>
|
||||
<button type="button" className="secondary-button role-action-button" onClick={resetChanges} disabled={isSaving}>
|
||||
Discard
|
||||
</button>
|
||||
<button type="button" className="role-action-button" onClick={saveChanges} disabled={isSaving}>
|
||||
Save Changes
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button type="button" className="role-action-button" onClick={() => beginEdit(selectedRole.id)}>
|
||||
<span className="material-symbols-outlined">edit</span>
|
||||
Edit Role
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{saveMessage ? <p className="roles-feedback">{saveMessage}</p> : null}
|
||||
<div className="permission-table-wrap">
|
||||
<table className="permission-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Module / Permission</th>
|
||||
{permissionColumns.map((column) => (
|
||||
<th key={column.key}>{column.label}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{selectedRole.permissionRows.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>
|
||||
<div className="permission-label">
|
||||
<span className="material-symbols-outlined">{row.icon}</span>
|
||||
<div>
|
||||
<strong>{row.label}</strong>
|
||||
<small>{row.description}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{permissionColumns.map((column) => {
|
||||
const value = row.values[column.key];
|
||||
if (value === null) {
|
||||
return (
|
||||
<td key={column.key} className="permission-unavailable">
|
||||
—
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isEditingSelectedRole) {
|
||||
return (
|
||||
<td key={column.key}>
|
||||
<span className={value ? 'permission-indicator is-on' : 'permission-indicator is-off'}>
|
||||
{value ? 'On' : 'Off'}
|
||||
</span>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<td key={column.key}>
|
||||
<label className="toggle-switch">
|
||||
<input type="checkbox" checked={value} onChange={(event) => updatePermission(row.id, column.key, event.target.checked)} />
|
||||
<span className="toggle-track">
|
||||
<span className="toggle-thumb" />
|
||||
</span>
|
||||
</label>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="roles-bottom-grid">
|
||||
<article className="surface-card">
|
||||
<div className="card-head">
|
||||
<div>
|
||||
<p className="card-kicker">Audit Trail</p>
|
||||
<h3 className="roles-section-title">Recent Changes</h3>
|
||||
</div>
|
||||
<a href="/dashboard/settings/audit-trail" className="roles-inline-link">
|
||||
View All
|
||||
</a>
|
||||
</div>
|
||||
<div className="roles-audit-list">
|
||||
{initialAuditHighlights.map((entry) => (
|
||||
<div key={entry.id} className="roles-audit-item">
|
||||
<div className="roles-audit-icon">
|
||||
<span className="material-symbols-outlined">{entry.icon}</span>
|
||||
</div>
|
||||
<div className="roles-audit-copy">
|
||||
<strong>{entry.title}</strong>
|
||||
<p>{entry.description}</p>
|
||||
</div>
|
||||
<span className="roles-audit-time">{entry.time}</span>
|
||||
</div>
|
||||
))}
|
||||
{initialAuditHighlights.length === 0 ? (
|
||||
<div className="roles-audit-item">
|
||||
<div className="roles-audit-icon">
|
||||
<span className="material-symbols-outlined">history</span>
|
||||
</div>
|
||||
<div className="roles-audit-copy">
|
||||
<strong>No role changes yet</strong>
|
||||
<p>Role create and edit actions will appear here once they are recorded.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<aside className="roles-help-card">
|
||||
<div className="roles-help-copy">
|
||||
<p className="card-kicker">Security Guidance</p>
|
||||
<h3>Need help?</h3>
|
||||
<p>Learn how to set granular access boundaries for enterprise teams with higher security requirements.</p>
|
||||
<a href="/dashboard/settings/security" className="roles-help-button">
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
<span className="material-symbols-outlined roles-help-mark">lock_person</span>
|
||||
</aside>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
90
frontend/src/components/security-session-card.tsx
Normal file
90
frontend/src/components/security-session-card.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
import { logoutAction } from '../app/actions';
|
||||
|
||||
type Props = {
|
||||
session: {
|
||||
user: {
|
||||
name: string;
|
||||
email: string;
|
||||
status: string;
|
||||
lastLoginAt: string | null;
|
||||
twoFactorEnabled: boolean;
|
||||
twoFactorConfirmedAt: string | null;
|
||||
};
|
||||
session: {
|
||||
issuedAt: string | null;
|
||||
expiresAt: string | null;
|
||||
refreshExpiresAt: string | null;
|
||||
currentIp: string | null;
|
||||
policy: 'single-session';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
function formatDateTime(value: string | null) {
|
||||
if (!value) return 'Not available';
|
||||
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
export function SecuritySessionCard({ session }: Props) {
|
||||
return (
|
||||
<article className="surface-card security-session-card">
|
||||
<div className="security-session-head">
|
||||
<div>
|
||||
<p className="page-eyebrow">Session</p>
|
||||
<h2>Current Admin Session</h2>
|
||||
<p className="page-copy">
|
||||
BizOne currently enforces a single active admin session. A fresh login rotates tokens and replaces the
|
||||
previous session.
|
||||
</p>
|
||||
</div>
|
||||
<span className="security-session-badge">Single Session Policy</span>
|
||||
</div>
|
||||
|
||||
<div className="security-session-grid">
|
||||
<div className="security-session-item">
|
||||
<strong>Signed in as</strong>
|
||||
<span>{session.user.name}</span>
|
||||
<small>{session.user.email}</small>
|
||||
</div>
|
||||
<div className="security-session-item">
|
||||
<strong>Access token issued</strong>
|
||||
<span>{formatDateTime(session.session.issuedAt)}</span>
|
||||
</div>
|
||||
<div className="security-session-item">
|
||||
<strong>Access token expires</strong>
|
||||
<span>{formatDateTime(session.session.expiresAt)}</span>
|
||||
</div>
|
||||
<div className="security-session-item">
|
||||
<strong>Refresh token expires</strong>
|
||||
<span>{formatDateTime(session.session.refreshExpiresAt)}</span>
|
||||
</div>
|
||||
<div className="security-session-item">
|
||||
<strong>Current IP</strong>
|
||||
<span>{session.session.currentIp || 'Unavailable'}</span>
|
||||
</div>
|
||||
<div className="security-session-item">
|
||||
<strong>Last successful login</strong>
|
||||
<span>{formatDateTime(session.user.lastLoginAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="security-session-actions">
|
||||
<form action={logoutAction}>
|
||||
<button type="submit" className="invite-submit-button">
|
||||
Sign Out and Revoke Session
|
||||
</button>
|
||||
</form>
|
||||
<p className="page-copy">
|
||||
Use this if you suspect account exposure. Signing out here invalidates the active refresh token and forces a
|
||||
fresh login.
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
149
frontend/src/components/set-password-card.tsx
Normal file
149
frontend/src/components/set-password-card.tsx
Normal file
@ -0,0 +1,149 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
type Props = {
|
||||
token: string;
|
||||
invitation: {
|
||||
email: string;
|
||||
name: string;
|
||||
roleName: string;
|
||||
expiresAt: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
function passwordChecks(password: string) {
|
||||
return {
|
||||
minLength: password.length >= 8,
|
||||
hasNumber: /\d/.test(password),
|
||||
hasSpecial: /[^A-Za-z0-9]/.test(password),
|
||||
};
|
||||
}
|
||||
|
||||
export function SetPasswordCard({ token, invitation }: Props) {
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
const checks = useMemo(() => passwordChecks(password), [password]);
|
||||
const strengthCount = [checks.minLength, checks.hasNumber, checks.hasSpecial].filter(Boolean).length;
|
||||
const strengthLabel = strengthCount <= 1 ? 'Weak' : strengthCount === 2 ? 'Medium' : 'Strong';
|
||||
|
||||
async function submit() {
|
||||
setError('');
|
||||
setMessage('');
|
||||
if (password !== confirmPassword) {
|
||||
setError('Password confirmation does not match.');
|
||||
return;
|
||||
}
|
||||
|
||||
setPending(true);
|
||||
try {
|
||||
const response = await fetch(`/api/invitations/${token}/complete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
setError(payload.message || 'Failed to activate account');
|
||||
return;
|
||||
}
|
||||
|
||||
setMessage('Account activated. You can now log in with your email and password.');
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="invite-page">
|
||||
<div className="invite-brand">
|
||||
<div className="invite-brand-mark">
|
||||
<span className="material-symbols-outlined">chat</span>
|
||||
</div>
|
||||
<span>WA Business</span>
|
||||
</div>
|
||||
|
||||
<main className="invite-card">
|
||||
<div className="invite-card-head">
|
||||
<h1>Set Your Password</h1>
|
||||
<p>
|
||||
Welcome, {invitation.name}. Create a secure password to activate your account for{' '}
|
||||
<strong>{invitation.email}</strong> as <strong>{invitation.roleName}</strong>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="invite-form">
|
||||
<label className="invite-field">
|
||||
<span>New Password</span>
|
||||
<div className="invite-password-wrap">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
<button type="button" onClick={() => setShowPassword((current) => !current)}>
|
||||
<span className="material-symbols-outlined">{showPassword ? 'visibility_off' : 'visibility'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="invite-field">
|
||||
<span>Confirm New Password</span>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(event) => setConfirmPassword(event.target.value)}
|
||||
placeholder="Repeat your password"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="invite-strength">
|
||||
<div className="invite-strength-head">
|
||||
<span>Password Strength</span>
|
||||
<strong>{strengthLabel}</strong>
|
||||
</div>
|
||||
<div className="invite-strength-bars">
|
||||
{[0, 1, 2, 3].map((index) => (
|
||||
<span key={index} className={index < strengthCount + 1 ? 'is-active' : ''} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="invite-checklist">
|
||||
<div className={checks.minLength ? 'is-done' : ''}>
|
||||
<span className="material-symbols-outlined">{checks.minLength ? 'check_circle' : 'circle'}</span>
|
||||
Minimum 8 characters
|
||||
</div>
|
||||
<div className={checks.hasNumber ? 'is-done' : ''}>
|
||||
<span className="material-symbols-outlined">{checks.hasNumber ? 'check_circle' : 'circle'}</span>
|
||||
At least one number
|
||||
</div>
|
||||
<div className={checks.hasSpecial ? 'is-done' : ''}>
|
||||
<span className="material-symbols-outlined">{checks.hasSpecial ? 'check_circle' : 'circle'}</span>
|
||||
One special character (@, #, $, etc.)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" className="invite-submit-button" onClick={submit} disabled={pending}>
|
||||
{pending ? 'Activating...' : 'Activate Account'}
|
||||
</button>
|
||||
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
{message ? <p className="form-success">{message}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="invite-footer">
|
||||
<span>Need help? Contact your admin or</span>
|
||||
<Link href="/login">go to login</Link>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
372
frontend/src/components/template-builder-form.tsx
Normal file
372
frontend/src/components/template-builder-form.tsx
Normal file
@ -0,0 +1,372 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
type TemplateButton = {
|
||||
type: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type TemplateRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
status: string;
|
||||
language: string;
|
||||
headerText: string | null;
|
||||
bodyText: string;
|
||||
footerText: string | null;
|
||||
buttons: TemplateButton[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
initialTemplate: TemplateRecord | null;
|
||||
};
|
||||
|
||||
const sampleVariableValues = ['Alex', 'SUMMER50', 'Order-481', 'May 15', 'BizOne'];
|
||||
|
||||
function substituteVariables(input: string) {
|
||||
return input.replace(/\{\{(\d+)\}\}/g, (_, token) => {
|
||||
const index = Number(token) - 1;
|
||||
return `[${sampleVariableValues[index] || `Value ${token}`}]`;
|
||||
});
|
||||
}
|
||||
|
||||
function countTemplateVariables(input: string) {
|
||||
return new Set(input.match(/\{\{\d+\}\}/g) || []).size;
|
||||
}
|
||||
|
||||
export function TemplateBuilderForm({ initialTemplate }: Props) {
|
||||
const router = useRouter();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [form, setForm] = useState({
|
||||
name: initialTemplate?.name || '',
|
||||
category: initialTemplate?.category || 'Marketing',
|
||||
status: initialTemplate?.status || 'Draft',
|
||||
language: initialTemplate?.language || 'en_US',
|
||||
headerText: initialTemplate?.headerText || '',
|
||||
bodyText:
|
||||
initialTemplate?.bodyText ||
|
||||
`Hi {{1}}, our Summer Sale is finally here! 🌴
|
||||
|
||||
Get up to 50% OFF on all collections using code {{2}} at checkout.
|
||||
|
||||
Shop now: https://example.com/shop`,
|
||||
footerText: initialTemplate?.footerText || '',
|
||||
buttons:
|
||||
initialTemplate?.buttons?.length
|
||||
? initialTemplate.buttons
|
||||
: [{ type: 'quick_reply', label: 'Stop promotions' }],
|
||||
});
|
||||
|
||||
const variableCount = useMemo(() => countTemplateVariables(form.bodyText), [form.bodyText]);
|
||||
const previewBody = useMemo(() => substituteVariables(form.bodyText), [form.bodyText]);
|
||||
const previewHeader = useMemo(() => substituteVariables(form.headerText), [form.headerText]);
|
||||
const previewFooter = useMemo(() => substituteVariables(form.footerText), [form.footerText]);
|
||||
|
||||
async function submit(intent: 'draft' | 'submit') {
|
||||
setIsSaving(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
...form,
|
||||
status: intent === 'submit' ? 'Pending' : 'Draft',
|
||||
headerText: form.headerText.trim() || undefined,
|
||||
footerText: form.footerText.trim() || undefined,
|
||||
buttons: form.buttons
|
||||
.map((button) => ({
|
||||
type: button.type || 'quick_reply',
|
||||
label: button.label.trim(),
|
||||
}))
|
||||
.filter((button) => button.label.length > 0),
|
||||
};
|
||||
|
||||
const response = await fetch(initialTemplate ? `/api/templates/${initialTemplate.id}` : '/api/templates', {
|
||||
method: initialTemplate ? 'PATCH' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!response.ok) {
|
||||
const message = typeof result?.message === 'string' ? result.message : 'Failed to save template';
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
setSuccess(intent === 'submit' ? 'Template submitted for approval.' : 'Draft saved.');
|
||||
|
||||
if (!initialTemplate?.id && result?.id) {
|
||||
router.push(`/dashboard/templates/builder?id=${result.id}`);
|
||||
} else {
|
||||
router.refresh();
|
||||
}
|
||||
} catch (submitError) {
|
||||
setError(submitError instanceof Error ? submitError.message : 'Failed to save template');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="template-builder-page">
|
||||
<header className="template-builder-hero">
|
||||
<div>
|
||||
<h1 className="template-builder-heading">
|
||||
{initialTemplate ? 'Edit Message Template' : 'Create Message Template'}
|
||||
</h1>
|
||||
<p className="template-builder-copy">
|
||||
Design and manage your business messages before they are approved for outbound delivery.
|
||||
</p>
|
||||
</div>
|
||||
<div className="template-builder-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="template-builder-draft-button"
|
||||
disabled={isSaving}
|
||||
onClick={() => submit('draft')}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Draft'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="template-builder-submit-button"
|
||||
disabled={isSaving}
|
||||
onClick={() => submit('submit')}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Submit for Approval'}
|
||||
<span className="material-symbols-outlined">send</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
{success ? <p className="form-success">{success}</p> : null}
|
||||
|
||||
<section className="template-builder-grid">
|
||||
<div className="template-builder-form-column">
|
||||
<article className="surface-card template-builder-card">
|
||||
<div className="template-builder-card-head">
|
||||
<span className="material-symbols-outlined">edit_note</span>
|
||||
<h2>Basic Details</h2>
|
||||
</div>
|
||||
<div className="template-builder-basic-grid">
|
||||
<label className="template-builder-field template-builder-field-full">
|
||||
<span>Template Name</span>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
|
||||
placeholder="e.g. welcome_message"
|
||||
/>
|
||||
</label>
|
||||
<label className="template-builder-field">
|
||||
<span>Category</span>
|
||||
<select
|
||||
value={form.category}
|
||||
onChange={(event) => setForm((current) => ({ ...current, category: event.target.value }))}
|
||||
>
|
||||
<option value="Marketing">Marketing</option>
|
||||
<option value="Utility">Utility</option>
|
||||
<option value="Authentication">Authentication</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="template-builder-field">
|
||||
<span>Language</span>
|
||||
<select
|
||||
value={form.language}
|
||||
onChange={(event) => setForm((current) => ({ ...current, language: event.target.value }))}
|
||||
>
|
||||
<option value="en_US">English (US)</option>
|
||||
<option value="id_ID">Bahasa Indonesia</option>
|
||||
<option value="pt_BR">Portuguese (BR)</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="surface-card template-builder-card">
|
||||
<div className="template-builder-card-topline">
|
||||
<div className="template-builder-card-head">
|
||||
<span className="material-symbols-outlined">subject</span>
|
||||
<h2>Message Content</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="template-builder-content-stack">
|
||||
<label className="template-builder-field">
|
||||
<span>Header (Optional)</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Add a title or choose media"
|
||||
value={form.headerText}
|
||||
onChange={(event) => setForm((current) => ({ ...current, headerText: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="template-builder-field">
|
||||
<span>Body Text</span>
|
||||
<textarea
|
||||
rows={8}
|
||||
value={form.bodyText}
|
||||
onChange={(event) => setForm((current) => ({ ...current, bodyText: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<div className="template-builder-inline-meta">
|
||||
<span>Variables detected: {variableCount}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
bodyText: `${current.bodyText}${current.bodyText.endsWith(' ') || current.bodyText.endsWith('\n') ? '' : ' '}{{${variableCount + 1}}}`,
|
||||
}))
|
||||
}
|
||||
>
|
||||
Add Variable
|
||||
</button>
|
||||
</div>
|
||||
<label className="template-builder-field">
|
||||
<span>Footer (Optional)</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Add a short line of text"
|
||||
value={form.footerText}
|
||||
onChange={(event) => setForm((current) => ({ ...current, footerText: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="surface-card template-builder-card">
|
||||
<div className="template-builder-card-head">
|
||||
<span className="material-symbols-outlined">smart_button</span>
|
||||
<h2>Buttons</h2>
|
||||
</div>
|
||||
<div className="template-builder-button-stack">
|
||||
{form.buttons.map((button, index) => (
|
||||
<div key={`${index}-${button.label}`} className="template-builder-button-row">
|
||||
<span className="material-symbols-outlined">ads_click</span>
|
||||
<div>
|
||||
<strong>Quick Reply</strong>
|
||||
<input
|
||||
type="text"
|
||||
value={button.label}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
buttons: current.buttons.map((item, itemIndex) =>
|
||||
itemIndex === index ? { ...item, label: event.target.value } : item,
|
||||
),
|
||||
}))
|
||||
}
|
||||
placeholder="Button label"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="template-builder-delete-button"
|
||||
aria-label="Delete quick reply"
|
||||
onClick={() =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
buttons: current.buttons.filter((_, itemIndex) => itemIndex !== index),
|
||||
}))
|
||||
}
|
||||
>
|
||||
<span className="material-symbols-outlined">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="template-builder-add-button"
|
||||
onClick={() =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
buttons: [...current.buttons, { type: 'quick_reply', label: '' }].slice(0, 10),
|
||||
}))
|
||||
}
|
||||
>
|
||||
<span className="material-symbols-outlined">add</span>
|
||||
Add Button (Max 10)
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<aside className="template-builder-preview-column">
|
||||
<div className="template-builder-preview-shell">
|
||||
<div className="template-builder-phone-frame">
|
||||
<div className="template-builder-phone-status">
|
||||
<span>9:41</span>
|
||||
<div>
|
||||
<span className="material-symbols-outlined">signal_cellular_4_bar</span>
|
||||
<span className="material-symbols-outlined">wifi</span>
|
||||
<span className="material-symbols-outlined">battery_full</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="template-builder-phone-screen">
|
||||
<div className="template-builder-phone-header">
|
||||
<span className="material-symbols-outlined">arrow_back</span>
|
||||
<div className="template-builder-phone-avatar">Y</div>
|
||||
<div className="template-builder-phone-contact">
|
||||
<strong>Your Business</strong>
|
||||
<span>online</span>
|
||||
</div>
|
||||
<span className="material-symbols-outlined">videocam</span>
|
||||
<span className="material-symbols-outlined">call</span>
|
||||
<span className="material-symbols-outlined">more_vert</span>
|
||||
</div>
|
||||
|
||||
<div className="template-builder-chat-area">
|
||||
<div className="template-builder-chat-date">TODAY</div>
|
||||
<div className="template-builder-chat-stack">
|
||||
<div className="template-builder-chat-bubble">
|
||||
{previewHeader ? <strong>{previewHeader}</strong> : null}
|
||||
<p>{previewBody}</p>
|
||||
{previewFooter ? <small>{previewFooter}</small> : null}
|
||||
<div className="template-builder-chat-meta">
|
||||
<small>09:41 AM</small>
|
||||
<span className="material-symbols-outlined">done_all</span>
|
||||
</div>
|
||||
</div>
|
||||
{form.buttons
|
||||
.filter((button) => button.label.trim().length > 0)
|
||||
.map((button, index) => (
|
||||
<button key={`${button.label}-${index}`} type="button" className="template-builder-chat-reply">
|
||||
<span className="material-symbols-outlined">reply</span>
|
||||
{button.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="template-builder-input-row">
|
||||
<div className="template-builder-input-shell">
|
||||
<span className="material-symbols-outlined">mood</span>
|
||||
<span className="template-builder-input-placeholder">Message</span>
|
||||
<span className="material-symbols-outlined">attach_file</span>
|
||||
<span className="material-symbols-outlined">photo_camera</span>
|
||||
</div>
|
||||
<div className="template-builder-mic-button">
|
||||
<span className="material-symbols-outlined">mic</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="template-builder-preview-toggle">
|
||||
<button type="button" className="is-active">
|
||||
Mobile
|
||||
</button>
|
||||
<button type="button">Desktop</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
81
frontend/src/components/two-factor-login-card.tsx
Normal file
81
frontend/src/components/two-factor-login-card.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useActionState } from 'react';
|
||||
import { verifyTwoFactorLoginAction } from '../app/actions';
|
||||
|
||||
type Props = {
|
||||
email: string;
|
||||
languageSwitcher: ReactNode;
|
||||
};
|
||||
|
||||
const initialState: { error?: string } = {};
|
||||
|
||||
export function TwoFactorLoginCard({ email, languageSwitcher }: Props) {
|
||||
const [state, formAction, pending] = useActionState(verifyTwoFactorLoginAction, initialState);
|
||||
|
||||
return (
|
||||
<main className="auth-page auth-page-enterprise auth-page-login">
|
||||
<div className="auth-enterprise-glow auth-enterprise-glow-right" />
|
||||
<div className="auth-enterprise-glow auth-enterprise-glow-left" />
|
||||
<div className="auth-page-symbol auth-page-symbol-top">
|
||||
<span className="material-symbols-outlined">shield_lock</span>
|
||||
</div>
|
||||
<div className="auth-page-symbol auth-page-symbol-bottom">
|
||||
<span className="material-symbols-outlined">qr_code_2</span>
|
||||
</div>
|
||||
|
||||
<section className="two-factor-shell auth-container-login">
|
||||
<div className="auth-login-toolbar">
|
||||
<div className="auth-login-locale">{languageSwitcher}</div>
|
||||
</div>
|
||||
|
||||
<header className="two-factor-brand">
|
||||
<div className="two-factor-mark">
|
||||
<span className="material-symbols-outlined">shield_lock</span>
|
||||
</div>
|
||||
<div className="auth-brand-copy">
|
||||
<h1>BizOne</h1>
|
||||
<p>Verify the final step of your admin login with a TOTP code or a recovery code.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="two-factor-card">
|
||||
<div className="two-factor-copy auth-public-copy">
|
||||
<h2>Enter Authenticator Code</h2>
|
||||
<p>
|
||||
Complete login for <strong>{email}</strong> with the 6-digit code from your authenticator app or a
|
||||
one-time backup code.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form action={formAction} className="invite-form">
|
||||
<label className="invite-field">
|
||||
<span>Authentication or Backup Code</span>
|
||||
<input
|
||||
name="code"
|
||||
type="text"
|
||||
inputMode="text"
|
||||
pattern="(\d{6}|[A-Za-z0-9]{4}-?[A-Za-z0-9]{4})"
|
||||
maxLength={9}
|
||||
placeholder="123456 or ABCD-EF12"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button type="submit" className="invite-submit-button" disabled={pending}>
|
||||
{pending ? 'Verifying...' : 'Verify and Continue'}
|
||||
</button>
|
||||
|
||||
{state.error ? <p className="form-error">{state.error}</p> : null}
|
||||
</form>
|
||||
|
||||
<div className="two-factor-meta">
|
||||
<span className="auth-public-meta">Backup codes work once and should be stored offline.</span>
|
||||
<Link href="/login" className="text-link">Back to Login</Link>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
111
frontend/src/components/two-factor-placeholder.tsx
Normal file
111
frontend/src/components/two-factor-placeholder.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
type Props = {
|
||||
appName: string;
|
||||
labels: {
|
||||
title: string;
|
||||
description: string;
|
||||
verify: string;
|
||||
backToLogin: string;
|
||||
needHelp: string;
|
||||
appTitle: string;
|
||||
appDescription: string;
|
||||
securityTitle: string;
|
||||
securityDescription: string;
|
||||
placeholderBadge: string;
|
||||
placeholderBody: string;
|
||||
};
|
||||
};
|
||||
|
||||
function ShieldIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 3 5 6v5c0 4.7 2.8 8.9 7 10 4.2-1.1 7-5.3 7-10V6l-7-3Z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M8 2h8a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Zm4 17.5a1 1 0 1 0 0 .01Z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function VerifiedIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="m10 14.8-2.8-2.8-1.4 1.4L10 17.6l8-8-1.4-1.4-6.6 6.6Z" fill="currentColor" />
|
||||
<path d="M12 2 4 5v6c0 5.2 3.3 9.8 8 11 4.7-1.2 8-5.8 8-11V5l-8-3Z" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TwoFactorPlaceholder({ appName, labels }: Props) {
|
||||
return (
|
||||
<main className="auth-page auth-page-enterprise">
|
||||
<section className="two-factor-shell">
|
||||
<header className="two-factor-brand">
|
||||
<div className="two-factor-mark">
|
||||
<ShieldIcon />
|
||||
</div>
|
||||
<h1>{appName}</h1>
|
||||
<p>{labels.placeholderBadge}</p>
|
||||
</header>
|
||||
|
||||
<section className="two-factor-card">
|
||||
<div className="two-factor-copy">
|
||||
<h2>{labels.title}</h2>
|
||||
<p>{labels.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="otp-row" aria-hidden="true">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<div key={index} className="otp-box">0</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button type="button" className="auth-submit" disabled>
|
||||
{labels.verify}
|
||||
</button>
|
||||
|
||||
<div className="two-factor-meta">
|
||||
<Link href="/login" className="text-link">
|
||||
{labels.backToLogin}
|
||||
</Link>
|
||||
<button type="button" className="text-link">
|
||||
{labels.needHelp}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="placeholder-note">
|
||||
<strong>{labels.placeholderBadge}</strong>
|
||||
<p>{labels.placeholderBody}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="two-factor-info-grid">
|
||||
<article className="security-info-card">
|
||||
<span className="security-icon">
|
||||
<DeviceIcon />
|
||||
</span>
|
||||
<div>
|
||||
<h3>{labels.appTitle}</h3>
|
||||
<p>{labels.appDescription}</p>
|
||||
</div>
|
||||
</article>
|
||||
<article className="security-info-card">
|
||||
<span className="security-icon">
|
||||
<VerifiedIcon />
|
||||
</span>
|
||||
<div>
|
||||
<h3>{labels.securityTitle}</h3>
|
||||
<p>{labels.securityDescription}</p>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
200
frontend/src/components/two-factor-settings-card.tsx
Normal file
200
frontend/src/components/two-factor-settings-card.tsx
Normal file
@ -0,0 +1,200 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState, useMemo } from 'react';
|
||||
import {
|
||||
confirmTwoFactorSetupAction,
|
||||
disableTwoFactorAction,
|
||||
initiateTwoFactorSetupAction,
|
||||
regenerateTwoFactorRecoveryCodesAction,
|
||||
} from '../app/actions';
|
||||
|
||||
type Props = {
|
||||
status: {
|
||||
enabled: boolean;
|
||||
pendingSetup: boolean;
|
||||
confirmedAt: string | null;
|
||||
recoveryCodesRemaining: number;
|
||||
};
|
||||
};
|
||||
|
||||
const initialState: {
|
||||
error?: string;
|
||||
success?: string;
|
||||
manualEntryKey?: string;
|
||||
otpauthUrl?: string;
|
||||
qrCodeDataUrl?: string;
|
||||
recoveryCodes?: string[];
|
||||
} = {};
|
||||
|
||||
function RecoveryCodesPanel({ codes, title }: { codes: string[]; title: string }) {
|
||||
if (codes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="invite-form">
|
||||
<strong>{title}</strong>
|
||||
<p className="page-copy">Simpan kode ini sekarang. Setiap kode hanya bisa dipakai sekali saat login darurat.</p>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
|
||||
gap: '0.75rem',
|
||||
}}
|
||||
>
|
||||
{codes.map((code) => (
|
||||
<div
|
||||
key={code}
|
||||
style={{
|
||||
border: '1px solid var(--border-subtle)',
|
||||
borderRadius: '0.9rem',
|
||||
padding: '0.8rem 1rem',
|
||||
background: 'var(--surface-muted)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.08em',
|
||||
}}
|
||||
>
|
||||
{code}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TwoFactorSettingsCard({ status }: Props) {
|
||||
const [setupState, setupAction, setupPending] = useActionState(initiateTwoFactorSetupAction, initialState);
|
||||
const [confirmState, confirmAction, confirmPending] = useActionState(confirmTwoFactorSetupAction, initialState);
|
||||
const [regenerateState, regenerateAction, regeneratePending] = useActionState(
|
||||
regenerateTwoFactorRecoveryCodesAction,
|
||||
initialState,
|
||||
);
|
||||
const [disableState, disableAction, disablePending] = useActionState(disableTwoFactorAction, initialState);
|
||||
|
||||
const manualEntryKey = setupState.manualEntryKey;
|
||||
const otpauthUrl = setupState.otpauthUrl;
|
||||
const qrCodeDataUrl = setupState.qrCodeDataUrl;
|
||||
const hasPendingSecret = status.pendingSetup || Boolean(manualEntryKey);
|
||||
const confirmedLabel = useMemo(() => {
|
||||
if (!status.confirmedAt) return 'Not enabled';
|
||||
return new Date(status.confirmedAt).toLocaleString();
|
||||
}, [status.confirmedAt]);
|
||||
|
||||
return (
|
||||
<article className="surface-card">
|
||||
<h2>Two-Factor Authentication</h2>
|
||||
<p className="page-copy">
|
||||
Protect admin login with a time-based code from Google Authenticator, 1Password, or another TOTP app.
|
||||
</p>
|
||||
|
||||
<div className="metric-stack">
|
||||
<div>
|
||||
<strong>Status</strong>
|
||||
<span>{status.enabled ? 'Enabled' : 'Disabled'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Confirmed At</strong>
|
||||
<span>{confirmedLabel}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Backup Codes Remaining</strong>
|
||||
<span>{status.recoveryCodesRemaining}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!status.enabled ? (
|
||||
<form action={setupAction} className="invite-form">
|
||||
<button type="submit" className="invite-submit-button" disabled={setupPending}>
|
||||
{setupPending ? 'Preparing...' : 'Start 2FA Setup'}
|
||||
</button>
|
||||
{setupState.error ? <p className="form-error">{setupState.error}</p> : null}
|
||||
{setupState.success ? <p className="form-success">{setupState.success}</p> : null}
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{hasPendingSecret && !status.enabled ? (
|
||||
<div className="invite-form">
|
||||
{qrCodeDataUrl ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
justifyItems: 'start',
|
||||
gap: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={qrCodeDataUrl}
|
||||
alt="Scan this QR code in your authenticator app"
|
||||
width={220}
|
||||
height={220}
|
||||
style={{ borderRadius: '1rem', border: '1px solid var(--border-subtle)', background: '#fff' }}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<label className="invite-field">
|
||||
<span>Manual Entry Key</span>
|
||||
<input type="text" readOnly value={manualEntryKey || ''} />
|
||||
</label>
|
||||
{otpauthUrl ? (
|
||||
<label className="invite-field">
|
||||
<span>OTPAuth URL</span>
|
||||
<textarea readOnly rows={4} value={otpauthUrl} />
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
<form action={confirmAction} className="invite-form">
|
||||
<label className="invite-field">
|
||||
<span>Verification Code</span>
|
||||
<input name="code" type="text" inputMode="numeric" pattern="[0-9]{6}" maxLength={6} placeholder="123456" />
|
||||
</label>
|
||||
<button type="submit" className="invite-submit-button" disabled={confirmPending}>
|
||||
{confirmPending ? 'Verifying...' : 'Confirm and Enable 2FA'}
|
||||
</button>
|
||||
{confirmState.error ? <p className="form-error">{confirmState.error}</p> : null}
|
||||
{confirmState.success ? <p className="form-success">{confirmState.success}</p> : null}
|
||||
</form>
|
||||
|
||||
<RecoveryCodesPanel
|
||||
codes={confirmState.recoveryCodes || []}
|
||||
title="Backup Recovery Codes"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{status.enabled ? (
|
||||
<>
|
||||
<form action={regenerateAction} className="invite-form">
|
||||
<label className="invite-field">
|
||||
<span>Current 2FA Code to Regenerate Backup Codes</span>
|
||||
<input name="code" type="text" inputMode="numeric" pattern="[0-9]{6}" maxLength={6} placeholder="123456" />
|
||||
</label>
|
||||
<button type="submit" className="invite-submit-button" disabled={regeneratePending}>
|
||||
{regeneratePending ? 'Regenerating...' : 'Regenerate Backup Codes'}
|
||||
</button>
|
||||
{regenerateState.error ? <p className="form-error">{regenerateState.error}</p> : null}
|
||||
{regenerateState.success ? <p className="form-success">{regenerateState.success}</p> : null}
|
||||
</form>
|
||||
|
||||
<RecoveryCodesPanel
|
||||
codes={regenerateState.recoveryCodes || []}
|
||||
title="New Backup Recovery Codes"
|
||||
/>
|
||||
|
||||
<form action={disableAction} className="invite-form">
|
||||
<label className="invite-field">
|
||||
<span>Current 2FA Code</span>
|
||||
<input name="code" type="text" inputMode="numeric" pattern="[0-9]{6}" maxLength={6} placeholder="123456" />
|
||||
</label>
|
||||
<button type="submit" className="invite-submit-button" disabled={disablePending}>
|
||||
{disablePending ? 'Disabling...' : 'Disable 2FA'}
|
||||
</button>
|
||||
{disableState.error ? <p className="form-error">{disableState.error}</p> : null}
|
||||
{disableState.success ? <p className="form-success">{disableState.success}</p> : null}
|
||||
</form>
|
||||
</>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
770
frontend/src/components/users-management-board.tsx
Normal file
770
frontend/src/components/users-management-board.tsx
Normal file
@ -0,0 +1,770 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
type UserRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
status: 'invited' | 'active' | 'inactive' | 'suspended';
|
||||
roleId: string | null;
|
||||
roleName: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastLoginAt: string | null;
|
||||
emailVerifiedAt: string | null;
|
||||
};
|
||||
|
||||
type RoleOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
initialUsers: UserRecord[];
|
||||
initialTotal: number;
|
||||
initialPage: number;
|
||||
initialPageSize: number;
|
||||
initialTotalPages: number;
|
||||
availableRoles: RoleOption[];
|
||||
};
|
||||
|
||||
type PanelMode = 'create' | 'edit' | null;
|
||||
|
||||
function formatLastSeen(value: string | null, status: UserRecord['status']) {
|
||||
if (status === 'invited') return 'Pending invite';
|
||||
if (!value) return 'Never';
|
||||
|
||||
const diffMs = Date.now() - new Date(value).getTime();
|
||||
const minutes = Math.max(1, Math.floor(diffMs / 60000));
|
||||
if (minutes < 60) return 'Just now';
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days} day${days === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
|
||||
function formatStatusLabel(status: UserRecord['status']) {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'Active';
|
||||
case 'invited':
|
||||
return 'Invited';
|
||||
case 'inactive':
|
||||
return 'Inactive';
|
||||
case 'suspended':
|
||||
return 'Suspended';
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusTone(status: UserRecord['status']) {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'success';
|
||||
case 'invited':
|
||||
return 'warning';
|
||||
case 'suspended':
|
||||
return 'error';
|
||||
case 'inactive':
|
||||
return 'muted';
|
||||
}
|
||||
}
|
||||
|
||||
function getRoleTone(roleName: string) {
|
||||
const normalized = roleName.toLowerCase();
|
||||
if (normalized.includes('admin')) return 'admin';
|
||||
if (normalized.includes('editor')) return 'editor';
|
||||
return 'agent';
|
||||
}
|
||||
|
||||
function initials(name: string) {
|
||||
return name
|
||||
.split(/\s+/)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase() || '')
|
||||
.join('');
|
||||
}
|
||||
|
||||
const roleDescriptions: Record<string, string> = {
|
||||
Admin: 'Full system access, including billing, user management, and API settings.',
|
||||
Editor: 'Manage broadcasts and message templates, but cannot change system settings.',
|
||||
Agent: 'Limited to handling conversations, contacts, and operational response flow.',
|
||||
};
|
||||
|
||||
export function UsersManagementBoard({
|
||||
initialUsers,
|
||||
initialTotal,
|
||||
initialPage,
|
||||
initialPageSize,
|
||||
initialTotalPages,
|
||||
availableRoles,
|
||||
}: Props) {
|
||||
const [users, setUsers] = useState(initialUsers);
|
||||
const [total, setTotal] = useState(initialTotal);
|
||||
const [page, setPage] = useState(initialPage);
|
||||
const [pageSize] = useState(initialPageSize);
|
||||
const [totalPages, setTotalPages] = useState(initialTotalPages);
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [roleFilter, setRoleFilter] = useState('all');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [panelMode, setPanelMode] = useState<PanelMode>(null);
|
||||
const [editingUserId, setEditingUserId] = useState<string | null>(null);
|
||||
const [feedback, setFeedback] = useState('');
|
||||
const [inviteName, setInviteName] = useState('');
|
||||
const [inviteEmail, setInviteEmail] = useState('');
|
||||
const [inviteRoleId, setInviteRoleId] = useState(availableRoles[0]?.id || '');
|
||||
const [editName, setEditName] = useState('');
|
||||
const [editEmail, setEditEmail] = useState('');
|
||||
const [editRoleId, setEditRoleId] = useState('');
|
||||
const [editStatus, setEditStatus] = useState<UserRecord['status']>('active');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const editingUser = useMemo(
|
||||
() => users.find((user) => user.id === editingUserId) ?? null,
|
||||
[editingUserId, users],
|
||||
);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const totalUsers = total;
|
||||
const pendingInvites = users.filter((user) => user.status === 'invited').length;
|
||||
const activeRecently = users.filter((user) => {
|
||||
if (!user.lastLoginAt) return false;
|
||||
return Date.now() - new Date(user.lastLoginAt).getTime() <= 24 * 60 * 60 * 1000;
|
||||
}).length;
|
||||
return { totalUsers, pendingInvites, activeRecently };
|
||||
}, [total, users]);
|
||||
|
||||
const visibleRoles = useMemo(() => {
|
||||
const base = availableRoles.map((role) => role.name);
|
||||
return base.slice(0, 3);
|
||||
}, [availableRoles]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = window.setTimeout(() => {
|
||||
setDebouncedSearch(search.trim());
|
||||
setPage(1);
|
||||
}, 250);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [search]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', String(page));
|
||||
params.set('limit', String(pageSize));
|
||||
if (debouncedSearch) params.set('search', debouncedSearch);
|
||||
if (statusFilter !== 'all') params.set('status', statusFilter);
|
||||
if (roleFilter !== 'all') params.set('roleId', roleFilter);
|
||||
|
||||
const response = await fetch(`/api/users?${params.toString()}`, {
|
||||
signal: controller.signal,
|
||||
cache: 'no-store',
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
setFeedback(payload.message || 'Failed to load users');
|
||||
return;
|
||||
}
|
||||
|
||||
setUsers(payload.items);
|
||||
setTotal(payload.total);
|
||||
setTotalPages(payload.totalPages);
|
||||
} catch (error) {
|
||||
if ((error as Error).name !== 'AbortError') {
|
||||
setFeedback('Failed to load users');
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadUsers();
|
||||
return () => controller.abort();
|
||||
}, [debouncedSearch, page, pageSize, roleFilter, statusFilter]);
|
||||
|
||||
function closePanel() {
|
||||
setPanelMode(null);
|
||||
setEditingUserId(null);
|
||||
}
|
||||
|
||||
function openCreatePanel() {
|
||||
setPanelMode('create');
|
||||
setEditingUserId(null);
|
||||
setFeedback('');
|
||||
}
|
||||
|
||||
function beginEdit(user: UserRecord) {
|
||||
setPanelMode('edit');
|
||||
setEditingUserId(user.id);
|
||||
setEditName(user.name);
|
||||
setEditEmail(user.email);
|
||||
setEditRoleId(user.roleId || '');
|
||||
setEditStatus(user.status);
|
||||
setFeedback('');
|
||||
}
|
||||
|
||||
async function submitInvite() {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const response = await fetch('/api/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: inviteName,
|
||||
email: inviteEmail,
|
||||
roleId: inviteRoleId || undefined,
|
||||
}),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
setFeedback(payload.message || 'Failed to invite user');
|
||||
return;
|
||||
}
|
||||
|
||||
setFeedback(
|
||||
payload.emailSent
|
||||
? `Invitation sent to ${payload.email}.`
|
||||
: `User invited, but email was not sent. Activation link: ${payload.invitationUrl}`,
|
||||
);
|
||||
setInviteName('');
|
||||
setInviteEmail('');
|
||||
setInviteRoleId(availableRoles[0]?.id || '');
|
||||
closePanel();
|
||||
setPage(1);
|
||||
setDebouncedSearch('');
|
||||
setSearch('');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitEdit() {
|
||||
if (!editingUser) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const response = await fetch(`/api/users/${editingUser.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: editName,
|
||||
email: editEmail,
|
||||
roleId: editRoleId || undefined,
|
||||
status: editStatus,
|
||||
}),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
setFeedback(payload.message || 'Failed to update user');
|
||||
return;
|
||||
}
|
||||
|
||||
setUsers((current) =>
|
||||
current.map((user) =>
|
||||
user.id === payload.id
|
||||
? {
|
||||
...user,
|
||||
name: payload.name,
|
||||
email: payload.email,
|
||||
status: payload.status,
|
||||
roleId: payload.roleId,
|
||||
roleName: payload.roleName,
|
||||
lastLoginAt: payload.lastLoginAt,
|
||||
emailVerifiedAt: payload.emailVerifiedAt,
|
||||
}
|
||||
: user,
|
||||
),
|
||||
);
|
||||
setFeedback(`User ${payload.email} updated.`);
|
||||
closePanel();
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function resendInvite(user: UserRecord) {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const response = await fetch('/api/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
roleId: user.roleId || undefined,
|
||||
}),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
setFeedback(payload.message || 'Failed to resend invite');
|
||||
return;
|
||||
}
|
||||
|
||||
setFeedback(
|
||||
payload.emailSent
|
||||
? `Invitation resent to ${payload.email}.`
|
||||
: `Invite refreshed, but email was not sent. Activation link: ${payload.invitationUrl}`,
|
||||
);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
setSearch('');
|
||||
setDebouncedSearch('');
|
||||
setStatusFilter('all');
|
||||
setRoleFilter('all');
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
function exportUsers() {
|
||||
const params = new URLSearchParams();
|
||||
if (debouncedSearch) params.set('search', debouncedSearch);
|
||||
if (statusFilter !== 'all') params.set('status', statusFilter);
|
||||
if (roleFilter !== 'all') params.set('roleId', roleFilter);
|
||||
window.location.href = `/api/users/export${params.size ? `?${params.toString()}` : ''}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="users-board">
|
||||
<section className="page-header split users-page-header">
|
||||
<div>
|
||||
<p className="page-eyebrow">Users</p>
|
||||
<h1 className="page-heading">User Management</h1>
|
||||
<p className="page-copy">Manage team access levels, roles, and security permissions.</p>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<button type="button" className="users-hero-button" onClick={openCreatePanel}>
|
||||
<span className="material-symbols-outlined">person_add</span>
|
||||
Create User
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="users-stats-grid">
|
||||
<article className="users-stat-card">
|
||||
<div>
|
||||
<p className="users-stat-label">Total Users</p>
|
||||
<h3>{stats.totalUsers}</h3>
|
||||
<p className="users-stat-copy">
|
||||
<span className="material-symbols-outlined">trending_up</span>
|
||||
Directory accounts
|
||||
</p>
|
||||
</div>
|
||||
<div className="users-stat-icon tone-primary">
|
||||
<span className="material-symbols-outlined">group</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="users-stat-card">
|
||||
<div>
|
||||
<p className="users-stat-label">Pending Invites</p>
|
||||
<h3>{stats.pendingInvites}</h3>
|
||||
<p className="users-stat-copy is-warning">
|
||||
<span className="material-symbols-outlined">schedule</span>
|
||||
Awaiting response
|
||||
</p>
|
||||
</div>
|
||||
<div className="users-stat-icon tone-secondary">
|
||||
<span className="material-symbols-outlined">mail</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="users-stat-card is-accent">
|
||||
<div className="users-stat-content">
|
||||
<p className="users-stat-label">Active Sessions</p>
|
||||
<h3>{stats.activeRecently}</h3>
|
||||
<p className="users-stat-copy">Current real-time activity</p>
|
||||
</div>
|
||||
<div className="users-stat-icon tone-contrast">
|
||||
<span className="material-symbols-outlined">bolt</span>
|
||||
</div>
|
||||
<div className="users-stat-glow" />
|
||||
</article>
|
||||
</section>
|
||||
|
||||
{panelMode === 'create' ? (
|
||||
<section className="users-panel-layout">
|
||||
<div className="users-create-main">
|
||||
<article className="users-form-card create">
|
||||
<div className="users-form-head create">
|
||||
<div>
|
||||
<p className="card-kicker">Settings / Team Management</p>
|
||||
<h2>Add New Team Member</h2>
|
||||
<p>Invite a new administrator or support agent to your business dashboard.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="users-create-section">
|
||||
<h3>
|
||||
<span className="material-symbols-outlined">person_add</span>
|
||||
Profile Information
|
||||
</h3>
|
||||
<div className="users-form-grid">
|
||||
<label className="users-form-field">
|
||||
<span>Full Name</span>
|
||||
<input value={inviteName} onChange={(event) => setInviteName(event.target.value)} placeholder="e.g. Sarah Jenkins" />
|
||||
</label>
|
||||
<label className="users-form-field">
|
||||
<span>Email Address</span>
|
||||
<input value={inviteEmail} onChange={(event) => setInviteEmail(event.target.value)} placeholder="sarah.j@enterprise.com" />
|
||||
</label>
|
||||
<label className="users-form-field">
|
||||
<span>Phone Number (Optional)</span>
|
||||
<input placeholder="+1 (555) 000-0000" disabled />
|
||||
</label>
|
||||
<label className="users-form-field">
|
||||
<span>Role Selection</span>
|
||||
<select value={inviteRoleId} onChange={(event) => setInviteRoleId(event.target.value)}>
|
||||
{availableRoles.map((role) => (
|
||||
<option key={role.id} value={role.id}>
|
||||
{role.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="users-footer-actions">
|
||||
<button type="button" className="secondary-button" onClick={closePanel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" className="users-hero-button" onClick={submitInvite} disabled={isSaving}>
|
||||
<span className="material-symbols-outlined">send</span>
|
||||
{isSaving ? 'Creating...' : 'Create User'}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<aside className="users-create-side">
|
||||
<article className="users-summary-card">
|
||||
<h4>Summary</h4>
|
||||
<div className="users-summary-list">
|
||||
<div>
|
||||
<span>Role</span>
|
||||
<strong>
|
||||
{availableRoles.find((role) => role.id === inviteRoleId)?.name || 'Support Agent'}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Status</span>
|
||||
<strong>Draft Invitation</strong>
|
||||
</div>
|
||||
<p>
|
||||
Inviting this user will send a secure email link so they can verify their address and
|
||||
set their own password.
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="users-illustration-card">
|
||||
<div className="users-illustration-art" />
|
||||
<div className="users-illustration-copy">
|
||||
<h5>Secure Onboarding</h5>
|
||||
<p>Users receive a time-limited invitation link to create their own credentials.</p>
|
||||
</div>
|
||||
</article>
|
||||
</aside>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{panelMode === 'edit' && editingUser ? (
|
||||
<section className="users-edit-layout">
|
||||
<aside className="users-edit-side">
|
||||
<article className="users-profile-card">
|
||||
<div className="users-profile-avatar">{initials(editingUser.name)}</div>
|
||||
<h3>{editingUser.name}</h3>
|
||||
<p>{editingUser.email}</p>
|
||||
<div className="users-profile-meta">
|
||||
<div>
|
||||
<div>
|
||||
<p>Status</p>
|
||||
<strong className={`tone-${getStatusTone(editingUser.status)}`}>
|
||||
{formatStatusLabel(editingUser.status)}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p>Last Active</p>
|
||||
<strong>{formatLastSeen(editingUser.lastLoginAt, editingUser.status)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="users-security-card">
|
||||
<h4>Security Actions</h4>
|
||||
<button
|
||||
type="button"
|
||||
className="users-toolbar-button full"
|
||||
onClick={() => resendInvite(editingUser)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<span className="material-symbols-outlined">lock_reset</span>
|
||||
Send Password Reset Link
|
||||
</button>
|
||||
</article>
|
||||
</aside>
|
||||
|
||||
<div className="users-edit-main">
|
||||
<article className="users-form-card edit">
|
||||
<div className="users-form-head edit">
|
||||
<div>
|
||||
<p className="card-kicker">Edit User</p>
|
||||
<h2>Update user access and account status</h2>
|
||||
<p>Change display details, role assignment, and lifecycle state without leaving the directory.</p>
|
||||
</div>
|
||||
<span className={`users-panel-badge tone-${getStatusTone(editingUser.status)}`}>
|
||||
{formatStatusLabel(editingUser.status)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="users-form-grid">
|
||||
<label className="users-form-field">
|
||||
<span>Full Name</span>
|
||||
<input value={editName} onChange={(event) => setEditName(event.target.value)} />
|
||||
</label>
|
||||
<label className="users-form-field">
|
||||
<span>Email Address</span>
|
||||
<input value={editEmail} onChange={(event) => setEditEmail(event.target.value)} />
|
||||
</label>
|
||||
<label className="users-form-field">
|
||||
<span>Role</span>
|
||||
<select value={editRoleId} onChange={(event) => setEditRoleId(event.target.value)}>
|
||||
<option value="">Unassigned</option>
|
||||
{availableRoles.map((role) => (
|
||||
<option key={role.id} value={role.id}>
|
||||
{role.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="users-form-field">
|
||||
<span>Status</span>
|
||||
<select value={editStatus} onChange={(event) => setEditStatus(event.target.value as UserRecord['status'])}>
|
||||
<option value="invited">Invited</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
<option value="suspended">Suspended</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="users-edit-meta">
|
||||
<div>
|
||||
<strong>{editingUser.emailVerifiedAt ? 'Verified' : 'Pending Verification'}</strong>
|
||||
<span>Email verification</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{formatLastSeen(editingUser.lastLoginAt, editingUser.status)}</strong>
|
||||
<span>Last activity</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="users-footer-actions">
|
||||
<button type="button" className="secondary-button" onClick={closePanel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" className="users-hero-button" onClick={submitEdit} disabled={isSaving}>
|
||||
<span className="material-symbols-outlined">save</span>
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="users-danger-card">
|
||||
<div className="users-danger-icon">
|
||||
<span className="material-symbols-outlined">warning</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Danger Zone</h4>
|
||||
<p>Deleting this user is not enabled yet. For now, use `Suspended` status to revoke access safely.</p>
|
||||
<button type="button" disabled>
|
||||
Delete User
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{feedback ? <p className="users-feedback">{feedback}</p> : null}
|
||||
|
||||
<section className="users-table-shell">
|
||||
<div className="users-table-toolbar">
|
||||
<h4>Team Members</h4>
|
||||
<div className="users-toolbar-actions">
|
||||
<button type="button" className="users-toolbar-button large" onClick={() => setShowFilters((current) => !current)}>
|
||||
<span className="material-symbols-outlined">filter_list</span>
|
||||
Filter
|
||||
</button>
|
||||
<button type="button" className="users-toolbar-button large" onClick={exportUsers}>
|
||||
<span className="material-symbols-outlined">download</span>
|
||||
Export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showFilters ? (
|
||||
<div className="users-filters-panel">
|
||||
<label className="users-filter-field">
|
||||
<span>Search team members</span>
|
||||
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search name or email..." />
|
||||
</label>
|
||||
<label className="users-filter-field">
|
||||
<span>Status</span>
|
||||
<select value={statusFilter} onChange={(event) => {
|
||||
setStatusFilter(event.target.value);
|
||||
setPage(1);
|
||||
}}>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="invited">Invited</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
<option value="suspended">Suspended</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="users-filter-field">
|
||||
<span>Role</span>
|
||||
<select value={roleFilter} onChange={(event) => {
|
||||
setRoleFilter(event.target.value);
|
||||
setPage(1);
|
||||
}}>
|
||||
<option value="all">All Roles</option>
|
||||
{availableRoles.map((role) => (
|
||||
<option key={role.id} value={role.id}>
|
||||
{role.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="users-filter-actions">
|
||||
<button type="button" className="secondary-button" onClick={resetFilters}>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="users-table-wrap">
|
||||
<table className="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name / Email</th>
|
||||
<th>Role</th>
|
||||
<th>Last Active</th>
|
||||
<th>Status</th>
|
||||
<th className="align-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => (
|
||||
<tr key={user.id}>
|
||||
<td>
|
||||
<div className="users-identity">
|
||||
<div className={`users-avatar tone-${getRoleTone(user.roleName)}`}>{initials(user.name)}</div>
|
||||
<div>
|
||||
<p>{user.name}</p>
|
||||
<span>{user.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`users-role-pill tone-${getRoleTone(user.roleName)}`}>
|
||||
{user.roleName.toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="users-muted">{formatLastSeen(user.lastLoginAt, user.status)}</td>
|
||||
<td>
|
||||
<div className="users-status">
|
||||
<span className={`users-status-dot tone-${getStatusTone(user.status)}`} />
|
||||
<span>{formatStatusLabel(user.status)}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="users-actions">
|
||||
{user.status === 'invited' ? (
|
||||
<button type="button" onClick={() => resendInvite(user)} title="Resend Invite">
|
||||
<span className="material-symbols-outlined">mail</span>
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" onClick={() => beginEdit(user)} title="Edit User">
|
||||
<span className="material-symbols-outlined">edit</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{users.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="users-empty-state">
|
||||
No users matched the current filters.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="users-pagination">
|
||||
<p>
|
||||
Showing {users.length === 0 ? 0 : (page - 1) * pageSize + 1} to {(page - 1) * pageSize + users.length} of {total} team members
|
||||
</p>
|
||||
<div className="users-pagination-buttons">
|
||||
<button type="button" onClick={() => setPage((current) => Math.max(1, current - 1))} disabled={page <= 1}>
|
||||
<span className="material-symbols-outlined">chevron_left</span>
|
||||
</button>
|
||||
{Array.from({ length: totalPages }, (_, index) => index + 1)
|
||||
.slice(Math.max(0, page - 2), Math.max(0, page - 2) + 3)
|
||||
.map((pageNumber) => (
|
||||
<button
|
||||
key={pageNumber}
|
||||
type="button"
|
||||
className={pageNumber === page ? 'is-active' : ''}
|
||||
onClick={() => setPage(pageNumber)}
|
||||
>
|
||||
{pageNumber}
|
||||
</button>
|
||||
))}
|
||||
<button type="button" onClick={() => setPage((current) => Math.min(totalPages, current + 1))} disabled={page >= totalPages}>
|
||||
<span className="material-symbols-outlined">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{isLoading ? <div className="users-loading-bar" /> : null}
|
||||
</section>
|
||||
|
||||
<section className="users-role-overview">
|
||||
<div>
|
||||
<h5>Role Permissions</h5>
|
||||
<p>Quick overview of what each team member can access across the WhatsApp Business platform.</p>
|
||||
</div>
|
||||
<div className="users-role-cards">
|
||||
{visibleRoles.map((roleName) => (
|
||||
<article key={roleName} className="users-role-card">
|
||||
<div className={`users-role-card-head tone-${getRoleTone(roleName)}`}>
|
||||
<span className="material-symbols-outlined">
|
||||
{getRoleTone(roleName) === 'admin'
|
||||
? 'verified_user'
|
||||
: getRoleTone(roleName) === 'editor'
|
||||
? 'edit_note'
|
||||
: 'support_agent'}
|
||||
</span>
|
||||
<span>{roleName.toUpperCase()}</span>
|
||||
</div>
|
||||
<p>{roleDescriptions[roleName] || 'Operational access based on the assigned role matrix.'}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
30
frontend/src/components/webhook-retry-form.tsx
Normal file
30
frontend/src/components/webhook-retry-form.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
import { retryWebhookEventAction } from '../app/actions';
|
||||
|
||||
type Props = {
|
||||
eventId: string;
|
||||
};
|
||||
|
||||
type FormState = {
|
||||
error?: string;
|
||||
success?: string;
|
||||
};
|
||||
|
||||
const initialState: FormState = {};
|
||||
|
||||
export function WebhookRetryForm({ eventId }: Props) {
|
||||
const [state, action, pending] = useActionState(retryWebhookEventAction, initialState);
|
||||
|
||||
return (
|
||||
<form action={action} className="retry-form">
|
||||
<input type="hidden" name="eventId" value={eventId} />
|
||||
<button type="submit" className="secondary-button" disabled={pending}>
|
||||
{pending ? 'Retrying...' : 'Retry Event'}
|
||||
</button>
|
||||
{state.error ? <p className="form-error">{state.error}</p> : null}
|
||||
{state.success ? <p className="form-success">Event requeued.</p> : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
162
frontend/src/components/whatsapp-settings-form.tsx
Normal file
162
frontend/src/components/whatsapp-settings-form.tsx
Normal file
@ -0,0 +1,162 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
import {
|
||||
testWhatsappSettingsAction,
|
||||
updateWhatsappSettingsAction,
|
||||
} from '../app/actions';
|
||||
|
||||
type Props = {
|
||||
initialValues: {
|
||||
provider: string;
|
||||
verifyToken: string;
|
||||
phoneNumberId: string;
|
||||
isEnabled: boolean;
|
||||
subscriptions: string[];
|
||||
availableSubscriptions: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
type FormState = {
|
||||
error?: string;
|
||||
success?: string;
|
||||
};
|
||||
|
||||
const initialState: FormState = {};
|
||||
|
||||
export function WhatsappSettingsForm({ initialValues }: Props) {
|
||||
const [saveState, saveAction, savePending] = useActionState(
|
||||
updateWhatsappSettingsAction,
|
||||
initialState,
|
||||
);
|
||||
const [testState, testAction, testPending] = useActionState(
|
||||
testWhatsappSettingsAction,
|
||||
initialState,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="settings-form-stack">
|
||||
<form action={saveAction} className="surface-card">
|
||||
<div className="form-stack">
|
||||
<div>
|
||||
<label>Provider</label>
|
||||
<select name="provider" defaultValue={initialValues.provider}>
|
||||
<option value="meta">Meta</option>
|
||||
<option value="qontak">Qontak</option>
|
||||
<option value="default">Default</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Verify Token</label>
|
||||
<input name="webhookVerifyToken" type="text" defaultValue={initialValues.verifyToken} />
|
||||
</div>
|
||||
<div>
|
||||
<label>Signing Secret</label>
|
||||
<input
|
||||
name="sharedSecret"
|
||||
type="password"
|
||||
placeholder="Leave blank to keep current secret"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>Meta App Secret</label>
|
||||
<input
|
||||
name="appSecret"
|
||||
type="password"
|
||||
placeholder="Leave blank to keep current app secret"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>Meta Access Token</label>
|
||||
<input
|
||||
name="accessToken"
|
||||
type="password"
|
||||
placeholder="Leave blank to keep current access token"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>Meta Phone Number ID</label>
|
||||
<input
|
||||
name="phoneNumberId"
|
||||
type="text"
|
||||
defaultValue={initialValues.phoneNumberId}
|
||||
placeholder="e.g. 123456789012345"
|
||||
/>
|
||||
</div>
|
||||
<label className="settings-checkbox">
|
||||
<span>
|
||||
<strong>Enable WhatsApp integration</strong>
|
||||
<small>Master switch for webhook verification and processing flow.</small>
|
||||
</span>
|
||||
<span className="settings-toggle">
|
||||
<input name="isEnabled" type="checkbox" defaultChecked={initialValues.isEnabled} />
|
||||
<span className="settings-toggle-ui" aria-hidden="true" />
|
||||
</span>
|
||||
</label>
|
||||
<div className="settings-subscriptions">
|
||||
<div>
|
||||
<label>Event Subscriptions</label>
|
||||
<p>Select which normalized webhook events should be processed by the backend worker.</p>
|
||||
</div>
|
||||
{initialValues.availableSubscriptions.map((subscription) => (
|
||||
<label key={subscription.key} className="settings-subscription-row">
|
||||
<span>
|
||||
<strong>{subscription.label}</strong>
|
||||
<small>{subscription.description}</small>
|
||||
</span>
|
||||
<span className="settings-toggle">
|
||||
<input
|
||||
name="subscriptions"
|
||||
type="checkbox"
|
||||
value={subscription.key}
|
||||
defaultChecked={initialValues.subscriptions.includes(subscription.key)}
|
||||
/>
|
||||
<span className="settings-toggle-ui" aria-hidden="true" />
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<button type="submit" className="primary-button" disabled={savePending}>
|
||||
{savePending ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
{saveState.error ? <p className="form-error">{saveState.error}</p> : null}
|
||||
{saveState.success ? <p className="form-success">Settings updated.</p> : null}
|
||||
</form>
|
||||
|
||||
<form action={testAction} className="surface-card">
|
||||
<div className="form-stack">
|
||||
<div>
|
||||
<label>Test Provider</label>
|
||||
<select name="provider" defaultValue={initialValues.provider}>
|
||||
<option value="meta">Meta</option>
|
||||
<option value="qontak">Qontak</option>
|
||||
<option value="default">Default</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Test Sender Phone</label>
|
||||
<input name="senderPhone" type="text" placeholder="6281999000111" />
|
||||
</div>
|
||||
<p>
|
||||
Test payload will create a `message.inbound` event, so make sure that subscription is enabled
|
||||
if you want the worker to process it.
|
||||
</p>
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<button type="submit" className="secondary-button" disabled={testPending}>
|
||||
{testPending ? 'Queueing...' : 'Send Test Payload'}
|
||||
</button>
|
||||
</div>
|
||||
{testState.error ? <p className="form-error">{testState.error}</p> : null}
|
||||
{testState.success ? <p className="form-success">Test webhook queued.</p> : null}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user