From d9878c8b205fe5a632f9d3cc242acaee199e85f0 Mon Sep 17 00:00:00 2001 From: Dwindi Ramadhana Date: Sun, 4 Jan 2026 19:06:18 +0700 Subject: [PATCH] feat: Refactor Newsletter with horizontal tabs (Subscribers | Campaigns) - Created Newsletter/index.tsx as tabs container - Extracted Newsletter/Subscribers.tsx (from old Newsletter.tsx) - Moved Campaigns to Newsletter/Campaigns.tsx - Updated App.tsx routes (campaigns now under newsletter) - Removed separate Campaigns card from Marketing index - Follows Customer Notifications tab pattern for consistency --- admin-spa/src/App.tsx | 8 +- .../routes/Marketing/Newsletter/Campaigns.tsx | 289 ++++++++++++++++++ .../Marketing/Newsletter/Subscribers.tsx | 192 ++++++++++++ .../src/routes/Marketing/Newsletter/index.tsx | 74 +++++ admin-spa/src/routes/Marketing/index.tsx | 10 +- 5 files changed, 560 insertions(+), 13 deletions(-) create mode 100644 admin-spa/src/routes/Marketing/Newsletter/Campaigns.tsx create mode 100644 admin-spa/src/routes/Marketing/Newsletter/Subscribers.tsx create mode 100644 admin-spa/src/routes/Marketing/Newsletter/index.tsx diff --git a/admin-spa/src/App.tsx b/admin-spa/src/App.tsx index d1438e7..456d81c 100644 --- a/admin-spa/src/App.tsx +++ b/admin-spa/src/App.tsx @@ -257,8 +257,7 @@ import AppearanceCheckout from '@/routes/Appearance/Checkout'; import AppearanceThankYou from '@/routes/Appearance/ThankYou'; import AppearanceAccount from '@/routes/Appearance/Account'; import MarketingIndex from '@/routes/Marketing'; -import NewsletterSubscribers from '@/routes/Marketing/Newsletter'; -import CampaignsList from '@/routes/Marketing/Campaigns'; +import Newsletter from '@/routes/Marketing/Newsletter'; import CampaignEdit from '@/routes/Marketing/Campaigns/Edit'; import MorePage from '@/routes/More'; import Help from '@/routes/Help'; @@ -580,9 +579,8 @@ function AppRoutes() { {/* Marketing */} } /> - } /> - } /> - } /> + } /> + } /> {/* Help - Main menu route with no submenu */} } /> diff --git a/admin-spa/src/routes/Marketing/Newsletter/Campaigns.tsx b/admin-spa/src/routes/Marketing/Newsletter/Campaigns.tsx new file mode 100644 index 0000000..1c0b870 --- /dev/null +++ b/admin-spa/src/routes/Marketing/Newsletter/Campaigns.tsx @@ -0,0 +1,289 @@ +import React, { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; +import { SettingsCard } from '@/routes/Settings/components/SettingsCard'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Plus, + Search, + Send, + Clock, + CheckCircle2, + AlertCircle, + Trash2, + Edit, + MoreHorizontal, + Copy +} from 'lucide-react'; +import { toast } from 'sonner'; +import { api } from '@/lib/api'; +import { __ } from '@/lib/i18n'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; + +interface Campaign { + id: number; + title: string; + subject: string; + status: 'draft' | 'scheduled' | 'sending' | 'sent' | 'failed'; + recipient_count: number; + sent_count: number; + failed_count: number; + scheduled_at: string | null; + sent_at: string | null; + created_at: string; +} + +const statusConfig = { + draft: { label: 'Draft', icon: Edit, className: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-300' }, + scheduled: { label: 'Scheduled', icon: Clock, className: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300' }, + sending: { label: 'Sending', icon: Send, className: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300' }, + sent: { label: 'Sent', icon: CheckCircle2, className: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300' }, + failed: { label: 'Failed', icon: AlertCircle, className: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300' }, +}; + +export default function Campaigns() { + const [searchQuery, setSearchQuery] = useState(''); + const [deleteId, setDeleteId] = useState(null); + const navigate = useNavigate(); + const queryClient = useQueryClient(); + + const { data, isLoading } = useQuery({ + queryKey: ['campaigns'], + queryFn: async () => { + const response = await api.get('/campaigns'); + return response.data as Campaign[]; + }, + }); + + const deleteMutation = useMutation({ + mutationFn: async (id: number) => { + await api.del(`/campaigns/${id}`); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['campaigns'] }); + toast.success(__('Campaign deleted')); + setDeleteId(null); + }, + onError: () => { + toast.error(__('Failed to delete campaign')); + }, + }); + + const duplicateMutation = useMutation({ + mutationFn: async (campaign: Campaign) => { + const response = await api.post('/campaigns', { + title: `${campaign.title} (Copy)`, + subject: campaign.subject, + content: '', + status: 'draft', + }); + return response; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['campaigns'] }); + toast.success(__('Campaign duplicated')); + }, + onError: () => { + toast.error(__('Failed to duplicate campaign')); + }, + }); + + const campaigns = data || []; + const filteredCampaigns = campaigns.filter((c) => + c.title.toLowerCase().includes(searchQuery.toLowerCase()) || + c.subject?.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + const formatDate = (dateStr: string | null) => { + if (!dateStr) return '-'; + return new Date(dateStr).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + }; + + return ( +
+ +
+ {/* Actions Bar */} +
+
+ + setSearchQuery(e.target.value)} + className="!pl-9" + /> +
+ +
+ + {/* Campaigns Table */} + {isLoading ? ( +
+ {__('Loading campaigns...')} +
+ ) : filteredCampaigns.length === 0 ? ( +
+ {searchQuery ? __('No campaigns found matching your search') : ( +
+ +

{__('No campaigns yet')}

+ +
+ )} +
+ ) : ( +
+ + + + {__('Title')} + {__('Status')} + {__('Recipients')} + {__('Date')} + {__('Actions')} + + + + {filteredCampaigns.map((campaign) => { + const status = statusConfig[campaign.status] || statusConfig.draft; + const StatusIcon = status.icon; + + return ( + + +
+
{campaign.title}
+ {campaign.subject && ( +
+ {campaign.subject} +
+ )} +
+
+ + + + {__(status.label)} + + + + {campaign.status === 'sent' ? ( + + {campaign.sent_count}/{campaign.recipient_count} + {campaign.failed_count > 0 && ( + + ({campaign.failed_count} {__('failed')}) + + )} + + ) : ( + '-' + )} + + + {campaign.sent_at + ? formatDate(campaign.sent_at) + : campaign.scheduled_at + ? `${__('Scheduled')}: ${formatDate(campaign.scheduled_at)}` + : formatDate(campaign.created_at) + } + + + + + + + + navigate(`/marketing/newsletter/campaigns/${campaign.id}`)}> + + {__('Edit')} + + duplicateMutation.mutate(campaign)}> + + {__('Duplicate')} + + setDeleteId(campaign.id)} + className="text-red-600" + > + + {__('Delete')} + + + + +
+ ); + })} +
+
+
+ )} +
+
+ + {/* Delete Confirmation Dialog */} + setDeleteId(null)}> + + + {__('Delete Campaign')} + + {__('Are you sure you want to delete this campaign? This action cannot be undone.')} + + + + {__('Cancel')} + deleteId && deleteMutation.mutate(deleteId)} + className="bg-red-600 hover:bg-red-700" + > + {__('Delete')} + + + + +
+ ); +} diff --git a/admin-spa/src/routes/Marketing/Newsletter/Subscribers.tsx b/admin-spa/src/routes/Marketing/Newsletter/Subscribers.tsx new file mode 100644 index 0000000..59695d9 --- /dev/null +++ b/admin-spa/src/routes/Marketing/Newsletter/Subscribers.tsx @@ -0,0 +1,192 @@ +import React, { useState } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { SettingsCard } from '@/routes/Settings/components/SettingsCard'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Download, Trash2, Search } from 'lucide-react'; +import { toast } from 'sonner'; +import { api } from '@/lib/api'; +import { useNavigate } from 'react-router-dom'; +import { __ } from '@/lib/i18n'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; + +export default function Subscribers() { + const [searchQuery, setSearchQuery] = useState(''); + const queryClient = useQueryClient(); + const navigate = useNavigate(); + + const { data: subscribersData, isLoading } = useQuery({ + queryKey: ['newsletter-subscribers'], + queryFn: async () => { + const response = await api.get('/newsletter/subscribers'); + return response.data; + }, + }); + + const deleteSubscriber = useMutation({ + mutationFn: async (email: string) => { + await api.del(`/newsletter/subscribers/${encodeURIComponent(email)}`); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['newsletter-subscribers'] }); + toast.success(__('Subscriber removed successfully')); + }, + onError: () => { + toast.error(__('Failed to remove subscriber')); + }, + }); + + const exportSubscribers = () => { + if (!subscribersData?.subscribers) return; + + const csv = ['Email,Subscribed Date'].concat( + subscribersData.subscribers.map((sub: any) => + `${sub.email},${sub.subscribed_at || 'N/A'}` + ) + ).join('\n'); + + const blob = new Blob([csv], { type: 'text/csv' }); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `newsletter-subscribers-${new Date().toISOString().split('T')[0]}.csv`; + a.click(); + window.URL.revokeObjectURL(url); + }; + + const subscribers = subscribersData?.subscribers || []; + const filteredSubscribers = subscribers.filter((sub: any) => + sub.email.toLowerCase().includes(searchQuery.toLowerCase()) + ); + + return ( +
+ +
+ {/* Actions Bar */} +
+
+ + setSearchQuery(e.target.value)} + className="!pl-9" + /> +
+ +
+ + {/* Subscribers Table */} + {isLoading ? ( +
+ {__('Loading subscribers...')} +
+ ) : filteredSubscribers.length === 0 ? ( +
+ {searchQuery ? __('No subscribers found matching your search') : __('No subscribers yet')} +
+ ) : ( +
+ + + + {__('Email')} + {__('Status')} + {__('Subscribed Date')} + {__('WP User')} + {__('Actions')} + + + + {filteredSubscribers.map((subscriber: any) => ( + + {subscriber.email} + + + {subscriber.status || __('Active')} + + + + {subscriber.subscribed_at + ? new Date(subscriber.subscribed_at).toLocaleDateString() + : 'N/A' + } + + + {subscriber.user_id ? ( + {__('Yes')} (ID: {subscriber.user_id}) + ) : ( + {__('No')} + )} + + + + + + ))} + +
+
+ )} +
+
+ + {/* Email Template Settings */} + +
+
+

