Initial BizOne portal setup
This commit is contained in:
7
frontend/Dockerfile
Normal file
7
frontend/Dockerfile
Normal file
@ -0,0 +1,7 @@
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
COPY src ./src
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "run", "dev"]
|
||||
158
frontend/middleware.ts
Normal file
158
frontend/middleware.ts
Normal file
@ -0,0 +1,158 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api';
|
||||
const AUTH_COOKIE = 'wa_session';
|
||||
const REFRESH_COOKIE = 'wa_refresh';
|
||||
const SESSION_REFRESH_LEEWAY_SECONDS = 60;
|
||||
|
||||
type RefreshPayload = {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
access_token_max_age_seconds?: number;
|
||||
refresh_token_max_age_seconds?: number;
|
||||
};
|
||||
|
||||
function isProtectedPath(pathname: string) {
|
||||
if (pathname.startsWith('/dashboard')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!pathname.startsWith('/api')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/api/auth/password-reset')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/api/invitations/')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function decodeJwtExpiry(token: string) {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=');
|
||||
const payload = JSON.parse(atob(padded)) as { exp?: unknown };
|
||||
return typeof payload.exp === 'number' ? payload.exp : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function tokenNeedsRefresh(token: string) {
|
||||
const expiry = decodeJwtExpiry(token);
|
||||
if (!expiry) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return expiry * 1000 <= Date.now() + SESSION_REFRESH_LEEWAY_SECONDS * 1000;
|
||||
}
|
||||
|
||||
function buildRequestCookieHeader(request: NextRequest, payload: RefreshPayload) {
|
||||
const pairs = request.cookies.getAll().map((cookie) => [cookie.name, cookie.value] as const);
|
||||
const nextCookies = new Map<string, string>(pairs);
|
||||
nextCookies.set(AUTH_COOKIE, payload.access_token);
|
||||
nextCookies.set(REFRESH_COOKIE, payload.refresh_token);
|
||||
|
||||
return Array.from(nextCookies.entries())
|
||||
.map(([name, value]) => `${name}=${value}`)
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
function applySessionCookies(response: NextResponse, payload: RefreshPayload) {
|
||||
const secure = process.env.NODE_ENV === 'production';
|
||||
|
||||
response.cookies.set(AUTH_COOKIE, payload.access_token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure,
|
||||
path: '/',
|
||||
maxAge: payload.access_token_max_age_seconds || 60 * 60 * 24,
|
||||
});
|
||||
response.cookies.set(REFRESH_COOKIE, payload.refresh_token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure,
|
||||
path: '/',
|
||||
maxAge: payload.refresh_token_max_age_seconds || 60 * 60 * 24 * 30,
|
||||
});
|
||||
}
|
||||
|
||||
function clearSessionCookies(response: NextResponse) {
|
||||
response.cookies.delete(AUTH_COOKIE);
|
||||
response.cookies.delete(REFRESH_COOKIE);
|
||||
}
|
||||
|
||||
function unauthorizedResponse(request: NextRequest) {
|
||||
if (request.nextUrl.pathname.startsWith('/api')) {
|
||||
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
return NextResponse.redirect(new URL('/login', request.url));
|
||||
}
|
||||
|
||||
async function refreshSession(refreshToken: string) {
|
||||
const response = await fetch(`${API_URL}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (await response.json()) as RefreshPayload;
|
||||
}
|
||||
|
||||
export async function middleware(request: NextRequest) {
|
||||
if (!isProtectedPath(request.nextUrl.pathname)) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
const accessToken = request.cookies.get(AUTH_COOKIE)?.value;
|
||||
const refreshToken = request.cookies.get(REFRESH_COOKIE)?.value;
|
||||
|
||||
if (accessToken && !tokenNeedsRefresh(accessToken)) {
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
if (!refreshToken) {
|
||||
const response = unauthorizedResponse(request);
|
||||
clearSessionCookies(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
const payload = await refreshSession(refreshToken);
|
||||
if (!payload?.access_token || !payload.refresh_token) {
|
||||
const response = unauthorizedResponse(request);
|
||||
clearSessionCookies(response);
|
||||
return response;
|
||||
}
|
||||
|
||||
const requestHeaders = new Headers(request.headers);
|
||||
requestHeaders.set('cookie', buildRequestCookieHeader(request, payload));
|
||||
|
||||
const response = NextResponse.next({
|
||||
request: {
|
||||
headers: requestHeaders,
|
||||
},
|
||||
});
|
||||
|
||||
applySessionCookies(response, payload);
|
||||
return response;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/dashboard/:path*', '/api/:path*'],
|
||||
};
|
||||
5
frontend/next-env.d.ts
vendored
Normal file
5
frontend/next-env.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
1285
frontend/package-lock.json
generated
Normal file
1285
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
frontend/package.json
Normal file
23
frontend/package.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "wa-dashboard-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start -p 3000 -H 127.0.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"next": "15.0.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.0.0-rc-65a56d0e-20241020",
|
||||
"react-dom": "19.0.0-rc-65a56d0e-20241020"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.1",
|
||||
"@types/react": "^19.0.2",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
409
frontend/src/app/actions.ts
Normal file
409
frontend/src/app/actions.ts
Normal file
@ -0,0 +1,409 @@
|
||||
'use server';
|
||||
|
||||
import { cookies } from 'next/headers';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { redirect } from 'next/navigation';
|
||||
import {
|
||||
authCookieName,
|
||||
refreshCookieName,
|
||||
twoFactorChallengeCookieName,
|
||||
twoFactorEmailCookieName,
|
||||
} from '../lib/auth';
|
||||
import { defaultLocale, isLocale, localeCookieName } from '../lib/i18n';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api';
|
||||
const secureCookies = process.env.NODE_ENV === 'production';
|
||||
|
||||
type FormState = {
|
||||
error?: string;
|
||||
success?: string;
|
||||
manualEntryKey?: string;
|
||||
otpauthUrl?: string;
|
||||
qrCodeDataUrl?: string;
|
||||
recoveryCodes?: string[];
|
||||
};
|
||||
|
||||
function getErrorMessage(payload: unknown, fallback: string) {
|
||||
if (payload && typeof payload === 'object') {
|
||||
const message = (payload as { message?: unknown }).message;
|
||||
if (typeof message === 'string') {
|
||||
return message;
|
||||
}
|
||||
|
||||
if (Array.isArray(message)) {
|
||||
return message.join(', ');
|
||||
}
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function buildQrCodeDataUrl(value: string) {
|
||||
const { toDataURL } = await import('qrcode');
|
||||
return toDataURL(value, {
|
||||
errorCorrectionLevel: 'M',
|
||||
margin: 1,
|
||||
width: 220,
|
||||
});
|
||||
}
|
||||
|
||||
export async function loginAction(_: FormState, formData: FormData): Promise<FormState> {
|
||||
const email = String(formData.get('email') || '').trim();
|
||||
const password = String(formData.get('password') || '');
|
||||
|
||||
const response = await fetch(`${API_URL}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
return { error: payload.message || 'Login failed' };
|
||||
}
|
||||
|
||||
if (payload.requiresTwoFactor && payload.challengeToken) {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(twoFactorChallengeCookieName, payload.challengeToken, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: secureCookies,
|
||||
path: '/',
|
||||
maxAge: 60 * 10,
|
||||
});
|
||||
cookieStore.set(twoFactorEmailCookieName, payload.user?.email || email, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: secureCookies,
|
||||
path: '/',
|
||||
maxAge: 60 * 10,
|
||||
});
|
||||
redirect('/login/2fa');
|
||||
}
|
||||
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(authCookieName, payload.access_token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: secureCookies,
|
||||
path: '/',
|
||||
maxAge: payload.access_token_max_age_seconds || 60 * 60 * 24,
|
||||
});
|
||||
cookieStore.set(refreshCookieName, payload.refresh_token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: secureCookies,
|
||||
path: '/',
|
||||
maxAge: payload.refresh_token_max_age_seconds || 60 * 60 * 24 * 30,
|
||||
});
|
||||
|
||||
redirect('/dashboard');
|
||||
}
|
||||
|
||||
export async function logoutAction() {
|
||||
const cookieStore = await cookies();
|
||||
const accessToken = cookieStore.get(authCookieName)?.value;
|
||||
const refreshToken = cookieStore.get(refreshCookieName)?.value;
|
||||
|
||||
if (accessToken || refreshToken) {
|
||||
await fetch(`${API_URL}/auth/logout`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
cache: 'no-store',
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
|
||||
cookieStore.delete(authCookieName);
|
||||
cookieStore.delete(refreshCookieName);
|
||||
cookieStore.delete(twoFactorChallengeCookieName);
|
||||
cookieStore.delete(twoFactorEmailCookieName);
|
||||
redirect('/login');
|
||||
}
|
||||
|
||||
export async function verifyTwoFactorLoginAction(_: FormState, formData: FormData): Promise<FormState> {
|
||||
const code = String(formData.get('code') || '').trim();
|
||||
const cookieStore = await cookies();
|
||||
const challengeToken = cookieStore.get(twoFactorChallengeCookieName)?.value;
|
||||
|
||||
if (!challengeToken) {
|
||||
return { error: 'Two-factor login session expired. Please log in again.' };
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_URL}/auth/2fa/login/verify`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ challengeToken, code }),
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
return { error: getErrorMessage(payload, 'Failed to verify two-factor code') };
|
||||
}
|
||||
|
||||
cookieStore.set(authCookieName, payload.access_token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: secureCookies,
|
||||
path: '/',
|
||||
maxAge: payload.access_token_max_age_seconds || 60 * 60 * 24,
|
||||
});
|
||||
cookieStore.set(refreshCookieName, payload.refresh_token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: secureCookies,
|
||||
path: '/',
|
||||
maxAge: payload.refresh_token_max_age_seconds || 60 * 60 * 24 * 30,
|
||||
});
|
||||
cookieStore.delete(twoFactorChallengeCookieName);
|
||||
cookieStore.delete(twoFactorEmailCookieName);
|
||||
redirect('/dashboard');
|
||||
}
|
||||
|
||||
async function requireServerAuthCookie() {
|
||||
const token = (await cookies()).get(authCookieName)?.value;
|
||||
if (!token) {
|
||||
redirect('/login');
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function initiateTwoFactorSetupAction(_: FormState): Promise<FormState> {
|
||||
const token = await requireServerAuthCookie();
|
||||
const response = await fetch(`${API_URL}/auth/2fa/setup/initiate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
return { error: getErrorMessage(payload, 'Failed to start two-factor setup') };
|
||||
}
|
||||
|
||||
revalidatePath('/dashboard/settings/security');
|
||||
return {
|
||||
success: 'Two-factor setup initialized.',
|
||||
manualEntryKey: payload.manualEntryKey,
|
||||
otpauthUrl: payload.otpauthUrl,
|
||||
qrCodeDataUrl: await buildQrCodeDataUrl(payload.otpauthUrl),
|
||||
};
|
||||
}
|
||||
|
||||
export async function confirmTwoFactorSetupAction(_: FormState, formData: FormData): Promise<FormState> {
|
||||
const token = await requireServerAuthCookie();
|
||||
const code = String(formData.get('code') || '').trim();
|
||||
const response = await fetch(`${API_URL}/auth/2fa/setup/confirm`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ code }),
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
return { error: getErrorMessage(payload, 'Failed to confirm two-factor setup') };
|
||||
}
|
||||
|
||||
revalidatePath('/dashboard/settings/security');
|
||||
return {
|
||||
success: 'Two-factor authentication enabled.',
|
||||
recoveryCodes: Array.isArray(payload.recoveryCodes) ? payload.recoveryCodes : [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function disableTwoFactorAction(_: FormState, formData: FormData): Promise<FormState> {
|
||||
const token = await requireServerAuthCookie();
|
||||
const code = String(formData.get('code') || '').trim();
|
||||
const response = await fetch(`${API_URL}/auth/2fa/disable`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ code }),
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
return { error: getErrorMessage(payload, 'Failed to disable two-factor authentication') };
|
||||
}
|
||||
|
||||
revalidatePath('/dashboard/settings/security');
|
||||
return { success: 'Two-factor authentication disabled.' };
|
||||
}
|
||||
|
||||
export async function regenerateTwoFactorRecoveryCodesAction(
|
||||
_: FormState,
|
||||
formData: FormData,
|
||||
): Promise<FormState> {
|
||||
const token = await requireServerAuthCookie();
|
||||
const code = String(formData.get('code') || '').trim();
|
||||
const response = await fetch(`${API_URL}/auth/2fa/recovery-codes/regenerate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ code }),
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
return { error: getErrorMessage(payload, 'Failed to regenerate recovery codes') };
|
||||
}
|
||||
|
||||
revalidatePath('/dashboard/settings/security');
|
||||
return {
|
||||
success: 'Recovery codes regenerated.',
|
||||
recoveryCodes: Array.isArray(payload.recoveryCodes) ? payload.recoveryCodes : [],
|
||||
};
|
||||
}
|
||||
|
||||
export async function setLocaleAction(formData: FormData) {
|
||||
const nextLocale = String(formData.get('locale') || '');
|
||||
const redirectPath = String(formData.get('redirectPath') || '/');
|
||||
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(localeCookieName, isLocale(nextLocale) ? nextLocale : defaultLocale, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
secure: secureCookies,
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24 * 365,
|
||||
});
|
||||
|
||||
redirect(redirectPath);
|
||||
}
|
||||
|
||||
export async function createContactAction(_: FormState, formData: FormData): Promise<FormState> {
|
||||
const token = await requireServerAuthCookie();
|
||||
|
||||
const payload = {
|
||||
name: String(formData.get('name') || '').trim(),
|
||||
phoneNumber: String(formData.get('phoneNumber') || '').trim(),
|
||||
email: String(formData.get('email') || '').trim() || undefined,
|
||||
company: String(formData.get('company') || '').trim() || undefined,
|
||||
notes: String(formData.get('notes') || '').trim() || undefined,
|
||||
};
|
||||
|
||||
const response = await fetch(`${API_URL}/contacts`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!response.ok) {
|
||||
return { error: getErrorMessage(result, 'Failed to create contact') };
|
||||
}
|
||||
|
||||
revalidatePath('/dashboard/contacts');
|
||||
return { success: 'ok' };
|
||||
}
|
||||
|
||||
export async function updateWhatsappSettingsAction(_: FormState, formData: FormData): Promise<FormState> {
|
||||
const token = await requireServerAuthCookie();
|
||||
|
||||
const payload = {
|
||||
provider: String(formData.get('provider') || '').trim() || undefined,
|
||||
webhookVerifyToken: String(formData.get('webhookVerifyToken') || '').trim() || undefined,
|
||||
sharedSecret: String(formData.get('sharedSecret') || '').trim() || undefined,
|
||||
appSecret: String(formData.get('appSecret') || '').trim() || undefined,
|
||||
accessToken: String(formData.get('accessToken') || '').trim() || undefined,
|
||||
phoneNumberId: String(formData.get('phoneNumberId') || '').trim() || undefined,
|
||||
isEnabled: String(formData.get('isEnabled') || '') === 'on',
|
||||
subscriptions: formData
|
||||
.getAll('subscriptions')
|
||||
.map((item) => String(item).trim())
|
||||
.filter(Boolean),
|
||||
};
|
||||
|
||||
const response = await fetch(`${API_URL}/integrations/whatsapp`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!response.ok) {
|
||||
return { error: getErrorMessage(result, 'Failed to update WhatsApp settings') };
|
||||
}
|
||||
|
||||
revalidatePath('/dashboard/settings/whatsapp-api');
|
||||
return { success: 'saved' };
|
||||
}
|
||||
|
||||
export async function testWhatsappSettingsAction(_: FormState, formData: FormData): Promise<FormState> {
|
||||
const token = await requireServerAuthCookie();
|
||||
|
||||
const payload = {
|
||||
provider: String(formData.get('provider') || '').trim() || undefined,
|
||||
senderPhone: String(formData.get('senderPhone') || '').trim() || undefined,
|
||||
};
|
||||
|
||||
const response = await fetch(`${API_URL}/integrations/whatsapp/test`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!response.ok) {
|
||||
return { error: getErrorMessage(result, 'Failed to queue webhook test') };
|
||||
}
|
||||
|
||||
revalidatePath('/dashboard/settings/webhook-logs');
|
||||
return { success: `queued:${result.eventId || 'test-event'}` };
|
||||
}
|
||||
|
||||
export async function retryWebhookEventAction(_: FormState, formData: FormData): Promise<FormState> {
|
||||
const token = await requireServerAuthCookie();
|
||||
|
||||
const eventId = String(formData.get('eventId') || '').trim();
|
||||
if (!eventId) {
|
||||
return { error: 'Missing event id' };
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_URL}/webhooks/logs/${eventId}/retry`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!response.ok) {
|
||||
return { error: getErrorMessage(result, 'Failed to retry webhook event') };
|
||||
}
|
||||
|
||||
revalidatePath('/dashboard/settings/webhook-logs');
|
||||
return { success: `requeued:${eventId}` };
|
||||
}
|
||||
29
frontend/src/app/api/audit-trail/export/route.ts
Normal file
29
frontend/src/app/api/audit-trail/export/route.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { cookies } from 'next/headers';
|
||||
import { authCookieName } from '../../../../lib/auth';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const token = (await cookies()).get(authCookieName)?.value;
|
||||
|
||||
if (!token) {
|
||||
return new Response('Unauthorized', { status: 401 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const response = await fetch(`${API_URL}/logs/audit-trail/export${url.search}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
const csv = await response.text();
|
||||
return new Response(csv, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
'Content-Type': 'text/csv; charset=utf-8',
|
||||
'Content-Disposition': 'attachment; filename="audit-trail-export.csv"',
|
||||
},
|
||||
});
|
||||
}
|
||||
50
frontend/src/app/api/audit-trail/route.ts
Normal file
50
frontend/src/app/api/audit-trail/route.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { authCookieName } from '../../../lib/auth';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const token = (await cookies()).get(authCookieName)?.value;
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const response = await fetch(`${API_URL}/logs/audit-trail${url.search}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
return NextResponse.json(payload, { status: response.status });
|
||||
}
|
||||
|
||||
export async function HEAD() {
|
||||
return NextResponse.json({}, { status: 405 });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const token = (await cookies()).get(authCookieName)?.value;
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const response = await fetch(`${API_URL}/logs/audit-trail`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
return NextResponse.json(payload, { status: response.status });
|
||||
}
|
||||
31
frontend/src/app/api/auth/password-reset/[token]/route.ts
Normal file
31
frontend/src/app/api/auth/password-reset/[token]/route.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api';
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ token: string }> },
|
||||
) {
|
||||
const { token } = await params;
|
||||
const response = await fetch(`${API_URL}/auth/password-reset/${token}`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
const payload = await response.json();
|
||||
return NextResponse.json(payload, { status: response.status });
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ token: string }> },
|
||||
) {
|
||||
const { token } = await params;
|
||||
const body = await request.json();
|
||||
const response = await fetch(`${API_URL}/auth/password-reset/${token}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
cache: 'no-store',
|
||||
});
|
||||
const payload = await response.json();
|
||||
return NextResponse.json(payload, { status: response.status });
|
||||
}
|
||||
15
frontend/src/app/api/auth/password-reset/route.ts
Normal file
15
frontend/src/app/api/auth/password-reset/route.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json();
|
||||
const response = await fetch(`${API_URL}/auth/forgot-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
cache: 'no-store',
|
||||
});
|
||||
const payload = await response.json();
|
||||
return NextResponse.json(payload, { status: response.status });
|
||||
}
|
||||
27
frontend/src/app/api/campaigns/[id]/duplicate/route.ts
Normal file
27
frontend/src/app/api/campaigns/[id]/duplicate/route.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { authCookieName } from '../../../../../lib/auth';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api';
|
||||
|
||||
export async function POST(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const token = (await cookies()).get(authCookieName)?.value;
|
||||
if (!token) {
|
||||
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const response = await fetch(`${API_URL}/campaigns/${id}/duplicate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
return NextResponse.json(payload, { status: response.status });
|
||||
}
|
||||
39
frontend/src/app/api/campaigns/[id]/export/route.ts
Normal file
39
frontend/src/app/api/campaigns/[id]/export/route.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { authCookieName } from '../../../../../lib/auth';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api';
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const token = (await cookies()).get(authCookieName)?.value;
|
||||
if (!token) {
|
||||
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const url = new URL(request.url);
|
||||
const format = url.searchParams.get('format') === 'xlsx' ? 'xlsx' : 'csv';
|
||||
|
||||
const response = await fetch(`${API_URL}/campaigns/${id}/export?format=${format}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
const headers = new Headers();
|
||||
headers.set('Content-Type', response.headers.get('Content-Type') || 'application/octet-stream');
|
||||
headers.set(
|
||||
'Content-Disposition',
|
||||
response.headers.get('Content-Disposition') || `attachment; filename="campaign-report.${format}"`,
|
||||
);
|
||||
|
||||
return new NextResponse(buffer, {
|
||||
status: response.status,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
54
frontend/src/app/api/campaigns/[id]/route.ts
Normal file
54
frontend/src/app/api/campaigns/[id]/route.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { authCookieName } from '../../../../lib/auth';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api';
|
||||
|
||||
async function getToken() {
|
||||
return (await cookies()).get(authCookieName)?.value;
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const response = await fetch(`${API_URL}/campaigns/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
return NextResponse.json(payload, { status: response.status });
|
||||
}
|
||||
|
||||
export async function DELETE(_: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const response = await fetch(`${API_URL}/campaigns/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (response.status === 204) {
|
||||
return new NextResponse(null, { status: 204 });
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
return NextResponse.json(payload, { status: response.status });
|
||||
}
|
||||
27
frontend/src/app/api/campaigns/[id]/send/route.ts
Normal file
27
frontend/src/app/api/campaigns/[id]/send/route.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { authCookieName } from '../../../../../lib/auth';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api';
|
||||
|
||||
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const token = (await cookies()).get(authCookieName)?.value;
|
||||
if (!token) {
|
||||
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const response = await fetch(`${API_URL}/campaigns/${id}/send`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
return NextResponse.json(payload, { status: response.status });
|
||||
}
|
||||
26
frontend/src/app/api/campaigns/route.ts
Normal file
26
frontend/src/app/api/campaigns/route.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { authCookieName } from '../../../lib/auth';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const token = (await cookies()).get(authCookieName)?.value;
|
||||
if (!token) {
|
||||
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const response = await fetch(`${API_URL}/campaigns`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
return NextResponse.json(payload, { status: response.status });
|
||||
}
|
||||
64
frontend/src/app/api/contacts/[id]/route.ts
Normal file
64
frontend/src/app/api/contacts/[id]/route.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { authCookieName } from '../../../../lib/auth';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api';
|
||||
|
||||
async function getToken() {
|
||||
return (await cookies()).get(authCookieName)?.value;
|
||||
}
|
||||
|
||||
export async function GET(_: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const response = await fetch(`${API_URL}/contacts/${id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
return NextResponse.json(payload, { status: response.status });
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const response = await fetch(`${API_URL}/contacts/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
return NextResponse.json(payload, { status: response.status });
|
||||
}
|
||||
|
||||
export async function DELETE(_: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const response = await fetch(`${API_URL}/contacts/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
return NextResponse.json(payload, { status: response.status });
|
||||
}
|
||||
31
frontend/src/app/api/contacts/export/route.ts
Normal file
31
frontend/src/app/api/contacts/export/route.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { authCookieName } from '../../../../lib/auth';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const token = (await cookies()).get(authCookieName)?.value;
|
||||
if (!token) {
|
||||
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const response = await fetch(`${API_URL}/contacts/export${url.search ? url.search : ''}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
const headers = new Headers();
|
||||
headers.set('Content-Type', response.headers.get('Content-Type') || 'text/csv; charset=utf-8');
|
||||
headers.set(
|
||||
'Content-Disposition',
|
||||
response.headers.get('Content-Disposition') || 'attachment; filename="contacts-directory.csv"',
|
||||
);
|
||||
|
||||
return new NextResponse(buffer, {
|
||||
status: response.status,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
49
frontend/src/app/api/contacts/route.ts
Normal file
49
frontend/src/app/api/contacts/route.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { authCookieName } from '../../../lib/auth';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api';
|
||||
|
||||
function buildUrl(searchParams: URLSearchParams) {
|
||||
const query = searchParams.toString();
|
||||
return `${API_URL}/contacts${query ? `?${query}` : ''}`;
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const token = (await cookies()).get(authCookieName)?.value;
|
||||
if (!token) {
|
||||
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const response = await fetch(buildUrl(url.searchParams), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
return NextResponse.json(payload, { status: response.status });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const token = (await cookies()).get(authCookieName)?.value;
|
||||
if (!token) {
|
||||
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const response = await fetch(`${API_URL}/contacts`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
return NextResponse.json(payload, { status: response.status });
|
||||
}
|
||||
28
frontend/src/app/api/conversations/[id]/assign/route.ts
Normal file
28
frontend/src/app/api/conversations/[id]/assign/route.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { authCookieName } from '../../../../../lib/auth';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api';
|
||||
|
||||
async function getToken() {
|
||||
return (await cookies()).get(authCookieName)?.value;
|
||||
}
|
||||
|
||||
export async function POST(_: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const response = await fetch(`${API_URL}/conversations/${id}/assign`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
return NextResponse.json(payload, { status: response.status });
|
||||
}
|
||||
31
frontend/src/app/api/conversations/[id]/messages/route.ts
Normal file
31
frontend/src/app/api/conversations/[id]/messages/route.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { authCookieName } from '../../../../../lib/auth';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api';
|
||||
|
||||
async function getToken() {
|
||||
return (await cookies()).get(authCookieName)?.value;
|
||||
}
|
||||
|
||||
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const response = await fetch(`${API_URL}/conversations/${id}/messages`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
return NextResponse.json(payload, { status: response.status });
|
||||
}
|
||||
25
frontend/src/app/api/conversations/[id]/route.ts
Normal file
25
frontend/src/app/api/conversations/[id]/route.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { authCookieName } from '../../../../lib/auth';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api';
|
||||
|
||||
async function getToken() {
|
||||
return (await cookies()).get(authCookieName)?.value;
|
||||
}
|
||||
|
||||
export async function GET(_: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const response = await fetch(`${API_URL}/conversations/${id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
return NextResponse.json(payload, { status: response.status });
|
||||
}
|
||||
28
frontend/src/app/api/conversations/route.ts
Normal file
28
frontend/src/app/api/conversations/route.ts
Normal file
@ -0,0 +1,28 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { authCookieName } from '../../../lib/auth';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api';
|
||||
|
||||
function buildUrl(searchParams: URLSearchParams) {
|
||||
const query = searchParams.toString();
|
||||
return `${API_URL}/conversations${query ? `?${query}` : ''}`;
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const token = (await cookies()).get(authCookieName)?.value;
|
||||
if (!token) {
|
||||
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const response = await fetch(buildUrl(url.searchParams), {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
return NextResponse.json(payload, { status: response.status });
|
||||
}
|
||||
19
frontend/src/app/api/invitations/[token]/complete/route.ts
Normal file
19
frontend/src/app/api/invitations/[token]/complete/route.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api';
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ token: string }> },
|
||||
) {
|
||||
const { token } = await params;
|
||||
const body = await request.json();
|
||||
const response = await fetch(`${API_URL}/users/invitations/${token}/complete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
cache: 'no-store',
|
||||
});
|
||||
const payload = await response.json();
|
||||
return NextResponse.json(payload, { status: response.status });
|
||||
}
|
||||
15
frontend/src/app/api/invitations/[token]/route.ts
Normal file
15
frontend/src/app/api/invitations/[token]/route.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api';
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ token: string }> },
|
||||
) {
|
||||
const { token } = await params;
|
||||
const response = await fetch(`${API_URL}/users/invitations/${token}`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
const payload = await response.json();
|
||||
return NextResponse.json(payload, { status: response.status });
|
||||
}
|
||||
29
frontend/src/app/api/roles/[id]/route.ts
Normal file
29
frontend/src/app/api/roles/[id]/route.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { authCookieName } from '../../../../lib/auth';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api';
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const token = (await cookies()).get(authCookieName)?.value;
|
||||
if (!token) {
|
||||
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const response = await fetch(`${API_URL}/roles/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
cache: 'no-store',
|
||||
});
|
||||
const payload = await response.json();
|
||||
return NextResponse.json(payload, { status: response.status });
|
||||
}
|
||||
39
frontend/src/app/api/roles/route.ts
Normal file
39
frontend/src/app/api/roles/route.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { authCookieName } from '../../../lib/auth';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api';
|
||||
|
||||
export async function GET() {
|
||||
const token = (await cookies()).get(authCookieName)?.value;
|
||||
if (!token) {
|
||||
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_URL}/roles`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: 'no-store',
|
||||
});
|
||||
const payload = await response.json();
|
||||
return NextResponse.json(payload, { status: response.status });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const token = (await cookies()).get(authCookieName)?.value;
|
||||
if (!token) {
|
||||
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const response = await fetch(`${API_URL}/roles`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
cache: 'no-store',
|
||||
});
|
||||
const payload = await response.json();
|
||||
return NextResponse.json(payload, { status: response.status });
|
||||
}
|
||||
49
frontend/src/app/api/templates/[id]/route.ts
Normal file
49
frontend/src/app/api/templates/[id]/route.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { authCookieName } from '../../../../lib/auth';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api';
|
||||
|
||||
async function getToken() {
|
||||
return (await cookies()).get(authCookieName)?.value;
|
||||
}
|
||||
|
||||
export async function GET(_: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const response = await fetch(`${API_URL}/templates/${id}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
return NextResponse.json(payload, { status: response.status });
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const response = await fetch(`${API_URL}/templates/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
return NextResponse.json(payload, { status: response.status });
|
||||
}
|
||||
48
frontend/src/app/api/templates/route.ts
Normal file
48
frontend/src/app/api/templates/route.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { authCookieName } from '../../../lib/auth';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api';
|
||||
|
||||
async function getToken() {
|
||||
return (await cookies()).get(authCookieName)?.value;
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const response = await fetch(`${API_URL}/templates${url.search}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
return NextResponse.json(payload, { status: response.status });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const token = await getToken();
|
||||
if (!token) {
|
||||
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const response = await fetch(`${API_URL}/templates`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
return NextResponse.json(payload, { status: response.status });
|
||||
}
|
||||
29
frontend/src/app/api/users/[id]/route.ts
Normal file
29
frontend/src/app/api/users/[id]/route.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { authCookieName } from '../../../../lib/auth';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api';
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const token = (await cookies()).get(authCookieName)?.value;
|
||||
if (!token) {
|
||||
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const response = await fetch(`${API_URL}/users/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
cache: 'no-store',
|
||||
});
|
||||
const payload = await response.json();
|
||||
return NextResponse.json(payload, { status: response.status });
|
||||
}
|
||||
27
frontend/src/app/api/users/export/route.ts
Normal file
27
frontend/src/app/api/users/export/route.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { authCookieName } from '../../../../lib/auth';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const token = (await cookies()).get(authCookieName)?.value;
|
||||
if (!token) {
|
||||
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const response = await fetch(`${API_URL}/users/export${url.search}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: 'no-store',
|
||||
});
|
||||
const csv = await response.text();
|
||||
|
||||
return new NextResponse(csv, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
'Content-Type': 'text/csv; charset=utf-8',
|
||||
'Content-Disposition': 'attachment; filename="users-export.csv"',
|
||||
},
|
||||
});
|
||||
}
|
||||
40
frontend/src/app/api/users/route.ts
Normal file
40
frontend/src/app/api/users/route.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { authCookieName } from '../../../lib/auth';
|
||||
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const token = (await cookies()).get(authCookieName)?.value;
|
||||
if (!token) {
|
||||
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const response = await fetch(`${API_URL}/users${url.search}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: 'no-store',
|
||||
});
|
||||
const payload = await response.json();
|
||||
return NextResponse.json(payload, { status: response.status });
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const token = (await cookies()).get(authCookieName)?.value;
|
||||
if (!token) {
|
||||
return NextResponse.json({ message: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const response = await fetch(`${API_URL}/users/invite`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
cache: 'no-store',
|
||||
});
|
||||
const payload = await response.json();
|
||||
return NextResponse.json(payload, { status: response.status });
|
||||
}
|
||||
313
frontend/src/app/dashboard/campaigns/[id]/page.tsx
Normal file
313
frontend/src/app/dashboard/campaigns/[id]/page.tsx
Normal file
@ -0,0 +1,313 @@
|
||||
import Link from 'next/link';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { CampaignDetailActions } from '../../../../components/campaign-detail-actions';
|
||||
import { DashboardShell } from '../../../../components/dashboard-shell';
|
||||
import { requireAuthToken } from '../../../../lib/auth';
|
||||
import { fetchCampaignDetail } from '../../../../lib/api';
|
||||
|
||||
function formatDateTime(value: string) {
|
||||
const date = new Date(value);
|
||||
return {
|
||||
date: date.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }),
|
||||
time: date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true }),
|
||||
};
|
||||
}
|
||||
|
||||
function formatRecipientTime(value: string | null) {
|
||||
if (!value) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
return `Today, ${date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true })}`;
|
||||
}
|
||||
|
||||
function campaignStatusClassName(status: string) {
|
||||
switch (status) {
|
||||
case 'Sent':
|
||||
return 'campaign-detail-badge is-sent';
|
||||
case 'Scheduled':
|
||||
return 'campaign-detail-badge is-scheduled';
|
||||
case 'Draft':
|
||||
return 'campaign-detail-badge is-draft';
|
||||
case 'Failed':
|
||||
return 'campaign-detail-badge is-failed';
|
||||
default:
|
||||
return 'campaign-detail-badge';
|
||||
}
|
||||
}
|
||||
|
||||
function recipientStatusClassName(status: string) {
|
||||
switch (status) {
|
||||
case 'Read':
|
||||
return 'campaign-recipient-status is-read';
|
||||
case 'Delivered':
|
||||
return 'campaign-recipient-status is-delivered';
|
||||
case 'Failed':
|
||||
return 'campaign-recipient-status is-failed';
|
||||
default:
|
||||
return 'campaign-recipient-status';
|
||||
}
|
||||
}
|
||||
|
||||
function deviceIcon(label: string) {
|
||||
if (label === 'Android') return 'phone_android';
|
||||
if (label === 'iOS') return 'phone_iphone';
|
||||
return 'laptop';
|
||||
}
|
||||
|
||||
export default async function CampaignDetailPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams?: Promise<{ page?: string }>;
|
||||
}) {
|
||||
const token = await requireAuthToken();
|
||||
const { id } = await params;
|
||||
const query = await searchParams;
|
||||
const page = Math.max(1, Number(query?.page || '1'));
|
||||
|
||||
try {
|
||||
const detail = await fetchCampaignDetail(token, id, { page, limit: 5 });
|
||||
const stamp = formatDateTime(detail.campaign.initiatedAt);
|
||||
|
||||
return (
|
||||
<DashboardShell currentPath="/dashboard/campaigns">
|
||||
<div className="campaign-detail-page">
|
||||
<section className="campaign-detail-header">
|
||||
<div>
|
||||
<nav className="campaign-detail-breadcrumb">
|
||||
<Link href="/dashboard/campaigns">Campaigns</Link>
|
||||
<span className="material-symbols-outlined">chevron_right</span>
|
||||
<span>Analytics</span>
|
||||
</nav>
|
||||
<div className="campaign-detail-heading-row">
|
||||
<h1>{detail.campaign.name}</h1>
|
||||
<span className={campaignStatusClassName(detail.campaign.status)}>{detail.campaign.status}</span>
|
||||
</div>
|
||||
<p>Initiated on {stamp.date} at {stamp.time}</p>
|
||||
</div>
|
||||
<CampaignDetailActions campaign={detail.campaign} />
|
||||
</section>
|
||||
|
||||
<section className="campaign-detail-kpi-grid">
|
||||
<article className="campaign-detail-kpi-card">
|
||||
<div className="campaign-detail-kpi-head">
|
||||
<span className="material-symbols-outlined">group</span>
|
||||
<span className="is-positive">
|
||||
<span className="material-symbols-outlined">trending_up</span>
|
||||
12%
|
||||
</span>
|
||||
</div>
|
||||
<p>Total Recipients</p>
|
||||
<strong>{detail.campaign.totalRecipients.toLocaleString('en-US')}</strong>
|
||||
</article>
|
||||
|
||||
<article className="campaign-detail-kpi-card">
|
||||
<div className="campaign-detail-kpi-head">
|
||||
<span className="material-symbols-outlined is-secondary">mark_email_read</span>
|
||||
<span className="is-positive">{detail.campaign.deliveredRate.toFixed(1)}%</span>
|
||||
</div>
|
||||
<p>Delivered Rate</p>
|
||||
<strong>{detail.campaign.deliveredCount.toLocaleString('en-US')}</strong>
|
||||
</article>
|
||||
|
||||
<article className="campaign-detail-kpi-card">
|
||||
<div className="campaign-detail-kpi-head">
|
||||
<span className="material-symbols-outlined is-tertiary">visibility</span>
|
||||
<span className="is-warning">
|
||||
<span className="material-symbols-outlined">trending_down</span>
|
||||
4%
|
||||
</span>
|
||||
</div>
|
||||
<p>Read Rate</p>
|
||||
<strong>{detail.campaign.readRate.toFixed(1)}%</strong>
|
||||
</article>
|
||||
|
||||
<article className="campaign-detail-kpi-card">
|
||||
<div className="campaign-detail-kpi-head">
|
||||
<span className="material-symbols-outlined is-error">error</span>
|
||||
<span className="is-danger">{detail.campaign.failedRate.toFixed(1)}%</span>
|
||||
</div>
|
||||
<p>Failed Messages</p>
|
||||
<strong>{detail.campaign.failedCount.toLocaleString('en-US')}</strong>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="campaign-detail-main-grid">
|
||||
<article className="campaign-detail-card campaign-detail-timeline-card">
|
||||
<div className="campaign-detail-card-head">
|
||||
<h2>Delivery Timeline</h2>
|
||||
<select defaultValue="24h">
|
||||
<option value="24h">Last 24 Hours</option>
|
||||
<option value="7d">Last 7 Days</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="campaign-detail-timeline-bars">
|
||||
{detail.timeline.map((bucket, index) => (
|
||||
<div key={`${bucket.label}-${index}`} className="campaign-detail-timeline-column">
|
||||
<div
|
||||
className={index === 3 ? 'campaign-detail-timeline-bar is-highlight' : 'campaign-detail-timeline-bar'}
|
||||
style={{ height: `${bucket.height}%` }}
|
||||
title={`${bucket.label}: ${bucket.count}`}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="campaign-detail-timeline-labels">
|
||||
{detail.timeline.filter((_, index) => index % 2 === 0).map((bucket) => (
|
||||
<span key={bucket.label}>{bucket.label}</span>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="campaign-detail-card campaign-detail-preview-card">
|
||||
<h2>Message Preview</h2>
|
||||
<div className="campaign-preview-phone">
|
||||
<img src={detail.campaign.bannerImageUrl} alt={detail.campaign.name} />
|
||||
<p className="campaign-preview-title">{detail.campaign.messageTitle}</p>
|
||||
<p className="campaign-preview-copy">{detail.campaign.messageBody}</p>
|
||||
<div className="campaign-preview-meta">
|
||||
<span>Shop Now</span>
|
||||
<span>{stamp.time}</span>
|
||||
</div>
|
||||
<div className="campaign-preview-buttons">
|
||||
<button type="button">{detail.campaign.primaryButton}</button>
|
||||
<button type="button">{detail.campaign.secondaryButton}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="campaign-preview-foot">
|
||||
<div>
|
||||
<span>Template Name</span>
|
||||
<strong>{detail.campaign.templateName}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Language</span>
|
||||
<strong>{detail.campaign.language}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="campaign-detail-bottom-grid">
|
||||
<article className="campaign-detail-card campaign-detail-recipients-card">
|
||||
<div className="campaign-detail-card-head">
|
||||
<h2>Recipient List</h2>
|
||||
<div className="campaign-detail-icon-actions">
|
||||
<button type="button"><span className="material-symbols-outlined">filter_list</span></button>
|
||||
<button type="button"><span className="material-symbols-outlined">search</span></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="campaign-detail-table-wrap">
|
||||
<table className="campaign-detail-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Phone Number</th>
|
||||
<th>Status</th>
|
||||
<th>Timestamp</th>
|
||||
<th>Error Reason</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{detail.recipients.items.map((recipient) => (
|
||||
<tr key={recipient.id}>
|
||||
<td className="campaign-detail-mono">{recipient.phoneNumber}</td>
|
||||
<td>
|
||||
<div className={recipientStatusClassName(recipient.status)}>
|
||||
<span className="material-symbols-outlined">
|
||||
{recipient.status === 'Failed' ? 'error' : 'done_all'}
|
||||
</span>
|
||||
<span>{recipient.status}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>{formatRecipientTime(recipient.sentAt)}</td>
|
||||
<td>
|
||||
{recipient.errorReason ? (
|
||||
<span className="campaign-detail-error-pill">{recipient.errorReason}</span>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="campaign-detail-table-footer">
|
||||
<p>
|
||||
Showing {(detail.recipients.page - 1) * detail.recipients.pageSize + 1} to{' '}
|
||||
{(detail.recipients.page - 1) * detail.recipients.pageSize + detail.recipients.items.length} of{' '}
|
||||
{detail.recipients.total.toLocaleString('en-US')} recipients
|
||||
</p>
|
||||
<div className="campaign-detail-pagination">
|
||||
<Link
|
||||
href={`/dashboard/campaigns/${detail.campaign.id}?page=${Math.max(1, detail.recipients.page - 1)}`}
|
||||
className={detail.recipients.page <= 1 ? 'is-disabled' : ''}
|
||||
aria-disabled={detail.recipients.page <= 1}
|
||||
>
|
||||
<span className="material-symbols-outlined">chevron_left</span>
|
||||
</Link>
|
||||
<Link
|
||||
href={`/dashboard/campaigns/${detail.campaign.id}?page=${Math.min(detail.recipients.totalPages, detail.recipients.page + 1)}`}
|
||||
className={detail.recipients.page >= detail.recipients.totalPages ? 'is-disabled' : ''}
|
||||
aria-disabled={detail.recipients.page >= detail.recipients.totalPages}
|
||||
>
|
||||
<span className="material-symbols-outlined">chevron_right</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<aside className="campaign-detail-side-stack">
|
||||
<article className="campaign-detail-card">
|
||||
<h2>Device OS</h2>
|
||||
<div className="campaign-detail-device-list">
|
||||
{detail.deviceBreakdown.map((device) => (
|
||||
<div key={device.label} className="campaign-detail-device-row">
|
||||
<div className="campaign-detail-device-head">
|
||||
<span>
|
||||
<span className="material-symbols-outlined">{deviceIcon(device.label)}</span>
|
||||
{device.label}
|
||||
</span>
|
||||
<strong>{device.percentage}%</strong>
|
||||
</div>
|
||||
<div className="campaign-detail-device-track">
|
||||
<span
|
||||
className={
|
||||
device.label === 'Android'
|
||||
? 'is-android'
|
||||
: device.label === 'iOS'
|
||||
? 'is-ios'
|
||||
: 'is-web'
|
||||
}
|
||||
style={{ width: `${device.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="campaign-detail-insight-card">
|
||||
<div className="campaign-detail-insight-icon">
|
||||
<span className="material-symbols-outlined">rocket_launch</span>
|
||||
</div>
|
||||
<h3>Campaign Insights</h3>
|
||||
<p>
|
||||
Engagement peaked at 11:30 AM. Personalized CTA buttons increased click-through rate by 18.5%
|
||||
compared to previous month.
|
||||
</p>
|
||||
<button type="button">View Full Analysis</button>
|
||||
</article>
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
);
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
}
|
||||
15
frontend/src/app/dashboard/campaigns/page.tsx
Normal file
15
frontend/src/app/dashboard/campaigns/page.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { CampaignsManagementBoard } from '../../../components/campaigns-management-board';
|
||||
import { DashboardShell } from '../../../components/dashboard-shell';
|
||||
import { requireAuthToken } from '../../../lib/auth';
|
||||
import { fetchCampaigns } from '../../../lib/api';
|
||||
|
||||
export default async function CampaignsPage() {
|
||||
const token = await requireAuthToken();
|
||||
const campaignsData = await fetchCampaigns(token);
|
||||
|
||||
return (
|
||||
<DashboardShell currentPath="/dashboard/campaigns">
|
||||
<CampaignsManagementBoard campaigns={campaignsData.items} metrics={campaignsData.metrics} />
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
23
frontend/src/app/dashboard/contacts/[id]/page.tsx
Normal file
23
frontend/src/app/dashboard/contacts/[id]/page.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { ContactDetailBoard } from '../../../../components/contact-detail-board';
|
||||
import { DashboardShell } from '../../../../components/dashboard-shell';
|
||||
import { requireAuthToken } from '../../../../lib/auth';
|
||||
import { fetchContactDetail } from '../../../../lib/api';
|
||||
|
||||
export default async function ContactDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const token = await requireAuthToken();
|
||||
const { id } = await params;
|
||||
|
||||
let detail;
|
||||
try {
|
||||
detail = await fetchContactDetail(token, id);
|
||||
} catch {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardShell currentPath="/dashboard/contacts">
|
||||
<ContactDetailBoard contact={detail.contact} />
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
31
frontend/src/app/dashboard/contacts/page.tsx
Normal file
31
frontend/src/app/dashboard/contacts/page.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { DashboardShell } from '../../../components/dashboard-shell';
|
||||
import { ContactsDirectoryBoard } from '../../../components/contacts-directory-board';
|
||||
import { requireAuthToken } from '../../../lib/auth';
|
||||
import { fetchContactsDirectory } from '../../../lib/api';
|
||||
|
||||
export default async function ContactsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams?: Promise<{
|
||||
page?: string;
|
||||
limit?: string;
|
||||
search?: string;
|
||||
status?: string;
|
||||
tag?: string;
|
||||
}>;
|
||||
}) {
|
||||
const token = await requireAuthToken();
|
||||
const query = await searchParams;
|
||||
const page = Math.max(1, Number(query?.page || '1'));
|
||||
const limit = Math.max(1, Number(query?.limit || '10'));
|
||||
const search = query?.search || '';
|
||||
const status = query?.status || '';
|
||||
const tag = query?.tag || '';
|
||||
const data = await fetchContactsDirectory(token, { page, limit, search, status, tag });
|
||||
|
||||
return (
|
||||
<DashboardShell currentPath="/dashboard/contacts">
|
||||
<ContactsDirectoryBoard data={data} filters={{ search, status, tag }} />
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
35
frontend/src/app/dashboard/conversations/page.tsx
Normal file
35
frontend/src/app/dashboard/conversations/page.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import { DashboardShell } from '../../../components/dashboard-shell';
|
||||
import { ConversationsInbox } from '../../../components/conversations-inbox';
|
||||
import { requireAuthToken } from '../../../lib/auth';
|
||||
import { fetchConversationDetail, fetchConversations } from '../../../lib/api';
|
||||
|
||||
export default async function ConversationsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams?: Promise<{ q?: string }>;
|
||||
}) {
|
||||
const token = await requireAuthToken();
|
||||
const resolvedSearchParams = (await searchParams) || {};
|
||||
const search = resolvedSearchParams.q?.trim() || '';
|
||||
const conversations = await fetchConversations(token, { filter: 'all', search });
|
||||
const initialConversationDetail = conversations[0]
|
||||
? await fetchConversationDetail(token, conversations[0].id)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<DashboardShell
|
||||
currentPath="/dashboard/conversations"
|
||||
title="Conversations"
|
||||
searchPlaceholder="Search conversations..."
|
||||
searchValue={search}
|
||||
searchActionPath="/dashboard/conversations"
|
||||
searchQueryName="q"
|
||||
>
|
||||
<ConversationsInbox
|
||||
initialConversations={conversations}
|
||||
initialConversationDetail={initialConversationDetail}
|
||||
initialSearch={search}
|
||||
/>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
348
frontend/src/app/dashboard/logs/page.tsx
Normal file
348
frontend/src/app/dashboard/logs/page.tsx
Normal file
@ -0,0 +1,348 @@
|
||||
import { DashboardShell } from '../../../components/dashboard-shell';
|
||||
import {
|
||||
fetchAnalyticsSummary,
|
||||
fetchAuditTrail,
|
||||
} from '../../../lib/api';
|
||||
import { requireAuthToken } from '../../../lib/auth';
|
||||
import {
|
||||
analyticsLogs as fallbackAnalyticsLogs,
|
||||
analyticsWorkerHealth as fallbackWorkerHealth,
|
||||
} from '../../../lib/mock-data';
|
||||
|
||||
type StatusTone = 'success' | 'warning' | 'error';
|
||||
|
||||
function statusClassName(tone: StatusTone) {
|
||||
if (tone === 'error') return 'analytics-status-pill is-error';
|
||||
if (tone === 'warning') return 'analytics-status-pill is-warning';
|
||||
return 'analytics-status-pill is-success';
|
||||
}
|
||||
|
||||
function payloadButtonClassName(tone: 'primary' | 'success' | 'error' | 'neutral') {
|
||||
if (tone === 'error') return 'analytics-payload-button is-error';
|
||||
if (tone === 'success') return 'analytics-payload-button is-success';
|
||||
if (tone === 'primary') return 'analytics-payload-button is-primary';
|
||||
return 'analytics-payload-button';
|
||||
}
|
||||
|
||||
function formatTimestamp(value: string | null) {
|
||||
if (!value) {
|
||||
return 'Pending';
|
||||
}
|
||||
|
||||
return new Date(value).toLocaleString('sv-SE', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function actorBadge(name: string) {
|
||||
return name
|
||||
.split(/[\s_.-]+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase() || '')
|
||||
.join('') || 'SY';
|
||||
}
|
||||
|
||||
function inferActorKind(name: string) {
|
||||
const lowered = name.toLowerCase();
|
||||
if (lowered.includes('queue') || lowered.includes('system') || lowered.includes('scheduler')) {
|
||||
return { actorKind: 'service' as const, actorIcon: 'robot_2' };
|
||||
}
|
||||
|
||||
if (lowered.includes('ip:') || lowered.includes('http') || lowered.includes('webhook')) {
|
||||
return { actorKind: 'network' as const, actorIcon: 'public' };
|
||||
}
|
||||
|
||||
return { actorKind: 'user' as const, actorBadge: actorBadge(name) };
|
||||
}
|
||||
|
||||
function mapAuditStatus(actionType: string, severity: 'default' | 'alert'): { label: string; tone: StatusTone } {
|
||||
const lowered = actionType.toLowerCase();
|
||||
|
||||
if (severity === 'alert' || lowered.includes('fail') || lowered.includes('reject')) {
|
||||
return { label: 'Rejected', tone: 'error' };
|
||||
}
|
||||
|
||||
if (lowered.includes('retry') || lowered.includes('pending') || lowered.includes('queue')) {
|
||||
return { label: 'Pending', tone: 'warning' };
|
||||
}
|
||||
|
||||
if (lowered.includes('approve')) {
|
||||
return { label: 'Approved', tone: 'success' };
|
||||
}
|
||||
|
||||
return { label: 'Success', tone: 'success' };
|
||||
}
|
||||
|
||||
function mapPayloadTone(tone: StatusTone): 'primary' | 'success' | 'error' | 'neutral' {
|
||||
if (tone === 'error') return 'error';
|
||||
if (tone === 'warning') return 'neutral';
|
||||
return 'primary';
|
||||
}
|
||||
|
||||
export default async function LogsPage() {
|
||||
const token = await requireAuthToken();
|
||||
|
||||
const [summary, auditTrail] = await Promise.all([
|
||||
fetchAnalyticsSummary(token),
|
||||
fetchAuditTrail(token, { page: 1, limit: 5 }),
|
||||
]);
|
||||
|
||||
const queueStats = [
|
||||
{ label: 'Pending Jobs', value: summary.queue.pendingJobs.toLocaleString('en-US'), tone: 'info', icon: 'schedule' },
|
||||
{ label: 'Processing', value: summary.queue.processingJobs.toLocaleString('en-US'), tone: 'primary', icon: 'autorenew' },
|
||||
{ label: 'Failed (24H)', value: summary.queue.failedJobs24h.toLocaleString('en-US'), tone: 'error', icon: 'error_outline' },
|
||||
] as const;
|
||||
|
||||
const liveWorkerHealth = summary.workers.length > 0 ? summary.workers : fallbackWorkerHealth;
|
||||
|
||||
const liveLogs =
|
||||
auditTrail.items.length > 0
|
||||
? auditTrail.items.map((entry) => {
|
||||
const status = mapAuditStatus(entry.actionType, entry.severity);
|
||||
return {
|
||||
timestamp: formatTimestamp(entry.createdAt),
|
||||
action: entry.actionType.toUpperCase().replaceAll(' ', '_'),
|
||||
detail: entry.details,
|
||||
actor: entry.actorName,
|
||||
...inferActorKind(entry.actorName),
|
||||
status: status.label,
|
||||
statusTone: status.tone,
|
||||
payloadTone: mapPayloadTone(status.tone),
|
||||
};
|
||||
})
|
||||
: fallbackAnalyticsLogs;
|
||||
|
||||
const metricData = [
|
||||
{
|
||||
label: 'API Latency',
|
||||
value: `${summary.metrics.apiLatencyMs}ms`,
|
||||
meta: summary.health.database === 'ok' ? 'Live' : 'Degraded',
|
||||
metaTone: summary.health.database === 'ok' ? 'success' : 'warning',
|
||||
icon: 'bar_chart',
|
||||
chartTone: 'bars',
|
||||
chartHeights: summary.metrics.apiLatencyBars.map((value) => `${value}%`),
|
||||
},
|
||||
{
|
||||
label: 'DB Connections',
|
||||
value: summary.metrics.databaseConnectionsEstimate.toLocaleString('en-US'),
|
||||
meta: summary.health.database === 'ok' ? 'Active' : 'Down',
|
||||
metaTone: summary.health.database === 'ok' ? 'warning' : 'error',
|
||||
icon: 'database',
|
||||
chartTone: 'progress',
|
||||
progress: `${summary.metrics.databaseUsagePercent}%`,
|
||||
},
|
||||
{
|
||||
label: 'Memory Usage',
|
||||
value: `${summary.metrics.memoryUsageGbEstimate}GB`,
|
||||
meta: 'backend-estimated load',
|
||||
metaTone: 'muted',
|
||||
icon: 'memory',
|
||||
chartTone: 'memory',
|
||||
chartHeights: summary.metrics.memoryBars.map((value) => `${value}%`),
|
||||
},
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<DashboardShell
|
||||
currentPath="/dashboard/logs"
|
||||
title="Admin Dashboard"
|
||||
searchPlaceholder="Search system logs..."
|
||||
>
|
||||
<section className="analytics-page">
|
||||
<header className="analytics-hero">
|
||||
<div>
|
||||
<p className="page-eyebrow">Operations</p>
|
||||
<h1 className="analytics-heading">Activity Logs & Queue Monitor</h1>
|
||||
<p className="analytics-copy">
|
||||
Real-time surveillance of system processes, background jobs, and administrative actions.
|
||||
</p>
|
||||
</div>
|
||||
<div className="analytics-hero-actions">
|
||||
<button type="button" className="analytics-ghost-button">
|
||||
<span className="material-symbols-outlined">filter_list</span>
|
||||
Filter Logs
|
||||
</button>
|
||||
<button type="button" className="analytics-ghost-button">
|
||||
<span className="material-symbols-outlined">download</span>
|
||||
Export CSV
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="analytics-bento">
|
||||
<aside className="analytics-side-stack">
|
||||
<article className="surface-card analytics-panel">
|
||||
<div className="analytics-panel-head">
|
||||
<h3>Queue Monitor</h3>
|
||||
<span className="analytics-live-chip">
|
||||
<span className="analytics-live-dot" />
|
||||
{summary.health.status === 'ok' ? 'Live' : 'Fallback'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="analytics-queue-stack">
|
||||
{queueStats.map((item) => (
|
||||
<div key={item.label} className={`analytics-queue-card is-${item.tone}`}>
|
||||
<div>
|
||||
<p>{item.label}</p>
|
||||
<strong>{item.value}</strong>
|
||||
</div>
|
||||
<span className={`material-symbols-outlined analytics-queue-icon ${item.icon === 'autorenew' ? 'is-spinning' : ''}`}>
|
||||
{item.icon}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="analytics-worker-health">
|
||||
<h4>Worker Health</h4>
|
||||
<div className="analytics-worker-list">
|
||||
{liveWorkerHealth.map((worker) => (
|
||||
<div key={worker.name} className="analytics-worker-item">
|
||||
<div className="analytics-worker-meta">
|
||||
<span>{worker.name}</span>
|
||||
<strong className={`is-${worker.tone}`}>{worker.load}% Load</strong>
|
||||
</div>
|
||||
<div className="analytics-progress-track">
|
||||
<div className={`analytics-progress-fill is-${worker.tone}`} style={{ width: `${worker.load}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="analytics-throughput-card">
|
||||
<div className="analytics-throughput-icon">
|
||||
<span className="material-symbols-outlined">bolt</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Queue Throughput</h3>
|
||||
<p>
|
||||
System is currently processing {summary.throughput.perMinute.toLocaleString('en-US')} workload points
|
||||
per minute with {summary.throughput.verifiedWebhookRate}% verified webhook traffic.
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</aside>
|
||||
|
||||
<article className="surface-card analytics-table-card">
|
||||
<div className="analytics-table-head">
|
||||
<h3>Technical Activity Logs</h3>
|
||||
<span>Total: {auditTrail.total.toLocaleString('en-US')} events</span>
|
||||
</div>
|
||||
<div className="analytics-table-scroll">
|
||||
<table className="analytics-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>Action</th>
|
||||
<th>User / Service</th>
|
||||
<th>Status</th>
|
||||
<th>Payload</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{liveLogs.map((log) => (
|
||||
<tr key={`${log.timestamp}-${log.action}`}>
|
||||
<td className="analytics-table-time">{log.timestamp}</td>
|
||||
<td>
|
||||
<div className="analytics-action-cell">
|
||||
<strong>{log.action}</strong>
|
||||
<span>{log.detail}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="analytics-actor-cell">
|
||||
{log.actorKind === 'user' ? (
|
||||
<span className="analytics-actor-badge">{log.actorBadge}</span>
|
||||
) : (
|
||||
<span className="material-symbols-outlined analytics-actor-icon">{log.actorIcon}</span>
|
||||
)}
|
||||
<span>{log.actor}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className={statusClassName(log.statusTone)}>{log.status}</span>
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" className={payloadButtonClassName(log.payloadTone)} aria-label={`Open payload for ${log.action}`}>
|
||||
<span className="material-symbols-outlined">code</span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="analytics-table-foot">
|
||||
<span>
|
||||
Showing {liveLogs.length} of {auditTrail.total.toLocaleString('en-US')} logs
|
||||
</span>
|
||||
<div className="analytics-pagination">
|
||||
<button type="button" aria-label="Previous page">
|
||||
<span className="material-symbols-outlined">chevron_left</span>
|
||||
</button>
|
||||
<button type="button" aria-label="Next page">
|
||||
<span className="material-symbols-outlined">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="analytics-live-tail">
|
||||
<div className="analytics-live-tail-icon">
|
||||
<span className="material-symbols-outlined">terminal</span>
|
||||
</div>
|
||||
<div className="analytics-live-tail-copy">
|
||||
<h3>Live Tail Mode</h3>
|
||||
<p>
|
||||
Pulling live audit trail, queue jobs, and webhook activity from the backend. Current snapshot includes{' '}
|
||||
{summary.totals.totalJobs} jobs and {summary.totals.totalWebhookEvents} webhook events.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="analytics-console-button">
|
||||
<span className="material-symbols-outlined">play_circle</span>
|
||||
Launch Debug Console
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="analytics-metrics-grid">
|
||||
{metricData.map((metric) => (
|
||||
<article key={metric.label} className="surface-card analytics-metric-card">
|
||||
<div className="analytics-metric-head">
|
||||
<span>{metric.label}</span>
|
||||
<span className="material-symbols-outlined">{metric.icon}</span>
|
||||
</div>
|
||||
<div className="analytics-metric-value">
|
||||
<strong>{metric.value}</strong>
|
||||
<span className={`is-${metric.metaTone}`}>{metric.meta}</span>
|
||||
</div>
|
||||
|
||||
{metric.chartTone === 'progress' ? (
|
||||
<div className="analytics-progress-track analytics-metric-progress">
|
||||
<div className="analytics-progress-fill is-warning" style={{ width: metric.progress }} />
|
||||
</div>
|
||||
) : (
|
||||
<div className={`analytics-mini-bars ${metric.chartTone === 'memory' ? 'is-memory' : ''}`}>
|
||||
{metric.chartHeights.map((height, index) => (
|
||||
<span
|
||||
key={`${metric.label}-${index}`}
|
||||
className={index >= 4 && metric.chartTone === 'bars' ? 'is-primary' : ''}
|
||||
style={{ height }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
</section>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
195
frontend/src/app/dashboard/page.tsx
Normal file
195
frontend/src/app/dashboard/page.tsx
Normal file
@ -0,0 +1,195 @@
|
||||
import { DashboardShell } from '../../components/dashboard-shell';
|
||||
import { dashboardStats } from '../../lib/mock-data';
|
||||
|
||||
export default async function DashboardPage() {
|
||||
return (
|
||||
<DashboardShell currentPath="/dashboard" title="Dashboard Overview">
|
||||
<section className="dashboard-overview-layout">
|
||||
<div className="dashboard-overview-main">
|
||||
<section className="dashboard-stat-grid">
|
||||
{dashboardStats.map((item) => (
|
||||
<article key={item.label} className="surface-card dashboard-kpi-card">
|
||||
<div className="dashboard-kpi-top">
|
||||
<span className="card-kicker">{item.label.toUpperCase()}</span>
|
||||
<span className="dashboard-kpi-icon">
|
||||
<span className="material-symbols-outlined">
|
||||
{item.label === 'Total Messages'
|
||||
? 'chat_bubble'
|
||||
: item.label === 'Delivered'
|
||||
? 'done_all'
|
||||
: item.label === 'Read Rate'
|
||||
? 'visibility'
|
||||
: 'error'}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="dashboard-kpi-metric">
|
||||
<h3>{item.value}</h3>
|
||||
<span className={`dashboard-kpi-delta ${item.tone}`}>
|
||||
<span className="material-symbols-outlined">
|
||||
{item.tone === 'success' ? 'trending_up' : item.tone === 'warning' ? 'trending_down' : 'remove'}
|
||||
</span>
|
||||
{item.delta}
|
||||
</span>
|
||||
</div>
|
||||
<p className="card-caption">
|
||||
{item.label === 'Total Messages'
|
||||
? 'vs last 30 days'
|
||||
: item.label === 'Delivered'
|
||||
? '1,180,800 messages'
|
||||
: item.label === 'Read Rate'
|
||||
? '914,400 read'
|
||||
: '19,200 errors'}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="surface-card dashboard-volume-card">
|
||||
<div className="card-head">
|
||||
<h3>Message Volume (Last 7 Days)</h3>
|
||||
<select className="dashboard-select">
|
||||
<option>Daily</option>
|
||||
<option>Hourly</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="dashboard-line-chart">
|
||||
<div className="dashboard-line-axis">
|
||||
{[
|
||||
['MON', '60%'],
|
||||
['TUE', '75%'],
|
||||
['WED', '65%'],
|
||||
['THU', '90%'],
|
||||
['FRI', '80%'],
|
||||
['SAT', '40%'],
|
||||
['SUN', '35%'],
|
||||
].map(([label, height]) => (
|
||||
<div key={label} className="dashboard-axis-column">
|
||||
<div className="dashboard-axis-line" style={{ height }} />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<svg className="dashboard-line-svg" preserveAspectRatio="none" viewBox="0 0 800 300">
|
||||
<defs>
|
||||
<linearGradient id="chartGradient" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stopColor="#25D366" stopOpacity="0.3" />
|
||||
<stop offset="100%" stopColor="#25D366" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M0,180 Q100,120 200,80 T400,150 T600,60 T800,180 V300 H0 Z" fill="url(#chartGradient)" />
|
||||
<path d="M0,180 Q100,120 200,80 T400,150 T600,60 T800,180" fill="none" stroke="#25D366" strokeLinecap="round" strokeWidth="4" />
|
||||
</svg>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="surface-card dashboard-campaigns-card">
|
||||
<div className="dashboard-table-head">
|
||||
<h3>Recent Campaigns</h3>
|
||||
<button type="button" className="dashboard-link-button">View All</button>
|
||||
</div>
|
||||
<table className="dashboard-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Campaign Name</th>
|
||||
<th>Audience</th>
|
||||
<th>Status</th>
|
||||
<th>Delivery</th>
|
||||
<th>Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Summer Flash Sale #4</td>
|
||||
<td>45,200 users</td>
|
||||
<td><span className="status-pill success">SENT</span></td>
|
||||
<td>99.2%</td>
|
||||
<td>Oct 24, 2023</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Member Newsletter Oct</td>
|
||||
<td>12,800 users</td>
|
||||
<td><span className="status-pill info">SCHEDULED</span></td>
|
||||
<td>--</td>
|
||||
<td>Oct 28, 2023</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Product Launch Beta</td>
|
||||
<td>800 users</td>
|
||||
<td><span className="status-pill neutral">DRAFT</span></td>
|
||||
<td>--</td>
|
||||
<td>Nov 02, 2023</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside className="dashboard-overview-side">
|
||||
<article className="surface-card dashboard-funnel-card">
|
||||
<h3>Delivery Funnel</h3>
|
||||
<div className="dashboard-funnel-list">
|
||||
{[
|
||||
['Sent', '1,200,000', '100%', '0'],
|
||||
['Delivered', '1,180,800 (98.4%)', '98%', '16'],
|
||||
['Read', '914,400 (76.2%)', '76%', '32'],
|
||||
['Replied', '216,000 (18%)', '18%', '48'],
|
||||
].map(([label, value, width, offset]) => (
|
||||
<div key={label} className="dashboard-funnel-row" style={{ marginLeft: `${offset}px` }}>
|
||||
<div className="dashboard-funnel-meta">
|
||||
<span>{label}</span>
|
||||
<span>{value}</span>
|
||||
</div>
|
||||
<div className="dashboard-funnel-track">
|
||||
<div className="dashboard-funnel-fill" style={{ width }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="dashboard-webhook-card">
|
||||
<div className="dashboard-webhook-head">
|
||||
<div className="dashboard-webhook-title">
|
||||
<span className="material-symbols-outlined">terminal</span>
|
||||
<h3>Webhook Health</h3>
|
||||
</div>
|
||||
<span className="dashboard-live-dot" />
|
||||
</div>
|
||||
<div className="dashboard-endpoint-box">
|
||||
<div className="dashboard-endpoint-meta">
|
||||
<span>ENDPOINT</span>
|
||||
<span>LIVE</span>
|
||||
</div>
|
||||
<code>https://api.acme.corp/v1/wh/whatsapp</code>
|
||||
</div>
|
||||
<div className="dashboard-webhook-stats">
|
||||
<div><span>Avg. Response Time</span><strong>142ms</strong></div>
|
||||
<div><span>Success Rate</span><strong>99.98%</strong></div>
|
||||
<div><span>Pending Payloads</span><strong>0</strong></div>
|
||||
</div>
|
||||
<button type="button" className="dashboard-outline-button">View Debug Logs</button>
|
||||
</article>
|
||||
|
||||
<article className="surface-card dashboard-alerts-card">
|
||||
<h3>System Alerts</h3>
|
||||
<div className="dashboard-alert danger">
|
||||
<span className="material-symbols-outlined">warning</span>
|
||||
<div>
|
||||
<strong>Credit Low Alert</strong>
|
||||
<p>Balance is below $50.00. Top up soon to avoid interruption.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="dashboard-alert info">
|
||||
<span className="material-symbols-outlined">info</span>
|
||||
<div>
|
||||
<strong>New Feature</strong>
|
||||
<p>Multi-device authentication is now available for all business accounts.</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</aside>
|
||||
</section>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
68
frontend/src/app/dashboard/roles/page.tsx
Normal file
68
frontend/src/app/dashboard/roles/page.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
import { DashboardShell } from '../../../components/dashboard-shell';
|
||||
import { RolesPermissionsBoard } from '../../../components/roles-permissions-board';
|
||||
import { requireAuthToken } from '../../../lib/auth';
|
||||
import { fetchAuditTrail, fetchRoles } from '../../../lib/api';
|
||||
|
||||
function formatRelativeTime(value: string) {
|
||||
const diff = Date.now() - new Date(value).getTime();
|
||||
const minutes = Math.max(1, Math.floor(diff / 60000));
|
||||
|
||||
if (minutes < 60) {
|
||||
return `${minutes} min ago`;
|
||||
}
|
||||
|
||||
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 iconForAction(actionType: string) {
|
||||
if (actionType.includes('Created')) return 'add_circle';
|
||||
if (actionType.includes('Updated')) return 'person_edit';
|
||||
if (actionType.includes('Deleted')) return 'delete';
|
||||
return 'history';
|
||||
}
|
||||
|
||||
export default async function RolesPage() {
|
||||
const token = await requireAuthToken();
|
||||
const [roles, auditTrail] = await Promise.all([
|
||||
fetchRoles(token),
|
||||
fetchAuditTrail(token, { limit: 3, module: 'Access Control' }),
|
||||
]);
|
||||
|
||||
return (
|
||||
<DashboardShell currentPath="/dashboard/roles">
|
||||
<section className="page-header">
|
||||
<div>
|
||||
<p className="page-eyebrow">Access Control</p>
|
||||
<h1 className="page-heading">Roles & Permissions</h1>
|
||||
<p className="page-copy">Configure access levels and granular permissions for campaigns, analytics, settings, and audit visibility.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<RolesPermissionsBoard
|
||||
initialRoles={roles.map((role) => ({
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
badge: role.badge,
|
||||
tone: role.tone,
|
||||
summary: role.summary,
|
||||
usersAssigned: role.usersAssigned,
|
||||
icon: role.icon,
|
||||
permissionRows: role.permissions,
|
||||
}))}
|
||||
initialAuditHighlights={auditTrail.items.map((item) => ({
|
||||
id: item.id,
|
||||
icon: iconForAction(item.actionType),
|
||||
title: item.actionType,
|
||||
description: item.details,
|
||||
time: formatRelativeTime(item.createdAt),
|
||||
}))}
|
||||
/>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
5
frontend/src/app/dashboard/settings/api/page.tsx
Normal file
5
frontend/src/app/dashboard/settings/api/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function DeprecatedApiSettingsPage() {
|
||||
redirect('/dashboard/settings/whatsapp-api');
|
||||
}
|
||||
31
frontend/src/app/dashboard/settings/audit-trail/page.tsx
Normal file
31
frontend/src/app/dashboard/settings/audit-trail/page.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { AuditTrailBoard } from '../../../../components/audit-trail-board';
|
||||
import { DashboardShell } from '../../../../components/dashboard-shell';
|
||||
import { requireAuthToken } from '../../../../lib/auth';
|
||||
import { fetchAuditTrail } from '../../../../lib/api';
|
||||
|
||||
export default async function AuditTrailPage() {
|
||||
const token = await requireAuthToken();
|
||||
const auditTrail = await fetchAuditTrail(token, { page: 1, limit: 50 });
|
||||
const normalizedEntries = auditTrail.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,
|
||||
}));
|
||||
|
||||
return (
|
||||
<DashboardShell currentPath="/dashboard/settings/audit-trail">
|
||||
<AuditTrailBoard
|
||||
initialEntries={normalizedEntries}
|
||||
initialTotal={auditTrail.total}
|
||||
initialPage={auditTrail.page}
|
||||
initialPageSize={auditTrail.pageSize}
|
||||
initialTotalPages={auditTrail.totalPages}
|
||||
/>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
7
frontend/src/app/dashboard/settings/page.tsx
Normal file
7
frontend/src/app/dashboard/settings/page.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { DashboardShell } from '../../../components/dashboard-shell';
|
||||
|
||||
export default async function SettingsPage() {
|
||||
void DashboardShell;
|
||||
redirect('/dashboard/settings/whatsapp-api');
|
||||
}
|
||||
49
frontend/src/app/dashboard/settings/security/page.tsx
Normal file
49
frontend/src/app/dashboard/settings/security/page.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import { DashboardShell } from '../../../../components/dashboard-shell';
|
||||
import { SecuritySessionCard } from '../../../../components/security-session-card';
|
||||
import { TwoFactorSettingsCard } from '../../../../components/two-factor-settings-card';
|
||||
import { fetchCurrentSession, fetchTwoFactorStatus } from '../../../../lib/api';
|
||||
import { requireAuthToken } from '../../../../lib/auth';
|
||||
|
||||
export default async function SecuritySettingsPage() {
|
||||
const token = await requireAuthToken();
|
||||
const [status, session] = await Promise.all([fetchTwoFactorStatus(token), fetchCurrentSession(token)]);
|
||||
|
||||
return (
|
||||
<DashboardShell currentPath="/dashboard/settings/security">
|
||||
<section className="page-header">
|
||||
<div>
|
||||
<p className="page-eyebrow">Settings</p>
|
||||
<h1 className="page-heading">Security</h1>
|
||||
<p className="page-copy">
|
||||
Hardening auth production, termasuk TOTP, backup recovery codes, dan sesi admin yang lebih aman.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="dashboard-two-column">
|
||||
<article className="surface-card">
|
||||
<div className="metric-stack">
|
||||
<div>
|
||||
<strong>JWT Auth</strong>
|
||||
<span>Login dan protected routes aktif</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Webhook Verification</strong>
|
||||
<span>Verify token, shared secret, dan Meta signature tersedia</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Queue Retry</strong>
|
||||
<span>Webhook jobs dapat di-retry dan tercatat di logs</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<TwoFactorSettingsCard status={status} />
|
||||
</section>
|
||||
|
||||
<section className="dashboard-two-column-bottom">
|
||||
<SecuritySessionCard session={session} />
|
||||
</section>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
102
frontend/src/app/dashboard/settings/webhook-logs/page.tsx
Normal file
102
frontend/src/app/dashboard/settings/webhook-logs/page.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
import Link from 'next/link';
|
||||
import { DashboardShell } from '../../../../components/dashboard-shell';
|
||||
import { WebhookRetryForm } from '../../../../components/webhook-retry-form';
|
||||
import { requireAuthToken } from '../../../../lib/auth';
|
||||
import { fetchJobLogs, fetchWebhookLogs } from '../../../../lib/api';
|
||||
|
||||
type Props = {
|
||||
searchParams?: Promise<{
|
||||
eventId?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
function formatTime(value: string | null) {
|
||||
if (!value) {
|
||||
return 'Pending';
|
||||
}
|
||||
|
||||
return new Date(value).toLocaleString('id-ID', {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'short',
|
||||
});
|
||||
}
|
||||
|
||||
export default async function SettingsWebhookLogsPage({ searchParams }: Props) {
|
||||
const token = await requireAuthToken();
|
||||
const params = (await searchParams) || {};
|
||||
const [webhookLogs, jobLogs] = await Promise.all([fetchWebhookLogs(token), fetchJobLogs(token)]);
|
||||
const selectedLog =
|
||||
webhookLogs.find((log) => log.eventId === params.eventId) || webhookLogs[0] || null;
|
||||
|
||||
return (
|
||||
<DashboardShell currentPath="/dashboard/settings/webhook-logs">
|
||||
<section className="page-header">
|
||||
<div>
|
||||
<p className="page-eyebrow">Settings</p>
|
||||
<h1 className="page-heading">Webhook Logs</h1>
|
||||
<p className="page-copy">
|
||||
Delivery log webhook sekarang mengambil data backend nyata, termasuk status event dan job queue.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="dashboard-two-column">
|
||||
<article className="surface-card">
|
||||
<div className="data-table">
|
||||
<div className="data-row data-row-head webhook-grid">
|
||||
<span>Provider</span>
|
||||
<span>Event</span>
|
||||
<span>Status</span>
|
||||
<span>Time</span>
|
||||
</div>
|
||||
{webhookLogs.map((log) => (
|
||||
<Link
|
||||
key={log.id}
|
||||
href={`/dashboard/settings/webhook-logs?eventId=${encodeURIComponent(log.eventId)}`}
|
||||
className="data-row webhook-grid data-row-link"
|
||||
>
|
||||
<span>{log.provider}</span>
|
||||
<span>{log.eventType}</span>
|
||||
<span>{log.processingStatus}</span>
|
||||
<span>{formatTime((log as { createdAt?: string }).createdAt || null)}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="surface-card code-card">
|
||||
{selectedLog ? (
|
||||
<div className="detail-stack">
|
||||
<div>
|
||||
<strong>{selectedLog.eventId}</strong>
|
||||
<span>{selectedLog.eventType}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{selectedLog.processingStatus}</strong>
|
||||
<span>{selectedLog.processingNotes || 'No processing notes'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{selectedLog.verified ? 'Verified' : 'Unverified'}</strong>
|
||||
<span>
|
||||
Sender: {selectedLog.senderPhone || '-'} | Recipient: {selectedLog.recipientPhone || '-'}
|
||||
</span>
|
||||
</div>
|
||||
<WebhookRetryForm eventId={selectedLog.eventId} />
|
||||
<pre>{JSON.stringify(selectedLog.payloadJson, null, 2)}</pre>
|
||||
</div>
|
||||
) : (
|
||||
<p>No webhook logs available.</p>
|
||||
)}
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="surface-card code-card">
|
||||
<div className="card-head">
|
||||
<h2>Recent Queue Jobs</h2>
|
||||
<span className="soft-chip">Redis + DB audit</span>
|
||||
</div>
|
||||
<pre>{JSON.stringify(jobLogs.slice(0, 5), null, 2)}</pre>
|
||||
</section>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
99
frontend/src/app/dashboard/settings/whatsapp-api/page.tsx
Normal file
99
frontend/src/app/dashboard/settings/whatsapp-api/page.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
import { DashboardShell } from '../../../../components/dashboard-shell';
|
||||
import { WhatsappSettingsForm } from '../../../../components/whatsapp-settings-form';
|
||||
import { requireAuthToken } from '../../../../lib/auth';
|
||||
import { fetchWhatsappSettings } from '../../../../lib/api';
|
||||
|
||||
function maskSecret(enabled: boolean) {
|
||||
return enabled ? 'Configured' : 'Not configured';
|
||||
}
|
||||
|
||||
export default async function WhatsappApiSettingsPage() {
|
||||
const token = await requireAuthToken();
|
||||
const settings = await fetchWhatsappSettings(token);
|
||||
|
||||
return (
|
||||
<DashboardShell currentPath="/dashboard/settings/whatsapp-api">
|
||||
<section className="page-header">
|
||||
<div>
|
||||
<p className="page-eyebrow">Settings</p>
|
||||
<h1 className="page-heading">WhatsApp API Setting</h1>
|
||||
<p className="page-copy">
|
||||
Halaman ini hanya menampilkan parameter yang memang didukung backend saat ini: provider,
|
||||
webhook URL, verify token, shared secret/app secret status, dan status koneksi.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="dashboard-two-column">
|
||||
<WhatsappSettingsForm
|
||||
initialValues={{
|
||||
provider: settings.provider,
|
||||
verifyToken: settings.verifyToken,
|
||||
phoneNumberId: settings.phoneNumberId,
|
||||
isEnabled: settings.isEnabled,
|
||||
subscriptions: settings.subscriptions,
|
||||
availableSubscriptions: settings.availableSubscriptions,
|
||||
}}
|
||||
/>
|
||||
|
||||
<article className="surface-card">
|
||||
<div className="metric-stack">
|
||||
<div>
|
||||
<strong>{settings.isEnabled ? 'Enabled' : 'Disabled'}</strong>
|
||||
<span>Integration runtime switch</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{settings.hasSharedSecret ? 'Shared secret ready' : 'Shared secret missing'}</strong>
|
||||
<span>Generic provider verification</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{settings.hasAppSecret ? 'Meta signature ready' : 'Meta signature inactive'}</strong>
|
||||
<span>Meta-specific signature validation</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{settings.hasAccessToken ? 'Access token ready' : 'Access token missing'}</strong>
|
||||
<span>Required for outbound message send from Conversations</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{settings.webhookUrl}</strong>
|
||||
<span>Current webhook destination URL</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{settings.subscriptions.length} active subscriptions</strong>
|
||||
<span>
|
||||
{settings.availableSubscriptions
|
||||
.filter((item) => settings.subscriptions.includes(item.key))
|
||||
.map((item) => item.label)
|
||||
.join(', ') || 'No subscriptions enabled'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="dashboard-two-column">
|
||||
<article className="surface-card">
|
||||
<h2>Parameter Match Check</h2>
|
||||
<p>
|
||||
Sesuai backend: `provider`, `webhookUrl`, `verifyToken`, `sharedSecret`, `appSecret`,
|
||||
`accessToken`, `phoneNumberId`, `isEnabled`, `subscriptions`, dan test webhook.
|
||||
</p>
|
||||
<p>
|
||||
Belum ada di backend: sender pool count, latency metrics, dan delivery
|
||||
success analytics khusus settings page.
|
||||
</p>
|
||||
</article>
|
||||
<article className="surface-card">
|
||||
<h2>Secret Visibility Note</h2>
|
||||
<p>
|
||||
Backend tidak mengembalikan nilai secret asli. Form edit hanya bisa mengganti secret,
|
||||
bukan membaca ulang nilainya.
|
||||
</p>
|
||||
<p>
|
||||
Status `Configured` berarti secret sudah ada di backend, tetapi nilainya tetap disembunyikan.
|
||||
</p>
|
||||
</article>
|
||||
</section>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
27
frontend/src/app/dashboard/templates/builder/page.tsx
Normal file
27
frontend/src/app/dashboard/templates/builder/page.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import { DashboardShell } from '../../../../components/dashboard-shell';
|
||||
import { TemplateBuilderForm } from '../../../../components/template-builder-form';
|
||||
import { requireAuthToken } from '../../../../lib/auth';
|
||||
import { fetchTemplateById } from '../../../../lib/api';
|
||||
|
||||
type Props = {
|
||||
searchParams?: Promise<{
|
||||
id?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export default async function TemplateBuilderPage({ searchParams }: Props) {
|
||||
const token = await requireAuthToken();
|
||||
const resolvedSearchParams = (await searchParams) || {};
|
||||
const templateId = resolvedSearchParams.id?.trim();
|
||||
const template = templateId ? await fetchTemplateById(token, templateId) : null;
|
||||
|
||||
return (
|
||||
<DashboardShell
|
||||
currentPath="/dashboard/templates"
|
||||
title="Admin Dashboard"
|
||||
searchPlaceholder="Search templates..."
|
||||
>
|
||||
<TemplateBuilderForm initialTemplate={template} />
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
193
frontend/src/app/dashboard/templates/page.tsx
Normal file
193
frontend/src/app/dashboard/templates/page.tsx
Normal file
@ -0,0 +1,193 @@
|
||||
import Link from 'next/link';
|
||||
import { DashboardShell } from '../../../components/dashboard-shell';
|
||||
import { requireAuthToken } from '../../../lib/auth';
|
||||
import { fetchTemplates } from '../../../lib/api';
|
||||
|
||||
function templateStatusClassName(status: string) {
|
||||
if (status === 'Pending') return 'templates-status-pill is-pending';
|
||||
if (status === 'Rejected' || status === 'Archived') return 'templates-status-pill is-rejected';
|
||||
return 'templates-status-pill is-approved';
|
||||
}
|
||||
|
||||
type Props = {
|
||||
searchParams?: Promise<{
|
||||
search?: string;
|
||||
category?: string;
|
||||
status?: string;
|
||||
language?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export default async function TemplatesPage({ searchParams }: Props) {
|
||||
const token = await requireAuthToken();
|
||||
const resolvedSearchParams = (await searchParams) || {};
|
||||
const search = resolvedSearchParams.search?.trim() || '';
|
||||
const category = resolvedSearchParams.category?.trim() || '';
|
||||
const status = resolvedSearchParams.status?.trim() || '';
|
||||
const language = resolvedSearchParams.language?.trim() || '';
|
||||
const templatesData = await fetchTemplates(token, {
|
||||
search: search || undefined,
|
||||
category: category || undefined,
|
||||
status: status || undefined,
|
||||
language: language || undefined,
|
||||
});
|
||||
|
||||
const categoryOptions = Array.from(new Set(templatesData.items.map((template) => template.category))).sort();
|
||||
const statusOptions = Array.from(new Set(templatesData.items.map((template) => template.status))).sort();
|
||||
const languageOptions = Array.from(new Set(templatesData.items.map((template) => template.language))).sort();
|
||||
|
||||
return (
|
||||
<DashboardShell
|
||||
currentPath="/dashboard/templates"
|
||||
title="Admin Dashboard"
|
||||
searchPlaceholder="Search templates..."
|
||||
searchValue={search}
|
||||
searchQueryName="search"
|
||||
>
|
||||
<section className="templates-page">
|
||||
<header className="templates-hero">
|
||||
<div>
|
||||
<h1 className="templates-heading">Message Templates</h1>
|
||||
<p className="templates-copy">
|
||||
Create and manage your WhatsApp message templates. All templates must be approved by WhatsApp before
|
||||
sending.
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/dashboard/templates/builder" className="templates-create-button">
|
||||
<span className="material-symbols-outlined">add_circle</span>
|
||||
Create New Template
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
<form className="templates-filter-row" method="GET">
|
||||
<input type="hidden" name="search" defaultValue={search} />
|
||||
<label className="templates-filter-pill templates-filter-pill-select">
|
||||
<span>Category</span>
|
||||
<select name="category" defaultValue={category}>
|
||||
<option value="">All</option>
|
||||
{categoryOptions.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="templates-filter-pill templates-filter-pill-select">
|
||||
<span>Status</span>
|
||||
<select name="status" defaultValue={status}>
|
||||
<option value="">All</option>
|
||||
{statusOptions.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="templates-filter-pill templates-filter-pill-select">
|
||||
<span>Language</span>
|
||||
<select name="language" defaultValue={language}>
|
||||
<option value="">All</option>
|
||||
{languageOptions.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" className="templates-filter-apply">
|
||||
Apply
|
||||
</button>
|
||||
<div className="templates-filter-meta">Showing {templatesData.total} Templates</div>
|
||||
</form>
|
||||
|
||||
<section className="templates-grid">
|
||||
{templatesData.items.filter((template) => !template.compact).map((template) => (
|
||||
<article key={template.id} className="templates-card surface-card">
|
||||
<div className="templates-card-head">
|
||||
<span className={templateStatusClassName(template.status)}>{template.status}</span>
|
||||
<Link href={`/dashboard/templates/builder?id=${template.id}`} className="templates-overflow-button">
|
||||
<span className="material-symbols-outlined">edit</span>
|
||||
</Link>
|
||||
</div>
|
||||
<h2>{template.name}</h2>
|
||||
<p className="templates-category">{template.category}</p>
|
||||
<div className="templates-preview-box">
|
||||
<p>{template.preview}</p>
|
||||
<div className="templates-preview-fade" />
|
||||
</div>
|
||||
<footer className="templates-card-foot">
|
||||
<div className={`templates-update-meta ${template.status === 'Rejected' ? 'is-alert' : ''}`}>
|
||||
<span className="material-symbols-outlined">
|
||||
{template.status === 'Rejected' ? 'warning' : 'schedule'}
|
||||
</span>
|
||||
<span>{template.updatedLabel}</span>
|
||||
</div>
|
||||
<Link href={`/dashboard/templates/builder?id=${template.id}`} className="templates-edit-button">
|
||||
<span className="material-symbols-outlined">edit</span>
|
||||
</Link>
|
||||
</footer>
|
||||
</article>
|
||||
))}
|
||||
|
||||
{templatesData.items
|
||||
.filter((template) => template.compact)
|
||||
.map((template) => (
|
||||
<article key={template.id} className="templates-row-card surface-card">
|
||||
<div className="templates-row-icon">
|
||||
<span className="material-symbols-outlined">mail</span>
|
||||
</div>
|
||||
<div className="templates-row-main">
|
||||
<h2>{template.name}</h2>
|
||||
<div className="templates-row-meta">
|
||||
<span>{template.category}</span>
|
||||
<i />
|
||||
<p>{template.preview}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="templates-row-date">
|
||||
<span>Last Modified</span>
|
||||
<strong>{template.updatedLabel}</strong>
|
||||
</div>
|
||||
<div className="templates-row-status">
|
||||
<span className={templateStatusClassName(template.status)}>{template.status}</span>
|
||||
</div>
|
||||
<div className="templates-row-actions">
|
||||
<Link href={`/dashboard/templates/builder?id=${template.id}`} aria-label={`Preview ${template.name}`}>
|
||||
<span className="material-symbols-outlined">visibility</span>
|
||||
</Link>
|
||||
<Link
|
||||
href={`/dashboard/templates/builder?id=${template.id}`}
|
||||
className="is-primary"
|
||||
aria-label={`Edit ${template.name}`}
|
||||
>
|
||||
<span className="material-symbols-outlined">edit</span>
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="templates-guidelines">
|
||||
<div className="templates-guidelines-icon">
|
||||
<span className="material-symbols-outlined">contact_support</span>
|
||||
</div>
|
||||
<div className="templates-guidelines-copy">
|
||||
<h3>Need help with Template Guidelines?</h3>
|
||||
<p>
|
||||
WhatsApp has strict policies on message content. Ensure your templates follow the Business Policy to avoid
|
||||
rejection and maintain a high quality rating.
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
className="templates-guidelines-button"
|
||||
href="https://www.whatsapp.com/legal/business-policy/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Read Guidelines
|
||||
</a>
|
||||
</section>
|
||||
</section>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
25
frontend/src/app/dashboard/users/page.tsx
Normal file
25
frontend/src/app/dashboard/users/page.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { DashboardShell } from '../../../components/dashboard-shell';
|
||||
import { UsersManagementBoard } from '../../../components/users-management-board';
|
||||
import { requireAuthToken } from '../../../lib/auth';
|
||||
import { fetchRoles, fetchUsers } from '../../../lib/api';
|
||||
|
||||
export default async function UsersPage() {
|
||||
const token = await requireAuthToken();
|
||||
const [users, roles] = await Promise.all([fetchUsers(token, { page: 1, limit: 10 }), fetchRoles(token)]);
|
||||
|
||||
return (
|
||||
<DashboardShell currentPath="/dashboard/users">
|
||||
<UsersManagementBoard
|
||||
initialUsers={users.items}
|
||||
initialTotal={users.total}
|
||||
initialPage={users.page}
|
||||
initialPageSize={users.pageSize}
|
||||
initialTotalPages={users.totalPages}
|
||||
availableRoles={roles.map((role) => ({
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
}))}
|
||||
/>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
5
frontend/src/app/dashboard/webhooks/logs/page.tsx
Normal file
5
frontend/src/app/dashboard/webhooks/logs/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function DeprecatedWebhookLogsPage() {
|
||||
redirect('/dashboard/settings/webhook-logs');
|
||||
}
|
||||
54
frontend/src/app/dashboard/webhooks/page.tsx
Normal file
54
frontend/src/app/dashboard/webhooks/page.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import { DashboardShell } from '../../../components/dashboard-shell';
|
||||
import { webhookLogs } from '../../../lib/mock-data';
|
||||
|
||||
export default async function WebhooksPage() {
|
||||
return (
|
||||
<DashboardShell currentPath="/dashboard/webhooks/logs">
|
||||
<section className="page-header">
|
||||
<div>
|
||||
<p className="page-eyebrow">Webhooks</p>
|
||||
<h1 className="page-heading">Webhook Logs</h1>
|
||||
<p className="page-copy">Inbound event visibility, payload sampling, and retry monitoring.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="dashboard-two-column">
|
||||
<article className="surface-card">
|
||||
<div className="data-table">
|
||||
<div className="data-row data-row-head webhook-grid">
|
||||
<span>Provider</span>
|
||||
<span>Event</span>
|
||||
<span>Status</span>
|
||||
<span>Time</span>
|
||||
</div>
|
||||
{webhookLogs.map((log) => (
|
||||
<div key={log.id} className="data-row webhook-grid">
|
||||
<span>{log.provider}</span>
|
||||
<span>{log.event}</span>
|
||||
<span><span className="status-pill neutral">{log.status}</span></span>
|
||||
<span>{log.time}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="surface-card code-card">
|
||||
<div className="card-head">
|
||||
<h2>Payload Preview</h2>
|
||||
<span className="soft-chip">evt_1003</span>
|
||||
</div>
|
||||
<pre>{`{
|
||||
"provider": "qontak",
|
||||
"event_type": "message.failed",
|
||||
"external_message_id": "wamid.xxx",
|
||||
"timestamp": "2026-05-09T09:28:00.000Z",
|
||||
"payload": {
|
||||
"error_code": "THROTTLED",
|
||||
"retry_count": 2
|
||||
}
|
||||
}`}</pre>
|
||||
</article>
|
||||
</section>
|
||||
</DashboardShell>
|
||||
);
|
||||
}
|
||||
23
frontend/src/app/forgot-password/page.tsx
Normal file
23
frontend/src/app/forgot-password/page.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { LanguageSwitcher } from '../../components/language-switcher';
|
||||
import { ForgotPasswordCard } from '../../components/forgot-password-card';
|
||||
import { getDictionary, getLocale } from '../../lib/i18n';
|
||||
|
||||
export default async function ForgotPasswordPage() {
|
||||
const locale = await getLocale();
|
||||
const dict = await getDictionary();
|
||||
|
||||
return (
|
||||
<ForgotPasswordCard
|
||||
languageSwitcher={
|
||||
<LanguageSwitcher
|
||||
currentLocale={locale}
|
||||
label={dict.common.language}
|
||||
englishLabel={dict.common.english}
|
||||
indonesianLabel={dict.common.indonesian}
|
||||
submitLabel={dict.common.save}
|
||||
variant="compact"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
9138
frontend/src/app/globals.css
Normal file
9138
frontend/src/app/globals.css
Normal file
File diff suppressed because it is too large
Load Diff
13
frontend/src/app/invite/[token]/page.tsx
Normal file
13
frontend/src/app/invite/[token]/page.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { SetPasswordCard } from '../../../components/set-password-card';
|
||||
import { fetchInvitation } from '../../../lib/api';
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ token: string }>;
|
||||
};
|
||||
|
||||
export default async function InvitePage({ params }: Props) {
|
||||
const { token } = await params;
|
||||
const invitation = await fetchInvitation(token);
|
||||
|
||||
return <SetPasswordCard token={token} invitation={invitation} />;
|
||||
}
|
||||
15
frontend/src/app/layout.tsx
Normal file
15
frontend/src/app/layout.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import './globals.css';
|
||||
import type { ReactNode } from 'react';
|
||||
import { getLocale } from '../lib/i18n';
|
||||
|
||||
export default async function RootLayout({ children }: { children: ReactNode }) {
|
||||
const locale = await getLocale();
|
||||
|
||||
return (
|
||||
<html lang={locale}>
|
||||
<body>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
32
frontend/src/app/login/2fa/page.tsx
Normal file
32
frontend/src/app/login/2fa/page.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { cookies } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { LanguageSwitcher } from '../../../components/language-switcher';
|
||||
import { TwoFactorLoginCard } from '../../../components/two-factor-login-card';
|
||||
import { twoFactorEmailCookieName } from '../../../lib/auth';
|
||||
import { getDictionary, getLocale } from '../../../lib/i18n';
|
||||
|
||||
export default async function TwoFactorPage() {
|
||||
const locale = await getLocale();
|
||||
const dict = await getDictionary();
|
||||
const email = (await cookies()).get(twoFactorEmailCookieName)?.value;
|
||||
|
||||
if (!email) {
|
||||
redirect('/login');
|
||||
}
|
||||
|
||||
return (
|
||||
<TwoFactorLoginCard
|
||||
email={email}
|
||||
languageSwitcher={
|
||||
<LanguageSwitcher
|
||||
currentLocale={locale}
|
||||
label={dict.common.language}
|
||||
englishLabel={dict.common.english}
|
||||
indonesianLabel={dict.common.indonesian}
|
||||
submitLabel={dict.common.save}
|
||||
variant="compact"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
49
frontend/src/app/login/page.tsx
Normal file
49
frontend/src/app/login/page.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
import { getDictionary, getLocale } from '../../lib/i18n';
|
||||
import { LanguageSwitcher } from '../../components/language-switcher';
|
||||
import { LoginForm } from '../../components/login-form';
|
||||
|
||||
export default async function LoginPage() {
|
||||
const locale = await getLocale();
|
||||
const dict = await getDictionary();
|
||||
|
||||
return (
|
||||
<LoginForm
|
||||
languageSwitcher={
|
||||
<LanguageSwitcher
|
||||
currentLocale={locale}
|
||||
label={dict.common.language}
|
||||
englishLabel={dict.common.english}
|
||||
indonesianLabel={dict.common.indonesian}
|
||||
submitLabel={dict.common.save}
|
||||
variant="compact"
|
||||
/>
|
||||
}
|
||||
appName={dict.common.appName}
|
||||
labels={{
|
||||
title: dict.login.title,
|
||||
description: dict.login.description,
|
||||
email: dict.login.email,
|
||||
password: dict.login.password,
|
||||
submit: dict.login.submit,
|
||||
goToDashboard: dict.login.goToDashboard,
|
||||
emailLabel: dict.login.emailLabel,
|
||||
passwordLabel: dict.login.passwordLabel,
|
||||
forgotPassword: dict.login.forgotPassword,
|
||||
rememberMe: dict.login.rememberMe,
|
||||
accessVia: dict.login.accessVia,
|
||||
google: dict.login.google,
|
||||
sso: dict.login.sso,
|
||||
newToPlatform: dict.login.newToPlatform,
|
||||
applyAccess: dict.login.applyAccess,
|
||||
privacyPolicy: dict.login.privacyPolicy,
|
||||
termsOfService: dict.login.termsOfService,
|
||||
helpCenter: dict.login.helpCenter,
|
||||
securityPreview: dict.login.securityPreview,
|
||||
loginHelp: dict.login.loginHelp,
|
||||
twoFactorPreview: dict.login.twoFactorPreview,
|
||||
showPassword: dict.login.showPassword,
|
||||
hidePassword: dict.login.hidePassword,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
24
frontend/src/app/page.tsx
Normal file
24
frontend/src/app/page.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import Link from 'next/link';
|
||||
import { getDictionary } from '../lib/i18n';
|
||||
|
||||
export default async function HomePage() {
|
||||
const dict = await getDictionary();
|
||||
|
||||
return (
|
||||
<main className="marketing-page">
|
||||
<section className="hero-card">
|
||||
<p className="eyebrow">{dict.common.appName}</p>
|
||||
<h1>{dict.home.title}</h1>
|
||||
<p>{dict.home.description}</p>
|
||||
<div className="hero-actions">
|
||||
<Link className="primary-button" href="/login">
|
||||
{dict.login.submit}
|
||||
</Link>
|
||||
<Link className="secondary-button" href="/dashboard">
|
||||
{dict.common.openDashboard}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
32
frontend/src/app/reset-password/[token]/page.tsx
Normal file
32
frontend/src/app/reset-password/[token]/page.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { LanguageSwitcher } from '../../../components/language-switcher';
|
||||
import { ResetPasswordCard } from '../../../components/reset-password-card';
|
||||
import { fetchPasswordResetRequest } from '../../../lib/api';
|
||||
import { getDictionary, getLocale } from '../../../lib/i18n';
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ token: string }>;
|
||||
};
|
||||
|
||||
export default async function ResetPasswordPage({ params }: Props) {
|
||||
const { token } = await params;
|
||||
const locale = await getLocale();
|
||||
const dict = await getDictionary();
|
||||
const resetRequest = await fetchPasswordResetRequest(token);
|
||||
|
||||
return (
|
||||
<ResetPasswordCard
|
||||
token={token}
|
||||
resetRequest={resetRequest}
|
||||
languageSwitcher={
|
||||
<LanguageSwitcher
|
||||
currentLocale={locale}
|
||||
label={dict.common.language}
|
||||
englishLabel={dict.common.english}
|
||||
indonesianLabel={dict.common.indonesian}
|
||||
submitLabel={dict.common.save}
|
||||
variant="compact"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
489
frontend/src/components/audit-trail-board.tsx
Normal file
489
frontend/src/components/audit-trail-board.tsx
Normal file
@ -0,0 +1,489 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { type AuditTrailEntry, seedAuditTrailEntries } from '../lib/audit-trail';
|
||||
|
||||
function formatDate(value: string) {
|
||||
const date = new Date(value);
|
||||
return {
|
||||
day: date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }),
|
||||
time: date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }),
|
||||
};
|
||||
}
|
||||
|
||||
function downloadCsv(rows: AuditTrailEntry[]) {
|
||||
const header = ['Timestamp', 'Admin User', 'Action Type', 'Module', 'IP Address', 'Severity', 'Details'];
|
||||
const csvRows = rows.map((row) =>
|
||||
[
|
||||
row.timestamp,
|
||||
row.adminUser,
|
||||
row.actionType,
|
||||
row.module,
|
||||
row.ipAddress,
|
||||
row.severity,
|
||||
row.details,
|
||||
]
|
||||
.map((cell) => `"${String(cell).replaceAll('"', '""')}"`)
|
||||
.join(','),
|
||||
);
|
||||
|
||||
const blob = new Blob([[header.join(','), ...csvRows].join('\n')], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = 'audit-trail-export.csv';
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
initialEntries: AuditTrailEntry[];
|
||||
initialTotal?: number;
|
||||
initialPage?: number;
|
||||
initialPageSize?: number;
|
||||
initialTotalPages?: number;
|
||||
};
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
|
||||
|
||||
function buildVisiblePages(page: number, totalPages: number) {
|
||||
if (totalPages <= 5) {
|
||||
return Array.from({ length: totalPages }, (_, index) => index + 1);
|
||||
}
|
||||
|
||||
const start = Math.max(1, Math.min(page - 2, totalPages - 4));
|
||||
return Array.from({ length: 5 }, (_, index) => start + index);
|
||||
}
|
||||
|
||||
export function AuditTrailBoard({
|
||||
initialEntries,
|
||||
initialTotal,
|
||||
initialPage,
|
||||
initialPageSize,
|
||||
initialTotalPages,
|
||||
}: Props) {
|
||||
const [allEntries] = useState<AuditTrailEntry[]>(initialEntries.length > 0 ? initialEntries : seedAuditTrailEntries);
|
||||
const [entries, setEntries] = useState<AuditTrailEntry[]>(initialEntries.length > 0 ? initialEntries : seedAuditTrailEntries);
|
||||
const [total, setTotal] = useState(initialTotal ?? initialEntries.length);
|
||||
const [page, setPage] = useState(initialPage ?? 1);
|
||||
const [pageSize, setPageSize] = useState(initialPageSize ?? 50);
|
||||
const [totalPages, setTotalPages] = useState(initialTotalPages ?? 1);
|
||||
const [range, setRange] = useState('7d');
|
||||
const [adminUser, setAdminUser] = useState('all');
|
||||
const [actionType, setActionType] = useState('all');
|
||||
const [moduleName, setModuleName] = useState('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedEntryId, setSelectedEntryId] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedEntryId(initialEntries[0]?.id ?? seedAuditTrailEntries[0]?.id ?? null);
|
||||
}, [initialEntries]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
const timeout = window.setTimeout(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', String(page));
|
||||
params.set('limit', String(pageSize));
|
||||
if (range !== 'all') params.set('range', range);
|
||||
if (adminUser !== 'all') params.set('user', adminUser);
|
||||
if (actionType !== 'all') params.set('actionType', actionType);
|
||||
if (moduleName !== 'all') params.set('module', moduleName);
|
||||
if (search.trim()) params.set('search', search.trim());
|
||||
|
||||
const response = await fetch(`/api/audit-trail?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
items: Array<{
|
||||
id: string;
|
||||
actorName: string;
|
||||
actionType: string;
|
||||
module: string;
|
||||
ipAddress: string | null;
|
||||
severity: 'default' | 'alert';
|
||||
details: string;
|
||||
createdAt: string;
|
||||
}>;
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
};
|
||||
|
||||
const normalized = payload.items.map((entry) => ({
|
||||
id: entry.id,
|
||||
timestamp: entry.createdAt,
|
||||
adminUser: entry.actorName,
|
||||
actionType: entry.actionType,
|
||||
module: entry.module,
|
||||
ipAddress: entry.ipAddress || '-',
|
||||
severity: entry.severity,
|
||||
details: entry.details,
|
||||
}));
|
||||
|
||||
setEntries(normalized);
|
||||
setTotal(payload.total);
|
||||
setPage(payload.page);
|
||||
setPageSize(payload.pageSize);
|
||||
setTotalPages(payload.totalPages);
|
||||
} catch (error) {
|
||||
if ((error as Error).name !== 'AbortError') {
|
||||
console.error(error);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, 250);
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
window.clearTimeout(timeout);
|
||||
};
|
||||
}, [actionType, adminUser, moduleName, page, pageSize, range, search]);
|
||||
|
||||
const selectedEntry =
|
||||
entries.find((entry) => entry.id === selectedEntryId) ?? entries[0] ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedEntryId && entries[0]?.id) {
|
||||
setSelectedEntryId(entries[0].id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedEntryId && !entries.some((entry) => entry.id === selectedEntryId)) {
|
||||
setSelectedEntryId(entries[0]?.id ?? null);
|
||||
}
|
||||
}, [entries, selectedEntryId]);
|
||||
|
||||
const users = Array.from(new Set(allEntries.map((entry) => entry.adminUser)));
|
||||
const actions = Array.from(new Set(allEntries.map((entry) => entry.actionType)));
|
||||
const modules = Array.from(new Set(allEntries.map((entry) => entry.module)));
|
||||
|
||||
const alertsCount = allEntries.filter((entry) => entry.severity === 'alert').length;
|
||||
const mostActiveAdmin =
|
||||
users
|
||||
.map((user) => ({
|
||||
user,
|
||||
count: allEntries.filter((entry) => entry.adminUser === user).length,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count)[0] ?? { user: 'Admin User', count: 0 };
|
||||
const visiblePages = useMemo(() => buildVisiblePages(page, totalPages), [page, totalPages]);
|
||||
const pageStart = entries.length === 0 ? 0 : (page - 1) * pageSize + 1;
|
||||
const pageEnd = entries.length === 0 ? 0 : (page - 1) * pageSize + entries.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="page-header">
|
||||
<div>
|
||||
<p className="page-eyebrow">Settings</p>
|
||||
<h1 className="page-heading">Audit Trail</h1>
|
||||
<p className="page-copy">Monitor administrative actions, role changes, and system modifications from one place.</p>
|
||||
</div>
|
||||
<button type="button" className="audit-export-button" onClick={() => downloadCsv(entries)}>
|
||||
<span className="material-symbols-outlined">download</span>
|
||||
Export to Excel
|
||||
</button>
|
||||
<a
|
||||
className="audit-export-button secondary"
|
||||
href={`/api/audit-trail/export?${new URLSearchParams({
|
||||
...(range !== 'all' ? { range } : {}),
|
||||
...(adminUser !== 'all' ? { user: adminUser } : {}),
|
||||
...(actionType !== 'all' ? { actionType } : {}),
|
||||
...(moduleName !== 'all' ? { module: moduleName } : {}),
|
||||
...(search.trim() ? { search: search.trim() } : {}),
|
||||
}).toString()}`}
|
||||
>
|
||||
<span className="material-symbols-outlined">download</span>
|
||||
Export Server CSV
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<section className="audit-kpi-grid">
|
||||
<article className="audit-kpi-card">
|
||||
<div className="audit-kpi-head">
|
||||
<span>Total Actions (Last 24H)</span>
|
||||
<span className="material-symbols-outlined">history</span>
|
||||
</div>
|
||||
<div className="audit-kpi-metric">
|
||||
<strong>{entries.length.toLocaleString('en-US')}</strong>
|
||||
<span className="audit-kpi-trend is-positive">12.5%</span>
|
||||
</div>
|
||||
<div className="audit-kpi-bar">
|
||||
<div style={{ width: `${Math.min(100, 35 + entries.length * 8)}%` }} />
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="audit-kpi-card">
|
||||
<div className="audit-kpi-head">
|
||||
<span>Security Alerts</span>
|
||||
<span className="material-symbols-outlined audit-danger">security</span>
|
||||
</div>
|
||||
<div className="audit-kpi-metric">
|
||||
<strong>{alertsCount.toString().padStart(2, '0')}</strong>
|
||||
<span className="audit-kpi-trend is-danger">Critical</span>
|
||||
</div>
|
||||
<p>Failed login bursts and suspicious access activity are surfaced here.</p>
|
||||
</article>
|
||||
|
||||
<article className="audit-kpi-card">
|
||||
<div className="audit-kpi-head">
|
||||
<span>Most Active Admin</span>
|
||||
<span className="material-symbols-outlined audit-secondary">person</span>
|
||||
</div>
|
||||
<div className="audit-kpi-user">
|
||||
<div className="audit-avatar">{mostActiveAdmin.user.slice(0, 1)}</div>
|
||||
<div>
|
||||
<strong>{mostActiveAdmin.user}</strong>
|
||||
<span>{mostActiveAdmin.count} actions performed</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="audit-filter-bar">
|
||||
<div className="audit-filter-title">
|
||||
<span className="material-symbols-outlined">filter_list</span>
|
||||
<span>Filters</span>
|
||||
</div>
|
||||
|
||||
<label className="audit-filter-field">
|
||||
<span>Date Range</span>
|
||||
<select value={range} onChange={(event) => {
|
||||
setRange(event.target.value);
|
||||
setPage(1);
|
||||
}}>
|
||||
<option value="24h">Last 24 Hours</option>
|
||||
<option value="7d">Last 7 Days</option>
|
||||
<option value="30d">Last 30 Days</option>
|
||||
<option value="all">All Time</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="audit-filter-field">
|
||||
<span>Admin User</span>
|
||||
<select value={adminUser} onChange={(event) => {
|
||||
setAdminUser(event.target.value);
|
||||
setPage(1);
|
||||
}}>
|
||||
<option value="all">All Admins</option>
|
||||
{users.map((user) => (
|
||||
<option key={user} value={user}>
|
||||
{user}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="audit-filter-field">
|
||||
<span>Action Type</span>
|
||||
<select value={actionType} onChange={(event) => {
|
||||
setActionType(event.target.value);
|
||||
setPage(1);
|
||||
}}>
|
||||
<option value="all">All Actions</option>
|
||||
{actions.map((action) => (
|
||||
<option key={action} value={action}>
|
||||
{action}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="audit-filter-field">
|
||||
<span>Module</span>
|
||||
<select value={moduleName} onChange={(event) => {
|
||||
setModuleName(event.target.value);
|
||||
setPage(1);
|
||||
}}>
|
||||
<option value="all">All Modules</option>
|
||||
{modules.map((module) => (
|
||||
<option key={module} value={module}>
|
||||
{module}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="audit-filter-search">
|
||||
<span>Search</span>
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(event) => {
|
||||
setSearch(event.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
placeholder="Search logs, admin names..."
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="audit-reset-button"
|
||||
onClick={() => {
|
||||
setRange('7d');
|
||||
setAdminUser('all');
|
||||
setActionType('all');
|
||||
setModuleName('all');
|
||||
setSearch('');
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
Reset All
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="audit-layout">
|
||||
<article className="audit-table-card">
|
||||
{isLoading ? <div className="audit-loading-bar" /> : null}
|
||||
<table className="audit-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>Admin User</th>
|
||||
<th>Action Type</th>
|
||||
<th>Module</th>
|
||||
<th>IP Address</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => {
|
||||
const stamp = formatDate(entry.timestamp);
|
||||
const isSelected = entry.id === selectedEntry?.id;
|
||||
return (
|
||||
<tr
|
||||
key={entry.id}
|
||||
className={`${entry.severity === 'alert' ? 'is-alert' : ''} ${isSelected ? 'is-selected' : ''}`}
|
||||
>
|
||||
<td>
|
||||
<div className="audit-stamp">
|
||||
<strong>{stamp.day}</strong>
|
||||
<span>{stamp.time}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="audit-user-cell">
|
||||
<div className={`audit-avatar small ${entry.severity === 'alert' ? 'is-alert' : ''}`}>
|
||||
{entry.adminUser.slice(0, 1)}
|
||||
</div>
|
||||
<span>{entry.adminUser}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`audit-action-pill tone-${entry.severity === 'alert' ? 'alert' : 'default'}`}>
|
||||
{entry.actionType}
|
||||
</span>
|
||||
</td>
|
||||
<td>{entry.module}</td>
|
||||
<td className="audit-mono">{entry.ipAddress}</td>
|
||||
<td className="audit-actions-cell">
|
||||
<button type="button" onClick={() => setSelectedEntryId(entry.id)}>
|
||||
View Details
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="audit-pagination">
|
||||
<div className="audit-pagination-meta">
|
||||
<span>
|
||||
Showing {pageStart} to {pageEnd} of {total} results
|
||||
</span>
|
||||
<label className="audit-page-size">
|
||||
<span>Show</span>
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(event) => {
|
||||
setPageSize(Number(event.target.value));
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{PAGE_SIZE_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<span>rows</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="audit-pagination-buttons">
|
||||
<button type="button" disabled={page <= 1} onClick={() => setPage((current) => Math.max(1, current - 1))}>
|
||||
<span className="material-symbols-outlined">chevron_left</span>
|
||||
</button>
|
||||
{visiblePages.map((pageNumber) => (
|
||||
<button
|
||||
key={pageNumber}
|
||||
type="button"
|
||||
className={pageNumber === page ? 'is-active' : ''}
|
||||
onClick={() => setPage(pageNumber)}
|
||||
>
|
||||
{pageNumber}
|
||||
</button>
|
||||
))}
|
||||
{visiblePages[visiblePages.length - 1] < totalPages ? <span>...</span> : null}
|
||||
{visiblePages[visiblePages.length - 1] < totalPages ? (
|
||||
<button type="button" onClick={() => setPage(totalPages)}>
|
||||
{totalPages}
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" disabled={page >= totalPages} onClick={() => setPage((current) => Math.min(totalPages, current + 1))}>
|
||||
<span className="material-symbols-outlined">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<aside className="audit-detail-card">
|
||||
{selectedEntry ? (
|
||||
<>
|
||||
<div className="card-head">
|
||||
<div>
|
||||
<p className="card-kicker">Selected Event</p>
|
||||
<h3>{selectedEntry.actionType}</h3>
|
||||
</div>
|
||||
<span className={`audit-action-pill tone-${selectedEntry.severity === 'alert' ? 'alert' : 'default'}`}>
|
||||
{selectedEntry.module}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-stack">
|
||||
<div>
|
||||
<strong>{selectedEntry.adminUser}</strong>
|
||||
<span>Actor</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{formatDate(selectedEntry.timestamp).day} {formatDate(selectedEntry.timestamp).time}</strong>
|
||||
<span>Timestamp</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{selectedEntry.ipAddress}</strong>
|
||||
<span>IP Address</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{selectedEntry.details}</strong>
|
||||
<span>Details</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p>No audit entries found.</p>
|
||||
)}
|
||||
</aside>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
324
frontend/src/components/campaign-detail-actions.tsx
Normal file
324
frontend/src/components/campaign-detail-actions.tsx
Normal file
@ -0,0 +1,324 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
|
||||
type Props = {
|
||||
campaign: {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
totalRecipients: number;
|
||||
templateName: string;
|
||||
language: string;
|
||||
messageTitle: string;
|
||||
messageBody: string;
|
||||
primaryButton: string;
|
||||
secondaryButton: string;
|
||||
bannerImageUrl: string;
|
||||
};
|
||||
};
|
||||
|
||||
function toDateTimeLocal(value: string | null | undefined) {
|
||||
if (!value) return '';
|
||||
const date = new Date(value);
|
||||
const offset = date.getTimezoneOffset();
|
||||
const local = new Date(date.getTime() - offset * 60000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
export function CampaignDetailActions({ campaign }: Props) {
|
||||
const router = useRouter();
|
||||
const [isDuplicating, setIsDuplicating] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isSendingNow, setIsSendingNow] = useState(false);
|
||||
const [isScheduling, setIsScheduling] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [scheduledAt, setScheduledAt] = useState('');
|
||||
const [form, setForm] = useState({
|
||||
name: campaign.name,
|
||||
status: campaign.status,
|
||||
totalRecipients: String(campaign.totalRecipients),
|
||||
templateName: campaign.templateName,
|
||||
language: campaign.language,
|
||||
messageTitle: campaign.messageTitle,
|
||||
messageBody: campaign.messageBody,
|
||||
primaryButton: campaign.primaryButton,
|
||||
secondaryButton: campaign.secondaryButton,
|
||||
bannerImageUrl: campaign.bannerImageUrl,
|
||||
});
|
||||
|
||||
async function readPayload(response: Response) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
throw new Error(typeof payload?.message === 'string' ? payload.message : 'Request failed');
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="campaign-detail-header-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="campaign-detail-secondary-button"
|
||||
onClick={() => {
|
||||
setMessage(null);
|
||||
setIsEditing((value) => !value);
|
||||
}}
|
||||
>
|
||||
<span className="material-symbols-outlined">edit</span>
|
||||
{isEditing ? 'Close Edit' : 'Edit'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="campaign-detail-secondary-button"
|
||||
disabled={isDuplicating}
|
||||
onClick={async () => {
|
||||
try {
|
||||
setMessage(null);
|
||||
setIsDuplicating(true);
|
||||
const response = await fetch(`/api/campaigns/${campaign.id}/duplicate`, {
|
||||
method: 'POST',
|
||||
});
|
||||
const payload = await readPayload(response);
|
||||
router.push(`/dashboard/campaigns/${payload.id}`);
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : 'Failed to duplicate campaign');
|
||||
} finally {
|
||||
setIsDuplicating(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="material-symbols-outlined">content_copy</span>
|
||||
{isDuplicating ? 'Duplicating...' : 'Duplicate'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="campaign-detail-secondary-button"
|
||||
disabled={isSendingNow}
|
||||
onClick={async () => {
|
||||
try {
|
||||
setMessage(null);
|
||||
setIsSendingNow(true);
|
||||
const response = await fetch(`/api/campaigns/${campaign.id}/send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mode: 'now' }),
|
||||
});
|
||||
await readPayload(response);
|
||||
setMessage('Campaign send queued.');
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : 'Failed to queue campaign');
|
||||
} finally {
|
||||
setIsSendingNow(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="material-symbols-outlined">send</span>
|
||||
{isSendingNow ? 'Queuing...' : 'Send Now'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="campaign-detail-secondary-button"
|
||||
disabled={isScheduling || !scheduledAt}
|
||||
onClick={async () => {
|
||||
try {
|
||||
setMessage(null);
|
||||
setIsScheduling(true);
|
||||
const response = await fetch(`/api/campaigns/${campaign.id}/send`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mode: 'scheduled', scheduledAt: new Date(scheduledAt).toISOString() }),
|
||||
});
|
||||
await readPayload(response);
|
||||
setMessage('Campaign scheduled successfully.');
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : 'Failed to schedule campaign');
|
||||
} finally {
|
||||
setIsScheduling(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="material-symbols-outlined">schedule</span>
|
||||
{isScheduling ? 'Scheduling...' : 'Schedule'}
|
||||
</button>
|
||||
<a className="campaign-detail-secondary-button" href={`/api/campaigns/${campaign.id}/export?format=csv`}>
|
||||
<span className="material-symbols-outlined">download</span>
|
||||
Export CSV
|
||||
</a>
|
||||
<a className="campaign-detail-primary-button" href={`/api/campaigns/${campaign.id}/export?format=xlsx`}>
|
||||
<span className="material-symbols-outlined">download</span>
|
||||
Export XLSX
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="campaign-detail-inline-tools">
|
||||
<label className="campaign-detail-schedule-field">
|
||||
<span>Schedule at</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={scheduledAt}
|
||||
onChange={(event) => setScheduledAt(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="campaign-detail-danger-button"
|
||||
disabled={isDeleting}
|
||||
onClick={async () => {
|
||||
if (!window.confirm(`Delete campaign "${campaign.name}"? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setMessage(null);
|
||||
setIsDeleting(true);
|
||||
const response = await fetch(`/api/campaigns/${campaign.id}`, { method: 'DELETE' });
|
||||
if (!response.ok && response.status !== 204) {
|
||||
await readPayload(response);
|
||||
}
|
||||
router.push('/dashboard/campaigns');
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : 'Failed to delete campaign');
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="material-symbols-outlined">delete</span>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{message ? <p className="campaign-detail-inline-message">{message}</p> : null}
|
||||
|
||||
{isEditing ? (
|
||||
<form
|
||||
className="campaign-detail-edit-panel"
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
try {
|
||||
setMessage(null);
|
||||
setIsSaving(true);
|
||||
const response = await fetch(`/api/campaigns/${campaign.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...form,
|
||||
totalRecipients: Number(form.totalRecipients || '0'),
|
||||
}),
|
||||
});
|
||||
await readPayload(response);
|
||||
setMessage('Campaign updated successfully.');
|
||||
setIsEditing(false);
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
setMessage(error instanceof Error ? error.message : 'Failed to update campaign');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="campaign-detail-edit-grid">
|
||||
<label className="campaign-detail-field">
|
||||
<span>Name</span>
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="campaign-detail-field">
|
||||
<span>Status</span>
|
||||
<select
|
||||
value={form.status}
|
||||
onChange={(event) => setForm((current) => ({ ...current, status: event.target.value }))}
|
||||
>
|
||||
<option value="Draft">Draft</option>
|
||||
<option value="Scheduled">Scheduled</option>
|
||||
<option value="Sent">Sent</option>
|
||||
<option value="Failed">Failed</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="campaign-detail-field">
|
||||
<span>Total Recipients</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={form.totalRecipients}
|
||||
onChange={(event) => setForm((current) => ({ ...current, totalRecipients: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="campaign-detail-field">
|
||||
<span>Template Name</span>
|
||||
<input
|
||||
value={form.templateName}
|
||||
onChange={(event) => setForm((current) => ({ ...current, templateName: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="campaign-detail-field">
|
||||
<span>Language</span>
|
||||
<input
|
||||
value={form.language}
|
||||
onChange={(event) => setForm((current) => ({ ...current, language: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="campaign-detail-field">
|
||||
<span>Banner URL</span>
|
||||
<input
|
||||
value={form.bannerImageUrl}
|
||||
onChange={(event) => setForm((current) => ({ ...current, bannerImageUrl: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="campaign-detail-field">
|
||||
<span>Primary Button</span>
|
||||
<input
|
||||
value={form.primaryButton}
|
||||
onChange={(event) => setForm((current) => ({ ...current, primaryButton: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="campaign-detail-field">
|
||||
<span>Secondary Button</span>
|
||||
<input
|
||||
value={form.secondaryButton}
|
||||
onChange={(event) => setForm((current) => ({ ...current, secondaryButton: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="campaign-detail-field is-full">
|
||||
<span>Message Title</span>
|
||||
<input
|
||||
value={form.messageTitle}
|
||||
onChange={(event) => setForm((current) => ({ ...current, messageTitle: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="campaign-detail-field is-full">
|
||||
<span>Message Body</span>
|
||||
<textarea
|
||||
rows={4}
|
||||
value={form.messageBody}
|
||||
onChange={(event) => setForm((current) => ({ ...current, messageBody: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="campaign-detail-edit-actions">
|
||||
<button type="button" className="campaign-detail-secondary-button" onClick={() => setIsEditing(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="campaign-detail-primary-button" disabled={isSaving}>
|
||||
<span className="material-symbols-outlined">save</span>
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
557
frontend/src/components/campaigns-management-board.tsx
Normal file
557
frontend/src/components/campaigns-management-board.tsx
Normal file
@ -0,0 +1,557 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
type Campaign = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
audience: string;
|
||||
audienceGroup: string;
|
||||
sent: string;
|
||||
opened: string;
|
||||
status: 'Sent' | 'Scheduled' | 'Draft' | 'Failed';
|
||||
deliveryRate: number | null;
|
||||
dateLabel: string;
|
||||
timeLabel: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
campaigns: Campaign[];
|
||||
metrics?: {
|
||||
totalMessages: number;
|
||||
averageDeliveryRate: number;
|
||||
scheduledCount: number;
|
||||
failedDeliveries: number;
|
||||
};
|
||||
};
|
||||
|
||||
const STATUS_FILTERS = ['All Campaigns', 'Sent', 'Scheduled', 'Draft', 'Failed'] as const;
|
||||
const SORT_OPTIONS = ['newest', 'oldest', 'delivery'] as const;
|
||||
|
||||
type StatusFilter = (typeof STATUS_FILTERS)[number];
|
||||
type SortOption = (typeof SORT_OPTIONS)[number];
|
||||
|
||||
function parseCampaignDate(campaign: Campaign) {
|
||||
if (campaign.status === 'Scheduled') {
|
||||
return Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
if (campaign.dateLabel === 'Not set') {
|
||||
return Number.NEGATIVE_INFINITY;
|
||||
}
|
||||
|
||||
const parsed = Date.parse(`${campaign.dateLabel} ${campaign.timeLabel}`.trim());
|
||||
return Number.isNaN(parsed) ? Number.NEGATIVE_INFINITY : parsed;
|
||||
}
|
||||
|
||||
function formatCompactNumber(value: number) {
|
||||
return new Intl.NumberFormat('en', {
|
||||
notation: 'compact',
|
||||
maximumFractionDigits: 1,
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function formatPercent(value: number) {
|
||||
return `${value.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function getStatusClassName(status: Campaign['status']) {
|
||||
switch (status) {
|
||||
case 'Sent':
|
||||
return 'campaign-status-pill is-sent';
|
||||
case 'Scheduled':
|
||||
return 'campaign-status-pill is-scheduled';
|
||||
case 'Draft':
|
||||
return 'campaign-status-pill is-draft';
|
||||
case 'Failed':
|
||||
return 'campaign-status-pill is-failed';
|
||||
}
|
||||
}
|
||||
|
||||
export function CampaignsManagementBoard({ campaigns, metrics }: Props) {
|
||||
const router = useRouter();
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('All Campaigns');
|
||||
const [sortBy, setSortBy] = useState<SortOption>('newest');
|
||||
const [search, setSearch] = useState('');
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState('');
|
||||
const [createForm, setCreateForm] = useState({
|
||||
name: '',
|
||||
audienceLabel: '',
|
||||
audienceGroup: '',
|
||||
totalRecipients: '1000',
|
||||
status: 'Draft',
|
||||
scheduledAt: '',
|
||||
templateName: '',
|
||||
language: 'English (US)',
|
||||
messageTitle: '',
|
||||
messageBody: '',
|
||||
primaryButton: '',
|
||||
secondaryButton: '',
|
||||
bannerImageUrl: '',
|
||||
});
|
||||
|
||||
const filteredCampaigns = useMemo(() => {
|
||||
const loweredSearch = search.trim().toLowerCase();
|
||||
|
||||
const nextRows = campaigns.filter((campaign) => {
|
||||
const matchesStatus = statusFilter === 'All Campaigns' || campaign.status === statusFilter;
|
||||
const matchesSearch =
|
||||
loweredSearch.length === 0 ||
|
||||
campaign.name.toLowerCase().includes(loweredSearch) ||
|
||||
campaign.code.toLowerCase().includes(loweredSearch) ||
|
||||
campaign.audience.toLowerCase().includes(loweredSearch);
|
||||
|
||||
return matchesStatus && matchesSearch;
|
||||
});
|
||||
|
||||
nextRows.sort((left, right) => {
|
||||
if (sortBy === 'delivery') {
|
||||
return (right.deliveryRate ?? -1) - (left.deliveryRate ?? -1);
|
||||
}
|
||||
|
||||
const leftDate = parseCampaignDate(left);
|
||||
const rightDate = parseCampaignDate(right);
|
||||
|
||||
return sortBy === 'newest' ? rightDate - leftDate : leftDate - rightDate;
|
||||
});
|
||||
|
||||
return nextRows;
|
||||
}, [campaigns, search, sortBy, statusFilter]);
|
||||
|
||||
const computedMetrics = useMemo(() => {
|
||||
const totalSent = campaigns.reduce((sum, campaign) => sum + Number(campaign.sent.replace(/,/g, '')), 0);
|
||||
const deliveryRates = campaigns
|
||||
.map((campaign) => campaign.deliveryRate)
|
||||
.filter((rate): rate is number => rate !== null && rate > 0);
|
||||
const averageDeliveryRate = deliveryRates.length
|
||||
? deliveryRates.reduce((sum, rate) => sum + rate, 0) / deliveryRates.length
|
||||
: 0;
|
||||
const scheduledCount = campaigns.filter((campaign) => campaign.status === 'Scheduled').length;
|
||||
const failedDeliveries = campaigns.reduce((sum, campaign) => {
|
||||
if (campaign.deliveryRate === null) {
|
||||
return sum;
|
||||
}
|
||||
|
||||
const sent = Number(campaign.sent.replace(/,/g, ''));
|
||||
const failed = Math.round(sent * (1 - campaign.deliveryRate / 100));
|
||||
return sum + Math.max(failed, 0);
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
totalSent,
|
||||
averageDeliveryRate,
|
||||
scheduledCount,
|
||||
failedDeliveries,
|
||||
};
|
||||
}, [campaigns]);
|
||||
|
||||
const resolvedMetrics = {
|
||||
totalMessages: metrics?.totalMessages ?? computedMetrics.totalSent,
|
||||
averageDeliveryRate: metrics?.averageDeliveryRate ?? computedMetrics.averageDeliveryRate,
|
||||
scheduledCount: metrics?.scheduledCount ?? computedMetrics.scheduledCount,
|
||||
failedDeliveries: metrics?.failedDeliveries ?? computedMetrics.failedDeliveries,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="campaigns-page">
|
||||
<section className="campaigns-header">
|
||||
<div>
|
||||
<h1 className="campaigns-title">Campaign Management</h1>
|
||||
<p className="campaigns-copy">Design, schedule, and track your WhatsApp broadcast performance.</p>
|
||||
</div>
|
||||
<button type="button" className="campaigns-primary-button" onClick={() => setIsCreateOpen(true)}>
|
||||
<span className="material-symbols-outlined">campaign</span>
|
||||
New Campaign
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{isCreateOpen ? (
|
||||
<section className="campaigns-create-modal-backdrop" onClick={() => !isCreating && setIsCreateOpen(false)}>
|
||||
<div className="campaigns-create-modal" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="campaigns-create-modal-head">
|
||||
<div>
|
||||
<p>Create Campaign</p>
|
||||
<h2>Launch a new WhatsApp broadcast</h2>
|
||||
</div>
|
||||
<button type="button" onClick={() => !isCreating && setIsCreateOpen(false)}>
|
||||
<span className="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
<form
|
||||
className="campaigns-create-form"
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
setIsCreating(true);
|
||||
setCreateError('');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/campaigns', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
...createForm,
|
||||
totalRecipients: Number(createForm.totalRecipients),
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(typeof payload?.message === 'string' ? payload.message : 'Failed to create campaign');
|
||||
}
|
||||
|
||||
setIsCreateOpen(false);
|
||||
router.push(`/dashboard/campaigns/${payload.id}`);
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
setCreateError(error instanceof Error ? error.message : 'Failed to create campaign');
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
<span>Campaign Name</span>
|
||||
<input
|
||||
value={createForm.name}
|
||||
onChange={(event) => setCreateForm((current) => ({ ...current, name: event.target.value }))}
|
||||
placeholder="Summer Launch Blast"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Audience Label</span>
|
||||
<input
|
||||
value={createForm.audienceLabel}
|
||||
onChange={(event) => setCreateForm((current) => ({ ...current, audienceLabel: event.target.value }))}
|
||||
placeholder="VIP Customer List"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Audience Group</span>
|
||||
<input
|
||||
value={createForm.audienceGroup}
|
||||
onChange={(event) => setCreateForm((current) => ({ ...current, audienceGroup: event.target.value }))}
|
||||
placeholder="High-value segment"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Total Recipients</span>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={createForm.totalRecipients}
|
||||
onChange={(event) => setCreateForm((current) => ({ ...current, totalRecipients: event.target.value }))}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Status</span>
|
||||
<select
|
||||
value={createForm.status}
|
||||
onChange={(event) => setCreateForm((current) => ({ ...current, status: event.target.value }))}
|
||||
>
|
||||
<option value="Draft">Draft</option>
|
||||
<option value="Scheduled">Scheduled</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Scheduled At</span>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={createForm.scheduledAt}
|
||||
onChange={(event) => setCreateForm((current) => ({ ...current, scheduledAt: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Template Name</span>
|
||||
<input
|
||||
value={createForm.templateName}
|
||||
onChange={(event) => setCreateForm((current) => ({ ...current, templateName: event.target.value }))}
|
||||
placeholder="summer_promo_v3"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Language</span>
|
||||
<input
|
||||
value={createForm.language}
|
||||
onChange={(event) => setCreateForm((current) => ({ ...current, language: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="is-wide">
|
||||
<span>Message Title</span>
|
||||
<input
|
||||
value={createForm.messageTitle}
|
||||
onChange={(event) => setCreateForm((current) => ({ ...current, messageTitle: event.target.value }))}
|
||||
placeholder="Hi {{name}}, your private offer is ready"
|
||||
/>
|
||||
</label>
|
||||
<label className="is-wide">
|
||||
<span>Message Body</span>
|
||||
<textarea
|
||||
value={createForm.messageBody}
|
||||
onChange={(event) => setCreateForm((current) => ({ ...current, messageBody: event.target.value }))}
|
||||
placeholder="Write the campaign body that will appear in the WhatsApp template preview."
|
||||
rows={4}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Primary Button</span>
|
||||
<input
|
||||
value={createForm.primaryButton}
|
||||
onChange={(event) => setCreateForm((current) => ({ ...current, primaryButton: event.target.value }))}
|
||||
placeholder="Shop Now"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Secondary Button</span>
|
||||
<input
|
||||
value={createForm.secondaryButton}
|
||||
onChange={(event) => setCreateForm((current) => ({ ...current, secondaryButton: event.target.value }))}
|
||||
placeholder="View Catalog"
|
||||
/>
|
||||
</label>
|
||||
<label className="is-wide">
|
||||
<span>Banner Image URL</span>
|
||||
<input
|
||||
value={createForm.bannerImageUrl}
|
||||
onChange={(event) => setCreateForm((current) => ({ ...current, bannerImageUrl: event.target.value }))}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</label>
|
||||
|
||||
{createError ? <p className="campaigns-create-error">{createError}</p> : null}
|
||||
|
||||
<div className="campaigns-create-actions">
|
||||
<button type="button" className="campaigns-create-cancel" onClick={() => setIsCreateOpen(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="campaigns-primary-button" disabled={isCreating}>
|
||||
<span className="material-symbols-outlined">campaign</span>
|
||||
{isCreating ? 'Creating...' : 'Create Campaign'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="campaigns-stats-grid">
|
||||
<article className="campaigns-stat-card">
|
||||
<div className="campaigns-stat-head">
|
||||
<span className="material-symbols-outlined">send</span>
|
||||
<span className="campaigns-stat-delta is-positive">
|
||||
<span className="material-symbols-outlined">trending_up</span>
|
||||
12%
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p>Total Messages</p>
|
||||
<strong>{formatCompactNumber(resolvedMetrics.totalMessages)}</strong>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="campaigns-stat-card">
|
||||
<div className="campaigns-stat-head">
|
||||
<span className="material-symbols-outlined is-secondary">done_all</span>
|
||||
<span className="campaigns-stat-delta is-positive">
|
||||
<span className="material-symbols-outlined">trending_up</span>
|
||||
8.4%
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p>Delivery Rate</p>
|
||||
<strong className="is-secondary">{formatPercent(resolvedMetrics.averageDeliveryRate)}</strong>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="campaigns-stat-card">
|
||||
<div className="campaigns-stat-head">
|
||||
<span className="material-symbols-outlined is-warning">schedule</span>
|
||||
</div>
|
||||
<div>
|
||||
<p>Scheduled</p>
|
||||
<strong>{resolvedMetrics.scheduledCount}</strong>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="campaigns-stat-card">
|
||||
<div className="campaigns-stat-head">
|
||||
<span className="material-symbols-outlined is-error">error_outline</span>
|
||||
</div>
|
||||
<div>
|
||||
<p>Failed Delivery</p>
|
||||
<strong>{resolvedMetrics.failedDeliveries}</strong>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="campaigns-table-card">
|
||||
<div className="campaigns-table-toolbar">
|
||||
<div className="campaigns-filter-tabs">
|
||||
{STATUS_FILTERS.map((filter) => (
|
||||
<button
|
||||
key={filter}
|
||||
type="button"
|
||||
className={filter === statusFilter ? 'campaigns-filter-pill is-active' : 'campaigns-filter-pill'}
|
||||
onClick={() => setStatusFilter(filter)}
|
||||
>
|
||||
{filter}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="campaigns-table-controls">
|
||||
<label className="campaigns-search-field">
|
||||
<span className="material-symbols-outlined">search</span>
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Search campaigns..."
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="campaigns-sort-field">
|
||||
<span className="material-symbols-outlined">filter_list</span>
|
||||
<select value={sortBy} onChange={(event) => setSortBy(event.target.value as SortOption)}>
|
||||
<option value="newest">Sort by: Newest</option>
|
||||
<option value="oldest">Sort by: Oldest</option>
|
||||
<option value="delivery">Sort by: Best Delivery</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="campaigns-table-wrap">
|
||||
<table className="campaigns-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Campaign Name</th>
|
||||
<th>Audience</th>
|
||||
<th>Date</th>
|
||||
<th>Status</th>
|
||||
<th>Delivery Rate</th>
|
||||
<th className="is-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredCampaigns.map((campaign) => (
|
||||
<tr key={campaign.id}>
|
||||
<td>
|
||||
<Link href={`/dashboard/campaigns/${campaign.id}`} className="campaigns-name-link">
|
||||
<span>{campaign.name}</span>
|
||||
<small>ID: {campaign.code}</small>
|
||||
</Link>
|
||||
</td>
|
||||
<td>
|
||||
<div className="campaigns-audience-cell">
|
||||
<span className="material-symbols-outlined">group</span>
|
||||
<div>
|
||||
<span>{campaign.audience}</span>
|
||||
<small>{campaign.audienceGroup}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className={campaign.status === 'Scheduled' ? 'campaigns-date-cell is-scheduled' : 'campaigns-date-cell'}>
|
||||
<span>{campaign.dateLabel}</span>
|
||||
{campaign.timeLabel ? <small>{campaign.timeLabel}</small> : null}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className={getStatusClassName(campaign.status)}>
|
||||
{campaign.status === 'Failed' ? (
|
||||
<span className="material-symbols-outlined">error</span>
|
||||
) : (
|
||||
<span className="campaigns-status-dot" />
|
||||
)}
|
||||
{campaign.status}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{campaign.deliveryRate === null ? (
|
||||
<span className="campaigns-pending-text">Pending send...</span>
|
||||
) : (
|
||||
<div className="campaigns-delivery-cell">
|
||||
<div className="campaigns-progress-track">
|
||||
<span
|
||||
className={campaign.status === 'Failed' ? 'campaigns-progress-bar is-failed' : 'campaigns-progress-bar'}
|
||||
style={{ width: `${Math.max(0, Math.min(campaign.deliveryRate, 100))}%` }}
|
||||
/>
|
||||
</div>
|
||||
<strong className={campaign.status === 'Failed' ? 'is-error' : undefined}>
|
||||
{formatPercent(campaign.deliveryRate)}
|
||||
</strong>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="is-right">
|
||||
{campaign.status === 'Failed' ? (
|
||||
<button type="button" className="campaigns-action-button is-error" aria-label={`Retry ${campaign.name}`}>
|
||||
<span className="material-symbols-outlined">refresh</span>
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" className="campaigns-action-button" aria-label={`Actions for ${campaign.name}`}>
|
||||
<span className="material-symbols-outlined">more_vert</span>
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="campaigns-pagination">
|
||||
<p>
|
||||
Showing <strong>{filteredCampaigns.length === 0 ? 0 : 1}-{filteredCampaigns.length}</strong> of <strong>{campaigns.length}</strong> campaigns
|
||||
</p>
|
||||
<div className="campaigns-pagination-buttons">
|
||||
<button type="button" disabled aria-label="Previous page">
|
||||
<span className="material-symbols-outlined">chevron_left</span>
|
||||
</button>
|
||||
<button type="button" className="is-active">1</button>
|
||||
<button type="button">2</button>
|
||||
<button type="button">3</button>
|
||||
<span>...</span>
|
||||
<button type="button">25</button>
|
||||
<button type="button" aria-label="Next page">
|
||||
<span className="material-symbols-outlined">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="campaigns-insight-grid">
|
||||
<article className="campaigns-insight-card">
|
||||
<div className="campaigns-insight-glow" />
|
||||
<div className="campaigns-insight-body">
|
||||
<h2>Campaign Performance Optimization</h2>
|
||||
<p>
|
||||
Based on your recent broadcasts, campaigns sent between <strong>09:00 AM - 11:00 AM</strong> on Tuesdays
|
||||
have a <strong className="is-success">15% higher</strong> read rate. Consider scheduling your next
|
||||
template for this window.
|
||||
</p>
|
||||
<button type="button">View Details</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="campaigns-health-card">
|
||||
<div className="campaigns-health-head">
|
||||
<h2>Template Health</h2>
|
||||
<span className="material-symbols-outlined">verified</span>
|
||||
</div>
|
||||
<strong>94%</strong>
|
||||
<p>High quality rating across all approved templates.</p>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
320
frontend/src/components/contact-detail-board.tsx
Normal file
320
frontend/src/components/contact-detail-board.tsx
Normal file
@ -0,0 +1,320 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
type Props = {
|
||||
contact: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
phoneNumber: string;
|
||||
company: string | null;
|
||||
status: 'Active' | 'Inactive';
|
||||
tags: string[];
|
||||
location: string;
|
||||
lastSeenLabel: string;
|
||||
isBlacklisted: boolean;
|
||||
avatarInitials: string;
|
||||
notes: Array<{
|
||||
id: string;
|
||||
author: string;
|
||||
dateLabel: string;
|
||||
body: string;
|
||||
emphasized: boolean;
|
||||
}>;
|
||||
history: Array<{
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
at: string;
|
||||
summary: string;
|
||||
status: string;
|
||||
errorReason: string | null;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
function formatTimelineDate(value: string) {
|
||||
const date = new Date(value);
|
||||
return date.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: true,
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
}
|
||||
|
||||
export function ContactDetailBoard({ contact }: Props) {
|
||||
const router = useRouter();
|
||||
const [tab, setTab] = useState<'all' | 'messages' | 'system'>('all');
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [form, setForm] = useState({
|
||||
name: contact.name,
|
||||
phoneNumber: contact.phoneNumber,
|
||||
email: contact.email || '',
|
||||
company: contact.company || '',
|
||||
notes: contact.notes.map((note) => note.body).join('\n'),
|
||||
isBlacklisted: contact.isBlacklisted,
|
||||
});
|
||||
|
||||
const filteredHistory = useMemo(() => {
|
||||
if (tab === 'all') return contact.history;
|
||||
if (tab === 'messages') return contact.history.filter((item) => item.type === 'message' || item.type === 'inbound');
|
||||
return contact.history.filter((item) => item.type !== 'message' && item.type !== 'inbound');
|
||||
}, [contact.history, tab]);
|
||||
|
||||
return (
|
||||
<div className="contact-detail-page">
|
||||
<nav className="contact-detail-breadcrumb">
|
||||
<Link href="/dashboard/contacts">Contacts</Link>
|
||||
<span className="material-symbols-outlined">chevron_right</span>
|
||||
<span>{contact.name}</span>
|
||||
</nav>
|
||||
|
||||
<section className="contact-detail-hero">
|
||||
<div className="contact-detail-hero-main">
|
||||
<div className="contact-detail-hero-avatar">
|
||||
<span>{contact.avatarInitials}</span>
|
||||
<i />
|
||||
</div>
|
||||
<div>
|
||||
<div className="contact-detail-hero-title">
|
||||
<h1>{contact.name}</h1>
|
||||
<span>ID: {contact.id.slice(0, 6).toUpperCase()}</span>
|
||||
</div>
|
||||
<p>{contact.status} Customer • Last seen {contact.lastSeenLabel}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="contact-detail-hero-actions">
|
||||
<button type="button" className="contacts-secondary-button" onClick={() => setIsEditing(true)}>
|
||||
<span className="material-symbols-outlined">edit</span>
|
||||
Edit Contact
|
||||
</button>
|
||||
<Link href="/dashboard/conversations" className="contacts-primary-button">
|
||||
<span className="material-symbols-outlined">chat</span>
|
||||
Message
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="contact-detail-danger-icon"
|
||||
onClick={async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const response = await fetch(`/api/contacts/${contact.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ isBlacklisted: !form.isBlacklisted }),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(typeof payload?.message === 'string' ? payload.message : 'Failed to update contact');
|
||||
}
|
||||
router.refresh();
|
||||
} catch (actionError) {
|
||||
setError(actionError instanceof Error ? actionError.message : 'Failed to update contact');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="material-symbols-outlined">block</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error ? <p className="contacts-form-error">{error}</p> : null}
|
||||
|
||||
<section className="contact-detail-grid">
|
||||
<aside className="contact-detail-sidebar">
|
||||
<article className="contact-detail-card">
|
||||
<h2><span className="material-symbols-outlined">info</span>Contact Information</h2>
|
||||
<div className="contact-detail-info-list">
|
||||
<div>
|
||||
<label>Phone Number</label>
|
||||
<div className="contact-detail-info-row">
|
||||
<strong>{contact.phoneNumber}</strong>
|
||||
<button type="button" className="contacts-icon-button" onClick={() => navigator.clipboard.writeText(contact.phoneNumber)}>
|
||||
<span className="material-symbols-outlined">content_copy</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label>Email Address</label>
|
||||
<strong>{contact.email || 'No email on file'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<label>Location</label>
|
||||
<strong>{contact.location}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<label>Tags</label>
|
||||
<div className="contacts-tags-inline is-wrap">
|
||||
{contact.tags.map((tag) => (
|
||||
<span key={tag} className="contacts-tag-chip">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="contact-detail-card">
|
||||
<div className="contact-detail-card-head">
|
||||
<h2><span className="material-symbols-outlined">sticky_note_2</span>Internal Notes</h2>
|
||||
<button type="button" onClick={() => setIsEditing(true)}>Add Note</button>
|
||||
</div>
|
||||
<div className="contact-detail-notes">
|
||||
{contact.notes.map((note) => (
|
||||
<div key={note.id} className={note.emphasized ? 'contact-note-card is-emphasized' : 'contact-note-card'}>
|
||||
<p>{note.body}</p>
|
||||
<div>
|
||||
<span>- {note.author}</span>
|
||||
<span>{note.dateLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
</aside>
|
||||
|
||||
<section className="contact-detail-history-card">
|
||||
<div className="contact-detail-card-head">
|
||||
<h2><span className="material-symbols-outlined">history</span>Interaction History</h2>
|
||||
<div className="contact-detail-tabs">
|
||||
<button type="button" className={tab === 'all' ? 'is-active' : ''} onClick={() => setTab('all')}>All Activity</button>
|
||||
<button type="button" className={tab === 'messages' ? 'is-active' : ''} onClick={() => setTab('messages')}>Messages</button>
|
||||
<button type="button" className={tab === 'system' ? 'is-active' : ''} onClick={() => setTab('system')}>System</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="contact-history-timeline">
|
||||
{filteredHistory.map((item) => (
|
||||
<div key={item.id} className="contact-history-item">
|
||||
<div className="contact-history-icon">
|
||||
<span className="material-symbols-outlined">
|
||||
{item.type === 'message' ? 'chat' : item.type === 'inbound' ? 'call_received' : item.type === 'tag' ? 'sell' : 'person_pin'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="contact-history-body">
|
||||
<div className="contact-history-title">
|
||||
<strong>{item.title}</strong>
|
||||
<span>• {formatTimelineDate(item.at)}</span>
|
||||
</div>
|
||||
<p>{item.summary}</p>
|
||||
{item.errorReason ? <small>{item.errorReason}</small> : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
{isEditing ? (
|
||||
<div className="contacts-modal-backdrop" onClick={() => setIsEditing(false)}>
|
||||
<div className="contacts-modal-card" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="contacts-modal-head">
|
||||
<div>
|
||||
<p>Edit Contact</p>
|
||||
<h2>Update contact profile</h2>
|
||||
</div>
|
||||
<button type="button" className="contacts-icon-button" onClick={() => setIsEditing(false)}>
|
||||
<span className="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="contacts-form-grid"
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
setError(null);
|
||||
setIsSaving(true);
|
||||
const response = await fetch(`/api/contacts/${contact.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(typeof payload?.message === 'string' ? payload.message : 'Failed to update contact');
|
||||
}
|
||||
setIsEditing(false);
|
||||
router.refresh();
|
||||
} catch (submissionError) {
|
||||
setError(submissionError instanceof Error ? submissionError.message : 'Failed to update contact');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label className="contacts-form-field">
|
||||
<span>Full Name</span>
|
||||
<input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} />
|
||||
</label>
|
||||
<label className="contacts-form-field">
|
||||
<span>Phone Number</span>
|
||||
<input value={form.phoneNumber} onChange={(event) => setForm((current) => ({ ...current, phoneNumber: event.target.value }))} />
|
||||
</label>
|
||||
<label className="contacts-form-field">
|
||||
<span>Email Address</span>
|
||||
<input value={form.email} onChange={(event) => setForm((current) => ({ ...current, email: event.target.value }))} />
|
||||
</label>
|
||||
<label className="contacts-form-field">
|
||||
<span>Company</span>
|
||||
<input value={form.company} onChange={(event) => setForm((current) => ({ ...current, company: event.target.value }))} />
|
||||
</label>
|
||||
<label className="contacts-form-field is-full">
|
||||
<span>Notes</span>
|
||||
<textarea rows={5} value={form.notes} onChange={(event) => setForm((current) => ({ ...current, notes: event.target.value }))} />
|
||||
</label>
|
||||
<label className="contacts-checkbox-row is-full">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.isBlacklisted}
|
||||
onChange={(event) => setForm((current) => ({ ...current, isBlacklisted: event.target.checked }))}
|
||||
/>
|
||||
<span>Mark this contact as inactive / blocked</span>
|
||||
</label>
|
||||
<div className="contacts-form-actions">
|
||||
<button type="button" className="contacts-secondary-button" onClick={() => setIsEditing(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" className="contact-detail-delete-button" disabled={isDeleting} onClick={async () => {
|
||||
if (!window.confirm(`Delete contact "${contact.name}"?`)) return;
|
||||
try {
|
||||
setError(null);
|
||||
setIsDeleting(true);
|
||||
const response = await fetch(`/api/contacts/${contact.id}`, { method: 'DELETE' });
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(typeof payload?.message === 'string' ? payload.message : 'Failed to delete contact');
|
||||
}
|
||||
router.push('/dashboard/contacts');
|
||||
router.refresh();
|
||||
} catch (deleteError) {
|
||||
setError(deleteError instanceof Error ? deleteError.message : 'Failed to delete contact');
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}}>
|
||||
<span className="material-symbols-outlined">delete</span>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
<button type="submit" className="contacts-primary-button" disabled={isSaving}>
|
||||
<span className="material-symbols-outlined">save</span>
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
frontend/src/components/contact-form.tsx
Normal file
49
frontend/src/components/contact-form.tsx
Normal file
@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState, useEffect, useRef } from 'react';
|
||||
import { createContactAction } from '../app/actions';
|
||||
|
||||
type Props = {
|
||||
labels: {
|
||||
name: string;
|
||||
phoneNumber: string;
|
||||
email: string;
|
||||
company: string;
|
||||
notes: string;
|
||||
submit: string;
|
||||
success: string;
|
||||
};
|
||||
};
|
||||
|
||||
type ContactFormState = {
|
||||
error?: string;
|
||||
success?: string;
|
||||
};
|
||||
|
||||
const initialState: ContactFormState = {};
|
||||
|
||||
export function ContactForm({ labels }: Props) {
|
||||
const [state, formAction, pending] = useActionState(createContactAction, initialState);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.success) {
|
||||
formRef.current?.reset();
|
||||
}
|
||||
}, [state.success]);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="stack-form card" ref={formRef}>
|
||||
<input name="name" placeholder={labels.name} required />
|
||||
<input name="phoneNumber" placeholder={labels.phoneNumber} required />
|
||||
<input name="email" placeholder={labels.email} type="email" />
|
||||
<input name="company" placeholder={labels.company} />
|
||||
<textarea name="notes" placeholder={labels.notes} rows={4} />
|
||||
<button type="submit" disabled={pending}>
|
||||
{pending ? '...' : labels.submit}
|
||||
</button>
|
||||
{state.error ? <p className="form-error">{state.error}</p> : null}
|
||||
{state.success ? <p className="form-success">{labels.success}</p> : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
409
frontend/src/components/contacts-directory-board.tsx
Normal file
409
frontend/src/components/contacts-directory-board.tsx
Normal file
@ -0,0 +1,409 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
|
||||
type ContactRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
phoneNumber: string;
|
||||
company: string | null;
|
||||
status: 'Active' | 'Inactive';
|
||||
tags: string[];
|
||||
location: string;
|
||||
lastMessageLabel: string;
|
||||
avatarInitials: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
data: {
|
||||
items: ContactRow[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
availableTags: string[];
|
||||
statusCounts: {
|
||||
active: number;
|
||||
inactive: number;
|
||||
};
|
||||
};
|
||||
filters: {
|
||||
search: string;
|
||||
status: string;
|
||||
tag: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function ContactsDirectoryBoard({ data, filters }: Props) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [form, setForm] = useState({
|
||||
name: '',
|
||||
phoneNumber: '',
|
||||
email: '',
|
||||
company: '',
|
||||
notes: '',
|
||||
});
|
||||
|
||||
const pageNumbers = useMemo(() => {
|
||||
const pages = new Set<number>();
|
||||
pages.add(1);
|
||||
pages.add(data.totalPages);
|
||||
pages.add(data.page);
|
||||
pages.add(Math.max(1, data.page - 1));
|
||||
pages.add(Math.min(data.totalPages, data.page + 1));
|
||||
return Array.from(pages).sort((left, right) => left - right);
|
||||
}, [data.page, data.totalPages]);
|
||||
|
||||
function updateQuery(updates: Record<string, string | number | null>) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (value === null || value === '' || value === 'all') {
|
||||
params.delete(key);
|
||||
} else {
|
||||
params.set(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updates).some((key) => key !== 'page')) {
|
||||
params.set('page', '1');
|
||||
}
|
||||
|
||||
router.push(`/dashboard/contacts${params.toString() ? `?${params.toString()}` : ''}`);
|
||||
}
|
||||
|
||||
async function importCsv(file: File) {
|
||||
const text = await file.text();
|
||||
const rows = text
|
||||
.split(/\r?\n/)
|
||||
.map((row) => row.trim())
|
||||
.filter(Boolean);
|
||||
if (rows.length < 2) {
|
||||
throw new Error('CSV file is empty.');
|
||||
}
|
||||
|
||||
const headers = rows[0].split(',').map((value) => value.trim().toLowerCase());
|
||||
const items = rows.slice(1).map((row) => {
|
||||
const cols = row.split(',').map((value) => value.trim().replace(/^"|"$/g, ''));
|
||||
const get = (key: string) => cols[headers.indexOf(key)] || '';
|
||||
return {
|
||||
name: get('name'),
|
||||
phoneNumber: get('phonenumber') || get('phone_number') || get('phone'),
|
||||
email: get('email'),
|
||||
company: get('company'),
|
||||
notes: get('notes'),
|
||||
};
|
||||
}).filter((item) => item.name && item.phoneNumber);
|
||||
|
||||
if (items.length === 0) {
|
||||
throw new Error('CSV does not contain valid contact rows.');
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
const response = await fetch('/api/contacts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(item),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
throw new Error(typeof payload?.message === 'string' ? payload.message : `Failed to import contact ${item.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
return items.length;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="contacts-directory-page">
|
||||
<section className="contacts-directory-header">
|
||||
<div>
|
||||
<h1>Contacts Directory</h1>
|
||||
<p>Manage your customer database and communication segments.</p>
|
||||
</div>
|
||||
<div className="contacts-directory-actions">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
hidden
|
||||
onChange={async (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
setIsSubmitting(true);
|
||||
const imported = await importCsv(file);
|
||||
setNotice(`Imported ${imported} contacts successfully.`);
|
||||
router.refresh();
|
||||
} catch (importError) {
|
||||
setError(importError instanceof Error ? importError.message : 'Failed to import contacts');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
event.target.value = '';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button type="button" className="contacts-secondary-button" onClick={() => fileInputRef.current?.click()}>
|
||||
<span className="material-symbols-outlined">upload</span>
|
||||
Import
|
||||
</button>
|
||||
<a
|
||||
className="contacts-secondary-button"
|
||||
href={`/api/contacts/export?${new URLSearchParams({
|
||||
...(filters.search ? { search: filters.search } : {}),
|
||||
...(filters.status ? { status: filters.status } : {}),
|
||||
...(filters.tag ? { tag: filters.tag } : {}),
|
||||
}).toString()}`}
|
||||
>
|
||||
<span className="material-symbols-outlined">download</span>
|
||||
Export
|
||||
</a>
|
||||
<button type="button" className="contacts-primary-button" onClick={() => setIsCreateOpen(true)}>
|
||||
<span className="material-symbols-outlined">person_add</span>
|
||||
Add Contact
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{notice ? <p className="contacts-form-success">{notice}</p> : null}
|
||||
{error && !isCreateOpen ? <p className="contacts-form-error">{error}</p> : null}
|
||||
|
||||
<section className="contacts-filter-bar">
|
||||
<div className="contacts-filter-label">
|
||||
<span className="material-symbols-outlined">filter_list</span>
|
||||
<span>Filters</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
className="contacts-filter-input"
|
||||
value={filters.search}
|
||||
onChange={(event) => updateQuery({ search: event.target.value })}
|
||||
placeholder="Search contacts..."
|
||||
/>
|
||||
|
||||
<select
|
||||
className="contacts-filter-select"
|
||||
value={filters.status || 'all'}
|
||||
onChange={(event) => updateQuery({ status: event.target.value })}
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="Active">Active</option>
|
||||
<option value="Inactive">Inactive</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="contacts-filter-select"
|
||||
value={filters.tag || 'all'}
|
||||
onChange={(event) => updateQuery({ tag: event.target.value })}
|
||||
>
|
||||
<option value="all">All Tags</option>
|
||||
{data.availableTags.map((tag) => (
|
||||
<option key={tag} value={tag}>
|
||||
{tag}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<button type="button" className="contacts-clear-button" onClick={() => router.push('/dashboard/contacts')}>
|
||||
Clear all filters
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="contacts-table-card">
|
||||
<div className="contacts-table-wrap">
|
||||
<table className="contacts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Contact Name</th>
|
||||
<th>Phone Number</th>
|
||||
<th>Status</th>
|
||||
<th>Tags</th>
|
||||
<th>Last Message</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.items.map((contact) => (
|
||||
<tr key={contact.id}>
|
||||
<td>
|
||||
<Link href={`/dashboard/contacts/${contact.id}`} className="contacts-contact-cell">
|
||||
<span className="contacts-avatar">{contact.avatarInitials}</span>
|
||||
<span>
|
||||
<strong>{contact.name}</strong>
|
||||
<small>{contact.email || contact.location}</small>
|
||||
</span>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="contacts-mono-cell">{contact.phoneNumber}</td>
|
||||
<td>
|
||||
<span className={contact.status === 'Active' ? 'contacts-status-chip is-active' : 'contacts-status-chip is-inactive'}>
|
||||
{contact.status}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="contacts-tags-inline">
|
||||
{contact.tags.slice(0, 2).map((tag) => (
|
||||
<span key={tag} className="contacts-tag-chip">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td>{contact.lastMessageLabel}</td>
|
||||
<td className="contacts-table-actions">
|
||||
<Link href={`/dashboard/contacts/${contact.id}`} className="contacts-icon-button">
|
||||
<span className="material-symbols-outlined">more_vert</span>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="contacts-table-footer">
|
||||
<div>
|
||||
Showing <strong>{data.total === 0 ? 0 : (data.page - 1) * data.pageSize + 1} - {(data.page - 1) * data.pageSize + data.items.length}</strong> of{' '}
|
||||
<strong>{data.total}</strong> contacts
|
||||
</div>
|
||||
|
||||
<div className="contacts-pagination">
|
||||
<button
|
||||
type="button"
|
||||
className="contacts-page-button"
|
||||
disabled={data.page <= 1}
|
||||
onClick={() => updateQuery({ page: data.page - 1 })}
|
||||
>
|
||||
<span className="material-symbols-outlined">chevron_left</span>
|
||||
</button>
|
||||
{pageNumbers.map((pageNumber, index) => (
|
||||
<span key={pageNumber}>
|
||||
{index > 0 && pageNumber - pageNumbers[index - 1] > 1 ? <span className="contacts-page-gap">...</span> : null}
|
||||
<button
|
||||
type="button"
|
||||
className={pageNumber === data.page ? 'contacts-page-button is-active' : 'contacts-page-button'}
|
||||
onClick={() => updateQuery({ page: pageNumber })}
|
||||
>
|
||||
{pageNumber}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="contacts-page-button"
|
||||
disabled={data.page >= data.totalPages}
|
||||
onClick={() => updateQuery({ page: data.page + 1 })}
|
||||
>
|
||||
<span className="material-symbols-outlined">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="contacts-rows-label">
|
||||
<span>Rows per page:</span>
|
||||
<select
|
||||
value={String(data.pageSize)}
|
||||
onChange={(event) => updateQuery({ limit: event.target.value })}
|
||||
>
|
||||
<option value="10">10</option>
|
||||
<option value="20">20</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<button type="button" className="contacts-fab" onClick={() => setIsCreateOpen(true)}>
|
||||
<span className="material-symbols-outlined">add</span>
|
||||
</button>
|
||||
|
||||
{isCreateOpen ? (
|
||||
<div className="contacts-modal-backdrop" onClick={() => setIsCreateOpen(false)}>
|
||||
<div className="contacts-modal-card" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="contacts-modal-head">
|
||||
<div>
|
||||
<p>New Contact</p>
|
||||
<h2>Add a new contact profile</h2>
|
||||
</div>
|
||||
<button type="button" className="contacts-icon-button" onClick={() => setIsCreateOpen(false)}>
|
||||
<span className="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="contacts-form-grid"
|
||||
onSubmit={async (event) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
setError(null);
|
||||
setIsSubmitting(true);
|
||||
const response = await fetch('/api/contacts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(typeof payload?.message === 'string' ? payload.message : 'Failed to create contact');
|
||||
}
|
||||
|
||||
setIsCreateOpen(false);
|
||||
setForm({ name: '', phoneNumber: '', email: '', company: '', notes: '' });
|
||||
router.refresh();
|
||||
} catch (submissionError) {
|
||||
setError(submissionError instanceof Error ? submissionError.message : 'Failed to create contact');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label className="contacts-form-field">
|
||||
<span>Full Name</span>
|
||||
<input value={form.name} onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))} />
|
||||
</label>
|
||||
<label className="contacts-form-field">
|
||||
<span>Phone Number</span>
|
||||
<input value={form.phoneNumber} onChange={(event) => setForm((current) => ({ ...current, phoneNumber: event.target.value }))} />
|
||||
</label>
|
||||
<label className="contacts-form-field">
|
||||
<span>Email Address</span>
|
||||
<input value={form.email} onChange={(event) => setForm((current) => ({ ...current, email: event.target.value }))} />
|
||||
</label>
|
||||
<label className="contacts-form-field">
|
||||
<span>Company</span>
|
||||
<input value={form.company} onChange={(event) => setForm((current) => ({ ...current, company: event.target.value }))} />
|
||||
</label>
|
||||
<label className="contacts-form-field is-full">
|
||||
<span>Notes</span>
|
||||
<textarea rows={4} value={form.notes} onChange={(event) => setForm((current) => ({ ...current, notes: event.target.value }))} />
|
||||
</label>
|
||||
|
||||
{error ? <p className="contacts-form-error">{error}</p> : null}
|
||||
|
||||
<div className="contacts-form-actions">
|
||||
<button type="button" className="contacts-secondary-button" onClick={() => setIsCreateOpen(false)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" className="contacts-primary-button" disabled={isSubmitting}>
|
||||
<span className="material-symbols-outlined">save</span>
|
||||
{isSubmitting ? 'Saving...' : 'Save Contact'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
563
frontend/src/components/conversations-inbox.tsx
Normal file
563
frontend/src/components/conversations-inbox.tsx
Normal file
@ -0,0 +1,563 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
type FilterMode = 'All' | 'Active' | 'Pending';
|
||||
type ComposerMode = 'quick-replies' | 'templates';
|
||||
type ConversationSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
initials: string;
|
||||
time: string;
|
||||
status: string;
|
||||
tone: string;
|
||||
topic: string;
|
||||
snippet: string;
|
||||
online: boolean;
|
||||
location: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
customerSince: string;
|
||||
tags: string[];
|
||||
lastActivityAt: string;
|
||||
unreadCount: number;
|
||||
assignedAgentName: string | null;
|
||||
};
|
||||
type ConversationDetail = ConversationSummary & {
|
||||
activity: Array<{
|
||||
title: string;
|
||||
meta: string;
|
||||
tone: string;
|
||||
}>;
|
||||
messages: Array<{
|
||||
id: string;
|
||||
direction: 'incoming' | 'outgoing';
|
||||
body: string;
|
||||
time: string;
|
||||
status?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
const quickReplies = [
|
||||
'Checking this for you now.',
|
||||
'Could you share your order ID?',
|
||||
'I have escalated this to our support team.',
|
||||
] as const;
|
||||
|
||||
const templateReplies = [
|
||||
{
|
||||
label: 'Activation Follow-up',
|
||||
body: 'Your activation request is being processed. We will share the update shortly.',
|
||||
},
|
||||
{
|
||||
label: 'Shipping Update',
|
||||
body: 'Your shipment is in transit and the tracking page will refresh within a few minutes.',
|
||||
},
|
||||
{
|
||||
label: 'Discount Clarification',
|
||||
body: 'The enterprise discount is still available for qualifying annual plans.',
|
||||
},
|
||||
] as const;
|
||||
|
||||
function conversationPillClassName(tone: string) {
|
||||
if (tone === 'warning') return 'conversation-list-pill is-warning';
|
||||
if (tone === 'success') return 'conversation-list-pill is-success';
|
||||
return 'conversation-list-pill is-info';
|
||||
}
|
||||
|
||||
function getFallbackMessage(name: string) {
|
||||
return {
|
||||
id: `fallback-${name}`,
|
||||
direction: 'incoming' as const,
|
||||
body: `No active thread loaded for ${name} yet.`,
|
||||
time: '',
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeFilter(filter: FilterMode) {
|
||||
if (filter === 'Pending') return 'pending';
|
||||
if (filter === 'Active') return 'active';
|
||||
return 'all';
|
||||
}
|
||||
|
||||
export function ConversationsInbox({
|
||||
initialConversations,
|
||||
initialConversationDetail,
|
||||
initialSearch,
|
||||
}: {
|
||||
initialConversations: ConversationSummary[];
|
||||
initialConversationDetail: ConversationDetail | null;
|
||||
initialSearch: string;
|
||||
}) {
|
||||
const [items, setItems] = useState<ConversationSummary[]>(initialConversations);
|
||||
const [filter, setFilter] = useState<FilterMode>('All');
|
||||
const [activeConversationId, setActiveConversationId] = useState<string>(initialConversationDetail?.id ?? initialConversations[0]?.id ?? '');
|
||||
const [composerMode, setComposerMode] = useState<ComposerMode>('templates');
|
||||
const [composerText, setComposerText] = useState('');
|
||||
const [searchTerm] = useState(initialSearch);
|
||||
const [activeConversation, setActiveConversation] = useState<ConversationDetail | null>(initialConversationDetail);
|
||||
const [isThreadLoading, setIsThreadLoading] = useState(false);
|
||||
const [isSending, setIsSending] = useState(false);
|
||||
const [isAssigning, setIsAssigning] = useState(false);
|
||||
|
||||
const filteredConversations = useMemo(
|
||||
() =>
|
||||
items.filter((conversation) =>
|
||||
filter === 'All'
|
||||
? true
|
||||
: filter === 'Pending'
|
||||
? conversation.status === 'PENDING'
|
||||
: conversation.status === 'ACTIVE' || conversation.status === 'NEW',
|
||||
),
|
||||
[filter, items],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
const timeout = window.setTimeout(async () => {
|
||||
try {
|
||||
const params = new URLSearchParams({ filter: normalizeFilter(filter) });
|
||||
if (searchTerm) params.set('search', searchTerm);
|
||||
const response = await fetch(`/api/conversations?${params.toString()}`, {
|
||||
signal: controller.signal,
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as ConversationSummary[];
|
||||
setItems(payload);
|
||||
} catch (error) {
|
||||
if ((error as Error).name !== 'AbortError') {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
}, 150);
|
||||
|
||||
return () => {
|
||||
controller.abort();
|
||||
window.clearTimeout(timeout);
|
||||
};
|
||||
}, [filter, searchTerm]);
|
||||
|
||||
useEffect(() => {
|
||||
const nextActiveId =
|
||||
filteredConversations.find((conversation) => conversation.id === activeConversationId)?.id
|
||||
?? filteredConversations[0]?.id
|
||||
?? '';
|
||||
|
||||
if (nextActiveId && nextActiveId !== activeConversationId) {
|
||||
setActiveConversationId(nextActiveId);
|
||||
}
|
||||
if (!nextActiveId) {
|
||||
setActiveConversationId('');
|
||||
setActiveConversation(null);
|
||||
}
|
||||
}, [activeConversationId, filteredConversations]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeConversationId) {
|
||||
setActiveConversation(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
void (async () => {
|
||||
try {
|
||||
setIsThreadLoading(true);
|
||||
const response = await fetch(`/api/conversations/${activeConversationId}`, {
|
||||
signal: controller.signal,
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as ConversationDetail;
|
||||
setActiveConversation(payload);
|
||||
setItems((current) =>
|
||||
current.map((item) => (item.id === payload.id ? { ...item, ...payload } : item)),
|
||||
);
|
||||
} catch (error) {
|
||||
if ((error as Error).name !== 'AbortError') {
|
||||
console.error(error);
|
||||
}
|
||||
} finally {
|
||||
setIsThreadLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => controller.abort();
|
||||
}, [activeConversationId]);
|
||||
|
||||
function applySuggestion(text: string) {
|
||||
setComposerText(text);
|
||||
}
|
||||
|
||||
async function refreshSummaries(nextActiveId?: string) {
|
||||
const params = new URLSearchParams({ filter: normalizeFilter(filter) });
|
||||
if (searchTerm) params.set('search', searchTerm);
|
||||
const response = await fetch(`/api/conversations?${params.toString()}`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as ConversationSummary[];
|
||||
setItems(payload);
|
||||
if (nextActiveId && !payload.some((item) => item.id === nextActiveId) && payload[0]?.id) {
|
||||
setActiveConversationId(payload[0].id);
|
||||
}
|
||||
}
|
||||
|
||||
async function assignToMe() {
|
||||
if (!activeConversation || isAssigning) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsAssigning(true);
|
||||
const response = await fetch(`/api/conversations/${activeConversation.id}/assign`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
await refreshSummaries(activeConversation.id);
|
||||
const detailResponse = await fetch(`/api/conversations/${activeConversation.id}`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!detailResponse.ok) {
|
||||
return;
|
||||
}
|
||||
const payload = (await detailResponse.json()) as ConversationDetail;
|
||||
setActiveConversation(payload);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsAssigning(false);
|
||||
}
|
||||
}
|
||||
|
||||
function renderOutgoingStatus(status?: string) {
|
||||
if (status === 'failed') {
|
||||
return <span className="material-symbols-outlined">error</span>;
|
||||
}
|
||||
if (status === 'read' || status === 'delivered') {
|
||||
return <span className="material-symbols-outlined">done_all</span>;
|
||||
}
|
||||
return <span className="material-symbols-outlined">done</span>;
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const nextBody = composerText.trim();
|
||||
if (!nextBody || !activeConversation || isSending) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsSending(true);
|
||||
const response = await fetch(`/api/conversations/${activeConversation.id}/messages`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ body: nextBody }),
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.message || 'Failed to send message');
|
||||
}
|
||||
|
||||
setActiveConversation((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
snippet: nextBody,
|
||||
time: 'Just now',
|
||||
status: 'ACTIVE',
|
||||
tone: 'success',
|
||||
messages: [...current.messages, payload.message],
|
||||
}
|
||||
: current,
|
||||
);
|
||||
setItems((current) =>
|
||||
current.map((conversation) =>
|
||||
conversation.id === activeConversation.id
|
||||
? {
|
||||
...conversation,
|
||||
snippet: nextBody,
|
||||
time: 'Just now',
|
||||
status: 'ACTIVE',
|
||||
tone: 'success',
|
||||
}
|
||||
: conversation,
|
||||
),
|
||||
);
|
||||
setComposerText('');
|
||||
setFilter('All');
|
||||
await refreshSummaries(activeConversation.id);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setIsSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="conversations-page">
|
||||
<div className="conversations-layout">
|
||||
<aside className="conversations-sidebar surface-card">
|
||||
<div className="conversations-filter-tabs">
|
||||
{(['All', 'Active', 'Pending'] as const).map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
className={filter === item ? 'is-active' : undefined}
|
||||
onClick={() => setFilter(item)}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="conversations-list custom-scrollbar">
|
||||
{filteredConversations.map((conversation) => {
|
||||
const isActive = conversation.id === activeConversation?.id;
|
||||
|
||||
return (
|
||||
<article
|
||||
key={conversation.id}
|
||||
className={isActive ? 'conversation-list-item is-active' : 'conversation-list-item'}
|
||||
onClick={() => setActiveConversationId(conversation.id)}
|
||||
>
|
||||
{isActive ? <span className="conversation-active-rail" /> : null}
|
||||
<div className="conversation-avatar">{conversation.initials}</div>
|
||||
<div className="conversation-list-main">
|
||||
<div className="conversation-list-topline">
|
||||
<h3>{conversation.name}</h3>
|
||||
<span className={isActive ? 'is-recent' : ''}>{conversation.time}</span>
|
||||
</div>
|
||||
<p>{conversation.snippet}</p>
|
||||
<div className="conversation-list-tags">
|
||||
<span className={conversationPillClassName(conversation.tone)}>{conversation.status}</span>
|
||||
{conversation.topic ? <span className="conversation-list-pill is-muted">{conversation.topic}</span> : null}
|
||||
{conversation.unreadCount > 0 ? <span className="conversation-list-pill is-info">{conversation.unreadCount} unread</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
|
||||
{filteredConversations.length === 0 ? (
|
||||
<div className="conversations-empty-state">
|
||||
<span className="material-symbols-outlined">forum</span>
|
||||
<p>No conversations match this filter.</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="conversations-thread surface-card">
|
||||
<header className="conversations-thread-head">
|
||||
<div className="conversations-thread-contact">
|
||||
<div className="conversation-avatar is-large">{activeConversation?.initials ?? '--'}</div>
|
||||
<div>
|
||||
<h2>{activeConversation?.name ?? 'No conversation selected'}</h2>
|
||||
<div className="conversations-online-row">
|
||||
<span className={`conversations-online-dot ${activeConversation?.online ? 'is-online' : 'is-offline'}`} />
|
||||
<span>{activeConversation?.online ? 'Online' : 'Offline'}</span>
|
||||
{activeConversation?.assignedAgentName ? <span>• {activeConversation.assignedAgentName}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="conversations-thread-actions">
|
||||
<button type="button" aria-label="Start video call">
|
||||
<span className="material-symbols-outlined">videocam</span>
|
||||
</button>
|
||||
<button type="button" aria-label="Start call">
|
||||
<span className="material-symbols-outlined">call</span>
|
||||
</button>
|
||||
<button type="button" aria-label="More actions">
|
||||
<span className="material-symbols-outlined">more_vert</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="conversations-thread-body custom-scrollbar">
|
||||
{isThreadLoading ? (
|
||||
<div className="conversations-empty-state">
|
||||
<span className="material-symbols-outlined">hourglass_top</span>
|
||||
<p>Loading conversation...</p>
|
||||
</div>
|
||||
) : (
|
||||
(activeConversation?.messages.length ? activeConversation.messages : activeConversation ? [getFallbackMessage(activeConversation.name)] : []).map(
|
||||
(message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`conversation-message-wrap ${message.direction === 'outgoing' ? 'is-outgoing' : 'is-incoming'}`}
|
||||
>
|
||||
<div className={`conversation-bubble ${message.direction === 'outgoing' ? 'is-outgoing' : 'is-incoming'}`}>
|
||||
<p>{message.body}</p>
|
||||
</div>
|
||||
<div className={`conversation-meta ${message.direction === 'outgoing' ? 'is-outgoing' : 'is-incoming'}`}>
|
||||
<span>{message.time}</span>
|
||||
{message.direction === 'outgoing' ? renderOutgoingStatus(message.status) : null}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="conversations-composer">
|
||||
<div className="conversations-composer-tools">
|
||||
<button
|
||||
type="button"
|
||||
className={composerMode === 'quick-replies' ? 'is-active' : undefined}
|
||||
onClick={() => setComposerMode('quick-replies')}
|
||||
>
|
||||
<span className="material-symbols-outlined">auto_awesome</span>
|
||||
Quick Replies
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={composerMode === 'templates' ? 'is-active' : undefined}
|
||||
onClick={() => setComposerMode('templates')}
|
||||
>
|
||||
<span className="material-symbols-outlined">description</span>
|
||||
Templates
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="conversations-composer-suggestions">
|
||||
{(composerMode === 'quick-replies' ? quickReplies : templateReplies.map((template) => template.label)).map((item) => (
|
||||
<button
|
||||
key={item}
|
||||
type="button"
|
||||
className="conversation-suggestion-chip"
|
||||
onClick={() =>
|
||||
applySuggestion(
|
||||
composerMode === 'quick-replies'
|
||||
? item
|
||||
: templateReplies.find((template) => template.label === item)?.body ?? item,
|
||||
)
|
||||
}
|
||||
>
|
||||
{item}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="conversations-composer-shell">
|
||||
<button type="button" aria-label="Emoji">
|
||||
<span className="material-symbols-outlined">sentiment_satisfied</span>
|
||||
</button>
|
||||
<button type="button" aria-label="Attach file">
|
||||
<span className="material-symbols-outlined">attach_file</span>
|
||||
</button>
|
||||
<textarea
|
||||
rows={1}
|
||||
placeholder="Type a message..."
|
||||
value={composerText}
|
||||
onChange={(event) => setComposerText(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void sendMessage();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="conversations-send-button"
|
||||
aria-label="Send message"
|
||||
onClick={() => void sendMessage()}
|
||||
disabled={isSending}
|
||||
>
|
||||
<span className="material-symbols-outlined">send</span>
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<aside className="conversations-profile surface-card">
|
||||
<div className="conversations-profile-top">
|
||||
<div className="conversation-avatar is-profile">{activeConversation?.initials ?? '--'}</div>
|
||||
<h3>{activeConversation?.name ?? 'No conversation selected'}</h3>
|
||||
<p>{activeConversation?.location ?? 'Unavailable'}</p>
|
||||
<p>{activeConversation?.assignedAgentName ? `Assigned to ${activeConversation.assignedAgentName}` : 'Unassigned conversation'}</p>
|
||||
<div className="conversations-profile-actions">
|
||||
<button type="button" aria-label="Assign conversation" onClick={() => void assignToMe()} disabled={isAssigning}>
|
||||
<span className="material-symbols-outlined">assignment_ind</span>
|
||||
</button>
|
||||
<button type="button" aria-label="Edit contact">
|
||||
<span className="material-symbols-outlined">edit</span>
|
||||
</button>
|
||||
<button type="button" aria-label="Favorite contact">
|
||||
<span className="material-symbols-outlined">star</span>
|
||||
</button>
|
||||
<button type="button" aria-label="Block contact">
|
||||
<span className="material-symbols-outlined">block</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="conversations-profile-scroll custom-scrollbar">
|
||||
<section className="conversations-profile-section">
|
||||
<h4>Contact Details</h4>
|
||||
<div className="conversations-detail-list">
|
||||
<div>
|
||||
<span className="material-symbols-outlined">mail</span>
|
||||
<p>{activeConversation?.email ?? 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="material-symbols-outlined">call</span>
|
||||
<p>{activeConversation?.phone ?? 'N/A'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="material-symbols-outlined">calendar_today</span>
|
||||
<p>{activeConversation?.customerSince ?? 'N/A'}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="conversations-profile-section">
|
||||
<h4>Tags</h4>
|
||||
<div className="conversations-tags">
|
||||
{activeConversation?.tags.map((tag) => <span key={tag}>{tag}</span>)}
|
||||
<button type="button">+ Add Tag</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="conversations-profile-section">
|
||||
<h4>Recent Activity</h4>
|
||||
<div className="conversations-activity-list">
|
||||
{activeConversation?.activity.map((item) => (
|
||||
<article key={`${item.title}-${item.meta}`} className="conversations-activity-item">
|
||||
<i className={item.tone === 'primary' ? 'is-primary' : 'is-muted'} />
|
||||
<div>
|
||||
<strong>{item.title}</strong>
|
||||
<p>{item.meta}</p>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="conversations-profile-footer">
|
||||
<button type="button">View Full CRM Profile</button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
231
frontend/src/components/dashboard-shell.tsx
Normal file
231
frontend/src/components/dashboard-shell.tsx
Normal file
@ -0,0 +1,231 @@
|
||||
import Link from 'next/link';
|
||||
import type { ReactNode } from 'react';
|
||||
import { logoutAction } from '../app/actions';
|
||||
import { requireAuthToken } from '../lib/auth';
|
||||
import { getDictionary, getLocale } from '../lib/i18n';
|
||||
import { LanguageSwitcher } from './language-switcher';
|
||||
|
||||
type Props = {
|
||||
children: ReactNode;
|
||||
currentPath: string;
|
||||
title?: string;
|
||||
searchPlaceholder?: string;
|
||||
searchValue?: string;
|
||||
searchActionPath?: string;
|
||||
searchQueryName?: string;
|
||||
};
|
||||
|
||||
function isActive(href: string, currentPath: string) {
|
||||
if (href === '/dashboard') {
|
||||
return currentPath === href;
|
||||
}
|
||||
|
||||
return currentPath === href || currentPath.startsWith(`${href}/`);
|
||||
}
|
||||
|
||||
function SearchIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
d="M11 5a6 6 0 1 0 0 12 6 6 0 0 0 0-12Zm0 0c3.3 0 6 2.7 6 6m-1.5 5.5L20 21"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function BellIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
d="M9.5 18a2.5 2.5 0 0 0 5 0M6 17h12l-1.4-1.6a2 2 0 0 1-.5-1.3V10a4.1 4.1 0 0 0-8.2 0v4.1a2 2 0 0 1-.5 1.3L6 17Z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function HelpIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path
|
||||
d="M9.6 9.1a2.9 2.9 0 1 1 4.8 2.2c-.9.7-1.7 1.3-1.7 2.4v.4M12 17.6h.01"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export async function DashboardShell({
|
||||
children,
|
||||
currentPath,
|
||||
title,
|
||||
searchPlaceholder,
|
||||
searchValue,
|
||||
searchActionPath,
|
||||
searchQueryName,
|
||||
}: Props) {
|
||||
await requireAuthToken();
|
||||
const locale = await getLocale();
|
||||
const dict = await getDictionary();
|
||||
const isConversationsRoute = currentPath.startsWith('/dashboard/conversations');
|
||||
|
||||
const navItems = [
|
||||
{ href: '/dashboard', label: dict.dashboard.navOverview, icon: 'dashboard' },
|
||||
{ href: '/dashboard/conversations', label: dict.dashboard.navConversations, icon: 'chat' },
|
||||
{ href: '/dashboard/templates', label: dict.dashboard.navTemplates, icon: 'description' },
|
||||
{ href: '/dashboard/logs', label: 'Analytics', icon: 'monitoring' },
|
||||
];
|
||||
|
||||
const utilityItems = [
|
||||
{ href: '/dashboard/contacts', label: dict.dashboard.navContacts, icon: 'contacts' },
|
||||
{ href: '/dashboard/campaigns', label: dict.dashboard.navCampaigns, icon: 'campaign' },
|
||||
{ href: '/dashboard/users', label: dict.dashboard.navUsers, icon: 'group' },
|
||||
{ href: '/dashboard/roles', label: dict.dashboard.navRoles, icon: 'admin_panel_settings' },
|
||||
];
|
||||
|
||||
const settingsItems = [
|
||||
{ href: '/dashboard/settings/whatsapp-api', label: 'WhatsApp API Setting', icon: 'link' },
|
||||
{ href: '/dashboard/settings/security', label: 'Security', icon: 'security' },
|
||||
{ href: '/dashboard/settings/webhook-logs', label: 'Webhook Logs', icon: 'webhook' },
|
||||
{ href: '/dashboard/settings/audit-trail', label: 'Audit Trail', icon: 'history' },
|
||||
];
|
||||
const isSettingsSection = currentPath.startsWith('/dashboard/settings');
|
||||
|
||||
return (
|
||||
<div className="dashboard-app">
|
||||
<aside className="dashboard-sidebar">
|
||||
<div className="dashboard-brand">
|
||||
<h1>BizOne</h1>
|
||||
<p>Enterprise API</p>
|
||||
</div>
|
||||
|
||||
<Link href="/dashboard/campaigns" className="dashboard-primary-action">
|
||||
<span className="material-symbols-outlined">add</span>
|
||||
{dict.dashboard.newMessage}
|
||||
</Link>
|
||||
|
||||
<div className="dashboard-sidebar-scroll">
|
||||
<nav className="dashboard-nav">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={isActive(item.href, currentPath) ? 'dashboard-nav-link is-active' : 'dashboard-nav-link'}
|
||||
>
|
||||
<span className="material-symbols-outlined">{item.icon}</span>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="dashboard-nav-secondary">
|
||||
{utilityItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={isActive(item.href, currentPath) ? 'dashboard-subnav-link is-active' : 'dashboard-subnav-link'}
|
||||
>
|
||||
<span className="material-symbols-outlined">{item.icon}</span>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="dashboard-sidebar-footer">
|
||||
<details className="dashboard-settings-accordion" open={isSettingsSection}>
|
||||
<summary className={isSettingsSection ? 'dashboard-nav-link is-active' : 'dashboard-nav-link'}>
|
||||
<span className="dashboard-nav-link-main">
|
||||
<span className="material-symbols-outlined">settings</span>
|
||||
{dict.dashboard.navSettings}
|
||||
</span>
|
||||
<span className="material-symbols-outlined dashboard-settings-caret">expand_more</span>
|
||||
</summary>
|
||||
<div className="dashboard-settings-subnav">
|
||||
{settingsItems.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={isActive(item.href, currentPath) ? 'dashboard-settings-link is-active' : 'dashboard-settings-link'}
|
||||
>
|
||||
<span className="material-symbols-outlined">{item.icon}</span>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
<form action={logoutAction}>
|
||||
<button type="submit" className="dashboard-logout-button">
|
||||
<span className="material-symbols-outlined">logout</span>
|
||||
{dict.common.logout}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className={isConversationsRoute ? 'dashboard-main dashboard-main-conversations' : 'dashboard-main'}>
|
||||
<header className="dashboard-topbar">
|
||||
<div className="dashboard-topbar-left">
|
||||
<h2>{title || dict.common.overview}</h2>
|
||||
{searchPlaceholder ? (
|
||||
<>
|
||||
<div className="dashboard-topbar-divider" />
|
||||
<form className="dashboard-searchbar" action={searchActionPath || currentPath}>
|
||||
<span className="material-symbols-outlined">search</span>
|
||||
<input
|
||||
type="search"
|
||||
name={searchQueryName || 'q'}
|
||||
placeholder={searchPlaceholder}
|
||||
defaultValue={searchValue || ''}
|
||||
/>
|
||||
</form>
|
||||
</>
|
||||
) : null}
|
||||
<LanguageSwitcher
|
||||
currentLocale={locale}
|
||||
label={dict.common.language}
|
||||
englishLabel={dict.common.english}
|
||||
indonesianLabel={dict.common.indonesian}
|
||||
submitLabel={dict.common.save}
|
||||
variant="compact"
|
||||
/>
|
||||
</div>
|
||||
<div className="dashboard-topbar-actions">
|
||||
<button type="button" className="dashboard-icon-button" aria-label={dict.common.search}>
|
||||
<SearchIcon />
|
||||
</button>
|
||||
<button type="button" className="dashboard-icon-button" aria-label="notifications">
|
||||
<BellIcon />
|
||||
</button>
|
||||
<button type="button" className="dashboard-icon-button" aria-label={dict.common.helpCenter}>
|
||||
<HelpIcon />
|
||||
</button>
|
||||
<div className="dashboard-profile">
|
||||
<div>
|
||||
<strong>{dict.common.adminUser}</strong>
|
||||
<span>{dict.common.superAdmin}</span>
|
||||
</div>
|
||||
<div className="dashboard-avatar">A</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className={isConversationsRoute ? 'dashboard-content dashboard-content-conversations' : 'dashboard-content'}>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
frontend/src/components/forgot-password-card.tsx
Normal file
99
frontend/src/components/forgot-password-card.tsx
Normal file
@ -0,0 +1,99 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
type Props = {
|
||||
languageSwitcher: ReactNode;
|
||||
};
|
||||
|
||||
export function ForgotPasswordCard({ languageSwitcher }: Props) {
|
||||
const [email, setEmail] = useState('');
|
||||
const [pending, setPending] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
async function submit() {
|
||||
setPending(true);
|
||||
setMessage('');
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/auth/password-reset', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
setError(payload.message || 'Failed to request password reset.');
|
||||
return;
|
||||
}
|
||||
|
||||
setMessage('If the account exists, a reset link has been sent to the email address.');
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="auth-page auth-page-enterprise auth-page-login">
|
||||
<div className="auth-enterprise-glow auth-enterprise-glow-right" />
|
||||
<div className="auth-enterprise-glow auth-enterprise-glow-left" />
|
||||
<div className="auth-page-symbol auth-page-symbol-top">
|
||||
<span className="material-symbols-outlined">mark_email_unread</span>
|
||||
</div>
|
||||
<div className="auth-page-symbol auth-page-symbol-bottom">
|
||||
<span className="material-symbols-outlined">lock_reset</span>
|
||||
</div>
|
||||
|
||||
<section className="auth-container auth-container-login">
|
||||
<div className="auth-login-toolbar">
|
||||
<div className="auth-login-locale">{languageSwitcher}</div>
|
||||
</div>
|
||||
|
||||
<header className="auth-brand">
|
||||
<div className="auth-brand-mark">
|
||||
<span className="material-symbols-outlined">lock_reset</span>
|
||||
</div>
|
||||
<div className="auth-brand-copy">
|
||||
<h1>BizOne</h1>
|
||||
<p>Reset access securely. We will send a password reset link if the account exists.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="auth-card auth-card-enterprise">
|
||||
<div className="two-factor-copy auth-public-copy">
|
||||
<h2>Forgot Password</h2>
|
||||
<p>Enter your email address and we will send you a reset link if the account exists.</p>
|
||||
</div>
|
||||
|
||||
<div className="invite-form">
|
||||
<label className="invite-field">
|
||||
<span>Email Address</span>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
placeholder="admin@example.com"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button type="button" className="invite-submit-button" onClick={submit} disabled={pending || !email.trim()}>
|
||||
{pending ? 'Sending...' : 'Send Reset Link'}
|
||||
</button>
|
||||
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
{message ? <p className="form-success">{message}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="invite-footer auth-inline-footer">
|
||||
<span>Remembered your password?</span>
|
||||
<Link href="/login">Back to login</Link>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
66
frontend/src/components/language-switcher.tsx
Normal file
66
frontend/src/components/language-switcher.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
import { setLocaleAction } from '../app/actions';
|
||||
import type { Locale } from '../lib/i18n';
|
||||
|
||||
type Props = {
|
||||
currentLocale: Locale;
|
||||
label: string;
|
||||
englishLabel: string;
|
||||
indonesianLabel: string;
|
||||
submitLabel: string;
|
||||
variant?: 'default' | 'compact';
|
||||
};
|
||||
|
||||
export function LanguageSwitcher({
|
||||
currentLocale,
|
||||
label,
|
||||
englishLabel,
|
||||
indonesianLabel,
|
||||
submitLabel,
|
||||
variant = 'default',
|
||||
}: Props) {
|
||||
const pathname = usePathname();
|
||||
const [locale, setLocale] = useState<Locale>(currentLocale);
|
||||
|
||||
if (variant === 'compact') {
|
||||
return (
|
||||
<form action={setLocaleAction} className="language-switcher language-switcher-compact">
|
||||
<input type="hidden" name="redirectPath" value={pathname} />
|
||||
<span className="sr-only">{label}</span>
|
||||
<button
|
||||
type="submit"
|
||||
name="locale"
|
||||
value="en"
|
||||
className={currentLocale === 'en' ? 'is-active' : ''}
|
||||
>
|
||||
EN
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
name="locale"
|
||||
value="id"
|
||||
className={currentLocale === 'id' ? 'is-active' : ''}
|
||||
>
|
||||
ID
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form action={setLocaleAction} className="language-switcher">
|
||||
<input type="hidden" name="redirectPath" value={pathname} />
|
||||
<label>
|
||||
<span>{label}</span>
|
||||
<select name="locale" value={locale} onChange={(event) => setLocale(event.target.value as Locale)}>
|
||||
<option value="id">{indonesianLabel}</option>
|
||||
<option value="en">{englishLabel}</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">{submitLabel}</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
175
frontend/src/components/login-form.tsx
Normal file
175
frontend/src/components/login-form.tsx
Normal file
@ -0,0 +1,175 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { useActionState, useState } from 'react';
|
||||
import { loginAction } from '../app/actions';
|
||||
|
||||
type Props = {
|
||||
languageSwitcher: ReactNode;
|
||||
labels: {
|
||||
title: string;
|
||||
description: string;
|
||||
email: string;
|
||||
password: string;
|
||||
submit: string;
|
||||
goToDashboard: string;
|
||||
emailLabel: string;
|
||||
passwordLabel: string;
|
||||
forgotPassword: string;
|
||||
rememberMe: string;
|
||||
accessVia: string;
|
||||
google: string;
|
||||
sso: string;
|
||||
newToPlatform: string;
|
||||
applyAccess: string;
|
||||
privacyPolicy: string;
|
||||
termsOfService: string;
|
||||
helpCenter: string;
|
||||
securityPreview: string;
|
||||
loginHelp: string;
|
||||
twoFactorPreview: string;
|
||||
showPassword: string;
|
||||
hidePassword: string;
|
||||
};
|
||||
appName: string;
|
||||
};
|
||||
|
||||
type LoginFormState = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const initialState: LoginFormState = {};
|
||||
|
||||
function MailIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M4 6h16a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2Zm0 2 8 5 8-5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function LockIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M7 10V8a5 5 0 0 1 10 0v2m-9 0h8a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2Z" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M6 18.5V20l2.7-1.8c.4-.2.8-.3 1.2-.3h8.1A2 2 0 0 0 20 15.9V6.8A1.8 1.8 0 0 0 18.2 5H5.8A1.8 1.8 0 0 0 4 6.8v9.4c0 1.3 1 2.3 2 2.3Z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ArrowIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M5 12h14m-5-5 5 5-5 5" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoginForm({ labels, appName, languageSwitcher }: Props) {
|
||||
const [state, formAction, pending] = useActionState(loginAction, initialState);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
return (
|
||||
<main className="auth-page auth-page-enterprise auth-page-login">
|
||||
<div className="auth-enterprise-glow auth-enterprise-glow-right" />
|
||||
<div className="auth-enterprise-glow auth-enterprise-glow-left" />
|
||||
<div className="auth-page-symbol auth-page-symbol-top">
|
||||
<span className="material-symbols-outlined">hub</span>
|
||||
</div>
|
||||
<div className="auth-page-symbol auth-page-symbol-bottom">
|
||||
<span className="material-symbols-outlined">security_update_good</span>
|
||||
</div>
|
||||
|
||||
<section className="auth-container auth-container-login">
|
||||
<div className="auth-login-toolbar">
|
||||
<div className="auth-login-locale">{languageSwitcher}</div>
|
||||
</div>
|
||||
|
||||
<header className="auth-brand">
|
||||
<div className="auth-brand-mark">
|
||||
<ChatIcon />
|
||||
</div>
|
||||
<div className="auth-brand-copy">
|
||||
<h1>{appName}</h1>
|
||||
<p>{labels.description}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="auth-card auth-card-enterprise">
|
||||
<form action={formAction} className="enterprise-form">
|
||||
<div className="auth-form-block">
|
||||
<label className="field-label" htmlFor="email">
|
||||
{labels.emailLabel}
|
||||
</label>
|
||||
<div className="input-shell">
|
||||
<span className="input-icon">
|
||||
<MailIcon />
|
||||
</span>
|
||||
<input id="email" name="email" type="email" placeholder="admin@enterprise.com" required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="auth-form-block">
|
||||
<div className="field-row">
|
||||
<label className="field-label" htmlFor="password">
|
||||
{labels.passwordLabel}
|
||||
</label>
|
||||
<a href="/forgot-password" className="text-link">
|
||||
{labels.forgotPassword}
|
||||
</a>
|
||||
</div>
|
||||
<div className="input-shell">
|
||||
<span className="input-icon">
|
||||
<LockIcon />
|
||||
</span>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="text-link text-link-compact"
|
||||
onClick={() => setShowPassword((current) => !current)}
|
||||
>
|
||||
{showPassword ? labels.hidePassword : labels.showPassword}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="check-row">
|
||||
<input type="checkbox" name="remember" />
|
||||
<span>{labels.rememberMe}</span>
|
||||
</label>
|
||||
|
||||
<button type="submit" disabled={pending} className="auth-submit">
|
||||
<span>{pending ? '...' : labels.submit}</span>
|
||||
<span className="button-icon">
|
||||
<ArrowIcon />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{state.error ? <p className="form-error">{state.error}</p> : null}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<footer className="auth-footer">
|
||||
<nav className="auth-footer-links">
|
||||
<button type="button">{labels.privacyPolicy}</button>
|
||||
<button type="button">{labels.termsOfService}</button>
|
||||
<button type="button">{labels.helpCenter}</button>
|
||||
</nav>
|
||||
</footer>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
177
frontend/src/components/reset-password-card.tsx
Normal file
177
frontend/src/components/reset-password-card.tsx
Normal file
@ -0,0 +1,177 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
type Props = {
|
||||
token: string;
|
||||
languageSwitcher: ReactNode;
|
||||
resetRequest: {
|
||||
email: string;
|
||||
name: string;
|
||||
expiresAt: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
function passwordChecks(password: string) {
|
||||
return {
|
||||
minLength: password.length >= 8,
|
||||
hasNumber: /\d/.test(password),
|
||||
hasSpecial: /[^A-Za-z0-9]/.test(password),
|
||||
};
|
||||
}
|
||||
|
||||
function formatExpiry(value: string | null) {
|
||||
if (!value) return 'Unavailable';
|
||||
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
export function ResetPasswordCard({ token, resetRequest, languageSwitcher }: Props) {
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
const checks = useMemo(() => passwordChecks(password), [password]);
|
||||
const strengthCount = [checks.minLength, checks.hasNumber, checks.hasSpecial].filter(Boolean).length;
|
||||
const strengthLabel = strengthCount <= 1 ? 'Weak' : strengthCount === 2 ? 'Medium' : 'Strong';
|
||||
|
||||
async function submit() {
|
||||
setError('');
|
||||
setMessage('');
|
||||
if (password !== confirmPassword) {
|
||||
setError('Password confirmation does not match.');
|
||||
return;
|
||||
}
|
||||
|
||||
setPending(true);
|
||||
try {
|
||||
const response = await fetch(`/api/auth/password-reset/${token}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
setError(payload.message || 'Failed to reset password');
|
||||
return;
|
||||
}
|
||||
|
||||
setMessage('Password updated. You can now log in with your new password.');
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="auth-page auth-page-enterprise auth-page-login">
|
||||
<div className="auth-enterprise-glow auth-enterprise-glow-right" />
|
||||
<div className="auth-enterprise-glow auth-enterprise-glow-left" />
|
||||
<div className="auth-page-symbol auth-page-symbol-top">
|
||||
<span className="material-symbols-outlined">password</span>
|
||||
</div>
|
||||
<div className="auth-page-symbol auth-page-symbol-bottom">
|
||||
<span className="material-symbols-outlined">verified_user</span>
|
||||
</div>
|
||||
|
||||
<section className="auth-container auth-container-login">
|
||||
<div className="auth-login-toolbar">
|
||||
<div className="auth-login-locale">{languageSwitcher}</div>
|
||||
</div>
|
||||
|
||||
<header className="auth-brand">
|
||||
<div className="auth-brand-mark">
|
||||
<span className="material-symbols-outlined">vpn_key</span>
|
||||
</div>
|
||||
<div className="auth-brand-copy">
|
||||
<h1>BizOne</h1>
|
||||
<p>Create a new password and secure the admin account before returning to the dashboard.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="auth-card auth-card-enterprise">
|
||||
<div className="two-factor-copy auth-public-copy">
|
||||
<h2>Reset Your Password</h2>
|
||||
<p>
|
||||
Hello, {resetRequest.name}. Choose a new password for <strong>{resetRequest.email}</strong>.
|
||||
</p>
|
||||
<p className="auth-public-meta">Reset link expires: {formatExpiry(resetRequest.expiresAt)}</p>
|
||||
</div>
|
||||
|
||||
<div className="invite-form">
|
||||
<label className="invite-field">
|
||||
<span>New Password</span>
|
||||
<div className="invite-password-wrap">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
<button type="button" onClick={() => setShowPassword((current) => !current)}>
|
||||
<span className="material-symbols-outlined">{showPassword ? 'visibility_off' : 'visibility'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="invite-field">
|
||||
<span>Confirm New Password</span>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(event) => setConfirmPassword(event.target.value)}
|
||||
placeholder="Repeat your password"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="invite-strength">
|
||||
<div className="invite-strength-head">
|
||||
<span>Password Strength</span>
|
||||
<strong>{strengthLabel}</strong>
|
||||
</div>
|
||||
<div className="invite-strength-bars">
|
||||
{[0, 1, 2, 3].map((index) => (
|
||||
<span key={index} className={index < strengthCount + 1 ? 'is-active' : ''} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="invite-checklist">
|
||||
<div className={checks.minLength ? 'is-done' : ''}>
|
||||
<span className="material-symbols-outlined">{checks.minLength ? 'check_circle' : 'circle'}</span>
|
||||
Minimum 8 characters
|
||||
</div>
|
||||
<div className={checks.hasNumber ? 'is-done' : ''}>
|
||||
<span className="material-symbols-outlined">{checks.hasNumber ? 'check_circle' : 'circle'}</span>
|
||||
At least one number
|
||||
</div>
|
||||
<div className={checks.hasSpecial ? 'is-done' : ''}>
|
||||
<span className="material-symbols-outlined">{checks.hasSpecial ? 'check_circle' : 'circle'}</span>
|
||||
One special character (@, #, $, etc.)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" className="invite-submit-button" onClick={submit} disabled={pending}>
|
||||
{pending ? 'Updating...' : 'Update Password'}
|
||||
</button>
|
||||
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
{message ? <p className="form-success">{message}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="invite-footer auth-inline-footer">
|
||||
<span>Need help?</span>
|
||||
<Link href="/login">Back to login</Link>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
530
frontend/src/components/roles-permissions-board.tsx
Normal file
530
frontend/src/components/roles-permissions-board.tsx
Normal file
@ -0,0 +1,530 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
type PermissionKey = 'view' | 'edit' | 'delete' | 'manage';
|
||||
type RoleId = string;
|
||||
type PermissionValue = boolean | null;
|
||||
|
||||
type PermissionRow = {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
values: Record<PermissionKey, PermissionValue>;
|
||||
};
|
||||
|
||||
type RoleDefinition = {
|
||||
id: RoleId;
|
||||
name: string;
|
||||
badge: string;
|
||||
tone: 'primary' | 'secondary' | 'tertiary';
|
||||
summary: string;
|
||||
usersAssigned: number;
|
||||
icon: string;
|
||||
permissionRows: PermissionRow[];
|
||||
};
|
||||
|
||||
const permissionColumns: Array<{ key: PermissionKey; label: string }> = [
|
||||
{ key: 'view', label: 'View' },
|
||||
{ key: 'edit', label: 'Create/Edit' },
|
||||
{ key: 'delete', label: 'Delete' },
|
||||
{ key: 'manage', label: 'Manage All' },
|
||||
];
|
||||
|
||||
function cloneRoleSet(roles: RoleDefinition[]) {
|
||||
return roles.map((role) => ({
|
||||
...role,
|
||||
permissionRows: role.permissionRows.map((row) => ({
|
||||
...row,
|
||||
values: { ...row.values },
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
function roleBadge(role: RoleDefinition, isEditing: boolean) {
|
||||
if (isEditing) {
|
||||
return { label: 'Editing Now', tone: 'editing' as const };
|
||||
}
|
||||
|
||||
return { label: role.badge, tone: role.tone };
|
||||
}
|
||||
|
||||
type AuditHighlight = {
|
||||
id: string;
|
||||
icon: string;
|
||||
title: string;
|
||||
description: string;
|
||||
time: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
initialRoles: RoleDefinition[];
|
||||
initialAuditHighlights: AuditHighlight[];
|
||||
};
|
||||
|
||||
export function RolesPermissionsBoard({ initialRoles, initialAuditHighlights }: Props) {
|
||||
const [roles, setRoles] = useState<RoleDefinition[]>(() => cloneRoleSet(initialRoles));
|
||||
const [savedRoles, setSavedRoles] = useState<RoleDefinition[]>(() => cloneRoleSet(initialRoles));
|
||||
const [selectedRoleId, setSelectedRoleId] = useState<RoleId>(initialRoles[0]?.id || '');
|
||||
const [editingRoleId, setEditingRoleId] = useState<RoleId | null>(null);
|
||||
const [saveMessage, setSaveMessage] = useState('');
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [newRoleName, setNewRoleName] = useState('');
|
||||
const [newRoleSummary, setNewRoleSummary] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const selectedRole = useMemo(
|
||||
() => roles.find((role) => role.id === selectedRoleId) ?? roles[0],
|
||||
[roles, selectedRoleId],
|
||||
);
|
||||
|
||||
const isEditingSelectedRole = selectedRole ? editingRoleId === selectedRole.id : false;
|
||||
|
||||
useEffect(() => {
|
||||
const nextRoles = cloneRoleSet(initialRoles);
|
||||
setRoles(nextRoles);
|
||||
setSavedRoles(nextRoles);
|
||||
setSelectedRoleId((current) =>
|
||||
nextRoles.some((role) => role.id === current) ? current : nextRoles[0]?.id || '',
|
||||
);
|
||||
setEditingRoleId((current) =>
|
||||
nextRoles.some((role) => role.id === current) ? current : null,
|
||||
);
|
||||
}, [initialRoles]);
|
||||
|
||||
function updatePermission(rowId: string, permission: PermissionKey, nextValue: boolean) {
|
||||
if (!isEditingSelectedRole) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRoles((currentRoles) =>
|
||||
currentRoles.map((role) =>
|
||||
role.id !== selectedRoleId
|
||||
? role
|
||||
: {
|
||||
...role,
|
||||
permissionRows: role.permissionRows.map((row) =>
|
||||
row.id === rowId ? { ...row, values: { ...row.values, [permission]: nextValue } } : row,
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
setSaveMessage('');
|
||||
}
|
||||
|
||||
function beginEdit(roleId: RoleId) {
|
||||
setSelectedRoleId(roleId);
|
||||
setEditingRoleId(roleId);
|
||||
setSaveMessage('');
|
||||
}
|
||||
|
||||
function resetChanges() {
|
||||
setRoles(cloneRoleSet(savedRoles));
|
||||
setEditingRoleId(null);
|
||||
setSaveMessage('Changes discarded.');
|
||||
}
|
||||
|
||||
async function saveChanges() {
|
||||
if (!selectedRole) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const response = await fetch(`/api/roles/${selectedRole.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
permissions: selectedRole.permissionRows,
|
||||
summary: selectedRole.summary,
|
||||
badge: selectedRole.badge,
|
||||
tone: selectedRole.tone,
|
||||
icon: selectedRole.icon,
|
||||
}),
|
||||
});
|
||||
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
setSaveMessage(payload.message || 'Failed to save role');
|
||||
return;
|
||||
}
|
||||
|
||||
setRoles((current) =>
|
||||
current.map((role) =>
|
||||
role.id === payload.id
|
||||
? {
|
||||
...role,
|
||||
permissionRows: payload.permissions,
|
||||
usersAssigned: payload.usersAssigned,
|
||||
summary: payload.summary,
|
||||
badge: payload.badge,
|
||||
tone: payload.tone,
|
||||
icon: payload.icon,
|
||||
}
|
||||
: role,
|
||||
),
|
||||
);
|
||||
setSavedRoles((current) =>
|
||||
current.map((role) =>
|
||||
role.id === payload.id
|
||||
? {
|
||||
...role,
|
||||
permissionRows: payload.permissions,
|
||||
usersAssigned: payload.usersAssigned,
|
||||
summary: payload.summary,
|
||||
badge: payload.badge,
|
||||
tone: payload.tone,
|
||||
icon: payload.icon,
|
||||
}
|
||||
: role,
|
||||
),
|
||||
);
|
||||
setEditingRoleId(null);
|
||||
setSaveMessage(`Permission matrix for ${payload.name} saved.`);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function createRole() {
|
||||
const trimmedName = newRoleName.trim();
|
||||
const trimmedSummary = newRoleSummary.trim();
|
||||
|
||||
if (!trimmedName) {
|
||||
setSaveMessage('Role name is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
const id =
|
||||
trimmedName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') || `role-${Date.now()}`;
|
||||
|
||||
const nextRole: RoleDefinition = {
|
||||
id,
|
||||
name: trimmedName,
|
||||
badge: 'Custom',
|
||||
tone: 'secondary',
|
||||
summary: trimmedSummary || 'Custom role for a focused operational access policy.',
|
||||
usersAssigned: 0,
|
||||
icon: 'verified_user',
|
||||
permissionRows: [
|
||||
{ id: 'campaigns', label: 'Manage Campaigns', icon: 'campaign', description: 'Broadcasts and outbound campaign controls.', values: { view: true, edit: false, delete: false, manage: false } },
|
||||
{ id: 'analytics', label: 'View Analytics', icon: 'monitoring', description: 'KPI, trends, and performance dashboards.', values: { view: true, edit: null, delete: null, manage: false } },
|
||||
{ id: 'settings', label: 'Edit Settings', icon: 'settings', description: 'Providers, secrets, and environment-facing settings.', values: { view: false, edit: false, delete: false, manage: false } },
|
||||
{ id: 'billing', label: 'Billing & Invoices', icon: 'payments', description: 'Plan usage, invoices, and billing visibility.', values: { view: false, edit: null, delete: null, manage: false } },
|
||||
],
|
||||
};
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const response = await fetch('/api/roles', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: trimmedName,
|
||||
summary: trimmedSummary || 'Custom role for a focused operational access policy.',
|
||||
badge: 'Custom',
|
||||
tone: 'secondary',
|
||||
icon: 'verified_user',
|
||||
permissions: nextRole.permissionRows,
|
||||
}),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
setSaveMessage(payload.message || 'Failed to create role');
|
||||
return;
|
||||
}
|
||||
|
||||
const createdRole = {
|
||||
id: payload.id,
|
||||
name: payload.name,
|
||||
badge: payload.badge,
|
||||
tone: payload.tone,
|
||||
summary: payload.summary,
|
||||
usersAssigned: payload.usersAssigned,
|
||||
icon: payload.icon,
|
||||
permissionRows: payload.permissions,
|
||||
} satisfies RoleDefinition;
|
||||
|
||||
const nextRoles = [createdRole, ...cloneRoleSet(roles)];
|
||||
setRoles(nextRoles);
|
||||
setSavedRoles(cloneRoleSet(nextRoles));
|
||||
setSelectedRoleId(payload.id);
|
||||
setEditingRoleId(payload.id);
|
||||
setIsCreateOpen(false);
|
||||
setNewRoleName('');
|
||||
setNewRoleSummary('');
|
||||
setSaveMessage(`Role ${payload.name} created and opened for editing.`);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!selectedRole) {
|
||||
return (
|
||||
<section className="permission-matrix-card">
|
||||
<div className="permission-matrix-head">
|
||||
<div>
|
||||
<p className="card-kicker">Roles</p>
|
||||
<h3>No roles available</h3>
|
||||
<p className="permission-matrix-copy">
|
||||
Create the first role to start defining access permissions.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="role-action-button"
|
||||
onClick={() => setIsCreateOpen(true)}
|
||||
>
|
||||
<span className="material-symbols-outlined">add_moderator</span>
|
||||
Create Role
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="page-section">
|
||||
<div className="header-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="roles-create-button"
|
||||
onClick={() => {
|
||||
setIsCreateOpen((current) => !current);
|
||||
setSaveMessage('');
|
||||
}}
|
||||
>
|
||||
<span className="material-symbols-outlined">{isCreateOpen ? 'close' : 'add_moderator'}</span>
|
||||
{isCreateOpen ? 'Hide Role Form' : 'Create New Role'}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{isCreateOpen ? (
|
||||
<section className="roles-create-panel">
|
||||
<div className="roles-create-panel-head">
|
||||
<div>
|
||||
<p className="card-kicker">New Role</p>
|
||||
<h3>Create a custom access profile</h3>
|
||||
<p className="roles-create-copy">Define a clear role name and a short summary so your team can understand the scope immediately.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="roles-create-form">
|
||||
<label className="roles-create-field">
|
||||
<span>Role name</span>
|
||||
<input type="text" value={newRoleName} onChange={(event) => setNewRoleName(event.target.value)} placeholder="Reporting Only" />
|
||||
<small>Use a short operational name like Viewer, QA Reviewer, or Billing Admin.</small>
|
||||
</label>
|
||||
<label className="roles-create-field">
|
||||
<span>Summary</span>
|
||||
<textarea value={newRoleSummary} onChange={(event) => setNewRoleSummary(event.target.value)} placeholder="Read-only access to analytics, campaign stats, and webhook summaries." rows={3} />
|
||||
<small>Describe what this role can access and the boundaries it should keep.</small>
|
||||
</label>
|
||||
</div>
|
||||
<div className="button-row roles-create-actions">
|
||||
<button type="button" className="secondary-button role-action-button" onClick={() => {
|
||||
setIsCreateOpen(false);
|
||||
setNewRoleName('');
|
||||
setNewRoleSummary('');
|
||||
}}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" className="role-action-button" onClick={createRole} disabled={isSaving}>
|
||||
Save Role
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className="roles-card-grid">
|
||||
{roles.map((role) => {
|
||||
const badge = roleBadge(role, editingRoleId === role.id);
|
||||
const isSelected = role.id === selectedRoleId;
|
||||
|
||||
return (
|
||||
<article
|
||||
key={role.id}
|
||||
className={isSelected ? 'role-card is-selected' : 'role-card'}
|
||||
onClick={() => {
|
||||
setSelectedRoleId(role.id);
|
||||
setSaveMessage('');
|
||||
}}
|
||||
aria-selected={isSelected}
|
||||
>
|
||||
<div className="role-card-head">
|
||||
<div className={`role-icon tone-${role.tone}`}>
|
||||
<span className="material-symbols-outlined">{role.icon}</span>
|
||||
</div>
|
||||
<span className={`role-badge tone-${badge.tone}`}>{badge.label}</span>
|
||||
</div>
|
||||
<h2>{role.name}</h2>
|
||||
<p>{role.summary}</p>
|
||||
<div className="role-card-meta">
|
||||
<span className="material-symbols-outlined">group</span>
|
||||
{role.usersAssigned} users assigned
|
||||
</div>
|
||||
<div className="role-card-actions">
|
||||
<button
|
||||
type="button"
|
||||
className={editingRoleId === role.id ? 'role-inline-button is-active' : 'role-inline-button'}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
beginEdit(role.id);
|
||||
}}
|
||||
>
|
||||
<span className="material-symbols-outlined">edit</span>
|
||||
{editingRoleId === role.id ? 'Editing' : 'Edit'}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</section>
|
||||
|
||||
<section className="permission-matrix-card">
|
||||
<div className="permission-matrix-head">
|
||||
<div>
|
||||
<p className="card-kicker">Permission Matrix</p>
|
||||
<h3>
|
||||
{isEditingSelectedRole ? 'Editing' : 'Viewing'} <span>{selectedRole.name}</span>
|
||||
</h3>
|
||||
<p className="permission-matrix-copy">
|
||||
{isEditingSelectedRole
|
||||
? 'Toggle permissions below, then save the matrix to persist your changes.'
|
||||
: 'This matrix is read-only until you click Edit on the selected role card.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="button-row">
|
||||
{isEditingSelectedRole ? (
|
||||
<>
|
||||
<button type="button" className="secondary-button role-action-button" onClick={resetChanges} disabled={isSaving}>
|
||||
Discard
|
||||
</button>
|
||||
<button type="button" className="role-action-button" onClick={saveChanges} disabled={isSaving}>
|
||||
Save Changes
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button type="button" className="role-action-button" onClick={() => beginEdit(selectedRole.id)}>
|
||||
<span className="material-symbols-outlined">edit</span>
|
||||
Edit Role
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{saveMessage ? <p className="roles-feedback">{saveMessage}</p> : null}
|
||||
<div className="permission-table-wrap">
|
||||
<table className="permission-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Module / Permission</th>
|
||||
{permissionColumns.map((column) => (
|
||||
<th key={column.key}>{column.label}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{selectedRole.permissionRows.map((row) => (
|
||||
<tr key={row.id}>
|
||||
<td>
|
||||
<div className="permission-label">
|
||||
<span className="material-symbols-outlined">{row.icon}</span>
|
||||
<div>
|
||||
<strong>{row.label}</strong>
|
||||
<small>{row.description}</small>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{permissionColumns.map((column) => {
|
||||
const value = row.values[column.key];
|
||||
if (value === null) {
|
||||
return (
|
||||
<td key={column.key} className="permission-unavailable">
|
||||
—
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isEditingSelectedRole) {
|
||||
return (
|
||||
<td key={column.key}>
|
||||
<span className={value ? 'permission-indicator is-on' : 'permission-indicator is-off'}>
|
||||
{value ? 'On' : 'Off'}
|
||||
</span>
|
||||
</td>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<td key={column.key}>
|
||||
<label className="toggle-switch">
|
||||
<input type="checkbox" checked={value} onChange={(event) => updatePermission(row.id, column.key, event.target.checked)} />
|
||||
<span className="toggle-track">
|
||||
<span className="toggle-thumb" />
|
||||
</span>
|
||||
</label>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="roles-bottom-grid">
|
||||
<article className="surface-card">
|
||||
<div className="card-head">
|
||||
<div>
|
||||
<p className="card-kicker">Audit Trail</p>
|
||||
<h3 className="roles-section-title">Recent Changes</h3>
|
||||
</div>
|
||||
<a href="/dashboard/settings/audit-trail" className="roles-inline-link">
|
||||
View All
|
||||
</a>
|
||||
</div>
|
||||
<div className="roles-audit-list">
|
||||
{initialAuditHighlights.map((entry) => (
|
||||
<div key={entry.id} className="roles-audit-item">
|
||||
<div className="roles-audit-icon">
|
||||
<span className="material-symbols-outlined">{entry.icon}</span>
|
||||
</div>
|
||||
<div className="roles-audit-copy">
|
||||
<strong>{entry.title}</strong>
|
||||
<p>{entry.description}</p>
|
||||
</div>
|
||||
<span className="roles-audit-time">{entry.time}</span>
|
||||
</div>
|
||||
))}
|
||||
{initialAuditHighlights.length === 0 ? (
|
||||
<div className="roles-audit-item">
|
||||
<div className="roles-audit-icon">
|
||||
<span className="material-symbols-outlined">history</span>
|
||||
</div>
|
||||
<div className="roles-audit-copy">
|
||||
<strong>No role changes yet</strong>
|
||||
<p>Role create and edit actions will appear here once they are recorded.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<aside className="roles-help-card">
|
||||
<div className="roles-help-copy">
|
||||
<p className="card-kicker">Security Guidance</p>
|
||||
<h3>Need help?</h3>
|
||||
<p>Learn how to set granular access boundaries for enterprise teams with higher security requirements.</p>
|
||||
<a href="/dashboard/settings/security" className="roles-help-button">
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
<span className="material-symbols-outlined roles-help-mark">lock_person</span>
|
||||
</aside>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
90
frontend/src/components/security-session-card.tsx
Normal file
90
frontend/src/components/security-session-card.tsx
Normal file
@ -0,0 +1,90 @@
|
||||
'use client';
|
||||
|
||||
import { logoutAction } from '../app/actions';
|
||||
|
||||
type Props = {
|
||||
session: {
|
||||
user: {
|
||||
name: string;
|
||||
email: string;
|
||||
status: string;
|
||||
lastLoginAt: string | null;
|
||||
twoFactorEnabled: boolean;
|
||||
twoFactorConfirmedAt: string | null;
|
||||
};
|
||||
session: {
|
||||
issuedAt: string | null;
|
||||
expiresAt: string | null;
|
||||
refreshExpiresAt: string | null;
|
||||
currentIp: string | null;
|
||||
policy: 'single-session';
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
function formatDateTime(value: string | null) {
|
||||
if (!value) return 'Not available';
|
||||
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
export function SecuritySessionCard({ session }: Props) {
|
||||
return (
|
||||
<article className="surface-card security-session-card">
|
||||
<div className="security-session-head">
|
||||
<div>
|
||||
<p className="page-eyebrow">Session</p>
|
||||
<h2>Current Admin Session</h2>
|
||||
<p className="page-copy">
|
||||
BizOne currently enforces a single active admin session. A fresh login rotates tokens and replaces the
|
||||
previous session.
|
||||
</p>
|
||||
</div>
|
||||
<span className="security-session-badge">Single Session Policy</span>
|
||||
</div>
|
||||
|
||||
<div className="security-session-grid">
|
||||
<div className="security-session-item">
|
||||
<strong>Signed in as</strong>
|
||||
<span>{session.user.name}</span>
|
||||
<small>{session.user.email}</small>
|
||||
</div>
|
||||
<div className="security-session-item">
|
||||
<strong>Access token issued</strong>
|
||||
<span>{formatDateTime(session.session.issuedAt)}</span>
|
||||
</div>
|
||||
<div className="security-session-item">
|
||||
<strong>Access token expires</strong>
|
||||
<span>{formatDateTime(session.session.expiresAt)}</span>
|
||||
</div>
|
||||
<div className="security-session-item">
|
||||
<strong>Refresh token expires</strong>
|
||||
<span>{formatDateTime(session.session.refreshExpiresAt)}</span>
|
||||
</div>
|
||||
<div className="security-session-item">
|
||||
<strong>Current IP</strong>
|
||||
<span>{session.session.currentIp || 'Unavailable'}</span>
|
||||
</div>
|
||||
<div className="security-session-item">
|
||||
<strong>Last successful login</strong>
|
||||
<span>{formatDateTime(session.user.lastLoginAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="security-session-actions">
|
||||
<form action={logoutAction}>
|
||||
<button type="submit" className="invite-submit-button">
|
||||
Sign Out and Revoke Session
|
||||
</button>
|
||||
</form>
|
||||
<p className="page-copy">
|
||||
Use this if you suspect account exposure. Signing out here invalidates the active refresh token and forces a
|
||||
fresh login.
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
149
frontend/src/components/set-password-card.tsx
Normal file
149
frontend/src/components/set-password-card.tsx
Normal file
@ -0,0 +1,149 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
type Props = {
|
||||
token: string;
|
||||
invitation: {
|
||||
email: string;
|
||||
name: string;
|
||||
roleName: string;
|
||||
expiresAt: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
function passwordChecks(password: string) {
|
||||
return {
|
||||
minLength: password.length >= 8,
|
||||
hasNumber: /\d/.test(password),
|
||||
hasSpecial: /[^A-Za-z0-9]/.test(password),
|
||||
};
|
||||
}
|
||||
|
||||
export function SetPasswordCard({ token, invitation }: Props) {
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
const checks = useMemo(() => passwordChecks(password), [password]);
|
||||
const strengthCount = [checks.minLength, checks.hasNumber, checks.hasSpecial].filter(Boolean).length;
|
||||
const strengthLabel = strengthCount <= 1 ? 'Weak' : strengthCount === 2 ? 'Medium' : 'Strong';
|
||||
|
||||
async function submit() {
|
||||
setError('');
|
||||
setMessage('');
|
||||
if (password !== confirmPassword) {
|
||||
setError('Password confirmation does not match.');
|
||||
return;
|
||||
}
|
||||
|
||||
setPending(true);
|
||||
try {
|
||||
const response = await fetch(`/api/invitations/${token}/complete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
setError(payload.message || 'Failed to activate account');
|
||||
return;
|
||||
}
|
||||
|
||||
setMessage('Account activated. You can now log in with your email and password.');
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="invite-page">
|
||||
<div className="invite-brand">
|
||||
<div className="invite-brand-mark">
|
||||
<span className="material-symbols-outlined">chat</span>
|
||||
</div>
|
||||
<span>WA Business</span>
|
||||
</div>
|
||||
|
||||
<main className="invite-card">
|
||||
<div className="invite-card-head">
|
||||
<h1>Set Your Password</h1>
|
||||
<p>
|
||||
Welcome, {invitation.name}. Create a secure password to activate your account for{' '}
|
||||
<strong>{invitation.email}</strong> as <strong>{invitation.roleName}</strong>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="invite-form">
|
||||
<label className="invite-field">
|
||||
<span>New Password</span>
|
||||
<div className="invite-password-wrap">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="Enter your password"
|
||||
/>
|
||||
<button type="button" onClick={() => setShowPassword((current) => !current)}>
|
||||
<span className="material-symbols-outlined">{showPassword ? 'visibility_off' : 'visibility'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className="invite-field">
|
||||
<span>Confirm New Password</span>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(event) => setConfirmPassword(event.target.value)}
|
||||
placeholder="Repeat your password"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="invite-strength">
|
||||
<div className="invite-strength-head">
|
||||
<span>Password Strength</span>
|
||||
<strong>{strengthLabel}</strong>
|
||||
</div>
|
||||
<div className="invite-strength-bars">
|
||||
{[0, 1, 2, 3].map((index) => (
|
||||
<span key={index} className={index < strengthCount + 1 ? 'is-active' : ''} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="invite-checklist">
|
||||
<div className={checks.minLength ? 'is-done' : ''}>
|
||||
<span className="material-symbols-outlined">{checks.minLength ? 'check_circle' : 'circle'}</span>
|
||||
Minimum 8 characters
|
||||
</div>
|
||||
<div className={checks.hasNumber ? 'is-done' : ''}>
|
||||
<span className="material-symbols-outlined">{checks.hasNumber ? 'check_circle' : 'circle'}</span>
|
||||
At least one number
|
||||
</div>
|
||||
<div className={checks.hasSpecial ? 'is-done' : ''}>
|
||||
<span className="material-symbols-outlined">{checks.hasSpecial ? 'check_circle' : 'circle'}</span>
|
||||
One special character (@, #, $, etc.)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" className="invite-submit-button" onClick={submit} disabled={pending}>
|
||||
{pending ? 'Activating...' : 'Activate Account'}
|
||||
</button>
|
||||
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
{message ? <p className="form-success">{message}</p> : null}
|
||||
</div>
|
||||
|
||||
<div className="invite-footer">
|
||||
<span>Need help? Contact your admin or</span>
|
||||
<Link href="/login">go to login</Link>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
372
frontend/src/components/template-builder-form.tsx
Normal file
372
frontend/src/components/template-builder-form.tsx
Normal file
@ -0,0 +1,372 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
type TemplateButton = {
|
||||
type: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type TemplateRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
status: string;
|
||||
language: string;
|
||||
headerText: string | null;
|
||||
bodyText: string;
|
||||
footerText: string | null;
|
||||
buttons: TemplateButton[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
initialTemplate: TemplateRecord | null;
|
||||
};
|
||||
|
||||
const sampleVariableValues = ['Alex', 'SUMMER50', 'Order-481', 'May 15', 'BizOne'];
|
||||
|
||||
function substituteVariables(input: string) {
|
||||
return input.replace(/\{\{(\d+)\}\}/g, (_, token) => {
|
||||
const index = Number(token) - 1;
|
||||
return `[${sampleVariableValues[index] || `Value ${token}`}]`;
|
||||
});
|
||||
}
|
||||
|
||||
function countTemplateVariables(input: string) {
|
||||
return new Set(input.match(/\{\{\d+\}\}/g) || []).size;
|
||||
}
|
||||
|
||||
export function TemplateBuilderForm({ initialTemplate }: Props) {
|
||||
const router = useRouter();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [success, setSuccess] = useState('');
|
||||
const [form, setForm] = useState({
|
||||
name: initialTemplate?.name || '',
|
||||
category: initialTemplate?.category || 'Marketing',
|
||||
status: initialTemplate?.status || 'Draft',
|
||||
language: initialTemplate?.language || 'en_US',
|
||||
headerText: initialTemplate?.headerText || '',
|
||||
bodyText:
|
||||
initialTemplate?.bodyText ||
|
||||
`Hi {{1}}, our Summer Sale is finally here! 🌴
|
||||
|
||||
Get up to 50% OFF on all collections using code {{2}} at checkout.
|
||||
|
||||
Shop now: https://example.com/shop`,
|
||||
footerText: initialTemplate?.footerText || '',
|
||||
buttons:
|
||||
initialTemplate?.buttons?.length
|
||||
? initialTemplate.buttons
|
||||
: [{ type: 'quick_reply', label: 'Stop promotions' }],
|
||||
});
|
||||
|
||||
const variableCount = useMemo(() => countTemplateVariables(form.bodyText), [form.bodyText]);
|
||||
const previewBody = useMemo(() => substituteVariables(form.bodyText), [form.bodyText]);
|
||||
const previewHeader = useMemo(() => substituteVariables(form.headerText), [form.headerText]);
|
||||
const previewFooter = useMemo(() => substituteVariables(form.footerText), [form.footerText]);
|
||||
|
||||
async function submit(intent: 'draft' | 'submit') {
|
||||
setIsSaving(true);
|
||||
setError('');
|
||||
setSuccess('');
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
...form,
|
||||
status: intent === 'submit' ? 'Pending' : 'Draft',
|
||||
headerText: form.headerText.trim() || undefined,
|
||||
footerText: form.footerText.trim() || undefined,
|
||||
buttons: form.buttons
|
||||
.map((button) => ({
|
||||
type: button.type || 'quick_reply',
|
||||
label: button.label.trim(),
|
||||
}))
|
||||
.filter((button) => button.label.length > 0),
|
||||
};
|
||||
|
||||
const response = await fetch(initialTemplate ? `/api/templates/${initialTemplate.id}` : '/api/templates', {
|
||||
method: initialTemplate ? 'PATCH' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!response.ok) {
|
||||
const message = typeof result?.message === 'string' ? result.message : 'Failed to save template';
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
setSuccess(intent === 'submit' ? 'Template submitted for approval.' : 'Draft saved.');
|
||||
|
||||
if (!initialTemplate?.id && result?.id) {
|
||||
router.push(`/dashboard/templates/builder?id=${result.id}`);
|
||||
} else {
|
||||
router.refresh();
|
||||
}
|
||||
} catch (submitError) {
|
||||
setError(submitError instanceof Error ? submitError.message : 'Failed to save template');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="template-builder-page">
|
||||
<header className="template-builder-hero">
|
||||
<div>
|
||||
<h1 className="template-builder-heading">
|
||||
{initialTemplate ? 'Edit Message Template' : 'Create Message Template'}
|
||||
</h1>
|
||||
<p className="template-builder-copy">
|
||||
Design and manage your business messages before they are approved for outbound delivery.
|
||||
</p>
|
||||
</div>
|
||||
<div className="template-builder-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="template-builder-draft-button"
|
||||
disabled={isSaving}
|
||||
onClick={() => submit('draft')}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Save Draft'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="template-builder-submit-button"
|
||||
disabled={isSaving}
|
||||
onClick={() => submit('submit')}
|
||||
>
|
||||
{isSaving ? 'Saving...' : 'Submit for Approval'}
|
||||
<span className="material-symbols-outlined">send</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error ? <p className="form-error">{error}</p> : null}
|
||||
{success ? <p className="form-success">{success}</p> : null}
|
||||
|
||||
<section className="template-builder-grid">
|
||||
<div className="template-builder-form-column">
|
||||
<article className="surface-card template-builder-card">
|
||||
<div className="template-builder-card-head">
|
||||
<span className="material-symbols-outlined">edit_note</span>
|
||||
<h2>Basic Details</h2>
|
||||
</div>
|
||||
<div className="template-builder-basic-grid">
|
||||
<label className="template-builder-field template-builder-field-full">
|
||||
<span>Template Name</span>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
|
||||
placeholder="e.g. welcome_message"
|
||||
/>
|
||||
</label>
|
||||
<label className="template-builder-field">
|
||||
<span>Category</span>
|
||||
<select
|
||||
value={form.category}
|
||||
onChange={(event) => setForm((current) => ({ ...current, category: event.target.value }))}
|
||||
>
|
||||
<option value="Marketing">Marketing</option>
|
||||
<option value="Utility">Utility</option>
|
||||
<option value="Authentication">Authentication</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="template-builder-field">
|
||||
<span>Language</span>
|
||||
<select
|
||||
value={form.language}
|
||||
onChange={(event) => setForm((current) => ({ ...current, language: event.target.value }))}
|
||||
>
|
||||
<option value="en_US">English (US)</option>
|
||||
<option value="id_ID">Bahasa Indonesia</option>
|
||||
<option value="pt_BR">Portuguese (BR)</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="surface-card template-builder-card">
|
||||
<div className="template-builder-card-topline">
|
||||
<div className="template-builder-card-head">
|
||||
<span className="material-symbols-outlined">subject</span>
|
||||
<h2>Message Content</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="template-builder-content-stack">
|
||||
<label className="template-builder-field">
|
||||
<span>Header (Optional)</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Add a title or choose media"
|
||||
value={form.headerText}
|
||||
onChange={(event) => setForm((current) => ({ ...current, headerText: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="template-builder-field">
|
||||
<span>Body Text</span>
|
||||
<textarea
|
||||
rows={8}
|
||||
value={form.bodyText}
|
||||
onChange={(event) => setForm((current) => ({ ...current, bodyText: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<div className="template-builder-inline-meta">
|
||||
<span>Variables detected: {variableCount}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
bodyText: `${current.bodyText}${current.bodyText.endsWith(' ') || current.bodyText.endsWith('\n') ? '' : ' '}{{${variableCount + 1}}}`,
|
||||
}))
|
||||
}
|
||||
>
|
||||
Add Variable
|
||||
</button>
|
||||
</div>
|
||||
<label className="template-builder-field">
|
||||
<span>Footer (Optional)</span>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Add a short line of text"
|
||||
value={form.footerText}
|
||||
onChange={(event) => setForm((current) => ({ ...current, footerText: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="surface-card template-builder-card">
|
||||
<div className="template-builder-card-head">
|
||||
<span className="material-symbols-outlined">smart_button</span>
|
||||
<h2>Buttons</h2>
|
||||
</div>
|
||||
<div className="template-builder-button-stack">
|
||||
{form.buttons.map((button, index) => (
|
||||
<div key={`${index}-${button.label}`} className="template-builder-button-row">
|
||||
<span className="material-symbols-outlined">ads_click</span>
|
||||
<div>
|
||||
<strong>Quick Reply</strong>
|
||||
<input
|
||||
type="text"
|
||||
value={button.label}
|
||||
onChange={(event) =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
buttons: current.buttons.map((item, itemIndex) =>
|
||||
itemIndex === index ? { ...item, label: event.target.value } : item,
|
||||
),
|
||||
}))
|
||||
}
|
||||
placeholder="Button label"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="template-builder-delete-button"
|
||||
aria-label="Delete quick reply"
|
||||
onClick={() =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
buttons: current.buttons.filter((_, itemIndex) => itemIndex !== index),
|
||||
}))
|
||||
}
|
||||
>
|
||||
<span className="material-symbols-outlined">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="template-builder-add-button"
|
||||
onClick={() =>
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
buttons: [...current.buttons, { type: 'quick_reply', label: '' }].slice(0, 10),
|
||||
}))
|
||||
}
|
||||
>
|
||||
<span className="material-symbols-outlined">add</span>
|
||||
Add Button (Max 10)
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<aside className="template-builder-preview-column">
|
||||
<div className="template-builder-preview-shell">
|
||||
<div className="template-builder-phone-frame">
|
||||
<div className="template-builder-phone-status">
|
||||
<span>9:41</span>
|
||||
<div>
|
||||
<span className="material-symbols-outlined">signal_cellular_4_bar</span>
|
||||
<span className="material-symbols-outlined">wifi</span>
|
||||
<span className="material-symbols-outlined">battery_full</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="template-builder-phone-screen">
|
||||
<div className="template-builder-phone-header">
|
||||
<span className="material-symbols-outlined">arrow_back</span>
|
||||
<div className="template-builder-phone-avatar">Y</div>
|
||||
<div className="template-builder-phone-contact">
|
||||
<strong>Your Business</strong>
|
||||
<span>online</span>
|
||||
</div>
|
||||
<span className="material-symbols-outlined">videocam</span>
|
||||
<span className="material-symbols-outlined">call</span>
|
||||
<span className="material-symbols-outlined">more_vert</span>
|
||||
</div>
|
||||
|
||||
<div className="template-builder-chat-area">
|
||||
<div className="template-builder-chat-date">TODAY</div>
|
||||
<div className="template-builder-chat-stack">
|
||||
<div className="template-builder-chat-bubble">
|
||||
{previewHeader ? <strong>{previewHeader}</strong> : null}
|
||||
<p>{previewBody}</p>
|
||||
{previewFooter ? <small>{previewFooter}</small> : null}
|
||||
<div className="template-builder-chat-meta">
|
||||
<small>09:41 AM</small>
|
||||
<span className="material-symbols-outlined">done_all</span>
|
||||
</div>
|
||||
</div>
|
||||
{form.buttons
|
||||
.filter((button) => button.label.trim().length > 0)
|
||||
.map((button, index) => (
|
||||
<button key={`${button.label}-${index}`} type="button" className="template-builder-chat-reply">
|
||||
<span className="material-symbols-outlined">reply</span>
|
||||
{button.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="template-builder-input-row">
|
||||
<div className="template-builder-input-shell">
|
||||
<span className="material-symbols-outlined">mood</span>
|
||||
<span className="template-builder-input-placeholder">Message</span>
|
||||
<span className="material-symbols-outlined">attach_file</span>
|
||||
<span className="material-symbols-outlined">photo_camera</span>
|
||||
</div>
|
||||
<div className="template-builder-mic-button">
|
||||
<span className="material-symbols-outlined">mic</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="template-builder-preview-toggle">
|
||||
<button type="button" className="is-active">
|
||||
Mobile
|
||||
</button>
|
||||
<button type="button">Desktop</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
81
frontend/src/components/two-factor-login-card.tsx
Normal file
81
frontend/src/components/two-factor-login-card.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useActionState } from 'react';
|
||||
import { verifyTwoFactorLoginAction } from '../app/actions';
|
||||
|
||||
type Props = {
|
||||
email: string;
|
||||
languageSwitcher: ReactNode;
|
||||
};
|
||||
|
||||
const initialState: { error?: string } = {};
|
||||
|
||||
export function TwoFactorLoginCard({ email, languageSwitcher }: Props) {
|
||||
const [state, formAction, pending] = useActionState(verifyTwoFactorLoginAction, initialState);
|
||||
|
||||
return (
|
||||
<main className="auth-page auth-page-enterprise auth-page-login">
|
||||
<div className="auth-enterprise-glow auth-enterprise-glow-right" />
|
||||
<div className="auth-enterprise-glow auth-enterprise-glow-left" />
|
||||
<div className="auth-page-symbol auth-page-symbol-top">
|
||||
<span className="material-symbols-outlined">shield_lock</span>
|
||||
</div>
|
||||
<div className="auth-page-symbol auth-page-symbol-bottom">
|
||||
<span className="material-symbols-outlined">qr_code_2</span>
|
||||
</div>
|
||||
|
||||
<section className="two-factor-shell auth-container-login">
|
||||
<div className="auth-login-toolbar">
|
||||
<div className="auth-login-locale">{languageSwitcher}</div>
|
||||
</div>
|
||||
|
||||
<header className="two-factor-brand">
|
||||
<div className="two-factor-mark">
|
||||
<span className="material-symbols-outlined">shield_lock</span>
|
||||
</div>
|
||||
<div className="auth-brand-copy">
|
||||
<h1>BizOne</h1>
|
||||
<p>Verify the final step of your admin login with a TOTP code or a recovery code.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="two-factor-card">
|
||||
<div className="two-factor-copy auth-public-copy">
|
||||
<h2>Enter Authenticator Code</h2>
|
||||
<p>
|
||||
Complete login for <strong>{email}</strong> with the 6-digit code from your authenticator app or a
|
||||
one-time backup code.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form action={formAction} className="invite-form">
|
||||
<label className="invite-field">
|
||||
<span>Authentication or Backup Code</span>
|
||||
<input
|
||||
name="code"
|
||||
type="text"
|
||||
inputMode="text"
|
||||
pattern="(\d{6}|[A-Za-z0-9]{4}-?[A-Za-z0-9]{4})"
|
||||
maxLength={9}
|
||||
placeholder="123456 or ABCD-EF12"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button type="submit" className="invite-submit-button" disabled={pending}>
|
||||
{pending ? 'Verifying...' : 'Verify and Continue'}
|
||||
</button>
|
||||
|
||||
{state.error ? <p className="form-error">{state.error}</p> : null}
|
||||
</form>
|
||||
|
||||
<div className="two-factor-meta">
|
||||
<span className="auth-public-meta">Backup codes work once and should be stored offline.</span>
|
||||
<Link href="/login" className="text-link">Back to Login</Link>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
111
frontend/src/components/two-factor-placeholder.tsx
Normal file
111
frontend/src/components/two-factor-placeholder.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
import Link from 'next/link';
|
||||
|
||||
type Props = {
|
||||
appName: string;
|
||||
labels: {
|
||||
title: string;
|
||||
description: string;
|
||||
verify: string;
|
||||
backToLogin: string;
|
||||
needHelp: string;
|
||||
appTitle: string;
|
||||
appDescription: string;
|
||||
securityTitle: string;
|
||||
securityDescription: string;
|
||||
placeholderBadge: string;
|
||||
placeholderBody: string;
|
||||
};
|
||||
};
|
||||
|
||||
function ShieldIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M12 3 5 6v5c0 4.7 2.8 8.9 7 10 4.2-1.1 7-5.3 7-10V6l-7-3Z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="M8 2h8a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2Zm4 17.5a1 1 0 1 0 0 .01Z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function VerifiedIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<path d="m10 14.8-2.8-2.8-1.4 1.4L10 17.6l8-8-1.4-1.4-6.6 6.6Z" fill="currentColor" />
|
||||
<path d="M12 2 4 5v6c0 5.2 3.3 9.8 8 11 4.7-1.2 8-5.8 8-11V5l-8-3Z" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TwoFactorPlaceholder({ appName, labels }: Props) {
|
||||
return (
|
||||
<main className="auth-page auth-page-enterprise">
|
||||
<section className="two-factor-shell">
|
||||
<header className="two-factor-brand">
|
||||
<div className="two-factor-mark">
|
||||
<ShieldIcon />
|
||||
</div>
|
||||
<h1>{appName}</h1>
|
||||
<p>{labels.placeholderBadge}</p>
|
||||
</header>
|
||||
|
||||
<section className="two-factor-card">
|
||||
<div className="two-factor-copy">
|
||||
<h2>{labels.title}</h2>
|
||||
<p>{labels.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="otp-row" aria-hidden="true">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<div key={index} className="otp-box">0</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button type="button" className="auth-submit" disabled>
|
||||
{labels.verify}
|
||||
</button>
|
||||
|
||||
<div className="two-factor-meta">
|
||||
<Link href="/login" className="text-link">
|
||||
{labels.backToLogin}
|
||||
</Link>
|
||||
<button type="button" className="text-link">
|
||||
{labels.needHelp}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="placeholder-note">
|
||||
<strong>{labels.placeholderBadge}</strong>
|
||||
<p>{labels.placeholderBody}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="two-factor-info-grid">
|
||||
<article className="security-info-card">
|
||||
<span className="security-icon">
|
||||
<DeviceIcon />
|
||||
</span>
|
||||
<div>
|
||||
<h3>{labels.appTitle}</h3>
|
||||
<p>{labels.appDescription}</p>
|
||||
</div>
|
||||
</article>
|
||||
<article className="security-info-card">
|
||||
<span className="security-icon">
|
||||
<VerifiedIcon />
|
||||
</span>
|
||||
<div>
|
||||
<h3>{labels.securityTitle}</h3>
|
||||
<p>{labels.securityDescription}</p>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
200
frontend/src/components/two-factor-settings-card.tsx
Normal file
200
frontend/src/components/two-factor-settings-card.tsx
Normal file
@ -0,0 +1,200 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState, useMemo } from 'react';
|
||||
import {
|
||||
confirmTwoFactorSetupAction,
|
||||
disableTwoFactorAction,
|
||||
initiateTwoFactorSetupAction,
|
||||
regenerateTwoFactorRecoveryCodesAction,
|
||||
} from '../app/actions';
|
||||
|
||||
type Props = {
|
||||
status: {
|
||||
enabled: boolean;
|
||||
pendingSetup: boolean;
|
||||
confirmedAt: string | null;
|
||||
recoveryCodesRemaining: number;
|
||||
};
|
||||
};
|
||||
|
||||
const initialState: {
|
||||
error?: string;
|
||||
success?: string;
|
||||
manualEntryKey?: string;
|
||||
otpauthUrl?: string;
|
||||
qrCodeDataUrl?: string;
|
||||
recoveryCodes?: string[];
|
||||
} = {};
|
||||
|
||||
function RecoveryCodesPanel({ codes, title }: { codes: string[]; title: string }) {
|
||||
if (codes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="invite-form">
|
||||
<strong>{title}</strong>
|
||||
<p className="page-copy">Simpan kode ini sekarang. Setiap kode hanya bisa dipakai sekali saat login darurat.</p>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))',
|
||||
gap: '0.75rem',
|
||||
}}
|
||||
>
|
||||
{codes.map((code) => (
|
||||
<div
|
||||
key={code}
|
||||
style={{
|
||||
border: '1px solid var(--border-subtle)',
|
||||
borderRadius: '0.9rem',
|
||||
padding: '0.8rem 1rem',
|
||||
background: 'var(--surface-muted)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.08em',
|
||||
}}
|
||||
>
|
||||
{code}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TwoFactorSettingsCard({ status }: Props) {
|
||||
const [setupState, setupAction, setupPending] = useActionState(initiateTwoFactorSetupAction, initialState);
|
||||
const [confirmState, confirmAction, confirmPending] = useActionState(confirmTwoFactorSetupAction, initialState);
|
||||
const [regenerateState, regenerateAction, regeneratePending] = useActionState(
|
||||
regenerateTwoFactorRecoveryCodesAction,
|
||||
initialState,
|
||||
);
|
||||
const [disableState, disableAction, disablePending] = useActionState(disableTwoFactorAction, initialState);
|
||||
|
||||
const manualEntryKey = setupState.manualEntryKey;
|
||||
const otpauthUrl = setupState.otpauthUrl;
|
||||
const qrCodeDataUrl = setupState.qrCodeDataUrl;
|
||||
const hasPendingSecret = status.pendingSetup || Boolean(manualEntryKey);
|
||||
const confirmedLabel = useMemo(() => {
|
||||
if (!status.confirmedAt) return 'Not enabled';
|
||||
return new Date(status.confirmedAt).toLocaleString();
|
||||
}, [status.confirmedAt]);
|
||||
|
||||
return (
|
||||
<article className="surface-card">
|
||||
<h2>Two-Factor Authentication</h2>
|
||||
<p className="page-copy">
|
||||
Protect admin login with a time-based code from Google Authenticator, 1Password, or another TOTP app.
|
||||
</p>
|
||||
|
||||
<div className="metric-stack">
|
||||
<div>
|
||||
<strong>Status</strong>
|
||||
<span>{status.enabled ? 'Enabled' : 'Disabled'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Confirmed At</strong>
|
||||
<span>{confirmedLabel}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Backup Codes Remaining</strong>
|
||||
<span>{status.recoveryCodesRemaining}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!status.enabled ? (
|
||||
<form action={setupAction} className="invite-form">
|
||||
<button type="submit" className="invite-submit-button" disabled={setupPending}>
|
||||
{setupPending ? 'Preparing...' : 'Start 2FA Setup'}
|
||||
</button>
|
||||
{setupState.error ? <p className="form-error">{setupState.error}</p> : null}
|
||||
{setupState.success ? <p className="form-success">{setupState.success}</p> : null}
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{hasPendingSecret && !status.enabled ? (
|
||||
<div className="invite-form">
|
||||
{qrCodeDataUrl ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
justifyItems: 'start',
|
||||
gap: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={qrCodeDataUrl}
|
||||
alt="Scan this QR code in your authenticator app"
|
||||
width={220}
|
||||
height={220}
|
||||
style={{ borderRadius: '1rem', border: '1px solid var(--border-subtle)', background: '#fff' }}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<label className="invite-field">
|
||||
<span>Manual Entry Key</span>
|
||||
<input type="text" readOnly value={manualEntryKey || ''} />
|
||||
</label>
|
||||
{otpauthUrl ? (
|
||||
<label className="invite-field">
|
||||
<span>OTPAuth URL</span>
|
||||
<textarea readOnly rows={4} value={otpauthUrl} />
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
<form action={confirmAction} className="invite-form">
|
||||
<label className="invite-field">
|
||||
<span>Verification Code</span>
|
||||
<input name="code" type="text" inputMode="numeric" pattern="[0-9]{6}" maxLength={6} placeholder="123456" />
|
||||
</label>
|
||||
<button type="submit" className="invite-submit-button" disabled={confirmPending}>
|
||||
{confirmPending ? 'Verifying...' : 'Confirm and Enable 2FA'}
|
||||
</button>
|
||||
{confirmState.error ? <p className="form-error">{confirmState.error}</p> : null}
|
||||
{confirmState.success ? <p className="form-success">{confirmState.success}</p> : null}
|
||||
</form>
|
||||
|
||||
<RecoveryCodesPanel
|
||||
codes={confirmState.recoveryCodes || []}
|
||||
title="Backup Recovery Codes"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{status.enabled ? (
|
||||
<>
|
||||
<form action={regenerateAction} className="invite-form">
|
||||
<label className="invite-field">
|
||||
<span>Current 2FA Code to Regenerate Backup Codes</span>
|
||||
<input name="code" type="text" inputMode="numeric" pattern="[0-9]{6}" maxLength={6} placeholder="123456" />
|
||||
</label>
|
||||
<button type="submit" className="invite-submit-button" disabled={regeneratePending}>
|
||||
{regeneratePending ? 'Regenerating...' : 'Regenerate Backup Codes'}
|
||||
</button>
|
||||
{regenerateState.error ? <p className="form-error">{regenerateState.error}</p> : null}
|
||||
{regenerateState.success ? <p className="form-success">{regenerateState.success}</p> : null}
|
||||
</form>
|
||||
|
||||
<RecoveryCodesPanel
|
||||
codes={regenerateState.recoveryCodes || []}
|
||||
title="New Backup Recovery Codes"
|
||||
/>
|
||||
|
||||
<form action={disableAction} className="invite-form">
|
||||
<label className="invite-field">
|
||||
<span>Current 2FA Code</span>
|
||||
<input name="code" type="text" inputMode="numeric" pattern="[0-9]{6}" maxLength={6} placeholder="123456" />
|
||||
</label>
|
||||
<button type="submit" className="invite-submit-button" disabled={disablePending}>
|
||||
{disablePending ? 'Disabling...' : 'Disable 2FA'}
|
||||
</button>
|
||||
{disableState.error ? <p className="form-error">{disableState.error}</p> : null}
|
||||
{disableState.success ? <p className="form-success">{disableState.success}</p> : null}
|
||||
</form>
|
||||
</>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
770
frontend/src/components/users-management-board.tsx
Normal file
770
frontend/src/components/users-management-board.tsx
Normal file
@ -0,0 +1,770 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
type UserRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
status: 'invited' | 'active' | 'inactive' | 'suspended';
|
||||
roleId: string | null;
|
||||
roleName: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastLoginAt: string | null;
|
||||
emailVerifiedAt: string | null;
|
||||
};
|
||||
|
||||
type RoleOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
initialUsers: UserRecord[];
|
||||
initialTotal: number;
|
||||
initialPage: number;
|
||||
initialPageSize: number;
|
||||
initialTotalPages: number;
|
||||
availableRoles: RoleOption[];
|
||||
};
|
||||
|
||||
type PanelMode = 'create' | 'edit' | null;
|
||||
|
||||
function formatLastSeen(value: string | null, status: UserRecord['status']) {
|
||||
if (status === 'invited') return 'Pending invite';
|
||||
if (!value) return 'Never';
|
||||
|
||||
const diffMs = Date.now() - new Date(value).getTime();
|
||||
const minutes = Math.max(1, Math.floor(diffMs / 60000));
|
||||
if (minutes < 60) return 'Just now';
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days} day${days === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
|
||||
function formatStatusLabel(status: UserRecord['status']) {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'Active';
|
||||
case 'invited':
|
||||
return 'Invited';
|
||||
case 'inactive':
|
||||
return 'Inactive';
|
||||
case 'suspended':
|
||||
return 'Suspended';
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusTone(status: UserRecord['status']) {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'success';
|
||||
case 'invited':
|
||||
return 'warning';
|
||||
case 'suspended':
|
||||
return 'error';
|
||||
case 'inactive':
|
||||
return 'muted';
|
||||
}
|
||||
}
|
||||
|
||||
function getRoleTone(roleName: string) {
|
||||
const normalized = roleName.toLowerCase();
|
||||
if (normalized.includes('admin')) return 'admin';
|
||||
if (normalized.includes('editor')) return 'editor';
|
||||
return 'agent';
|
||||
}
|
||||
|
||||
function initials(name: string) {
|
||||
return name
|
||||
.split(/\s+/)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase() || '')
|
||||
.join('');
|
||||
}
|
||||
|
||||
const roleDescriptions: Record<string, string> = {
|
||||
Admin: 'Full system access, including billing, user management, and API settings.',
|
||||
Editor: 'Manage broadcasts and message templates, but cannot change system settings.',
|
||||
Agent: 'Limited to handling conversations, contacts, and operational response flow.',
|
||||
};
|
||||
|
||||
export function UsersManagementBoard({
|
||||
initialUsers,
|
||||
initialTotal,
|
||||
initialPage,
|
||||
initialPageSize,
|
||||
initialTotalPages,
|
||||
availableRoles,
|
||||
}: Props) {
|
||||
const [users, setUsers] = useState(initialUsers);
|
||||
const [total, setTotal] = useState(initialTotal);
|
||||
const [page, setPage] = useState(initialPage);
|
||||
const [pageSize] = useState(initialPageSize);
|
||||
const [totalPages, setTotalPages] = useState(initialTotalPages);
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [roleFilter, setRoleFilter] = useState('all');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [panelMode, setPanelMode] = useState<PanelMode>(null);
|
||||
const [editingUserId, setEditingUserId] = useState<string | null>(null);
|
||||
const [feedback, setFeedback] = useState('');
|
||||
const [inviteName, setInviteName] = useState('');
|
||||
const [inviteEmail, setInviteEmail] = useState('');
|
||||
const [inviteRoleId, setInviteRoleId] = useState(availableRoles[0]?.id || '');
|
||||
const [editName, setEditName] = useState('');
|
||||
const [editEmail, setEditEmail] = useState('');
|
||||
const [editRoleId, setEditRoleId] = useState('');
|
||||
const [editStatus, setEditStatus] = useState<UserRecord['status']>('active');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const editingUser = useMemo(
|
||||
() => users.find((user) => user.id === editingUserId) ?? null,
|
||||
[editingUserId, users],
|
||||
);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const totalUsers = total;
|
||||
const pendingInvites = users.filter((user) => user.status === 'invited').length;
|
||||
const activeRecently = users.filter((user) => {
|
||||
if (!user.lastLoginAt) return false;
|
||||
return Date.now() - new Date(user.lastLoginAt).getTime() <= 24 * 60 * 60 * 1000;
|
||||
}).length;
|
||||
return { totalUsers, pendingInvites, activeRecently };
|
||||
}, [total, users]);
|
||||
|
||||
const visibleRoles = useMemo(() => {
|
||||
const base = availableRoles.map((role) => role.name);
|
||||
return base.slice(0, 3);
|
||||
}, [availableRoles]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = window.setTimeout(() => {
|
||||
setDebouncedSearch(search.trim());
|
||||
setPage(1);
|
||||
}, 250);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [search]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
async function loadUsers() {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', String(page));
|
||||
params.set('limit', String(pageSize));
|
||||
if (debouncedSearch) params.set('search', debouncedSearch);
|
||||
if (statusFilter !== 'all') params.set('status', statusFilter);
|
||||
if (roleFilter !== 'all') params.set('roleId', roleFilter);
|
||||
|
||||
const response = await fetch(`/api/users?${params.toString()}`, {
|
||||
signal: controller.signal,
|
||||
cache: 'no-store',
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
setFeedback(payload.message || 'Failed to load users');
|
||||
return;
|
||||
}
|
||||
|
||||
setUsers(payload.items);
|
||||
setTotal(payload.total);
|
||||
setTotalPages(payload.totalPages);
|
||||
} catch (error) {
|
||||
if ((error as Error).name !== 'AbortError') {
|
||||
setFeedback('Failed to load users');
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadUsers();
|
||||
return () => controller.abort();
|
||||
}, [debouncedSearch, page, pageSize, roleFilter, statusFilter]);
|
||||
|
||||
function closePanel() {
|
||||
setPanelMode(null);
|
||||
setEditingUserId(null);
|
||||
}
|
||||
|
||||
function openCreatePanel() {
|
||||
setPanelMode('create');
|
||||
setEditingUserId(null);
|
||||
setFeedback('');
|
||||
}
|
||||
|
||||
function beginEdit(user: UserRecord) {
|
||||
setPanelMode('edit');
|
||||
setEditingUserId(user.id);
|
||||
setEditName(user.name);
|
||||
setEditEmail(user.email);
|
||||
setEditRoleId(user.roleId || '');
|
||||
setEditStatus(user.status);
|
||||
setFeedback('');
|
||||
}
|
||||
|
||||
async function submitInvite() {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const response = await fetch('/api/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: inviteName,
|
||||
email: inviteEmail,
|
||||
roleId: inviteRoleId || undefined,
|
||||
}),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
setFeedback(payload.message || 'Failed to invite user');
|
||||
return;
|
||||
}
|
||||
|
||||
setFeedback(
|
||||
payload.emailSent
|
||||
? `Invitation sent to ${payload.email}.`
|
||||
: `User invited, but email was not sent. Activation link: ${payload.invitationUrl}`,
|
||||
);
|
||||
setInviteName('');
|
||||
setInviteEmail('');
|
||||
setInviteRoleId(availableRoles[0]?.id || '');
|
||||
closePanel();
|
||||
setPage(1);
|
||||
setDebouncedSearch('');
|
||||
setSearch('');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitEdit() {
|
||||
if (!editingUser) return;
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const response = await fetch(`/api/users/${editingUser.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: editName,
|
||||
email: editEmail,
|
||||
roleId: editRoleId || undefined,
|
||||
status: editStatus,
|
||||
}),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
setFeedback(payload.message || 'Failed to update user');
|
||||
return;
|
||||
}
|
||||
|
||||
setUsers((current) =>
|
||||
current.map((user) =>
|
||||
user.id === payload.id
|
||||
? {
|
||||
...user,
|
||||
name: payload.name,
|
||||
email: payload.email,
|
||||
status: payload.status,
|
||||
roleId: payload.roleId,
|
||||
roleName: payload.roleName,
|
||||
lastLoginAt: payload.lastLoginAt,
|
||||
emailVerifiedAt: payload.emailVerifiedAt,
|
||||
}
|
||||
: user,
|
||||
),
|
||||
);
|
||||
setFeedback(`User ${payload.email} updated.`);
|
||||
closePanel();
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function resendInvite(user: UserRecord) {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const response = await fetch('/api/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
roleId: user.roleId || undefined,
|
||||
}),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) {
|
||||
setFeedback(payload.message || 'Failed to resend invite');
|
||||
return;
|
||||
}
|
||||
|
||||
setFeedback(
|
||||
payload.emailSent
|
||||
? `Invitation resent to ${payload.email}.`
|
||||
: `Invite refreshed, but email was not sent. Activation link: ${payload.invitationUrl}`,
|
||||
);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
setSearch('');
|
||||
setDebouncedSearch('');
|
||||
setStatusFilter('all');
|
||||
setRoleFilter('all');
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
function exportUsers() {
|
||||
const params = new URLSearchParams();
|
||||
if (debouncedSearch) params.set('search', debouncedSearch);
|
||||
if (statusFilter !== 'all') params.set('status', statusFilter);
|
||||
if (roleFilter !== 'all') params.set('roleId', roleFilter);
|
||||
window.location.href = `/api/users/export${params.size ? `?${params.toString()}` : ''}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="users-board">
|
||||
<section className="page-header split users-page-header">
|
||||
<div>
|
||||
<p className="page-eyebrow">Users</p>
|
||||
<h1 className="page-heading">User Management</h1>
|
||||
<p className="page-copy">Manage team access levels, roles, and security permissions.</p>
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
<button type="button" className="users-hero-button" onClick={openCreatePanel}>
|
||||
<span className="material-symbols-outlined">person_add</span>
|
||||
Create User
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="users-stats-grid">
|
||||
<article className="users-stat-card">
|
||||
<div>
|
||||
<p className="users-stat-label">Total Users</p>
|
||||
<h3>{stats.totalUsers}</h3>
|
||||
<p className="users-stat-copy">
|
||||
<span className="material-symbols-outlined">trending_up</span>
|
||||
Directory accounts
|
||||
</p>
|
||||
</div>
|
||||
<div className="users-stat-icon tone-primary">
|
||||
<span className="material-symbols-outlined">group</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="users-stat-card">
|
||||
<div>
|
||||
<p className="users-stat-label">Pending Invites</p>
|
||||
<h3>{stats.pendingInvites}</h3>
|
||||
<p className="users-stat-copy is-warning">
|
||||
<span className="material-symbols-outlined">schedule</span>
|
||||
Awaiting response
|
||||
</p>
|
||||
</div>
|
||||
<div className="users-stat-icon tone-secondary">
|
||||
<span className="material-symbols-outlined">mail</span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="users-stat-card is-accent">
|
||||
<div className="users-stat-content">
|
||||
<p className="users-stat-label">Active Sessions</p>
|
||||
<h3>{stats.activeRecently}</h3>
|
||||
<p className="users-stat-copy">Current real-time activity</p>
|
||||
</div>
|
||||
<div className="users-stat-icon tone-contrast">
|
||||
<span className="material-symbols-outlined">bolt</span>
|
||||
</div>
|
||||
<div className="users-stat-glow" />
|
||||
</article>
|
||||
</section>
|
||||
|
||||
{panelMode === 'create' ? (
|
||||
<section className="users-panel-layout">
|
||||
<div className="users-create-main">
|
||||
<article className="users-form-card create">
|
||||
<div className="users-form-head create">
|
||||
<div>
|
||||
<p className="card-kicker">Settings / Team Management</p>
|
||||
<h2>Add New Team Member</h2>
|
||||
<p>Invite a new administrator or support agent to your business dashboard.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="users-create-section">
|
||||
<h3>
|
||||
<span className="material-symbols-outlined">person_add</span>
|
||||
Profile Information
|
||||
</h3>
|
||||
<div className="users-form-grid">
|
||||
<label className="users-form-field">
|
||||
<span>Full Name</span>
|
||||
<input value={inviteName} onChange={(event) => setInviteName(event.target.value)} placeholder="e.g. Sarah Jenkins" />
|
||||
</label>
|
||||
<label className="users-form-field">
|
||||
<span>Email Address</span>
|
||||
<input value={inviteEmail} onChange={(event) => setInviteEmail(event.target.value)} placeholder="sarah.j@enterprise.com" />
|
||||
</label>
|
||||
<label className="users-form-field">
|
||||
<span>Phone Number (Optional)</span>
|
||||
<input placeholder="+1 (555) 000-0000" disabled />
|
||||
</label>
|
||||
<label className="users-form-field">
|
||||
<span>Role Selection</span>
|
||||
<select value={inviteRoleId} onChange={(event) => setInviteRoleId(event.target.value)}>
|
||||
{availableRoles.map((role) => (
|
||||
<option key={role.id} value={role.id}>
|
||||
{role.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="users-footer-actions">
|
||||
<button type="button" className="secondary-button" onClick={closePanel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" className="users-hero-button" onClick={submitInvite} disabled={isSaving}>
|
||||
<span className="material-symbols-outlined">send</span>
|
||||
{isSaving ? 'Creating...' : 'Create User'}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<aside className="users-create-side">
|
||||
<article className="users-summary-card">
|
||||
<h4>Summary</h4>
|
||||
<div className="users-summary-list">
|
||||
<div>
|
||||
<span>Role</span>
|
||||
<strong>
|
||||
{availableRoles.find((role) => role.id === inviteRoleId)?.name || 'Support Agent'}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Status</span>
|
||||
<strong>Draft Invitation</strong>
|
||||
</div>
|
||||
<p>
|
||||
Inviting this user will send a secure email link so they can verify their address and
|
||||
set their own password.
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="users-illustration-card">
|
||||
<div className="users-illustration-art" />
|
||||
<div className="users-illustration-copy">
|
||||
<h5>Secure Onboarding</h5>
|
||||
<p>Users receive a time-limited invitation link to create their own credentials.</p>
|
||||
</div>
|
||||
</article>
|
||||
</aside>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{panelMode === 'edit' && editingUser ? (
|
||||
<section className="users-edit-layout">
|
||||
<aside className="users-edit-side">
|
||||
<article className="users-profile-card">
|
||||
<div className="users-profile-avatar">{initials(editingUser.name)}</div>
|
||||
<h3>{editingUser.name}</h3>
|
||||
<p>{editingUser.email}</p>
|
||||
<div className="users-profile-meta">
|
||||
<div>
|
||||
<div>
|
||||
<p>Status</p>
|
||||
<strong className={`tone-${getStatusTone(editingUser.status)}`}>
|
||||
{formatStatusLabel(editingUser.status)}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p>Last Active</p>
|
||||
<strong>{formatLastSeen(editingUser.lastLoginAt, editingUser.status)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="users-security-card">
|
||||
<h4>Security Actions</h4>
|
||||
<button
|
||||
type="button"
|
||||
className="users-toolbar-button full"
|
||||
onClick={() => resendInvite(editingUser)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<span className="material-symbols-outlined">lock_reset</span>
|
||||
Send Password Reset Link
|
||||
</button>
|
||||
</article>
|
||||
</aside>
|
||||
|
||||
<div className="users-edit-main">
|
||||
<article className="users-form-card edit">
|
||||
<div className="users-form-head edit">
|
||||
<div>
|
||||
<p className="card-kicker">Edit User</p>
|
||||
<h2>Update user access and account status</h2>
|
||||
<p>Change display details, role assignment, and lifecycle state without leaving the directory.</p>
|
||||
</div>
|
||||
<span className={`users-panel-badge tone-${getStatusTone(editingUser.status)}`}>
|
||||
{formatStatusLabel(editingUser.status)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="users-form-grid">
|
||||
<label className="users-form-field">
|
||||
<span>Full Name</span>
|
||||
<input value={editName} onChange={(event) => setEditName(event.target.value)} />
|
||||
</label>
|
||||
<label className="users-form-field">
|
||||
<span>Email Address</span>
|
||||
<input value={editEmail} onChange={(event) => setEditEmail(event.target.value)} />
|
||||
</label>
|
||||
<label className="users-form-field">
|
||||
<span>Role</span>
|
||||
<select value={editRoleId} onChange={(event) => setEditRoleId(event.target.value)}>
|
||||
<option value="">Unassigned</option>
|
||||
{availableRoles.map((role) => (
|
||||
<option key={role.id} value={role.id}>
|
||||
{role.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="users-form-field">
|
||||
<span>Status</span>
|
||||
<select value={editStatus} onChange={(event) => setEditStatus(event.target.value as UserRecord['status'])}>
|
||||
<option value="invited">Invited</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
<option value="suspended">Suspended</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="users-edit-meta">
|
||||
<div>
|
||||
<strong>{editingUser.emailVerifiedAt ? 'Verified' : 'Pending Verification'}</strong>
|
||||
<span>Email verification</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{formatLastSeen(editingUser.lastLoginAt, editingUser.status)}</strong>
|
||||
<span>Last activity</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="users-footer-actions">
|
||||
<button type="button" className="secondary-button" onClick={closePanel}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" className="users-hero-button" onClick={submitEdit} disabled={isSaving}>
|
||||
<span className="material-symbols-outlined">save</span>
|
||||
{isSaving ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article className="users-danger-card">
|
||||
<div className="users-danger-icon">
|
||||
<span className="material-symbols-outlined">warning</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4>Danger Zone</h4>
|
||||
<p>Deleting this user is not enabled yet. For now, use `Suspended` status to revoke access safely.</p>
|
||||
<button type="button" disabled>
|
||||
Delete User
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{feedback ? <p className="users-feedback">{feedback}</p> : null}
|
||||
|
||||
<section className="users-table-shell">
|
||||
<div className="users-table-toolbar">
|
||||
<h4>Team Members</h4>
|
||||
<div className="users-toolbar-actions">
|
||||
<button type="button" className="users-toolbar-button large" onClick={() => setShowFilters((current) => !current)}>
|
||||
<span className="material-symbols-outlined">filter_list</span>
|
||||
Filter
|
||||
</button>
|
||||
<button type="button" className="users-toolbar-button large" onClick={exportUsers}>
|
||||
<span className="material-symbols-outlined">download</span>
|
||||
Export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showFilters ? (
|
||||
<div className="users-filters-panel">
|
||||
<label className="users-filter-field">
|
||||
<span>Search team members</span>
|
||||
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Search name or email..." />
|
||||
</label>
|
||||
<label className="users-filter-field">
|
||||
<span>Status</span>
|
||||
<select value={statusFilter} onChange={(event) => {
|
||||
setStatusFilter(event.target.value);
|
||||
setPage(1);
|
||||
}}>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="invited">Invited</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
<option value="suspended">Suspended</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="users-filter-field">
|
||||
<span>Role</span>
|
||||
<select value={roleFilter} onChange={(event) => {
|
||||
setRoleFilter(event.target.value);
|
||||
setPage(1);
|
||||
}}>
|
||||
<option value="all">All Roles</option>
|
||||
{availableRoles.map((role) => (
|
||||
<option key={role.id} value={role.id}>
|
||||
{role.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<div className="users-filter-actions">
|
||||
<button type="button" className="secondary-button" onClick={resetFilters}>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="users-table-wrap">
|
||||
<table className="users-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name / Email</th>
|
||||
<th>Role</th>
|
||||
<th>Last Active</th>
|
||||
<th>Status</th>
|
||||
<th className="align-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => (
|
||||
<tr key={user.id}>
|
||||
<td>
|
||||
<div className="users-identity">
|
||||
<div className={`users-avatar tone-${getRoleTone(user.roleName)}`}>{initials(user.name)}</div>
|
||||
<div>
|
||||
<p>{user.name}</p>
|
||||
<span>{user.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`users-role-pill tone-${getRoleTone(user.roleName)}`}>
|
||||
{user.roleName.toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="users-muted">{formatLastSeen(user.lastLoginAt, user.status)}</td>
|
||||
<td>
|
||||
<div className="users-status">
|
||||
<span className={`users-status-dot tone-${getStatusTone(user.status)}`} />
|
||||
<span>{formatStatusLabel(user.status)}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="users-actions">
|
||||
{user.status === 'invited' ? (
|
||||
<button type="button" onClick={() => resendInvite(user)} title="Resend Invite">
|
||||
<span className="material-symbols-outlined">mail</span>
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" onClick={() => beginEdit(user)} title="Edit User">
|
||||
<span className="material-symbols-outlined">edit</span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{users.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="users-empty-state">
|
||||
No users matched the current filters.
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="users-pagination">
|
||||
<p>
|
||||
Showing {users.length === 0 ? 0 : (page - 1) * pageSize + 1} to {(page - 1) * pageSize + users.length} of {total} team members
|
||||
</p>
|
||||
<div className="users-pagination-buttons">
|
||||
<button type="button" onClick={() => setPage((current) => Math.max(1, current - 1))} disabled={page <= 1}>
|
||||
<span className="material-symbols-outlined">chevron_left</span>
|
||||
</button>
|
||||
{Array.from({ length: totalPages }, (_, index) => index + 1)
|
||||
.slice(Math.max(0, page - 2), Math.max(0, page - 2) + 3)
|
||||
.map((pageNumber) => (
|
||||
<button
|
||||
key={pageNumber}
|
||||
type="button"
|
||||
className={pageNumber === page ? 'is-active' : ''}
|
||||
onClick={() => setPage(pageNumber)}
|
||||
>
|
||||
{pageNumber}
|
||||
</button>
|
||||
))}
|
||||
<button type="button" onClick={() => setPage((current) => Math.min(totalPages, current + 1))} disabled={page >= totalPages}>
|
||||
<span className="material-symbols-outlined">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{isLoading ? <div className="users-loading-bar" /> : null}
|
||||
</section>
|
||||
|
||||
<section className="users-role-overview">
|
||||
<div>
|
||||
<h5>Role Permissions</h5>
|
||||
<p>Quick overview of what each team member can access across the WhatsApp Business platform.</p>
|
||||
</div>
|
||||
<div className="users-role-cards">
|
||||
{visibleRoles.map((roleName) => (
|
||||
<article key={roleName} className="users-role-card">
|
||||
<div className={`users-role-card-head tone-${getRoleTone(roleName)}`}>
|
||||
<span className="material-symbols-outlined">
|
||||
{getRoleTone(roleName) === 'admin'
|
||||
? 'verified_user'
|
||||
: getRoleTone(roleName) === 'editor'
|
||||
? 'edit_note'
|
||||
: 'support_agent'}
|
||||
</span>
|
||||
<span>{roleName.toUpperCase()}</span>
|
||||
</div>
|
||||
<p>{roleDescriptions[roleName] || 'Operational access based on the assigned role matrix.'}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
30
frontend/src/components/webhook-retry-form.tsx
Normal file
30
frontend/src/components/webhook-retry-form.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
import { retryWebhookEventAction } from '../app/actions';
|
||||
|
||||
type Props = {
|
||||
eventId: string;
|
||||
};
|
||||
|
||||
type FormState = {
|
||||
error?: string;
|
||||
success?: string;
|
||||
};
|
||||
|
||||
const initialState: FormState = {};
|
||||
|
||||
export function WebhookRetryForm({ eventId }: Props) {
|
||||
const [state, action, pending] = useActionState(retryWebhookEventAction, initialState);
|
||||
|
||||
return (
|
||||
<form action={action} className="retry-form">
|
||||
<input type="hidden" name="eventId" value={eventId} />
|
||||
<button type="submit" className="secondary-button" disabled={pending}>
|
||||
{pending ? 'Retrying...' : 'Retry Event'}
|
||||
</button>
|
||||
{state.error ? <p className="form-error">{state.error}</p> : null}
|
||||
{state.success ? <p className="form-success">Event requeued.</p> : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
162
frontend/src/components/whatsapp-settings-form.tsx
Normal file
162
frontend/src/components/whatsapp-settings-form.tsx
Normal file
@ -0,0 +1,162 @@
|
||||
'use client';
|
||||
|
||||
import { useActionState } from 'react';
|
||||
import {
|
||||
testWhatsappSettingsAction,
|
||||
updateWhatsappSettingsAction,
|
||||
} from '../app/actions';
|
||||
|
||||
type Props = {
|
||||
initialValues: {
|
||||
provider: string;
|
||||
verifyToken: string;
|
||||
phoneNumberId: string;
|
||||
isEnabled: boolean;
|
||||
subscriptions: string[];
|
||||
availableSubscriptions: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
type FormState = {
|
||||
error?: string;
|
||||
success?: string;
|
||||
};
|
||||
|
||||
const initialState: FormState = {};
|
||||
|
||||
export function WhatsappSettingsForm({ initialValues }: Props) {
|
||||
const [saveState, saveAction, savePending] = useActionState(
|
||||
updateWhatsappSettingsAction,
|
||||
initialState,
|
||||
);
|
||||
const [testState, testAction, testPending] = useActionState(
|
||||
testWhatsappSettingsAction,
|
||||
initialState,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="settings-form-stack">
|
||||
<form action={saveAction} className="surface-card">
|
||||
<div className="form-stack">
|
||||
<div>
|
||||
<label>Provider</label>
|
||||
<select name="provider" defaultValue={initialValues.provider}>
|
||||
<option value="meta">Meta</option>
|
||||
<option value="qontak">Qontak</option>
|
||||
<option value="default">Default</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Verify Token</label>
|
||||
<input name="webhookVerifyToken" type="text" defaultValue={initialValues.verifyToken} />
|
||||
</div>
|
||||
<div>
|
||||
<label>Signing Secret</label>
|
||||
<input
|
||||
name="sharedSecret"
|
||||
type="password"
|
||||
placeholder="Leave blank to keep current secret"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>Meta App Secret</label>
|
||||
<input
|
||||
name="appSecret"
|
||||
type="password"
|
||||
placeholder="Leave blank to keep current app secret"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>Meta Access Token</label>
|
||||
<input
|
||||
name="accessToken"
|
||||
type="password"
|
||||
placeholder="Leave blank to keep current access token"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>Meta Phone Number ID</label>
|
||||
<input
|
||||
name="phoneNumberId"
|
||||
type="text"
|
||||
defaultValue={initialValues.phoneNumberId}
|
||||
placeholder="e.g. 123456789012345"
|
||||
/>
|
||||
</div>
|
||||
<label className="settings-checkbox">
|
||||
<span>
|
||||
<strong>Enable WhatsApp integration</strong>
|
||||
<small>Master switch for webhook verification and processing flow.</small>
|
||||
</span>
|
||||
<span className="settings-toggle">
|
||||
<input name="isEnabled" type="checkbox" defaultChecked={initialValues.isEnabled} />
|
||||
<span className="settings-toggle-ui" aria-hidden="true" />
|
||||
</span>
|
||||
</label>
|
||||
<div className="settings-subscriptions">
|
||||
<div>
|
||||
<label>Event Subscriptions</label>
|
||||
<p>Select which normalized webhook events should be processed by the backend worker.</p>
|
||||
</div>
|
||||
{initialValues.availableSubscriptions.map((subscription) => (
|
||||
<label key={subscription.key} className="settings-subscription-row">
|
||||
<span>
|
||||
<strong>{subscription.label}</strong>
|
||||
<small>{subscription.description}</small>
|
||||
</span>
|
||||
<span className="settings-toggle">
|
||||
<input
|
||||
name="subscriptions"
|
||||
type="checkbox"
|
||||
value={subscription.key}
|
||||
defaultChecked={initialValues.subscriptions.includes(subscription.key)}
|
||||
/>
|
||||
<span className="settings-toggle-ui" aria-hidden="true" />
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<button type="submit" className="primary-button" disabled={savePending}>
|
||||
{savePending ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
{saveState.error ? <p className="form-error">{saveState.error}</p> : null}
|
||||
{saveState.success ? <p className="form-success">Settings updated.</p> : null}
|
||||
</form>
|
||||
|
||||
<form action={testAction} className="surface-card">
|
||||
<div className="form-stack">
|
||||
<div>
|
||||
<label>Test Provider</label>
|
||||
<select name="provider" defaultValue={initialValues.provider}>
|
||||
<option value="meta">Meta</option>
|
||||
<option value="qontak">Qontak</option>
|
||||
<option value="default">Default</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label>Test Sender Phone</label>
|
||||
<input name="senderPhone" type="text" placeholder="6281999000111" />
|
||||
</div>
|
||||
<p>
|
||||
Test payload will create a `message.inbound` event, so make sure that subscription is enabled
|
||||
if you want the worker to process it.
|
||||
</p>
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<button type="submit" className="secondary-button" disabled={testPending}>
|
||||
{testPending ? 'Queueing...' : 'Send Test Payload'}
|
||||
</button>
|
||||
</div>
|
||||
{testState.error ? <p className="form-error">{testState.error}</p> : null}
|
||||
{testState.success ? <p className="form-success">Test webhook queued.</p> : null}
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
677
frontend/src/lib/api.ts
Normal file
677
frontend/src/lib/api.ts
Normal file
@ -0,0 +1,677 @@
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api';
|
||||
|
||||
async function readJson<T>(response: Response): Promise<T> {
|
||||
const payload = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
typeof payload?.message === 'string'
|
||||
? payload.message
|
||||
: `Request failed with status ${response.status}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
export async function fetchDashboardSummary(token: string) {
|
||||
const response = await fetch(`${API_URL}/dashboard/summary`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
return readJson<{
|
||||
totalContacts: number;
|
||||
totalWebhookEvents: number;
|
||||
deliveredRate: number;
|
||||
webhookHealth: string;
|
||||
}>(response);
|
||||
}
|
||||
|
||||
export async function fetchAnalyticsSummary(token: string) {
|
||||
const response = await fetch(`${API_URL}/dashboard/analytics-summary`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
return readJson<{
|
||||
generatedAt: string;
|
||||
queue: {
|
||||
pendingJobs: number;
|
||||
processingJobs: number;
|
||||
failedJobs24h: number;
|
||||
};
|
||||
workers: Array<{
|
||||
name: string;
|
||||
load: number;
|
||||
tone: 'success' | 'warning';
|
||||
}>;
|
||||
throughput: {
|
||||
perMinute: number;
|
||||
verifiedWebhookRate: number;
|
||||
jobsLastHour: number;
|
||||
webhooksLastHour: number;
|
||||
};
|
||||
metrics: {
|
||||
apiLatencyMs: number;
|
||||
apiLatencyBars: number[];
|
||||
databaseConnectionsEstimate: number;
|
||||
databaseUsagePercent: number;
|
||||
memoryUsageGbEstimate: number;
|
||||
memoryBars: number[];
|
||||
};
|
||||
totals: {
|
||||
totalJobs: number;
|
||||
totalWebhookEvents: number;
|
||||
pendingWebhookEvents: number;
|
||||
auditTrailTotal: number;
|
||||
};
|
||||
health: {
|
||||
status: string;
|
||||
database: string;
|
||||
};
|
||||
}>(response);
|
||||
}
|
||||
|
||||
export async function fetchHealthStatus() {
|
||||
const response = await fetch(`${API_URL}/health`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
return readJson<{
|
||||
status: string;
|
||||
service: string;
|
||||
database: string;
|
||||
timestamp: string;
|
||||
}>(response);
|
||||
}
|
||||
|
||||
export async function fetchCurrentSession(token: string) {
|
||||
const response = await fetch(`${API_URL}/auth/session`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
return readJson<{
|
||||
user: {
|
||||
id: string;
|
||||
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';
|
||||
};
|
||||
}>(response);
|
||||
}
|
||||
|
||||
export async function fetchTemplates(
|
||||
token: string,
|
||||
params?: {
|
||||
search?: string;
|
||||
category?: string;
|
||||
status?: string;
|
||||
language?: string;
|
||||
},
|
||||
) {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.search) query.set('search', params.search);
|
||||
if (params?.category) query.set('category', params.category);
|
||||
if (params?.status) query.set('status', params.status);
|
||||
if (params?.language) query.set('language', params.language);
|
||||
|
||||
const response = await fetch(`${API_URL}/templates${query.size ? `?${query.toString()}` : ''}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
return readJson<{
|
||||
total: number;
|
||||
items: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
status: string;
|
||||
language: string;
|
||||
updatedAt: string;
|
||||
updatedLabel: string;
|
||||
preview: string;
|
||||
compact: boolean;
|
||||
buttons: Array<{ type: string; label: string }>;
|
||||
}>;
|
||||
}>(response);
|
||||
}
|
||||
|
||||
export async function fetchTemplateById(token: string, id: string) {
|
||||
const response = await fetch(`${API_URL}/templates/${id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
return readJson<{
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
status: string;
|
||||
language: string;
|
||||
headerText: string | null;
|
||||
bodyText: string;
|
||||
footerText: string | null;
|
||||
buttons: Array<{ type: string; label: string }>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}>(response);
|
||||
}
|
||||
|
||||
export async function fetchConversations(
|
||||
token: string,
|
||||
params?: {
|
||||
filter?: 'all' | 'active' | 'pending';
|
||||
search?: string;
|
||||
},
|
||||
) {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.filter) query.set('filter', params.filter);
|
||||
if (params?.search) query.set('search', params.search);
|
||||
|
||||
const response = await fetch(`${API_URL}/conversations${query.size ? `?${query.toString()}` : ''}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
return readJson<
|
||||
Array<{
|
||||
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;
|
||||
}>
|
||||
>(response);
|
||||
}
|
||||
|
||||
export async function fetchConversationDetail(token: string, contactId: string) {
|
||||
const response = await fetch(`${API_URL}/conversations/${contactId}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
return readJson<{
|
||||
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;
|
||||
activity: Array<{
|
||||
title: string;
|
||||
meta: string;
|
||||
tone: string;
|
||||
}>;
|
||||
messages: Array<{
|
||||
id: string;
|
||||
direction: 'incoming' | 'outgoing';
|
||||
body: string;
|
||||
time: string;
|
||||
status?: string;
|
||||
}>;
|
||||
}>(response);
|
||||
}
|
||||
|
||||
export async function fetchContacts(token: string) {
|
||||
const response = await fetch(`${API_URL}/contacts`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
return readJson<Array<{ id: string; name: string; phoneNumber: string }>>(response);
|
||||
}
|
||||
|
||||
export async function fetchContactsDirectory(
|
||||
token: string,
|
||||
params?: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
status?: string;
|
||||
tag?: string;
|
||||
},
|
||||
) {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.page) query.set('page', String(params.page));
|
||||
if (params?.limit) query.set('limit', String(params.limit));
|
||||
if (params?.search) query.set('search', params.search);
|
||||
if (params?.status) query.set('status', params.status);
|
||||
if (params?.tag) query.set('tag', params.tag);
|
||||
|
||||
const response = await fetch(`${API_URL}/contacts${query.size ? `?${query.toString()}` : ''}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
return readJson<{
|
||||
items: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
phoneNumber: string;
|
||||
company: string | null;
|
||||
status: 'Active' | 'Inactive';
|
||||
tags: string[];
|
||||
location: string;
|
||||
lastMessageAt: string | null;
|
||||
lastMessageLabel: string;
|
||||
lastSeenLabel: string;
|
||||
isBlacklisted: boolean;
|
||||
avatarInitials: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}>;
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
availableTags: string[];
|
||||
statusCounts: {
|
||||
active: number;
|
||||
inactive: number;
|
||||
};
|
||||
}>(response);
|
||||
}
|
||||
|
||||
export async function fetchContactDetail(token: string, id: string) {
|
||||
const response = await fetch(`${API_URL}/contacts/${id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
return readJson<{
|
||||
contact: {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
phoneNumber: string;
|
||||
company: string | null;
|
||||
status: 'Active' | 'Inactive';
|
||||
tags: string[];
|
||||
location: string;
|
||||
lastMessageAt: string | null;
|
||||
lastMessageLabel: string;
|
||||
lastSeenLabel: string;
|
||||
isBlacklisted: boolean;
|
||||
avatarInitials: string;
|
||||
createdAt: string;
|
||||
updatedAt: 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;
|
||||
}>;
|
||||
};
|
||||
}>(response);
|
||||
}
|
||||
|
||||
export async function fetchWebhookLogs(token: string) {
|
||||
const response = await fetch(`${API_URL}/webhooks/logs`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
return readJson<
|
||||
Array<{
|
||||
id: string;
|
||||
provider: string;
|
||||
eventId: string;
|
||||
eventType: string;
|
||||
senderPhone: string | null;
|
||||
recipientPhone: string | null;
|
||||
externalMessageId: string | null;
|
||||
eventTimestamp: string;
|
||||
payloadJson: unknown;
|
||||
verified: boolean;
|
||||
processingStatus: string;
|
||||
processingNotes: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}>
|
||||
>(response);
|
||||
}
|
||||
|
||||
export async function fetchWhatsappSettings(token: string) {
|
||||
const response = await fetch(`${API_URL}/integrations/whatsapp`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
return readJson<{
|
||||
provider: string;
|
||||
webhookUrl: string;
|
||||
verifyToken: string;
|
||||
status: string;
|
||||
hasSharedSecret: boolean;
|
||||
hasAppSecret: boolean;
|
||||
hasAccessToken: boolean;
|
||||
phoneNumberId: string;
|
||||
isEnabled: boolean;
|
||||
subscriptions: string[];
|
||||
availableSubscriptions: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
}>;
|
||||
}>(response);
|
||||
}
|
||||
|
||||
export async function fetchJobLogs(token: string) {
|
||||
const response = await fetch(`${API_URL}/logs/jobs?limit=20`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
return readJson<
|
||||
Array<{
|
||||
id: string;
|
||||
queueName: string;
|
||||
jobType: string;
|
||||
status: string;
|
||||
attempts: number;
|
||||
maxAttempts: number;
|
||||
processedAt: string | null;
|
||||
createdAt: string;
|
||||
}>
|
||||
>(response);
|
||||
}
|
||||
|
||||
export async function fetchAuditTrail(
|
||||
token: string,
|
||||
params?: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
range?: string;
|
||||
user?: string;
|
||||
actionType?: string;
|
||||
module?: string;
|
||||
search?: string;
|
||||
},
|
||||
) {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.page) query.set('page', String(params.page));
|
||||
if (params?.limit) query.set('limit', String(params.limit));
|
||||
if (params?.range) query.set('range', params.range);
|
||||
if (params?.user) query.set('user', params.user);
|
||||
if (params?.actionType) query.set('actionType', params.actionType);
|
||||
if (params?.module) query.set('module', params.module);
|
||||
if (params?.search) query.set('search', params.search);
|
||||
|
||||
const response = await fetch(`${API_URL}/logs/audit-trail${query.size ? `?${query.toString()}` : ''}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
return readJson<{
|
||||
items: Array<{
|
||||
id: string;
|
||||
actorUserId: string | null;
|
||||
actorName: string;
|
||||
actorEmail: string | null;
|
||||
actionType: string;
|
||||
module: string;
|
||||
ipAddress: string | null;
|
||||
severity: 'default' | 'alert';
|
||||
details: string;
|
||||
metadataJson: unknown;
|
||||
createdAt: string;
|
||||
}>;
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}>(response);
|
||||
}
|
||||
|
||||
export async function fetchRoles(token: string) {
|
||||
const response = await fetch(`${API_URL}/roles`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
return readJson<
|
||||
Array<{
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
summary: string;
|
||||
badge: string;
|
||||
tone: 'primary' | 'secondary' | 'tertiary';
|
||||
icon: string;
|
||||
usersAssigned: number;
|
||||
permissions: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
values: Record<string, boolean | null>;
|
||||
}>;
|
||||
}>
|
||||
>(response);
|
||||
}
|
||||
|
||||
export async function fetchUsers(
|
||||
token: string,
|
||||
params?: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
search?: string;
|
||||
roleId?: string;
|
||||
status?: string;
|
||||
},
|
||||
) {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.page) query.set('page', String(params.page));
|
||||
if (params?.limit) query.set('limit', String(params.limit));
|
||||
if (params?.search) query.set('search', params.search);
|
||||
if (params?.roleId) query.set('roleId', params.roleId);
|
||||
if (params?.status) query.set('status', params.status);
|
||||
|
||||
const response = await fetch(`${API_URL}/users${query.size ? `?${query.toString()}` : ''}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
return readJson<
|
||||
{
|
||||
items: Array<{
|
||||
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;
|
||||
}>;
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
}
|
||||
>(response);
|
||||
}
|
||||
|
||||
export async function fetchCampaigns(token: string) {
|
||||
const response = await fetch(`${API_URL}/campaigns`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
return readJson<{
|
||||
metrics: {
|
||||
totalMessages: number;
|
||||
averageDeliveryRate: number;
|
||||
scheduledCount: number;
|
||||
failedDeliveries: number;
|
||||
};
|
||||
items: Array<{
|
||||
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;
|
||||
templateName: string | null;
|
||||
}>;
|
||||
}>(response);
|
||||
}
|
||||
|
||||
export async function fetchCampaignDetail(
|
||||
token: string,
|
||||
id: string,
|
||||
params?: {
|
||||
page?: number;
|
||||
limit?: number;
|
||||
},
|
||||
) {
|
||||
const query = new URLSearchParams();
|
||||
if (params?.page) query.set('page', String(params.page));
|
||||
if (params?.limit) query.set('limit', String(params.limit));
|
||||
|
||||
const response = await fetch(`${API_URL}/campaigns/${id}${query.size ? `?${query.toString()}` : ''}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
return readJson<{
|
||||
campaign: {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
status: string;
|
||||
initiatedAt: string;
|
||||
totalRecipients: number;
|
||||
deliveredCount: number;
|
||||
deliveredRate: number;
|
||||
readCount: number;
|
||||
readRate: number;
|
||||
failedCount: number;
|
||||
failedRate: number;
|
||||
templateName: string;
|
||||
language: string;
|
||||
messageTitle: string;
|
||||
messageBody: string;
|
||||
primaryButton: string;
|
||||
secondaryButton: string;
|
||||
bannerImageUrl: string;
|
||||
};
|
||||
timeline: Array<{
|
||||
label: string;
|
||||
count: number;
|
||||
height: number;
|
||||
}>;
|
||||
recipients: {
|
||||
items: Array<{
|
||||
id: string;
|
||||
phoneNumber: string;
|
||||
status: string;
|
||||
sentAt: string | null;
|
||||
errorReason: string | null;
|
||||
deviceOs: string | null;
|
||||
}>;
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
totalPages: number;
|
||||
};
|
||||
deviceBreakdown: Array<{
|
||||
label: string;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}>;
|
||||
}>(response);
|
||||
}
|
||||
|
||||
export async function fetchInvitation(token: string) {
|
||||
const response = await fetch(`${API_URL}/users/invitations/${token}`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
return readJson<{
|
||||
email: string;
|
||||
name: string;
|
||||
roleName: string;
|
||||
expiresAt: string | null;
|
||||
}>(response);
|
||||
}
|
||||
|
||||
export async function fetchPasswordResetRequest(token: string) {
|
||||
const response = await fetch(`${API_URL}/auth/password-reset/${token}`, {
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
return readJson<{
|
||||
email: string;
|
||||
name: string;
|
||||
expiresAt: string | null;
|
||||
}>(response);
|
||||
}
|
||||
|
||||
export async function fetchTwoFactorStatus(token: string) {
|
||||
const response = await fetch(`${API_URL}/auth/2fa/status`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
return readJson<{
|
||||
enabled: boolean;
|
||||
pendingSetup: boolean;
|
||||
confirmedAt: string | null;
|
||||
recoveryCodesRemaining: number;
|
||||
}>(response);
|
||||
}
|
||||
101
frontend/src/lib/audit-trail.ts
Normal file
101
frontend/src/lib/audit-trail.ts
Normal file
@ -0,0 +1,101 @@
|
||||
export type AuditTrailEntry = {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
adminUser: string;
|
||||
actionType: string;
|
||||
module: string;
|
||||
ipAddress: string;
|
||||
severity: 'default' | 'alert';
|
||||
details: string;
|
||||
};
|
||||
|
||||
export const AUDIT_TRAIL_STORAGE_KEY = 'bizone.audit.trail.v1';
|
||||
|
||||
export const seedAuditTrailEntries: AuditTrailEntry[] = [
|
||||
{
|
||||
id: 'audit_001',
|
||||
timestamp: '2026-05-09T07:12:05+07:00',
|
||||
adminUser: 'Sarah Wilson',
|
||||
actionType: 'Template Created',
|
||||
module: 'Marketing Logs',
|
||||
ipAddress: '192.168.1.45',
|
||||
severity: 'default',
|
||||
details: 'Created a new promo template for regional Eid campaign.',
|
||||
},
|
||||
{
|
||||
id: 'audit_002',
|
||||
timestamp: '2026-05-09T06:10:12+07:00',
|
||||
adminUser: 'John Smith',
|
||||
actionType: 'User Role Updated',
|
||||
module: 'Access Control',
|
||||
ipAddress: '45.23.11.102',
|
||||
severity: 'default',
|
||||
details: 'Updated access scope for campaign operators.',
|
||||
},
|
||||
{
|
||||
id: 'audit_003',
|
||||
timestamp: '2026-05-09T05:45:55+07:00',
|
||||
adminUser: 'Michael Chen',
|
||||
actionType: 'Webhook Modified',
|
||||
module: 'Integrations',
|
||||
ipAddress: '108.4.22.91',
|
||||
severity: 'default',
|
||||
details: 'Adjusted webhook signing secret and callback verification settings.',
|
||||
},
|
||||
{
|
||||
id: 'audit_004',
|
||||
timestamp: '2026-05-09T04:05:33+07:00',
|
||||
adminUser: 'SYSTEM_SEC',
|
||||
actionType: 'Failed Login Limit',
|
||||
module: 'Auth Gateway',
|
||||
ipAddress: '212.88.34.12',
|
||||
severity: 'alert',
|
||||
details: 'Security alert raised after repeated failed login attempts.',
|
||||
},
|
||||
{
|
||||
id: 'audit_005',
|
||||
timestamp: '2026-05-09T02:12:44+07:00',
|
||||
adminUser: 'Sarah Wilson',
|
||||
actionType: 'Profile Updated',
|
||||
module: 'Account',
|
||||
ipAddress: '192.168.1.45',
|
||||
severity: 'default',
|
||||
details: 'Updated profile identity and notification preferences.',
|
||||
},
|
||||
];
|
||||
|
||||
export function readStoredAuditTrail(): AuditTrailEntry[] {
|
||||
if (typeof window === 'undefined') {
|
||||
return seedAuditTrailEntries;
|
||||
}
|
||||
|
||||
const raw = window.localStorage.getItem(AUDIT_TRAIL_STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return seedAuditTrailEntries;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as AuditTrailEntry[];
|
||||
return Array.isArray(parsed) && parsed.length > 0 ? parsed : seedAuditTrailEntries;
|
||||
} catch {
|
||||
window.localStorage.removeItem(AUDIT_TRAIL_STORAGE_KEY);
|
||||
return seedAuditTrailEntries;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeStoredAuditTrail(entries: AuditTrailEntry[]) {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(AUDIT_TRAIL_STORAGE_KEY, JSON.stringify(entries));
|
||||
}
|
||||
|
||||
export function appendAuditTrailEntry(entry: AuditTrailEntry) {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = readStoredAuditTrail();
|
||||
writeStoredAuditTrail([entry, ...entries]);
|
||||
}
|
||||
21
frontend/src/lib/auth.ts
Normal file
21
frontend/src/lib/auth.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { cookies } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export const authCookieName = 'wa_session';
|
||||
export const refreshCookieName = 'wa_refresh';
|
||||
export const twoFactorChallengeCookieName = 'wa_2fa_challenge';
|
||||
export const twoFactorEmailCookieName = 'wa_2fa_email';
|
||||
|
||||
export async function getAuthToken() {
|
||||
const cookieStore = await cookies();
|
||||
return cookieStore.get(authCookieName)?.value;
|
||||
}
|
||||
|
||||
export async function requireAuthToken() {
|
||||
const token = await getAuthToken();
|
||||
if (!token) {
|
||||
redirect('/login');
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
318
frontend/src/lib/i18n.ts
Normal file
318
frontend/src/lib/i18n.ts
Normal file
@ -0,0 +1,318 @@
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
export const locales = ['en', 'id'] as const;
|
||||
|
||||
export type Locale = (typeof locales)[number];
|
||||
|
||||
export const defaultLocale: Locale = 'id';
|
||||
export const localeCookieName = 'wa_locale';
|
||||
|
||||
type Dictionary = {
|
||||
common: {
|
||||
appName: string;
|
||||
openDashboard: string;
|
||||
language: string;
|
||||
english: string;
|
||||
indonesian: string;
|
||||
logout: string;
|
||||
loading: string;
|
||||
save: string;
|
||||
search: string;
|
||||
adminUser: string;
|
||||
superAdmin: string;
|
||||
helpCenter: string;
|
||||
overview: string;
|
||||
};
|
||||
home: {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
login: {
|
||||
title: string;
|
||||
description: string;
|
||||
email: string;
|
||||
password: string;
|
||||
submit: string;
|
||||
goToDashboard: string;
|
||||
invalidCredentials: 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;
|
||||
};
|
||||
twoFactor: {
|
||||
title: string;
|
||||
description: string;
|
||||
verify: string;
|
||||
backToLogin: string;
|
||||
needHelp: string;
|
||||
appTitle: string;
|
||||
appDescription: string;
|
||||
securityTitle: string;
|
||||
securityDescription: string;
|
||||
placeholderBadge: string;
|
||||
placeholderBody: string;
|
||||
};
|
||||
dashboard: {
|
||||
title: string;
|
||||
navOverview: string;
|
||||
navContacts: string;
|
||||
navCampaigns: string;
|
||||
navTemplates: string;
|
||||
navSettings: string;
|
||||
navWebhooks: string;
|
||||
navConversations: string;
|
||||
navUsers: string;
|
||||
navRoles: string;
|
||||
navLogs: string;
|
||||
totalContacts: string;
|
||||
webhookEvents: string;
|
||||
deliveredRate: string;
|
||||
webhookHealth: string;
|
||||
newMessage: string;
|
||||
};
|
||||
contacts: {
|
||||
title: string;
|
||||
submit: string;
|
||||
empty: string;
|
||||
name: string;
|
||||
phoneNumber: string;
|
||||
email: string;
|
||||
company: string;
|
||||
notes: string;
|
||||
createSuccess: string;
|
||||
};
|
||||
settings: {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
webhooks: {
|
||||
title: string;
|
||||
empty: string;
|
||||
};
|
||||
};
|
||||
|
||||
const dictionaries: Record<Locale, Dictionary> = {
|
||||
en: {
|
||||
common: {
|
||||
appName: 'BizOne',
|
||||
openDashboard: 'Open Dashboard',
|
||||
language: 'Language',
|
||||
english: 'English',
|
||||
indonesian: 'Indonesian',
|
||||
logout: 'Log out',
|
||||
loading: 'Loading...',
|
||||
save: 'Save',
|
||||
search: 'Search',
|
||||
adminUser: 'Admin User',
|
||||
superAdmin: 'Super Admin',
|
||||
helpCenter: 'Help Center',
|
||||
overview: 'Overview',
|
||||
},
|
||||
home: {
|
||||
title: 'WhatsApp Business Dashboard',
|
||||
description: 'Admin dashboard starter for operations, messaging, contacts, and webhook monitoring.',
|
||||
},
|
||||
login: {
|
||||
title: 'Login',
|
||||
description: 'Sign in to access the WhatsApp operations dashboard.',
|
||||
email: 'Email',
|
||||
password: 'Password',
|
||||
submit: 'Sign In',
|
||||
goToDashboard: 'Go to Dashboard',
|
||||
invalidCredentials: 'Invalid email or password.',
|
||||
emailLabel: 'Email Address',
|
||||
passwordLabel: 'Password',
|
||||
forgotPassword: 'Forgot password?',
|
||||
rememberMe: 'Remember me on this device',
|
||||
accessVia: 'Or access via',
|
||||
google: 'Google',
|
||||
sso: 'SSO',
|
||||
newToPlatform: 'New to WhatsApp Business?',
|
||||
applyAccess: 'Apply for API access',
|
||||
privacyPolicy: 'Privacy Policy',
|
||||
termsOfService: 'Terms of Service',
|
||||
helpCenter: 'Help Center',
|
||||
securityPreview: 'Enterprise security-first access for messaging operations.',
|
||||
loginHelp: 'Need help?',
|
||||
twoFactorPreview: '2FA screen preview',
|
||||
showPassword: 'Show',
|
||||
hidePassword: 'Hide',
|
||||
},
|
||||
twoFactor: {
|
||||
title: 'Two-Factor Authentication',
|
||||
description: 'Open your Google Authenticator app and enter the 6-digit verification code below to verify your identity.',
|
||||
verify: 'Verify Code',
|
||||
backToLogin: 'Back to Login',
|
||||
needHelp: 'Need help?',
|
||||
appTitle: 'Google Authenticator',
|
||||
appDescription: 'Generate secure codes even when your phone is offline.',
|
||||
securityTitle: 'Enterprise Security',
|
||||
securityDescription: 'Your account is protected by mandatory 2FA protocols.',
|
||||
placeholderBadge: 'Coming soon',
|
||||
placeholderBody: 'This screen is a placeholder for the future 2FA login step. The backend verification flow has not been enabled yet.',
|
||||
},
|
||||
dashboard: {
|
||||
title: 'Dashboard Overview',
|
||||
navOverview: 'Overview',
|
||||
navContacts: 'Contacts',
|
||||
navCampaigns: 'Campaigns',
|
||||
navTemplates: 'Templates',
|
||||
navSettings: 'Settings',
|
||||
navWebhooks: 'Webhooks',
|
||||
navConversations: 'Conversations',
|
||||
navUsers: 'Users',
|
||||
navRoles: 'Roles',
|
||||
navLogs: 'System Logs',
|
||||
totalContacts: 'Total Contacts',
|
||||
webhookEvents: 'Webhook Events',
|
||||
deliveredRate: 'Delivered Rate',
|
||||
webhookHealth: 'Webhook Health',
|
||||
newMessage: 'New Broadcast',
|
||||
},
|
||||
contacts: {
|
||||
title: 'Contacts',
|
||||
submit: 'Create Contact',
|
||||
empty: 'No contacts yet.',
|
||||
name: 'Name',
|
||||
phoneNumber: 'Phone Number',
|
||||
email: 'Email',
|
||||
company: 'Company',
|
||||
notes: 'Notes',
|
||||
createSuccess: 'Contact created successfully.',
|
||||
},
|
||||
settings: {
|
||||
title: 'WhatsApp Settings',
|
||||
description: 'API token, webhook URL, verify token, signing secret, and sender configuration.',
|
||||
},
|
||||
webhooks: {
|
||||
title: 'Webhook Logs',
|
||||
empty: 'No webhook logs yet.',
|
||||
},
|
||||
},
|
||||
id: {
|
||||
common: {
|
||||
appName: 'BizOne',
|
||||
openDashboard: 'Buka Dashboard',
|
||||
language: 'Bahasa',
|
||||
english: 'Inggris',
|
||||
indonesian: 'Indonesia',
|
||||
logout: 'Keluar',
|
||||
loading: 'Memuat...',
|
||||
save: 'Simpan',
|
||||
search: 'Cari',
|
||||
adminUser: 'Admin',
|
||||
superAdmin: 'Super Admin',
|
||||
helpCenter: 'Pusat Bantuan',
|
||||
overview: 'Ringkasan',
|
||||
},
|
||||
home: {
|
||||
title: 'Dashboard WhatsApp Business',
|
||||
description: 'Starter dashboard admin untuk operasional, messaging, kontak, dan monitoring webhook.',
|
||||
},
|
||||
login: {
|
||||
title: 'Masuk',
|
||||
description: 'Masuk untuk mengakses dashboard operasional WhatsApp.',
|
||||
email: 'Email',
|
||||
password: 'Kata Sandi',
|
||||
submit: 'Masuk',
|
||||
goToDashboard: 'Ke Dashboard',
|
||||
invalidCredentials: 'Email atau kata sandi tidak valid.',
|
||||
emailLabel: 'Alamat Email',
|
||||
passwordLabel: 'Kata Sandi',
|
||||
forgotPassword: 'Lupa kata sandi?',
|
||||
rememberMe: 'Ingat perangkat ini',
|
||||
accessVia: 'Atau akses melalui',
|
||||
google: 'Google',
|
||||
sso: 'SSO',
|
||||
newToPlatform: 'Baru di WhatsApp Business?',
|
||||
applyAccess: 'Ajukan akses API',
|
||||
privacyPolicy: 'Kebijakan Privasi',
|
||||
termsOfService: 'Syarat Layanan',
|
||||
helpCenter: 'Pusat Bantuan',
|
||||
securityPreview: 'Akses enterprise yang fokus pada keamanan untuk operasional messaging.',
|
||||
loginHelp: 'Butuh bantuan?',
|
||||
twoFactorPreview: 'Preview layar 2FA',
|
||||
showPassword: 'Tampilkan',
|
||||
hidePassword: 'Sembunyikan',
|
||||
},
|
||||
twoFactor: {
|
||||
title: 'Autentikasi Dua Faktor',
|
||||
description: 'Buka aplikasi Google Authenticator Anda lalu masukkan 6 digit kode verifikasi di bawah untuk memverifikasi identitas Anda.',
|
||||
verify: 'Verifikasi Kode',
|
||||
backToLogin: 'Kembali ke Login',
|
||||
needHelp: 'Butuh bantuan?',
|
||||
appTitle: 'Google Authenticator',
|
||||
appDescription: 'Hasilkan kode aman bahkan saat ponsel Anda offline.',
|
||||
securityTitle: 'Keamanan Enterprise',
|
||||
securityDescription: 'Akun Anda akan dilindungi oleh protokol 2FA wajib.',
|
||||
placeholderBadge: 'Segera hadir',
|
||||
placeholderBody: 'Layar ini masih placeholder untuk langkah login 2FA di masa berikutnya. Flow verifikasi backend belum diaktifkan.',
|
||||
},
|
||||
dashboard: {
|
||||
title: 'Ringkasan Dashboard',
|
||||
navOverview: 'Ringkasan',
|
||||
navContacts: 'Kontak',
|
||||
navCampaigns: 'Kampanye',
|
||||
navTemplates: 'Template',
|
||||
navSettings: 'Pengaturan',
|
||||
navWebhooks: 'Webhook',
|
||||
navConversations: 'Percakapan',
|
||||
navUsers: 'Pengguna',
|
||||
navRoles: 'Peran',
|
||||
navLogs: 'Log Sistem',
|
||||
totalContacts: 'Total Kontak',
|
||||
webhookEvents: 'Event Webhook',
|
||||
deliveredRate: 'Rasio Terkirim',
|
||||
webhookHealth: 'Status Webhook',
|
||||
newMessage: 'Broadcast Baru',
|
||||
},
|
||||
contacts: {
|
||||
title: 'Kontak',
|
||||
submit: 'Buat Kontak',
|
||||
empty: 'Belum ada kontak.',
|
||||
name: 'Nama',
|
||||
phoneNumber: 'Nomor Telepon',
|
||||
email: 'Email',
|
||||
company: 'Perusahaan',
|
||||
notes: 'Catatan',
|
||||
createSuccess: 'Kontak berhasil dibuat.',
|
||||
},
|
||||
settings: {
|
||||
title: 'Pengaturan WhatsApp',
|
||||
description: 'Token API, URL webhook, verify token, signing secret, dan konfigurasi sender.',
|
||||
},
|
||||
webhooks: {
|
||||
title: 'Log Webhook',
|
||||
empty: 'Belum ada log webhook.',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export async function getLocale(): Promise<Locale> {
|
||||
const cookieStore = await cookies();
|
||||
const locale = cookieStore.get(localeCookieName)?.value;
|
||||
return isLocale(locale) ? locale : defaultLocale;
|
||||
}
|
||||
|
||||
export async function getDictionary() {
|
||||
const locale = await getLocale();
|
||||
return dictionaries[locale];
|
||||
}
|
||||
|
||||
export function isLocale(value: string | undefined | null): value is Locale {
|
||||
return Boolean(value && locales.includes(value as Locale));
|
||||
}
|
||||
374
frontend/src/lib/mock-data.ts
Normal file
374
frontend/src/lib/mock-data.ts
Normal file
@ -0,0 +1,374 @@
|
||||
export type CampaignStatus = 'Sent' | 'Scheduled' | 'Draft' | 'Failed';
|
||||
|
||||
export type CampaignRow = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
audience: string;
|
||||
audienceGroup: string;
|
||||
sent: string;
|
||||
opened: string;
|
||||
status: CampaignStatus;
|
||||
deliveryRate: number | null;
|
||||
dateLabel: string;
|
||||
timeLabel: string;
|
||||
};
|
||||
|
||||
export const dashboardStats = [
|
||||
{ label: 'Total Messages', value: '1.2M', delta: '+12%', tone: 'success' },
|
||||
{ label: 'Delivered', value: '98.4%', delta: '+0.5%', tone: 'success' },
|
||||
{ label: 'Read Rate', value: '76.2%', delta: '-2%', tone: 'warning' },
|
||||
{ label: 'Failed', value: '1.6%', delta: 'Stable', tone: 'neutral' },
|
||||
];
|
||||
|
||||
export const contacts = [
|
||||
{ id: 'sarah-jenkins', name: 'Sarah Jenkins', phone: '+62 812-5551-8871', tag: 'VIP Customer', company: 'Northwind Retail', status: 'Active' },
|
||||
{ id: 'dimas-pratama', name: 'Dimas Pratama', phone: '+62 821-9933-1182', tag: 'Lead', company: 'BizOne Labs', status: 'New' },
|
||||
{ id: 'amelia-hart', name: 'Amelia Hart', phone: '+62 813-7122-4419', tag: 'Support', company: 'Aster Digital', status: 'Follow-up' },
|
||||
{ id: 'yusuf-rizki', name: 'Yusuf Rizki', phone: '+62 811-6123-7740', tag: 'Wholesale', company: 'Mercury Supply', status: 'Inactive' },
|
||||
];
|
||||
|
||||
export const campaigns: CampaignRow[] = [
|
||||
{
|
||||
id: 'summer-sale-2024',
|
||||
code: 'CAM-98231',
|
||||
name: 'Summer Sale 2024',
|
||||
audience: '45,200 recipients',
|
||||
audienceGroup: 'Retail subscribers',
|
||||
sent: '45,200',
|
||||
opened: '38,910',
|
||||
status: 'Sent',
|
||||
deliveryRate: 99.2,
|
||||
dateLabel: 'June 12, 2024',
|
||||
timeLabel: '09:15 AM',
|
||||
},
|
||||
{
|
||||
id: 'weekly-newsletter-42',
|
||||
code: 'CAM-98244',
|
||||
name: 'Weekly Newsletter #42',
|
||||
audience: 'VIP Customer List',
|
||||
audienceGroup: 'High-value segment',
|
||||
sent: '18,640',
|
||||
opened: '0',
|
||||
status: 'Scheduled',
|
||||
deliveryRate: null,
|
||||
dateLabel: 'In 2 hours',
|
||||
timeLabel: '14:00 PM',
|
||||
},
|
||||
{
|
||||
id: 'product-launch-promo',
|
||||
code: 'CAM-98255',
|
||||
name: 'Product Launch Promo',
|
||||
audience: 'New Leads Segment',
|
||||
audienceGroup: 'Cold outreach',
|
||||
sent: '0',
|
||||
opened: '0',
|
||||
status: 'Draft',
|
||||
deliveryRate: 0,
|
||||
dateLabel: 'Not set',
|
||||
timeLabel: '',
|
||||
},
|
||||
{
|
||||
id: 'loyalty-program-update',
|
||||
code: 'CAM-98201',
|
||||
name: 'Loyalty Program Update',
|
||||
audience: 'Dormant Users',
|
||||
audienceGroup: 'Reactivation list',
|
||||
sent: '60,700',
|
||||
opened: '27,315',
|
||||
status: 'Failed',
|
||||
deliveryRate: 45,
|
||||
dateLabel: 'June 10, 2024',
|
||||
timeLabel: '',
|
||||
},
|
||||
];
|
||||
|
||||
export const templates = [
|
||||
{
|
||||
id: 'order-confirmation-v2',
|
||||
name: 'order_confirmation_v2',
|
||||
category: 'Utility',
|
||||
status: 'Approved',
|
||||
language: 'en_US',
|
||||
updatedLabel: 'Updated 2h ago',
|
||||
preview:
|
||||
"Hi {{1}}, thank you for your order #{{2}}! We've received your payment and will notify you when it ships...",
|
||||
note: 'Last approved variant for e-commerce notifications.',
|
||||
compact: false,
|
||||
},
|
||||
{
|
||||
id: 'holiday-sale-promo',
|
||||
name: 'holiday_sale_promo',
|
||||
category: 'Marketing',
|
||||
status: 'Pending',
|
||||
language: 'en_US',
|
||||
updatedLabel: 'Updated 1d ago',
|
||||
preview:
|
||||
'Exclusive Holiday Sale! Get 30% OFF on all items using code FESTIVE30. Shop now at {{1}}...',
|
||||
note: 'Awaiting Meta content review.',
|
||||
compact: false,
|
||||
},
|
||||
{
|
||||
id: 'account-recovery-otp',
|
||||
name: 'account_recovery_otp',
|
||||
category: 'Authentication',
|
||||
status: 'Rejected',
|
||||
language: 'en_US',
|
||||
updatedLabel: 'Needs Review',
|
||||
preview:
|
||||
'Your recovery code is {{1}}. Do not share this with anyone. This code expires in 5 minutes.',
|
||||
note: 'Rejected due to policy mismatch in footer copy.',
|
||||
compact: false,
|
||||
},
|
||||
{
|
||||
id: 'shipping-update-express',
|
||||
name: 'shipping_update_express',
|
||||
category: 'Utility',
|
||||
status: 'Approved',
|
||||
language: 'en_US',
|
||||
updatedLabel: 'Oct 24, 2023',
|
||||
preview: 'Good news! Your package is out for delivery and should arrive before 7 PM today.',
|
||||
note: 'High-delivery template used by logistics teams.',
|
||||
compact: true,
|
||||
},
|
||||
{
|
||||
id: 'welcome-onboarding-v4',
|
||||
name: 'welcome_onboarding_v4',
|
||||
category: 'Marketing',
|
||||
status: 'Approved',
|
||||
language: 'id_ID',
|
||||
updatedLabel: 'Updated 3d ago',
|
||||
preview:
|
||||
"Welcome to the community {{1}}! We're thrilled to have you here. To get started, please check out...",
|
||||
note: 'Localized onboarding template for Indonesia.',
|
||||
compact: false,
|
||||
},
|
||||
{
|
||||
id: 'appointment-remind-6h',
|
||||
name: 'appointment_remind_6h',
|
||||
category: 'Utility',
|
||||
status: 'Approved',
|
||||
language: 'en_US',
|
||||
updatedLabel: 'Updated 1w ago',
|
||||
preview:
|
||||
'Reminder: Your appointment at {{1}} starts in 6 hours. If you need to reschedule, please call...',
|
||||
note: 'Low drop-off reminder used by support and clinic verticals.',
|
||||
compact: false,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const webhookLogs = [
|
||||
{ id: 'evt_1001', provider: 'meta', event: 'message.delivered', status: 'processed', time: '2026-05-09 09:32' },
|
||||
{ id: 'evt_1002', provider: 'meta', event: 'message.read', status: 'processed', time: '2026-05-09 09:31' },
|
||||
{ id: 'evt_1003', provider: 'qontak', event: 'message.failed', status: 'retrying', time: '2026-05-09 09:28' },
|
||||
{ id: 'evt_1004', provider: 'meta', event: 'template.updated', status: 'received', time: '2026-05-09 09:22' },
|
||||
];
|
||||
|
||||
export const users = [
|
||||
{ name: 'Admin User', role: 'Super Admin', email: 'admin@example.com', status: 'Active', lastSeen: '2 min ago' },
|
||||
{ name: 'Nadia Putri', role: 'Operator', email: 'nadia@bizone.id', status: 'Active', lastSeen: '16 min ago' },
|
||||
{ name: 'Reza Hidayat', role: 'Viewer', email: 'reza@bizone.id', status: 'Invited', lastSeen: 'Never' },
|
||||
];
|
||||
|
||||
export const activityLogs = [
|
||||
{ actor: 'Admin User', action: 'Updated API token', target: 'WhatsApp Integration', time: '09:40' },
|
||||
{ actor: 'Nadia Putri', action: 'Created draft message', target: 'Order Recovery Flow', time: '09:18' },
|
||||
{ actor: 'System', action: 'Retried failed webhook', target: 'evt_1003', time: '08:59' },
|
||||
];
|
||||
|
||||
export const analyticsQueueStats = [
|
||||
{ label: 'Pending Jobs', value: '1,284', tone: 'info', icon: 'schedule' },
|
||||
{ label: 'Processing', value: '42', tone: 'primary', icon: 'autorenew' },
|
||||
{ label: 'Failed (24H)', value: '7', tone: 'error', icon: 'error_outline' },
|
||||
] as const;
|
||||
|
||||
export const analyticsWorkerHealth = [
|
||||
{ name: 'node-worker-01', load: 98, tone: 'success' },
|
||||
{ name: 'node-worker-02', load: 12, tone: 'warning' },
|
||||
] as const;
|
||||
|
||||
export const analyticsLogs = [
|
||||
{
|
||||
timestamp: '2026-05-09 14:22:01',
|
||||
action: 'BROADCAST_START',
|
||||
detail: 'Marketing Campaign v2',
|
||||
actor: 'admin_jane',
|
||||
actorKind: 'user',
|
||||
actorBadge: 'AD',
|
||||
status: 'Success',
|
||||
statusTone: 'success',
|
||||
payloadTone: 'primary',
|
||||
},
|
||||
{
|
||||
timestamp: '2026-05-09 14:21:55',
|
||||
action: 'WEBHOOK_RETRY',
|
||||
detail: 'Endpoint: /api/v1/update',
|
||||
actor: 'QueueWorker_02',
|
||||
actorKind: 'service',
|
||||
actorIcon: 'robot_2',
|
||||
status: 'Pending',
|
||||
statusTone: 'warning',
|
||||
payloadTone: 'neutral',
|
||||
},
|
||||
{
|
||||
timestamp: '2026-05-09 14:21:48',
|
||||
action: 'AUTH_FAILURE',
|
||||
detail: 'Invalid Token Attempt',
|
||||
actor: 'IP: 192.168.1.1',
|
||||
actorKind: 'network',
|
||||
actorIcon: 'public',
|
||||
status: 'Rejected',
|
||||
statusTone: 'error',
|
||||
payloadTone: 'error',
|
||||
},
|
||||
{
|
||||
timestamp: '2026-05-09 14:20:12',
|
||||
action: 'TEMPLATE_CREATE',
|
||||
detail: 'New template: Welcome_V2',
|
||||
actor: 'mike_dev',
|
||||
actorKind: 'user',
|
||||
actorBadge: 'MK',
|
||||
status: 'Approved',
|
||||
statusTone: 'success',
|
||||
payloadTone: 'success',
|
||||
},
|
||||
{
|
||||
timestamp: '2026-05-09 14:19:44',
|
||||
action: 'EXPORT_COMPLETE',
|
||||
detail: 'User_Database_Daily.csv',
|
||||
actor: 'SystemScheduler',
|
||||
actorKind: 'service',
|
||||
actorIcon: 'settings_suggest',
|
||||
status: 'Success',
|
||||
statusTone: 'success',
|
||||
payloadTone: 'neutral',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const analyticsMetrics = [
|
||||
{
|
||||
label: 'API Latency',
|
||||
value: '124ms',
|
||||
meta: '↓ 12%',
|
||||
metaTone: 'success',
|
||||
icon: 'bar_chart',
|
||||
chartHeights: ['40%', '60%', '45%', '80%', '35%', '20%', '30%'],
|
||||
chartTone: 'bars',
|
||||
},
|
||||
{
|
||||
label: 'DB Connections',
|
||||
value: '84',
|
||||
meta: 'Active',
|
||||
metaTone: 'warning',
|
||||
icon: 'database',
|
||||
progress: '42%',
|
||||
chartTone: 'progress',
|
||||
},
|
||||
{
|
||||
label: 'Memory Usage',
|
||||
value: '4.2GB',
|
||||
meta: 'of 16GB',
|
||||
metaTone: 'muted',
|
||||
icon: 'memory',
|
||||
chartHeights: ['60%', '65%', '70%', '62%', '68%', '72%', '75%'],
|
||||
chartTone: 'memory',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const conversations = [
|
||||
{
|
||||
id: 'elena-rodriguez',
|
||||
name: 'Elena Rodriguez',
|
||||
initials: 'ER',
|
||||
time: '2m ago',
|
||||
status: 'NEW',
|
||||
tone: 'info',
|
||||
topic: 'INQUIRY',
|
||||
snippet: 'Hey, I wanted to check the status of my shipment...',
|
||||
online: true,
|
||||
location: 'San Francisco, CA',
|
||||
email: 'elena.rod@example.com',
|
||||
phone: '+1 (555) 0123-4567',
|
||||
customerSince: 'Customer since Jan 2024',
|
||||
tags: ['PREMIUM', 'ACTIVE_TRIAL'],
|
||||
activity: [
|
||||
{ title: 'Order #WH-98234-X', meta: 'Completed • 24 hours ago', tone: 'primary' },
|
||||
{ title: 'Webinar Registered', meta: 'Organic • 3 days ago', tone: 'muted' },
|
||||
],
|
||||
messages: [
|
||||
{
|
||||
direction: 'incoming',
|
||||
body: "Hello! I purchased a subscription yesterday but I haven't received the activation code yet. Could you help me with that?",
|
||||
time: '10:42 AM',
|
||||
},
|
||||
{
|
||||
direction: 'outgoing',
|
||||
body: "Hi Elena! I'm sorry for the delay. Let me check that for you right away. Could you please provide your order ID?",
|
||||
time: '10:44 AM',
|
||||
},
|
||||
{
|
||||
direction: 'incoming',
|
||||
body: "Sure, the order ID is #WH-98234-X. I checked my spam folder too but it's not there.",
|
||||
time: '10:45 AM',
|
||||
},
|
||||
{
|
||||
direction: 'system',
|
||||
body: 'System: Order Verified',
|
||||
time: '',
|
||||
},
|
||||
{
|
||||
direction: 'outgoing-rich',
|
||||
title: 'Verification Successful!',
|
||||
body: "I've resent the activation email to your registered address. You should see it in the next few minutes. Would you like me to send a PDF copy here as well?",
|
||||
time: '10:48 AM',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'marcus-chen',
|
||||
name: 'Marcus Chen',
|
||||
initials: 'MC',
|
||||
time: '45m ago',
|
||||
status: 'ACTIVE',
|
||||
tone: 'success',
|
||||
topic: '',
|
||||
snippet: "Thank you for the help! I'll wait for the email.",
|
||||
online: false,
|
||||
location: 'Singapore',
|
||||
email: 'marcus.chen@example.com',
|
||||
phone: '+65 8123 4411',
|
||||
customerSince: 'Customer since Nov 2023',
|
||||
tags: ['LOYAL', 'B2B'],
|
||||
activity: [
|
||||
{ title: 'Invoice Request', meta: 'Resolved • Today', tone: 'primary' },
|
||||
{ title: 'Campaign Clickthrough', meta: 'Marketing • 1 week ago', tone: 'muted' },
|
||||
],
|
||||
messages: [],
|
||||
},
|
||||
{
|
||||
id: 'sarah-jenkins',
|
||||
name: 'Sarah Jenkins',
|
||||
initials: 'SJ',
|
||||
time: '2h ago',
|
||||
status: 'PENDING',
|
||||
tone: 'warning',
|
||||
topic: '',
|
||||
snippet: 'Is the 20% discount still valid for enterprise?',
|
||||
online: false,
|
||||
location: 'Austin, TX',
|
||||
email: 'sarah.jenkins@example.com',
|
||||
phone: '+1 (555) 0199-2201',
|
||||
customerSince: 'Customer since Mar 2024',
|
||||
tags: ['LEAD'],
|
||||
activity: [
|
||||
{ title: 'Pricing Inquiry', meta: 'Pending • 2 hours ago', tone: 'primary' },
|
||||
],
|
||||
messages: [],
|
||||
},
|
||||
] as const;
|
||||
|
||||
export const messageRows = [
|
||||
{ contact: 'Sarah Jenkins', template: 'order_update_v2', status: 'Delivered', sentAt: '09:12', channel: 'Template' },
|
||||
{ contact: 'Dimas Pratama', template: 'welcome_lead_id', status: 'Queued', sentAt: '09:10', channel: 'Draft' },
|
||||
{ contact: 'Amelia Hart', template: 'post_purchase_followup', status: 'Read', sentAt: '08:55', channel: 'Campaign' },
|
||||
];
|
||||
35
frontend/tsconfig.json
Normal file
35
frontend/tsconfig.json
Normal file
@ -0,0 +1,35 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user