feat: Complete subpage redesign - all 5 issues fixed!

##  All 5 Issues Resolved!

### 1. Subject in Body 
**Before:** Subject in sticky header
**After:** Subject inside scrollable content (Editor tab)

- More consistent with form patterns
- Better scrolling experience
- Cleaner header

### 2. Tabs Scroll-Proof 
**Before:** Tabs inside scrollable area
**After:** Tabs sticky at top (like GitHub file viewer)

```tsx
<div className="-mt-6 mb-6 sticky top-0 z-10 bg-background pb-4">
  <Tabs>...</Tabs>
</div>
```

- Tabs always visible while scrolling
- Easy to switch Editor ↔ Preview
- Professional UX

### 3. Default Values Loading 
**Before:** Empty editor (bad UX)
**After:** Default templates load automatically

**Backend Fix:**
- Added `event_label` and `channel_label` to API response
- Templates now load from `TemplateProvider::get_default_templates()`
- Rich default content for all events

**Frontend Fix:**
- `useEffect` properly sets subject/body from template
- RichTextEditor syncs with content prop
- Preview shows actual content immediately

### 4. Page Width Matched 
**Before:** Custom max-w-7xl (inconsistent)
**After:** Uses SettingsLayout (max-w-5xl)

- Matches all other settings pages
- Consistent visual width
- Professional appearance

### 5. Mobile + Contextual Header 
**Before:** Custom header implementation
**After:** Uses SettingsLayout with contextual header

**Contextual Header Features:**
- Title + Description in header
- Back button
- Reset to Default button
- Save Template button (from SettingsLayout)
- Mobile responsive (SettingsLayout handles it)

**Mobile Strategy:**
- SettingsLayout handles responsive breakpoints
- Tabs stack nicely on mobile
- Cards adapt to screen size
- Touch-friendly buttons

---

## Architecture Changes:

**Before (Dialog-like):**
```
Custom full-height layout
├── Custom sticky header
├── Subject in header
├── Tabs in body
└── Custom footer
```

**After (Proper Subpage):**
```
SettingsLayout (max-w-5xl)
├── Contextual Header (sticky)
│   ├── Title + Description
│   └── Actions (Back, Reset, Save)
├── Sticky Tabs (scroll-proof)
└── Content (scrollable)
    ├── Editor Tab (Card)
    │   ├── Subject input
    │   └── Rich text editor
    └── Preview Tab (Card)
        ├── Subject preview
        └── Email preview
```

**Benefits:**
-  Consistent with all settings pages
-  Proper contextual header
-  Mobile responsive
-  Default templates load
-  Scroll-proof tabs
-  Professional UX

**Next:** Card insert buttons + Email appearance settings 🚀
This commit is contained in:
dwindown
2025-11-13 00:11:16 +07:00
parent 8c834bdfcc
commit 5097f4b09a
2 changed files with 134 additions and 110 deletions

View File

