Add notification template management features, including creation, editing, and version control. Implement filtering options by category, language, and channel. Introduce a preview functionality and enhance the user interface for better usability.
This commit is contained in:
parent
b181f99237
commit
76841b5b81
133
docx/overview.docx
Normal file
133
docx/overview.docx
Normal file
@ -0,0 +1,133 @@
|
||||
Notification System User Requirements
|
||||
1. Notification Templates
|
||||
Functional Requirements
|
||||
Template Creation: Users must be able to create reusable notification templates with customizable content, subject lines, and formatting
|
||||
Multi-Channel Support: Templates must support different notification channels (email, SMS, push notifications, in-app notifications)
|
||||
Dynamic Content: Templates must support placeholder variables and merge fields for personalized content (user name, dates, amounts, etc.)
|
||||
Rich Content: Email templates must support HTML formatting, images, links, and attachments
|
||||
Template Versioning: System must maintain version history of templates with ability to revert to previous versions
|
||||
Template Categories: Users must be able to organize templates by categories or tags for easy management
|
||||
Preview Functionality: Users must be able to preview templates before saving or sending
|
||||
Localization Support: Templates must support multiple languages and localization
|
||||
Non-Functional Requirements
|
||||
Templates must load within 2 seconds
|
||||
System must support at least 1000 active templates
|
||||
Template editor must be responsive and work on mobile devices
|
||||
Must support templates up to 100KB in size
|
||||
2. Notification Triggers / Rules
|
||||
Functional Requirements
|
||||
Event-Based Triggers: System must trigger notifications based on specific events (user registration, purchase completion, password reset, etc.)
|
||||
Condition-Based Rules: Users must be able to set complex conditional logic (if/then/else scenarios)
|
||||
Time-Based Triggers: Support for scheduled notifications, recurring notifications, and time-delayed triggers
|
||||
User Segmentation: Ability to target specific user groups based on demographics, behavior, or custom attributes
|
||||
Trigger Prioritization: System must support priority levels for different trigger types
|
||||
Rule Testing: Users must be able to test rules before activation
|
||||
Rule Dependencies: Support for chained triggers and dependent rule execution
|
||||
External API Triggers: Ability to trigger notifications via external API calls or webhooks
|
||||
Non-Functional Requirements
|
||||
Rules must be evaluated within 100 milliseconds
|
||||
System must support at least 500 active rules simultaneously
|
||||
Rules engine must be fault-tolerant and handle errors gracefully
|
||||
Must support real-time trigger processing
|
||||
3. Notification Queue & Scheduler
|
||||
Functional Requirements
|
||||
Queue Management: System must queue notifications and process them in order with different priority levels
|
||||
Batch Processing: Ability to group and send notifications in batches to optimize delivery
|
||||
Scheduling: Users must be able to schedule notifications for future delivery at specific dates and times
|
||||
Retry Logic: Failed notifications must be automatically retried with configurable retry intervals
|
||||
Rate Limiting: System must respect rate limits for different channels and recipients
|
||||
Queue Monitoring: Real-time visibility into queue status, pending notifications, and processing rates
|
||||
Dead Letter Queue: Failed notifications after maximum retries must be moved to a separate queue for manual review
|
||||
Timezone Handling: Proper handling of different timezones for scheduled notifications
|
||||
Non-Functional Requirements
|
||||
Queue must process at least 10,000 notifications per minute
|
||||
System must maintain 99.9% uptime for queue processing
|
||||
Queue data must be persistent and survive system restarts
|
||||
Must support horizontal scaling of queue workers
|
||||
4. Notification Delivery Engine
|
||||
Functional Requirements
|
||||
Multi-Channel Delivery: Support for email, SMS, push notifications, and in-app notifications
|
||||
Delivery Status Tracking: Real-time tracking of delivery status (sent, delivered, failed, bounced)
|
||||
Fallback Mechanisms: Automatic fallback to alternative channels if primary delivery fails
|
||||
Delivery Optimization: Intelligent routing and timing optimization for better delivery rates
|
||||
Bounce Handling: Automatic handling of bounced emails and invalid phone numbers
|
||||
Delivery Reporting: Comprehensive delivery reports with success rates and failure reasons
|
||||
Third-Party Integration: Integration with popular email services (SendGrid, AWS SES) and SMS providers
|
||||
Webhook Support: Delivery status callbacks via webhooks for external systems
|
||||
Non-Functional Requirements
|
||||
Delivery engine must process 99.5% of notifications successfully
|
||||
Average delivery time must be under 30 seconds for high-priority notifications
|
||||
System must handle peak loads of 50,000 notifications per hour
|
||||
Must maintain delivery logs for at least 90 days
|
||||
5. User Preferences & Subscription Settings
|
||||
Functional Requirements
|
||||
Preference Management: Users must be able to manage their notification preferences across all channels
|
||||
Granular Controls: Fine-grained control over notification types, frequency, and timing
|
||||
Subscription Categories: Ability to subscribe/unsubscribe from different notification categories
|
||||
Quiet Hours: Users must be able to set "do not disturb" time periods
|
||||
Channel Preferences: Option to choose preferred notification channels for different types of messages
|
||||
Frequency Limits: Users can set maximum frequency limits for non-critical notifications
|
||||
One-Click Unsubscribe: Easy unsubscribe mechanism with single-click options
|
||||
Preference Import/Export: Ability to backup and restore notification preferences
|
||||
Non-Functional Requirements
|
||||
Preference changes must take effect within 5 minutes
|
||||
Preference interface must load within 3 seconds
|
||||
System must respect user preferences 100% of the time
|
||||
Must support preferences for at least 1 million users
|
||||
6. Notification Logs & Audit Trail
|
||||
Functional Requirements
|
||||
Comprehensive Logging: Complete audit trail of all notification activities including creation, modification, and delivery
|
||||
Search and Filter: Advanced search capabilities with filters by date, user, channel, status, and content
|
||||
Delivery Analytics: Detailed analytics on delivery rates, open rates, click-through rates, and engagement metrics
|
||||
Error Logging: Detailed error logs with root cause analysis and troubleshooting information
|
||||
Export Functionality: Ability to export logs and reports in various formats (CSV, PDF, Excel)
|
||||
Retention Policies: Configurable data retention policies for different types of logs
|
||||
Compliance Reporting: Generate compliance reports for regulatory requirements
|
||||
Real-Time Monitoring: Live dashboard showing current notification activity and system health
|
||||
Non-Functional Requirements
|
||||
Log queries must return results within 5 seconds
|
||||
System must retain logs for minimum 1 year
|
||||
Logs must be searchable and indexable
|
||||
Must support concurrent access by multiple administrators
|
||||
7. Admin Panel / Management Interface
|
||||
Functional Requirements
|
||||
Dashboard Overview: Comprehensive dashboard showing key metrics, system health, and recent activity
|
||||
User Management: Admin tools to manage user accounts, permissions, and notification preferences
|
||||
System Configuration: Interface to configure system settings, rate limits, and integration parameters
|
||||
Template Management: Centralized management of all notification templates with approval workflows
|
||||
Rule Management: Interface to create, modify, and monitor notification rules and triggers
|
||||
Reporting Tools: Built-in reporting tools with customizable charts and metrics
|
||||
System Monitoring: Real-time monitoring of system performance, queue status, and delivery rates
|
||||
Role-Based Access: Different access levels for administrators, managers, and operators
|
||||
Bulk Operations: Ability to perform bulk operations on users, templates, and notifications
|
||||
API Management: Interface to manage API keys, webhooks, and external integrations
|
||||
Non-Functional Requirements
|
||||
Admin interface must load within 3 seconds
|
||||
Must support concurrent access by up to 50 administrators
|
||||
Interface must be responsive and mobile-friendly
|
||||
Must provide role-based security with audit logging
|
||||
Dashboard must refresh data in real-time or near real-time
|
||||
General System Requirements
|
||||
Security Requirements
|
||||
All user data must be encrypted at rest and in transit
|
||||
API endpoints must be secured with authentication and authorization
|
||||
System must support SSO integration (SAML, OAuth)
|
||||
Regular security audits and vulnerability assessments
|
||||
GDPR and privacy regulation compliance
|
||||
Performance Requirements
|
||||
System must support 1 million active users
|
||||
99.9% system uptime requirement
|
||||
Database queries must execute within 2 seconds
|
||||
API response times must be under 500ms for 95% of requests
|
||||
Integration Requirements
|
||||
RESTful API for external system integration
|
||||
Webhook support for real-time event notifications
|
||||
Support for popular third-party services (CRM, marketing automation tools)
|
||||
Standard data import/export capabilities
|
||||
Scalability Requirements
|
||||
System must be horizontally scalable
|
||||
Support for cloud deployment (AWS, Azure, GCP)
|
||||
Auto-scaling capabilities based on load
|
||||
Database clustering and load balancing support
|
||||
|
||||
|
29853
package-lock.json
generated
Normal file
29853
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -46,6 +46,10 @@
|
||||
"@prisma/client": "^5.1.1",
|
||||
"@shimyshack/uid": "^0.1.7",
|
||||
"@sweetalert2/theme-dark": "^5.0.14",
|
||||
"@tiptap/extension-underline": "^2.12.0",
|
||||
"@tiptap/pm": "^2.12.0",
|
||||
"@tiptap/starter-kit": "^2.12.0",
|
||||
"@tiptap/vue-3": "^2.12.0",
|
||||
"@vueup/vue-quill": "^1.0.0",
|
||||
"@vueuse/core": "^9.5.0",
|
||||
"@vueuse/nuxt": "^9.5.0",
|
||||
|
380
pages/notification/templates/create_template/index.vue
Normal file
380
pages/notification/templates/create_template/index.vue
Normal file
@ -0,0 +1,380 @@
|
||||
<template>
|
||||
<rs-card>
|
||||
<template #header>
|
||||
<h2 class="text-xl font-semibold">{{ isEditing ? 'Edit' : 'Create' }} Notification Template</h2>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="pt-2">
|
||||
<rs-card class="shadow-sm">
|
||||
<div class="p-5 sm:p-6">
|
||||
<FormKit type="form" @submit="submitTemplate" :actions="false" class="w-full max-w-5xl mx-auto">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-1">
|
||||
<!-- Left Column -->
|
||||
<div class="space-y-4">
|
||||
<FormKit
|
||||
type="text"
|
||||
name="title"
|
||||
label="Template Title"
|
||||
placeholder="Enter template title"
|
||||
validation="required"
|
||||
v-model="templateForm.title"
|
||||
input-class="formkit-input appearance-none bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md py-2 px-3 text-base leading-normal focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-400 dark:focus:border-primary-400"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="text"
|
||||
name="subject"
|
||||
label="Subject Line / Notification Title"
|
||||
placeholder="e.g. Welcome {{first_name}}"
|
||||
validation="required"
|
||||
v-model="templateForm.subject"
|
||||
input-class="formkit-input appearance-none bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md py-2 px-3 text-base leading-normal focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-400 dark:focus:border-primary-400"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="select"
|
||||
name="category"
|
||||
label="Category"
|
||||
placeholder="Select category"
|
||||
:options="categoryOptions"
|
||||
validation="required"
|
||||
v-model="templateForm.category"
|
||||
input-class="formkit-input appearance-none bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md py-2 px-3 text-base leading-normal focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-400 dark:focus:border-primary-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
<div class="space-y-4">
|
||||
<FormKit
|
||||
type="select"
|
||||
name="language"
|
||||
label="Language"
|
||||
placeholder="Select language"
|
||||
:options="languageOptions"
|
||||
validation="required"
|
||||
v-model="templateForm.language"
|
||||
input-class="formkit-input appearance-none bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md py-2 px-3 text-base leading-normal focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-400 dark:focus:border-primary-400"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="checkbox"
|
||||
name="channels"
|
||||
label="Supported Channels"
|
||||
:options="channelOptions"
|
||||
validation="required|min:1"
|
||||
v-model="templateForm.channels"
|
||||
decorator-icon="material-symbols:check"
|
||||
options-class="grid grid-cols-2 gap-x-3 gap-y-1.5 pt-1"
|
||||
label-class="mb-0 text-sm font-medium"
|
||||
wrapper-class="mt-1"
|
||||
/>
|
||||
|
||||
<FormKit
|
||||
type="select"
|
||||
name="status"
|
||||
label="Status"
|
||||
placeholder="Select status"
|
||||
:options="statusOptions"
|
||||
validation="required"
|
||||
v-model="templateForm.status"
|
||||
input-class="formkit-input appearance-none bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md py-2 px-3 text-base leading-normal focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-400 dark:focus:border-primary-400"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Template Content Section -->
|
||||
<div class="mt-5">
|
||||
<FormKit
|
||||
type="textarea"
|
||||
name="content"
|
||||
label="Template Content"
|
||||
validation="required"
|
||||
:validation-messages="{ required: 'Content is required' }"
|
||||
v-model="templateForm.content"
|
||||
>
|
||||
<template #input="{ value, onChange }">
|
||||
<div class="editor-wrapper border dark:border-gray-600 rounded-lg overflow-hidden mt-1">
|
||||
<div class="menubar">
|
||||
<template v-for="(item, index) in menuItems" :key="index">
|
||||
<span v-if="item.type === 'separator'" class="separator dark:bg-gray-600"></span>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="menu-button dark:text-gray-300 dark:hover:bg-gray-700 dark:[&.is-active]:bg-gray-600"
|
||||
:class="{ 'is-active': editor?.isActive(item.name) }"
|
||||
@click="item.action"
|
||||
:title="item.label || (item.name ? item.name.charAt(0).toUpperCase() + item.name.slice(1) : '')"
|
||||
>
|
||||
<Icon :name="item.icon" />
|
||||
<span v-if="item.label">{{ item.label }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<editor-content
|
||||
:editor="editor"
|
||||
class="editor-content bg-white dark:bg-gray-800 dark:text-gray-200"
|
||||
@update:model-value="(val) => { templateForm.content = val; onChange(val); }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</FormKit>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex justify-end items-center mt-6 gap-4">
|
||||
<rs-button type="button" variant="outline" @click="previewTemplate" title="Preview Template">
|
||||
<Icon name="material-symbols:preview-outline" class="mr-1" />
|
||||
Preview
|
||||
</rs-button>
|
||||
<rs-button type="submit" :title="isEditing ? 'Save Changes' : 'Create Template'">
|
||||
<Icon name="material-symbols:save-rounded" class="mr-1" />
|
||||
{{ isEditing ? 'Save Changes' : 'Create Template' }}
|
||||
</rs-button>
|
||||
</div>
|
||||
</FormKit>
|
||||
</div>
|
||||
</rs-card>
|
||||
</div>
|
||||
|
||||
<!-- Preview Modal -->
|
||||
<rs-modal v-model="showPreviewModal" title="Template Preview" size="lg">
|
||||
<div class="space-y-4">
|
||||
<p><span class="font-semibold">Title:</span> {{ templateForm.title }}</p>
|
||||
<p><span class="font-semibold">Subject:</span> {{ templateForm.subject }}</p>
|
||||
<div class="border rounded-lg p-4 bg-gray-50">
|
||||
<h3 class="font-semibold mb-2">Content Preview:</h3>
|
||||
<div v-html="templateForm.content"></div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<rs-button @click="showPreviewModal = false">Close</rs-button>
|
||||
</template>
|
||||
</rs-modal>
|
||||
</template>
|
||||
</rs-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useEditor, EditorContent } from '@tiptap/vue-3';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Underline from '@tiptap/extension-underline';
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { $swal } = useNuxtApp();
|
||||
|
||||
const isEditing = computed(() => !!route.query.id);
|
||||
const templateId = computed(() => route.query.id ? parseInt(route.query.id) : null);
|
||||
|
||||
const templateForm = ref({
|
||||
title: '',
|
||||
subject: '',
|
||||
category: '',
|
||||
language: 'en',
|
||||
channels: [],
|
||||
status: 'Draft',
|
||||
content: '',
|
||||
});
|
||||
|
||||
// Options for select/checkbox fields
|
||||
const categoryOptions = [
|
||||
{ label: 'User Management', value: 'user_management' },
|
||||
{ label: 'Orders', value: 'orders' },
|
||||
{ label: 'Security', value: 'security' },
|
||||
{ label: 'Marketing', value: 'marketing' },
|
||||
{ label: 'General', value: 'general' },
|
||||
];
|
||||
const languageOptions = [
|
||||
{ label: 'English', value: 'en' },
|
||||
{ label: 'Spanish', value: 'es' },
|
||||
{ label: 'French', value: 'fr' },
|
||||
{ label: 'German', value: 'de' },
|
||||
];
|
||||
const channelOptions = [
|
||||
{ label: 'Email', value: 'email' },
|
||||
{ label: 'SMS', value: 'sms' },
|
||||
{ label: 'Push', value: 'push' },
|
||||
{ label: 'In-App', value: 'in_app' },
|
||||
];
|
||||
const statusOptions = [
|
||||
{ label: 'Draft', value: 'Draft' },
|
||||
{ label: 'Active', value: 'Active' },
|
||||
{ label: 'Archived', value: 'Archived' },
|
||||
];
|
||||
|
||||
// TipTap Editor Setup
|
||||
const editor = useEditor({
|
||||
content: templateForm.value.content,
|
||||
extensions: [StarterKit, Underline],
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'prose prose-sm dark:prose-invert focus:outline-none max-w-none min-h-[200px] px-3 py-2',
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor: currentEditor }) => {
|
||||
templateForm.value.content = currentEditor.getHTML();
|
||||
},
|
||||
});
|
||||
|
||||
watch(() => templateForm.value.content, (newContent) => {
|
||||
if (editor.value && editor.value.getHTML() !== newContent) {
|
||||
editor.value.commands.setContent(newContent, false);
|
||||
}
|
||||
});
|
||||
|
||||
const menuItems = computed(() => [
|
||||
{ name: 'bold', icon: 'material-symbols:format-bold', action: () => editor.value?.chain().focus().toggleBold().run() },
|
||||
{ name: 'italic', icon: 'material-symbols:format-italic', action: () => editor.value?.chain().focus().toggleItalic().run() },
|
||||
{ name: 'underline', icon: 'material-symbols:format-underlined', action: () => editor.value?.chain().focus().toggleUnderline().run() },
|
||||
{ type: 'separator' },
|
||||
{ name: 'bulletList', icon: 'material-symbols:list', action: () => editor.value?.chain().focus().toggleBulletList().run() },
|
||||
{ name: 'orderedList', icon: 'material-symbols:format-list-numbered', action: () => editor.value?.chain().focus().toggleOrderedList().run() },
|
||||
{ type: 'separator' },
|
||||
{ name: 'variable', icon: 'material-symbols:code', label: 'Insert Variable', action: insertVariable },
|
||||
]);
|
||||
|
||||
function insertVariable() {
|
||||
const variables = ['user_name', 'first_name', 'last_name', 'order_id', 'email', 'company_name', 'reset_link'];
|
||||
const variable = prompt('Select or type a variable:', variables.join(', '));
|
||||
if (variable && editor.value) {
|
||||
editor.value.chain().focus().insertContent(`{{${variable.trim()}}}`).run();
|
||||
}
|
||||
}
|
||||
|
||||
// Load template data if editing
|
||||
onMounted(() => {
|
||||
if (isEditing.value && templateId.value) {
|
||||
// Mock fetching template data - replace with actual API call
|
||||
const MOCK_TEMPLATES_DB = [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Welcome Email',
|
||||
subject: 'Welcome to Our Platform, {{first_name}}!',
|
||||
category: 'user_management',
|
||||
description: 'Template for new user welcome email',
|
||||
content: '<p>Hello {{first_name}}, welcome to our platform!</p><p>We are excited to have you.</p>',
|
||||
channels: ['email', 'in_app'],
|
||||
language: 'en',
|
||||
version: '1.2',
|
||||
status: 'Active',
|
||||
createdAt: '2024-03-20',
|
||||
updatedAt: '2024-03-20',
|
||||
},
|
||||
// Add other mock templates from index.vue if needed for testing edit mode
|
||||
];
|
||||
const existingTemplate = MOCK_TEMPLATES_DB.find(t => t.id === templateId.value);
|
||||
if (existingTemplate) {
|
||||
templateForm.value = { ...templateForm.value, ...existingTemplate };
|
||||
if(editor.value) editor.value.commands.setContent(existingTemplate.content, false);
|
||||
}
|
||||
}
|
||||
// Set placeholder for TipTap if content is empty
|
||||
if (editor.value && !editor.value.getText()) {
|
||||
const placeholderExtension = editor.value.extensionManager.extensions.find(ext => ext.name === 'placeholder');
|
||||
if (placeholderExtension && placeholderExtension.options) {
|
||||
placeholderExtension.options.placeholder = 'Enter your template content here...';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Preview Modal
|
||||
const showPreviewModal = ref(false);
|
||||
const previewTemplate = () => {
|
||||
showPreviewModal.value = true;
|
||||
};
|
||||
|
||||
// Form Submission
|
||||
const submitTemplate = async () => {
|
||||
// Actual submission logic here
|
||||
console.log('Submitting Template:', templateForm.value);
|
||||
await $swal.fire({
|
||||
title: isEditing.value ? 'Template Updated' : 'Template Created',
|
||||
text: `Template "${templateForm.value.title}" has been successfully ${isEditing.value ? 'updated' : 'created'}.`,
|
||||
icon: 'success',
|
||||
timer: 2000,
|
||||
showConfirmButton: false,
|
||||
});
|
||||
router.push('/notification/templates');
|
||||
};
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
// Minimal scoped styles, relying on utility classes and global FormKit/component styles.
|
||||
|
||||
.editor-wrapper {
|
||||
/* background-color set by utility classes */
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.menubar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.5rem;
|
||||
/* background-color by utility: bg-gray-50 dark:bg-gray-700 */
|
||||
/* border-bottom by utility: border-b dark:border-gray-600 */
|
||||
/* margin: -1px -1px 0.5rem -1px; */ /* Removed, handled by overflow-hidden and padding on parent */
|
||||
/* width: calc(100% + 2px); */ /* Removed */
|
||||
}
|
||||
|
||||
.menu-button {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
|
||||
/* &:hover by utility: dark:hover:bg-gray-700 */
|
||||
/* &.is-active by utility: dark:[&.is-active]:bg-gray-600 */
|
||||
}
|
||||
|
||||
.separator {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
/* background-color by utility: dark:bg-gray-600 */
|
||||
margin: 0 0.5rem;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.editor-content {
|
||||
/* padding handled by editorProps attributes */
|
||||
/* min-height set in editorProps */
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* :deep(.ProseMirror) styles are mostly handled by editorProps attributes now */
|
||||
:deep(.ProseMirror p.is-editor-empty:first-child::before) {
|
||||
color: #adb5bd; /* Tailwind gray-400 */
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Ensure FormKit elements don't have excessive default margins if not desired */
|
||||
:deep(.formkit-outer) {
|
||||
margin-bottom: 0.75rem; /* Default FormKit-like spacing, adjust as needed */
|
||||
}
|
||||
:deep(.formkit-outer:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Ensure checkbox grid label is aligned well */
|
||||
:deep(.formkit-label[for*="channels"]) {
|
||||
margin-bottom: 0.25rem !important;
|
||||
}
|
||||
</style>
|
@ -1,7 +1,493 @@
|
||||
<template>
|
||||
<div></div>
|
||||
<div>
|
||||
<LayoutsBreadcrumb />
|
||||
<rs-card class="mb-5">
|
||||
<template #header>
|
||||
<div class="flex">
|
||||
<span title="Info"><Icon class="mr-2 flex justify-center" name="ic:outline-info"></Icon></span>
|
||||
Info
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<p class="mb-4">
|
||||
Manage your notification templates here. You can create, edit, preview, and manage versions of templates for various channels like Email, SMS, Push, and In-App messages.
|
||||
</p>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<rs-card>
|
||||
<template #header>
|
||||
<h2 class="text-xl font-semibold">Notification Templates</h2>
|
||||
</template>
|
||||
<template #body>
|
||||
<div class="pt-2">
|
||||
<rs-tab fill>
|
||||
<rs-tab-item title="All Templates">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<div class="flex items-center gap-4 flex-wrap">
|
||||
<FormKit
|
||||
type="select"
|
||||
name="category"
|
||||
placeholder="Filter by Category"
|
||||
:options="categories"
|
||||
@input="filterByCategory"
|
||||
input-class="formkit-input appearance-none bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md py-2 px-3 text-base leading-normal focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-400 dark:focus:border-primary-400"
|
||||
outer-class="flex-grow sm:flex-grow-0 min-w-[180px] mb-0"
|
||||
/>
|
||||
<FormKit
|
||||
type="select"
|
||||
name="language"
|
||||
placeholder="Filter by Language"
|
||||
:options="languages"
|
||||
@input="filterByLanguage"
|
||||
input-class="formkit-input appearance-none bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md py-2 px-3 text-base leading-normal focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-400 dark:focus:border-primary-400"
|
||||
outer-class="flex-grow sm:flex-grow-0 min-w-[180px] mb-0"
|
||||
/>
|
||||
<FormKit
|
||||
type="select"
|
||||
name="channel"
|
||||
placeholder="Filter by Channel"
|
||||
:options="channels"
|
||||
@input="filterByChannel"
|
||||
input-class="formkit-input appearance-none bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-md py-2 px-3 text-base leading-normal focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:focus:ring-primary-400 dark:focus:border-primary-400"
|
||||
outer-class="flex-grow sm:flex-grow-0 min-w-[180px] mb-0"
|
||||
/>
|
||||
</div>
|
||||
<rs-button @click="$router.push('/notification/templates/create_template')" class="ml-auto" >
|
||||
<Icon name="material-symbols:add" class="mr-1"></Icon>
|
||||
Create Template
|
||||
</rs-button>
|
||||
</div>
|
||||
<rs-table
|
||||
v-if="templateList && templateList.length > 0"
|
||||
:data="templateList"
|
||||
:columns="columns"
|
||||
:options="{
|
||||
variant: 'default',
|
||||
striped: true,
|
||||
borderless: true,
|
||||
class: 'align-middle'
|
||||
}"
|
||||
advanced
|
||||
>
|
||||
<template v-slot:channel="{ value }">
|
||||
<div class="flex items-center justify-start gap-1 flex-wrap" style="max-width: 100px;">
|
||||
<template v-if="value.channel && value.channel.length">
|
||||
<template v-for="channel_item in value.channel" :key="channel_item">
|
||||
<span :title="channel_item">
|
||||
<Icon
|
||||
:name="getChannelIcon(channel_item)"
|
||||
class="text-gray-700 dark:text-gray-300"
|
||||
size="18"
|
||||
/>
|
||||
</span>
|
||||
</template>
|
||||
</template>
|
||||
<span v-else>-</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-slot:version="{ text }">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">v{{ text }}</span>
|
||||
</template>
|
||||
<template v-slot:status="{ text }">
|
||||
<span
|
||||
class="px-2 py-1 rounded-full text-xs font-medium"
|
||||
:class="getStatusClass(text)"
|
||||
>
|
||||
{{ text }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-slot:action="data">
|
||||
<div class="flex justify-center items-center gap-2">
|
||||
<span title="Edit">
|
||||
<Icon
|
||||
name="material-symbols:edit-outline-rounded"
|
||||
class="text-primary hover:text-primary/90 cursor-pointer"
|
||||
size="22"
|
||||
@click="editTemplate(data.value)"
|
||||
/>
|
||||
</span>
|
||||
<span title="Duplicate">
|
||||
<Icon
|
||||
name="material-symbols:content-copy"
|
||||
class="text-primary hover:text-primary/90 cursor-pointer"
|
||||
size="22"
|
||||
@click="duplicateTemplate(data.value)"
|
||||
/>
|
||||
</span>
|
||||
<span title="Version History">
|
||||
<Icon
|
||||
name="material-symbols:history"
|
||||
class="text-primary hover:text-primary/90 cursor-pointer"
|
||||
size="22"
|
||||
@click="showVersionHistory(data.value)"
|
||||
/>
|
||||
</span>
|
||||
<span title="Preview">
|
||||
<Icon
|
||||
name="material-symbols:preview-outline"
|
||||
class="text-primary hover:text-primary/90 cursor-pointer"
|
||||
size="22"
|
||||
@click="previewTemplate(data.value)"
|
||||
/>
|
||||
</span>
|
||||
<span title="Delete">
|
||||
<Icon
|
||||
name="material-symbols:close-rounded"
|
||||
class="text-red-500 hover:text-red-600 cursor-pointer"
|
||||
size="22"
|
||||
@click="openModalDelete(data.value)"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</rs-table>
|
||||
</rs-tab-item>
|
||||
</rs-tab>
|
||||
</div>
|
||||
</template>
|
||||
</rs-card>
|
||||
|
||||
<!-- Preview Modal -->
|
||||
<rs-modal v-model="showPreview" title="Template Preview" size="lg">
|
||||
<div class="space-y-4 p-1">
|
||||
<div class="flex gap-4 mb-4">
|
||||
<FormKit
|
||||
type="select"
|
||||
name="previewChannel"
|
||||
label="Preview Channel"
|
||||
:options="channels"
|
||||
v-model="previewChannel"
|
||||
/>
|
||||
<FormKit
|
||||
type="select"
|
||||
name="previewLanguage"
|
||||
label="Preview Language"
|
||||
:options="languages"
|
||||
v-model="previewLanguage"
|
||||
/>
|
||||
</div>
|
||||
<div class="border rounded-lg p-4 bg-gray-50 dark:bg-gray-800">
|
||||
<h3 class="font-semibold mb-2 text-gray-800 dark:text-gray-200">{{ selectedTemplate?.notificationTitle }}</h3>
|
||||
<div class="prose prose-sm max-w-none dark:prose-invert" v-html="selectedTemplate?.content"></div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<rs-button @click="showPreview = false">Close</rs-button>
|
||||
</template>
|
||||
</rs-modal>
|
||||
|
||||
<!-- Version History Modal -->
|
||||
<rs-modal v-model="showVersions" title="Version History" size="lg">
|
||||
<div class="space-y-4 p-1">
|
||||
<div v-if="versionHistory.length">
|
||||
<div v-for="version in versionHistory" :key="version.id" class="border rounded-lg p-4 mb-3 last:mb-0 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors duration-150 ease-in-out">
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<span class="font-semibold text-gray-700 dark:text-gray-300">Version {{ version.version }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">{{ version.updatedAt }}</span>
|
||||
<rs-button size="sm" @click="restoreVersion(version)" variant="outline">Restore</rs-button>
|
||||
<rs-button size="sm" @click="deleteVersion(version)" variant="danger-outline">Delete</rs-button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{{ version.changeDescription }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-center text-gray-500 dark:text-gray-400 py-4">
|
||||
No version history available for this template.
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<rs-button @click="showVersions = false">Close</rs-button>
|
||||
</template>
|
||||
</rs-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup></script>
|
||||
<script setup>
|
||||
definePageMeta({
|
||||
title: "Notification Templates",
|
||||
middleware: ["auth"],
|
||||
requiresAuth: true,
|
||||
});
|
||||
|
||||
<style lang="scss" scoped></style>
|
||||
const { $swal } = useNuxtApp();
|
||||
const router = useRouter();
|
||||
|
||||
const categories = ref([
|
||||
{ label: 'All Categories', value: '' },
|
||||
{ label: 'User Management', value: 'user' },
|
||||
{ label: 'Orders', value: 'order' },
|
||||
{ label: 'Security', value: 'security' },
|
||||
{ label: 'Marketing', value: 'marketing' },
|
||||
]);
|
||||
|
||||
const languages = ref([
|
||||
{ label: 'All Languages', value: '' },
|
||||
{ label: 'English', value: 'en' },
|
||||
{ label: 'Spanish', value: 'es' },
|
||||
{ label: 'French', value: 'fr' },
|
||||
{ label: 'German', value: 'de' },
|
||||
]);
|
||||
|
||||
const channels = ref([
|
||||
{ label: 'All Channels', value: '' },
|
||||
{ label: 'Email', value: 'email' },
|
||||
{ label: 'SMS', value: 'sms' },
|
||||
{ label: 'Push', value: 'push' },
|
||||
{ label: 'In-App', value: 'in-app' },
|
||||
]);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
label: 'Title',
|
||||
key: 'title',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
label: 'Category',
|
||||
key: 'category',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
label: 'Channels',
|
||||
sortable: false,
|
||||
},
|
||||
{
|
||||
label: 'Language',
|
||||
key: 'language',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
label: 'Version',
|
||||
key: 'version',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
label: 'Status',
|
||||
key: 'status',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
label: 'Last Updated',
|
||||
key: 'updatedAt',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
label: 'Action',
|
||||
key: 'action',
|
||||
align: 'center',
|
||||
},
|
||||
];
|
||||
|
||||
const templateList = ref([
|
||||
{
|
||||
id: 1,
|
||||
title: 'Welcome Email',
|
||||
category: 'User Management',
|
||||
description: 'Template for new user welcome email',
|
||||
notificationTitle: 'Welcome {{first_name}}',
|
||||
content: 'Hello {{first_name}}, welcome to our platform!',
|
||||
channel: ['email', 'in-app'],
|
||||
language: 'en',
|
||||
version: '1.2',
|
||||
status: 'Active',
|
||||
createdAt: '2024-03-20',
|
||||
updatedAt: '2024-03-20',
|
||||
action: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'Order Confirmation',
|
||||
category: 'Orders',
|
||||
description: 'Template for order confirmation',
|
||||
notificationTitle: 'Order #{{order_id}} Confirmed',
|
||||
content: 'Your order #{{order_id}} has been confirmed.',
|
||||
channel: ['email', 'sms', 'push'],
|
||||
language: 'en',
|
||||
version: '2.0',
|
||||
status: 'Active',
|
||||
createdAt: '2024-03-19',
|
||||
updatedAt: '2024-03-19',
|
||||
action: null,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'Password Reset',
|
||||
category: 'Security',
|
||||
description: 'Template for password reset requests',
|
||||
notificationTitle: 'Password Reset Request',
|
||||
content: 'Click the link to reset your password: {{reset_link}}',
|
||||
channel: ['email'],
|
||||
language: 'en',
|
||||
version: '1.0',
|
||||
status: 'Draft',
|
||||
createdAt: '2024-03-18',
|
||||
updatedAt: '2024-03-18',
|
||||
action: null,
|
||||
},
|
||||
]);
|
||||
|
||||
const showPreview = ref(false);
|
||||
const selectedTemplate = ref(null);
|
||||
const previewChannel = ref('email');
|
||||
const previewLanguage = ref('en');
|
||||
|
||||
const showVersions = ref(false);
|
||||
const versionHistory = ref([]);
|
||||
|
||||
const getChannelIcon = (channel_item) => {
|
||||
const icons = {
|
||||
email: 'material-symbols:mail-outline-rounded',
|
||||
sms: 'material-symbols:sms-outline-rounded',
|
||||
push: 'material-symbols:notifications-active-outline-rounded',
|
||||
'in-app': 'material-symbols:chat-bubble-outline-rounded',
|
||||
};
|
||||
return icons[channel_item] || 'material-symbols:help-outline-rounded';
|
||||
};
|
||||
|
||||
const getStatusClass = (status) => {
|
||||
const classes = {
|
||||
Active: 'bg-green-100 text-green-800 dark:bg-green-700 dark:text-green-100',
|
||||
Draft: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200',
|
||||
Archived: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-600 dark:text-yellow-100',
|
||||
};
|
||||
return classes[status];
|
||||
};
|
||||
|
||||
const filterByCategory = (event) => {
|
||||
console.log('Filter by category:', event.target.value);
|
||||
};
|
||||
|
||||
const filterByLanguage = (event) => {
|
||||
console.log('Filter by language:', event.target.value);
|
||||
};
|
||||
|
||||
const filterByChannel = (event) => {
|
||||
console.log('Filter by channel:', event.target.value);
|
||||
};
|
||||
|
||||
const editTemplate = (template) => {
|
||||
router.push(`/notification/templates/create_template?id=${template.id}`);
|
||||
};
|
||||
|
||||
const duplicateTemplate = (template) => {
|
||||
$swal.fire({
|
||||
title: 'Duplicate Template?',
|
||||
text: `Are you sure you want to duplicate "${template.title}"?`,
|
||||
icon: 'question',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Duplicate',
|
||||
cancelButtonText: 'Cancel',
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
console.log('Duplicate template:', template.title);
|
||||
$swal.fire('Duplicated!', 'Template has been duplicated. (Mock)', 'success');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const previewTemplate = (template) => {
|
||||
selectedTemplate.value = template;
|
||||
showPreview.value = true;
|
||||
};
|
||||
|
||||
const showVersionHistory = async (template) => {
|
||||
versionHistory.value = [
|
||||
{
|
||||
id: 1,
|
||||
version: '1.2',
|
||||
changeDescription: 'Updated welcome message and added new variables for personalization.',
|
||||
updatedAt: '2024-03-20 14:30',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
version: '1.1',
|
||||
changeDescription: 'Fixed formatting issues and typos in the content body.',
|
||||
updatedAt: '2024-03-19 11:20',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
version: '1.0',
|
||||
changeDescription: 'Initial version of the template created for welcome emails.',
|
||||
updatedAt: '2024-03-18 09:15',
|
||||
},
|
||||
];
|
||||
selectedTemplate.value = template;
|
||||
showVersions.value = true;
|
||||
};
|
||||
|
||||
const restoreVersion = async (version) => {
|
||||
$swal.fire({
|
||||
title: 'Restore Version?',
|
||||
text: `Are you sure you want to restore version ${version.version} for "${selectedTemplate.value?.title}"? Current content will be overwritten.`,
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Restore',
|
||||
cancelButtonText: 'Cancel',
|
||||
dangerMode: true,
|
||||
}).then((result) => {
|
||||
if (result.isConfirmed) {
|
||||
console.log('Restore version:', version);
|
||||
$swal.fire('Restored!', `Version ${version.version} has been restored. (Mock)`, 'success');
|
||||
showVersions.value = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const deleteVersion = async (version) => {
|
||||
const result = await $swal.fire({
|
||||
title: 'Delete Version?',
|
||||
text: `Are you sure you want to delete version ${version.version} for "${selectedTemplate.value?.title}"? This action cannot be undone.`,
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Yes, delete it!',
|
||||
cancelButtonText: 'Cancel',
|
||||
confirmButtonColor: '#d33', // Standard red for delete
|
||||
cancelButtonColor: '#3085d6',
|
||||
});
|
||||
|
||||
if (result.isConfirmed) {
|
||||
// Mock deletion: In a real app, you'd call an API here
|
||||
versionHistory.value = versionHistory.value.filter(v => v.id !== version.id);
|
||||
$swal.fire(
|
||||
'Deleted!',
|
||||
`Version ${version.version} has been deleted. (Mock)`,
|
||||
'success'
|
||||
);
|
||||
// If no versions left, maybe close modal or show a message?
|
||||
// if (versionHistory.value.length === 0) {
|
||||
// showVersions.value = false;
|
||||
// }
|
||||
}
|
||||
};
|
||||
|
||||
const openModalDelete = async (templateToDelete) => {
|
||||
const result = await $swal.fire({
|
||||
title: 'Delete Template',
|
||||
text: `Are you sure you want to delete "${templateToDelete.title}"? This action cannot be undone.`,
|
||||
icon: 'warning',
|
||||
showCancelButton: true,
|
||||
confirmButtonText: 'Yes, delete it!',
|
||||
cancelButtonText: 'Cancel',
|
||||
confirmButtonColor: '#d33',
|
||||
cancelButtonColor: '#3085d6',
|
||||
});
|
||||
|
||||
if (result.isConfirmed) {
|
||||
deleteTemplate(templateToDelete);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteTemplate = async (templateToDelete) => {
|
||||
templateList.value = templateList.value.filter(t => t.id !== templateToDelete.id);
|
||||
$swal.fire({
|
||||
position: "center",
|
||||
icon: "success",
|
||||
title: "Deleted!",
|
||||
text: `Template "${templateToDelete.title}" has been deleted.`,
|
||||
timer: 2000,
|
||||
showConfirmButton: false,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
Loading…
x
Reference in New Issue
Block a user