Files
crm/apps/web/src/views/LeadFormBuilderView.vue
T

653 lines
30 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, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import UiIcon from '@/components/UiIcon.vue';
import type {
LeadFormDto,
LeadFormFieldDto,
LeadFormFieldInput,
LeadFormFieldType,
LeadFormMapTo,
LeadFormSubmissionDto,
OrgUserDto,
PipelineDto,
} from '@mento/shared';
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 advancedOpen = ref(false);
const readyOpen = ref<string | null>('personal');
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 createPerson = ref(true);
const createDeal = ref(true);
const pipelineId = ref('');
const source = ref('وب‌سایت');
const thankYouMessage = ref('');
const redirectUrl = ref('');
const assigneeIds = ref<string[]>([]);
const users = ref<OrgUserDto[]>([]);
const pipelines = ref<PipelineDto[]>([]);
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: '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: 'custom' as const },
{ label: 'سمت', fieldType: 'text' as const, mapTo: 'person.position' as const },
],
},
{
key: 'job', title: 'اطلاعات شغلی',
items: [{ label: 'عنوان شغلی', fieldType: 'text' as const, mapTo: 'person.position' as const }],
},
{
key: 'market', title: 'سؤالات بازاریابی',
items: [{
label: 'از کجا آشنا شدید؟', fieldType: 'select' as const, mapTo: 'custom' as const,
options: ['سایت', 'اینستاگرام', 'معرفی'],
}],
},
];
const origin = computed(() => window.location.origin);
const publicUrl = computed(() => (form.value ? `${origin.value}${form.value.publicPath}` : ''));
const iframeSnippet = computed(
() => `<iframe src="${publicUrl.value}" width="100%" height="720" style="border:0;border-radius:16px" title="${name.value}"></iframe>`,
);
const qrUrl = computed(() =>
`https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=${encodeURIComponent(publicUrl.value)}`,
);
const coverSrc = computed(() =>
form.value?.hasCover ? `/api/public/forms/${form.value.publicToken}/cover` : '',
);
const editingField = computed(() => fields.value.find((f) => f.key === editFieldKey.value) ?? null);
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 {
if (type === 'email') return 'person.email';
if (type === 'phone') return 'person.mobile';
if (type === 'text') return 'person.fullName';
if (type === 'textarea') return 'deal.notes';
return 'custom';
}
function addField(type: LeadFormFieldType, preset?: Partial<LeadFormFieldInput>) {
const meta = toolbox.find((t) => t.type === type);
const label = preset?.label || meta?.label || 'فیلد جدید';
const needsOptions = type === 'select' || type === 'radio' || type === 'checkbox';
fields.value.push({
id: newKey(label),
key: newKey(label),
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 ? ['گزینه ۱', 'گزینه ۲'] : []),
});
}
function duplicateField(field: LeadFormFieldDto) {
addField(field.fieldType as LeadFormFieldType, {
label: `${field.label} (کپی)`,
mapTo: field.mapTo as LeadFormMapTo,
options: [...field.options],
placeholder: field.placeholder,
});
}
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;
createPerson.value = row.createPerson;
createDeal.value = row.createDeal;
pipelineId.value = row.pipelineId ?? '';
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] = await Promise.all([
api.get<LeadFormDto>(`/lead-forms/${id.value}`),
api.get<OrgUserDto[]>('/users'),
api.get<PipelineDto[]>('/pipelines'),
]);
applyForm(fRes.data);
users.value = uRes.data;
pipelines.value = pRes.data;
} catch {
error.value = 'بارگذاری فرم ناموفق بود.';
} finally {
loading.value = false;
}
}
async function save() {
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,
createPerson: createPerson.value,
createDeal: createDeal.value,
pipelineId: pipelineId.value || null,
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 file = (ev.target as HTMLInputElement).files?.[0];
if (!file) return;
const body = new FormData();
body.append('file', file);
try {
const { data } = await api.post<LeadFormDto>(`/lead-forms/${id.value}/cover`, body);
applyForm(data);
} catch {
error.value = 'آپلود کاور ناموفق بود.';
}
}
async function clearCover() {
const { data } = await api.delete<LeadFormDto>(`/lead-forms/${id.value}/cover`);
applyForm(data);
}
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 = 'کپی شد.';
}
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="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 primary" type="button" :disabled="saving" @click="save">
<UiIcon name="check" :size="15" /> ذخیره تغییرات
</button>
</div>
</header>
<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>
{{ f.label }}
<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" type="button" @click="coverInput?.click()">
<img v-if="coverSrc" :src="coverSrc" alt="" />
<span v-else>+ افزودن لوگو یا تصویر کاور</span>
</button>
<input ref="coverInput" type="file" accept="image/*" hidden @change="uploadCover" />
<button v-if="coverSrc" class="link" type="button" @click="clearCover">حذف کاور</button>
<div class="title-card">
<input v-model="name" class="title" />
<textarea 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>
<strong>{{ f.label }}</strong>
<small>{{ typeLabel[f.fieldType] || f.fieldType }}</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="addField('text')">+ افزودن فیلد جدید</button>
<div class="submit-card">
<label>متن دکمه ارسال
<input v-model="submitLabel" />
</label>
<label>رنگ دکمه
<input v-model="submitColor" type="color" />
</label>
</div>
<button class="adv" type="button" @click="advancedOpen = !advancedOpen">تنظیمات پیشرفته</button>
<div v-if="advancedOpen" class="adv-box">
<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" @click="readyOpen = readyOpen === g.key ? null : g.key">{{ g.title }}</button>
<div v-if="readyOpen === g.key">
<button
v-for="item in g.items"
:key="item.label"
type="button"
@click="addField(item.fieldType, item)"
>
{{ item.label }}
</button>
</div>
</div>
</aside>
</div>
<section v-else-if="tab === 'settings'" class="panel">
<label class="chk"><input v-model="isActive" type="checkbox" /> فرم فعال باشد</label>
<label>منبع بازاریابی <input v-model="source" /></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>
<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 }}
</button>
</div>
<button class="btn primary" type="button" @click="save">ذخیره تنظیمات</button>
</section>
<section v-else-if="tab === 'share'" class="panel">
<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">
<input :value="iframeSnippet" readonly />
<button class="btn ghost" type="button" @click="copyText(iframeSnippet)">کپی</button>
</div>
</label>
<img v-if="publicUrl" :src="qrUrl" alt="QR" width="140" height="140" />
</section>
<section v-else class="panel">
<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') }}</small>
<p v-for="(val, key) in s.values" :key="key"><b>{{ key }}:</b> {{ val }}</p>
</article>
</section>
<div v-if="desktopPreviewOpen" class="overlay desk-overlay" @click.self="desktopPreviewOpen = false">
<div class="desk-sheet" role="dialog" aria-label="پیش‌نمایش دسکتاپ">
<header>
<strong>پیش‌نمایش دسکتاپ</strong>
<button class="btn ghost" type="button" @click="desktopPreviewOpen = false">بستن</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' }">
{{ f.label }}
<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" @click.self="editFieldKey = null">
<div class="sheet">
<h3>ویرایش فیلد</h3>
<label>عنوان <input v-model="editingField.label" /></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">
<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="deal.title">عنوان معامله</option>
<option value="deal.notes">یادداشت معامله</option>
<option value="notes">یادداشت شخص</option>
<option value="custom">فقط در پاسخ فرم</option>
</select>
</label>
<label v-if="['select','radio','checkbox'].includes(editingField.fieldType)">
گزینه‌ها (هر خط یکی)
<textarea
:value="editingField.options.join('\n')"
rows="4"
@input="editingField.options = ($event.target as HTMLTextAreaElement).value.split('\n').map(s => s.trim()).filter(Boolean)"
/>
</label>
<button class="btn primary" type="button" @click="editFieldKey = null">تمام</button>
</div>
</div>
</section>
</template>
<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: 800; 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; }
.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: 12px; }
.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: 14px; }
.tabs button { border: 0; background: transparent; min-height: 38px; padding: 0 0.85rem; border-radius: 10px; 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: 16px; 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: 10px; 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: 28px; 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: 20px;
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: 18px; overflow: hidden; box-shadow: 0 16px 40px rgba(15,23,42,.08); }
.desk-form .cover { height: 140px; }
.desk-form .cover img { width: 100%; height: 140px; object-fit: cover; }
.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 { height: 86px; 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: 10px; 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 { width: 100%; min-height: 88px; border: 1.5px dashed #99f6e4; border-radius: 14px; background: #f0fdfa; color: #0f766e; font: inherit; font-weight: 700; cursor: pointer; overflow: hidden; }
.cover-box img { width: 100%; height: 88px; object-fit: cover; display: block; }
.title-card, .field-card, .submit-card, .adv-box { border: 1px solid #eef2f7; border-radius: 14px; padding: 0.8rem; display: grid; gap: 0.45rem; }
.title { font-size: 1.15rem; font-weight: 800; border: 0; background: transparent; font: inherit; }
.title-card textarea, .submit-card input, .panel input, .panel select, .sheet input, .sheet select, .sheet textarea {
border: 1px solid #e2e8f0; border-radius: 10px; 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: 8px; 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, .ready div button { border: 1px solid #eef2f7; background: #f8fafc; border-radius: 10px; 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; }
.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; }
.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; }
.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: 16px; padding: 1rem; display: grid; gap: 0.65rem; }
.link { background: none; border: 0; color: #0f766e; font: inherit; font-weight: 700; cursor: pointer; }
@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, .top-actions { flex-direction: column; }
.tabs { display: grid; grid-template-columns: 1fr 1fr; }
}
</style>