Initial BizOne portal setup

This commit is contained in:
2026-05-11 11:36:33 +07:00
commit 57017dd397
249 changed files with 41305 additions and 0 deletions

View 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>
</>
);
}

View 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}
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}