feat: Add Store link to admin header and notification activity log

- Add Store link to admin header (visible when customer SPA is enabled)
- Add storeUrl and customerSpaEnabled to WNW_CONFIG in Assets.php and StandaloneAdmin.php
- Update window.d.ts with new WNW_CONFIG properties
- Create ActivityLog.tsx component with search, filters, and pagination
- Add /notifications/logs API endpoint to NotificationsController
- Update Notifications.tsx to link to activity log page
- Add ActivityLog route to App.tsx
This commit is contained in:
Dwindi Ramadhana
2026-01-04 23:51:54 +07:00
parent 0f542ad452
commit 6c8cbb93e6
8 changed files with 366 additions and 33 deletions

View File

@@ -243,6 +243,7 @@ import EmailConfiguration from '@/routes/Settings/Notifications/EmailConfigurati
import PushConfiguration from '@/routes/Settings/Notifications/PushConfiguration';
import EmailCustomization from '@/routes/Settings/Notifications/EmailCustomization';
import EditTemplate from '@/routes/Settings/Notifications/EditTemplate';
import ActivityLog from '@/routes/Settings/Notifications/ActivityLog';
import SettingsDeveloper from '@/routes/Settings/Developer';
import SettingsModules from '@/routes/Settings/Modules';
import ModuleSettings from '@/routes/Settings/ModuleSettings';
@@ -462,6 +463,17 @@ function Header({ onFullscreen, fullscreen, showToggle = true, scrollContainerRe
>
<span>{__('WordPress')}</span>
</a>
{window.WNW_CONFIG?.customerSpaEnabled && (
<a
href={window.WNW_CONFIG?.storeUrl || '/store/'}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 border rounded-md px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground"
title="Open Store"
>
<span>{__('Store')}</span>
</a>
)}
<button
onClick={handleLogout}
className="inline-flex items-center gap-2 border rounded-md px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground"
@@ -471,6 +483,17 @@ function Header({ onFullscreen, fullscreen, showToggle = true, scrollContainerRe
</button>
</>
)}
{!isStandalone && window.WNW_CONFIG?.customerSpaEnabled && (
<a
href={window.WNW_CONFIG?.storeUrl || '/store/'}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 border rounded-md px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground"
title="Open Store"
>
<span>{__('Store')}</span>
</a>
)}
<ThemeToggle />
{showToggle && (
<button
@@ -560,6 +583,7 @@ function AppRoutes() {
<Route path="/settings/notifications/channels/push" element={<PushConfiguration />} />
<Route path="/settings/notifications/email-customization" element={<EmailCustomization />} />
<Route path="/settings/notifications/edit-template" element={<EditTemplate />} />
<Route path="/settings/notifications/activity-log" element={<ActivityLog />} />
<Route path="/settings/brand" element={<SettingsIndex />} />
<Route path="/settings/developer" element={<SettingsDeveloper />} />
<Route path="/settings/modules" element={<SettingsModules />} />

View File

@@ -6,15 +6,15 @@ import { formatRelativeOrDate } from '@/lib/dates';
import { formatMoney } from '@/lib/currency';
import { ArrowLeft, Printer, ExternalLink, Loader2, Ticket, FileText, Pencil, RefreshCw } from 'lucide-react';
import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from '@/components/ui/select';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import { showErrorToast, showSuccessToast, getPageLoadErrorMessage } from '@/lib/errorHandling';
@@ -67,7 +67,7 @@ export default function OrderShow() {
function printInvoice() {
triggerPrint('invoice');
}
const [showRetryDialog, setShowRetryDialog] = useState(false);
const qrRef = useRef<HTMLCanvasElement | null>(null);
const q = useQuery({
@@ -90,16 +90,16 @@ export default function OrderShow() {
onMutate: async (nextStatus) => {
// Cancel outgoing refetches
await qc.cancelQueries({ queryKey: ['order', id] });
// Snapshot previous value
const previous = qc.getQueryData(['order', id]);
// Optimistically update
qc.setQueryData(['order', id], (old: any) => ({
...old,
status: nextStatus,
}));
return { previous };
},
onSuccess: () => {
@@ -183,8 +183,8 @@ export default function OrderShow() {
useEffect(() => {
if (!isPrintMode || !qrRef.current || !order) return;
(async () => {
try {
const mod = await import( 'qrcode' );
try {
const mod = await import('qrcode');
const QR = (mod as any).default || (mod as any);
const text = `ORDER:${order.number || id}`;
await QR.toCanvas(qrRef.current, text, { width: 128, margin: 1 });
@@ -208,9 +208,6 @@ export default function OrderShow() {
<button className="border rounded-md px-3 py-2 text-sm flex items-center gap-2 no-print" onClick={printLabel} title={__('Print shipping label')}>
<Ticket className="w-4 h-4" /> {__('Label')}
</button>
<Link className="border rounded-md px-3 py-2 text-sm flex items-center gap-2 no-print" to={`/orders`} title={__('Back to orders list')}>
<ExternalLink className="w-4 h-4" /> {__('Orders')}
</Link>
</div>
</div>
@@ -232,8 +229,8 @@ export default function OrderShow() {
<div className="px-4 py-3 border-b flex items-center justify-between">
<div className="font-medium">{__('Summary')}</div>
<div className="w-[180px] flex items-center gap-2">
<Select
value={order.status || ''}
<Select
value={order.status || ''}
onValueChange={(v) => handleStatusChange(v)}
disabled={statusMutation.isPending}
>
@@ -333,9 +330,9 @@ export default function OrderShow() {
<div className="opacity-60">{meta.label}</div>
<div className="font-medium">
{meta.key.includes('url') || meta.key.includes('redirect') ? (
<a
href={meta.value}
target="_blank"
<a
href={meta.value}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline flex items-center gap-1"
>
@@ -482,7 +479,7 @@ export default function OrderShow() {
<div className="flex items-start justify-between mb-6">
<div>
<div className="text-xl font-semibold">Invoice</div>
<div className="opacity-60">Order #{order.number} · {new Date((order.date_ts||0)*1000).toLocaleString()}</div>
<div className="opacity-60">Order #{order.number} · {new Date((order.date_ts || 0) * 1000).toLocaleString()}</div>
</div>
<div className="text-right">
<div className="font-medium">{siteTitle}</div>
@@ -508,7 +505,7 @@ export default function OrderShow() {
</tr>
</thead>
<tbody>
{(order.items || []).map((it:any) => (
{(order.items || []).map((it: any) => (
<tr key={it.id}>
<td className="py-1 pr-2">{it.name}</td>
<td className="py-1 px-2 text-right">×{it.qty}</td>
@@ -542,7 +539,7 @@ export default function OrderShow() {
</div>
<div className="text-xs opacity-60 mb-1">{__('Items')}</div>
<ul className="text-sm list-disc pl-4">
{(order.items||[]).map((it:any)=> (
{(order.items || []).map((it: any) => (
<li key={it.id}>{it.name} ×{it.qty}</li>
))}
</ul>

View File

@@ -154,12 +154,14 @@ export default function NotificationsSettings() {
</p>
<div className="flex items-center justify-between pt-2">
<div className="text-sm text-muted-foreground">
{__('Coming soon')}
{__('Sent, Failed, Pending')}
</div>
<Button variant="outline" size="sm" disabled>
{__('View Log')}
<ChevronRight className="ml-2 h-4 w-4" />
</Button>
<Link to="/settings/notifications/activity-log">
<Button variant="outline" size="sm">
{__('View Log')}
<ChevronRight className="ml-2 h-4 w-4" />
</Button>
</Link>
</div>
</CardContent>
</Card>

View File

@@ -0,0 +1,241 @@
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { api } from '@/lib/api';
import { __ } from '@/lib/i18n';
import { SettingsLayout } from '../components/SettingsLayout';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import {
ArrowLeft,
Mail,
Bell,
MessageCircle,
Send,
CheckCircle2,
XCircle,
Clock,
RefreshCw,
Filter,
Search
} from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
interface NotificationLogEntry {
id: number;
channel: 'email' | 'push' | 'whatsapp' | 'telegram';
event: string;
recipient: string;
subject?: string;
status: 'sent' | 'failed' | 'pending' | 'queued';
created_at: string;
sent_at?: string;
error_message?: string;
}
interface NotificationLogsResponse {
logs: NotificationLogEntry[];
total: number;
page: number;
per_page: number;
}
const channelIcons: Record<string, React.ReactNode> = {
email: <Mail className="h-4 w-4" />,
push: <Bell className="h-4 w-4" />,
whatsapp: <MessageCircle className="h-4 w-4" />,
telegram: <Send className="h-4 w-4" />,
};
const statusConfig: Record<string, { icon: React.ReactNode; color: string; label: string }> = {
sent: { icon: <CheckCircle2 className="h-4 w-4" />, color: 'text-green-600 bg-green-50', label: 'Sent' },
failed: { icon: <XCircle className="h-4 w-4" />, color: 'text-red-600 bg-red-50', label: 'Failed' },
pending: { icon: <Clock className="h-4 w-4" />, color: 'text-yellow-600 bg-yellow-50', label: 'Pending' },
queued: { icon: <RefreshCw className="h-4 w-4" />, color: 'text-blue-600 bg-blue-50', label: 'Queued' },
};
export default function ActivityLog() {
const [search, setSearch] = React.useState('');
const [channelFilter, setChannelFilter] = React.useState('all');
const [statusFilter, setStatusFilter] = React.useState('all');
const [page, setPage] = React.useState(1);
const { data, isLoading, error, refetch } = useQuery<NotificationLogsResponse>({
queryKey: ['notification-logs', page, channelFilter, statusFilter, search],
queryFn: async () => {
const params = new URLSearchParams();
params.set('page', page.toString());
params.set('per_page', '20');
if (channelFilter !== 'all') params.set('channel', channelFilter);
if (statusFilter !== 'all') params.set('status', statusFilter);
if (search) params.set('search', search);
return api.get(`/notifications/logs?${params.toString()}`);
},
});
const formatDate = (dateStr: string) => {
return new Date(dateStr).toLocaleString();
};
return (
<SettingsLayout
title={__('Activity Log')}
description={__('View notification history and delivery status')}
action={
<Link to="/settings/notifications">
<Button variant="outline">
<ArrowLeft className="mr-2 h-4 w-4" />
{__('Back')}
</Button>
</Link>
}
>
<div className="space-y-6">
{/* Filters */}
<Card>
<CardContent className="pt-6">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={__('Search by recipient or subject...')}
value={search}
onChange={(e) => setSearch(e.target.value)}
className="!pl-9"
/>
</div>
</div>
<Select value={channelFilter} onValueChange={setChannelFilter}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder={__('Channel')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{__('All Channels')}</SelectItem>
<SelectItem value="email">{__('Email')}</SelectItem>
<SelectItem value="push">{__('Push')}</SelectItem>
<SelectItem value="whatsapp">{__('WhatsApp')}</SelectItem>
<SelectItem value="telegram">{__('Telegram')}</SelectItem>
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder={__('Status')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{__('All Status')}</SelectItem>
<SelectItem value="sent">{__('Sent')}</SelectItem>
<SelectItem value="failed">{__('Failed')}</SelectItem>
<SelectItem value="pending">{__('Pending')}</SelectItem>
<SelectItem value="queued">{__('Queued')}</SelectItem>
</SelectContent>
</Select>
<Button variant="outline" size="icon" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
{/* Activity Log Table */}
<Card>
<CardHeader>
<CardTitle>{__('Recent Activity')}</CardTitle>
<CardDescription>
{data?.total ? `${data.total} ${__('notifications found')}` : __('Loading...')}
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-12 text-muted-foreground">
<RefreshCw className="h-8 w-8 mx-auto mb-2 animate-spin" />
<p>{__('Loading activity log...')}</p>
</div>
) : error ? (
<div className="text-center py-12 text-muted-foreground">
<XCircle className="h-8 w-8 mx-auto mb-2 text-red-500" />
<p>{__('Failed to load activity log')}</p>
<Button variant="outline" className="mt-4" onClick={() => refetch()}>
{__('Try Again')}
</Button>
</div>
) : !data?.logs?.length ? (
<div className="text-center py-12 text-muted-foreground">
<Bell className="h-12 w-12 mx-auto mb-2 opacity-30" />
<p className="text-lg font-medium">{__('No notifications yet')}</p>
<p className="text-sm">{__('Notification activities will appear here once sent.')}</p>
</div>
) : (
<div className="space-y-4">
{data.logs.map((log) => (
<div
key={log.id}
className="flex items-start gap-4 p-4 border rounded-lg hover:bg-muted/50 transition-colors"
>
{/* Channel Icon */}
<div className="p-2 bg-muted rounded-lg">
{channelIcons[log.channel] || <Bell className="h-4 w-4" />}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium truncate">{log.event}</span>
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs ${statusConfig[log.status]?.color || 'text-gray-600 bg-gray-50'}`}
>
{statusConfig[log.status]?.icon}
{statusConfig[log.status]?.label || log.status}
</span>
</div>
<p className="text-sm text-muted-foreground truncate">
{__('To')}: {log.recipient}
{log.subject && `${log.subject}`}
</p>
{log.error_message && (
<p className="text-sm text-red-600 mt-1">
{__('Error')}: {log.error_message}
</p>
)}
</div>
{/* Timestamp */}
<div className="text-xs text-muted-foreground whitespace-nowrap">
{formatDate(log.sent_at || log.created_at)}
</div>
</div>
))}
{/* Pagination */}
{data.total > 20 && (
<div className="flex items-center justify-between pt-4 border-t">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage(p => Math.max(1, p - 1))}
>
{__('Previous')}
</Button>
<span className="text-sm text-muted-foreground">
{__('Page')} {page} {__('of')} {Math.ceil(data.total / 20)}
</span>
<Button
variant="outline"
size="sm"
disabled={page >= Math.ceil(data.total / 20)}
onClick={() => setPage(p => p + 1)}
>
{__('Next')}
</Button>
</div>
)}
</div>
)}
</CardContent>
</Card>
</div>
</SettingsLayout>
);
}

View File

@@ -41,6 +41,10 @@ interface WNW_CONFIG {
decimalSeparator: string;
decimals: number;
};
storeUrl?: string;
customerSpaEnabled?: boolean;
nonce?: string;
pluginUrl?: string;
}
declare global {
@@ -52,4 +56,4 @@ declare global {
}
}
export {};
export { };