Initial commit

This commit is contained in:
Viet An
2026-03-02 09:45:33 +07:00
commit d17a9e2588
415 changed files with 92113 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
export const apiUrl = "https://api.utopia.com.vn/data";
export const putApiUrl = "https://api.utopia.com.vn/data-detail";
export const sendEmailUrl = "https://api.utopia.com.vn/send-email";
export const logoUrl = "https://bigdatatech.vn/logo.png";
export const imageUrl = "https://api.bigdatatech.vn/static/files/20251113051227-1.png";

View File

@@ -0,0 +1,471 @@
<template>
<div class="container is-fluid px-0" style="overflow: hidden">
<!-- 95px is height of nav + breadcrumb -->
<div class="columns m-0" style="height: calc(100vh - 95px)">
<!-- Form Section -->
<div class="column is-5 pb-5" style="
overflow-y: scroll;
border-right: 1px solid #dbdbdb;
">
<label class="label">
<span class="icon-text">
<span>Chọn mẫu</span>
</span>
</label>
<div class="level mb-4 mt-2 pr-4">
<!-- Template Selector -->
<div class="level-left" style="flex: 1">
<div class="level-item" style="flex: 1">
<div class="field" style="width: 100%">
<div class="control">
<div class="select is-fullwidth">
<select v-model="selectedValue" @change="handleTemplateChange">
<option value="defaultTemplate">Mặc định</option>
<option
v-for="template in dataTemplate"
:key="template.id"
:value="template.name"
>
{{ template.name }}
</option>
</select>
</div>
</div>
</div>
</div>
</div>
<!-- Action Buttons -->
<div v-if="$getEditRights()" class="level-right">
<div class="level-item">
<div class="buttons">
<button class="button is-light" @click="handleOpenModal">
<span class="icon">
<SvgIcon v-bind="{ name: 'save.svg', type: 'primary', size: 18 }"></SvgIcon>
</span>
</button>
<button
v-if="selectedTemplateId"
class="button is-danger is-outlined"
@click="showDeleteDialog = true"
:disabled="loading"
>
<span class="icon">
<SvgIcon v-bind="{ name: 'trash.svg', type: 'white', size: 18 }"></SvgIcon>
</span>
</button>
</div>
</div>
</div>
</div>
<!-- Tabbed Interface -->
<div class="tabs mb-3">
<ul>
<li :class="{ 'is-active': activeTab === 'content' }">
<a @click="activeTab = 'content'">Content</a>
</li>
<template v-if="$getEditRights()">
<li :class="{ 'is-active': activeTab === 'mappings' }">
<a @click="activeTab = 'mappings'">Mappings</a>
</li>
<li :class="{ 'is-active': activeTab === 'automation' }">
<a @click="activeTab = 'automation'">Automation</a>
</li>
</template>
</ul>
</div>
<!-- Form Component based on tab -->
<div v-show="activeTab === 'content'">
<component
:is="currentFormComponent"
:key="formKey"
:initial-data="emailFormData"
@data-change="handleChangeData"
/>
</div>
<div v-if="activeTab === 'mappings'">
<MappingConfigurator :mappings="formData.mappings" @update:mappings="updateMappings" />
</div>
<div v-if="activeTab === 'automation'">
<JobConfigurator :template-id="selectedTemplateId" />
</div>
</div>
<!-- Preview Section -->
<div class="column is-7 p-0" style="overflow-y: scroll">
<component :is="currentTemplateComponent" v-bind="templateProps" style="height: 100%" />
</div>
</div>
<!-- Delete Confirmation Modal -->
<div v-if="showDeleteDialog" class="modal is-active">
<div class="modal-background" @click="showDeleteDialog = false"></div>
<div class="modal-card">
<header class="modal-card-head p-4">
<p class="modal-card-title">Xác nhận xóa mẫu email</p>
<button class="delete" @click="showDeleteDialog = false"></button>
</header>
<section class="modal-card-body p-4">
<p class="mb-4">Bạn chắc chắn muốn xóa mẫu email này không? Hành động này không thể hoàn tác.</p>
<p class="has-text-weight-bold has-text-danger">Mẫu: {{ dataTemplateSelected?.name }}</p>
</section>
<footer class="modal-card-foot p-4">
<button class="button mr-2" @click="showDeleteDialog = false" :disabled="loading">Hủy</button>
<button
class="button is-danger has-text-white"
@click="handleDeleteTemplate"
:disabled="loading"
:class="{ 'is-loading': loading }"
>
{{ loading ? "Đang xóa..." : "Xóa" }}
</button>
</footer>
</div>
</div>
</div>
<Modal @close="showmodal = undefined" v-bind="showmodal" v-if="showmodal"></Modal>
</template>
<script setup lang="ts">
import { useNuxtApp } from "nuxt/app";
import { ref, computed, onMounted, watch, markRaw } from "vue";
import EmailForm1 from "./forms/EmailForm1.vue";
import Template1 from "@/lib/email/templates/Template1.vue";
import Modal from "@/components/Modal.vue";
import MappingConfigurator from "@/components/marketing/email/MappingConfigurator.vue";
import JobConfigurator from "@/components/marketing/email/JobConfigurator.vue";
const nuxtApp = useNuxtApp();
const $snackbar = nuxtApp.$snackbar as (message?: string) => void;
const $getdata = nuxtApp.$getdata as (name: string) => Promise<DataTemplate[]>;
const $deleteapi = nuxtApp.$deleteapi as (name: string, id: any) => Promise<DataTemplate[]>;
const $getEditRights = nuxtApp.$getEditRights as () => boolean;
const showmodal = ref<any>();
const activeTab = ref("content");
const defaultTemplate = 'defaultTemplate';
// Types
interface FormContent {
receiver: string;
subject: string;
content: string; // This is the email body
imageUrl: string | null;
linkUrl: string[];
textLinkUrl: string[];
keyword: (string | { keyword: string; value: string })[];
html: string;
}
interface FormData {
name?: string;
id?: number;
template: string;
content: FormContent;
mappings: any[];
}
interface DataTemplate {
id: number;
name: string;
content: Record<string, any> | string;
mappings?: any[];
}
// Reactive state
const formData = ref<FormData>({
name: "",
id: undefined,
template: defaultTemplate,
content: {
receiver: "",
subject: "",
content: "",
imageUrl: null,
linkUrl: [""],
textLinkUrl: [""],
keyword: [{ keyword: "", value: "" }],
html: "",
},
mappings: [],
});
const dataTemplate = ref<DataTemplate[] | null>(null);
const dataTemplateSelected = ref<DataTemplate | null>(null);
const originalLoadedTemplate = ref<DataTemplate | null>(null); // For diffing on save
const editMode = ref(false);
const formKey = ref(0);
const selectedValue = ref(defaultTemplate);
const loading = ref(false);
const selectedTemplateId = ref<number | null>(null);
const showDeleteDialog = ref(false);
// Computed properties
const emailFormData = computed(() => ({
id: formData.value.id,
name: formData.value.name || "",
template: formData.value.template,
content: formData.value.content,
}));
const templateProps = computed(() => ({
content: {
subject: formData.value.content.subject || "Thông báo mới",
message: formData.value.content.content || "Bạn có một thông báo mới.",
imageUrl: formData.value.content.imageUrl || null,
linkUrl: formData.value.content.linkUrl || [""],
textLinkUrl: formData.value.content.textLinkUrl || [""],
keyword: Array.isArray(formData.value.content.keyword)
? formData.value.content.keyword.map((k) => (typeof k === "string" ? { keyword: k, value: "" } : k))
: [{ keyword: "", value: "" }],
},
previewMode: true,
}));
const currentFormComponent = computed(() => {
return markRaw(EmailForm1);
});
const currentTemplateComponent = computed(() => {
return markRaw(Template1);
});
// Methods
const handleChangeData = (data: Partial<FormData>) => {
formData.value.name = data.name || formData.value.name;
formData.value.id = data.id || formData.value.id;
if (data.content) {
formData.value.content = { ...formData.value.content, ...data.content };
}
};
const updateMappings = (newMappings: any[]) => {
formData.value.mappings = newMappings;
};
const resetToDefault = () => {
formData.value = {
name: "",
id: undefined,
template: defaultTemplate,
content: {
receiver: "",
subject: "",
content: "",
imageUrl: null,
linkUrl: [""],
textLinkUrl: [""],
keyword: [{ keyword: "", value: "" }],
html: "",
},
mappings: [],
};
dataTemplateSelected.value = null;
originalLoadedTemplate.value = null;
editMode.value = false;
formKey.value++;
};
const handleTemplateChange = async () => {
const templateValue = selectedValue.value;
if (templateValue === defaultTemplate) {
selectedTemplateId.value = null;
resetToDefault();
$snackbar("Template reset to default");
return;
}
const selectedTemplate = dataTemplate.value?.find((t) => t.name === templateValue);
if (selectedTemplate) {
originalLoadedTemplate.value = JSON.parse(JSON.stringify(selectedTemplate)); // Deep copy for diffing
selectedTemplateId.value = selectedTemplate.id;
dataTemplateSelected.value = selectedTemplate;
editMode.value = true;
const tplContent = selectedTemplate.content;
let emailBody = "";
let subject = "";
let receiver = "";
let imageUrl = null;
let linkUrl = [""];
let textLinkUrl = [""];
let keyword: any[] = [{ keyword: "", value: "" }];
let mappings: any[] = [];
if (typeof tplContent === "string") {
emailBody = tplContent;
subject = selectedTemplate.name;
} else if (typeof tplContent === "object" && tplContent !== null) {
emailBody = tplContent.content || ""; // Always use content
subject = tplContent.subject || "";
receiver = tplContent.receiver || "";
imageUrl = tplContent.imageUrl || null;
linkUrl = Array.isArray(tplContent.linkUrl) ? tplContent.linkUrl : [""];
textLinkUrl = Array.isArray(tplContent.textLinkUrl) ? tplContent.textLinkUrl : [""];
keyword = Array.isArray(tplContent.keyword) ? tplContent.keyword : [{ keyword: "", value: "" }];
mappings = tplContent.mappings || [];
}
formData.value = {
id: selectedTemplate.id,
name: selectedTemplate.name || "",
template: defaultTemplate,
content: {
receiver,
subject,
content: emailBody,
imageUrl,
linkUrl,
textLinkUrl,
keyword,
html: "", // Will be generated by preview
},
mappings,
};
formKey.value++;
$snackbar(`Template loaded: ${selectedTemplate.name}`);
}
};
const handleDeleteTemplate = async () => {
if (!selectedTemplateId.value || !dataTemplateSelected.value?.name) return;
try {
loading.value = true;
await $deleteapi('emailtemplate', selectedTemplateId.value);
$snackbar(`Template deleted: ${dataTemplateSelected.value.name}`);
await fetchTemplates();
selectedValue.value = defaultTemplate;
resetToDefault();
} catch (error) {
console.error("Error deleting template:", error);
$snackbar("Deleting template failed");
} finally {
loading.value = false;
showDeleteDialog.value = false;
}
};
const fetchTemplates = async () => {
try {
dataTemplate.value = await $getdata('emailtemplate');
} catch (error) {
$snackbar('Error: Failed to fetch templates');
console.error(error);
}
};
const handleOpenModal = () => {
let dataForModal;
if (editMode.value && originalLoadedTemplate.value) {
// EDIT MODE: Calculate a patch of changed data
const patchPayload: { id: number; name?: string; content?: Record<string, any> } = {
id: formData.value.id!,
};
// 1. Check if name has changed
if (formData.value.name !== originalLoadedTemplate.value.name) {
patchPayload.name = formData.value.name;
}
// 2. Reconstruct the content object, preserving all original fields
const originalContentObject = typeof originalLoadedTemplate.value.content === 'object'
? JSON.parse(JSON.stringify(originalLoadedTemplate.value.content))
: {};
const newContentObject = {
...originalContentObject,
subject: formData.value.content.subject,
content: formData.value.content.content,
receiver: formData.value.content.receiver,
imageUrl: formData.value.content.imageUrl,
linkUrl: formData.value.content.linkUrl,
textLinkUrl: formData.value.content.textLinkUrl,
keyword: formData.value.content.keyword,
mappings: formData.value.mappings,
};
// 3. Only include the 'content' field in the patch if it has actually changed
if (JSON.stringify(newContentObject) !== JSON.stringify(originalLoadedTemplate.value.content)) {
patchPayload.content = newContentObject;
}
dataForModal = patchPayload;
} else {
// CREATE MODE: Build the full object
const contentToSave = {
subject: formData.value.content.subject,
content: formData.value.content.content,
receiver: formData.value.content.receiver,
imageUrl: formData.value.content.imageUrl,
linkUrl: formData.value.content.linkUrl,
textLinkUrl: formData.value.content.textLinkUrl,
keyword: formData.value.content.keyword,
mappings: formData.value.mappings,
};
dataForModal = {
name: formData.value.name,
content: contentToSave,
};
}
showmodal.value = {
component: "marketing/email/dataTemplate/SaveListTemplate",
title: "Lưu mẫu email",
width: "auto",
height: "200px",
vbind: {
data: dataForModal,
editMode: editMode.value,
onSuccess: fetchTemplates,
},
};
};
// Watchers
watch(selectedValue, (newValue, oldValue) => {
if (newValue === defaultTemplate && oldValue !== defaultTemplate) {
resetToDefault();
}
});
// Lifecycle
onMounted(() => {
fetchTemplates();
});
</script>
<style scoped>
/* Custom Scrollbar */
.column::-webkit-scrollbar {
width: 8px;
}
.column::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
.column::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
.column::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* Firefox */
.column {
scrollbar-width: thin;
scrollbar-color: #888 #f1f1f1;
}
</style>

