feat: migrate CRM screens to Ant Design Vue and expand home dashboard

Preserve the local Ant UI work across views/components and add the new home dashboard chart panels before merging latest main.
This commit is contained in:
2026-11-03 16:51:51 +03:30
parent 631a3267d1
commit 26792f9a37
80 changed files with 19982 additions and 24020 deletions
+373 -480
View File
@@ -16,17 +16,48 @@ import {
MESSAGE_AUTOMATION_FIELDS,
SMS_CAMPAIGN_CHUNK_SIZE,
} from '@mento/shared';
import {
UiButton,
UiCard,
UiEmptyState,
UiField,
UiInput,
UiPageHeader,
UiTextarea,
} from '@/components/ui';
import UiIcon from '@/components/UiIcon.vue';
import { formatJalaliNumeric, toFaDigits } from '@/lib/jalali';
import {
Alert as AAlert,
Button as AButton,
Card as ACard,
Checkbox as ACheckbox,
Col as ACol,
Divider as ADivider,
Dropdown as ADropdown,
Empty as AEmpty,
Flex as AFlex,
Form as AForm,
FormItem as AFormItem,
Input as AInput,
List as AList,
ListItem as AListItem,
Menu as AMenu,
MenuItem as AMenuItem,
Modal,
Pagination as APagination,
Row as ARow,
Select as ASelect,
Space as ASpace,
Spin as ASpin,
Switch as ASwitch,
Table as ATable,
TabPane as ATabPane,
Tabs as ATabs,
Tag as ATag,
Textarea as ATextarea,
Typography,
} from 'ant-design-vue';
import type { TableColumnsType, TableProps } from 'ant-design-vue';
import {
ReloadOutlined,
SendOutlined,
StopOutlined,
BarChartOutlined,
} from '@ant-design/icons-vue';
const { Title: ATypographyTitle, Text: ATypographyText } = Typography;
const AListItemMeta = AList.Item.Meta;
type AudienceTab = 'people' | 'leads';
@@ -65,7 +96,40 @@ const statusLabel: Record<string, string> = {
cancelled: 'لغو شده',
};
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / pageSize)));
const statusColor: Record<string, string> = {
draft: 'default',
queued: 'processing',
sending: 'warning',
done: 'success',
cancelled: 'error',
};
const stageOptions = computed(() => [
{ value: '', label: 'همه' },
...stages.value.map((s) => ({ value: s.id, label: s.name })),
]);
const pageIds = computed(() =>
audience.value === 'people'
? people.value.map((p) => p.id)
: leads.value.map((l) => l.id),
);
const tableData = computed(() =>
audience.value === 'people' ? people.value : leads.value,
);
const audienceColumns = computed<TableColumnsType>(() => [
{ title: 'نام', key: 'name' },
]);
const rowSelection = computed<TableProps['rowSelection']>(() => ({
selectedRowKeys: selectedIds.value.filter((id) => pageIds.value.includes(id)),
onChange: (keys) => {
const kept = selectedIds.value.filter((id) => !pageIds.value.includes(id));
selectedIds.value = [...kept, ...keys.map(String)];
},
}));
const filterParams = computed(() => {
const base: Record<string, string | number | boolean> = {
@@ -163,17 +227,31 @@ watch(page, () => {
});
});
watch(audience, (next, prev) => {
function onAudienceChange(key: string | number) {
const next = String(key) as AudienceTab;
const prev = audience.value;
if (next === prev) return;
if (selectedIds.value.length) {
const okSwitch = window.confirm(
'با تعویض تب، انتخاب‌های فعلی پاک می‌شود. ادامه می‌دهید؟',
);
if (!okSwitch) {
audience.value = prev;
return;
}
Modal.confirm({
title: 'تعویض تب مخاطب',
content: 'با تعویض تب، انتخاب‌های فعلی پاک می‌شود. ادامه می‌دهید؟',
okText: 'ادامه',
cancelText: 'انصراف',
onOk: () => {
audience.value = next;
selectedIds.value = [];
q.value = '';
province.value = '';
city.value = '';
region.value = '';
stageId.value = '';
page.value = 1;
void loadList();
},
});
return;
}
audience.value = next;
selectedIds.value = [];
q.value = '';
province.value = '';
@@ -182,21 +260,10 @@ watch(audience, (next, prev) => {
stageId.value = '';
page.value = 1;
void loadList();
});
function toggleId(id: string) {
if (selectedIds.value.includes(id)) {
selectedIds.value = selectedIds.value.filter((x) => x !== id);
} else {
selectedIds.value = [...selectedIds.value, id];
}
}
function selectVisible() {
const ids =
audience.value === 'people'
? people.value.map((p) => p.id)
: leads.value.map((l) => l.id);
const ids = pageIds.value;
selectedIds.value = [...new Set([...selectedIds.value, ...ids])];
}
@@ -211,12 +278,16 @@ async function selectAllFiltered() {
error.value = 'نتیجه‌ای با این فیلتر پیدا نشد.';
return;
}
const confirmed = window.confirm(
`${toFaDigits(data.total)} مخاطب مطابق فیلتر انتخاب شود؟`,
);
if (!confirmed) return;
selectedIds.value = data.ids;
ok.value = `${toFaDigits(data.ids.length)} مخاطب انتخاب شد.`;
Modal.confirm({
title: 'انتخاب همهٔ نتایج',
content: `${toFaDigits(data.total)} مخاطب مطابق فیلتر انتخاب شود؟`,
okText: 'انتخاب',
cancelText: 'انصراف',
onOk: () => {
selectedIds.value = data.ids;
ok.value = `${toFaDigits(data.ids.length)} مخاطب انتخاب شد.`;
},
});
} catch (e) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
@@ -316,462 +387,284 @@ async function cancel(id: string) {
}
}
function goReport(id: string) {
void router.push({ name: 'sms-campaign-report', params: { id } });
}
function personLine(p: PersonDto) {
const bits: string[] = [p.mobile || 'بدون موبایل'];
if (p.company) {
bits.push(p.company.name);
const place = [p.company.city, p.company.province].filter(Boolean).join('، ');
if (place) bits.push(place);
}
return bits.join(' · ');
}
function leadLine(l: LeadDto) {
const bits: string[] = [l.mobile || 'بدون موبایل'];
const place = [l.city, l.region].filter(Boolean).join('، ');
if (place) bits.push(place);
bits.push(l.stageName);
return bits.join(' · ');
}
onMounted(() => {
void load();
});
</script>
<template>
<div class="page">
<UiPageHeader
title="پیامک گروهی"
description="ارسال انبوه پیامک به اشخاص یا لیدهای فیلترشده"
>
<template #actions>
<UiButton variant="secondary" :disabled="loading" @click="load">
<UiIcon name="refresh" />
بروزرسانی
</UiButton>
</template>
</UiPageHeader>
<a-flex vertical :gap="16">
<a-flex justify="space-between" align="flex-start" wrap="wrap" :gap="12">
<a-flex vertical :gap="4">
<a-typography-title :level="3" :style="{ margin: 0 }">پیامک گروهی</a-typography-title>
<a-typography-text type="secondary">
ارسال انبوه پیامک به اشخاص یا لیدهای فیلترشده
</a-typography-text>
</a-flex>
<a-button :disabled="loading" @click="load">
<template #icon><ReloadOutlined /></template>
بروزرسانی
</a-button>
</a-flex>
<p v-if="error" class="error">{{ error }}</p>
<p v-if="ok" class="ok">{{ ok }}</p>
<a-alert v-if="error" type="error" show-icon :message="error" closable @close="error = ''" />
<a-alert v-if="ok" type="success" show-icon :message="ok" closable @close="ok = ''" />
<UiCard title="کمپین جدید">
<div class="form-grid">
<UiField label="نام کمپین">
<UiInput v-model="name" placeholder="مثلاً اطلاع‌رسانی جشنواره" />
</UiField>
<label class="check">
<input v-model="queueNow" type="checkbox" />
ارسال فوری پس از ذخیره
</label>
</div>
<a-spin :spinning="loading">
<a-card :bordered="false" title="کمپین جدید">
<a-form layout="vertical" @finish="createCampaign">
<a-divider orientation="right">متن و تنظیمات</a-divider>
<a-row :gutter="[16, 0]">
<a-col :xs="24" :md="12">
<a-form-item label="نام کمپین" required>
<a-input
v-model:value="name"
placeholder="مثلاً اطلاع‌رسانی جشنواره"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :md="12">
<a-form-item label="ارسال">
<a-space :size="8">
<a-switch v-model:checked="queueNow" />
<a-typography-text>ارسال فوری پس از ذخیره</a-typography-text>
</a-space>
</a-form-item>
</a-col>
<a-col :span="24">
<a-form-item label="متن پیامک" required>
<template #extra>
<a-dropdown v-model:open="fieldsOpen" :trigger="['click']">
<a-button type="link">{ } فیلدهای پویا</a-button>
<template #overlay>
<a-menu>
<a-menu-item
v-for="item in MESSAGE_AUTOMATION_FIELDS"
:key="item.token"
@click="insertField(item.token)"
>
{{ item.token }} — {{ item.label }}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</template>
<a-textarea
v-model:value="body"
:rows="4"
placeholder="متن پیامک را بنویسید… از فیلدهای پویا استفاده کنید. لینک http(s) برای هر مخاطب یکتا می‌شود."
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<div class="body-block">
<div class="body-tools">
<span class="muted">متن پیامک</span>
<div class="pop-wrap">
<button
class="pill"
type="button"
@click="fieldsOpen = !fieldsOpen"
<a-divider orientation="right">مخاطبان</a-divider>
<a-tabs :active-key="audience" @change="onAudienceChange">
<a-tab-pane key="people" tab="اشخاص" />
<a-tab-pane key="leads" tab="لیدها" />
</a-tabs>
<a-row :gutter="[16, 0]">
<a-col :xs="24" :sm="12" :md="8">
<a-form-item label="جستجو">
<a-input
v-model:value="q"
allow-clear
placeholder="نام یا موبایل…"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col v-if="audience === 'people'" :xs="24" :sm="12" :md="8">
<a-form-item label="استان">
<a-input
v-model:value="province"
allow-clear
placeholder="مثلاً اصفهان"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8">
<a-form-item label="شهر">
<a-input
v-model:value="city"
allow-clear
placeholder="شهر…"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col v-if="audience === 'leads'" :xs="24" :sm="12" :md="8">
<a-form-item label="منطقه / استان">
<a-input
v-model:value="region"
allow-clear
placeholder="منطقه یا استان…"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col v-if="audience === 'leads'" :xs="24" :sm="12" :md="8">
<a-form-item label="مرحله">
<a-select
v-model:value="stageId"
:options="stageOptions"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :xs="24" :sm="12" :md="8">
<a-form-item label="موبایل">
<a-space :size="8">
<a-checkbox v-model:checked="hasMobile" />
<a-typography-text>فقط با موبایل</a-typography-text>
</a-space>
</a-form-item>
</a-col>
</a-row>
<a-flex justify="space-between" align="center" wrap="wrap" :gap="8" :style="{ marginBottom: '12px' }">
<a-typography-text type="secondary">
انتخاب‌شده: {{ toFaDigits(selectedIds.length) }} نفر · نتایج فیلتر:
{{ toFaDigits(total) }}
</a-typography-text>
<a-space wrap :size="8">
<a-button @click="selectVisible">انتخاب صفحه</a-button>
<a-button @click="selectAllFiltered">انتخاب همهٔ نتایج فیلتر</a-button>
</a-space>
</a-flex>
<a-spin :spinning="listLoading">
<a-table
:columns="audienceColumns"
:data-source="tableData"
:row-selection="rowSelection"
:pagination="false"
row-key="id"
:locale="{ emptyText: 'موردی پیدا نشد.' }"
:scroll="{ y: 360 }"
>
<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>
<UiTextarea
v-model="body"
:rows="4"
placeholder="متن پیامک را بنویسید… از فیلدهای پویا استفاده کنید. لینک http(s) برای هر مخاطب یکتا می‌شود."
/>
</div>
<div class="tabs" role="tablist">
<button
type="button"
role="tab"
:class="{ on: audience === 'people' }"
@click="audience = 'people'"
>
اشخاص
</button>
<button
type="button"
role="tab"
:class="{ on: audience === 'leads' }"
@click="audience = 'leads'"
>
لیدها
</button>
</div>
<div class="filters">
<UiField label="جستجو">
<UiInput v-model="q" placeholder="نام یا موبایل…" />
</UiField>
<UiField v-if="audience === 'people'" label="استان">
<UiInput v-model="province" placeholder="مثلاً اصفهان" />
</UiField>
<UiField :label="audience === 'people' ? 'شهر' : 'شهر'">
<UiInput v-model="city" placeholder="شهر…" />
</UiField>
<UiField v-if="audience === 'leads'" label="منطقه / استان">
<UiInput v-model="region" placeholder="منطقه یا استان…" />
</UiField>
<UiField v-if="audience === 'leads'" label="مرحله">
<select v-model="stageId" class="select">
<option value="">همه</option>
<option v-for="s in stages" :key="s.id" :value="s.id">
{{ s.name }}
</option>
</select>
</UiField>
<label class="check filter-check">
<input v-model="hasMobile" type="checkbox" />
فقط با موبایل
</label>
</div>
<div class="people-head">
<p class="muted">
انتخاب‌شده: {{ toFaDigits(selectedIds.length) }} نفر · نتایج فیلتر:
{{ toFaDigits(total) }}
</p>
<div class="head-actions">
<UiButton variant="secondary" @click="selectVisible">
انتخاب صفحه
</UiButton>
<UiButton variant="secondary" @click="selectAllFiltered">
انتخاب همهٔ نتایج فیلتر
</UiButton>
</div>
</div>
<p v-if="listLoading" class="muted">در حال بارگذاری…</p>
<div class="people-list">
<template v-if="audience === 'people'">
<label v-for="p in people" :key="p.id" class="person">
<input
type="checkbox"
:checked="selectedIds.includes(p.id)"
@change="toggleId(p.id)"
/>
<span>
<strong>{{ p.fullName }}</strong>
<small>
{{ p.mobile || 'بدون موبایل' }}
<template v-if="p.company">
· {{ p.company.name }}
<template v-if="p.company.city || p.company.province">
·
{{
[p.company.city, p.company.province].filter(Boolean).join('، ')
}}
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'name'">
<template v-if="audience === 'people'">
<a-typography-text strong>
{{ (record as PersonDto).fullName }}
</a-typography-text>
<div>
<a-typography-text type="secondary">
{{ personLine(record as PersonDto) }}
</a-typography-text>
</div>
</template>
<template v-else>
<a-typography-text strong>
{{ (record as LeadDto).contactName || (record as LeadDto).title }}
</a-typography-text>
<div>
<a-typography-text type="secondary">
{{ leadLine(record as LeadDto) }}
</a-typography-text>
</div>
</template>
</template>
</small>
</span>
</label>
</template>
<template v-else>
<label v-for="l in leads" :key="l.id" class="person">
<input
type="checkbox"
:checked="selectedIds.includes(l.id)"
@change="toggleId(l.id)"
</template>
</a-table>
</a-spin>
<a-flex justify="center" :style="{ marginTop: '12px', marginBottom: '16px' }">
<a-pagination
v-model:current="page"
:total="total"
:page-size="pageSize"
:show-size-changer="false"
/>
<span>
<strong>{{ l.contactName || l.title }}</strong>
<small>
{{ l.mobile || 'بدون موبایل' }}
<template v-if="l.city || l.region">
· {{ [l.city, l.region].filter(Boolean).join('، ') }}
</template>
· {{ l.stageName }}
</small>
</span>
</label>
</template>
<p
v-if="!listLoading && !(audience === 'people' ? people.length : leads.length)"
class="muted empty-list"
>
موردی پیدا نشد.
</p>
</div>
</a-flex>
<div class="pager">
<UiButton
variant="secondary"
:disabled="page <= 1"
@click="page -= 1"
>
قبلی
</UiButton>
<span class="muted">
صفحه {{ toFaDigits(page) }} از {{ toFaDigits(pageCount) }}
</span>
<UiButton
variant="secondary"
:disabled="page >= pageCount"
@click="page += 1"
>
بعدی
</UiButton>
</div>
<a-button type="primary" html-type="submit" :loading="saving" :disabled="saving">
ساخت کمپین
</a-button>
</a-form>
</a-card>
<UiButton :loading="saving" @click="createCampaign">ساخت کمپین</UiButton>
</UiCard>
<UiCard title="کمپین‌ها">
<UiEmptyState
v-if="!loading && !campaigns.length"
title="کمپینی نیست"
description="اولین کمپین پیامک گروهی را بسازید."
/>
<div v-else class="campaigns">
<article v-for="c in campaigns" :key="c.id" class="campaign">
<div>
<strong>{{ c.name }}</strong>
<p class="muted">{{ c.body }}</p>
<p class="meta">
{{ statusLabel[c.status] ?? c.status }}
· کل {{ toFaDigits(c.totalCount) }}
· ارسال {{ toFaDigits(c.sentCount) }}
· ناموفق {{ toFaDigits(c.failedCount) }}
· ردشده {{ toFaDigits(c.skippedCount) }}
· {{ formatJalaliNumeric(new Date(c.createdAt)) }}
</p>
</div>
<div class="actions">
<UiButton
variant="secondary"
@click="router.push({ name: 'sms-campaign-report', params: { id: c.id } })"
>
گزارش
</UiButton>
<UiButton
v-if="c.status === 'draft' || c.status === 'cancelled'"
variant="secondary"
@click="queue(c.id)"
>
صف ارسال
</UiButton>
<UiButton
v-if="c.status === 'queued' || c.status === 'sending' || c.status === 'draft'"
variant="secondary"
@click="cancel(c.id)"
>
لغو
</UiButton>
</div>
</article>
</div>
</UiCard>
</div>
<a-card :bordered="false" title="کمپین‌ها" :style="{ marginTop: '16px' }">
<a-empty
v-if="!loading && !campaigns.length"
description="کمپینی نیست. اولین کمپین پیامک گروهی را بسازید."
/>
<a-list v-else :data-source="campaigns" item-layout="horizontal">
<template #renderItem="{ item }">
<a-list-item>
<a-list-item-meta
:title="(item as SmsCampaignDto).name"
:description="(item as SmsCampaignDto).body"
/>
<template #actions>
<a-tag
:color="statusColor[(item as SmsCampaignDto).status] || 'default'"
:bordered="false"
>
{{ statusLabel[(item as SmsCampaignDto).status] ?? (item as SmsCampaignDto).status }}
</a-tag>
<a-typography-text type="secondary">
کل {{ toFaDigits((item as SmsCampaignDto).totalCount) }}
· ارسال {{ toFaDigits((item as SmsCampaignDto).sentCount) }}
· ناموفق {{ toFaDigits((item as SmsCampaignDto).failedCount) }}
· ردشده {{ toFaDigits((item as SmsCampaignDto).skippedCount) }}
· {{ formatJalaliNumeric(new Date((item as SmsCampaignDto).createdAt)) }}
</a-typography-text>
<a-button @click="goReport((item as SmsCampaignDto).id)">
<template #icon><BarChartOutlined /></template>
گزارش
</a-button>
<a-button
v-if="(item as SmsCampaignDto).status === 'draft' || (item as SmsCampaignDto).status === 'cancelled'"
@click="queue((item as SmsCampaignDto).id)"
>
<template #icon><SendOutlined /></template>
صف ارسال
</a-button>
<a-button
v-if="
(item as SmsCampaignDto).status === 'queued' ||
(item as SmsCampaignDto).status === 'sending' ||
(item as SmsCampaignDto).status === 'draft'
"
danger
@click="cancel((item as SmsCampaignDto).id)"
>
<template #icon><StopOutlined /></template>
لغو
</a-button>
</template>
</a-list-item>
</template>
</a-list>
</a-card>
</a-spin>
</a-flex>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
}
.form-grid {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.75rem;
align-items: end;
margin-bottom: 0.75rem;
}
.check {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-weight: 600;
color: #334155;
min-height: var(--control-h);
}
.filter-check {
align-self: end;
margin-bottom: 0.35rem;
}
.body-block {
margin-bottom: 0.75rem;
}
.body-tools {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.35rem;
}
.pop-wrap {
position: relative;
}
.pill {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border: 1px solid #e2e8f0;
background: #fff;
border-radius: 999px;
padding: 0.35rem 0.75rem;
font-weight: 700;
cursor: pointer;
color: #0f172a;
}
.pill:hover {
background: #f8fafc;
}
.pop {
position: absolute;
inset-inline-end: 0;
top: calc(100% + 0.35rem);
z-index: 20;
min-width: 220px;
display: grid;
gap: 0.2rem;
padding: 0.45rem;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
}
.pop button {
display: flex;
justify-content: space-between;
gap: 0.75rem;
border: 0;
background: transparent;
padding: 0.45rem 0.55rem;
border-radius: 8px;
cursor: pointer;
text-align: start;
}
.pop button:hover {
background: #f1f5f9;
}
.pop code {
font-weight: 700;
color: #0f766e;
}
.tabs {
display: flex;
gap: 0.35rem;
margin: 0.5rem 0 0.75rem;
border-bottom: 1px solid #e2e8f0;
}
.tabs button {
border: 0;
background: transparent;
padding: 0.55rem 0.9rem;
font-weight: 700;
color: #64748b;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.tabs button.on {
color: #0f766e;
border-bottom-color: #0f766e;
}
.filters {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 0.65rem;
margin-bottom: 0.5rem;
}
.select {
width: 100%;
min-height: var(--control-h);
border: 1px solid #e2e8f0;
border-radius: var(--radius);
padding: 0 0.65rem;
background: #fff;
}
.people-head {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
margin-top: 0.5rem;
}
.head-actions {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
}
.people-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 0.45rem;
max-height: 360px;
overflow: auto;
margin: 0.75rem 0 0.75rem;
padding: 0.35rem;
border: 1px solid #eef2f7;
border-radius: var(--radius);
}
.person {
display: flex;
gap: 0.45rem;
align-items: flex-start;
padding: 0.45rem;
border-radius: var(--radius);
cursor: pointer;
}
.person:hover {
background: #f8fafc;
}
.person span {
display: grid;
}
.person small {
color: #64748b;
}
.empty-list {
grid-column: 1 / -1;
padding: 0.75rem;
}
.pager {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
margin-bottom: 1rem;
}
.campaigns {
display: grid;
gap: 0.75rem;
}
.campaign {
display: flex;
justify-content: space-between;
gap: 0.75rem;
padding: 0.85rem;
border: 1px solid #eef2f7;
border-radius: var(--radius);
}
.actions {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
align-items: start;
}
.muted,
.meta {
color: #64748b;
margin: 0.25rem 0 0;
font-size: 0.85rem;
}
.error {
color: #b91c1c;
font-weight: 600;
}
.ok {
color: #0f766e;
font-weight: 600;
}
@media (max-width: 800px) {
.form-grid,
.campaign {
grid-template-columns: 1fr;
flex-direction: column;
}
}
</style>