Files
CRM_Montzar/apps/web/src/views/MessageAutomationsView.vue
T

2498 lines
59 KiB
Vue
Raw Normal View History

2026-09-13 19:11:47 +03:30
<script setup lang="ts">
2026-09-14 16:49:43 +03:30
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
2026-09-13 19:11:47 +03:30
import axios from 'axios';
import { api } from '@/lib/api';
2026-09-14 16:49:43 +03:30
import UiIcon from '@/components/UiIcon.vue';
import { UiDropdownMenu } from '@/components/ui';
2026-09-14 16:49:43 +03:30
import { formatClockFa, formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
2026-09-13 19:11:47 +03:30
import type {
CreateMessageAutomationRuleRequest,
MessageAutomationChannel,
MessageAutomationDelayUnit,
MessageAutomationRuleDto,
MessageAutomationTarget,
MessageAutomationTrigger,
UpdateMessageAutomationRuleRequest,
} from '@mento/shared';
import {
MESSAGE_AUTOMATION_CHANNEL_LABELS,
2026-09-14 16:49:43 +03:30
MESSAGE_AUTOMATION_FIELDS,
MESSAGE_AUTOMATION_SMS_SEGMENT,
2026-09-13 19:11:47 +03:30
MESSAGE_AUTOMATION_TARGET_LABELS,
MESSAGE_AUTOMATION_TRIGGER_LABELS,
MESSAGE_AUTOMATION_TRIGGERS,
MESSAGE_AUTOMATION_UNIT_LABELS,
} from '@mento/shared';
2026-09-14 16:49:43 +03:30
const MOBILE_MQ = '(max-width: 979px)';
const DELAY_UNITS: MessageAutomationDelayUnit[] = ['hours', 'days', 'weeks', 'months'];
const DELAY_MAX: Record<MessageAutomationDelayUnit, number> = {
minutes: 1,
hours: 23,
days: 6,
weeks: 3,
months: 11,
};
const EMOJIS = ['🙏', '😊', '❤️', '👍', '🎉', '🌸', '✨', '👋', '💐', '🌟', '😍', '🤝'];
2026-09-13 19:11:47 +03:30
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const ok = ref('');
const items = ref<MessageAutomationRuleDto[]>([]);
const editingId = ref<string | null>(null);
const name = ref('');
2026-09-14 16:49:43 +03:30
const description = ref('');
2026-09-13 19:11:47 +03:30
const trigger = ref<MessageAutomationTrigger>('invoice_issued');
2026-09-14 16:49:43 +03:30
const delayLater = ref(false);
const startDelayValue = ref(1);
const startDelayUnit = ref<MessageAutomationDelayUnit>('hours');
2026-09-13 19:11:47 +03:30
const repeatCount = ref(1);
2026-09-13 21:22:41 +03:30
const intervalValue = ref(2);
const intervalUnit = ref<MessageAutomationDelayUnit>('days');
2026-09-13 19:11:47 +03:30
const notifyTarget = ref<MessageAutomationTarget>('person');
const channel = ref<MessageAutomationChannel>('sms');
const body = ref('');
2026-09-14 16:49:43 +03:30
const bodyEl = ref<HTMLTextAreaElement | null>(null);
const fieldsOpen = ref(false);
const emojiOpen = ref(false);
const fieldsSheet = ref(false);
const isMobile = ref(typeof window !== 'undefined' && window.matchMedia(MOBILE_MQ).matches);
const mobileView = ref<'list' | 'wizard'>('list');
const mobileStep = ref(1);
const query = ref('');
const statusFilter = ref<'all' | 'active' | 'inactive'>('all');
const menuId = ref<string | null>(null);
const MOBILE_STEPS = [
{ n: 1, label: 'اطلاعات' },
{ n: 2, label: 'شرط اجرا' },
{ n: 3, label: 'پیام' },
{ n: 4, label: 'تکمیل' },
] as const;
const visibleItems = computed(() => {
const q = query.value.trim();
return items.value.filter((row) => {
if (statusFilter.value === 'active' && !row.isActive) return false;
if (statusFilter.value === 'inactive' && row.isActive) return false;
if (!q) return true;
return `${row.name} ${row.description ?? ''} ${row.body}`.includes(q);
});
});
const delayMax = computed(() => DELAY_MAX[startDelayUnit.value] || 23);
const intervalMax = computed(() => DELAY_MAX[intervalUnit.value] || 6);
const bodyCount = computed(() => body.value.length);
const bodyOver = computed(() => bodyCount.value > MESSAGE_AUTOMATION_SMS_SEGMENT);
const smsParts = computed(() =>
bodyCount.value === 0 ? 0 : Math.ceil(bodyCount.value / MESSAGE_AUTOMATION_SMS_SEGMENT),
2026-09-13 19:11:47 +03:30
);
2026-09-14 16:49:43 +03:30
const previewBody = computed(() => {
const sample = {
'{نام}': 'علی',
'{شماره}': '۰۹۱۲۱۲۳۴۵۶۷',
'{تاریخ}': formatJalaliNumeric(new Date()),
'{عنوان}': name.value.trim() || 'عنوان',
'{شرکت}': 'شرکت نمونه',
'{title}': name.value.trim() || 'عنوان',
};
let text = body.value.trim();
for (const [token, value] of Object.entries(sample)) {
text = text.split(token).join(value);
}
return text;
});
const previewClock = computed(() => formatClockFa(new Date()));
watch(startDelayUnit, () => {
if (startDelayValue.value > delayMax.value) startDelayValue.value = delayMax.value;
});
watch(intervalUnit, () => {
if (intervalValue.value > intervalMax.value) intervalValue.value = intervalMax.value;
});
2026-09-13 19:11:47 +03:30
function resetForm() {
editingId.value = null;
name.value = '';
2026-09-14 16:49:43 +03:30
description.value = '';
2026-09-13 19:11:47 +03:30
trigger.value = 'invoice_issued';
2026-09-14 16:49:43 +03:30
delayLater.value = false;
startDelayValue.value = 1;
startDelayUnit.value = 'hours';
2026-09-13 19:11:47 +03:30
repeatCount.value = 1;
2026-09-13 21:22:41 +03:30
intervalValue.value = 2;
intervalUnit.value = 'days';
2026-09-13 19:11:47 +03:30
notifyTarget.value = 'person';
channel.value = 'sms';
body.value = '';
2026-09-14 16:49:43 +03:30
fieldsOpen.value = false;
emojiOpen.value = false;
fieldsSheet.value = false;
menuId.value = null;
2026-09-13 19:11:47 +03:30
ok.value = '';
}
2026-09-14 16:49:43 +03:30
function ruleDate(row: MessageAutomationRuleDto) {
return formatJalaliNumeric(new Date(row.createdAt));
}
function openMobileCreate() {
resetForm();
mobileStep.value = 1;
mobileView.value = 'wizard';
}
function openMobileEdit(row: MessageAutomationRuleDto) {
startEdit(row);
menuId.value = null;
mobileStep.value = 1;
mobileView.value = 'wizard';
}
function closeMobileWizard() {
resetForm();
mobileStep.value = 1;
mobileView.value = 'list';
}
function nextMobileStep() {
if (mobileStep.value === 1 && !name.value.trim()) {
error.value = 'عنوان قانون را وارد کنید.';
return;
}
if (mobileStep.value === 3 && !body.value.trim()) {
error.value = 'متن پیام را وارد کنید.';
return;
}
error.value = '';
if (mobileStep.value < 4) mobileStep.value += 1;
}
function prevMobileStep() {
error.value = '';
if (mobileStep.value <= 1) {
closeMobileWizard();
return;
}
mobileStep.value -= 1;
}
2026-09-13 19:11:47 +03:30
function startEdit(row: MessageAutomationRuleDto) {
editingId.value = row.id;
name.value = row.name;
2026-09-14 16:49:43 +03:30
description.value = row.description ?? '';
2026-09-13 19:11:47 +03:30
trigger.value = row.trigger;
2026-09-14 16:49:43 +03:30
delayLater.value = row.startDelayValue > 0;
startDelayValue.value = Math.max(1, row.startDelayValue || 1);
startDelayUnit.value =
row.startDelayUnit === 'minutes' ? 'hours' : row.startDelayUnit;
2026-09-13 19:11:47 +03:30
repeatCount.value = row.repeatCount;
2026-09-13 21:22:41 +03:30
intervalValue.value = row.intervalValue;
intervalUnit.value = row.intervalUnit;
2026-09-13 19:11:47 +03:30
notifyTarget.value = row.notifyTarget;
channel.value = row.channel;
body.value = row.body;
ok.value = '';
}
function scheduleLabel(row: MessageAutomationRuleDto) {
const start =
row.startDelayValue === 0
2026-09-14 16:49:43 +03:30
? 'بلافاصله'
2026-09-13 21:22:41 +03:30
: `${toFaDigits(row.startDelayValue)} ${MESSAGE_AUTOMATION_UNIT_LABELS[row.startDelayUnit]} بعد`;
2026-09-13 19:11:47 +03:30
if (row.repeatCount <= 1) return start;
2026-09-13 21:22:41 +03:30
return `${start} · ${toFaDigits(row.repeatCount)} بار، هر ${toFaDigits(row.intervalValue)} ${MESSAGE_AUTOMATION_UNIT_LABELS[row.intervalUnit]}`;
2026-09-13 19:11:47 +03:30
}
async function load() {
loading.value = true;
error.value = '';
try {
const { data } = await api.get<MessageAutomationRuleDto[]>('/message-automations');
items.value = data;
} catch (e) {
error.value = axios.isAxiosError(e) && !e.response
? 'اتصال به API برقرار نشد.'
: 'بارگذاری قوانین پیام ناموفق بود.';
} finally {
loading.value = false;
}
}
function payload(): CreateMessageAutomationRuleRequest {
2026-09-14 16:49:43 +03:30
const named = name.value.trim();
2026-09-13 19:11:47 +03:30
return {
2026-09-14 16:49:43 +03:30
name: named,
2026-09-13 20:11:48 +03:30
isActive: true,
2026-09-13 19:11:47 +03:30
trigger: trigger.value,
2026-09-14 16:49:43 +03:30
startDelayValue: delayLater.value ? Math.max(1, startDelayValue.value || 1) : 0,
startDelayUnit: delayLater.value ? startDelayUnit.value : 'minutes',
repeatCount: Math.min(10, Math.max(1, Number(repeatCount.value) || 1)),
2026-09-13 21:22:41 +03:30
intervalValue: Math.max(1, intervalValue.value || 1),
intervalUnit: intervalUnit.value,
2026-09-13 19:11:47 +03:30
notifyTarget: notifyTarget.value,
channel: channel.value,
2026-09-14 16:49:43 +03:30
title: named,
description: description.value.trim() || null,
2026-09-13 19:11:47 +03:30
body: body.value.trim(),
};
}
async function save() {
2026-09-14 16:49:43 +03:30
if (!name.value.trim() || !body.value.trim()) {
error.value = 'عنوان قانون و متن پیام را وارد کنید.';
2026-09-13 19:11:47 +03:30
return;
}
saving.value = true;
error.value = '';
ok.value = '';
try {
if (editingId.value) {
2026-09-13 20:11:48 +03:30
const { isActive: _ignore, ...rest } = payload();
const bodyDto: UpdateMessageAutomationRuleRequest = rest;
2026-09-13 19:11:47 +03:30
await api.patch(`/message-automations/${editingId.value}`, bodyDto);
ok.value = 'قانون به‌روز شد.';
} else {
await api.post('/message-automations', payload());
ok.value = 'قانون ساخته شد.';
}
resetForm();
2026-09-14 16:49:43 +03:30
if (isMobile.value) {
mobileStep.value = 1;
mobileView.value = 'list';
}
2026-09-13 19:11:47 +03:30
await load();
} catch {
error.value = 'ذخیره قانون ناموفق بود.';
} finally {
saving.value = false;
}
}
async function toggle(row: MessageAutomationRuleDto) {
try {
await api.patch(`/message-automations/${row.id}`, { isActive: !row.isActive });
await load();
} catch {
error.value = 'تغییر وضعیت ناموفق بود.';
}
}
2026-09-15 14:10:52 +03:30
const pendingDelete = ref<MessageAutomationRuleDto | null>(null);
function askRemove(row: MessageAutomationRuleDto) {
menuId.value = null;
pendingDelete.value = row;
}
function cancelRemove() {
pendingDelete.value = null;
}
async function confirmRemove() {
const row = pendingDelete.value;
if (!row) return;
pendingDelete.value = null;
2026-09-13 19:11:47 +03:30
try {
await api.delete(`/message-automations/${row.id}`);
if (editingId.value === row.id) resetForm();
await load();
} catch {
error.value = 'حذف قانون ناموفق بود.';
}
}
2026-09-14 16:49:43 +03:30
function insertAtCursor(text: string) {
const el = bodyEl.value;
const start = el?.selectionStart ?? body.value.length;
const end = el?.selectionEnd ?? start;
body.value = `${body.value.slice(0, start)}${text}${body.value.slice(end)}`;
void nextTick(() => {
if (!el) return;
const pos = start + text.length;
el.focus();
el.setSelectionRange(pos, pos);
});
}
function insertField(token: string) {
insertAtCursor(token);
fieldsOpen.value = false;
fieldsSheet.value = false;
}
function insertEmoji(mark: string) {
insertAtCursor(mark);
emojiOpen.value = false;
}
let mq: MediaQueryList | null = null;
function syncViewport() {
isMobile.value = !!mq?.matches;
}
onMounted(() => {
mq = window.matchMedia(MOBILE_MQ);
syncViewport();
mq.addEventListener('change', syncViewport);
void load();
});
onUnmounted(() => {
mq?.removeEventListener('change', syncViewport);
});
2026-09-13 19:11:47 +03:30
</script>
<template>
<section class="page">
2026-09-14 16:49:43 +03:30
<div v-if="isMobile && mobileView === 'list'" class="m-home">
<header class="m-title">
<h1>اتوماسیون پیامک‌ها</h1>
<p>با قوانین هوشمند، در زمان مناسب پیام مناسب را ارسال کنید.</p>
</header>
<div class="m-hero">
<img
class="hero-art"
src="/brand/message-automation-hero.png"
alt=""
width="1600"
height="983"
/>
<h2>ارتباط هوشمند با مشتریان</h2>
<p class="sub">برای هر رویداد در سیستم، به صورت خودکار پیامک ارسال کنید.</p>
<button class="btn primary wide" type="button" @click="openMobileCreate">
<UiIcon name="plus" :size="18" />
قانون جدید
</button>
</div>
<div class="m-tools">
<label class="m-search">
<UiIcon name="search" :size="16" />
<input v-model="query" type="search" placeholder="جستجوی قانون..." />
<UiIcon name="funnel" :size="16" />
</label>
<div class="m-tabs" role="tablist">
<button
type="button"
role="tab"
:class="{ on: statusFilter === 'all' }"
@click="statusFilter = 'all'"
>
همه
</button>
<button
type="button"
role="tab"
:class="{ on: statusFilter === 'active' }"
@click="statusFilter = 'active'"
>
فعال
</button>
<button
type="button"
role="tab"
:class="{ on: statusFilter === 'inactive' }"
@click="statusFilter = 'inactive'"
>
غیرفعال
</button>
</div>
</div>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<div class="m-cards">
<article
v-for="row in visibleItems"
:key="row.id"
class="m-card"
:class="{ off: !row.isActive }"
>
<header>
<div class="m-menu">
<UiDropdownMenu
:open="menuId === row.id"
align="end"
@update:open="(v) => (menuId = v ? row.id : null)"
2026-09-14 16:49:43 +03:30
>
<template #trigger="{ toggle }">
<button
type="button"
aria-label="عملیات"
@click.stop="toggle"
>
<UiIcon name="dots-v" :size="18" />
</button>
</template>
2026-09-14 16:49:43 +03:30
<button type="button" @click="openMobileEdit(row)">ویرایش</button>
2026-09-15 14:10:52 +03:30
<button type="button" class="danger" @click="askRemove(row)">حذف</button>
</UiDropdownMenu>
2026-09-14 16:49:43 +03:30
</div>
<div class="m-copy">
<strong>{{ row.name }}</strong>
<p>{{ row.description || scheduleLabel(row) }}</p>
</div>
<button
class="toggle"
type="button"
:class="{ on: row.isActive }"
:aria-pressed="row.isActive"
:aria-label="row.isActive ? 'فعال' : 'غیرفعال'"
@click="toggle(row)"
/>
</header>
<footer>
<span class="m-status" :class="{ on: row.isActive }">
<i />
{{ row.isActive ? 'فعال' : 'غیرفعال' }}
</span>
<time>{{ ruleDate(row) }}</time>
</footer>
</article>
<p v-if="!loading && !visibleItems.length" class="empty">هنوز قانون پیامی نیست.</p>
</div>
</div>
<form
v-else-if="isMobile"
class="m-wiz"
@submit.prevent="mobileStep === 4 ? save() : nextMobileStep()"
>
<header class="m-wiz-head">
<button class="m-back" type="button" aria-label="بازگشت" @click="prevMobileStep">
<UiIcon name="chevron" :size="20" />
</button>
<div>
<h1>{{ editingId ? 'ویرایش قانون' : 'قانون جدید' }}</h1>
<p>ایجاد یک قانون پیامک خودکار</p>
</div>
<span class="block-ico" aria-hidden="true">
<UiIcon name="document" :size="18" />
</span>
</header>
<ol class="m-steps">
<li
v-for="step in MOBILE_STEPS"
:key="step.n"
:class="{ on: mobileStep === step.n, done: mobileStep > step.n }"
>
<b>{{ toFaDigits(step.n) }}</b>
<span>{{ step.label }}</span>
</li>
</ol>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<section v-if="mobileStep === 1" class="stack">
<section class="block">
<header class="block-head">
<div>
<h2>اطلاعات کلی</h2>
</div>
<span class="block-ico" aria-hidden="true">
<UiIcon name="document" :size="18" />
</span>
</header>
<label class="field">
<span>عنوان قانون <em>*</em></span>
<input v-model="name" maxlength="120" placeholder="مثلاً تشکر بعد از فاکتور" />
</label>
<label class="field">
توضیحات
<textarea
v-model="description"
rows="2"
maxlength="500"
placeholder="توضیح کوتاهی درباره هدف این قانون بنویسید..."
/>
</label>
</section>
<section class="block">
<header class="block-head">
<div>
<h2>زمان و تعداد</h2>
<p>چه زمانی و چه تعداد باید پیامک ارسال شود؟</p>
</div>
<span class="block-ico clock" aria-hidden="true">
<UiIcon name="clock" :size="18" />
</span>
</header>
<div class="m-schedule">
<label class="m-opt" :class="{ on: !delayLater }">
<input v-model="delayLater" type="radio" :value="false" />
<i />
<span>
<strong>بلافاصله</strong>
<small>بلافاصله بعد از وقوع رویداد</small>
</span>
</label>
<div class="m-opt-box" :class="{ on: delayLater }">
<label class="m-opt tight">
<input v-model="delayLater" type="radio" :value="true" />
<i />
<span>
<strong>با تأخیر زمانی</strong>
</span>
</label>
<div v-if="delayLater" class="m-delay">
<em>بعد از</em>
<input
v-model.number="startDelayValue"
type="number"
min="1"
:max="delayMax"
aria-label="تأخیر"
/>
<select v-model="startDelayUnit" aria-label="واحد تأخیر">
<option v-for="unit in DELAY_UNITS" :key="unit" :value="unit">
{{ MESSAGE_AUTOMATION_UNIT_LABELS[unit] }}
</option>
</select>
</div>
</div>
<div class="m-repeat">
<div class="m-delay">
<span class="cell-label">تعداد</span>
<input
v-model.number="repeatCount"
type="number"
min="1"
max="10"
aria-label="تعداد پیام"
/>
</div>
<div class="m-delay" :class="{ off: repeatCount <= 1 }">
<span class="cell-label">فاصله بین پیام‌ها</span>
<em>هر</em>
<input
v-model.number="intervalValue"
type="number"
min="1"
:max="intervalMax"
:disabled="repeatCount <= 1"
aria-label="فاصله تکرار"
/>
<select v-model="intervalUnit" :disabled="repeatCount <= 1" aria-label="واحد فاصله">
<option v-for="unit in DELAY_UNITS" :key="unit" :value="unit">
{{ MESSAGE_AUTOMATION_UNIT_LABELS[unit] }}
</option>
</select>
</div>
</div>
</div>
</section>
<section class="block">
<header class="block-head">
<div>
<h2>برای چه کسی؟</h2>
</div>
<span class="block-ico users" aria-hidden="true">
<UiIcon name="users" :size="18" />
</span>
</header>
<label class="field">
برای چه کسی
<select v-model="notifyTarget">
<option value="person">مشتری</option>
<option value="staff">مسئول پرونده</option>
<option value="both">مشتری و مسئول</option>
</select>
</label>
<label class="field">
از چه راهی؟
<select v-model="channel">
<option value="sms">پیامک</option>
<option value="app">ناتیف داخل برنامه</option>
<option value="both">پیامک و ناتیف</option>
</select>
</label>
</section>
</section>
<section v-else-if="mobileStep === 2" class="stack">
<section class="block">
<header class="block-head">
<div>
<h2>رویداد/شرط اجرا</h2>
<p>این قانون در چه شرایطی فعال شود؟</p>
</div>
<span class="block-ico funnel" aria-hidden="true">
<UiIcon name="funnel" :size="18" />
</span>
</header>
<label class="field">
نوع رویداد
<select v-model="trigger">
<option
v-for="item in MESSAGE_AUTOMATION_TRIGGERS"
:key="item"
:value="item"
>
{{ MESSAGE_AUTOMATION_TRIGGER_LABELS[item] }}
</option>
</select>
</label>
</section>
</section>
<section v-else-if="mobileStep === 3" class="stack">
<section class="block">
<header class="block-head">
<div>
<h2>متن پیام</h2>
<p>متن پیامک خود را بنویسید. می‌توانید از فیلدهای پویا استفاده کنید.</p>
</div>
<span class="block-ico chat" aria-hidden="true">
<UiIcon name="chat" :size="18" />
</span>
</header>
<div class="msg-box">
<div class="msg-tools">
<div class="pop-wrap">
<button class="pill" type="button" @click="fieldsOpen = !fieldsOpen; emojiOpen = false">
<b>{ }</b>
فیلدهای پویا
</button>
<div v-if="fieldsOpen" class="pop">
<button
v-for="item in MESSAGE_AUTOMATION_FIELDS"
:key="item.token"
type="button"
@click="insertField(item.token)"
>
<code>{{ item.token }}</code>
<span>{{ item.label }}</span>
</button>
</div>
</div>
<div class="pop-wrap">
<button class="icon-pill" type="button" aria-label="ایموجی" @click="emojiOpen = !emojiOpen; fieldsOpen = false">
😊
</button>
<div v-if="emojiOpen" class="pop emojis">
<button
v-for="mark in EMOJIS"
:key="mark"
type="button"
@click="insertEmoji(mark)"
>
{{ mark }}
</button>
</div>
</div>
</div>
<textarea
ref="bodyEl"
v-model="body"
maxlength="500"
rows="6"
placeholder="سلام {نام} عزیز"
/>
<span class="counter" :class="{ over: bodyOver }">
{{ toFaDigits(bodyCount) }}/{{ toFaDigits(MESSAGE_AUTOMATION_SMS_SEGMENT) }}
</span>
</div>
</section>
<div class="tip">
<strong>
<UiIcon name="lightbulb" :size="16" />
نکته
</strong>
<p>
می‌توانید از فیلدهای پویا مثل
<bdi class="token">{نام}</bdi>،
<bdi class="token">{شماره}</bdi>
و
<bdi class="token">{تاریخ}</bdi>
در متن پیام استفاده کنید.
</p>
</div>
</section>
<section v-else class="stack">
<section class="block">
<header class="block-head">
<div>
<h2>پیش‌نمایش پیام</h2>
</div>
<span class="block-ico eye" aria-hidden="true">
<UiIcon name="eye" :size="18" />
</span>
</header>
<div class="m-preview">
<div class="m-bubble">
<p>{{ previewBody || 'متن پیام هنوز وارد نشده است.' }}</p>
<time>{{ previewClock }}</time>
</div>
<div class="m-sms-note">
<strong>
<i>i</i>
تعداد کاراکتر: {{ toFaDigits(bodyCount) }} ({{ toFaDigits(smsParts) }} پیامک)
</strong>
<p>ممکن است بسته به نوع سامانه پیامکی تعداد کاراکتر متفاوت باشد.</p>
</div>
</div>
</section>
</section>
<footer class="m-wiz-foot">
<button v-if="mobileStep < 4" class="btn primary" type="submit">
مرحله بعد
<span>←</span>
</button>
<button v-else class="btn primary" type="submit" :disabled="saving">
{{ saving ? 'در حال ذخیره…' : editingId ? 'ذخیره' : 'ثبت قانون' }}
</button>
<button class="btn ghost" type="button" @click="closeMobileWizard">انصراف</button>
</footer>
</form>
<header v-show="!isMobile" class="page-head">
2026-09-13 19:11:47 +03:30
<div class="head-copy">
2026-09-14 16:49:43 +03:30
<h1>اتوماسیون پیامک‌ها</h1>
2026-09-13 19:11:47 +03:30
<p class="lead">
2026-09-14 16:49:43 +03:30
پیامک و ناتیف بعد از رویداد، با تأخیر یا همان لحظه.
2026-09-13 19:11:47 +03:30
</p>
</div>
2026-09-13 20:11:48 +03:30
<div class="head-actions">
<button class="btn ghost" type="button" :disabled="loading" @click="load">
بروزرسانی
</button>
</div>
2026-09-13 19:11:47 +03:30
</header>
2026-09-14 16:49:43 +03:30
<p v-show="!isMobile && error" class="error">{{ error }}</p>
<p v-show="!isMobile && ok" class="ok">{{ ok }}</p>
2026-09-13 19:11:47 +03:30
2026-09-14 16:49:43 +03:30
<form v-show="!isMobile" class="composer" @submit.prevent="save">
<div class="stack">
<section class="block">
<header class="block-head">
<div>
<h2><b>{{ toFaDigits(1) }}</b> اطلاعات کلی</h2>
<p>ابتدا یک نام و توضیح برای این قانون مشخص کنید.</p>
</div>
<span class="block-ico" aria-hidden="true">
<UiIcon name="document" :size="18" />
</span>
</header>
<label class="field">
<span>عنوان قانون <em>*</em></span>
<input v-model="name" maxlength="120" placeholder="مثلاً تشکر بعد از فاکتور" />
</label>
<label class="field">
توضیحات
<textarea
v-model="description"
rows="2"
maxlength="500"
placeholder="توضیح کوتاهی درباره هدف این قانون بنویسید..."
/>
</label>
</section>
<section class="block">
<header class="block-head">
<div>
<h2><b>{{ toFaDigits(2) }}</b> زمان و تعداد</h2>
<p>چه زمانی و چه تعداد باید پیامک ارسال شود؟</p>
</div>
<span class="block-ico clock" aria-hidden="true">
<UiIcon name="clock" :size="18" />
</span>
</header>
<div class="schedule">
<div class="schedule-row">
<label class="choice" :class="{ on: !delayLater }">
<input v-model="delayLater" type="radio" :value="false" />
<i />
<span>
<strong>بلافاصله</strong>
<small>بلافاصله بعد از وقوع رویداد</small>
</span>
</label>
<div class="split flush" :class="{ off: !delayLater }">
<label class="choice" :class="{ on: delayLater }">
<input v-model="delayLater" type="radio" :value="true" />
<i />
<span>
<strong>با تأخیر زمانی</strong>
<small>با فاصله از وقوع رویداد</small>
</span>
</label>
<span class="delay-controls">
<em>بعد از</em>
<input
v-model.number="startDelayValue"
type="number"
min="1"
:max="delayMax"
:disabled="!delayLater"
aria-label="تأخیر"
/>
<select v-model="startDelayUnit" :disabled="!delayLater" aria-label="واحد تأخیر">
<option v-for="unit in DELAY_UNITS" :key="unit" :value="unit">
{{ MESSAGE_AUTOMATION_UNIT_LABELS[unit] }}
</option>
</select>
</span>
</div>
</div>
<div class="schedule-row">
<label class="split">
<span class="cell-label">تعداد</span>
<span class="delay-controls">
<input
v-model.number="repeatCount"
type="number"
min="1"
max="10"
aria-label="تعداد پیام"
/>
</span>
</label>
<label class="split flush" :class="{ off: repeatCount <= 1 }">
<span class="cell-label">فاصله بین پیام‌ها</span>
<span class="delay-controls">
<em>هر</em>
<input
v-model.number="intervalValue"
type="number"
min="1"
:max="intervalMax"
:disabled="repeatCount <= 1"
aria-label="فاصله تکرار"
/>
<select
v-model="intervalUnit"
:disabled="repeatCount <= 1"
aria-label="واحد فاصله"
>
<option v-for="unit in DELAY_UNITS" :key="unit" :value="unit">
{{ MESSAGE_AUTOMATION_UNIT_LABELS[unit] }}
</option>
</select>
</span>
</label>
</div>
</div>
</section>
<section class="block">
<header class="block-head">
<div>
<h2><b>{{ toFaDigits(3) }}</b> رویداد/شرط اجرا</h2>
<p>این قانون در چه شرایطی فعال شود؟</p>
</div>
<span class="block-ico funnel" aria-hidden="true">
<UiIcon name="funnel" :size="18" />
</span>
</header>
<label class="field">
نوع رویداد
<select v-model="trigger">
<option
v-for="item in MESSAGE_AUTOMATION_TRIGGERS"
:key="item"
:value="item"
>
{{ MESSAGE_AUTOMATION_TRIGGER_LABELS[item] }}
</option>
</select>
</label>
<label class="field">
برای چه کسی
<select v-model="notifyTarget">
<option value="person">مشتری</option>
<option value="staff">مسئول پرونده</option>
<option value="both">مشتری و مسئول</option>
</select>
</label>
<label class="field">
از چه راهی
<select v-model="channel">
<option value="sms">پیامک</option>
<option value="app">ناتیف داخل برنامه</option>
<option value="both">پیامک و ناتیف</option>
</select>
</label>
</section>
<section class="block">
<header class="block-head">
<div>
<h2><b>{{ toFaDigits(4) }}</b> متن پیام</h2>
<p>متن پیامک خود را بنویسید. می‌توانید از فیلدهای پویا استفاده کنید.</p>
</div>
<span class="block-ico chat" aria-hidden="true">
<UiIcon name="chat" :size="18" />
</span>
</header>
<div class="msg-box">
<div class="msg-tools">
<div class="pop-wrap">
<button class="pill" type="button" @click="fieldsOpen = !fieldsOpen; emojiOpen = false">
<b>{ }</b>
فیلدهای پویا
</button>
<div v-if="fieldsOpen" class="pop">
<button
v-for="item in MESSAGE_AUTOMATION_FIELDS"
:key="item.token"
type="button"
@click="insertField(item.token)"
>
<code>{{ item.token }}</code>
<span>{{ item.label }}</span>
</button>
</div>
</div>
<div class="pop-wrap">
<button class="icon-pill" type="button" aria-label="ایموجی" @click="emojiOpen = !emojiOpen; fieldsOpen = false">
😊
</button>
<div v-if="emojiOpen" class="pop emojis">
<button
v-for="mark in EMOJIS"
:key="mark"
type="button"
@click="insertEmoji(mark)"
>
{{ mark }}
</button>
</div>
</div>
</div>
<textarea
ref="bodyEl"
v-model="body"
maxlength="500"
rows="6"
placeholder="سلام {نام} عزیز"
/>
<span class="counter" :class="{ over: bodyOver }">
{{ toFaDigits(bodyCount) }}/{{ toFaDigits(MESSAGE_AUTOMATION_SMS_SEGMENT) }}
</span>
</div>
<div class="form-actions">
<button class="btn primary" type="submit" :disabled="saving">
{{ saving ? 'در حال ذخیره…' : editingId ? 'ذخیره' : 'ساخت' }}
</button>
<button v-if="editingId" class="btn ghost" type="button" @click="resetForm">
انصراف
</button>
</div>
</section>
2026-09-13 19:11:47 +03:30
</div>
2026-09-14 16:49:43 +03:30
<div class="side">
<aside class="intro">
<div class="hero">
<img
class="hero-art"
src="/brand/message-automation-hero.png"
alt=""
width="1600"
height="983"
/>
<h3>ارتباط هوشمند با مشتریان</h3>
<p>با اتوماسیون پیامک، در زمان مناسب، پیام مناسب را به افراد مناسب ارسال کنید.</p>
</div>
<ul>
<li>
<UiIcon name="pulse" :size="20" />
افزایش فروش
</li>
<li>
<UiIcon name="clock" :size="20" />
صرفه‌جویی در زمان
</li>
<li>
<UiIcon name="users" :size="20" />
بهبود ارتباط با مشتریان
</li>
<li>
<UiIcon name="sliders" :size="20" />
کاملاً قابل شخصی‌سازی
</li>
</ul>
</aside>
<div class="tip">
<strong>
<UiIcon name="lightbulb" :size="16" />
نکته
</strong>
<p>
می‌توانید از فیلدهای پویا مثل
<bdi class="token">{نام}</bdi>،
<bdi class="token">{شماره}</bdi>
و
<bdi class="token">{تاریخ}</bdi>
در متن پیام استفاده کنید.
</p>
<button type="button" @click="fieldsSheet = true">
مشاهده فهرست فیلدها
<span>←</span>
</button>
</div>
2026-09-13 19:11:47 +03:30
</div>
</form>
2026-09-14 16:49:43 +03:30
<div v-if="fieldsSheet" class="sheet-layer">
<button class="dim" type="button" aria-label="بستن" @click="fieldsSheet = false" />
<section class="sheet" role="dialog" aria-modal="true" aria-label="فهرست فیلدها">
<header>
<h3>فهرست فیلدهای پویا</h3>
<button type="button" aria-label="بستن" @click="fieldsSheet = false">
<UiIcon name="x" :size="16" />
</button>
</header>
<button
v-for="item in MESSAGE_AUTOMATION_FIELDS"
:key="item.token"
type="button"
@click="insertField(item.token)"
>
<code>{{ item.token }}</code>
<span>{{ item.label }}</span>
</button>
</section>
</div>
<div v-show="!isMobile" class="list-wrap">
<div v-if="items.length" class="tc-row">
<TableColumnsMenu table-key="message-automations" align="end" />
</div>
<table v-if="items.length" v-table-cols="'message-automations'" class="desk-table">
2026-09-13 20:11:48 +03:30
<thead>
<tr>
<th>عنوان</th>
<th>رویداد</th>
<th>زمان‌بندی</th>
<th>مخاطب</th>
<th>کانال</th>
<th>عملیات</th>
2026-09-13 20:11:48 +03:30
</tr>
</thead>
<tbody>
<tr v-for="row in items" :key="row.id" :class="{ off: !row.isActive }">
<td>
<strong>{{ row.name }}</strong>
2026-09-14 16:49:43 +03:30
<span class="preview">{{ row.description || row.body }}</span>
2026-09-13 20:11:48 +03:30
</td>
<td>{{ MESSAGE_AUTOMATION_TRIGGER_LABELS[row.trigger] }}</td>
<td>{{ scheduleLabel(row) }}</td>
<td>{{ MESSAGE_AUTOMATION_TARGET_LABELS[row.notifyTarget] }}</td>
<td>{{ MESSAGE_AUTOMATION_CHANNEL_LABELS[row.channel] }}</td>
<td class="ops">
2026-09-15 14:10:52 +03:30
<div class="ops-row">
<button
class="icon-btn status"
type="button"
:class="{ on: row.isActive }"
:aria-pressed="row.isActive"
:aria-label="row.isActive ? 'فعال' : 'غیرفعال'"
:title="row.isActive ? 'فعال' : 'غیرفعال'"
@click="toggle(row)"
>
<UiIcon :name="row.isActive ? 'check' : 'x-circle'" :size="24" />
</button>
<button class="icon-btn edit" type="button" title="ویرایش" aria-label="ویرایش" @click="startEdit(row)">
<UiIcon name="edit" :size="24" />
</button>
<button class="icon-btn danger" type="button" title="حذف" aria-label="حذف" @click="askRemove(row)">
<UiIcon name="trash" :size="24" />
</button>
</div>
2026-09-13 20:11:48 +03:30
</td>
</tr>
</tbody>
</table>
<div class="mobile-cards">
<article
v-for="row in items"
:key="row.id"
class="card"
:class="{ off: !row.isActive }"
>
<header>
<div>
<strong>{{ row.name }}</strong>
<p class="muted">{{ MESSAGE_AUTOMATION_TRIGGER_LABELS[row.trigger] }}</p>
</div>
<button
class="toggle"
type="button"
:class="{ on: row.isActive }"
:aria-pressed="row.isActive"
:aria-label="row.isActive ? 'فعال' : 'غیرفعال'"
@click="toggle(row)"
/>
</header>
<p>{{ scheduleLabel(row) }}</p>
<p class="muted">
{{ MESSAGE_AUTOMATION_TARGET_LABELS[row.notifyTarget] }}
·
{{ MESSAGE_AUTOMATION_CHANNEL_LABELS[row.channel] }}
</p>
2026-09-14 16:49:43 +03:30
<p class="preview">{{ row.description || row.body }}</p>
2026-09-13 20:11:48 +03:30
<div class="card-actions">
<button class="btn ghost" type="button" @click="startEdit(row)">ویرایش</button>
2026-09-15 14:10:52 +03:30
<button class="btn danger" type="button" @click="askRemove(row)">حذف</button>
2026-09-13 20:11:48 +03:30
</div>
</article>
</div>
2026-09-13 19:11:47 +03:30
<p v-if="!loading && !items.length" class="empty">هنوز قانون پیامی نیست.</p>
</div>
2026-09-15 14:10:52 +03:30
<div v-if="pendingDelete" class="sheet-layer">
<button class="dim" type="button" aria-label="انصراف" @click="cancelRemove" />
<section class="confirm-box" role="dialog" aria-modal="true" aria-labelledby="delete-confirm-title">
<h3 id="delete-confirm-title">حذف قانون</h3>
<p>آیا از حذف مطمئن هستید؟</p>
<div class="confirm-actions">
<button class="btn danger" type="button" @click="confirmRemove">حذف</button>
<button class="btn ghost" type="button" @click="cancelRemove">انصراف</button>
</div>
</section>
</div>
2026-09-13 19:11:47 +03:30
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
min-width: 0;
2026-09-13 20:11:48 +03:30
width: 100%;
2026-09-13 19:11:47 +03:30
}
.page-head {
display: flex;
justify-content: space-between;
gap: 1rem;
2026-09-13 20:11:48 +03:30
flex-wrap: wrap;
align-items: flex-start;
}
.head-copy {
min-width: 0;
}
2026-09-14 16:49:43 +03:30
.head-actions,
.form-actions,
.card-actions,
.ops {
2026-09-13 20:11:48 +03:30
display: flex;
2026-09-14 16:49:43 +03:30
gap: 0.5rem;
2026-09-13 20:11:48 +03:30
flex-wrap: wrap;
2026-09-13 19:11:47 +03:30
}
h1 {
margin: 0 0 0.3rem;
font-size: clamp(1.45rem, 2.5vw, 1.85rem);
font-weight: 800;
}
.lead,
2026-09-14 16:49:43 +03:30
.muted {
2026-09-13 19:11:47 +03:30
margin: 0;
color: #64748b;
}
2026-09-14 16:49:43 +03:30
.composer {
display: grid;
gap: 1rem;
align-items: start;
2026-09-13 19:11:47 +03:30
}
2026-09-14 16:49:43 +03:30
.stack {
display: grid;
gap: 0.85rem;
2026-09-13 20:11:48 +03:30
min-width: 0;
}
2026-09-14 16:49:43 +03:30
.block,
.intro,
2026-09-13 20:11:48 +03:30
.list-wrap {
2026-09-14 16:49:43 +03:30
background: #fff;
border: 1px solid #e8eef3;
border-radius: 22px;
min-width: 0;
2026-09-13 20:11:48 +03:30
}
2026-09-14 16:49:43 +03:30
.block {
2026-09-13 20:11:48 +03:30
display: grid;
2026-09-14 16:49:43 +03:30
gap: 0.85rem;
padding: 1.15rem 1.2rem 1.2rem;
2026-09-13 20:11:48 +03:30
}
2026-09-14 16:49:43 +03:30
.block-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.8rem;
}
.block-head h2 {
display: flex;
align-items: center;
gap: 0.4rem;
margin: 0;
font-size: 1rem;
font-weight: 800;
}
.block-head h2 b {
font-weight: 800;
color: #0f172a;
}
.block-head p {
margin: 0.2rem 0 0;
color: #94a3b8;
font-size: 0.8rem;
}
.block-ico {
width: 2.15rem;
height: 2.15rem;
border-radius: 999px;
2026-09-13 20:11:48 +03:30
display: grid;
2026-09-14 16:49:43 +03:30
place-items: center;
background: #ecfdf5;
color: #0f766e;
flex-shrink: 0;
}
.block-ico.clock {
background: #ecfeff;
color: #0e7490;
}
.block-ico.funnel {
background: #f5f3ff;
color: #6d28d9;
}
.block-ico.chat {
background: #ecfeff;
color: #0f766e;
}
.block-ico.users {
background: #ecfdf5;
color: #0f766e;
}
.block-ico.eye {
background: #ecfeff;
color: #0e7490;
2026-09-13 19:11:47 +03:30
}
.field {
display: grid;
2026-09-14 16:49:43 +03:30
gap: 0.35rem;
2026-09-13 19:11:47 +03:30
color: #64748b;
2026-09-13 20:11:48 +03:30
font-size: 0.86rem;
2026-09-13 19:11:47 +03:30
}
2026-09-14 16:49:43 +03:30
.field em {
color: #ef4444;
font-style: normal;
2026-09-13 19:11:47 +03:30
}
input,
select,
textarea {
border: 1px solid #e2e8f0;
2026-09-14 16:49:43 +03:30
border-radius: 12px;
2026-09-13 19:11:47 +03:30
min-height: var(--control-h);
padding: 0 0.85rem;
font: inherit;
background: #fff;
width: 100%;
box-sizing: border-box;
color: #0f172a;
}
textarea {
2026-09-14 16:49:43 +03:30
min-height: 4.4rem;
padding: 0.65rem 0.85rem;
resize: vertical;
2026-09-13 19:11:47 +03:30
}
2026-09-14 16:49:43 +03:30
.choice {
position: relative;
2026-09-13 19:11:47 +03:30
display: flex;
2026-09-14 16:49:43 +03:30
align-items: flex-start;
gap: 0.65rem;
cursor: pointer;
}
.choice input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.choice i {
width: 1.15rem;
height: 1.15rem;
margin-top: 0.1rem;
border: 2px solid #cbd5e1;
border-radius: 999px;
background: #fff;
flex-shrink: 0;
position: relative;
}
.choice.on i {
border-color: #14b8a6;
}
.choice.on i::after {
content: '';
position: absolute;
inset: 3px;
border-radius: 999px;
background: #14b8a6;
}
.choice span {
display: grid;
gap: 0.1rem;
}
.choice strong {
color: #0f172a;
font-size: 0.92rem;
}
.choice small {
color: #94a3b8;
font-size: 0.78rem;
}
.schedule {
--choice-indent: calc(1.15rem + 0.65rem);
display: grid;
gap: 0.9rem;
}
.schedule-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr);
align-items: start;
gap: 0.75rem 1.2rem;
width: 100%;
}
@media (max-width: 720px) {
.schedule-row {
grid-template-columns: 1fr;
}
}
.split {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.65rem;
min-width: 0;
}
.schedule-row > .split:not(.flush) {
padding-inline-start: var(--choice-indent);
}
.split.flush {
display: grid;
grid-template-columns: 9rem 12.5rem;
justify-content: end;
column-gap: 1.35rem;
align-items: center;
}
.split.flush .delay-controls {
justify-self: end;
}
.split.flush .cell-label {
justify-self: start;
margin-inline-start: var(--choice-indent);
}
.schedule-row:first-child .split.flush {
align-items: start;
}
.split.off .delay-controls {
opacity: 0.45;
}
.cell-label {
color: #0f172a;
font-size: 0.92rem;
font-weight: 700;
white-space: nowrap;
}
.delay-controls {
display: flex;
align-items: center;
gap: 0.4rem;
color: #64748b;
font-size: 0.86rem;
}
.delay-controls em {
font-style: normal;
white-space: nowrap;
}
.delay-controls input,
.delay-controls select {
width: auto;
min-height: 2.15rem;
}
.delay-controls input {
width: 3.4rem;
text-align: center;
padding: 0;
}
.delay-controls select {
width: 5.8rem;
}
.msg-box {
position: relative;
display: grid;
gap: 0.55rem;
padding: 0.75rem;
border: 1px solid #e8eef3;
border-radius: 16px;
background: #fafbfc;
}
.msg-tools {
display: flex;
gap: 0.45rem;
}
.pop-wrap {
position: relative;
}
.pill,
.icon-pill {
display: inline-flex;
align-items: center;
gap: 0.35rem;
min-height: 2rem;
padding: 0 0.7rem;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
color: #334155;
font: inherit;
font-size: 0.82rem;
cursor: pointer;
}
.pill b {
color: #0f766e;
font-weight: 800;
}
.icon-pill {
width: 2rem;
padding: 0;
justify-content: center;
}
.pop {
position: absolute;
top: calc(100% + 6px);
inset-inline-start: 0;
z-index: 5;
min-width: 12rem;
padding: 0.35rem;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #fff;
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.1);
display: grid;
gap: 0.15rem;
}
.pop button {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
width: 100%;
border: 0;
background: transparent;
padding: 0.45rem 0.5rem;
border-radius: 8px;
font: inherit;
cursor: pointer;
color: #334155;
}
.pop button:hover {
background: #f1f5f9;
}
.pop code,
.tip code,
.sheet code {
color: #0f766e;
font-weight: 700;
}
.pop.emojis {
grid-template-columns: repeat(6, 1fr);
min-width: 0;
}
.pop.emojis button {
justify-content: center;
font-size: 1.1rem;
}
.counter {
justify-self: start;
color: #94a3b8;
font-size: 0.75rem;
}
.counter.over {
color: #b45309;
}
.side {
display: grid;
gap: 0.85rem;
min-width: 0;
}
.intro {
display: grid;
gap: 1.15rem;
padding: 1.35rem 1.15rem 1.35rem;
border: 0;
background: #eef4fa;
box-shadow: 0 0 0 1px #eef2f6;
}
.hero {
text-align: start;
padding-bottom: 1.15rem;
border-bottom: 1px solid #e2e8f0;
}
.hero-art {
display: block;
width: min(100%, 15.5rem);
height: auto;
margin: 0.15rem auto 1.65rem;
}
.hero h3 {
margin: 0 0 0.45rem;
color: #0f172a;
font-size: 1.02rem;
font-weight: 800;
text-align: center;
}
.hero p {
margin: 0;
color: #94a3b8;
font-size: 0.8rem;
line-height: 1.85;
}
.intro ul {
list-style: none;
width: 100%;
margin: 0.2rem 0 0;
padding: 0;
display: grid;
gap: 1.55rem;
}
.intro li {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.6rem;
color: #334155;
font-size: 0.95rem;
font-weight: 600;
}
.intro li :deep(.ico) {
color: #14b8a6;
}
.tip {
padding: 1rem 1.05rem 1rem;
border-radius: 22px;
background: #ecfdf5;
color: #475569;
}
.tip strong {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.35rem;
margin-bottom: 0.45rem;
color: #0f766e;
font-size: 1.02rem;
}
.tip p {
margin: 0;
font-size: 0.88rem;
line-height: 1.9;
text-align: start;
}
.token {
display: inline-block;
padding: 0 0.15rem;
color: #0f766e;
font-weight: 700;
direction: ltr;
unicode-bidi: isolate;
}
.tip button {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.35rem;
width: 100%;
margin-top: 0.75rem;
border: 0;
background: transparent;
color: #0f766e;
font: inherit;
font-weight: 700;
font-size: 0.9rem;
cursor: pointer;
}
.sheet-layer {
position: fixed;
inset: 0;
z-index: 90;
display: grid;
place-items: center;
padding: 1rem;
}
.sheet-layer .dim {
position: absolute;
inset: 0;
border: 0;
background: rgba(15, 23, 42, 0.32);
}
2026-09-15 14:10:52 +03:30
.confirm-box {
position: relative;
z-index: 1;
width: min(22rem, 100%);
display: grid;
gap: 0.85rem;
padding: 1.15rem 1.2rem 1.1rem;
border-radius: 18px;
background: #fff;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.16);
}
.confirm-box h3 {
margin: 0;
font-size: 1.05rem;
font-weight: 800;
color: #0f172a;
}
.confirm-box p {
margin: 0;
color: #475569;
font-size: 0.95rem;
}
.confirm-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
2026-09-14 16:49:43 +03:30
.sheet {
position: relative;
z-index: 1;
width: min(22rem, 100%);
display: grid;
gap: 0.35rem;
padding: 1rem;
border-radius: 18px;
background: #fff;
}
.sheet header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.4rem;
}
.sheet header h3 {
margin: 0;
font-size: 0.95rem;
}
.sheet header button,
.sheet > button {
border: 0;
background: #f8fafc;
border-radius: 10px;
cursor: pointer;
font: inherit;
}
.sheet header button {
width: 2rem;
height: 2rem;
display: grid;
place-items: center;
}
.sheet > button {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.65rem 0.7rem;
}
.list-wrap {
padding: 0.85rem 1.1rem;
}
.empty {
margin: 0;
padding: 1.4rem 0.2rem;
text-align: center;
color: #64748b;
2026-09-13 19:11:47 +03:30
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
2026-09-13 20:11:48 +03:30
gap: 0.35rem;
2026-09-13 19:11:47 +03:30
min-height: var(--control-h);
padding: 0 0.95rem;
border-radius: var(--radius);
font-weight: 700;
border: 1px solid transparent;
cursor: pointer;
font: inherit;
}
.btn.primary {
background: #14b8a6;
color: #fff;
2026-09-13 20:11:48 +03:30
box-shadow: 0 8px 18px rgba(20, 184, 166, 0.24);
2026-09-13 19:11:47 +03:30
}
.btn.ghost {
background: #fff;
color: #0f766e;
border-color: #99f6e4;
}
.btn.danger {
background: #fff;
color: #b91c1c;
border-color: #fecaca;
}
2026-09-13 20:11:48 +03:30
.desk-table {
display: none;
width: 100%;
border-collapse: collapse;
}
.desk-table th,
.desk-table td {
padding: 0.85rem 0.9rem;
text-align: start;
border-bottom: 1px solid #f1f5f9;
vertical-align: middle;
}
.desk-table th {
color: #94a3b8;
font-size: 0.75rem;
font-weight: 700;
}
.desk-table td {
font-size: 0.88rem;
color: #334155;
}
.desk-table td strong {
display: block;
color: #0f172a;
}
.desk-table tr.off {
opacity: 0.62;
}
.desk-table .ops {
2026-09-15 14:10:52 +03:30
display: table-cell;
width: 1%;
2026-09-13 20:11:48 +03:30
white-space: nowrap;
2026-09-15 14:10:52 +03:30
vertical-align: middle;
}
.ops-row {
display: inline-flex;
align-items: center;
gap: 0.35rem;
2026-09-13 20:11:48 +03:30
}
.icon-btn {
2026-09-15 14:10:52 +03:30
display: inline-grid;
place-items: center;
2026-09-15 14:10:52 +03:30
flex: 0 0 2.75rem;
width: 2.75rem;
height: 2.75rem;
padding: 0;
2026-09-15 14:10:52 +03:30
border: 0;
border-radius: 8px;
background: transparent;
color: #64748b;
cursor: pointer;
}
.icon-btn:hover {
2026-09-15 14:10:52 +03:30
background: transparent;
color: #0f172a;
}
2026-09-15 14:10:52 +03:30
.icon-btn.edit {
color: #2563eb;
}
.icon-btn.edit:hover {
color: #1d4ed8;
}
.icon-btn.danger {
2026-09-15 14:10:52 +03:30
color: #dc2626;
}
.icon-btn.danger:hover {
2026-09-15 14:10:52 +03:30
color: #b91c1c;
background: transparent;
}
.icon-btn.status {
color: #dc2626;
}
.icon-btn.status.on {
color: #16a34a;
}
2026-09-13 20:11:48 +03:30
.mobile-cards {
2026-09-13 19:11:47 +03:30
display: grid;
2026-09-13 20:11:48 +03:30
gap: 0.75rem;
}
.card {
display: grid;
gap: 0.4rem;
padding: 0.95rem 1rem;
border: 1px solid #eef2f7;
border-radius: 16px;
background: #f8fafc;
2026-09-13 19:11:47 +03:30
}
.card.off {
opacity: 0.7;
}
.card header {
display: flex;
2026-09-13 20:11:48 +03:30
align-items: flex-start;
2026-09-13 19:11:47 +03:30
justify-content: space-between;
gap: 0.75rem;
}
.card p {
margin: 0;
}
2026-09-13 20:11:48 +03:30
.card .preview {
2026-09-13 19:11:47 +03:30
color: #334155;
font-size: 0.88rem;
}
2026-09-13 20:11:48 +03:30
.toggle {
position: relative;
width: 42px;
height: 24px;
2026-09-13 19:11:47 +03:30
border: 0;
border-radius: 999px;
2026-09-13 20:11:48 +03:30
background: #cbd5e1;
2026-09-13 19:11:47 +03:30
cursor: pointer;
2026-09-13 20:11:48 +03:30
flex-shrink: 0;
padding: 0;
2026-09-13 19:11:47 +03:30
}
2026-09-13 20:11:48 +03:30
.toggle::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.16);
transition: left 0.16s ease;
}
.toggle.on {
background: #14b8a6;
}
.toggle.on::after {
left: 20px;
2026-09-13 19:11:47 +03:30
}
.error {
margin: 0;
color: #b91c1c;
}
.ok {
margin: 0;
color: #0f766e;
font-weight: 600;
}
2026-09-13 20:11:48 +03:30
2026-09-14 16:49:43 +03:30
.desk {
display: grid;
gap: 1rem;
min-width: 0;
}
2026-09-13 20:11:48 +03:30
2026-09-14 16:49:43 +03:30
.btn.wide {
width: 100%;
}
.m-home,
.m-wiz {
display: grid;
gap: 1rem;
min-width: 0;
}
.m-title {
text-align: start;
}
.m-title h1,
.m-wiz-head h1 {
margin: 0 0 0.35rem;
font-size: 1.45rem;
font-weight: 800;
}
.m-title p,
.m-hero p,
.m-wiz-head p {
margin: 0;
color: #94a3b8;
font-size: 0.86rem;
line-height: 1.75;
}
.m-hero {
text-align: center;
padding: 0.15rem 0 0.1rem;
}
.m-hero .hero-art {
width: min(100%, 18rem);
margin: 0.15rem auto 1rem;
}
.m-hero h2 {
margin: 0 0 0.35rem;
font-size: 1.08rem;
font-weight: 800;
}
.m-hero .sub {
margin-bottom: 1.05rem;
}
.m-hero .btn {
background: #0f766e;
box-shadow: 0 10px 22px rgba(15, 118, 110, 0.22);
}
.m-tools {
display: grid;
gap: 0.75rem;
}
.m-search {
display: flex;
align-items: center;
gap: 0.5rem;
min-height: var(--control-h);
padding: 0 0.85rem;
border: 1px solid #e2e8f0;
border-radius: 999px;
background: #fff;
color: #94a3b8;
}
.m-search input {
border: 0;
min-height: 0;
padding: 0;
border-radius: 0;
}
.m-tabs {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.35rem;
padding: 0.25rem;
border-radius: 999px;
background: #eef2f6;
}
.m-tabs button {
min-height: 2.2rem;
border: 0;
border-radius: 999px;
background: transparent;
color: #64748b;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.m-tabs button.on {
background: #fff;
color: #0f172a;
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.06);
}
.m-cards {
display: grid;
gap: 0.75rem;
}
.m-card {
padding: 0.95rem 1rem 0.85rem;
border-radius: 20px;
background: #fff;
box-shadow: 0 0 0 1px #eef2f7;
}
.m-card.off {
opacity: 0.78;
}
.m-card header {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: start;
gap: 0.65rem;
}
.m-copy strong {
display: block;
color: #0f172a;
}
.m-copy p {
margin: 0.25rem 0 0;
color: #94a3b8;
font-size: 0.8rem;
}
.m-menu {
position: relative;
}
.m-menu :deep(button),
2026-09-14 16:49:43 +03:30
.m-back {
width: 2rem;
height: 2rem;
border: 0;
background: transparent;
color: #64748b;
display: grid;
place-items: center;
cursor: pointer;
padding: 0;
}
.m-card footer {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 0.85rem;
color: #94a3b8;
font-size: 0.78rem;
}
.m-status {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.m-status i {
width: 0.5rem;
height: 0.5rem;
border-radius: 999px;
background: #cbd5e1;
}
.m-status.on {
color: #0f766e;
}
.m-status.on i {
background: #14b8a6;
}
.m-wiz-head {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.65rem;
}
.m-wiz-head h1 {
font-size: 1.15rem;
}
.m-wiz-head > div {
text-align: start;
}
.m-back {
transform: scaleX(-1);
}
.m-schedule {
display: grid;
gap: 0.7rem;
}
.m-opt {
position: relative;
display: flex;
align-items: flex-start;
gap: 0.65rem;
padding: 0.85rem 0.9rem;
border: 1px solid #e8eef3;
border-radius: 16px;
background: #fff;
cursor: pointer;
}
.m-opt.tight {
align-items: center;
border: 0;
padding: 0.85rem 0.9rem 0.55rem;
}
.m-opt input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.m-opt i {
width: 1.15rem;
height: 1.15rem;
margin-top: 0.15rem;
border: 2px solid #cbd5e1;
border-radius: 999px;
background: #fff;
flex-shrink: 0;
position: relative;
}
.m-opt.tight i {
margin-top: 0;
}
.m-opt.on i,
.m-opt-box.on .m-opt i {
border-color: #14b8a6;
}
.m-opt.on i::after,
.m-opt-box.on .m-opt i::after {
content: '';
position: absolute;
inset: 3px;
border-radius: 999px;
background: #14b8a6;
}
.m-opt span {
display: grid;
gap: 0.12rem;
}
.m-opt strong {
color: #0f172a;
font-size: 0.92rem;
}
.m-opt small {
color: #94a3b8;
font-size: 0.78rem;
}
.m-opt.on,
.m-opt-box.on {
border-color: #99f6e4;
background: #f0fdfa;
}
.m-opt-box {
display: grid;
border: 1px solid #e8eef3;
border-radius: 16px;
background: #fff;
}
.m-opt-box .m-delay {
padding: 0 0.9rem 0.85rem;
}
.m-repeat {
display: grid;
gap: 0.75rem;
margin-top: 0.25rem;
padding-top: 0.85rem;
border-top: 1px solid #eef2f6;
}
.m-delay {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-start;
gap: 0.45rem;
color: #64748b;
font-size: 0.86rem;
}
.m-delay.off {
opacity: 0.45;
}
.m-delay em {
font-style: normal;
white-space: nowrap;
}
.m-delay input,
.m-delay select {
width: auto;
min-height: 2.15rem;
}
.m-delay input[type='number'] {
width: 3.4rem;
text-align: center;
padding: 0;
}
.m-delay select {
width: 5.8rem;
}
.m-steps {
list-style: none;
margin: 0.2rem 0 0.3rem;
padding: 0.4rem 0 0;
display: grid;
grid-template-columns: repeat(4, 1fr);
position: relative;
}
.m-steps::before {
content: '';
position: absolute;
top: 1.25rem;
inset-inline: 12%;
height: 2px;
background: #e2e8f0;
}
.m-steps li {
display: grid;
justify-items: center;
gap: 0.35rem;
position: relative;
z-index: 1;
color: #94a3b8;
font-size: 0.72rem;
}
.m-steps li b {
width: 1.85rem;
height: 1.85rem;
border-radius: 999px;
display: grid;
place-items: center;
background: #e2e8f0;
color: #64748b;
font-size: 0.78rem;
}
.m-steps li.on {
color: #0f766e;
font-weight: 700;
}
.m-steps li.on b,
.m-steps li.done b {
background: #14b8a6;
color: #fff;
}
.m-preview {
display: grid;
gap: 0.85rem;
}
.m-bubble {
padding: 0.95rem 1rem 0.7rem;
border-radius: 18px;
background: #e8f7f8;
color: #334155;
}
.m-bubble p {
margin: 0;
white-space: pre-wrap;
line-height: 1.9;
text-align: start;
}
.m-bubble time {
display: block;
margin-top: 0.7rem;
color: #94a3b8;
font-size: 0.75rem;
text-align: end;
}
.m-sms-note {
padding: 0.85rem 0.95rem;
border-radius: 16px;
background: #eef8fb;
color: #64748b;
}
.m-sms-note strong {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 0.4rem;
margin-bottom: 0.35rem;
color: #0f172a;
font-size: 0.86rem;
}
.m-sms-note strong i {
width: 1.05rem;
height: 1.05rem;
border-radius: 999px;
display: grid;
place-items: center;
background: #bae6fd;
color: #0369a1;
font-size: 0.7rem;
font-style: normal;
font-weight: 800;
flex-shrink: 0;
}
.m-sms-note p {
margin: 0;
font-size: 0.8rem;
line-height: 1.75;
text-align: start;
}
.m-wiz-foot {
position: sticky;
bottom: var(--mobile-tabbar-h);
2026-09-14 16:49:43 +03:30
display: grid;
grid-template-columns: 1.25fr 1fr;
gap: 0.65rem;
padding: 0.85rem 0 0.35rem;
background: linear-gradient(180deg, transparent, #fff 18%);
}
.m-wiz-foot .btn.ghost {
color: #334155;
border-color: #e2e8f0;
box-shadow: none;
}
@media (min-width: 980px) {
.composer {
grid-template-columns: minmax(0, 1fr) 18rem;
}
2026-09-13 20:11:48 +03:30
.desk-table {
display: table;
}
.mobile-cards {
display: none;
}
}
2026-09-13 19:11:47 +03:30
</style>