View File

@@ -0,0 +1,256 @@
<template>
<div class="box">
<div class="level">
<div class="level-left">
<h5 class="title is-5">Automation Jobs</h5>
</div>
<div class="level-right">
<button class="button is-primary" @click="openNewJobForm">
<SvgIcon v-bind="{ name: 'add4.svg', type: 'white', size: 18 }" />
<span class="ml-2">Add New Job</span>
</button>
</div>
</div>
<div v-if="isLoading" class="has-text-centered">
<p>Loading jobs...</p>
</div>
<div v-else-if="jobs.length === 0" class="has-text-centered">
<p>No automation jobs found for this template.</p>
</div>
<div v-else>
<div v-for="job in jobs" :key="job.id" class="mb-4 p-3 border">
<div class="level">
<div class="level-left">
<div>
<p class="has-text-weight-bold">{{ job.name }}</p>
<p class="is-size-7">Model: {{ job.model_name }}</p>
<p class="is-size-7">
Triggers:
<span v-if="job.trigger_on_create" class="tag is-info is-light mr-1">On Create</span>
<span v-if="job.trigger_on_update" class="tag is-warning is-light">On Update</span>
</p>
</div>
</div>
<div class="level-right">
<div class="field has-addons">
<div class="control">
<button class="button is-small" :class="{'is-success': job.active, 'is-light': !job.active}" @click="toggleJobStatus(job)">
{{ job.active ? 'Active' : 'Inactive' }}
</button>
</div>
<div class="control">
<button class="button is-small" @click="openEditJobForm(job)">
<SvgIcon v-bind="{ name: 'pen1.svg', type: 'primary', size: 18 }" />
</button>
</div>
<div class="control">
<button class="button is-danger is-small" @click="confirmDelete(job)">
<SvgIcon v-bind="{ name: 'trash.svg', type: 'white', size: 18 }" />
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Job Form Modal -->
<div v-if="showForm" class="modal is-active">
<div class="modal-background" @click="closeForm"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">{{ isEditing ? 'Edit Job' : 'Create New Job' }}</p>
<button class="delete" @click="closeForm"></button>
</header>
<section class="modal-card-body">
<div class="field">
<label class="label">Job Name</label>
<div class="control">
<input class="input" type="text" v-model="jobForm.name" placeholder="e.g., Notify on Transaction Update">
</div>
</div>
<div class="field">
<label class="label">Model Name</label>
<div class="control">
<input class="input" type="text" v-model="jobForm.model_name" placeholder="e.g., app.Transaction_Detail">
</div>
</div>
<div class="field">
<label class="label">Triggers</label>
<div class="control">
<label class="checkbox mr-4">
<input type="checkbox" v-model="jobForm.trigger_on_create">
On Create
</label>
<label class="checkbox">
<input type="checkbox" v-model="jobForm.trigger_on_update">
On Update
</label>
</div>
</div>
<div class="field">
<label class="label">Status</label>
<div class="control">
<label class="checkbox">
<input type="checkbox" v-model="jobForm.active">
Active
</label>
</div>
</div>
</section>
<footer class="modal-card-foot">
<button class="button is-success" @click="saveJob" :disabled="isSaving">
{{ isSaving ? 'Saving...' : 'Save' }}
</button>
<button class="button" @click="closeForm">Cancel</button>
</footer>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue';
import axios from 'axios';
import { useNuxtApp } from 'nuxt/app';
import { apiUrl, putApiUrl } from '@/components/marketing/email/Email.utils';
const props = defineProps({
templateId: {
type: [Number, null],
default: null,
},
});
const nuxtApp = useNuxtApp();
const $snackbar = nuxtApp.$snackbar as (message: string) => void;
const jobs = ref<any[]>([]);
const isLoading = ref(false);
const isSaving = ref(false);
const showForm = ref(false);
const isEditing = ref(false);
const defaultJobForm = () => ({
id: null,
name: '',
model_name: 'app.Transaction_Detail',
template: props.templateId,
trigger_on_create: false,
trigger_on_update: false,
active: true,
});
const jobForm = ref(defaultJobForm());
const fetchJobs = async () => {
if (!props.templateId) {
jobs.value = [];
return;
}
isLoading.value = true;
try {
const response = await axios.get(`${apiUrl}/Email_Job/`, {
params: { template_id: props.templateId },
});
jobs.value = response.data.rows || [];
} catch (error) {
console.error("Error fetching email jobs:", error);
$snackbar(`Error fetching jobs`);
} finally {
isLoading.value = false;
}
};
watch(() => props.templateId, fetchJobs, { immediate: true });
const openNewJobForm = () => {
if (!props.templateId) {
$snackbar(`Please save the template first.`);
return;
}
isEditing.value = false;
jobForm.value = defaultJobForm();
showForm.value = true;
};
const openEditJobForm = (job: any) => {
isEditing.value = true;
jobForm.value = { ...job };
showForm.value = true;
};
const closeForm = () => {
showForm.value = false;
};
const saveJob = async () => {
isSaving.value = true;
try {
let response;
const data = { ...jobForm.value };
if (isEditing.value) {
response = await axios.put(`${putApiUrl}/Email_Job/${data.id}`, data);
} else {
response = await axios.post(`${apiUrl}/Email_Job/`, data);
}
if (response.status === 200 || response.status === 201) {
$snackbar(`Job saved successfully`);
await fetchJobs();
closeForm();
}
} catch (error) {
console.error("Error saving job:", error);
$snackbar(`Error saving job`);
} finally {
isSaving.value = false;
}
};
const toggleJobStatus = async (job: any) => {
const updatedJob = { ...job, active: !job.active };
try {
await axios.put(`${putApiUrl}/Email_Job/${job.id}`, updatedJob);
$snackbar(`Job status updated`);
await fetchJobs();
} catch (error) {
console.error("Error updating job status:", error);
$snackbar(`Error updating job status`);
}
};
const confirmDelete = (job: any) => {
if (confirm(`Are you sure you want to delete the job "${job.name}"?`)) {
deleteJob(job.id);
}
};
const deleteJob = async (jobId: number) => {
try {
await axios.delete(`${putApiUrl}/Email_Job/${jobId}`);
$snackbar(`Job deleted successfully`);
await fetchJobs();
} catch (error) {
console.error("Error deleting job:", error);
$snackbar(`Error deleting job`);
}
};
onMounted(fetchJobs);
</script>
<style scoped>
.box {
padding: 1.5rem;
}
.border {
border: 1px solid #dbdbdb;
border-radius: 4px;
}
.modal-card-foot {
justify-content: flex-end;
}
</style>

