1321 lines
43 KiB
Vue
1321 lines
43 KiB
Vue
<script setup>
|
|
import AdminLayout from "@/Layouts/AdminLayout.vue";
|
|
import { Head, Link, useForm, router, usePage } from "@inertiajs/vue3";
|
|
import { ref, watch, computed, onMounted, nextTick } from "vue";
|
|
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
|
import { faArrowLeft, faEye } from "@fortawesome/free-solid-svg-icons";
|
|
// Ensure Quill is available before importing the wrapper component
|
|
import Quill from "quill";
|
|
if (typeof window !== "undefined" && !window.Quill) {
|
|
// @ts-ignore
|
|
window.Quill = Quill;
|
|
}
|
|
import "quill/dist/quill.snow.css";
|
|
|
|
const props = defineProps({
|
|
template: { type: Object, default: null },
|
|
});
|
|
|
|
const form = useForm({
|
|
name: props.template?.name ?? "",
|
|
key: props.template?.key ?? "",
|
|
subject_template: props.template?.subject_template ?? "",
|
|
html_template: props.template?.html_template ?? "",
|
|
text_template: props.template?.text_template ?? "",
|
|
entity_types: props.template?.entity_types ?? ["client", "contract"],
|
|
active: props.template?.active ?? true,
|
|
});
|
|
|
|
const preview = ref({ subject: "", html: "", text: "" });
|
|
const finalHtml = ref("");
|
|
const finalAttachments = ref([]);
|
|
const showFinal = ref(false);
|
|
const finalEmbedMode = ref("base64"); // 'base64' (default) or 'hosted' for diagnostics
|
|
const sendEmbedMode = ref("base64"); // embed mode for Send Test action
|
|
const isLocalHost = computed(() => {
|
|
if (typeof window === "undefined") return false;
|
|
const h = window.location.hostname || "";
|
|
return /^(localhost|127\.0\.0\.1)$/i.test(h);
|
|
});
|
|
// If preview.html is empty (e.g., because only head/styles were changed), show the body-safe content from the current source as a fallback.
|
|
const previewHtml = computed(() => {
|
|
const html = preview.value?.html || "";
|
|
if (html.trim() !== "") {
|
|
return containsDocScaffold(html) ? extractBody(html) : html;
|
|
}
|
|
// Fallback to local content if server preview didn't provide HTML
|
|
return sourceMode.value
|
|
? extractBody(form.html_template || "")
|
|
: stripDocScaffold(form.html_template || "");
|
|
});
|
|
const docsRaw = ref(props.template?.documents ? [...props.template.documents] : []);
|
|
const docs = computed(() =>
|
|
(docsRaw.value ?? []).map((d) => ({
|
|
...d,
|
|
url: d?.path ? `/storage/${d.path}` : null,
|
|
}))
|
|
);
|
|
function updateLocalDoc(documentId, path, name = null, size = null) {
|
|
if (!documentId) return;
|
|
const idx = docsRaw.value.findIndex((d) => d.id === documentId);
|
|
if (idx !== -1) {
|
|
const next = { ...docsRaw.value[idx] };
|
|
next.path = path ?? next.path;
|
|
if (name) next.name = name;
|
|
if (size != null) next.size = size;
|
|
docsRaw.value.splice(idx, 1, next);
|
|
}
|
|
}
|
|
function formatSize(bytes) {
|
|
if (!bytes && bytes !== 0) return "";
|
|
const kb = bytes / 1024;
|
|
if (kb < 1024) return `${kb.toFixed(1)} KB`;
|
|
const mb = kb / 1024;
|
|
return `${mb.toFixed(2)} MB`;
|
|
}
|
|
const debounce = (fn, ms = 500) => {
|
|
let t;
|
|
return (...args) => {
|
|
clearTimeout(t);
|
|
t = setTimeout(() => fn(...args), ms);
|
|
};
|
|
};
|
|
|
|
// Core preview fetcher; mirrors values when creating (no ID), calls backend when editing
|
|
const fetchPreview = () => {
|
|
if (!props.template?.id) {
|
|
preview.value = {
|
|
subject: form.subject_template,
|
|
html: form.html_template,
|
|
text: form.text_template,
|
|
};
|
|
return;
|
|
}
|
|
window.axios
|
|
.post(route("admin.email-templates.preview", props.template.id), {
|
|
subject: form.subject_template,
|
|
// Always send full HTML so head/styles can be respected and inlined server-side
|
|
html: form.html_template,
|
|
text: form.text_template,
|
|
client_id: sample.value.client_id || undefined,
|
|
case_id: sample.value.case_id || undefined,
|
|
contract_id: sample.value.contract_id || undefined,
|
|
extra: safeParseExtra(sample.value.extra),
|
|
})
|
|
.then((r) => {
|
|
preview.value = r.data;
|
|
});
|
|
};
|
|
|
|
const doPreview = debounce(fetchPreview, 400);
|
|
|
|
watch(
|
|
() => [form.subject_template, form.html_template, form.text_template],
|
|
() => doPreview(),
|
|
{ deep: true, immediate: true }
|
|
);
|
|
|
|
function submit() {
|
|
if (props.template?.id) {
|
|
form.put(route("admin.email-templates.update", props.template.id));
|
|
} else {
|
|
form.post(route("admin.email-templates.store"));
|
|
}
|
|
}
|
|
|
|
async function fetchFinalHtml() {
|
|
const { data } = await window.axios.post(
|
|
route("admin.email-templates.render-final", props.template.id),
|
|
{
|
|
subject: form.subject_template,
|
|
html: form.html_template,
|
|
text: form.text_template,
|
|
client_id: sample.value.client_id || undefined,
|
|
case_id: sample.value.case_id || undefined,
|
|
contract_id: sample.value.contract_id || undefined,
|
|
extra: safeParseExtra(sample.value.extra),
|
|
embed: finalEmbedMode.value,
|
|
}
|
|
);
|
|
finalHtml.value = data?.html || "";
|
|
finalAttachments.value = Array.isArray(data?.attachments) ? data.attachments : [];
|
|
}
|
|
|
|
async function openFinalHtmlDialog() {
|
|
if (!props.template?.id) {
|
|
alert("Najprej shranite predlogo.");
|
|
return;
|
|
}
|
|
try {
|
|
await fetchFinalHtml();
|
|
showFinal.value = true;
|
|
} catch (e) {
|
|
console.error("Failed to render final HTML", e);
|
|
alert("Neuspešen prikaz končne HTML vsebine.");
|
|
}
|
|
}
|
|
|
|
function copyFinalHtml() {
|
|
try {
|
|
navigator.clipboard.writeText(finalHtml.value || "");
|
|
alert("Končni HTML je kopiran v odložišče.");
|
|
} catch (e) {
|
|
console.warn("Clipboard write failed", e);
|
|
}
|
|
}
|
|
|
|
function downloadFinalHtml() {
|
|
const blob = new Blob([finalHtml.value || ""], { type: "text/html;charset=utf-8" });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = `${(form.name || "email-template").replace(/[^a-z0-9-_]+/gi, "-")}.html`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
onMounted(() => {
|
|
// Show something instantly and request preview immediately
|
|
preview.value = {
|
|
subject: form.subject_template,
|
|
html: form.html_template,
|
|
text: form.text_template,
|
|
};
|
|
// Populate cascading selects immediately so the Client dropdown isn't empty
|
|
loadClients();
|
|
fetchPreview();
|
|
// Mount Quill editor directly
|
|
try {
|
|
if (quillContainer.value) {
|
|
// instantiate lazily to ensure DOM is ready
|
|
const editor = new Quill(quillContainer.value, {
|
|
theme: "snow",
|
|
modules: quillModules.value,
|
|
});
|
|
quill.value = editor;
|
|
if (form.html_template) {
|
|
const bodyOnly = stripDocScaffold(form.html_template);
|
|
if (bodyOnly) {
|
|
// Ensure Quill properly converts HTML to Delta
|
|
editor.clipboard.dangerouslyPasteHTML(0, bodyOnly, "api");
|
|
} else {
|
|
editor.setText(
|
|
"(Body is empty. Switch to Source HTML to edit full document.)",
|
|
"api"
|
|
);
|
|
}
|
|
}
|
|
editor.on("text-change", (_delta, _old, source) => {
|
|
// Ignore programmatic changes; only persist user edits
|
|
if (source !== "user") {
|
|
return;
|
|
}
|
|
if (!sourceMode.value) {
|
|
const bodyHtml = editor.root.innerHTML;
|
|
form.html_template = containsDocScaffold(form.html_template)
|
|
? replaceBody(form.html_template, bodyHtml)
|
|
: bodyHtml;
|
|
}
|
|
});
|
|
}
|
|
} catch (e) {
|
|
console.error("Failed to mount Quill", e);
|
|
}
|
|
// Initialize iframe editor if advanced mode is on
|
|
if (advancedMode.value) {
|
|
initIframeEditor();
|
|
}
|
|
});
|
|
|
|
// --- Variable insertion and sample entity selection ---
|
|
const subjectRef = ref(null);
|
|
const quillContainer = ref(null);
|
|
const htmlSourceRef = ref(null);
|
|
const textRef = ref(null);
|
|
const activeField = ref(null); // 'subject' | 'html' | 'text'
|
|
const quill = ref(null);
|
|
const sourceMode = ref(false); // toggle for HTML source editing
|
|
// Advanced full-document editor that renders styles from <head>
|
|
const advancedMode = ref(false);
|
|
const iframeRef = ref(null);
|
|
let iframeSyncing = false;
|
|
const selectedImageSrc = ref("");
|
|
|
|
// Detect and handle full-document HTML so Quill doesn't wipe content
|
|
function containsDocScaffold(html) {
|
|
if (!html || typeof html !== "string") return false;
|
|
const h = html.toLowerCase();
|
|
return (
|
|
h.includes("<!doctype") ||
|
|
h.includes("<html") ||
|
|
h.includes("<head") ||
|
|
h.includes("<body") ||
|
|
h.includes("<style")
|
|
);
|
|
}
|
|
|
|
function extractBody(html) {
|
|
const m = /<body[^>]*>([\s\S]*?)<\/body>/i.exec(html);
|
|
return m ? m[1] : html;
|
|
}
|
|
|
|
function stripDocScaffold(html) {
|
|
if (!html) return "";
|
|
let out = html;
|
|
// Prefer body content when present
|
|
out = extractBody(out);
|
|
// Remove comments, doctype, html/head blocks, meta/title, and styles (Quill can't keep these)
|
|
out = out
|
|
.replace(/<!DOCTYPE[\s\S]*?>/gi, "")
|
|
.replace(/<\/?html[^>]*>/gi, "")
|
|
.replace(/<head[\s\S]*?>[\s\S]*?<\/head>/gi, "")
|
|
.replace(/<\/?meta[^>]*>/gi, "")
|
|
.replace(/<title[\s\S]*?>[\s\S]*?<\/title>/gi, "")
|
|
.replace(/<style[\s\S]*?>[\s\S]*?<\/style>/gi, "");
|
|
return out.trim();
|
|
}
|
|
|
|
// Replace only the inner content of <body>...</body> in a full document
|
|
function replaceBody(htmlDoc, newBody) {
|
|
if (!htmlDoc || typeof htmlDoc !== "string") return newBody || "";
|
|
const hasBody = /<body[^>]*>[\s\S]*?<\/body>/i.test(htmlDoc);
|
|
if (!hasBody) {
|
|
return newBody || "";
|
|
}
|
|
return htmlDoc.replace(/(<body[^>]*>)[\s\S]*?(<\/body>)/i, `$1${newBody || ""}$2`);
|
|
}
|
|
|
|
// Keep Quill and textarea in sync when toggling source mode
|
|
watch(
|
|
() => sourceMode.value,
|
|
(on) => {
|
|
if (on) {
|
|
// Switching to source view: if current model is NOT a full document,
|
|
// sync from Quill. Otherwise, keep the full source untouched.
|
|
if (quill.value && !containsDocScaffold(form.html_template)) {
|
|
form.html_template = quill.value.root.innerHTML;
|
|
}
|
|
} else {
|
|
// switching back to WYSIWYG: update editor html from model
|
|
if (quill.value) {
|
|
const bodyOnly = stripDocScaffold(form.html_template || "");
|
|
if (bodyOnly) {
|
|
quill.value.clipboard.dangerouslyPasteHTML(0, bodyOnly, "api");
|
|
} else {
|
|
quill.value.setText(
|
|
"(Body is empty. Switch to Source HTML to edit full document.)",
|
|
"api"
|
|
);
|
|
}
|
|
} else if (quillContainer.value) {
|
|
// if the instance doesn't exist for any reason, re-create it
|
|
const editor = new Quill(quillContainer.value, {
|
|
theme: "snow",
|
|
modules: quillModules.value,
|
|
});
|
|
quill.value = editor;
|
|
const bodyOnly = stripDocScaffold(form.html_template || "");
|
|
if (bodyOnly) {
|
|
editor.clipboard.dangerouslyPasteHTML(0, bodyOnly, "api");
|
|
} else {
|
|
editor.setText(
|
|
"(Body is empty. Switch to Source HTML to edit full document.)",
|
|
"api"
|
|
);
|
|
}
|
|
editor.on("text-change", (_d, _o, source) => {
|
|
if (source !== "user") {
|
|
return;
|
|
}
|
|
if (!sourceMode.value) {
|
|
const bodyHtml = editor.root.innerHTML;
|
|
form.html_template = containsDocScaffold(form.html_template)
|
|
? replaceBody(form.html_template, bodyHtml)
|
|
: bodyHtml;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
);
|
|
|
|
// Keep advanced editor in a stable state with source/quill
|
|
watch(
|
|
() => advancedMode.value,
|
|
async (on) => {
|
|
if (on) {
|
|
sourceMode.value = false;
|
|
await nextTick();
|
|
initIframeEditor();
|
|
}
|
|
}
|
|
);
|
|
|
|
// When HTML changes externally, reflect it into iframe (unless we're the ones syncing)
|
|
watch(
|
|
() => form.html_template,
|
|
async () => {
|
|
if (!advancedMode.value || iframeSyncing) return;
|
|
await nextTick();
|
|
writeIframeDocument();
|
|
}
|
|
);
|
|
|
|
function ensureFullDoc(html) {
|
|
if (!html)
|
|
return '<!doctype html><html><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /></head><body></body></html>';
|
|
if (/<html[\s\S]*<\/html>/i.test(html)) return html;
|
|
return `<!doctype html><html><head><meta charset=\"utf-8\" /><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" /></head><body>${html}</body></html>`;
|
|
}
|
|
|
|
function writeIframeDocument() {
|
|
const iframe = iframeRef.value;
|
|
if (!iframe) return;
|
|
const doc = iframe.contentDocument;
|
|
if (!doc) return;
|
|
const html = ensureFullDoc(form.html_template || "");
|
|
doc.open();
|
|
doc.write(html);
|
|
doc.close();
|
|
try {
|
|
doc.body.setAttribute("spellcheck", "false");
|
|
} catch {}
|
|
}
|
|
|
|
function initIframeEditor() {
|
|
writeIframeDocument();
|
|
const iframe = iframeRef.value;
|
|
if (!iframe) return;
|
|
const doc = iframe.contentDocument;
|
|
if (!doc) return;
|
|
try {
|
|
doc.designMode = "on";
|
|
} catch {}
|
|
// track selected image src on click
|
|
doc.addEventListener("click", (e) => {
|
|
const t = e.target;
|
|
if (t && t.tagName === "IMG") {
|
|
selectedImageSrc.value = t.getAttribute("src") || "";
|
|
} else {
|
|
selectedImageSrc.value = "";
|
|
}
|
|
});
|
|
const handler = debounce(() => {
|
|
if (!advancedMode.value) return;
|
|
try {
|
|
iframeSyncing = true;
|
|
const full = doc.documentElement.outerHTML;
|
|
form.html_template = full;
|
|
} finally {
|
|
iframeSyncing = false;
|
|
}
|
|
}, 250);
|
|
doc.addEventListener("input", handler);
|
|
doc.addEventListener("keyup", handler);
|
|
}
|
|
|
|
function iframeExec(command, value = null) {
|
|
const iframe = iframeRef.value;
|
|
if (!iframe) return;
|
|
const doc = iframe.contentDocument;
|
|
if (!doc) return;
|
|
try {
|
|
doc.body.focus();
|
|
} catch {}
|
|
try {
|
|
doc.execCommand(command, false, value);
|
|
} catch (e) {
|
|
console.warn("execCommand failed", command, e);
|
|
}
|
|
}
|
|
|
|
async function iframeInsertImage() {
|
|
const input = document.createElement("input");
|
|
input.type = "file";
|
|
input.accept = "image/*";
|
|
input.onchange = async () => {
|
|
const file = input.files?.[0];
|
|
if (!file) return;
|
|
const data = new FormData();
|
|
data.append("file", file);
|
|
try {
|
|
const { data: res } = await window.axios.post(
|
|
route("admin.email-templates.upload-image"),
|
|
data,
|
|
{ headers: { "Content-Type": "multipart/form-data" } }
|
|
);
|
|
const url = res?.url;
|
|
const documentId = res?.document_id;
|
|
if (url) {
|
|
iframeExec("insertImage", url);
|
|
}
|
|
} catch (e) {
|
|
console.error("Image upload failed", e);
|
|
}
|
|
};
|
|
input.click();
|
|
}
|
|
|
|
async function iframeReplaceSelectedImage() {
|
|
if (!props.template?.id) {
|
|
alert("Najprej shranite predlogo, nato zamenjajte sliko.");
|
|
return;
|
|
}
|
|
if (!selectedImageSrc.value) {
|
|
alert("Najprej kliknite na sliko v dokumentu, ki jo želite zamenjati.");
|
|
return;
|
|
}
|
|
const input = document.createElement("input");
|
|
input.type = "file";
|
|
input.accept = "image/*";
|
|
input.onchange = async () => {
|
|
const file = input.files?.[0];
|
|
if (!file) return;
|
|
const data = new FormData();
|
|
data.append("file", file);
|
|
data.append("current_src", selectedImageSrc.value);
|
|
try {
|
|
const { data: res } = await window.axios.post(
|
|
route("admin.email-templates.replace-image", props.template.id),
|
|
data,
|
|
{ headers: { "Content-Type": "multipart/form-data" } }
|
|
);
|
|
const url = res?.url;
|
|
if (url) {
|
|
// Replace src in the iframe document
|
|
const iframe = iframeRef.value;
|
|
const doc = iframe?.contentDocument;
|
|
if (doc) {
|
|
// Update only the selected image if possible, else do a global replace
|
|
const imgs = Array.from(doc.querySelectorAll("img"));
|
|
const img = imgs.find(
|
|
(im) => (im.getAttribute("src") || "") === selectedImageSrc.value
|
|
);
|
|
if (img) {
|
|
img.setAttribute("src", url);
|
|
} else {
|
|
doc.body.innerHTML = doc.body.innerHTML.replaceAll(
|
|
selectedImageSrc.value,
|
|
url
|
|
);
|
|
}
|
|
// Sync back to the model
|
|
iframeSyncing = true;
|
|
form.html_template = doc.documentElement.outerHTML;
|
|
iframeSyncing = false;
|
|
selectedImageSrc.value = url;
|
|
}
|
|
if (documentId && res?.path) {
|
|
updateLocalDoc(documentId, res.path, null, res?.size ?? null);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error("Replace image failed", e);
|
|
}
|
|
};
|
|
input.click();
|
|
}
|
|
|
|
function setActive(field) {
|
|
activeField.value = field;
|
|
}
|
|
|
|
function insertAtCursor(el, value, modelGetter, modelSetter) {
|
|
if (!el) return;
|
|
const start = el.selectionStart ?? 0;
|
|
const end = el.selectionEnd ?? 0;
|
|
const current = modelGetter();
|
|
const next = current.slice(0, start) + value + current.slice(end);
|
|
modelSetter(next);
|
|
// restore caret after inserted value
|
|
requestAnimationFrame(() => {
|
|
el.focus();
|
|
const pos = start + value.length;
|
|
try {
|
|
el.setSelectionRange(pos, pos);
|
|
} catch {}
|
|
});
|
|
}
|
|
|
|
function insertPlaceholder(token) {
|
|
const content = `{{ ${token} }}`;
|
|
if (activeField.value === "subject" && subjectRef.value) {
|
|
insertAtCursor(
|
|
subjectRef.value,
|
|
content,
|
|
() => form.subject_template,
|
|
(v) => (form.subject_template = v)
|
|
);
|
|
} else if (activeField.value === "html") {
|
|
// If in source mode, treat HTML as textarea
|
|
if (sourceMode.value && htmlSourceRef.value) {
|
|
insertAtCursor(
|
|
htmlSourceRef.value,
|
|
content,
|
|
() => form.html_template,
|
|
(v) => (form.html_template = v)
|
|
);
|
|
return;
|
|
}
|
|
// Insert into Quill editor at current selection
|
|
if (quill.value) {
|
|
let range = quill.value.getSelection(true);
|
|
const index = range ? range.index : quill.value.getLength() - 1;
|
|
quill.value.insertText(index, content, "user");
|
|
quill.value.setSelection(index + content.length, 0, "user");
|
|
// Sync back to form model as HTML
|
|
form.html_template = quill.value.root.innerHTML;
|
|
} else {
|
|
// Fallback: append to the end of the model
|
|
form.html_template = (form.html_template || "") + content;
|
|
}
|
|
} else if (activeField.value === "text" && textRef.value) {
|
|
insertAtCursor(
|
|
textRef.value,
|
|
content,
|
|
() => form.text_template,
|
|
(v) => (form.text_template = v)
|
|
);
|
|
}
|
|
}
|
|
|
|
// Quill toolbar & image upload handler
|
|
const quillModules = computed(() => ({
|
|
toolbar: {
|
|
container: [
|
|
["bold", "italic", "underline", "strike"],
|
|
[{ header: [1, 2, 3, false] }],
|
|
[{ list: "ordered" }, { list: "bullet" }],
|
|
["link", "image"],
|
|
[{ align: [] }],
|
|
["clean"],
|
|
],
|
|
handlers: {
|
|
image: () => onQuillImageUpload(),
|
|
},
|
|
},
|
|
}));
|
|
|
|
function onQuillImageUpload() {
|
|
const input = document.createElement("input");
|
|
input.type = "file";
|
|
input.accept = "image/*";
|
|
input.onchange = async () => {
|
|
const file = input.files?.[0];
|
|
if (!file) return;
|
|
const data = new FormData();
|
|
data.append("file", file);
|
|
try {
|
|
const { data: res } = await window.axios.post(
|
|
route("admin.email-templates.upload-image"),
|
|
data,
|
|
{ headers: { "Content-Type": "multipart/form-data" } }
|
|
);
|
|
const url = res?.url;
|
|
if (url && quill.value) {
|
|
const range = quill.value.getSelection(true);
|
|
const index = range ? range.index : quill.value.getLength();
|
|
quill.value.insertEmbed(index, "image", url, "user");
|
|
quill.value.setSelection(index + 1, 0, "user");
|
|
form.html_template = quill.value.root.innerHTML;
|
|
}
|
|
} catch (e) {
|
|
// optional: show toast
|
|
console.error("Image upload failed", e);
|
|
}
|
|
};
|
|
input.click();
|
|
}
|
|
|
|
const placeholderGroups = computed(() => {
|
|
const groups = [];
|
|
const want = new Set(form.entity_types || []);
|
|
const add = (key, label, tokens) => groups.push({ key, label, tokens });
|
|
if (want.has("person")) {
|
|
add("person", "Person", [
|
|
"person.first_name",
|
|
"person.last_name",
|
|
"person.full_name",
|
|
"person.email",
|
|
"person.phone",
|
|
]);
|
|
}
|
|
if (want.has("client")) {
|
|
add("client", "Client", ["client.id", "client.uuid"]);
|
|
}
|
|
if (want.has("client_case")) {
|
|
add("case", "Case", ["case.id", "case.uuid", "case.reference"]);
|
|
}
|
|
if (want.has("contract")) {
|
|
add("contract", "Contract", [
|
|
"contract.id",
|
|
"contract.uuid",
|
|
"contract.reference",
|
|
"contract.amount",
|
|
"contract.meta.some_key",
|
|
]);
|
|
}
|
|
// Extra is always useful for ad-hoc data
|
|
add("extra", "Extra", ["extra.some_key"]);
|
|
return groups;
|
|
});
|
|
|
|
// Sample entity selection for preview
|
|
const sample = ref({ client_id: "", case_id: "", contract_id: "", extra: "" });
|
|
|
|
// Cascading select options
|
|
const clients = ref([]);
|
|
const cases = ref([]);
|
|
const contracts = ref([]);
|
|
|
|
async function loadClients() {
|
|
const { data } = await window.axios.get(route("admin.email-templates.data.clients"));
|
|
clients.value = data;
|
|
}
|
|
async function loadCases(clientId) {
|
|
if (!clientId) {
|
|
cases.value = [];
|
|
return;
|
|
}
|
|
const { data } = await window.axios.get(
|
|
route("admin.email-templates.data.cases", clientId)
|
|
);
|
|
cases.value = data;
|
|
}
|
|
async function loadContracts(caseId) {
|
|
if (!caseId) {
|
|
contracts.value = [];
|
|
return;
|
|
}
|
|
const { data } = await window.axios.get(
|
|
route("admin.email-templates.data.contracts", caseId)
|
|
);
|
|
contracts.value = data;
|
|
}
|
|
|
|
watch(
|
|
() => sample.value.client_id,
|
|
(v) => {
|
|
sample.value.case_id = "";
|
|
sample.value.contract_id = "";
|
|
loadCases(v);
|
|
doPreview();
|
|
}
|
|
);
|
|
watch(
|
|
() => sample.value.case_id,
|
|
(v) => {
|
|
sample.value.contract_id = "";
|
|
loadContracts(v);
|
|
doPreview();
|
|
}
|
|
);
|
|
watch(
|
|
() => sample.value.contract_id,
|
|
() => doPreview()
|
|
);
|
|
|
|
function applySample() {
|
|
fetchPreview();
|
|
loadClients();
|
|
}
|
|
|
|
function safeParseExtra(input) {
|
|
if (!input || typeof input !== "string") return undefined;
|
|
try {
|
|
const parsed = JSON.parse(input);
|
|
return typeof parsed === "object" && parsed !== null ? parsed : undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function sendTest() {
|
|
if (!props.template?.id) {
|
|
alert("Najprej shranite predlogo, nato pošljite test.");
|
|
return;
|
|
}
|
|
if (!sample.value.to) {
|
|
alert("Vnesite ciljni email naslov.");
|
|
return;
|
|
}
|
|
const payload = {
|
|
subject: form.subject_template,
|
|
html: form.html_template,
|
|
text: form.text_template,
|
|
client_id: sample.value.client_id || undefined,
|
|
person_id: sample.value.person_id || undefined,
|
|
case_id: sample.value.case_id || undefined,
|
|
contract_id: sample.value.contract_id || undefined,
|
|
extra: safeParseExtra(sample.value.extra),
|
|
to: sample.value.to,
|
|
embed: sendEmbedMode.value,
|
|
};
|
|
|
|
// Small helper for simple notifications without reloading
|
|
const notify = (message, _type = "info") => {
|
|
if (!message) {
|
|
return;
|
|
}
|
|
// Replace with your app's toast if available
|
|
alert(message);
|
|
};
|
|
|
|
router.post(route("admin.email-templates.send-test", props.template.id), payload, {
|
|
preserveScroll: true,
|
|
preserveState: true,
|
|
onSuccess: () => {
|
|
try {
|
|
const page = usePage();
|
|
const flash = page?.props?.flash ?? {};
|
|
if (flash.success) {
|
|
notify(flash.success, "success");
|
|
return;
|
|
}
|
|
if (flash.error) {
|
|
notify(flash.error, "error");
|
|
return;
|
|
}
|
|
} catch {}
|
|
notify("Testni e-poštni naslov je bil poslan.", "success");
|
|
},
|
|
onError: () => {
|
|
// Validation errors trigger onError; controller may also set flash('error') on redirect
|
|
try {
|
|
const page = usePage();
|
|
const flash = page?.props?.flash ?? {};
|
|
if (flash.error) {
|
|
notify(flash.error, "error");
|
|
return;
|
|
}
|
|
} catch {}
|
|
notify(
|
|
"Pri pošiljanju testnega e-poštnega sporočila je prišlo do napake.",
|
|
"error"
|
|
);
|
|
},
|
|
});
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<AdminLayout :title="props.template ? 'Uredi predlogo' : 'Nova predloga'">
|
|
<Head :title="props.template ? 'Uredi predlogo' : 'Nova predloga'" />
|
|
|
|
<div class="mb-4 flex items-center justify-between">
|
|
<div class="flex items-center gap-3">
|
|
<Link
|
|
:href="route('admin.email-templates.index')"
|
|
class="text-sm text-gray-600 hover:text-gray-800 inline-flex items-center gap-2"
|
|
>
|
|
<FontAwesomeIcon :icon="faArrowLeft" class="w-4 h-4" /> Nazaj
|
|
</Link>
|
|
<h1 class="text-xl font-semibold text-gray-800">
|
|
{{ props.template ? "Uredi predlogo" : "Nova predloga" }}
|
|
</h1>
|
|
</div>
|
|
<button
|
|
@click="submit"
|
|
:disabled="form.processing"
|
|
class="px-4 py-2 text-sm rounded-md bg-indigo-600 text-white hover:bg-indigo-500 disabled:opacity-50"
|
|
>
|
|
Shrani
|
|
</button>
|
|
</div>
|
|
|
|
<div class="space-y-6">
|
|
<!-- Editor card -->
|
|
<div class="rounded-xl border bg-white/60 backdrop-blur-sm shadow-sm p-5 space-y-5">
|
|
<div class="flex items-center justify-between">
|
|
<div class="font-semibold text-gray-900">Urejevalnik</div>
|
|
</div>
|
|
<!-- Variable insertion toolbar -->
|
|
<div class="mb-2">
|
|
<div class="text-xs uppercase tracking-wider text-gray-500 font-semibold mb-2">
|
|
Vstavitev spremenljivk
|
|
</div>
|
|
<div class="flex flex-wrap gap-2">
|
|
<template v-for="grp in placeholderGroups" :key="grp.key">
|
|
<div class="flex items-center gap-2">
|
|
<div class="text-[11px] text-gray-500 font-semibold w-16">
|
|
{{ grp.label }}
|
|
</div>
|
|
<div class="flex flex-wrap gap-1">
|
|
<button
|
|
v-for="t in grp.tokens"
|
|
:key="t"
|
|
type="button"
|
|
class="text-xs px-2 py-1 rounded border bg-gray-50 hover:bg-gray-100 text-gray-700 transition-colors"
|
|
@click="insertPlaceholder(t)"
|
|
>
|
|
{{ t }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</div>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label class="label">Ime</label>
|
|
<input v-model="form.name" type="text" class="input" />
|
|
</div>
|
|
<div>
|
|
<label class="label">Ključ</label>
|
|
<input
|
|
v-model="form.key"
|
|
type="text"
|
|
class="input"
|
|
placeholder="e.g. welcome_email"
|
|
/>
|
|
</div>
|
|
<div class="md:col-span-2">
|
|
<label class="label">Subject</label>
|
|
<input
|
|
v-model="form.subject_template"
|
|
type="text"
|
|
class="input"
|
|
placeholder="Welcome, {{ person.full_name }}"
|
|
ref="subjectRef"
|
|
@focus="setActive('subject')"
|
|
/>
|
|
</div>
|
|
<div class="md:col-span-2">
|
|
<div class="flex items-center justify-between">
|
|
<label class="label">HTML vsebina</label>
|
|
<div class="flex items-center gap-4 text-xs text-gray-600">
|
|
<label class="inline-flex items-center gap-2">
|
|
<input type="checkbox" v-model="advancedMode" /> Napredni (poln
|
|
dokument)
|
|
</label>
|
|
<label class="inline-flex items-center gap-2">
|
|
<input type="checkbox" v-model="sourceMode" :disabled="advancedMode" />
|
|
Source HTML
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<!-- Quill body editor (hidden when advanced editor is active) -->
|
|
<div v-show="!sourceMode && !advancedMode">
|
|
<div
|
|
ref="quillContainer"
|
|
class="bg-white border rounded-lg min-h-[260px] p-2 focus-within:ring-2 ring-indigo-500/40"
|
|
@focusin="setActive('html')"
|
|
></div>
|
|
</div>
|
|
<!-- Source HTML textarea -->
|
|
<div v-show="sourceMode && !advancedMode">
|
|
<textarea
|
|
v-model="form.html_template"
|
|
rows="12"
|
|
class="input font-mono"
|
|
ref="htmlSourceRef"
|
|
@focus="setActive('html')"
|
|
></textarea>
|
|
</div>
|
|
<!-- Advanced full-document editor (iframe) -->
|
|
<div v-show="advancedMode" class="space-y-2">
|
|
<div class="flex flex-wrap gap-2">
|
|
<button
|
|
type="button"
|
|
class="text-xs px-2 py-1 rounded border bg-gray-50 hover:bg-gray-100"
|
|
@click="() => iframeExec('bold')"
|
|
>
|
|
B
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="text-xs px-2 py-1 rounded border bg-gray-50 hover:bg-gray-100"
|
|
@click="() => iframeExec('italic')"
|
|
>
|
|
<i>I</i>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="text-xs px-2 py-1 rounded border bg-gray-50 hover:bg-gray-100"
|
|
@click="() => iframeExec('underline')"
|
|
>
|
|
<u>U</u>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="text-xs px-2 py-1 rounded border bg-gray-50 hover:bg-gray-100"
|
|
@click="() => iframeExec('insertUnorderedList')"
|
|
>
|
|
• List
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="text-xs px-2 py-1 rounded border bg-gray-50 hover:bg-gray-100"
|
|
@click="() => iframeExec('insertOrderedList')"
|
|
>
|
|
1. List
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="text-xs px-2 py-1 rounded border bg-gray-50 hover:bg-gray-100"
|
|
@click="iframeInsertImage"
|
|
>
|
|
Slika
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="text-xs px-2 py-1 rounded border bg-gray-50 hover:bg-gray-100 disabled:opacity-50"
|
|
:disabled="!selectedImageSrc"
|
|
@click="iframeReplaceSelectedImage"
|
|
>
|
|
Zamenjaj izbrano sliko
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="text-xs px-2 py-1 rounded border bg-gray-50 hover:bg-gray-100"
|
|
@click="() => iframeExec('justifyLeft')"
|
|
>
|
|
Levo
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="text-xs px-2 py-1 rounded border bg-gray-50 hover:bg-gray-100"
|
|
@click="() => iframeExec('justifyCenter')"
|
|
>
|
|
Center
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="text-xs px-2 py-1 rounded border bg-gray-50 hover:bg-gray-100"
|
|
@click="() => iframeExec('justifyRight')"
|
|
>
|
|
Desno
|
|
</button>
|
|
</div>
|
|
<iframe ref="iframeRef" class="w-full h-[360px] border rounded"></iframe>
|
|
<div class="text-[11px] text-gray-500">
|
|
Opomba: Napredni urejevalnik prikazuje celoten dokument v iframe, zato
|
|
boste videli učinek stilov iz <head>. Pri pošiljanju se CSS še vedno
|
|
pretvori v inline stile za email.
|
|
</div>
|
|
<div class="text-[11px] text-gray-500">
|
|
Namig: če oznaka <code><img></code> nima atributa <code>src</code>,
|
|
bo ob predogledu in pošiljanju sistem poskušal samodejno nastaviti sliko
|
|
na podlagi pripetih slik te predloge. Najprej se išče ujemanje po
|
|
<code>alt</code> (npr. <code>alt="Logo"</code> → dokument z imenom
|
|
»logo«); če je pripeta le ena slika, bo uporabljena ta.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="md:col-span-2">
|
|
<label class="label">Text vsebina</label>
|
|
<textarea
|
|
v-model="form.text_template"
|
|
rows="6"
|
|
class="input font-mono"
|
|
ref="textRef"
|
|
@focus="setActive('text')"
|
|
></textarea>
|
|
</div>
|
|
<div>
|
|
<label class="label">Entity types</label>
|
|
<div class="flex flex-wrap gap-3 text-sm">
|
|
<label class="inline-flex items-center gap-2"
|
|
><input type="checkbox" value="client" v-model="form.entity_types" />
|
|
client</label
|
|
>
|
|
<label class="inline-flex items-center gap-2"
|
|
><input type="checkbox" value="client_case" v-model="form.entity_types" />
|
|
client_case</label
|
|
>
|
|
<label class="inline-flex items-center gap-2"
|
|
><input type="checkbox" value="contract" v-model="form.entity_types" />
|
|
contract</label
|
|
>
|
|
<label class="inline-flex items-center gap-2"
|
|
><input type="checkbox" value="person" v-model="form.entity_types" />
|
|
person</label
|
|
>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<input id="active" type="checkbox" v-model="form.active" />
|
|
<label for="active" class="text-sm text-gray-700">Aktivno</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Preview card -->
|
|
<div class="rounded-xl border bg-white/60 backdrop-blur-sm shadow-sm p-5 space-y-5">
|
|
<div class="flex items-center justify-between">
|
|
<div class="font-semibold text-gray-900">Predogled</div>
|
|
<button
|
|
@click="doPreview"
|
|
class="text-xs inline-flex items-center gap-2 px-2.5 py-1.5 rounded-md border text-gray-700 bg-gray-50 hover:bg-gray-100 transition-colors"
|
|
>
|
|
<FontAwesomeIcon :icon="faEye" class="w-3.5 h-3.5" /> Osveži
|
|
</button>
|
|
</div>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<div class="label">Sample entities</div>
|
|
<div class="grid grid-cols-2 gap-2">
|
|
<label class="text-xs text-gray-600">
|
|
Client
|
|
<select v-model="sample.client_id" class="input h-9">
|
|
<option value="">—</option>
|
|
<option v-for="c in clients" :key="c.id" :value="c.id">
|
|
{{ c.label }}
|
|
</option>
|
|
</select>
|
|
</label>
|
|
<label class="text-xs text-gray-600">
|
|
Case
|
|
<select
|
|
v-model="sample.case_id"
|
|
class="input h-9"
|
|
:disabled="!sample.client_id"
|
|
>
|
|
<option value="">—</option>
|
|
<option v-for="cs in cases" :key="cs.id" :value="cs.id">
|
|
{{ cs.label }}
|
|
</option>
|
|
</select>
|
|
</label>
|
|
<label class="text-xs text-gray-600">
|
|
Contract
|
|
<select
|
|
v-model="sample.contract_id"
|
|
class="input h-9"
|
|
:disabled="!sample.case_id"
|
|
>
|
|
<option value="">—</option>
|
|
<option v-for="ct in contracts" :key="ct.id" :value="ct.id">
|
|
{{ ct.label }}
|
|
</option>
|
|
</select>
|
|
</label>
|
|
<div class="col-span-2">
|
|
<label class="text-xs text-gray-600"
|
|
>Extra (JSON)
|
|
<textarea
|
|
rows="3"
|
|
v-model="sample.extra"
|
|
class="input font-mono"
|
|
placeholder='{"note":"hello"}'
|
|
></textarea>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div class="mt-2">
|
|
<button
|
|
@click="applySample"
|
|
type="button"
|
|
class="text-xs px-2 py-1 rounded border bg-gray-50 hover:bg-gray-100"
|
|
>
|
|
Uporabi sample
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div class="label">Subject</div>
|
|
<div class="p-2 rounded bg-gray-50 text-sm">{{ preview.subject }}</div>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div class="label">HTML</div>
|
|
<div
|
|
class="p-3 rounded-lg bg-gray-50 border ql-editor max-h-[480px] overflow-auto"
|
|
v-html="previewHtml"
|
|
></div>
|
|
</div>
|
|
<div>
|
|
<div class="label">Text</div>
|
|
<pre class="p-3 rounded-lg bg-gray-50 border text-xs whitespace-pre-wrap">{{
|
|
preview.text
|
|
}}</pre>
|
|
</div>
|
|
<div class="text-xs text-gray-500" v-pre>
|
|
Available placeholders example: {{ person.full_name }}, {{ client.uuid }},
|
|
{{ case.reference }}, {{ contract.reference }}, {{ contract.meta.some_key }}
|
|
</div>
|
|
<div class="mt-4 flex flex-col sm:flex-row sm:items-end gap-2">
|
|
<div class="w-full sm:w-auto">
|
|
<label class="label">Pošlji test na</label>
|
|
<input
|
|
type="email"
|
|
v-model="sample.to"
|
|
class="input"
|
|
placeholder="name@example.com"
|
|
/>
|
|
</div>
|
|
<div class="w-full sm:w-auto">
|
|
<label class="label">Način slik</label>
|
|
<select v-model="sendEmbedMode" class="input">
|
|
<option value="base64">Vdelano (base64)</option>
|
|
<option value="hosted">Gostovano (absolutni URL)</option>
|
|
</select>
|
|
<div
|
|
v-if="sendEmbedMode === 'hosted' && isLocalHost"
|
|
class="text-[11px] text-amber-600 mt-1"
|
|
>
|
|
Pozor: Uporabljate "localhost". Gmail/Outlook ne moreta pridobiti slik z
|
|
lokalnega strežnika. Nastavite APP_URL/ASSET_URL na javni HTTPS domeni ali
|
|
uporabite tunel (npr. ngrok) za test.
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
class="h-9 inline-flex items-center px-3 rounded-md border border-indigo-600 bg-indigo-600 text-white text-xs hover:bg-indigo-500 transition-colors"
|
|
@click="sendTest"
|
|
>
|
|
Pošlji test
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="h-9 inline-flex items-center px-3 rounded-md border text-xs bg-gray-50 hover:bg-gray-100"
|
|
@click="openFinalHtmlDialog"
|
|
>
|
|
Pokaži končni HTML
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Manage images panel -->
|
|
<div class="rounded-xl border bg-white/60 backdrop-blur-sm shadow-sm p-5 space-y-3">
|
|
<div class="flex items-center justify-between">
|
|
<div class="font-semibold text-gray-800">Pripete slike</div>
|
|
<div class="text-xs text-gray-500">
|
|
Slike se shranijo ob shranjevanju predloge ali pošiljanju testa.
|
|
</div>
|
|
</div>
|
|
<div v-if="props.template && docs.length" class="divide-y border rounded">
|
|
<div
|
|
v-for="d in docs"
|
|
:key="d.id"
|
|
class="flex items-center justify-between p-2 text-sm"
|
|
>
|
|
<div class="flex items-center gap-3 min-w-0">
|
|
<a
|
|
v-if="d.url"
|
|
:href="d.url"
|
|
target="_blank"
|
|
class="text-indigo-600 hover:underline truncate"
|
|
>
|
|
{{ d.name || d.file_name || d.path }}
|
|
</a>
|
|
<span v-else class="text-gray-700 truncate">{{
|
|
d.name || d.file_name || d.path
|
|
}}</span>
|
|
<span class="text-xs text-gray-500">{{ formatSize(d.size) }}</span>
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<a
|
|
v-if="d.url"
|
|
:href="d.url"
|
|
target="_blank"
|
|
class="text-xs px-2 py-1 rounded border bg-gray-50 hover:bg-gray-100"
|
|
>Odpri</a
|
|
>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-else class="text-sm text-gray-500">Ni pripetih slik.</div>
|
|
<div class="text-xs text-gray-500">
|
|
Če se slike v urejevalniku ne prikazujejo, preverite ali imate vzpostavljeno
|
|
povezavo do javnega diska (ukaz: storage:link).
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</AdminLayout>
|
|
|
|
<!-- Final HTML dialog -->
|
|
<div
|
|
v-if="showFinal"
|
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
|
|
@click.self="showFinal = false"
|
|
>
|
|
<div
|
|
class="bg-white rounded-lg shadow-xl max-w-5xl w-[92vw] max-h-[84vh] flex flex-col"
|
|
>
|
|
<div class="px-4 py-2 border-b flex items-center justify-between gap-2">
|
|
<div class="font-semibold text-gray-800 text-sm">
|
|
Končni HTML (točno to se pošlje)
|
|
</div>
|
|
<div class="flex items-center gap-2">
|
|
<div class="flex items-center gap-1 text-[11px] text-gray-700">
|
|
Način slik:
|
|
<select
|
|
v-model="finalEmbedMode"
|
|
@change="fetchFinalHtml"
|
|
class="border rounded px-2 py-1 text-[11px] bg-white hover:bg-gray-50"
|
|
>
|
|
<option value="base64">Vdelano (base64)</option>
|
|
<option value="hosted">Gostovano (absolutni URL)</option>
|
|
</select>
|
|
</div>
|
|
<div
|
|
v-if="finalEmbedMode === 'hosted' && isLocalHost"
|
|
class="text-[11px] text-amber-600"
|
|
>
|
|
Pozor: Uporabljate "localhost". Gmail/Outlook ne moreta pridobiti slik z
|
|
lokalnega strežnika.
|
|
</div>
|
|
<button
|
|
class="text-xs px-2 py-1 rounded border bg-gray-50 hover:bg-gray-100"
|
|
@click="copyFinalHtml"
|
|
>
|
|
Kopiraj
|
|
</button>
|
|
<button
|
|
class="text-xs px-2 py-1 rounded border bg-gray-50 hover:bg-gray-100"
|
|
@click="downloadFinalHtml"
|
|
>
|
|
Prenesi .html
|
|
</button>
|
|
<button
|
|
class="text-xs px-2 py-1 rounded border bg-gray-50 hover:bg-gray-100"
|
|
@click="showFinal = false"
|
|
>
|
|
Zapri
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="p-3 overflow-auto space-y-3">
|
|
<div v-if="finalAttachments.length" class="text-[11px] text-gray-600">
|
|
<div class="font-semibold mb-1">CID priponke:</div>
|
|
<ul class="list-disc pl-5">
|
|
<li v-for="a in finalAttachments" :key="a.cid">
|
|
<code>{{ a.src }}</code> → <code>cid:{{ a.cid }}</code>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
<pre class="text-xs whitespace-pre-wrap break-words">{{ finalHtml }}</pre>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.input {
|
|
width: 100%;
|
|
border-radius: 0.375rem;
|
|
border: 1px solid #d1d5db;
|
|
padding: 0.5rem 0.75rem;
|
|
font-size: 0.875rem;
|
|
line-height: 1.25rem;
|
|
}
|
|
.input:focus {
|
|
outline: 2px solid transparent;
|
|
outline-offset: 2px;
|
|
border-color: #6366f1;
|
|
box-shadow: 0 0 0 1px #6366f1;
|
|
}
|
|
.label {
|
|
display: block;
|
|
font-size: 0.65rem;
|
|
font-weight: 600;
|
|
letter-spacing: 0.05em;
|
|
text-transform: uppercase;
|
|
color: #6b7280;
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
</style>
|