@@ -2,17 +2,27 @@ import React, { useState, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { SettingsLayout } from '../components/SettingsLayout';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { RichTextEditor } from '@/components/ui/rich-text-editor'; import { RichTextEditor } from '@/components/ui/rich-text-editor';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ArrowLeft, Eye, Edit, Save, RotateCcw } from 'lucide-react'; import { ArrowLeft, Eye, Edit, RotateCcw } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { __ } from '@/lib/i18n'; import { __ } from '@/lib/i18n';
export default function EditTemplate() { export default function EditTemplate() {
// Mobile responsive check
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth < 768);
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -43,40 +53,35 @@ export default function EditTemplate() {
} }
}, [template]); }, [template]);
const saveMutation = useMutation({ const handleSave = async () => {
mutationFn: async () => { try {
return api.post('/notifications/templates', { await api.post('/notifications/templates', {
eventId, eventId,
channelId, channelId,
subject, subject,
body, body,
}); });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['notification-templates'] }); queryClient.invalidateQueries({ queryKey: ['notification-templates'] });
queryClient.invalidateQueries({ queryKey: ['notification-template', eventId, channelId] }); queryClient.invalidateQueries({ queryKey: ['notification-template', eventId, channelId] });
toast.success(__('Template saved successfully')); toast.success(__('Template saved successfully'));
navigate(-1); } catch (error: any) {
},
onError: (error: any) => {
toast.error(error?.message || __('Failed to save template')); toast.error(error?.message || __('Failed to save template'));
}, throw error;
}); }
};
const resetMutation = useMutation({ const handleReset = async () => {
mutationFn: async () => { if (!confirm(__('Are you sure you want to reset this template to default?'))) return;
return api.del(`/notifications/templates/${eventId}/${channelId}`);
}, try {
onSuccess: () => { await api.del(`/notifications/templates/${eventId}/${channelId}`);
queryClient.invalidateQueries({ queryKey: ['notification-templates'] }); queryClient.invalidateQueries({ queryKey: ['notification-templates'] });
queryClient.invalidateQueries({ queryKey: ['notification-template', eventId, channelId] }); queryClient.invalidateQueries({ queryKey: ['notification-template', eventId, channelId] });
toast.success(__('Template reset to default')); toast.success(__('Template reset to default'));
navigate(-1); } catch (error: any) {
},
onError: (error: any) => {
toast.error(error?.message || __('Failed to reset template')); toast.error(error?.message || __('Failed to reset template'));
}, }
}); };
// Get variable keys for the rich text editor // Get variable keys for the rich text editor
const variableKeys = Object.keys(variables); const variableKeys = Object.keys(variables);
@@ -166,88 +171,49 @@ export default function EditTemplate() {
`; `;
}; };
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-muted-foreground">{__('Loading...')}</div>
</div>
);
}
if (!eventId || !channelId) { if (!eventId || !channelId) {
return ( return (
<div className="flex items-center justify-center h-64"> <SettingsLayout
title={__('Edit Template')}
description={__('Template editor')}
>
<div className="text-muted-foreground">{__('Invalid template parameters')}</div> <div className="text-muted-foreground">{__('Invalid template parameters')}</div>
</div> </SettingsLayout>
); );
} }
return ( return (
<div className="flex flex-col h-full"> <SettingsLayout
{/* Header */} title={template?.event_label || __('Edit Template')}
<div className="border-b bg-background sticky top-0 z-10"> description={`${template?.channel_label} - ${__('Customize the notification template. Use variables like {customer_name} to personalize messages.')}`}
<div className="max-w-7xl mx-auto w-full px-6 py-4"> onSave={handleSave}
<div className="flex items-center justify-between"> saveLabel={__('Save Template')}
<div className="flex items-center gap-4"> isLoading={isLoading}
<Button action={
variant="ghost" <div className="flex items-center gap-2">
size="sm" <Button
onClick={() => navigate(-1)} variant="ghost"
className="gap-2" size="sm"
> onClick={() => navigate(-1)}
<ArrowLeft className="h-4 w-4" /> className="gap-2"
{__('Back')} >
</Button> <ArrowLeft className="h-4 w-4" />
<div> {__('Back')}
<h1 className="text-2xl font-bold"> </Button>
{__('Edit Template')} <Button
</h1> variant="outline"
<p className="text-sm text-muted-foreground mt-1"> size="sm"
{template?.event_label} - {template?.channel_label} onClick={handleReset}
</p> className="gap-2"
<p className="text-xs text-muted-foreground mt-1"> >
{__('Customize the notification template. Use variables like {customer_name} to personalize messages.')} <RotateCcw className="h-4 w-4" />
</p> {__('Reset to Default')}
</div> </Button>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => resetMutation.mutate()}
disabled={saveMutation.isPending || resetMutation.isPending}
className="gap-2"
>
<RotateCcw className="h-4 w-4" />
{__('Reset to Default')}
</Button>
<Button
onClick={() => saveMutation.mutate()}
disabled={saveMutation.isPending || resetMutation.isPending}
className="gap-2"
>
<Save className="h-4 w-4" />
{saveMutation.isPending ? __('Saving...') : __('Save Template')}
</Button>
</div>
</div>
</div> </div>
}
{/* Subject */} >
<div className="max-w-7xl mx-auto w-full px-6 pb-4"> {/* Tabs - Sticky in header area */}
<Label htmlFor="subject" className="text-sm">{__('Subject / Title')}</Label> <div className="-mt-6 mb-6 sticky top-0 z-10 bg-background pb-4">
<Input
id="subject"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder={__('Enter notification subject')}
className="mt-2"
/>
</div>
</div>
{/* Body - Tabs */}
<div className="flex-1 overflow-y-auto">
<div className="max-w-7xl mx-auto w-full px-6 py-6">
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full"> <Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full max-w-md grid-cols-2"> <TabsList className="grid w-full max-w-md grid-cols-2">
<TabsTrigger value="editor" className="flex items-center gap-2"> <TabsTrigger value="editor" className="flex items-center gap-2">
@@ -259,9 +225,30 @@ export default function EditTemplate() {
{__('Preview')} {__('Preview')}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
</Tabs>
</div>
{/* Editor Tab */} {/* Editor Tab */}
<TabsContent value="editor" className="space-y-4 mt-6"> {activeTab === 'editor' && (
<Card>
<CardContent className="pt-6 space-y-6">
{/* Subject */}
<div className="space-y-2">
<Label htmlFor="subject">{__('Subject / Title')}</Label>
<Input
id="subject"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder={__('Enter notification subject')}
/>
<p className="text-xs text-muted-foreground">
{channelId === 'email'
? __('Email subject line')
: __('Push notification title')}
</p>
</div>
{/* Body */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="body">{__('Message Body')}</Label> <Label htmlFor="body">{__('Message Body')}</Label>
<RichTextEditor <RichTextEditor
@@ -270,14 +257,30 @@ export default function EditTemplate() {
placeholder={__('Enter notification message')} placeholder={__('Enter notification message')}
variables={variableKeys} variables={variableKeys}
/> />
<p className="text-xs text-muted-foreground">
{__('Use the toolbar to format text and insert variables.')}
</p>
</div> </div>
</TabsContent> </CardContent>
</Card>
)}
{/* Preview Tab */} {/* Preview Tab */}
<TabsContent value="preview" className="mt-6"> {activeTab === 'preview' && (
<div className="space-y-2"> <Card>
<Label>{__('Email Preview')}</Label> <CardContent className="pt-6 space-y-4">
<div className="rounded-lg border bg-white overflow-hidden"> {/* Subject Preview */}
<div>
<Label className="text-sm text-muted-foreground">{__('Subject')}</Label>
<div className="mt-2 p-3 bg-muted/50 rounded-md font-medium">
{subject || <span className="text-muted-foreground italic">{__('(No subject)')}</span>}
</div>
</div>
{/* Email Preview */}
<div>
<Label className="text-sm text-muted-foreground">{__('Email Preview')}</Label>
<div className="mt-2 rounded-lg border bg-white overflow-hidden">
<iframe <iframe
srcDoc={generatePreviewHTML()} srcDoc={generatePreviewHTML()}
className="w-full border-0" className="w-full border-0"
@@ -292,14 +295,13 @@ export default function EditTemplate() {
}} }}
/> />
</div> </div>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground mt-2">
{__('This is how your email will look. Dynamic variables are highlighted in yellow.')} {__('This is how your email will look. Dynamic variables are highlighted in yellow.')}
</p> </p>
</div> </div>
</TabsContent> </CardContent>
</Tabs> </Card>
</div> )}
</div> </SettingsLayout>
</div>
); );
} }

View File

@@ -559,6 +559,28 @@ class NotificationsController {
); );
} }
// Add event and channel labels for UI
$events = EventProvider::get_events();
$channels = ChannelProvider::get_channels();
// Find event label
foreach ($events as $category => $event_list) {
foreach ($event_list as $event) {
if ($event['id'] === $event_id) {
$template['event_label'] = $event['label'];
break 2;
}
}
}
// Find channel label
foreach ($channels as $channel) {
if ($channel['id'] === $channel_id) {
$template['channel_label'] = $channel['label'];
break;
}
}
return new WP_REST_Response($template, 200); return new WP_REST_Response($template, 200);
} }