Polish lead form builder header and settings layout.

Use shared UiPageHeader back control, align ready-field chips with the toolbox, and organize CRM settings into clearer mobile/desktop cards.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-28 14:29:57 +03:30
co-authored by Cursor
parent f20206b045
commit 319493171f
+346 -90
View File
@@ -1,9 +1,10 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useRoute } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import UiIcon from '@/components/UiIcon.vue';
import { UiButton, UiPageHeader } from '@/components/ui';
import type {
LeadFormDto,
LeadFormDuplicateMode,
@@ -24,7 +25,6 @@ type IconName =
| 'check' | 'chart' | 'document' | 'star' | 'megaphone' | 'building';
const route = useRoute();
const router = useRouter();
const id = computed(() => String(route.params.id || ''));
const loading = ref(true);
@@ -167,6 +167,10 @@ function defaultMap(type: LeadFormFieldType): LeadFormMapTo {
return 'custom';
}
function readyIcon(type: LeadFormFieldType | string): IconName {
return toolbox.find((t) => t.type === type)?.icon ?? 'layers';
}
function addField(type: LeadFormFieldType, preset?: Partial<LeadFormFieldInput>) {
const meta = toolbox.find((t) => t.type === type);
const label = preset?.label || meta?.label || 'فیلد جدید';
@@ -319,6 +323,11 @@ async function copyText(text: string) {
ok.value = 'کپی شد.';
}
function openPreview() {
if (!form.value?.publicPath) return;
window.open(form.value.publicPath, '_blank', 'noopener');
}
function toggleAssignee(uid: string) {
assigneeIds.value = assigneeIds.value.includes(uid)
? assigneeIds.value.filter((x) => x !== uid)
@@ -329,25 +338,40 @@ onMounted(load);
</script>
<template>
<section class="page">
<header class="top">
<div>
<button class="back" type="button" @click="router.push({ name: 'lead-forms' })">بازگشت</button>
<h1>فرم‌ساز <UiIcon name="layers" :size="20" /></h1>
<p>فرم را بچینید، پیش‌نمایش بگیرید و پاسخ‌ها را ببینید.</p>
</div>
<div class="top-actions">
<a class="btn ghost" :href="form?.publicPath" target="_blank" rel="noopener">
<UiIcon name="eye" :size="15" /> پیش‌نمایش
</a>
<button class="btn ghost" type="button" @click="publishOpen = true">
<UiIcon name="megaphone" :size="15" /> انتشار فرم
</button>
<button class="btn primary" type="button" :disabled="saving" @click="save">
<UiIcon name="check" :size="15" /> ذخیره تغییرات
</button>
</div>
</header>
<section class="crm-page page" dir="rtl">
<UiPageHeader
title="فرم‌ساز"
description="فرم را بچینید، پیش‌نمایش بگیرید و پاسخ‌ها را ببینید."
:back-to="{ name: 'lead-forms' }"
>
<template #actions>
<div class="top-ghosts">
<UiButton
variant="secondary"
type="button"
:disabled="!form?.publicPath"
@click="openPreview"
>
<template #icon>
<UiIcon name="eye" :size="15" />
</template>
پیش‌نمایش
</UiButton>
<UiButton variant="secondary" type="button" @click="publishOpen = true">
<template #icon>
<UiIcon name="megaphone" :size="15" />
</template>
انتشار فرم
</UiButton>
</div>
<UiButton variant="primary" type="button" :loading="saving" :disabled="saving" @click="save">
<template #icon>
<UiIcon name="check" :size="15" />
</template>
ذخیره تغییرات
</UiButton>
</template>
</UiPageHeader>
<p v-if="error" class="banner err">{{ error }}</p>
<p v-if="ok" class="banner ok">{{ ok }}</p>
@@ -461,14 +485,18 @@ onMounted(load);
</div>
<h3>فیلدهای آماده</h3>
<div v-for="g in readyGroups" :key="g.key" class="ready">
<button type="button" @click="readyOpen = readyOpen === g.key ? null : g.key">{{ g.title }}</button>
<div v-if="readyOpen === g.key">
<button type="button" class="ready-toggle" @click="readyOpen = readyOpen === g.key ? null : g.key">
<span>{{ g.title }}</span>
<span class="ready-caret">{{ readyOpen === g.key ? '▾' : '◂' }}</span>
</button>
<div v-if="readyOpen === g.key" class="tools ready-tools">
<button
v-for="item in g.items"
:key="item.label"
type="button"
@click="addField(item.fieldType, item)"
>
<UiIcon :name="readyIcon(item.fieldType)" :size="16" />
{{ item.label }}
</button>
</div>
@@ -476,50 +504,109 @@ onMounted(load);
</aside>
</div>
<section v-else-if="tab === 'settings'" class="panel">
<h3 class="panel-title">اتصال به CRM</h3>
<label class="chk"><input v-model="isActive" type="checkbox" /> فرم فعال باشد</label>
<label class="chk"><input v-model="createLead" type="checkbox" /> ثبت پاسخ به‌عنوان لید</label>
<label>مرحله اولیه لید
<select v-model="leadStageId">
<option value="">اولین مرحله فعال</option>
<option v-for="s in leadStages" :key="s.id" :value="s.id">{{ s.name }}</option>
</select>
</label>
<label>منبع بازاریابی <input v-model="source" /></label>
<label>سیاست شماره تکراری
<select v-model="duplicateMode">
<option value="update">به‌روزرسانی لید موجود</option>
<option value="skip">رد کردن (ثبت نشود)</option>
<option value="activity">فقط ثبت رویداد روی لید موجود</option>
</select>
</label>
<label>پیام تشکر <input v-model="thankYouMessage" /></label>
<label>آدرس بعد از ارسال <input v-model="redirectUrl" /></label>
<p>توزیع نوبتی مسئولان</p>
<div class="chips">
<button
v-for="u in users"
:key="u.id"
type="button"
class="chip"
:class="{ on: assigneeIds.includes(u.id) }"
@click="toggleAssignee(u.id)"
>
{{ u.fullName }}
<section v-else-if="tab === 'settings'" class="panel settings-panel">
<article class="settings-card">
<header class="settings-card-head">
<h3>وضعیت فرم</h3>
<p>فعال بودن فرم و ثبت مستقیم در لیدها</p>
</header>
<div class="toggle-list">
<label class="toggle-row">
<span>
<strong>فرم فعال باشد</strong>
<small>اگر خاموش باشد لینک عمومی کار نمی‌کند</small>
</span>
<input v-model="isActive" type="checkbox" />
</label>
<label class="toggle-row">
<span>
<strong>ثبت پاسخ به‌عنوان لید</strong>
<small>هر ارسال در لیست لیدهای CRM ذخیره می‌شود</small>
</span>
<input v-model="createLead" type="checkbox" />
</label>
</div>
</article>
<article class="settings-card">
<header class="settings-card-head">
<h3>اتصال به CRM</h3>
<p>مرحله، منبع، تکراری و پیام بعد از ارسال</p>
</header>
<div class="settings-grid">
<label class="field">مرحله اولیه لید
<select v-model="leadStageId">
<option value="">اولین مرحله فعال</option>
<option v-for="s in leadStages" :key="s.id" :value="s.id">{{ s.name }}</option>
</select>
</label>
<label class="field">منبع بازاریابی
<input v-model="source" placeholder="مثلاً وب‌سایت" />
</label>
<label class="field span-2">سیاست شماره تکراری
<select v-model="duplicateMode">
<option value="update">به‌روزرسانی لید موجود</option>
<option value="skip">رد کردن (ثبت نشود)</option>
<option value="activity">فقط ثبت رویداد روی لید موجود</option>
</select>
</label>
<label class="field span-2">پیام تشکر
<input v-model="thankYouMessage" placeholder="پیام بعد از ارسال موفق" />
</label>
<label class="field span-2">آدرس بعد از ارسال
<input v-model="redirectUrl" placeholder="https://..." dir="ltr" />
</label>
</div>
</article>
<article class="settings-card">
<header class="settings-card-head">
<h3>توزیع نوبتی مسئولان</h3>
<p>لیدهای جدید به‌نوبت به این افراد اختصاص داده می‌شوند</p>
</header>
<div class="chips">
<button
v-for="u in users"
:key="u.id"
type="button"
class="chip"
:class="{ on: assigneeIds.includes(u.id) }"
@click="toggleAssignee(u.id)"
>
{{ u.fullName }}
</button>
</div>
<p v-if="!assigneeIds.length" class="settings-empty">مسئولی انتخاب نشده؛ لید بدون مسئول ثبت می‌شود.</p>
</article>
<article class="settings-card optional">
<header class="settings-card-head">
<h3>مسیر اختیاری</h3>
<p>فقط در صورت نیاز؛ پیش‌فرض خاموش است</p>
</header>
<div class="toggle-list compact">
<label class="toggle-row">
<span><strong>ساخت شخص</strong></span>
<input v-model="createPerson" type="checkbox" />
</label>
<label class="toggle-row">
<span><strong>ساخت معامله</strong></span>
<input v-model="createDeal" type="checkbox" />
</label>
</div>
<label v-if="createDeal" class="field pipeline-field">کاریز مقصد
<select v-model="pipelineId">
<option value="">پیش‌فرض سازمان</option>
<option v-for="p in pipelines" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</label>
</article>
<div class="settings-actions">
<button class="btn primary" type="button" :disabled="saving" @click="save">
ذخیره تنظیمات
</button>
</div>
<h3 class="panel-title">مسیر اختیاری شخص / معامله</h3>
<label class="chk"><input v-model="createPerson" type="checkbox" /> ساخت شخص</label>
<label class="chk"><input v-model="createDeal" type="checkbox" /> ساخت معامله</label>
<label>کاریز مقصد (برای معامله)
<select v-model="pipelineId">
<option value="">پیش‌فرض سازمان</option>
<option v-for="p in pipelines" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</label>
<button class="btn primary" type="button" @click="save">ذخیره تنظیمات</button>
</section>
<section v-else-if="tab === 'share'" class="panel">
@@ -693,13 +780,8 @@ onMounted(load);
<style scoped>
.page { --teal: #14b8a6; display: grid; gap: 0.85rem; }
.top { display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start; }
h1 { margin: 0; font-size: 1.55rem; font-weight: 600; display: inline-flex; gap: 0.35rem; align-items: center; }
h1 :deep(.ico) { color: var(--teal); }
.top p, .back { color: #64748b; }
.back { background: none; border: 0; font: inherit; cursor: pointer; padding: 0; }
.top-actions { display: flex; gap: 0.45rem; flex-wrap: wrap; }
.btn { display: inline-flex; align-items: center; gap: 0.3rem; min-height: var(--control-h); height: var(--control-h); padding: 0 0.9rem; border-radius: var(--radius); font: inherit; font-weight: 700; border: 1px solid transparent; cursor: pointer; text-decoration: none; box-sizing: border-box; }
.top-ghosts { display: flex; gap: 0.45rem; flex-wrap: nowrap; }
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 0.3rem; min-height: var(--control-h); height: var(--control-h); padding: 0 0.9rem; border-radius: var(--radius); font: inherit; font-weight: 700; border: 1px solid transparent; cursor: pointer; text-decoration: none; box-sizing: border-box; white-space: nowrap; }
.btn.primary { background: var(--teal); color: #fff; }
.btn.ghost { background: #fff; color: #0f766e; border-color: #99f6e4; }
.banner { margin: 0; padding: 0.6rem 0.8rem; border-radius: var(--radius); }
@@ -759,43 +841,200 @@ h1 :deep(.ico) { color: var(--teal); }
.adv { background: none; border: 0; font: inherit; font-weight: 700; color: #0f766e; cursor: pointer; text-align: right; }
.toolbox h3 { margin: 0 0 0.5rem; font-size: 0.95rem; }
.tools { display: grid; grid-template-columns: 1fr 1fr; gap: 0.4rem; margin-bottom: 1rem; }
.tools button, .ready div button { border: 1px solid #eef2f7; background: #f8fafc; border-radius: var(--radius); min-height: 52px; font: inherit; font-size: 0.75rem; cursor: pointer; display: grid; place-items: center; gap: 0.15rem; color: #334155; }
.ready { border-bottom: 1px solid #f1f5f9; }
.ready > button { width: 100%; background: none; border: 0; text-align: right; font: inherit; font-weight: 700; padding: 0.55rem 0; cursor: pointer; }
.tools button {
border: 1px solid #eef2f7;
background: #f8fafc;
border-radius: var(--radius);
min-height: 52px;
padding: 0.35rem 0.4rem;
font: inherit;
font-size: 0.75rem;
cursor: pointer;
display: grid;
place-items: center;
gap: 0.15rem;
color: #334155;
text-align: center;
}
.ready { margin-bottom: 0.35rem; }
.ready-toggle {
width: 100%;
background: none;
border: 0;
text-align: right;
font: inherit;
font-weight: 700;
padding: 0.45rem 0;
cursor: pointer;
color: #0f172a;
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}
.ready-caret { color: #94a3b8; font-size: 0.85rem; }
.ready-tools { margin: 0 0 0.65rem; }
.panel { display: grid; gap: 0.7rem; }
.panel label, .sheet label { display: grid; gap: 0.3rem; font-size: 0.88rem; font-weight: 700; color: #334155; }
.chk { display: flex !important; align-items: center; gap: 0.4rem; }
.chk { display: flex !important; align-items: center; gap: 0.4rem; font-weight: 600 !important; }
.chips { display: flex; flex-wrap: wrap; gap: 0.35rem; }
.chip { border: 1px solid #e2e8f0; background: #fff; border-radius: 999px; padding: 0.3rem 0.7rem; font: inherit; cursor: pointer; }
.chip.on { background: #ccfbf1; border-color: #99f6e4; color: #0f766e; font-weight: 700; }
.row { display: flex; gap: 0.4rem; }
.row input { flex: 1; }
.row { display: flex; gap: 0.4rem; align-items: stretch; }
.row input { flex: 1; min-width: 0; }
.sub { border-bottom: 1px solid #f1f5f9; padding: 0.5rem 0; }
.overlay { position: fixed; inset: 0; background: rgba(15,23,42,.35); display: grid; place-items: center; z-index: 40; padding: 1rem; }
.sheet { width: min(420px,100%); background: #fff; border-radius: var(--radius); padding: 1rem; display: grid; gap: 0.65rem; }
.link { background: none; border: 0; color: #0f766e; font: inherit; font-weight: 700; cursor: pointer; }
.panel-title { margin: 0.4rem 0 0; font-size: 0.95rem; color: #0f766e; }
.link { background: none; border: 0; color: #0f766e; font: inherit; font-weight: 700; cursor: pointer; text-align: right; padding: 0; }
.panel-title {
margin: 0.2rem 0 0;
padding-top: 0.55rem;
border-top: 1px solid #f1f5f9;
font-size: 0.95rem;
color: #0f766e;
}
.panel > .panel-title:first-child {
border-top: 0;
padding-top: 0;
}
.settings-panel {
gap: 0.85rem;
background: transparent;
box-shadow: none;
padding: 0;
}
.settings-card {
background: #fff;
border: 1px solid #eef2f7;
border-radius: var(--radius);
padding: 1rem 1.05rem;
display: grid;
gap: 0.85rem;
box-shadow: 0 8px 22px rgba(15,23,42,.04);
}
.settings-card.optional {
background: #f8fafc;
border-style: dashed;
}
.settings-card-head h3 {
margin: 0;
font-size: 0.98rem;
font-weight: 700;
color: #0f172a;
}
.settings-card-head p {
margin: 0.2rem 0 0;
font-size: 0.8rem;
color: #64748b;
font-weight: 500;
}
.toggle-list { display: grid; gap: 0.45rem; }
.toggle-row {
display: flex !important;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.7rem 0.8rem;
border: 1px solid #eef2f7;
border-radius: var(--radius);
background: #fff;
font-weight: 600 !important;
}
.toggle-list.compact .toggle-row { padding: 0.55rem 0.7rem; }
.toggle-row span {
display: grid;
gap: 0.15rem;
min-width: 0;
}
.toggle-row strong {
font-size: 0.9rem;
color: #0f172a;
font-weight: 700;
}
.toggle-row small {
font-size: 0.75rem;
color: #64748b;
font-weight: 500;
line-height: 1.35;
}
.toggle-row input[type='checkbox'] {
width: 1.15rem;
height: 1.15rem;
flex: 0 0 auto;
accent-color: #14b8a6;
cursor: pointer;
}
.settings-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem 0.85rem;
}
.settings-grid .field,
.settings-card .field {
display: grid;
gap: 0.3rem;
font-size: 0.84rem;
font-weight: 700;
color: #334155;
}
.settings-grid .span-2 { grid-column: 1 / -1; }
.settings-card .field input,
.settings-card .field select,
.settings-grid .field input,
.settings-grid .field select {
border: 1px solid #e2e8f0;
border-radius: var(--radius);
padding: 0.55rem 0.7rem;
font: inherit;
width: 100%;
background: #fff;
font-weight: 500;
color: #0f172a;
}
.settings-empty {
margin: 0;
font-size: 0.8rem;
color: #94a3b8;
}
.pipeline-field { margin-top: 0.15rem; }
.settings-actions {
display: flex;
justify-content: flex-end;
position: sticky;
bottom: 0.5rem;
padding-top: 0.25rem;
}
.settings-actions .btn { min-width: 160px; }
.share-actions { display: flex; gap: 0.4rem; }
.iframe-code { flex: 1; border: 1px solid #e2e8f0; border-radius: var(--radius); padding: 0.55rem 0.7rem; font: inherit; width: 100%; resize: vertical; }
.iframe-code { flex: 1; border: 1px solid #e2e8f0; border-radius: var(--radius); padding: 0.55rem 0.7rem; font: inherit; width: 100%; resize: vertical; min-width: 0; }
.row.col { flex-direction: column; align-items: stretch; }
.crm-hint { margin: 0; color: #64748b; font-size: 0.85rem; }
.crm-hint {
margin: 0;
color: #64748b;
font-size: 0.85rem;
background: #f8fafc;
border: 1px solid #eef2f7;
border-radius: var(--radius);
padding: 0.65rem 0.75rem;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
gap: 0.55rem;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.5rem;
margin-bottom: 0.85rem;
}
.stat {
background: #f0fdfa;
border: 1px solid #ccfbf1;
border-radius: var(--radius);
padding: 0.65rem 0.7rem;
padding: 0.6rem 0.55rem;
display: grid;
gap: 0.15rem;
text-align: center;
}
.stat b { font-size: 1.15rem; color: #0f766e; }
.stat span { font-size: 0.75rem; color: #64748b; }
.publish-sheet { width: min(520px, 100%); gap: 0.75rem; }
.stat b { font-size: 1.05rem; color: #0f766e; }
.stat span { font-size: 0.72rem; color: #64748b; line-height: 1.3; }
.publish-sheet { width: min(520px, 100%); gap: 0.75rem; max-height: calc(100vh - 2rem); overflow: auto; }
.publish-head { display: flex; justify-content: space-between; align-items: center; }
.publish-preview {
display: grid;
@@ -804,10 +1043,12 @@ h1 :deep(.ico) { color: var(--teal); }
align-items: center;
padding: 0.65rem;
background: #f8fafc;
border: 1px solid #eef2f7;
border-radius: var(--radius);
}
.publish-preview h4 { margin: 0 0 0.25rem; }
.publish-preview p { margin: 0 0 0.35rem; color: #64748b; font-size: 0.82rem; }
.publish-preview a { color: #0f766e; font-weight: 700; font-size: 0.85rem; }
.mini-cover {
width: 72px;
height: 72px;
@@ -815,7 +1056,7 @@ h1 :deep(.ico) { color: var(--teal); }
overflow: hidden;
}
.mini-cover img { width: 100%; height: 100%; object-fit: cover; }
.publish-qr { justify-self: start; border-radius: 8px; }
.publish-qr { justify-self: start; border-radius: 8px; border: 1px solid #eef2f7; }
@media (max-width: 1100px) {
.workspace { grid-template-columns: 1fr; }
.preview-col { order: 3; }
@@ -824,8 +1065,23 @@ h1 :deep(.ico) { color: var(--teal); }
.tools { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 700px) {
.top, .top-actions { flex-direction: column; }
.top-ghosts {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.45rem;
width: 100%;
}
.top-ghosts :deep(.ui-btn) { width: 100%; }
.tabs { display: grid; grid-template-columns: 1fr 1fr; }
.stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.publish-preview { grid-template-columns: 56px 1fr; }
.mini-cover { width: 56px; height: 56px; }
.settings-grid { grid-template-columns: 1fr; }
.settings-grid .span-2 { grid-column: auto; }
.settings-card { padding: 0.85rem; }
.toggle-row { align-items: flex-start; }
.settings-actions { justify-content: stretch; }
.settings-actions .btn { width: 100%; min-width: 0; }
}
</style>