View File

@@ -0,0 +1,192 @@
<template>
<div class="box">
<div v-for="(mapping, mapIndex) in localMappings" :key="mapIndex" class="mb-5 p-4 border">
<div class="level">
<div class="level-left">
<h5 class="title is-5">Mapping {{ mapIndex + 1 }}: {{ mapping.alias }}</h5>
</div>
<div class="level-right">
<button class="button is-danger is-small" @click="removeMapping(mapIndex)">
<span class="icon is-small">
<SvgIcon v-bind="{ name: 'bin1.svg', type: 'white', size: 16 }"></SvgIcon>
</span>
</button>
</div>
</div>
<div class="columns is-multiline">
<div class="column is-6">
<div class="field">
<label class="label">Alias</label>
<div class="control">
<input class="input" type="text" v-model="mapping.alias" @input="update" />
</div>
</div>
</div>
<div class="column is-6">
<div class="field">
<label class="label">Model</label>
<div class="control">
<input class="input" type="text" v-model="mapping.model" @input="update" placeholder="e.g., app.Transaction" />
</div>
</div>
</div>
<div class="column is-6">
<div class="field">
<label class="label">Lookup Field</label>
<div class="control">
<input class="input" type="text" v-model="mapping.lookup_field" @input="update" placeholder="e.g., id" />
</div>
</div>
</div>
<div class="column is-6">
<div class="field">
<label class="label">Lookup Value From</label>
<div class="control">
<input class="input" type="text" v-model="mapping.lookup_value_from" @input="update" placeholder="e.g., transaction_id or transaction.customer.id" />
</div>
</div>
</div>
<div class="column is-6">
<div class="field">
<label class="label">Type</label>
<div class="control">
<div class="select is-fullwidth">
<select v-model="mapping.type" @change="update">
<option value="object">Object</option>
<option value="list">List</option>
</select>
</div>
</div>
</div>
</div>
<div class="column is-6">
<div class="field">
<label class="label">Is Trigger Object?</label>
<div class="control">
<label class="checkbox">
<input type="checkbox" v-model="mapping.is_trigger_object" @change="update">
Yes
</label>
</div>
</div>
</div>
</div>
<hr>
<h6 class="title is-6">Fields</h6>
<div v-for="(field, fieldIndex) in mapping.fields" :key="fieldIndex" class="field has-addons">
<div class="control">
<input class="input" type="text" v-model="field.placeholder" @input="update" placeholder="[placeholder]">
</div>
<div class="control is-expanded">
<input class="input" type="text" v-model="field.source" @input="update" placeholder="source.field.name">
</div>
<div class="control">
<button class="button is-danger is-light" @click="removeField(mapIndex, fieldIndex as number)">
<SvgIcon v-bind="{ name: 'bin1.svg', type: 'primary'}"></SvgIcon>
</button>
</div>
</div>
<button class="button is-small is-light" @click="addField(mapIndex)">
<span class="icon is-small">+</span>
<span>Add Field</span>
</button>
</div>
<button class="button is-primary" @click="addMapping">
<SvgIcon class="mr-2" v-bind="{ name: 'add4.svg', type: 'white', size: 18 }" />
Add Mapping
</button>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
const props = defineProps({
mappings: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['update:mappings']);
// Use a local ref to manage mappings to handle the object-to-array transformation for fields
const localMappings = ref<any[]>([]);
// Helper to transform fields object to array for v-for
const transformMappings = (sourceMappings: any[]) => {
return sourceMappings.map(m => ({
...m,
fields: m.fields ? Object.entries(m.fields).map(([placeholder, source]) => ({ placeholder, source: typeof source === 'object' ? JSON.stringify(source) : source })) : []
}));
};
// Helper to transform fields array back to object for emission
const reformatMappingsForEmit = () => {
return localMappings.value.map(m => {
const newFields = m.fields.reduce((obj: any, item: any) => {
try {
// Attempt to parse if it's a JSON string for format objects
obj[item.placeholder] = JSON.parse(item.source);
} catch (e) {
obj[item.placeholder] = item.source;
}
return obj;
}, {});
return { ...m, fields: newFields };
});
};
watch(() => props.mappings, (newMappings) => {
localMappings.value = transformMappings(newMappings || []);
}, { immediate: true, deep: true });
const addMapping = () => {
localMappings.value.push({
alias: '',
model: '',
lookup_field: 'id',
lookup_value_from: '',
type: 'object',
is_trigger_object: false,
fields: [],
});
update();
};
const removeMapping = (index: number) => {
localMappings.value.splice(index, 1);
update();
};
const addField = (mapIndex: number) => {
if (!localMappings.value[mapIndex].fields) {
localMappings.value[mapIndex].fields = [];
}
localMappings.value[mapIndex].fields.push({ placeholder: '', source: '' });
update();
};
const removeField = (mapIndex: number, fieldIndex: number) => {
localMappings.value[mapIndex].fields.splice(fieldIndex, 1);
update();
};
const update = () => {
emit('update:mappings', reformatMappingsForEmit());
};
</script>
<style scoped>
.box {
padding: 1.5rem;
}
.border {
border: 1px solid #dbdbdb;
border-radius: 4px;
}
</style>

View File

@@ -0,0 +1,347 @@
<template>
<div class="container">
<div class="level mb-4">
<div class="level-left">
<div class="level-item">
<div>
<h2 class="title is-4">Sent Emails</h2>
<p class="subtitle is-6">
{{ loading ? "Loading..." : `${emailsSent.length} email(s) sent` }}
</p>
</div>
</div>
</div>
<div class="level-right">
<div class="level-item">
<button
class="button is-light"
@click="getDataEmailsSent"
:disabled="loading"
:class="{ 'is-loading': loading }"
>
<span class="icon">
<RefreshCw :size="16" />
</span>
<span>Refresh</span>
</button>
</div>
</div>
</div>
<div class="box">
<table class="table is-fullwidth is-striped is-hoverable">
<thead>
<tr>
<th>Emails</th>
<th>Subject</th>
<th>Content</th>
<th>Status</th>
<th>Sent At</th>
<th>Updated At</th>
</tr>
</thead>
<tbody>
<template v-if="loading">
<tr v-for="index in 5" :key="index">
<td><div class="skeleton-line"></div></td>
<td><div class="skeleton-line"></div></td>
<td><div class="skeleton-line"></div></td>
<td><div class="skeleton-line"></div></td>
<td><div class="skeleton-line"></div></td>
<td><div class="skeleton-line"></div></td>
</tr>
</template>
<template v-else-if="emailsSent.length === 0">
<tr>
<td colspan="6">
<div class="has-text-centered py-6">
<Mail :size="64" class="has-text-grey-light mb-4" />
<p class="title is-5">No emails sent yet</p>
<p class="subtitle is-6">Sent emails will appear here</p>
</div>
</td>
</tr>
</template>
<template v-else>
<tr v-for="email in emailsSent" :key="email.id" class="is-clickable" @click="handleViewDetail(email)">
<td>
<span>{{ truncateText(email.receiver, 40) }}</span>
</td>
<td>{{ truncateText(email.subject, 20) }}</td>
<td>
<div v-html="truncateText(stripHtml(extractMessageContent(email.content), 1000), 40)"></div>
</td>
<td>
<span v-if="email.status === 1" class="tag is-warning">
<span class="icon is-small">
<SvgIcon v-bind="{ name: 'loading.svg', type: 'white', size: 12 }" />
</span>
<span>Pending</span>
</span>
<span v-else-if="email.status === 2" class="tag is-success">
<span class="icon is-small">
<CheckCircle2 :size="12" />
</span>
<span>Sent</span>
</span>
<span v-else-if="email.status === 3" class="tag is-danger">
<span class="icon is-small">
<XCircle :size="12" />
</span>
<span>Failed</span>
</span>
<span v-else-if="email.status === 4" class="tag is-info">
<span class="icon is-small">
<Clock :size="12" />
</span>
<span>Scheduled</span>
</span>
</td>
<td>
<div>
<p class="is-size-7">
<span class="icon is-small">
<Calendar :size="12" />
</span>
{{ formatDate(email.create_time) }}
</p>
<p class="is-size-7 has-text-grey">{{ getRelativeTime(email.create_time) }}</p>
</div>
</td>
<td>
<div>
<p class="is-size-7">
<span class="icon is-small">
<Clock :size="12" />
</span>
{{ formatDate(email.update_time || "") }}
</p>
<p class="is-size-7 has-text-grey">{{ getRelativeTime(email.update_time || "") || "---" }}</p>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Detail Modal -->
<div v-if="showDetailModal && selectedEmail" class="modal is-active">
<div class="modal-background" @click="showDetailModal = false"></div>
<div class="modal-card" style="width: 90%; max-width: 1200px">
<header class="modal-card-head">
<p class="modal-card-title">Email Details</p>
<button class="delete" @click="showDetailModal = false"></button>
</header>
<section class="modal-card-body">
<div class="field">
<label class="label">
Recipients ({{ selectedEmail.receiver.split(";").filter((e) => e.trim()).length }})
</label>
<div class="box" style="max-height: 150px; overflow-y: auto">
<p style="white-space: pre-wrap">{{ selectedEmail.receiver }}</p>
</div>
</div>
<div class="field">
<label class="label">Subject</label>
<p>{{ selectedEmail.subject }}</p>
</div>
<div class="field">
<label class="label">Content</label>
<div
class="box content"
style="max-height: 300px; overflow-y: auto"
v-html="selectedEmail.content || 'No content'"
></div>
</div>
<div class="columns">
<div class="column">
<label class="label">Status</label>
<div>
<span v-if="selectedEmail.status === 1" class="tag is-warning">Pending</span>
<span v-else-if="selectedEmail.status === 2" class="tag is-success">Sent</span>
<span v-else-if="selectedEmail.status === 3" class="tag is-danger">Failed</span>
<span v-else-if="selectedEmail.status === 4" class="tag is-info">Scheduled</span>
</div>
</div>
<div class="column">
<label class="label">Sent At</label>
<p>{{ new Date(selectedEmail.create_time).toLocaleString("vi-VN") }}</p>
</div>
<div class="column">
<label class="label">Updated At</label>
<p>
{{ selectedEmail.update_time ? new Date(selectedEmail.update_time).toLocaleString("vi-VN") : "---" }}
</p>
</div>
</div>
</section>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { formatDistanceToNow } from "date-fns";
import axios from "axios";
import { useNuxtApp } from "nuxt/app";
import { apiUrl } from '@/components/marketing/email/Email.utils';
interface EmailSent {
id: number;
receiver: string;
subject: string;
content: string;
status: number;
create_time: string;
update_time?: string;
}
const nuxtApp = useNuxtApp();
const $snackbar = nuxtApp.$snackbar as (message?: string) => void;
const emailsSent = ref<EmailSent[]>([]);
const loading = ref(true);
const selectedEmail = ref<EmailSent | null>(null);
const showDetailModal = ref(false);
const extractMessageContent = (html: string | null | undefined): string => {
if (!html || html === "") return "No content";
try {
if (typeof window !== "undefined") {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const messageContentDiv = doc.querySelector("div.message-content");
if (messageContentDiv) {
const content = messageContentDiv.innerHTML.trim();
if (content) {
return content;
}
return messageContentDiv.innerHTML;
}
} else {
const regex = /<div[^>]*class=["'][^"']*message-content[^"']*["'][^>]*>([\s\S]*?)<\/div>/i;
const match = html.match(regex);
if (match && match[1]) {
return match[1].trim();
}
}
} catch (error) {
console.error("Error extracting message content:", error);
}
return html;
};
const stripHtml = (html: string, maxLength: number = 1000): string => {
if (!html) return "";
const text = html.replace(/<[^>]*>/g, "");
return text.length > maxLength ? text.substring(0, maxLength) + "..." : text;
};
const getDataEmailsSent = async () => {
loading.value = true;
try {
const response = await axios.get(`${apiUrl}/Email_Sent/?sort=-id`);
if (response.status === 200) {
emailsSent.value = response.data.rows || response.data || [];
}
} catch (error) {
console.error("Error fetching sent emails:", error);
$snackbar("Failed to load sent emails");
} finally {
loading.value = false;
}
};
const formatDate = (dateString: string) => {
try {
if (dateString === "") return "";
const date = new Date(dateString);
return date.toLocaleString("vi-VN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return "Invalid date";
}
};
const getRelativeTime = (dateString: string) => {
try {
if (dateString === "") return "";
return formatDistanceToNow(new Date(dateString), { addSuffix: true });
} catch {
return "";
}
};
const handleViewDetail = (email: EmailSent) => {
selectedEmail.value = email;
showDetailModal.value = true;
};
const truncateText = (text: string | undefined | null, maxLength: number = 50): string => {
if (!text) return "";
const textStr = String(text);
if (textStr.length <= maxLength) return textStr;
return textStr.substring(0, maxLength) + "...";
};
onMounted(() => {
getDataEmailsSent();
});
</script>
<style scoped>
.is-clickable {
cursor: pointer;
}
.is-clickable:hover {
background-color: #f5f5f5;
}
.skeleton-line {
height: 20px;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
border-radius: 4px;
}
@keyframes loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.rotating {
animation: rotate 1s linear infinite;
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -0,0 +1,130 @@
<template>
<div class="box">
<div class="table-container">
<table class="table is-bordered is-fullwidth is-hoverable">
<thead>
<tr>
<th>List Name</th>
<th>Email</th>
</tr>
</thead>
<tbody>
<template v-if="loading">
<tr v-for="index in 5" :key="index">
<td>
<div class="skeleton-loader" style="width: 150px; height: 20px"></div>
</td>
<td>
<div class="skeleton-loader" style="width: 250px; height: 20px"></div>
</td>
</tr>
</template>
<template v-else-if="dataEmail.length === 0">
<tr>
<td colspan="2" class="has-text-centered py-6">
<div class="has-text-grey">
<p class="has-text-weight-medium">No saved lists found</p>
<p class="is-size-7 mt-2">Create a new list to get started</p>
</div>
</td>
</tr>
</template>
<template v-else>
<tr
v-for="item in dataEmail"
:key="item.id"
:class="['is-clickable', selectedEmails === item.email ? 'is-selected' : '']"
@click="handleRowClick(item.email, item.name, item.id)"
>
<td class="has-text-weight-medium">{{ item.name }}</td>
<td class="has-text-grey-dark">{{ item.email }}</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { useNuxtApp } from "nuxt/app";
interface EmailItem {
id: string;
name: string;
email: string;
}
interface Props {
dataEmail: EmailItem[];
onClose: () => void;
loading?: boolean;
}
const emit = defineEmits<{
modalevent: [{ name: string; data: { email: string; name: string; id: string } }];
close: [];
success: [];
}>();
const props = withDefaults(defineProps<Props>(), {
loading: false,
});
console.log(props.dataEmail);
const nuxtApp = useNuxtApp();
const $snackbar = nuxtApp.$snackbar as (message?: string) => void;
const selectedEmails = ref<string>("");
const selectedName = ref<string>("");
const selectedId = ref<string>("");
const handleRowClick = (email: string, name: string, id: string) => {
selectedEmails.value = email;
selectedName.value = name;
selectedId.value = id;
$snackbar(`Đã chọn danh sách: ${name}`);
// Emit event to parent through Modal
emit("modalevent", {
name: "dataevent",
data: { email, name, id },
});
emit("close");
emit("success");
};
</script>
<style scoped>
.is-clickable {
cursor: pointer;
}
.is-clickable:hover {
background-color: hsl(219, 70%, 96%) !important;
}
.is-selected {
background-color: hsl(219, 70%, 90%) !important;
}
.skeleton-loader {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 2s ease-in-out infinite;
border-radius: 4px;
}
@keyframes loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
</style>

View File

@@ -0,0 +1,215 @@
<template>
<div class="box" style="min-width: 400px">
<div class="field">
<label class="label is-flex is-align-items-center mb-2" for="name">
Tên danh sách <span class="has-text-danger ml-1">*</span>
</label>
<div class="control">
<input
ref="listNameRef"
id="name"
name="name"
class="input"
placeholder="Nhập tên danh sách (ví dụ: Khách hàng của tôi)"
v-model="data.name"
:disabled="loading"
autofocus
/>
</div>
</div>
<div class="field">
<label class="label is-flex is-align-items-center mb-2" for="email">
Địa chỉ email <span class="has-text-danger ml-1">*</span>
</label>
<div class="control">
<textarea
id="email"
name="email"
class="textarea p-2"
rows="5"
placeholder="email1@example.com; email2@example.com"
v-model="data.email"
:disabled="loading"
/>
</div>
<p class="help">Ngăn cách nhiều email bằng dấu chấm phẩy (;)</p>
</div>
<div class="is-flex is-justify-content-flex-end pt-2">
<div class="buttons">
<button v-if="isEditMode" class="button" :disabled="loading" @click="handleCreateNew">
<span class="icon">
<SvgIcon v-bind="{ name: 'add4.svg', type: 'gray', size: 16 }"></SvgIcon>
</span>
<span>Create New</span>
</button>
<button
class="button is-dark"
@click="handleSave"
:disabled="loading || !data.name.trim() || !data.email.trim()"
>
<span v-if="loading"></span>
<span class="icon" v-else>
<SvgIcon v-bind="{ name: isEditMode ? 'save.svg' : 'add4.svg', type: 'white', size: 16 }"></SvgIcon>
</span>
<span>{{
loading
? isEditMode
? "Đang cập nhật..."
: "Đang tạo..."
: isEditMode
? "Cập nhật danh sách"
: "Tạo danh sách"
}}</span>
</button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, nextTick } from "vue";
import axios from "axios";
import { useNuxtApp } from "nuxt/app";
import { apiUrl, putApiUrl } from '@/components/marketing/email/Email.utils';
interface DataEmail {
id?: number;
email: string;
name: string;
}
interface Props {
emails: string;
name?: string;
id?: number;
}
const props = withDefaults(defineProps<Props>(), {
name: "",
emails: "",
id: undefined,
});
const emit = defineEmits<{
close: [];
success: [];
}>();
const nuxtApp = useNuxtApp();
const $snackbar = nuxtApp.$snackbar as (message?: string) => void;
const data = ref<DataEmail>({
id: props.id,
email: props.emails,
name: props.name || "",
});
const dataEmailList = ref<DataEmail[]>([]);
const loading = ref(false);
const listNameRef = ref<HTMLInputElement | null>(null);
const isEditMode = computed(() => {
return !!(props.id && props.name && props.emails);
});
watch(
() => [props.id, props.name, props.emails],
() => {
data.value = {
email: props.emails,
name: props.name || "",
};
}
);
const handleSave = async () => {
if (!data.value.name.trim()) {
$snackbar("Lỗi xác thực: Vui lòng nhập tên danh sách");
return;
}
if (!data.value.email.trim()) {
$snackbar("Lỗi xác thực: Vui lòng nhập địa chỉ email");
return;
}
loading.value = true;
const apiUrlPut = `${putApiUrl}/Email_List/`;
const apiUrlPost = `${apiUrl}/Email_List/`;
const isUpdate = isEditMode.value && props.id;
try {
dataEmailList.value = await axios.get(apiUrlPost).then((res) => res.data.rows);
let response;
if (dataEmailList.value.some((item) => item.name === data.value.name.trim() && item.id !== props.id)) {
$snackbar(`Lỗi: Tên danh sách "${data.value.name.trim()}" đã tồn tại. Vui lòng chọn tên khác.`);
loading.value = false;
return;
}
if (isUpdate) {
response = await axios.put(`${apiUrlPut}${props.id}`, {
name: data.value.name.trim(),
email: data.value.email,
});
} else {
response = await axios.post(apiUrlPost, {
name: data.value.name.trim(),
email: data.value.email,
});
}
if (response.status === 200 || response.status === 201 || response.status === 204) {
$snackbar(isUpdate ? `"${data.value.name}" cập nhật thành công!` : `"${data.value.name}" tạo thành công!`);
loading.value = false;
emit("close");
emit("success");
} else {
throw new Error(`Failed to ${isUpdate ? "update" : "create"} the Gmail list`);
}
} catch (error) {
console.error(`Error ${isUpdate ? "cập nhật" : "tạo"} danh sách:`, error);
const errorMessage = axios.isAxiosError(error)
? error.response?.data?.message || `Lỗi khi ${isUpdate ? "cập nhật" : "tạo"} danh sách`
: "Đã xảy ra lỗi";
$snackbar(errorMessage);
loading.value = false;
}
};
const handleCreateNew = async () => {
loading.value = true;
const emailListUrl = `${apiUrl}/Email_List/`;
try {
const response = await axios.post(emailListUrl, {
name: data.value.name.trim(),
email: data.value.email,
});
if (response.status === 200 || response.status === 201) {
$snackbar("Tạo danh sách email mới thành công!");
}
} catch (error) {
console.error("Lỗi khi tạo danh sách email mới:", error);
$snackbar("Tạo danh sách email mới thất bại");
} finally {
loading.value = false;
}
data.value = { email: "", name: "" };
await nextTick();
listNameRef.value?.focus();
emit("close");
emit("success");
};
</script>

View File

@@ -0,0 +1,225 @@
<template>
<div class="field"
style="min-width: 360px"
>
<label class="label is-flex is-align-items-center fsb-18 mb-2" for="name">
Template name <span class="has-text-danger ml-1">*</span>
</label>
<div class="control">
<input
ref="listNameRef"
id="name"
name="name"
class="input"
placeholder="Enter template name (e.g., My Template)"
v-model="templateName"
:disabled="loading"
autofocus
/>
</div>
</div>
<div class="is-flex is-justify-content-flex-end pt-2">
<div class="buttons">
<button
class="button is-success"
@click="handleSave"
:disabled="loading || !templateName.trim()"
>
<span class="icon" v-if="loading">
<SvgIcon v-bind="{ name: 'loading.svg', type: 'white', size: 16 }"></SvgIcon>
</span>
<span class="icon" v-else-if="isEditMode">
<SvgIcon v-bind="{ name: 'save.svg', type: 'white', size: 16 }"></SvgIcon>
</span>
<span class="icon" v-else>
<SvgIcon v-bind="{ name: 'add4.svg', type: 'white', size: 16 }"></SvgIcon>
</span>
<span>{{
loading
? isEditMode
? "Updating..."
: "Creating..."
: isEditMode
? "Update Template"
: "Create Template"
}}</span>
</button>
<button
v-if="isEditMode"
class="button is-success"
:disabled="loadingCreate"
@click="handleCreateNew"
>
<span class="icon" v-if="loadingCreate">
<SvgIcon v-bind="{ name: 'loading.svg', type: 'white', size: 16 }" />
</span>
<span class="icon" v-else>
<SvgIcon v-bind="{ name: 'add4.svg', type: 'white', size: 16 }"></SvgIcon>
</span>
<span>{{ loadingCreate ? "Creating..." : "Create" }}</span>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from "vue";
import { useNuxtApp } from "nuxt/app";
interface TemplateContent {
receiver: string;
subject: string;
content: string;
imageUrl: string | null;
linkUrl: string[];
textLinkUrl: string[];
keyword: string[];
html: string;
}
interface DataTemplate {
id?: number;
name: string;
content: TemplateContent;
}
interface Props {
data: DataTemplate;
editMode?: boolean;
onClose?: () => void;
onSuccess?: () => void;
}
const props = withDefaults(defineProps<Props>(), {
data: () => ({
name: "",
content: {
receiver: "",
subject: "",
content: "",
imageUrl: null,
linkUrl: [""],
textLinkUrl: [""],
keyword: [""],
html: "",
},
}),
editMode: false,
onSuccess: () => {},
});
const emit = defineEmits<{
close: [];
success: [];
}>();
const nuxtApp = useNuxtApp();
const { $snackbar, $insertapi, $patchapi } = nuxtApp;
const templateName = ref("");
const loading = ref(false);
const loadingCreate = ref(false);
const isEditMode = ref(props.editMode || false);
const listNameRef = ref<HTMLInputElement | null>(null);
const dataTemplate = ref<DataTemplate[]>([]);
onMounted(() => {
templateName.value = props.data.name || "";
});
watch(
() => props.data.name,
(newName) => {
templateName.value = newName || "";
}
);
watch(
() => props.editMode,
(newEditMode) => {
isEditMode.value = newEditMode || false;
}
);
const handleSave = async () => {
if (!templateName.value.trim()) {
$snackbar("Validation Error: Please enter a template name");
return;
}
loading.value = true;
try {
let response;
if (isEditMode.value) {
const patchData = {
...props.data,
name: templateName.value,
};
response = await $patchapi("Email_Template", patchData);
} else {
const insertData = {
...props.data,
name: templateName.value,
};
response = await $insertapi("Email_Template", insertData);
}
if (response && response !== "error") {
$snackbar(`Template ${isEditMode.value ? "updated" : "created"} successfully`);
if (props.onClose) props.onClose();
if (props.onSuccess) props.onSuccess();
emit("close");
emit("success");
} else {
throw new Error("Failed to save template");
}
} catch (error) {
console.error("Error saving template:", error);
const errorMessage = error.message || "Failed to save template.";
$snackbar(errorMessage);
} finally {
loading.value = false;
}
};
const handleCreateNew = async () => {
const postTemplateUrl = `${apiUrl}/Email_Template/`;
try {
loadingCreate.value = true;
const newTemplateData = {
...props.data,
name: templateName.value,
};
const response = await axios.post(postTemplateUrl, newTemplateData);
if (response.status === 201 || response.status === 200) {
$snackbar("Template created successfully");
if (props.onClose) props.onClose();
emit("close");
emit("success");
templateName.value = "";
}
} catch (error) {
console.error(error);
$snackbar("Error: Failed to create new template");
} finally {
loadingCreate.value = false;
}
};
</script>
<style scoped>
.rotating {
animation: spin 1s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -0,0 +1,686 @@
<template>
<div class="pr-4">
<form @submit.prevent="handleSendEmail">
<!-- Receiver Section -->
<div class="field">
<label class="label">
<span class="icon-text">
<span>Người nhận {{ selectedName ? `(Đang mở: ${selectedName})` : "" }}<b class="ml-1 has-text-danger">*</b></span>
</span>
</label>
<div class="field is-flex">
<div class="control is-expanded pr-3 is-flex-grow-1">
<input
ref="emailTextareaRef"
class="input"
name="receiver"
id="receiver"
style="height: 100%"
v-model="formData.content.receiver"
placeholder="Nhập địa chỉ email người nhận, ngăn cách bằng dấu chấm phẩy (;)"
@input="handleInputChange('receiver', $event)"
></input>
</div>
<div class="control">
<div class="buttons is-flex is-flex-wrap-nowrap">
<button
v-if="$getEditRights()"
type="button"
class="button is-light"
@click="handleOpenModal('save-list')"
style="border-radius: 8px; width: 100%"
>
<span>Lưu</span>
</button>
<button
type="button"
class="button is-info"
@click="handleOpenModal('open-list')"
style="border-radius: 8px; width: 100%"
>
<span>Mở</span>
</button>
</div>
</div>
</div>
</div>
<!-- Subject Section -->
<div class="field">
<label class="label">
<span class="icon-text">
<span>Tiêu đề<b class="ml-1 has-text-danger">*</b></span>
</span>
</label>
<div class="control">
<input
type="text"
id="subject"
name="subject"
class="input"
v-model="formData.content.subject"
placeholder="Nhập tiêu đề email..."
required
@input="handleInputChange('subject', $event)"
/>
</div>
</div>
<!-- Content Section -->
<div class="field">
<label class="label">
<span class="icon-text">
<span>Nội dung</span>
</span>
</label>
<div class="control">
<div class="box" style="padding: 0">
<Editor
:key="quillKey"
:text="formData.content.content"
pagename="pagedataemail"
:height="'300px'"
@content="handleQuillChange"
/>
</div>
</div>
</div>
<!-- Image URL Section -->
<div class="field">
<label class="label">
<span class="icon-text">
<span>Đường dẫn hình ảnh</span>
</span>
</label>
<div class="control">
<input
type="url"
id="imageUrl"
name="imageUrl"
class="input"
placeholder="https://hinhanh.com/image.jpg"
@input="handleInputChange('imageUrl', $event)"
/>
</div>
</div>
<!-- Link URLs Section -->
<div class="field">
<div class="level">
<div class="level-left">
<div class="level-item">
<label class="label mb-0">
<span class="icon-text">
<span>Đường dẫn liên kết</span>
</span>
</label>
</div>
</div>
<div class="level-right">
<div class="level-item">
<button type="button" @click="addLinkUrl" class="button is-normal">
<span class="icon">
<SvgIcon v-bind="{ name: 'add4.svg', type: 'black', size: 16 }"></SvgIcon>
</span>
<span>Thêm liên kết</span>
</button>
</div>
</div>
</div>
<div
v-for="(link, index) in formData.content.linkUrl"
:key="index"
class="field columns has-addons mb-2"
:class="`${formData.content.linkUrl.length > 1 ? 'pr-3' : ''}`"
>
<div class="control column is-6 pr-0 is-expanded">
<input
type="url"
class="input"
style="border-radius: 8px"
v-model="formData.content.linkUrl[index]"
:placeholder="`Liên kết ${index + 1}: https://example.com`"
@input="props.onDataChange(formData)"
/>
</div>
<div class="control column is-6 is-expanded">
<input
type="text"
class="input"
style="border-radius: 8px"
v-model="formData.content.textLinkUrl[index]"
:placeholder="`Tên ${index + 1}`"
@input="props.onDataChange(formData)"
/>
</div>
<div
class="control column is-1 p-0"
v-if="formData.content.linkUrl.length > 1"
style="align-self: center; width: 40px"
>
<button
type="button"
@click="removeLinkUrl(index)"
class="button is-danger has-text-white"
style="border-radius: 8px"
>
<span class="icon">
<SvgIcon v-bind="{ name: 'trash.svg', type: 'white', size: 16 }"></SvgIcon>
</span>
</button>
</div>
</div>
</div>
<!-- Keywords Section -->
<div v-if="detectedKeywords.length > 0" class="field">
<label class="label">
<span class="icon-text">
<span>Từ khóa</span>
</span>
</label>
<div class="box">
<div v-for="(keyword, index) in detectedKeywords" :key="index" class="field has-addons mb-2 columns">
<div class="control column is-4 pr-0 pb-0">
<span style="width: 100%" class="button is-static is-small">{{ keyword }}</span>
</div>
<div class="control is-expanded column is-8 pb-0">
<input
type="text"
class="input is-small"
:value="getKeywordValue(keyword)"
:placeholder="`Nhập giá trị cho ${keyword}`"
@input="handleKeywordValueChange(keyword, ($event.target as HTMLInputElement).value)"
/>
</div>
</div>
</div>
</div>
<!-- Submit Button -->
<div class="field">
<div class="control">
<button class="button is-primary has-text-white" :class="{ 'is-loading': loading }" :disabled="loading">
<span v-if="loading">Đang gửi</span>
<template v-else>
<span class="icon">
<SvgIcon v-bind="{ name: 'send.svg', type: 'white', size: 18 }" />
</span>
<span>Gửi email</span>
</template>
</button>
</div>
</div>
</form>
</div>
<Modal @dataevent="handleRowClick" @close="showmodal = undefined" v-bind="showmodal" v-if="showmodal"></Modal>
</template>
<script setup lang="ts">
import { useNuxtApp } from "nuxt/app";
import { render } from "@vue-email/render";
const nuxtApp = useNuxtApp();
const $snackbar = nuxtApp.$snackbar as (message?: string) => void;
const $getpath = nuxtApp.$getpath as (message?: string) => void;
const $getEditRights = nuxtApp.$getEditRights as () => boolean;
// const { $getpath, $snackbar} = useNuxtApp()
import { ref, watch, nextTick } from "vue";
import axios from "axios";
import Modal from "@/components/Modal.vue";
import Template1 from "@/lib/email/templates/Template1.vue";
import SvgIcon from "@/components/SvgIcon.vue";
import Editor from "@/components/common/Editor.vue";
const apiUrl = `${$getpath()}data`;
const sendEmailUrl = `${$getpath()}send-email`;
const showmodal = ref();
// Types
interface KeywordItem {
keyword: string;
value: string;
}
interface FormContent {
receiver: string;
subject: string;
content: string;
imageUrl: string | null;
linkUrl: string[];
textLinkUrl: string[];
keyword: KeywordItem[];
html: string;
}
interface FormData {
id?: number;
name: string;
template: string;
content: FormContent;
}
type ModalType = "none" | "save-list" | "open-list";
interface Props {
onDataChange: (data: FormData) => void;
initialData?: FormData;
}
// Props
const props = withDefaults(defineProps<Props>(), {
initialData: () => ({
id: undefined,
name: "",
template: "template1",
content: {
receiver: "",
subject: "",
content: "",
imageUrl: null,
linkUrl: [""],
textLinkUrl: [""],
keyword: [{ keyword: "", value: "" }],
html: "",
},
}),
});
const selectedName = ref("");
const selectedId = ref("");
// Refs
const emailTextareaRef = ref<HTMLTextAreaElement | null>(null);
const loading = ref(false);
const modalType = ref<ModalType>("none");
const formData = ref<FormData>(props.initialData);
const quillKey = ref(0);
const dataEmails = ref([]);
const detectedKeywords = ref<string[]>([]);
const handleRowClick = (data: { email: string; name: string; id: string }) => {
formData.value = {
...formData.value,
content: {
...formData.value.content,
receiver: data.email,
},
};
props.onDataChange(formData.value);
selectedName.value = data.name;
selectedId.value = data.id;
nextTick(() => {
emailTextareaRef.value?.focus();
});
};
// Methods
const extractKeywords = (content: string): string[] => {
if (!content) return [];
const regex = /\{\{([^}]+)\}\}/g;
const matches: string[] = [];
let match: RegExpExecArray | null = null;
while ((match = regex.exec(content)) !== null) {
if (match[1]) {
matches.push(match[1].trim());
}
}
return [...new Set(matches)];
};
const getKeywordValue = (keyword: string): string => {
const found = formData.value.content.keyword.find((kw) => kw.keyword === keyword);
return found ? found.value : "";
};
const handleInputChange = (field: keyof FormContent | "name" | "template", eventOrValue: Event | string) => {
const value =
typeof eventOrValue === "string" ? eventOrValue : (eventOrValue.target as HTMLInputElement)?.value ?? "";
if (field === "receiver" && value.trim() === "") {
selectedName.value = "";
}
if (field === "name" || field === "template") {
formData.value = { ...formData.value, [field]: value };
} else {
formData.value = {
...formData.value,
content: { ...formData.value.content, [field]: value },
};
}
props.onDataChange(formData.value);
};
const handleQuillChange = (content: string) => {
formData.value = {
...formData.value,
content: {
...formData.value.content,
content: content,
},
};
props.onDataChange(formData.value);
};
const handleLinkUrlChange = (index: number, eventOrValue: Event | string) => {
const value =
typeof eventOrValue === "string" ? eventOrValue : (eventOrValue.target as HTMLInputElement)?.value ?? "";
const newLinkUrl = [...formData.value.content.linkUrl];
newLinkUrl[index] = value;
formData.value = {
...formData.value,
content: { ...formData.value.content, linkUrl: newLinkUrl },
};
props.onDataChange(formData.value);
};
const handleTextLinkUrlChange = (index: number, eventOrValue: Event | string) => {
const value =
typeof eventOrValue === "string" ? eventOrValue : (eventOrValue.target as HTMLInputElement)?.value ?? "";
const newTextLinkUrl = [...formData.value.content.textLinkUrl];
newTextLinkUrl[index] = value;
formData.value = {
...formData.value,
content: { ...formData.value.content, textLinkUrl: newTextLinkUrl },
};
props.onDataChange(formData.value);
};
const handleKeywordValueChange = (keyword: string, eventOrValue: Event | string) => {
const value =
typeof eventOrValue === "string" ? eventOrValue : (eventOrValue.target as HTMLInputElement)?.value ?? "";
const keywordIndex = formData.value.content.keyword.findIndex((kw) => kw.keyword === keyword);
if (keywordIndex !== -1) {
const newKeywords = [...formData.value.content.keyword];
const keywordItem = newKeywords[keywordIndex];
if (keywordItem) {
keywordItem.value = value;
formData.value = {
...formData.value,
content: { ...formData.value.content, keyword: newKeywords },
};
props.onDataChange(formData.value);
}
}
};
const addLinkUrl = () => {
formData.value = {
...formData.value,
content: {
...formData.value.content,
linkUrl: [...formData.value.content.linkUrl, ""],
textLinkUrl: [...formData.value.content.textLinkUrl, ""],
},
};
props.onDataChange(formData.value);
};
const removeLinkUrl = (index: number) => {
if (formData.value.content.linkUrl.length > 1) {
const newLinkUrl = formData.value.content.linkUrl.filter((_, i) => i !== index);
const newTextLinkUrl = formData.value.content.textLinkUrl.filter((_, i) => i !== index);
formData.value = {
...formData.value,
content: { ...formData.value.content, linkUrl: newLinkUrl, textLinkUrl: newTextLinkUrl },
};
props.onDataChange(formData.value);
}
};
const getSelectedTemplate = (previewMode = true) => {
const templateProps = {
content: {
receiver: formData.value.content.receiver || "",
subject: formData.value.content.subject || "Thông báo mới",
message: formData.value.content.content || "Bạn có một thông báo mới.",
imageUrl: formData.value.content.imageUrl || null,
linkUrl: formData.value.content.linkUrl.filter((link) => link.trim() !== ""),
textLinkUrl: formData.value.content.textLinkUrl.filter((text) => text.trim() !== ""),
keyword: formData.value.content.keyword,
},
previewMode: previewMode,
};
// Switch between different templates based on formData.template
let component;
switch (formData.value.template) {
case "template1":
component = Template1;
break;
default:
component = Template1;
break;
}
return {
component,
props: templateProps,
};
};
const handleSendEmail = async () => {
if (!formData.value.content.receiver.trim()) {
$snackbar("Validation Error: Please enter a receiver email address");
return;
}
loading.value = true;
try {
// Get template component and props
const { component, props: templateProps } = getSelectedTemplate();
// Render Vue component to HTML string
let emailHtml = await render(component, templateProps);
// If no image URL provided, remove image section from HTML
if ((formData.value.content.imageUrl ?? "").trim() === "") {
emailHtml = emailHtml.replace(/<tr\s+class=["']header-row["'][^>]*>[\s\S]*?<\/tr>/gi, "");
emailHtml = emailHtml.replace(/\n\s*\n\s*\n/g, "\n\n");
}
// Replace keywords in HTML
let finalEmailHtml = emailHtml;
if (formData.value.content.keyword && formData.value.content.keyword.length > 0) {
formData.value.content.keyword.forEach(({ keyword, value }) => {
if (keyword && value) {
const regex = new RegExp(`\\{\\{${keyword}\\}\\}`, "g");
finalEmailHtml = finalEmailHtml.replace(regex, value);
}
});
}
const response = await nuxtApp.$insertapi("sendemail", {
to: formData.value.content.receiver,
content: finalEmailHtml,
subject: formData.value.content.subject || "Thông báo từ BigDataTech",
}, undefined, false
);
if (response !== null) {
$snackbar(`Email sent successfully! Sent to ${formData.value.content.receiver.split(";").length} recipient(s)`);
// Reset form
formData.value = {
id: undefined,
name: "",
template: formData.value.template,
content: {
receiver: "",
subject: "",
content: "",
imageUrl: null,
linkUrl: [""],
textLinkUrl: [""],
keyword: [{ keyword: "", value: "" }],
html: "",
},
};
props.onDataChange(formData.value);
quillKey.value++;
}
} catch (error) {
console.error("Error sending email:", error);
const errorMsg = axios.isAxiosError(error)
? error.response?.data?.error || error.message
: "Unknown error occurred";
$snackbar(`Failed to send email: ${errorMsg}`);
} finally {
loading.value = false;
}
};
const handleOpenModal = async (type: ModalType) => {
modalType.value = type;
await getData();
console.log(modalType.value);
if (type === "save-list") {
showmodal.value = {
component: "marketing/email/dataGmail/SaveListGmail",
title: "Lưu danh sách email",
width: "450px",
height: "350px",
vbind: {
emails: formData.value.content.receiver,
name: selectedName.value,
id: selectedId.value,
onClose: handleCloseModal,
},
};
} else if (type === "open-list") {
showmodal.value = {
component: "marketing/email/dataGmail/OpenListGmail",
title: "Danh sách email",
width: "95%",
height: "auto",
vbind: {
dataEmail: dataEmails.value,
loading: loading.value,
onClose: handleCloseModal,
},
};
}
};
const handleCloseModal = () => {
modalType.value = "none";
showmodal.value = undefined;
};
const getData = async () => {
loading.value = true;
const emailListUrl = `${apiUrl}/Email_List/`;
try {
const response = await axios.get(emailListUrl);
if (response.status === 200) {
console.log(response);
dataEmails.value = response.data.rows;
console.log(dataEmails.value);
} else {
throw new Error("Failed to fetch email list");
}
} catch (error) {
console.error("Error fetching email list:", error);
throw error;
} finally {
loading.value = false;
}
};
const handleEditList = (id: string, name: string, emails: string) => {
modalType.value = "save-list";
};
// Watchers
// watch(
// () => selectedEmails.value,
// (newValue) => {
// if (newValue) {
// formData.value = {
// ...formData.value,
// content: {
// ...formData.value.content,
// receiver: newValue,
// },
// }
// props.onDataChange(formData.value)
// nextTick(() => {
// emailTextareaRef.value?.focus()
// })
// setSelectedEmails('')
// }
// }
// )
watch(
() => props.initialData,
(newValue) => {
if (newValue) {
formData.value = { ...formData.value, ...newValue };
}
},
{ deep: true }
);
watch(
() => formData.value.content.content,
(newValue) => {
const keywords = extractKeywords(newValue);
detectedKeywords.value = keywords;
if (keywords.length > 0) {
const newKeywords = keywords.map((keyword) => {
const existing = formData.value.content.keyword.find((k) => k.keyword === keyword);
return existing || { keyword, value: "" };
});
formData.value = {
...formData.value,
content: {
...formData.value.content,
keyword: newKeywords,
},
};
props.onDataChange(formData.value);
} else {
formData.value = {
...formData.value,
content: {
...formData.value.content,
keyword: [{ keyword: "", value: "" }],
},
};
props.onDataChange(formData.value);
}
},
{ immediate: true }
);
</script>
<style scoped>
:deep(.ql-toolbar) {
position: sticky;
top: 0;
z-index: 10;
background-color: #f9fafb;
}
:deep(.ql-container) {
height: calc(100% - 42px);
overflow-y: auto;
}
</style>

View File

@@ -0,0 +1,283 @@
<template>
<div v-if="templateEmailContent" class="view-email">
<Template1 v-bind="templateProps" previewMode />
<div class="action mt-3">
<button
class="button is-info mx-3 has-text-white"
:class="{ 'is-loading': isLoading || workflowIsLoading }"
@click="handleSendEmail()"
>
<span>Gửi mail</span>
</button>
</div>
</div>
<div v-else>Chưa nội dung email</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import { render } from '@vue-email/render';
import Template1 from '@/lib/email/templates/Template1.vue';
const {
$insertapi,
$snackbar,
$getdata,
$mode,
$findapi,
$getapi,
$numtoString,
$numberToVietnamese,
$numberToVietnameseCurrency,
$formatDateVN,
$getFirstAndLastName,
$store,
$paymentQR,
} = useNuxtApp();
const isLoading = ref(false);
const templateEmailContent = ref(null);
const props = defineProps({
data: Object,
idEmailTemplate: Number,
scheduleItemId: Number,
});
const isVietnamese = computed(() => $store.lang.toLowerCase() === 'vi');
const paymentScheduleItem = ref(null);
const contentPaymentQR = ref('');
const emailTemplate = await $getdata('emailtemplate', { id: props.idEmailTemplate }, undefined, false);
templateEmailContent.value = emailTemplate[0] ?? null;
let foundPaymentSchedule = $findapi('payment_schedule');
foundPaymentSchedule.params = {
values:
'id,code,from_date,to_date,amount,cycle,paid_amount,remain_amount,txn_detail,txn_detail__transaction,txn_detail__transaction__customer,txn_detail__transaction__customer__type__code,txn_detail__transaction__customer__code,txn_detail__transaction__customer__fullname,txn_detail__transaction__customer__phone,txn_detail__transaction__customer__email,txn_detail__transaction__customer__contact_address,txn_detail__transaction__customer__address,txn_detail__transaction__customer__legal_code,txn_detail__transaction__product__trade_code',
filter: { id: props.scheduleItemId },
};
async function paymentSchedule() {
try {
const [paymentScheduleRes] = await $getapi([foundPaymentSchedule]);
paymentScheduleItem.value = paymentScheduleRes?.data?.rows[0] || null;
contentPaymentQR.value = buildContentPayment(paymentScheduleItem.value);
} catch (error) {
if ($mode === 'dev') {
console.error('Call api product error', error);
}
}
}
paymentSchedule();
const buildQrHtml = (url) => `
<div style="text-align: center; margin-top: 16px">
<img
src="${url}"
alt="VietQR"
width="500"
style="display: inline-block"
/>
</div>
`;
const buildContentPayment = (data) => {
const {
txn_detail__transaction__customer__type__code: customerType,
txn_detail__transaction__customer__code: customerCode,
txn_detail__transaction__customer__fullname: customerName,
txn_detail__transaction__product__trade_code: productCode,
cycle,
} = data;
if (customerType.toLowerCase() === 'cn') {
if (customerName.length < 14) {
return `${productCode} ${customerCode} ${customerName} TT ${cycle == 0 ? 'Dat Coc' : `Dot ${cycle}`}`;
} else {
return `${productCode} ${customerCode} ${$getFirstAndLastName(customerName)} TT ${cycle == 0 ? 'Dat Coc' : `Dot ${cycle}`}`;
}
} else {
return `${productCode} ${customerCode} TT ${cycle == 0 ? 'Dat Coc' : `Dot ${cycle}`}`;
}
};
const templateProps = computed(() => {
let content = templateEmailContent.value.content.content || '';
// 1⃣ XÓA TOÀN BỘ QR CŨ
content = content.replace(/<div[^>]*>\s*<img[^>]*img\.vietqr\.io[^>]*>\s*<\/div>/g, '');
// 2⃣ CHỈ APPEND 1 QR CUỐI CÙNG (NẾU CÓ URL)
if ($paymentQR(contentPaymentQR.value)) {
content = `
${content.trim()}
${buildQrHtml($paymentQR(contentPaymentQR.value))}
`;
}
return {
content: {
subject: templateEmailContent.value.content.subject || 'Thông báo mới',
message: replaceTemplateVars(content) || 'Bạn có một thông báo mới.',
imageUrl: templateEmailContent.value.content.imageUrl || null,
linkUrl: templateEmailContent.value.content.linkUrl || [''],
textLinkUrl: templateEmailContent.value.content.textLinkUrl || [''],
keyword: Array.isArray(templateEmailContent.value.content.keyword)
? templateEmailContent.value.content.keyword.map((k) => (typeof k === 'string' ? { keyword: k, value: '' } : k))
: [{ keyword: '', value: '' }],
},
previewMode: true,
};
});
const handleSendEmail = async () => {
isLoading.value = true;
const tempEm = {
value: {
...templateProps.value,
content: {
...templateProps.value.content,
},
},
};
// ===== QUILL → HTML EMAIL (INLINE STYLE) =====
tempEm.value.content.message = quillToEmailHtml(templateProps.value.content.message);
let emailHtml = await render(Template1, tempEm.value);
// If no image URL provided, remove image section from HTML
if ((templateProps.value.content.imageUrl ?? '').trim() === '') {
emailHtml = emailHtml.replace(/<tr\s+class=["']header-row["'][^>]*>[\s\S]*?<\/tr>/gi, '');
emailHtml = emailHtml.replace(/\n\s*\n\s*\n/g, '\n\n');
}
// Replace keywords in HTML
let finalEmailHtml = emailHtml;
if (templateProps.value.content.keyword && templateProps.value.content.keyword.length > 0) {
templateProps.value.content.keyword.forEach(({ keyword, value }) => {
if (keyword && value) {
const regex = new RegExp(`\\{\\{${keyword}\\}\\}`, 'g');
finalEmailHtml = finalEmailHtml.replace(regex, value);
}
});
}
const response = await $insertapi(
'sendemail',
{
to: paymentScheduleItem.value?.txn_detail__transaction__customer__email,
content: finalEmailHtml,
subject: replaceTemplateVars(templateProps.value.content.subject) || 'Thông báo từ Utopia Villas & Resort',
},
undefined,
false,
);
if (response !== null) {
isLoading.value = false;
$snackbar(
isVietnamese
? `Thông báo đã được gửi thành công đến khách hàng.`
: `The notification has been successfully sent to the customer.`,
);
}
};
function replaceTemplateVars(html) {
return html
.replace(/\[day]/g, String(new Date().getDate()).padStart(2, '0') || '')
.replace(/\[month]/g, String(new Date().getMonth() + 1).padStart(2, '0') || '')
.replace(/\[year]/g, new Date().getFullYear() || '')
.replace(/\[product\.trade_code\]/g, paymentScheduleItem.value?.txn_detail__transaction__product__trade_code)
.replace(
/\[product\.trade_code_payment\]/g,
sanitizeContentPayment(paymentScheduleItem.value?.txn_detail__transaction__product__trade_code),
)
.replace(/\[customer\.fullname\]/g, paymentScheduleItem.value?.txn_detail__transaction__customer__fullname)
.replace(
/\[customer\.name\]/g,
`${paymentScheduleItem.value?.txn_detail__transaction__customer__type__code.toLowerCase() == 'cn' ? (paymentScheduleItem.value?.txn_detail__transaction__customer__fullname.length < 14 ? paymentScheduleItem.value?.txn_detail__transaction__customer__fullname : $getFirstAndLastName(paymentScheduleItem.value.txn_detail__transaction__customer__fullname)) : ''}` ||
'',
)
.replace(/\[customer\.code\]/g, paymentScheduleItem.value?.txn_detail__transaction__customer__code || '')
.replace(
/\[customer\.legal_code\]/g,
paymentScheduleItem.value?.txn_detail__transaction__customer__legal_code || '',
)
.replace(
/\[customer\.contact_address\]/g,
paymentScheduleItem.value?.txn_detail__transaction__customer__contact_address ||
paymentScheduleItem.value?.txn_detail__transaction__customer__address ||
'',
)
.replace(/\[customer\.phone\]/g, paymentScheduleItem.value?.txn_detail__transaction__customer__phone || '')
.replace(/\[payment_schedule\.from_date\]/g, $formatDateVN(paymentScheduleItem.value?.from_date) || '')
.replace(/\[payment_schedule\.to_date\]/g, $formatDateVN(paymentScheduleItem.value?.to_date) || '')
.replace(/\[payment_schedule\.amount\]/g, $numtoString(paymentScheduleItem.value?.remain_amount) || '')
.replace(
/\[payment_schedule\.amount_in_word\]/g,
$numberToVietnameseCurrency(paymentScheduleItem.value?.remain_amount) || '',
)
.replace(/\[payment_schedule\.cycle\]/g, $numtoString(paymentScheduleItem.value?.cycle) || '')
.replace(
/\[payment_schedule\.cycle-in-words\]/g,
`${paymentScheduleItem.value?.cycle == 0 ? 'đặt cọc' : `đợt thứ ${$numberToVietnamese(paymentScheduleItem.value?.cycle).toLowerCase()}`}` ||
'',
)
.replace(
/\[payment_schedule\.note\]/g,
`${paymentScheduleItem.value?.cycle == 0 ? 'Dat coc' : `Dot ${paymentScheduleItem.value?.cycle}`}` || '',
);
}
function quillToEmailHtml(html) {
return (
html
// ALIGN
.replace(/class="([^"]*?)ql-align-center([^"]*?)"/g, 'style="text-align:center;"')
.replace(/class="([^"]*?)ql-align-right([^"]*?)"/g, 'style="text-align:right;"')
.replace(/class="([^"]*?)ql-align-left([^"]*?)"/g, 'style="text-align:left;"')
.replace(/class="([^"]*?)ql-align-justify([^"]*?)"/g, 'style="text-align:justify;"')
// FONT SIZE
.replace(/ql-size-small/g, '')
.replace(/ql-size-large/g, '')
.replace(/ql-size-huge/g, '')
// REMOVE EMPTY CLASS
.replace(/class=""/g, '')
);
}
function sanitizeContentPayment(text, maxLength = 80) {
if (!text) return '';
return text
.normalize('NFD') // bỏ dấu tiếng Việt
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-zA-Z0-9 _-]/g, '') // bỏ ký tự lạ
.replace(/\s+/g, ' ')
.trim()
.slice(0, maxLength);
}
</script>
<style>
.view-email .view-email-wrapper {
border: 1px solid #ccc;
padding: 16px;
border-radius: 8px;
background-color: #f9f9f9;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
width: 80%;
overflow-y: auto;
margin-bottom: 15px;
margin: 0 auto;
}
.action {
text-align: center;
}
</style>