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

2503 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.
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useEscClose } from '@/lib/modal-stack';
import { matchesSearch } from '@/lib/search';
import TableColumnsMenu from '@/components/TableColumnsMenu.vue';
import axios from 'axios';
import { api } from '@/lib/api';
import UiIcon from '@/components/UiIcon.vue';
import { UiDropdownMenu } from '@/components/ui';
import { formatClockFa, formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import type {
CreateMessageAutomationRuleRequest,
MessageAutomationChannel,
MessageAutomationDelayUnit,
MessageAutomationRuleDto,
MessageAutomationTarget,
MessageAutomationTrigger,
UpdateMessageAutomationRuleRequest,
} from '@mento/shared';
import {
MESSAGE_AUTOMATION_CHANNEL_LABELS,
MESSAGE_AUTOMATION_FIELDS,
MESSAGE_AUTOMATION_SMS_SEGMENT,
MESSAGE_AUTOMATION_TARGET_LABELS,
MESSAGE_AUTOMATION_TRIGGER_LABELS,
MESSAGE_AUTOMATION_TRIGGERS,
MESSAGE_AUTOMATION_UNIT_LABELS,
} from '@mento/shared';
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 = ['🙏', '😊', '❤️', '👍', '🎉', '🌸', '✨', '👋', '💐', '🌟', '😍', '🤝'];
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('');
const description = ref('');
const trigger = ref<MessageAutomationTrigger>('invoice_issued');
const delayLater = ref(false);
const startDelayValue = ref(1);
const startDelayUnit = ref<MessageAutomationDelayUnit>('hours');
const repeatCount = ref(1);
const intervalValue = ref(2);
const intervalUnit = ref<MessageAutomationDelayUnit>('days');
const notifyTarget = ref<MessageAutomationTarget>('person');
const channel = ref<MessageAutomationChannel>('sms');
const body = ref('');
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 matchesSearch(q, [row.name, row.description, row.body]);
});
});
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),
);
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;
});
function resetForm() {
editingId.value = null;
name.value = '';
description.value = '';
trigger.value = 'invoice_issued';
delayLater.value = false;
startDelayValue.value = 1;
startDelayUnit.value = 'hours';
repeatCount.value = 1;
intervalValue.value = 2;
intervalUnit.value = 'days';
notifyTarget.value = 'person';
channel.value = 'sms';
body.value = '';
fieldsOpen.value = false;
emojiOpen.value = false;
fieldsSheet.value = false;
menuId.value = null;
ok.value = '';
}
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;
}
function startEdit(row: MessageAutomationRuleDto) {
editingId.value = row.id;
name.value = row.name;
description.value = row.description ?? '';
trigger.value = row.trigger;
delayLater.value = row.startDelayValue > 0;
startDelayValue.value = Math.max(1, row.startDelayValue || 1);
startDelayUnit.value =
row.startDelayUnit === 'minutes' ? 'hours' : row.startDelayUnit;
repeatCount.value = row.repeatCount;
intervalValue.value = row.intervalValue;
intervalUnit.value = row.intervalUnit;
notifyTarget.value = row.notifyTarget;
channel.value = row.channel;
body.value = row.body;
ok.value = '';
}
function scheduleLabel(row: MessageAutomationRuleDto) {
const start =
row.startDelayValue === 0
? 'بلافاصله'
: `${toFaDigits(row.startDelayValue)} ${MESSAGE_AUTOMATION_UNIT_LABELS[row.startDelayUnit]} بعد`;
if (row.repeatCount <= 1) return start;
return `${start} · ${toFaDigits(row.repeatCount)} بار، هر ${toFaDigits(row.intervalValue)} ${MESSAGE_AUTOMATION_UNIT_LABELS[row.intervalUnit]}`;
}
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 {
const named = name.value.trim();
return {
name: named,
isActive: true,
trigger: trigger.value,
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)),
intervalValue: Math.max(1, intervalValue.value || 1),
intervalUnit: intervalUnit.value,
notifyTarget: notifyTarget.value,
channel: channel.value,
title: named,
description: description.value.trim() || null,
body: body.value.trim(),
};
}
async function save() {
if (!name.value.trim() || !body.value.trim()) {
error.value = 'عنوان قانون و متن پیام را وارد کنید.';
return;
}
saving.value = true;
error.value = '';
ok.value = '';
try {
if (editingId.value) {
const { isActive: _ignore, ...rest } = payload();
const bodyDto: UpdateMessageAutomationRuleRequest = rest;
await api.patch(`/message-automations/${editingId.value}`, bodyDto);
ok.value = 'قانون به‌روز شد.';
} else {
await api.post('/message-automations', payload());
ok.value = 'قانون ساخته شد.';
}
resetForm();
if (isMobile.value) {
mobileStep.value = 1;
mobileView.value = 'list';
}
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 = 'تغییر وضعیت ناموفق بود.';
}
}
const pendingDelete = ref<MessageAutomationRuleDto | null>(null);
function askRemove(row: MessageAutomationRuleDto) {
menuId.value = null;
pendingDelete.value = row;
}
useEscClose(() => fieldsSheet.value, () => { fieldsSheet.value = false; });
useEscClose(() => pendingDelete.value, () => cancelRemove());
function cancelRemove() {
pendingDelete.value = null;
}
async function confirmRemove() {
const row = pendingDelete.value;
if (!row) return;
pendingDelete.value = null;
try {
await api.delete(`/message-automations/${row.id}`);
if (editingId.value === row.id) resetForm();
await load();
} catch {
error.value = 'حذف قانون ناموفق بود.';
}
}
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);
});
</script>
<template>
<section class="page">
<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)"
>
<template #trigger="{ toggle }">
<button
type="button"
aria-label="عملیات"
@click.stop="toggle"
>
<UiIcon name="dots-v" :size="18" />
</button>
</template>
<button type="button" @click="openMobileEdit(row)">ویرایش</button>
<button type="button" class="danger" @click="askRemove(row)">حذف</button>
</UiDropdownMenu>
</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">
<div class="head-copy">
<h1>اتوماسیون پیامک‌ها</h1>
<p class="lead">
پیامک و ناتیف بعد از رویداد، با تأخیر یا همان لحظه.
</p>
</div>
<div class="head-actions">
<button class="btn ghost" type="button" :disabled="loading" @click="load">
بروزرسانی
</button>
</div>
</header>
<p v-show="!isMobile && error" class="error">{{ error }}</p>
<p v-show="!isMobile && ok" class="ok">{{ ok }}</p>
<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>
</div>
<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>
</div>
</form>
<div v-if="fieldsSheet" class="sheet-layer">
<div class="dim" aria-hidden="true" />
<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">
<thead>
<tr>
<th>عنوان</th>
<th>رویداد</th>
<th>زمان‌بندی</th>
<th>مخاطب</th>
<th>کانال</th>
<th>عملیات</th>
</tr>
</thead>
<tbody>
<tr v-for="row in items" :key="row.id" :class="{ off: !row.isActive }">
<td>
<strong>{{ row.name }}</strong>
<span class="preview">{{ row.description || row.body }}</span>
</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">
<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>
</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>
<p class="preview">{{ row.description || row.body }}</p>
<div class="card-actions">
<button class="btn ghost" type="button" @click="startEdit(row)">ویرایش</button>
<button class="btn danger" type="button" @click="askRemove(row)">حذف</button>
</div>
</article>
</div>
<p v-if="!loading && !items.length" class="empty">هنوز قانون پیامی نیست.</p>
</div>
<div v-if="pendingDelete" class="sheet-layer">
<div class="dim" aria-hidden="true" />
<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>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
min-width: 0;
width: 100%;
}
.page-head {
display: flex;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
align-items: flex-start;
}
.head-copy {
min-width: 0;
}
.head-actions,
.form-actions,
.card-actions,
.ops {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
h1 {
margin: 0 0 0.3rem;
font-size: clamp(1.45rem, 2.5vw, 1.85rem);
font-weight: 800;
}
.lead,
.muted {
margin: 0;
color: #64748b;
}
.composer {
display: grid;
gap: 1rem;
align-items: start;
}
.stack {
display: grid;
gap: 0.85rem;
min-width: 0;
}
.block,
.intro,
.list-wrap {
background: #fff;
border: 1px solid #e8eef3;
border-radius: 22px;
min-width: 0;
}
.block {
display: grid;
gap: 0.85rem;
padding: 1.15rem 1.2rem 1.2rem;
}
.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;
display: grid;
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;
}
.field {
display: grid;
gap: 0.35rem;
color: #64748b;
font-size: 0.86rem;
}
.field em {
color: #ef4444;
font-style: normal;
}
input,
select,
textarea {
border: 1px solid #e2e8f0;
border-radius: 12px;
min-height: var(--control-h);
padding: 0 0.85rem;
font: inherit;
background: #fff;
width: 100%;
box-sizing: border-box;
color: #0f172a;
}
textarea {
min-height: 4.4rem;
padding: 0.65rem 0.85rem;
resize: vertical;
}
.choice {
position: relative;
display: flex;
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);
}
.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;
}
.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;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
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;
box-shadow: 0 8px 18px rgba(20, 184, 166, 0.24);
}
.btn.ghost {
background: #fff;
color: #0f766e;
border-color: #99f6e4;
}
.btn.danger {
background: #fff;
color: #b91c1c;
border-color: #fecaca;
}
.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 {
display: table-cell;
width: 1%;
white-space: nowrap;
vertical-align: middle;
}
.ops-row {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.icon-btn {
display: inline-grid;
place-items: center;
flex: 0 0 2.75rem;
width: 2.75rem;
height: 2.75rem;
padding: 0;
border: 0;
border-radius: 8px;
background: transparent;
color: #64748b;
cursor: pointer;
}
.icon-btn:hover {
background: transparent;
color: #0f172a;
}
.icon-btn.edit {
color: #2563eb;
}
.icon-btn.edit:hover {
color: #1d4ed8;
}
.icon-btn.danger {
color: #dc2626;
}
.icon-btn.danger:hover {
color: #b91c1c;
background: transparent;
}
.icon-btn.status {
color: #dc2626;
}
.icon-btn.status.on {
color: #16a34a;
}
.mobile-cards {
display: grid;
gap: 0.75rem;
}
.card {
display: grid;
gap: 0.4rem;
padding: 0.95rem 1rem;
border: 1px solid #eef2f7;
border-radius: 16px;
background: #f8fafc;
}
.card.off {
opacity: 0.7;
}
.card header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.card p {
margin: 0;
}
.card .preview {
color: #334155;
font-size: 0.88rem;
}
.toggle {
position: relative;
width: 42px;
height: 24px;
border: 0;
border-radius: 999px;
background: #cbd5e1;
cursor: pointer;
flex-shrink: 0;
padding: 0;
}
.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;
}
.error {
margin: 0;
color: #b91c1c;
}
.ok {
margin: 0;
color: #0f766e;
font-weight: 600;
}
.desk {
display: grid;
gap: 1rem;
min-width: 0;
}
.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),
.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);
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;
}
.desk-table {
display: table;
}
.mobile-cards {
display: none;
}
}
</style>