Files
CRM_Montzar/apps/web/src/views/LeadFormBuilderView.vue
T
MahdiandCursor d7b5085096 feat(leads): keep gender on leads and form submissions
A lead can carry the same honorific as a person, and a form that asks gender writes it onto the lead.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-31 12:49:42 +03:30

1424 lines
59 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.
This file contains Unicode characters that might be confused with other characters. 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, nextTick, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } 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,
LeadFormFieldDto,
LeadFormFieldInput,
LeadFormFieldType,
LeadFormMapTo,
LeadFormSubmissionDto,
PublicLeadFormDto,
LeadStageDto,
OrgUserDto,
PipelineDto,
} from '@mento/shared';
import { toFaDigits } from '@/lib/jalali';
import { prepareFormCover } from '@/lib/form-cover';
import { useEscClose } from '@/lib/modal-stack';
type Tab = 'edit' | 'settings' | 'share' | 'responses';
type IconName =
| 'user' | 'chat' | 'mail' | 'phone' | 'list' | 'layers' | 'calendar'
| 'check' | 'chart' | 'document' | 'star' | 'megaphone' | 'building';
const route = useRoute();
const router = useRouter();
const id = computed(() => String(route.params.id || ''));
const loading = ref(true);
const saving = ref(false);
const error = ref('');
const ok = ref('');
const tab = ref<Tab>('edit');
const desktopPreviewOpen = ref(false);
const publishOpen = ref(false);
const advancedOpen = ref(false);
const readyOpen = ref<string | null>('lead');
const editFieldKey = ref<string | null>(null);
const dragKey = ref<string | null>(null);
const form = ref<LeadFormDto | null>(null);
const name = ref('');
const description = ref('');
const fields = ref<LeadFormFieldDto[]>([]);
const submitLabel = ref('ارسال درخواست');
const submitColor = ref('#14b8a6');
const isActive = ref(true);
const createLead = ref(true);
const createPerson = ref(false);
const createDeal = ref(false);
const pipelineId = ref('');
const leadStageId = ref('');
const duplicateMode = ref<LeadFormDuplicateMode>('update');
const source = ref('وب‌سایت');
const thankYouMessage = ref('');
const redirectUrl = ref('');
const assigneeIds = ref<string[]>([]);
const users = ref<OrgUserDto[]>([]);
const pipelines = ref<PipelineDto[]>([]);
const leadStages = ref<LeadStageDto[]>([]);
const submissions = ref<LeadFormSubmissionDto[]>([]);
const coverInput = ref<HTMLInputElement | null>(null);
const toolbox: Array<{ type: LeadFormFieldType; label: string; icon: IconName }> = [
{ type: 'text', label: 'متن کوتاه', icon: 'user' },
{ type: 'textarea', label: 'متن بلند', icon: 'chat' },
{ type: 'email', label: 'ایمیل', icon: 'mail' },
{ type: 'phone', label: 'شماره تماس', icon: 'phone' },
{ type: 'checkbox', label: 'چندانتخابی', icon: 'list' },
{ type: 'select', label: 'لیست کشویی', icon: 'layers' },
{ type: 'date', label: 'تاریخ', icon: 'calendar' },
{ type: 'radio', label: 'دکمه رادیویی', icon: 'check' },
{ type: 'number', label: 'عددی', icon: 'chart' },
{ type: 'file', label: 'آپلود فایل', icon: 'document' },
{ type: 'rating', label: 'امتیازدهی', icon: 'star' },
{ type: 'heading', label: 'متن توضیحی', icon: 'megaphone' },
];
const typeLabel: Record<string, string> = Object.fromEntries(
toolbox.map((t) => [t.type, t.label]),
);
const readyGroups = [
{
key: 'lead', title: 'فیلدهای لید',
items: [
{ label: 'نام فروشگاه / عنوان', fieldType: 'text' as const, mapTo: 'lead.title' as const },
{ label: 'نام مخاطب', fieldType: 'text' as const, mapTo: 'lead.contactName' as const },
{ label: 'موبایل', fieldType: 'phone' as const, mapTo: 'lead.mobile' as const },
{ label: 'تلفن', fieldType: 'phone' as const, mapTo: 'lead.phone' as const },
{ label: 'شهر', fieldType: 'text' as const, mapTo: 'lead.city' as const },
{ label: 'منطقه', fieldType: 'text' as const, mapTo: 'lead.region' as const },
{ label: 'صنف', fieldType: 'text' as const, mapTo: 'lead.trade' as const },
{ label: 'توضیحات', fieldType: 'textarea' as const, mapTo: 'lead.notes' as const },
],
},
{
key: 'personal', title: 'اطلاعات فردی (شخص)',
items: [
{ label: 'نام و نام خانوادگی', fieldType: 'text' as const, mapTo: 'person.fullName' as const },
{ label: 'ایمیل', fieldType: 'email' as const, mapTo: 'person.email' as const },
{ label: 'موبایل', fieldType: 'phone' as const, mapTo: 'person.mobile' as const },
],
},
{
key: 'org', title: 'اطلاعات سازمانی',
items: [
{ label: 'نام شرکت', fieldType: 'text' as const, mapTo: 'lead.title' as const },
{ label: 'سمت', fieldType: 'text' as const, mapTo: 'person.position' as const },
],
},
{
key: 'market', title: 'سؤالات بازاریابی',
items: [{
label: 'از کجا آشنا شدید؟', fieldType: 'select' as const, mapTo: 'lead.source' as const,
options: ['سایت', 'اینستاگرام', 'معرفی'],
}],
},
];
const publicBase = computed(() => {
const env = String(import.meta.env.VITE_LEAD_FORM_PUBLIC_BASE || '').replace(/\/$/, '');
return env || window.location.origin;
});
const publicUrl = computed(() => (form.value ? `${publicBase.value}${form.value.publicPath}` : ''));
const iframeSnippet = computed(
() =>
`<iframe src="${publicUrl.value}" style="width:100%;min-height:600px;border:0" title="${name.value || 'فرم'}" loading="lazy"></iframe>`,
);
const qrUrl = computed(() =>
`https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=${encodeURIComponent(publicUrl.value)}`,
);
const coverUploading = ref(false);
const coverVersion = ref(0);
const coverSrc = computed(() => {
if (!form.value?.hasCover) return '';
const v = coverVersion.value || Date.parse(form.value.updatedAt) || 0;
return `/api/public/forms/${form.value.publicToken}/cover?v=${v}`;
});
const editingField = computed(() => fields.value.find((f) => f.key === editFieldKey.value) ?? null);
/** متن خام گزینه‌ها — جدا از options تا خط خالی هنگام تایپ حذف نشود */
const optionsDraft = ref('');
const sheetError = ref('');
function parseOptions(raw: string): string[] {
return [...new Set(raw.split('\n').map((s) => s.trim()).filter(Boolean))];
}
function syncOptions() {
if (editingField.value) editingField.value.options = parseOptions(optionsDraft.value);
}
const fieldSnapshot = ref<LeadFormFieldDto | null>(null);
const fieldIsNew = ref(false);
watch(editFieldKey, () => {
const f = editingField.value;
optionsDraft.value = f?.options.join('\n') ?? '';
fieldSnapshot.value = f ? { ...f, options: [...f.options] } : null;
sheetError.value = '';
});
function cancelFieldSheet() {
const snap = fieldSnapshot.value;
const key = editFieldKey.value;
if (key && fieldIsNew.value) {
fields.value = fields.value.filter((f) => f.key !== key);
} else if (key && snap) {
const i = fields.value.findIndex((f) => f.key === key);
if (i >= 0) fields.value[i] = { ...snap, options: [...snap.options] };
}
fieldIsNew.value = false;
editFieldKey.value = null;
}
useEscClose(() => editFieldKey.value, cancelFieldSheet);
useEscClose(() => desktopPreviewOpen.value, () => { desktopPreviewOpen.value = false; });
useEscClose(() => publishOpen.value, () => { publishOpen.value = false; });
function needsOptions(type: string) {
return type === 'select' || type === 'radio' || type === 'checkbox';
}
function closeFieldSheet() {
const f = editingField.value;
if (f) {
if (!f.label.trim()) {
sheetError.value = 'عنوان فیلد را بنویسید.';
return;
}
if (needsOptions(f.fieldType) && !f.options.length) {
sheetError.value = 'حداقل یک گزینه بنویسید.';
return;
}
f.label = f.label.trim();
optionsDraft.value = f.options.join('\n');
}
fieldIsNew.value = false;
editFieldKey.value = null;
}
/* ---------- رنگ دکمه ---------- */
const COLOR_PRESETS = [
'#14b8a6', '#0d9488', '#10b981', '#22c55e', '#3b82f6', '#2563eb',
'#6366f1', '#8b5cf6', '#a855f7', '#ec4899', '#f43f5e', '#ef4444',
'#f97316', '#f59e0b', '#eab308', '#64748b', '#334155', '#0f172a',
];
const colorOpen = ref(false);
const hexDraft = ref('');
useEscClose(() => colorOpen.value, () => { colorOpen.value = false; });
function toggleColor() {
colorOpen.value = !colorOpen.value;
hexDraft.value = submitColor.value;
}
function pickColor(c: string) {
submitColor.value = c;
hexDraft.value = c;
}
function applyHex() {
const v = hexDraft.value.trim();
const hex = v.startsWith('#') ? v : `#${v}`;
if (/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(hex)) submitColor.value = hex.toLowerCase();
else hexDraft.value = submitColor.value;
}
const titleInput = ref<HTMLInputElement | null>(null);
const conversionRate = computed(() => {
const submits = form.value?.submitCount ?? 0;
const leads = form.value?.leadCreateCount ?? 0;
if (!submits) return '۰٪';
return `${toFaDigits(Math.round((leads / submits) * 100))}٪`;
});
const crmSummary = computed(() => {
const parts: string[] = [];
if (createLead.value) parts.push('ثبت لید');
if (createPerson.value) parts.push('شخص');
if (createDeal.value) parts.push('معامله');
const stage = leadStages.value.find((s) => s.id === leadStageId.value);
if (stage) parts.push(`مرحله: ${stage.name}`);
if (source.value) parts.push(`منبع: ${source.value}`);
return parts.join(' · ') || 'اتصال CRM تنظیم نشده';
});
function newKey(label: string) {
const base = label.replace(/\s+/g, '_').slice(0, 24) || 'field';
let key = `${base}_${Math.random().toString(36).slice(2, 6)}`;
while (fields.value.some((f) => f.key === key)) key += 'x';
return key;
}
function defaultMap(type: LeadFormFieldType): LeadFormMapTo {
let target: LeadFormMapTo = 'custom';
if (type === 'phone') target = 'lead.mobile';
else if (type === 'text') target = 'lead.contactName';
else if (type === 'textarea') target = 'lead.notes';
return fields.value.some((f) => f.mapTo === target) ? 'custom' : target;
}
const mapConflict = computed(() => {
const f = editingField.value;
if (!f || f.mapTo === 'custom' || f.mapTo === 'notes') return null;
return fields.value.find((o) => o.key !== f.key && o.mapTo === f.mapTo) ?? null;
});
const fieldLabels = computed(() => Object.fromEntries(fields.value.map((f) => [f.key, f.label])));
function formatValue(val: unknown): string {
if (Array.isArray(val)) return val.join('، ');
if (val === true) return 'بله';
if (val === false) return 'خیر';
if (val == null) return '';
const s = String(val);
try {
const parsed = JSON.parse(s) as { name?: string; dataUrl?: string };
if (parsed?.name && parsed?.dataUrl) return `📎 ${parsed.name}`;
} catch {
/* not a file payload */
}
if (s.startsWith('data:')) return '📎 فایل پیوست';
return s;
}
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 || 'فیلد جدید';
const key = newKey(label);
fields.value.push({
id: key,
key,
label,
fieldType: type,
required: preset?.required ?? type !== 'heading',
mapTo: (preset?.mapTo as LeadFormMapTo) || defaultMap(type),
position: fields.value.length,
placeholder: preset?.placeholder ?? '',
options: preset?.options ?? (needsOptions(type) ? ['گزینه ۱', 'گزینه ۲'] : []),
});
return key;
}
function addAndEdit(type: LeadFormFieldType) {
const key = addField(type);
fieldIsNew.value = true;
editFieldKey.value = key;
}
function duplicateField(field: LeadFormFieldDto) {
addField(field.fieldType as LeadFormFieldType, {
label: `${field.label} (کپی)`,
mapTo: field.mapTo as LeadFormMapTo,
options: [...field.options],
placeholder: field.placeholder,
required: field.required,
});
}
function removeField(key: string) {
fields.value = fields.value.filter((f) => f.key !== key);
if (editFieldKey.value === key) editFieldKey.value = null;
}
function onDragStart(key: string) { dragKey.value = key; }
function onDrop(targetKey: string) {
if (!dragKey.value || dragKey.value === targetKey) return;
const list = [...fields.value];
const from = list.findIndex((f) => f.key === dragKey.value);
const to = list.findIndex((f) => f.key === targetKey);
if (from < 0 || to < 0) return;
const [item] = list.splice(from, 1);
list.splice(to, 0, item!);
fields.value = list.map((f, i) => ({ ...f, position: i }));
dragKey.value = null;
}
function applyForm(row: LeadFormDto) {
form.value = row;
name.value = row.name;
description.value = row.description ?? '';
fields.value = row.fields;
submitLabel.value = row.submitLabel;
submitColor.value = row.submitColor;
isActive.value = row.isActive;
createLead.value = row.createLead;
createPerson.value = row.createPerson;
createDeal.value = row.createDeal;
pipelineId.value = row.pipelineId ?? '';
leadStageId.value = row.leadStageId ?? '';
duplicateMode.value = (row.duplicateMode as LeadFormDuplicateMode) || 'update';
source.value = row.source ?? 'وب‌سایت';
thankYouMessage.value = row.thankYouMessage ?? '';
redirectUrl.value = row.redirectUrl ?? '';
assigneeIds.value = row.assigneeIds;
}
async function load() {
loading.value = true;
error.value = '';
try {
const [fRes, uRes, pRes, sRes] = await Promise.all([
api.get<LeadFormDto>(`/lead-forms/${id.value}`),
api.get<OrgUserDto[]>('/users'),
api.get<PipelineDto[]>('/pipelines'),
api.get<LeadStageDto[]>('/leads/stages'),
]);
applyForm(fRes.data);
users.value = uRes.data;
pipelines.value = pRes.data;
leadStages.value = sRes.data;
} catch {
error.value = 'بارگذاری فرم ناموفق بود.';
} finally {
loading.value = false;
}
if (route.query.new && !error.value) {
tab.value = 'edit';
await nextTick();
titleInput.value?.focus();
titleInput.value?.select();
void router.replace({ query: {} });
}
}
function validate(): string {
if (!name.value.trim()) return 'عنوان فرم را بنویسید.';
const noLabel = fields.value.find((f) => !f.label.trim());
if (noLabel) return 'یکی از فیلدها عنوان ندارد.';
const noOptions = fields.value.find((f) => needsOptions(f.fieldType) && !f.options.length);
if (noOptions) return `فیلد «${noOptions.label}» هیچ گزینه‌ای ندارد.`;
return '';
}
async function save() {
const invalid = validate();
if (invalid) {
error.value = invalid;
ok.value = '';
if (!name.value.trim()) {
tab.value = 'edit';
await nextTick();
titleInput.value?.focus();
}
return;
}
saving.value = true;
error.value = '';
ok.value = '';
try {
const { data } = await api.patch<LeadFormDto>(`/lead-forms/${id.value}`, {
name: name.value.trim(),
description: description.value.trim() || null,
isActive: isActive.value,
createLead: createLead.value,
createPerson: createPerson.value,
createDeal: createDeal.value,
pipelineId: pipelineId.value || null,
leadStageId: leadStageId.value || null,
duplicateMode: duplicateMode.value,
source: source.value.trim() || null,
thankYouMessage: thankYouMessage.value.trim() || null,
redirectUrl: redirectUrl.value.trim() || null,
assigneeIds: assigneeIds.value,
submitLabel: submitLabel.value.trim() || 'ارسال درخواست',
submitColor: submitColor.value,
fields: fields.value.map((f, i) => ({
key: f.key, label: f.label, fieldType: f.fieldType, required: f.required,
mapTo: f.mapTo, position: i, placeholder: f.placeholder, options: f.options,
})),
});
applyForm(data);
ok.value = 'تغییرات ذخیره شد.';
} 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;
}
}
async function uploadCover(ev: Event) {
const input = ev.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
error.value = '';
coverUploading.value = true;
try {
const prepared = await prepareFormCover(file);
const body = new FormData();
body.append('file', prepared);
const { data } = await api.post<LeadFormDto>(`/lead-forms/${id.value}/cover`, body);
form.value = data;
coverVersion.value = Date.now();
} catch (e) {
error.value = e instanceof Error && !axios.isAxiosError(e) ? e.message : 'آپلود کاور ناموفق بود.';
} finally {
coverUploading.value = false;
}
}
async function clearCover() {
try {
const { data } = await api.delete<LeadFormDto>(`/lead-forms/${id.value}/cover`);
form.value = data;
} catch {
error.value = 'حذف کاور ناموفق بود.';
}
}
async function loadResponses() {
tab.value = 'responses';
const { data } = await api.get<LeadFormSubmissionDto[]>(`/lead-forms/${id.value}/submissions`);
submissions.value = data;
}
async function copyText(text: string) {
await navigator.clipboard.writeText(text);
ok.value = 'کپی شد.';
}
const previewDraft = computed<PublicLeadFormDto | null>(() => {
if (!form.value) return null;
return {
name: name.value.trim() || 'عنوان فرم',
description: description.value.trim() || null,
thankYouMessage: thankYouMessage.value.trim() || null,
redirectUrl: null,
submitLabel: submitLabel.value.trim() || 'ارسال درخواست',
submitColor: submitColor.value,
hasCover: form.value.hasCover,
coverPath: coverSrc.value || null,
fields: fields.value.map((f) => ({
key: f.key,
label: f.label,
fieldType: f.fieldType,
required: f.required,
placeholder: f.placeholder || null,
options: f.options,
})),
};
});
function previewKey() {
return `lead-form-preview:${form.value?.publicToken ?? ''}`;
}
function writePreviewDraft() {
if (!previewDraft.value) return;
try {
localStorage.setItem(previewKey(), JSON.stringify(previewDraft.value));
} catch {
/* storage full / disabled — preview falls back to saved version */
}
}
watch(previewDraft, writePreviewDraft, { deep: true });
function openPreview() {
if (!form.value?.publicPath) return;
writePreviewDraft();
window.open(`${form.value.publicPath}?preview=1`, '_blank', 'noopener');
}
function toggleAssignee(uid: string) {
assigneeIds.value = assigneeIds.value.includes(uid)
? assigneeIds.value.filter((x) => x !== uid)
: [...assigneeIds.value, uid];
}
onMounted(load);
</script>
<template>
<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>
<nav class="tabs">
<button type="button" :class="{ on: tab === 'edit' }" @click="tab = 'edit'">
<UiIcon name="pencil" :size="14" /> ویرایش فرم
</button>
<button type="button" :class="{ on: tab === 'settings' }" @click="tab = 'settings'">تنظیمات</button>
<button type="button" :class="{ on: tab === 'share' }" @click="tab = 'share'">اشتراک‌گذاری</button>
<button type="button" :class="{ on: tab === 'responses' }" @click="loadResponses">پاسخ‌ها</button>
</nav>
<div v-if="tab === 'edit'" class="workspace">
<aside class="preview-col">
<div class="device-toggle">
<button type="button" @click="desktopPreviewOpen = true">دسکتاپ</button>
<button type="button" class="on">موبایل</button>
</div>
<div class="phone">
<div class="cover" :style="{ background: submitColor }">
<img v-if="coverSrc" :src="coverSrc" alt="" />
<UiIcon v-else name="layers" :size="28" />
</div>
<div class="preview-body">
<h3>{{ name || 'عنوان فرم' }}</h3>
<p>{{ description || 'توضیح کوتاه فرم اینجاست.' }}</p>
<template v-for="f in fields" :key="f.key">
<p v-if="f.fieldType === 'heading'" class="heading">{{ f.label }}</p>
<label v-else>
<span>{{ f.label }}<span v-if="f.required" class="req">*</span></span>
<textarea v-if="f.fieldType === 'textarea'" rows="3" :placeholder="f.placeholder || ''" disabled />
<select v-else-if="f.fieldType === 'select'" disabled>
<option>{{ f.placeholder || 'انتخاب کنید' }}</option>
<option v-for="o in f.options" :key="o">{{ o }}</option>
</select>
<div v-else-if="f.fieldType === 'radio' || f.fieldType === 'checkbox'" class="opts">
<span v-for="o in f.options" :key="o">{{ f.fieldType === 'checkbox' ? '☐' : '○' }} {{ o }}</span>
</div>
<div v-else-if="f.fieldType === 'rating'" class="stars">★★★★★</div>
<input v-else disabled :placeholder="f.placeholder || ''" />
</label>
</template>
<button type="button" class="send" :style="{ background: submitColor }">
<UiIcon name="send" :size="14" /> {{ submitLabel }}
</button>
<small>اطلاعات شما محرمانه می‌ماند.</small>
</div>
</div>
</aside>
<section class="canvas">
<button
class="cover-box"
:class="{ has: coverSrc }"
type="button"
:disabled="coverUploading"
:style="coverSrc ? { background: submitColor } : undefined"
@click="coverInput?.click()"
>
<img v-if="coverSrc" :src="coverSrc" alt="" />
<span v-else-if="coverUploading">در حال پردازش تصویر…</span>
<span v-else class="cover-empty">
+ افزودن لوگو یا تصویر کاور
<small>اندازهٔ استاندارد ۱۲۰۰×۴۰۰ پیکسل (۳:۱) — تصاویر دیگر خودکار تنظیم می‌شوند</small>
</span>
<span v-if="coverSrc" class="cover-change">{{ coverUploading ? 'در حال آپلود…' : 'تغییر تصویر' }}</span>
</button>
<input ref="coverInput" type="file" accept="image/png,image/jpeg,image/webp,image/svg+xml" hidden @change="uploadCover" />
<button v-if="coverSrc" class="link" type="button" @click="clearCover">حذف کاور</button>
<div class="title-card">
<label class="title-label" for="form-title">
عنوان فرم
<UiIcon name="pencil" :size="13" />
</label>
<input
id="form-title"
ref="titleInput"
v-model="name"
class="title"
maxlength="120"
placeholder="مثلاً: فرم ثبت‌نام وبینار"
/>
<label class="title-label" for="form-desc">توضیح فرم</label>
<textarea id="form-desc" v-model="description" rows="2" placeholder="توضیح کوتاه برای بازدیدکننده (اختیاری)" />
</div>
<article
v-for="f in fields"
:key="f.key"
class="field-card"
draggable="true"
@dragstart="onDragStart(f.key)"
@dragover.prevent
@drop="onDrop(f.key)"
>
<span class="grip"><UiIcon name="grip" :size="16" /></span>
<div class="fbody" @click="editFieldKey = f.key">
<strong>{{ f.label }}<span v-if="f.required" class="req">*</span></strong>
<small>
{{ typeLabel[f.fieldType] || f.fieldType }}
<template v-if="needsOptions(f.fieldType)"> · {{ toFaDigits(f.options.length) }} گزینه</template>
</small>
</div>
<div class="fops">
<button type="button" title="کپی" @click="duplicateField(f)"><UiIcon name="layers" :size="14" /></button>
<button type="button" class="danger" title="حذف" @click="removeField(f.key)"><UiIcon name="trash" :size="14" /></button>
<button type="button" title="ویرایش" @click="editFieldKey = f.key"><UiIcon name="pencil" :size="14" /></button>
</div>
</article>
<button class="add-field" type="button" @click="addAndEdit('text')">+ افزودن فیلد جدید</button>
<div class="submit-card">
<label>متن دکمه ارسال
<input v-model="submitLabel" />
</label>
<div class="color-row">
<span class="color-label">رنگ دکمه</span>
<div class="color-pick">
<button
type="button"
class="swatch-btn"
:aria-expanded="colorOpen"
aria-label="انتخاب رنگ دکمه"
@click="toggleColor"
>
<span class="swatch" :style="{ background: submitColor }" />
<span class="hex" dir="ltr">{{ submitColor }}</span>
<UiIcon name="chevron" :size="14" />
</button>
<div v-if="colorOpen" class="palette-backdrop" @click="colorOpen = false" />
<div v-if="colorOpen" class="palette" @click.stop>
<div class="presets">
<button
v-for="c in COLOR_PRESETS"
:key="c"
type="button"
class="preset"
:class="{ on: submitColor.toLowerCase() === c }"
:style="{ background: c }"
:aria-label="c"
@click="pickColor(c)"
>
<UiIcon v-if="submitColor.toLowerCase() === c" name="check-plain" :size="12" />
</button>
</div>
<div class="custom">
<label class="native">
<input v-model="submitColor" type="color" @input="hexDraft = submitColor" />
رنگ دلخواه
</label>
<input
v-model="hexDraft"
class="hex-input"
dir="ltr"
maxlength="7"
placeholder="#14b8a6"
@keydown.enter.prevent="applyHex"
@blur="applyHex"
/>
</div>
<button type="button" class="palette-done" @click="colorOpen = false">تأیید</button>
</div>
</div>
</div>
</div>
<button class="adv" type="button" @click="advancedOpen = !advancedOpen">تنظیمات پیشرفته</button>
<div v-if="advancedOpen" class="adv-box">
<label class="chk"><input v-model="createLead" type="checkbox" /> ثبت به‌عنوان لید</label>
<label class="chk"><input v-model="createPerson" type="checkbox" /> ساخت شخص (اختیاری)</label>
<label class="chk"><input v-model="createDeal" type="checkbox" /> ساخت معامله (اختیاری)</label>
</div>
</section>
<aside class="toolbox">
<h3>افزودن فیلد</h3>
<div class="tools">
<button v-for="t in toolbox" :key="t.type" type="button" @click="addField(t.type)">
<UiIcon :name="t.icon" :size="16" />
{{ t.label }}
</button>
</div>
<h3>فیلدهای آماده</h3>
<div v-for="g in readyGroups" :key="g.key" class="ready">
<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>
</div>
</aside>
</div>
<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>
</section>
<section v-else-if="tab === 'share'" class="panel">
<div class="share-actions">
<button class="btn primary" type="button" @click="publishOpen = true">
<UiIcon name="megaphone" :size="15" /> باز کردن مودال انتشار
</button>
</div>
<label>لینک عمومی
<div class="row">
<input :value="publicUrl" readonly />
<button class="btn ghost" type="button" @click="copyText(publicUrl)">کپی</button>
</div>
</label>
<label>کد iframe
<div class="row">
<textarea class="iframe-code" :value="iframeSnippet" readonly rows="3" />
<button class="btn ghost" type="button" @click="copyText(iframeSnippet)">کپی</button>
</div>
</label>
<img v-if="publicUrl" :src="qrUrl" alt="QR" width="140" height="140" />
<p class="crm-hint">{{ crmSummary }}</p>
<button class="link" type="button" @click="tab = 'settings'">ویرایش اتصال CRM</button>
</section>
<section v-else class="panel">
<div v-if="form" class="stats-grid">
<div class="stat"><b>{{ toFaDigits(form.viewCount) }}</b><span>بازدید</span></div>
<div class="stat"><b>{{ toFaDigits(form.startCount) }}</b><span>شروع پر کردن</span></div>
<div class="stat"><b>{{ toFaDigits(form.submitCount) }}</b><span>ارسال</span></div>
<div class="stat"><b>{{ toFaDigits(form.leadCreateCount) }}</b><span>لید جدید</span></div>
<div class="stat"><b>{{ toFaDigits(form.duplicateCount) }}</b><span>تکراری</span></div>
<div class="stat"><b>{{ conversionRate }}</b><span>نرخ submit→lead</span></div>
</div>
<p v-if="!submissions.length">هنوز پاسخی ثبت نشده.</p>
<article v-for="s in submissions" :key="s.id" class="sub">
<small>
{{ new Date(s.createdAt).toLocaleString('fa-IR') }}
<span v-if="s.status"> · {{ s.status }}</span>
<span v-if="s.leadId"> · لید</span>
</small>
<p v-for="(val, key) in s.values" :key="key"><b>{{ fieldLabels[key] || key }}:</b> {{ formatValue(val) }}</p>
</article>
</section>
<div v-if="desktopPreviewOpen" class="overlay desk-overlay">
<div class="desk-sheet" role="dialog" aria-modal="true" aria-label="پیش‌نمایش دسکتاپ">
<header>
<strong>پیش‌نمایش دسکتاپ</strong>
<button class="x-btn" type="button" aria-label="بستن (Esc)" title="بستن (Esc)" @click="desktopPreviewOpen = false">
<UiIcon name="x" :size="18" />
</button>
</header>
<div class="desk-form">
<div class="cover" :style="{ background: submitColor }">
<img v-if="coverSrc" :src="coverSrc" alt="" />
<UiIcon v-else name="layers" :size="36" />
</div>
<div class="preview-body desk-body">
<h3>{{ name || 'عنوان فرم' }}</h3>
<p>{{ description || 'توضیح کوتاه فرم اینجاست.' }}</p>
<div class="desk-fields">
<template v-for="f in fields" :key="'d-' + f.key">
<p v-if="f.fieldType === 'heading'" class="heading wide">{{ f.label }}</p>
<label v-else :class="{ wide: f.fieldType === 'textarea' || f.fieldType === 'checkbox' }">
<span>{{ f.label }}<span v-if="f.required" class="req">*</span></span>
<textarea v-if="f.fieldType === 'textarea'" rows="3" :placeholder="f.placeholder || ''" disabled />
<select v-else-if="f.fieldType === 'select'" disabled>
<option>{{ f.placeholder || 'انتخاب کنید' }}</option>
<option v-for="o in f.options" :key="o">{{ o }}</option>
</select>
<div v-else-if="f.fieldType === 'radio' || f.fieldType === 'checkbox'" class="opts">
<span v-for="o in f.options" :key="o">{{ f.fieldType === 'checkbox' ? '☐' : '○' }} {{ o }}</span>
</div>
<div v-else-if="f.fieldType === 'rating'" class="stars">★★★★★</div>
<input v-else disabled :placeholder="f.placeholder || ''" />
</label>
</template>
</div>
<button type="button" class="send" :style="{ background: submitColor }">
<UiIcon name="send" :size="14" /> {{ submitLabel }}
</button>
<small>اطلاعات شما محرمانه می‌ماند.</small>
</div>
</div>
</div>
</div>
<div v-if="editingField" class="overlay">
<div class="sheet" role="dialog" aria-modal="true" aria-label="ویرایش فیلد">
<header class="sheet-head">
<h3>{{ fieldIsNew ? 'فیلد جدید' : 'ویرایش فیلد' }}</h3>
<button class="x-btn" type="button" aria-label="بستن (Esc)" title="بستن (Esc)" @click="cancelFieldSheet">
<UiIcon name="x" :size="18" />
</button>
</header>
<label>عنوان <input v-model="editingField.label" maxlength="120" /></label>
<label>متن راهنما <input v-model="editingField.placeholder" /></label>
<label class="chk"><input v-model="editingField.required" type="checkbox" /> الزامی</label>
<label>اتصال به
<select v-model="editingField.mapTo">
<optgroup label="لید">
<option value="lead.title">عنوان / نام فروشگاه</option>
<option value="lead.contactName">نام مخاطب</option>
<option value="lead.mobile">موبایل لید</option>
<option value="lead.phone">تلفن لید</option>
<option value="lead.city">شهر</option>
<option value="lead.region">منطقه</option>
<option value="lead.trade">صنف</option>
<option value="lead.notes">یادداشت لید</option>
<option value="lead.source">منبع لید</option>
<option value="lead.gender">جنسیت</option>
</optgroup>
<optgroup label="شخص">
<option value="person.fullName">نام کامل</option>
<option value="person.firstName">نام</option>
<option value="person.lastName">نام خانوادگی</option>
<option value="person.mobile">موبایل</option>
<option value="person.email">ایمیل</option>
<option value="person.phone">تلفن</option>
<option value="person.position">سمت</option>
<option value="person.gender">جنسیت شخص</option>
</optgroup>
<optgroup label="معامله">
<option value="deal.title">عنوان معامله</option>
<option value="deal.notes">یادداشت معامله</option>
</optgroup>
<option value="notes">یادداشت</option>
<option value="custom">فقط در پاسخ فرم</option>
</select>
<small v-if="mapConflict" class="sheet-warn">
فیلد «{{ mapConflict.label }}» هم به همین مقصد وصل است؛ مقدار یکی روی دیگری نوشته می‌شود.
</small>
</label>
<label v-if="['select','radio','checkbox'].includes(editingField.fieldType)">
گزینه‌ها (هر خط یکی)
<textarea
v-model="optionsDraft"
rows="5"
placeholder="گزینه ۱&#10;گزینه ۲"
@input="syncOptions"
/>
<small class="sheet-hint">
{{ toFaDigits(editingField.options.length) }} گزینه — با Enter به خط بعد بروید؛ خطوط خالی و تکراری نادیده گرفته می‌شوند.
</small>
</label>
<p v-if="sheetError" class="sheet-error">{{ sheetError }}</p>
<div class="sheet-actions">
<button class="btn primary" type="button" @click="closeFieldSheet">تأیید</button>
<button class="btn ghost" type="button" @click="cancelFieldSheet">انصراف</button>
</div>
</div>
</div>
<div v-if="publishOpen" class="overlay">
<div class="sheet publish-sheet" role="dialog" aria-modal="true" aria-label="انتشار فرم">
<header class="publish-head">
<strong>انتشار فرم</strong>
<button class="x-btn" type="button" aria-label="بستن (Esc)" title="بستن (Esc)" @click="publishOpen = false">
<UiIcon name="x" :size="18" />
</button>
</header>
<div class="publish-preview">
<div class="mini-cover" :style="{ background: submitColor }">
<img v-if="coverSrc" :src="coverSrc" alt="" />
</div>
<div>
<h4>{{ name || 'فرم' }}</h4>
<p>{{ crmSummary }}</p>
<a :href="form?.publicPath" target="_blank" rel="noopener">باز کردن پیش‌نمایش</a>
</div>
</div>
<label>لینک مستقیم
<div class="row">
<input :value="publicUrl" readonly />
<button class="btn ghost" type="button" @click="copyText(publicUrl)">کپی</button>
</div>
</label>
<label>کد iframe ریسپانسیو
<div class="row col">
<textarea class="iframe-code" :value="iframeSnippet" readonly rows="3" />
<button class="btn ghost" type="button" @click="copyText(iframeSnippet)">کپی iframe</button>
</div>
</label>
<img v-if="publicUrl" class="publish-qr" :src="qrUrl" alt="QR" width="120" height="120" />
<button class="link" type="button" @click="publishOpen = false; tab = 'settings'">
تنظیم اتصال CRM
</button>
</div>
</div>
</section>
</template>
<style scoped>
.page { --teal: #14b8a6; display: grid; gap: 0.85rem; }
.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); }
.banner.err { background: #fef2f2; color: #b91c1c; }
.banner.ok { background: #ecfdf5; color: #047857; }
.tabs { display: flex; gap: 0.35rem; flex-wrap: wrap; background: #fff; padding: 0.35rem; border-radius: var(--radius); }
.tabs button { border: 0; background: transparent; min-height: 38px; padding: 0 0.85rem; border-radius: var(--radius); font: inherit; font-weight: 700; color: #0f766e; cursor: pointer; display: inline-flex; align-items: center; gap: 0.3rem; }
.tabs button.on { background: var(--teal); color: #fff; }
.workspace { display: grid; grid-template-columns: 280px minmax(0,1fr) 240px; gap: 0.85rem; align-items: start; }
.preview-col, .canvas, .toolbox, .panel { background: #fff; border-radius: var(--radius); padding: 0.9rem; box-shadow: 0 8px 22px rgba(15,23,42,.04); }
.device-toggle { display: flex; gap: 0.3rem; margin-bottom: 0.7rem; }
.device-toggle button { flex: 1; border: 0; min-height: 34px; border-radius: var(--radius); font: inherit; font-weight: 700; background: #f1f5f9; cursor: pointer; }
.device-toggle button.on { background: var(--teal); color: #fff; }
.phone { border: 8px solid #0f172a; border-radius: var(--radius); overflow: hidden; background: #fff; max-width: 260px; margin: 0 auto; }
.desk-overlay { padding: 1.2rem; }
.desk-sheet {
width: min(920px, 100%);
max-height: calc(100vh - 2.4rem);
overflow: auto;
background: #eef2f6;
border-radius: var(--radius);
padding: 1rem 1.1rem 1.2rem;
display: grid;
gap: 0.75rem;
}
.desk-sheet header { display: flex; justify-content: space-between; align-items: center; }
.desk-form { background: #fff; border-radius: var(--radius); overflow: hidden; box-shadow: 0 16px 40px rgba(15,23,42,.08); }
.desk-form .cover { height: auto; aspect-ratio: 3 / 1; max-height: 260px; }
.desk-body { padding: 1.2rem 1.4rem 1.4rem; }
.desk-body h3 { font-size: 1.35rem; }
.desk-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem 1rem; }
.desk-fields .wide { grid-column: 1 / -1; }
.desk-form .send { justify-self: start; min-width: 200px; }
.cover { aspect-ratio: 3 / 1; display: grid; place-items: center; color: #fff; overflow: hidden; }
.cover img { width: 100%; height: 100%; object-fit: cover; }
.preview-body { padding: 0.8rem; display: grid; gap: 0.5rem; }
.preview-body h3 { margin: 0; font-size: 1rem; }
.preview-body p, .preview-body small { margin: 0; color: #64748b; font-size: 0.78rem; }
.preview-body label { display: grid; gap: 0.25rem; font-size: 0.78rem; color: #475569; }
.preview-body input, .preview-body textarea, .preview-body select { border: 1px solid #e2e8f0; border-radius: var(--radius); padding: 0.45rem 0.6rem; font: inherit; background: #f8fafc; }
.send { border: 0; color: #fff; border-radius: var(--radius); min-height: var(--control-h); height: var(--control-h); font: inherit; font-weight: 700; display: inline-flex; align-items: center; justify-content: center; gap: 0.3rem; box-sizing: border-box; }
.opts, .stars { color: #64748b; font-size: 0.75rem; display: grid; gap: 0.2rem; }
.cover-box { position: relative; width: 100%; aspect-ratio: 3 / 1; max-height: 200px; border: 1.5px dashed #99f6e4; border-radius: var(--radius); background: #f0fdfa; color: #0f766e; font: inherit; font-weight: 700; cursor: pointer; overflow: hidden; display: grid; place-items: center; padding: 0; }
.cover-box.has { border-style: solid; border-color: transparent; }
.cover-box:disabled { cursor: progress; opacity: 0.75; }
.cover-box img { width: 100%; height: 100%; object-fit: cover; display: block; }
.cover-empty { display: grid; gap: 0.25rem; justify-items: center; padding: 0.5rem; }
.cover-empty small { font-weight: 500; font-size: 0.74rem; color: #64748b; }
.cover-change { position: absolute; inset-inline-start: 0.5rem; bottom: 0.5rem; background: rgba(15,23,42,.65); color: #fff; font-size: 0.74rem; padding: 0.2rem 0.55rem; border-radius: 999px; opacity: 0; transition: opacity .15s; }
.cover-box:hover .cover-change, .cover-box:disabled .cover-change { opacity: 1; }
.title-card, .field-card, .submit-card, .adv-box { border: 1px solid #eef2f7; border-radius: var(--radius); padding: 0.8rem; display: grid; gap: 0.45rem; }
.title { font: inherit; font-size: 1.1rem; font-weight: 700; color: #0f172a; border: 1px solid #e2e8f0; border-radius: var(--radius); padding: 0.55rem 0.7rem; background: #fff; width: 100%; box-sizing: border-box; }
.title:hover { border-color: #99f6e4; }
.title:focus { outline: none; border-color: var(--teal); box-shadow: 0 0 0 3px rgba(20,184,166,.15); }
.title-label { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.78rem; font-weight: 700; color: #64748b; }
.color-row { display: flex; align-items: center; justify-content: space-between; gap: 0.6rem; font-size: 0.88rem; font-weight: 700; color: #334155; }
.color-pick { position: relative; }
.swatch-btn { display: inline-flex; align-items: center; gap: 0.45rem; border: 1px solid #e2e8f0; background: #fff; border-radius: var(--radius); padding: 0.3rem 0.5rem; font: inherit; cursor: pointer; }
.swatch-btn:hover { border-color: #99f6e4; }
.swatch { width: 26px; height: 26px; border-radius: 8px; box-shadow: inset 0 0 0 1px rgba(15,23,42,.12); }
.hex { font-size: 0.8rem; color: #475569; font-family: ui-monospace, monospace; }
.palette { position: absolute; inset-inline-end: 0; top: calc(100% + 6px); z-index: 30; width: 236px; background: #fff; border: 1px solid #e2e8f0; border-radius: var(--radius); box-shadow: 0 16px 40px rgba(15,23,42,.16); padding: 0.65rem; display: grid; gap: 0.6rem; }
.presets { display: grid; grid-template-columns: repeat(6, 1fr); gap: 0.35rem; }
.preset { width: 100%; aspect-ratio: 1; border: 0; border-radius: 8px; cursor: pointer; display: grid; place-items: center; color: #fff; box-shadow: inset 0 0 0 1px rgba(15,23,42,.1); }
.preset.on { outline: 2px solid #0f172a; outline-offset: 2px; }
.custom { display: flex; align-items: center; gap: 0.4rem; }
.native { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.78rem; font-weight: 600; color: #475569; cursor: pointer; white-space: nowrap; }
.palette-backdrop { position: fixed; inset: 0; z-index: 29; }
.sheet-warn { font-size: 0.76rem; font-weight: 600; color: #b45309; }
.fbody { cursor: pointer; min-width: 0; }
.req { color: #ef4444; margin-inline-start: 0.15rem; }
.submit-card .native input { width: 28px; height: 28px; padding: 0; border: 0; background: none; cursor: pointer; }
.submit-card .hex-input { flex: 1; min-width: 0; padding: 0.35rem 0.5rem; font-family: ui-monospace, monospace; font-size: 0.82rem; }
.palette-done { border: 0; background: var(--teal); color: #fff; border-radius: var(--radius); min-height: 32px; font: inherit; font-weight: 700; cursor: pointer; }
.sheet-hint { font-size: 0.74rem; font-weight: 500; color: #94a3b8; }
.sheet-error { margin: 0; color: #b91c1c; font-size: 0.84rem; }
.title-card textarea, .submit-card input, .panel input, .panel select, .sheet input, .sheet select, .sheet textarea {
border: 1px solid #e2e8f0; border-radius: var(--radius); padding: 0.55rem 0.7rem; font: inherit; width: 100%;
}
.field-card { grid-template-columns: auto 1fr auto; align-items: center; cursor: grab; }
.field-card small { color: #94a3b8; display: block; }
.fops { display: flex; gap: 0.2rem; }
.fops button { width: 30px; height: 30px; border: 0; border-radius: var(--radius); background: #f8fafc; cursor: pointer; color: #64748b; }
.fops .danger { color: #dc2626; }
.add-field { width: 100%; min-height: var(--control-h); height: var(--control-h); border: 1.5px dashed #14b8a6; background: #f0fdfa; color: #0f766e; border-radius: var(--radius); font: inherit; font-weight: 700; cursor: pointer; box-sizing: border-box; }
.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 {
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; 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; 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%); max-height: calc(100dvh - 2rem); overflow-y: auto; background: #fff; border-radius: var(--radius); padding: 1rem; display: grid; gap: 0.65rem; box-sizing: border-box; }
.sheet-head { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; }
.sheet-head h3 { margin: 0; }
.sheet-actions { display: flex; gap: 0.5rem; }
.sheet-actions .btn { flex: 1; }
.x-btn { width: 36px; height: 36px; flex-shrink: 0; border: 1px solid #e2e8f0; border-radius: var(--radius); background: #fff; color: #64748b; display: grid; place-items: center; cursor: pointer; }
.x-btn:hover { background: #f8fafc; color: #0f172a; border-color: #cbd5e1; }
.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; min-width: 0; }
.row.col { flex-direction: column; align-items: stretch; }
.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(3, minmax(0, 1fr));
gap: 0.5rem;
margin-bottom: 0.85rem;
}
.stat {
background: #f0fdfa;
border: 1px solid #ccfbf1;
border-radius: var(--radius);
padding: 0.6rem 0.55rem;
display: grid;
gap: 0.15rem;
text-align: center;
}
.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;
grid-template-columns: 72px 1fr;
gap: 0.75rem;
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: 108px;
aspect-ratio: 3 / 1;
border-radius: var(--radius);
overflow: hidden;
}
.mini-cover img { width: 100%; height: 100%; object-fit: cover; }
.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; }
.toolbox { order: 1; }
.canvas { order: 2; }
.tools { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 700px) {
.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: 84px; }
.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>