Initial commit
This commit is contained in:
471
app/components/marketing/email/Email.vue
Normal file
471
app/components/marketing/email/Email.vue
Normal 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 có 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>
|
||||
Reference in New Issue
Block a user