Files
CRM_Montzar/apps/web/src/views/LeadFormPublicView.vue
T
Mahdi 80fb5bf1bb feat(lead-forms): real file upload under 200KB and Jalali date
Public form file fields compress images or reject oversized files; dates use the full Jalali picker.
2026-10-22 14:28:42 +03:30

455 lines
16 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
import { useRoute } from 'vue-router';
import axios from 'axios';
import type { PublicLeadFormDto, SubmitLeadFormResponse } from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import {
FormFileTooLargeError,
FORM_FILE_MAX_BYTES,
encodeFormFileValue,
formFileDisplayName,
prepareFormFieldFile,
} from '@/lib/form-file-compress';
const route = useRoute();
const loading = ref(true);
const saving = ref(false);
const error = ref('');
const fieldErrors = reactive<Record<string, string>>({});
const fileBusy = reactive<Record<string, boolean>>({});
const fileInputKey = reactive<Record<string, number>>({});
const done = ref(false);
const thankYou = ref('');
const started = ref(false);
const form = ref<PublicLeadFormDto | null>(null);
const values = reactive<Record<string, string>>({});
const honeypot = ref('');
const token = String(route.params.token || '');
/** حالت پیش‌نمایش سازنده: نسخهٔ ذخیره‌نشده از localStorage خوانده می‌شود و ارسال/آمار غیرفعال است */
const isPreview = route.query.preview === '1';
const PREVIEW_KEY = `lead-form-preview:${token}`;
function readPreviewDraft(): PublicLeadFormDto | null {
try {
const raw = localStorage.getItem(PREVIEW_KEY);
return raw ? (JSON.parse(raw) as PublicLeadFormDto) : null;
} catch {
return null;
}
}
function applyLoaded(data: PublicLeadFormDto) {
form.value = data;
for (const field of data.fields) {
if (field.fieldType === 'heading') continue;
values[field.key] = values[field.key] ?? '';
}
}
function onStorage(e: StorageEvent) {
if (e.key !== PREVIEW_KEY) return;
const draft = readPreviewDraft();
if (draft) applyLoaded(draft);
}
const coverUrl = computed(() =>
form.value?.coverPath || (form.value?.hasCover ? `/api/public/forms/${token}/cover` : ''),
);
function readUtm() {
const q = route.query;
const pick = (key: string) => {
const v = q[key];
return typeof v === 'string' ? v : Array.isArray(v) ? String(v[0] || '') : '';
};
return {
source: pick('utm_source'),
medium: pick('utm_medium'),
campaign: pick('utm_campaign'),
term: pick('utm_term'),
content: pick('utm_content'),
landingPage: typeof window !== 'undefined' ? window.location.href : '',
referrer: typeof document !== 'undefined' ? document.referrer : '',
};
}
async function postEvent(type: 'view' | 'start') {
try {
await axios.post(`/api/public/forms/${token}/event`, { type });
} catch {
/* ignore analytics errors */
}
}
function onFieldFocus() {
if (started.value || isPreview) return;
started.value = true;
void postEvent('start');
}
async function load() {
loading.value = true;
error.value = '';
if (isPreview) {
const draft = readPreviewDraft();
if (draft) {
applyLoaded(draft);
loading.value = false;
window.addEventListener('storage', onStorage);
return;
}
}
try {
const { data } = await axios.get<PublicLeadFormDto>(`/api/public/forms/${token}`);
applyLoaded(data);
if (!isPreview) void postEvent('view');
} catch {
error.value = 'این فرم پیدا نشد یا غیرفعال است.';
form.value = null;
} finally {
loading.value = false;
}
}
function setMulti(key: string, option: string, on: boolean) {
const cur = new Set((values[key] || '').split(',').map((s) => s.trim()).filter(Boolean));
if (on) cur.add(option);
else cur.delete(option);
values[key] = [...cur].join(', ');
}
function hasMulti(key: string, option: string) {
return (values[key] || '').split(',').map((s) => s.trim()).includes(option);
}
function clearFile(key: string) {
values[key] = '';
fieldErrors[key] = '';
fileInputKey[key] = (fileInputKey[key] ?? 0) + 1;
}
async function onFileChange(key: string, event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
fieldErrors[key] = '';
if (!file) {
values[key] = '';
return;
}
fileBusy[key] = true;
try {
const prepared = await prepareFormFieldFile(file);
values[key] = encodeFormFileValue(prepared);
} catch (e) {
values[key] = '';
input.value = '';
fieldErrors[key] =
e instanceof FormFileTooLargeError
? e.message
: e instanceof Error
? e.message
: 'آپلود فایل ناموفق بود.';
} finally {
fileBusy[key] = false;
}
}
function validateRequiredFiles(): string | null {
if (!form.value) return null;
for (const field of form.value.fields) {
if (field.fieldType !== 'file' || !field.required) continue;
if (!values[field.key]?.trim()) {
fieldErrors[field.key] = 'آپلود فایل الزامی است.';
return `فیلد «${field.label || 'آپلود فایل'}» را کامل کنید.`;
}
}
return null;
}
async function submit() {
if (!form.value) return;
if (isPreview) {
thankYou.value = form.value.thankYouMessage || 'ثبت شد. متشکریم.';
done.value = true;
return;
}
const fileErr = validateRequiredFiles();
if (fileErr) {
error.value = fileErr;
return;
}
if (Object.values(fieldErrors).some(Boolean)) {
error.value = 'لطفاً خطاهای فایل را برطرف کنید.';
return;
}
saving.value = true;
error.value = '';
try {
const utm = readUtm();
const { data } = await axios.post<SubmitLeadFormResponse>(
`/api/public/forms/${token}/submit`,
{
values: { ...values },
utm,
pageUrl: window.location.href,
referrer: document.referrer || undefined,
website: honeypot.value,
},
);
thankYou.value =
data.message ||
data.thankYouMessage ||
'ثبت شد. متشکریم.';
done.value = true;
if (data.redirectUrl) window.location.assign(data.redirectUrl);
} catch (e) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'ارسال ناموفق بود');
} else error.value = 'ارسال ناموفق بود';
} finally {
saving.value = false;
}
}
onMounted(load);
onBeforeUnmount(() => window.removeEventListener('storage', onStorage));
</script>
<template>
<main class="shell" dir="rtl">
<p v-if="isPreview" class="preview-banner">
حالت پیش‌نمایش — تغییرات ذخیره‌نشده نمایش داده می‌شود و ارسال واقعی انجام نمی‌شود.
</p>
<div class="panel">
<div class="cover" :style="{ background: form?.submitColor || '#14b8a6' }">
<img v-if="coverUrl" :src="coverUrl" alt="" />
<UiIcon v-else name="layers" :size="34" />
</div>
<div class="body">
<header>
<h1>{{ form?.name || 'فرم' }}</h1>
<p v-if="form?.description">{{ form.description }}</p>
</header>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
<p v-else-if="error && !form" class="error">{{ error }}</p>
<div v-else-if="done" class="success">
<UiIcon name="check" :size="22" />
<strong>ثبت شد</strong>
<p>{{ thankYou }}</p>
</div>
<form v-else-if="form" class="fields" @submit.prevent="submit">
<!-- honeypot -->
<label class="hp" aria-hidden="true">
وب‌سایت
<input v-model="honeypot" type="text" tabindex="-1" autocomplete="off" />
</label>
<template v-for="field in form.fields" :key="field.key">
<p v-if="field.fieldType === 'heading'" class="heading">{{ field.label }}</p>
<label v-else @focusin="onFieldFocus">
{{ field.fieldType === 'file' ? (field.label || 'آپلود فایل') : field.label }}
<span v-if="field.required" class="req">*</span>
<textarea
v-if="field.fieldType === 'textarea'"
v-model="values[field.key]"
rows="4"
:placeholder="field.placeholder || ''"
:required="field.required"
/>
<select
v-else-if="field.fieldType === 'select'"
v-model="values[field.key]"
:required="field.required"
>
<option value="">{{ field.placeholder || 'انتخاب کنید' }}</option>
<option v-for="o in field.options" :key="o" :value="o">{{ o }}</option>
</select>
<div v-else-if="field.fieldType === 'radio'" class="opts">
<label v-for="o in field.options" :key="o">
<input v-model="values[field.key]" type="radio" :value="o" :required="field.required" />
{{ o }}
</label>
</div>
<div v-else-if="field.fieldType === 'checkbox'" class="opts">
<label v-for="o in field.options" :key="o">
<input
type="checkbox"
:checked="hasMulti(field.key, o)"
@change="setMulti(field.key, o, ($event.target as HTMLInputElement).checked)"
/>
{{ o }}
</label>
</div>
<div v-else-if="field.fieldType === 'rating'" class="stars">
<button
v-for="n in 5"
:key="n"
type="button"
:class="{ on: Number(values[field.key]) >= n }"
@click="values[field.key] = String(n)"
>
★
</button>
</div>
<div v-else-if="field.fieldType === 'date'" class="date-field">
<JalaliDateInput
v-model="values[field.key]"
mode="date"
:required="field.required"
:placeholder="field.placeholder || 'انتخاب تاریخ شمسی'"
clearable
/>
</div>
<div v-else-if="field.fieldType === 'file'" class="file-field">
<input
:key="fileInputKey[field.key] ?? 0"
type="file"
:disabled="fileBusy[field.key]"
:aria-label="field.label || 'آپلود فایل'"
@change="onFileChange(field.key, $event)"
/>
<small class="file-hint">
حداکثر {{ Math.round(FORM_FILE_MAX_BYTES / 1024) }} کیلوبایت پس از فشرده‌سازی
</small>
<p v-if="fileBusy[field.key]" class="muted">در حال فشرده‌سازی…</p>
<p v-else-if="values[field.key]" class="file-ok">
{{ formFileDisplayName(values[field.key]) }}
<button type="button" class="file-clear" @click="clearFile(field.key)">
حذف
</button>
</p>
<p v-if="fieldErrors[field.key]" class="error">{{ fieldErrors[field.key] }}</p>
</div>
<input
v-else
v-model="values[field.key]"
:type="field.fieldType === 'email' ? 'email' : field.fieldType === 'phone' ? 'tel' : field.fieldType === 'number' ? 'number' : 'text'"
:placeholder="field.placeholder || ''"
:required="field.required"
/>
</label>
</template>
<p v-if="error" class="error">{{ error }}</p>
<button
class="btn"
type="submit"
:disabled="saving || Object.values(fileBusy).some(Boolean)"
:style="{ background: form.submitColor || '#14b8a6' }"
>
<UiIcon v-if="!saving" name="send" :size="16" />
{{ saving ? 'در حال ارسال…' : (form.submitLabel || 'ارسال درخواست') }}
</button>
<small class="note">اطلاعات شما محرمانه می‌ماند.</small>
</form>
</div>
</div>
</main>
</template>
<style scoped>
.shell {
min-height: 100vh;
display: grid;
place-items: center;
align-content: center;
gap: 0.75rem;
padding: 1.5rem 1rem;
background: var(--bg, #f1f5f9);
box-sizing: border-box;
text-align: right;
}
.preview-banner {
margin: 0;
width: min(420px, 100%);
padding: 0.55rem 0.8rem;
border-radius: var(--radius);
background: #fef3c7;
color: #92400e;
font-size: 0.82rem;
font-weight: 600;
box-sizing: border-box;
}
.panel {
width: min(420px, 100%);
background: #fff;
border-radius: calc(var(--radius) + 4px);
overflow: hidden;
box-shadow: 0 0 0 1px #e2e8f0, 0 18px 44px rgba(15, 23, 42, 0.1);
}
.cover { aspect-ratio: 3 / 1; display: grid; place-items: center; color: #fff; overflow: hidden; }
.cover img { width: 100%; height: 100%; object-fit: cover; display: block; }
.body { padding: 1.1rem 1.15rem 1.25rem; display: grid; gap: 0.75rem; }
header { display: grid; gap: 0.3rem; }
h1 { margin: 0; font-size: 1.25rem; font-weight: 800; color: #0f172a; }
header p, .muted { margin: 0; color: #64748b; font-size: 0.88rem; line-height: 1.7; }
.fields { display: grid; gap: 0.7rem; }
label { display: grid; gap: 0.3rem; color: #475569; font-size: 0.88rem; font-weight: 600; }
.req { color: #ef4444; }
input, textarea, select {
border: 1px solid #e2e8f0; border-radius: var(--radius); min-height: var(--control-h, 42px); padding: 0.5rem 0.75rem;
font: inherit; font-weight: 400; color: #0f172a; width: 100%; background: #f8fafc; box-sizing: border-box;
}
textarea { resize: vertical; }
input:focus, textarea:focus, select:focus { outline: none; border-color: #14b8a6; background: #fff; box-shadow: 0 0 0 3px rgba(20, 184, 166, 0.15); }
.btn {
border: 0; color: #fff; border-radius: var(--radius); min-height: var(--control-h, 42px); height: var(--control-h, 42px);
padding: 0 1rem; font: inherit; font-weight: 700; cursor: pointer; box-sizing: border-box;
display: inline-flex; align-items: center; justify-content: center; gap: 0.4rem; width: 100%; margin-top: 0.15rem;
}
.btn:disabled { opacity: 0.7; cursor: default; }
.note { margin: 0; color: #94a3b8; font-size: 0.78rem; text-align: center; }
.error { color: #b42318; margin: 0; font-size: 0.88rem; }
.success { display: grid; justify-items: center; gap: 0.35rem; text-align: center; padding: 1rem; border-radius: var(--radius); background: #ecfdf5; color: #047857; }
.success p { margin: 0; color: #065f46; }
.opts { display: grid; gap: 0.4rem; font-weight: 400; }
.opts label { display: flex; align-items: center; gap: 0.45rem; font-weight: 400; color: #334155; }
.opts input { width: auto; min-height: 0; }
.stars { display: flex; gap: 0.25rem; }
.stars button { border: 0; background: none; font-size: 1.5rem; color: #cbd5e1; cursor: pointer; padding: 0; }
.stars button.on { color: #eab308; }
.heading { margin: 0.2rem 0 0; font-weight: 700; color: #0f172a; }
.date-field { min-width: 0; }
.date-field :deep(.jdi) { width: 100%; }
.file-field { display: grid; gap: 0.35rem; }
.file-field input[type='file'] {
padding: 0.45rem 0.55rem;
background: #fff;
cursor: pointer;
}
.file-hint { color: #94a3b8; font-size: 0.75rem; font-weight: 400; }
.file-ok {
margin: 0;
font-size: 0.82rem;
font-weight: 500;
color: #0f766e;
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.file-clear {
border: 0;
background: #f1f5f9;
color: #475569;
border-radius: 6px;
padding: 0.15rem 0.45rem;
font: inherit;
font-size: 0.75rem;
cursor: pointer;
}
.hp {
position: absolute;
clip-path: inset(50%);
opacity: 0;
width: 1px;
height: 1px;
overflow: hidden;
}
</style>