Files
CRM_Montzar/apps/web/src/views/LeadFormPublicView.vue
T
s.esmaeili 1ae87c97b7 feat(ui): standardize design system, modals, sidebar, and form inputs
- Implement unified design system tokens, 10px radius, and IRANSans typography
- Modernize sidebar with Gemini-style collapsed expander, square 1:1 items, and elevated profile flyout
- Standardize all modal dialogs with 16px padding, fixed header/footer, 16px medium title, and sticky bottom actions
- Refactor DealFormView and CustomFieldsSection with standard UiField, UiInput, and UiSelect components
- Add draggable bottom-left QuickCreate floating action button
2026-09-12 01:33:07 +03:30

204 lines
7.4 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, onMounted, reactive, ref } from 'vue';
import { useRoute } from 'vue-router';
import axios from 'axios';
import type { PublicLeadFormDto, SubmitLeadFormResponse } from '@mento/shared';
const route = useRoute();
const loading = ref(true);
const saving = ref(false);
const error = ref('');
const done = ref(false);
const thankYou = ref('');
const form = ref<PublicLeadFormDto | null>(null);
const values = reactive<Record<string, string>>({});
const token = String(route.params.token || '');
const coverUrl = computed(() =>
form.value?.coverPath || (form.value?.hasCover ? `/api/public/forms/${token}/cover` : ''),
);
async function load() {
loading.value = true;
error.value = '';
try {
const { data } = await axios.get<PublicLeadFormDto>(`/api/public/forms/${token}`);
form.value = data;
for (const field of data.fields) {
if (field.fieldType === 'heading') continue;
values[field.key] = values[field.key] ?? '';
}
} 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);
}
async function submit() {
if (!form.value) return;
saving.value = true;
error.value = '';
try {
const { data } = await axios.post<SubmitLeadFormResponse>(
`/api/public/forms/${token}/submit`,
{ values: { ...values } },
);
thankYou.value = 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);
</script>
<template>
<main class="shell">
<div class="panel">
<div v-if="coverUrl" class="cover" :style="{ background: form?.submitColor || '#14b8a6' }">
<img :src="coverUrl" alt="" />
</div>
<div v-else class="cover" :style="{ background: form?.submitColor || '#14b8a6' }" />
<header>
<h1 v-if="form">{{ form.name }}</h1>
<h1 v-else>فرم</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">
<strong>ثبت شد</strong>
<p>{{ thankYou }}</p>
</div>
<form v-else-if="form" class="fields" @submit.prevent="submit">
<template v-for="field in form.fields" :key="field.key">
<p v-if="field.fieldType === 'heading'" class="heading">{{ field.label }}</p>
<label v-else>
{{ 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>
<input
v-else
v-model="values[field.key]"
:type="field.fieldType === 'email' ? 'email' : field.fieldType === 'phone' ? 'tel' : field.fieldType === 'date' ? 'date' : field.fieldType === 'number' ? 'number' : field.fieldType === 'file' ? 'text' : 'text'"
:placeholder="field.placeholder || (field.fieldType === 'file' ? 'نام فایل یا لینک' : '')"
:required="field.required"
/>
</label>
</template>
<p v-if="error" class="error">{{ error }}</p>
<button
class="btn"
type="submit"
:disabled="saving"
:style="{ background: form.submitColor || '#14b8a6' }"
>
{{ saving ? 'در حال ارسال…' : (form.submitLabel || 'ارسال درخواست') }}
</button>
<small>اطلاعات شما محرمانه می‌ماند.</small>
</form>
</div>
</main>
</template>
<style scoped>
.shell {
min-height: 100vh;
display: grid;
place-items: center;
padding: 1.5rem;
background: var(--bg);
}
.panel {
width: min(440px, 100%);
background: #fff;
border-radius: var(--radius);
overflow: hidden;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08);
}
.cover { height: 110px; }
.cover img { width: 100%; height: 110px; object-fit: cover; display: block; }
header, .fields, .success { padding: 1.1rem 1.2rem 1.25rem; display: grid; gap: 0.75rem; }
h1 { margin: 0; font-size: 1.35rem; }
header p, .muted, small { margin: 0; color: #64748b; }
label { display: grid; gap: 0.35rem; color: #475569; font-size: 0.92rem; }
.req { color: #b42318; }
input, textarea, select {
border: 1px solid #e2e8f0; border-radius: var(--radius); min-height: var(--control-h); padding: 0 0.9rem; font: inherit; width: 100%; background: #f8fafc; box-sizing: border-box;
}
.btn { border: 0; color: #fff; border-radius: var(--radius); min-height: var(--control-h); height: var(--control-h); padding: 0 1rem; font: inherit; font-weight: 700; cursor: pointer; box-sizing: border-box; }
.error { color: #b42318; margin: 0; }
.success { background: #e7f6ef; }
.opts { display: grid; gap: 0.35rem; }
.opts label { display: flex; align-items: center; gap: 0.4rem; }
.stars { display: flex; gap: 0.25rem; }
.stars button { border: 0; background: none; font-size: 1.3rem; color: #cbd5e1; cursor: pointer; }
.stars button.on { color: #eab308; }
.heading { margin: 0; font-weight: 700; color: #0f172a; }
</style>