Initial commit
This commit is contained in:
5
app/components/marketing/email/Email.utils.js
Normal file
5
app/components/marketing/email/Email.utils.js
Normal 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";
|
||||
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>
|
||||
256
app/components/marketing/email/JobConfigurator.vue
Normal file
256
app/components/marketing/email/JobConfigurator.vue
Normal 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>
|
||||
192
app/components/marketing/email/MappingConfigurator.vue
Normal file
192
app/components/marketing/email/MappingConfigurator.vue
Normal 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>
|
||||
347
app/components/marketing/email/dataGmail/EmailSent.vue
Normal file
347
app/components/marketing/email/dataGmail/EmailSent.vue
Normal 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>
|
||||
130
app/components/marketing/email/dataGmail/OpenListGmail.vue
Normal file
130
app/components/marketing/email/dataGmail/OpenListGmail.vue
Normal 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>
|
||||
215
app/components/marketing/email/dataGmail/SaveListGmail.vue
Normal file
215
app/components/marketing/email/dataGmail/SaveListGmail.vue
Normal 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>
|
||||
225
app/components/marketing/email/dataTemplate/SaveListTemplate.vue
Normal file
225
app/components/marketing/email/dataTemplate/SaveListTemplate.vue
Normal 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>
|
||||
686
app/components/marketing/email/forms/EmailForm1.vue
Normal file
686
app/components/marketing/email/forms/EmailForm1.vue
Normal 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>
|
||||
283
app/components/marketing/email/viewEmail/ViewEmail.vue
Normal file
283
app/components/marketing/email/viewEmail/ViewEmail.vue
Normal 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 có 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>
|
||||
Reference in New Issue
Block a user