{__('Newsletter Welcome Email')}

+

+ {__('Welcome email sent when someone subscribes to your newsletter')} +

+ +
+ +
+

{__('New Subscriber Notification (Admin)')}

+

+ {__('Admin notification when someone subscribes to newsletter')} +

+ +
+
+
+
+ ); +} diff --git a/admin-spa/src/routes/Marketing/Newsletter/index.tsx b/admin-spa/src/routes/Marketing/Newsletter/index.tsx new file mode 100644 index 0000000..c4ddb55 --- /dev/null +++ b/admin-spa/src/routes/Marketing/Newsletter/index.tsx @@ -0,0 +1,74 @@ +import React, { useState, useEffect } from 'react'; +import { useSearchParams, useNavigate } from 'react-router-dom'; +import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Button } from '@/components/ui/button'; +import { Mail } from 'lucide-react'; +import { __ } from '@/lib/i18n'; +import { useModules } from '@/hooks/useModules'; +import Subscribers from './Subscribers'; +import Campaigns from './Campaigns'; + +export default function Newsletter() { + const [searchParams, setSearchParams] = useSearchParams(); + const [activeTab, setActiveTab] = useState('subscribers'); + const navigate = useNavigate(); + const { isEnabled } = useModules(); + + // Check for tab query param + useEffect(() => { + const tabParam = searchParams.get('tab'); + if (tabParam && ['subscribers', 'campaigns'].includes(tabParam)) { + setActiveTab(tabParam); + } + }, [searchParams]); + + // Update URL when tab changes + const handleTabChange = (value: string) => { + setActiveTab(value); + setSearchParams({ tab: value }); + }; + + // Show disabled state if newsletter module is off + if (!isEnabled('newsletter')) { + return ( + +
+ +

{__('Newsletter Module Disabled')}

+

+ {__('The newsletter module is currently disabled. Enable it in Settings > Modules to use this feature.')} +

+ +
+
+ ); + } + + return ( + + + + {__('Subscribers')} + {__('Campaigns')} + + + + + + + + + + + + ); +} diff --git a/admin-spa/src/routes/Marketing/index.tsx b/admin-spa/src/routes/Marketing/index.tsx index 35f9b61..9cb61a6 100644 --- a/admin-spa/src/routes/Marketing/index.tsx +++ b/admin-spa/src/routes/Marketing/index.tsx @@ -1,6 +1,6 @@ import { useNavigate } from 'react-router-dom'; import { SettingsLayout } from '@/routes/Settings/components/SettingsLayout'; -import { Mail, Send, Tag } from 'lucide-react'; +import { Mail, Tag } from 'lucide-react'; import { __ } from '@/lib/i18n'; interface MarketingCard { @@ -13,16 +13,10 @@ interface MarketingCard { const cards: MarketingCard[] = [ { title: __('Newsletter'), - description: __('Manage subscribers and email templates'), + description: __('Manage subscribers and send email campaigns'), icon: Mail, to: '/marketing/newsletter', }, - { - title: __('Campaigns'), - description: __('Create and send email campaigns'), - icon: Send, - to: '/marketing/campaigns', - }, { title: __('Coupons'), description: __('Discounts, promotions, and coupon codes'),