@@ -0,0 +1,24 @@
|
||||
import type { TableInput } from '@core/type/baseTable'
|
||||
import useAxios from '@core/utils/useAxios'
|
||||
import type { Automation, AutomationCatalog, AutomationCondition } from '@/model/automations'
|
||||
|
||||
export const getAutomationsTable = async (params: TableInput) =>
|
||||
await useAxios.post<Array<Automation>>('/admin/automations/table', params)
|
||||
|
||||
export const getAutomationCatalog = async () =>
|
||||
await useAxios.get<AutomationCatalog>('/admin/automations/catalog')
|
||||
|
||||
export const previewAutomationAudience = async (conditions: AutomationCondition[]) =>
|
||||
await useAxios.post<{ count: number }>('/admin/automations/preview', { conditions })
|
||||
|
||||
export const createAutomation = async (payload: Partial<Automation>) =>
|
||||
await useAxios.post<Automation>('/admin/automations', payload)
|
||||
|
||||
export const updateAutomation = async (id: number, payload: Partial<Automation>) =>
|
||||
await useAxios.put(`/admin/automations/${id}`, payload)
|
||||
|
||||
export const toggleAutomationActive = async (id: number) =>
|
||||
await useAxios.put<{ is_active: boolean }>(`/admin/automations/${id}/toggle`)
|
||||
|
||||
export const deleteAutomation = async (id: number) =>
|
||||
await useAxios.delete(`/admin/automations/${id}`)
|
||||
@@ -32,9 +32,9 @@
|
||||
<template #title>
|
||||
<span>{{ val[props.valueName] }}</span>
|
||||
</template>
|
||||
<div class="truncate max-w-40">{{ val[props.valueName] }}</div>
|
||||
<div class="whitespace-normal break-words leading-snug">{{ val[props.valueName] }}</div>
|
||||
</a-tooltip>
|
||||
<div v-else>{{ val[props.valueName] }}</div>
|
||||
<div v-else class="whitespace-normal break-words leading-snug">{{ val[props.valueName] }}</div>
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
export type AutomationTriggerType = 'schedule' | 'event' | 'price'
|
||||
export type AutomationChannel = 'sms' | 'in_app'
|
||||
export type AutomationConditionCr = 'and' | 'or'
|
||||
|
||||
export interface AutomationCondition {
|
||||
cr?: AutomationConditionCr
|
||||
field: string
|
||||
op?: string | null
|
||||
value?: unknown
|
||||
}
|
||||
|
||||
export type AutomationAudienceMode = 'all' | 'filter'
|
||||
export type AutomationPriceWindow = 'calendar_day' | 'from_last'
|
||||
|
||||
export interface AutomationTriggerConfig {
|
||||
event?: string
|
||||
delay_hours?: number
|
||||
every_days?: number
|
||||
step_toman?: number | string | null
|
||||
direction?: 'up' | 'down' | 'both'
|
||||
product_id?: number | null
|
||||
price_window?: AutomationPriceWindow
|
||||
}
|
||||
|
||||
export interface Automation {
|
||||
id?: number
|
||||
name: string
|
||||
code?: string
|
||||
is_active: boolean
|
||||
starts_at?: string | null
|
||||
ends_at?: string | null
|
||||
sms_send_hour?: number | null
|
||||
in_app_send_hour?: number | null
|
||||
trigger_type: AutomationTriggerType
|
||||
trigger_config?: AutomationTriggerConfig
|
||||
audience_mode?: AutomationAudienceMode
|
||||
conditions?: AutomationCondition[]
|
||||
channels: AutomationChannel[]
|
||||
title: string
|
||||
body: string
|
||||
cooldown_hours?: number
|
||||
max_sends?: number | null
|
||||
sent_count?: number
|
||||
failed_count?: number
|
||||
}
|
||||
|
||||
export interface AutomationCatalogOption {
|
||||
key: string | number
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface AutomationFieldInput {
|
||||
key: string
|
||||
type: 'number' | 'select' | 'operator'
|
||||
label: string
|
||||
options?: AutomationCatalogOption[]
|
||||
}
|
||||
|
||||
export interface AutomationField {
|
||||
key: string
|
||||
group: string
|
||||
name: string
|
||||
inputs: AutomationFieldInput[]
|
||||
}
|
||||
|
||||
export interface AutomationCatalog {
|
||||
triggers: AutomationCatalogOption[]
|
||||
events: AutomationCatalogOption[]
|
||||
channels: AutomationCatalogOption[]
|
||||
operators: AutomationCatalogOption[]
|
||||
fields: AutomationField[]
|
||||
products: Array<{ id: number; name: string }>
|
||||
hours: AutomationCatalogOption[]
|
||||
price_windows?: AutomationCatalogOption[]
|
||||
audience_modes?: AutomationCatalogOption[]
|
||||
}
|
||||
|
||||
export function emptyAutomation(): Automation {
|
||||
return {
|
||||
name: '',
|
||||
title: '',
|
||||
body: '',
|
||||
is_active: true,
|
||||
trigger_type: 'schedule',
|
||||
audience_mode: 'all',
|
||||
trigger_config: {
|
||||
delay_hours: 0,
|
||||
every_days: 1,
|
||||
direction: 'both',
|
||||
price_window: 'calendar_day',
|
||||
},
|
||||
conditions: [],
|
||||
channels: ['sms'],
|
||||
sms_send_hour: 10,
|
||||
in_app_send_hour: 10,
|
||||
cooldown_hours: 24,
|
||||
starts_at: null,
|
||||
ends_at: null,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,736 @@
|
||||
<template>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
layout="vertical"
|
||||
autocomplete="off"
|
||||
class="auto-editor"
|
||||
>
|
||||
<div class="auto-editor__grid">
|
||||
<div class="auto-editor__col">
|
||||
<a-form-item>
|
||||
<template #label>
|
||||
<AdminLabelWithHelp label="مخاطب" :help="automationAudienceHelp" />
|
||||
</template>
|
||||
<a-radio-group
|
||||
:value="audienceMode"
|
||||
option-type="button"
|
||||
button-style="solid"
|
||||
@update:value="setAudienceMode"
|
||||
>
|
||||
<a-radio-button value="all">همه کاربران</a-radio-button>
|
||||
<a-radio-button value="filter">با شرط</a-radio-button>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
|
||||
<template v-if="audienceMode === 'filter'">
|
||||
<div class="auto-editor__conds">
|
||||
<div
|
||||
v-for="(row, idx) in form.conditions"
|
||||
:key="idx"
|
||||
class="auto-editor__cond"
|
||||
>
|
||||
<div v-if="idx > 0" class="auto-editor__join">
|
||||
<a-radio-group
|
||||
:value="row.cr || 'and'"
|
||||
option-type="button"
|
||||
button-style="solid"
|
||||
size="small"
|
||||
@update:value="(v) => (row.cr = v)"
|
||||
>
|
||||
<a-radio-button value="and">و</a-radio-button>
|
||||
<a-radio-button value="or">یا</a-radio-button>
|
||||
</a-radio-group>
|
||||
</div>
|
||||
|
||||
<div class="auto-editor__cond-row">
|
||||
<BasicSelect
|
||||
class="auto-editor__cond-select"
|
||||
:value="row.field"
|
||||
:items="fieldItems"
|
||||
key-name="key"
|
||||
value-name="name"
|
||||
placeholder="نوع شرط"
|
||||
:has-tooltip="false"
|
||||
:dropdown-match-select-width="false"
|
||||
:dropdown-style="wideDropdownStyle"
|
||||
@update:value="(v) => onFieldChange(row, v)"
|
||||
/>
|
||||
<template v-for="inp in inputsOf(row.field)" :key="inp.key">
|
||||
<BasicSelect
|
||||
v-if="inp.type === 'operator'"
|
||||
class="auto-editor__cond-select"
|
||||
:value="row.op || 'eq'"
|
||||
:items="catalog?.operators || []"
|
||||
key-name="key"
|
||||
value-name="name"
|
||||
:placeholder="inp.label"
|
||||
:has-tooltip="false"
|
||||
@update:value="(v) => (row.op = v)"
|
||||
/>
|
||||
<BasicSelect
|
||||
v-else-if="inp.type === 'select'"
|
||||
class="auto-editor__cond-select"
|
||||
:value="row.value"
|
||||
:items="inp.options || []"
|
||||
key-name="key"
|
||||
value-name="name"
|
||||
:placeholder="inp.label"
|
||||
:has-tooltip="false"
|
||||
@update:value="(v) => (row.value = v)"
|
||||
/>
|
||||
<BasicInput
|
||||
v-else
|
||||
class="auto-editor__cond-input"
|
||||
:value="(row.value as any)"
|
||||
type="number"
|
||||
:place-holder="inp.label"
|
||||
@update:value="(v) => (row.value = v)"
|
||||
/>
|
||||
</template>
|
||||
<BaseButton danger size="small" @click="removeCondition(idx)">حذف</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<BaseButton size="small" type="dashed" @click="addCondition">افزودن شرط</BaseButton>
|
||||
</template>
|
||||
|
||||
<div class="auto-editor__hint">مخاطب تقریبی: {{ previewText }}</div>
|
||||
</div>
|
||||
|
||||
<div class="auto-editor__col">
|
||||
<div class="auto-editor__pair">
|
||||
<a-form-item name="name" :rules="inputRules()">
|
||||
<template #label>
|
||||
<AdminLabelWithHelp label="نام" :help="automationNameHelp" />
|
||||
</template>
|
||||
<BasicInput v-model:value="form.name" placeholder="نام اتوماسیون" />
|
||||
</a-form-item>
|
||||
<a-form-item name="title" :rules="inputRules()">
|
||||
<template #label>
|
||||
<AdminLabelWithHelp label="عنوان پیام" :help="automationTitleHelp" />
|
||||
</template>
|
||||
<BasicInput v-model:value="form.title" placeholder="عنوان پیامک / اعلان" />
|
||||
</a-form-item>
|
||||
</div>
|
||||
<a-form-item name="body" :rules="inputRules()" class="auto-editor__body">
|
||||
<template #label>
|
||||
<AdminLabelWithHelp label="متن" :help="automationBodyHelp" />
|
||||
</template>
|
||||
<a-textarea
|
||||
ref="bodyAreaRef"
|
||||
v-model:value="form.body"
|
||||
class="auto-editor__textarea"
|
||||
:rows="4"
|
||||
placeholder="متن پیام را بنویسید…"
|
||||
@blur="rememberBodyCursor"
|
||||
@click="rememberBodyCursor"
|
||||
@keyup="rememberBodyCursor"
|
||||
@select="rememberBodyCursor"
|
||||
/>
|
||||
<div class="auto-editor__vars">
|
||||
<button
|
||||
v-for="v in bodyVars"
|
||||
:key="v.token"
|
||||
type="button"
|
||||
class="auto-editor__var-chip"
|
||||
:title="v.help"
|
||||
@mousedown.prevent="insertBodyToken(v.token)"
|
||||
>
|
||||
<span class="auto-editor__var-token">%{{ v.token }}</span>
|
||||
<span>{{ v.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item name="trigger_type" :rules="inputRules()">
|
||||
<template #label>
|
||||
<AdminLabelWithHelp label="نوع اجرا" :help="automationTriggerTypeHelp" />
|
||||
</template>
|
||||
<a-radio-group
|
||||
v-model:value="form.trigger_type"
|
||||
option-type="button"
|
||||
button-style="solid"
|
||||
>
|
||||
<a-radio-button
|
||||
v-for="item in catalog?.triggers || []"
|
||||
:key="String(item.key)"
|
||||
:value="item.key"
|
||||
>
|
||||
{{ item.name }}
|
||||
</a-radio-button>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item v-if="form.trigger_type === 'schedule'">
|
||||
<template #label>
|
||||
<AdminLabelWithHelp label="هر چند روز یکبار" :help="automationEveryDaysHelp" />
|
||||
</template>
|
||||
<BasicInput
|
||||
v-model:value="form.trigger_config.every_days"
|
||||
type="number"
|
||||
placeholder="مثلاً ۲۰"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<template v-if="form.trigger_type === 'event'">
|
||||
<div class="auto-editor__pair">
|
||||
<a-form-item>
|
||||
<template #label>
|
||||
<AdminLabelWithHelp label="رویداد" :help="automationEventHelp" />
|
||||
</template>
|
||||
<BasicSelect
|
||||
v-model:value="form.trigger_config.event"
|
||||
:items="catalog?.events || []"
|
||||
key-name="key"
|
||||
value-name="name"
|
||||
placeholder="انتخاب رویداد"
|
||||
:has-tooltip="false"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<template #label>
|
||||
<AdminLabelWithHelp label="زمان ارسال" :help="automationEventTimingHelp" />
|
||||
</template>
|
||||
<a-radio-group
|
||||
:value="eventTiming"
|
||||
option-type="button"
|
||||
button-style="solid"
|
||||
@update:value="setEventTiming"
|
||||
>
|
||||
<a-radio-button value="immediate">سر ضرب</a-radio-button>
|
||||
<a-radio-button value="delayed">با تأخیر</a-radio-button>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
</div>
|
||||
<a-form-item v-if="eventTiming === 'delayed'">
|
||||
<template #label>
|
||||
<AdminLabelWithHelp label="تأخیر (ساعت)" :help="automationDelayHoursHelp" />
|
||||
</template>
|
||||
<BasicInput
|
||||
v-model:value="form.trigger_config.delay_hours"
|
||||
type="number"
|
||||
placeholder="۲۴"
|
||||
/>
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<template v-if="form.trigger_type === 'price'">
|
||||
<div class="auto-editor__pair">
|
||||
<a-form-item>
|
||||
<template #label>
|
||||
<AdminLabelWithHelp label="بازه" :help="automationPriceWindowHelp" />
|
||||
</template>
|
||||
<a-radio-group
|
||||
v-model:value="form.trigger_config.price_window"
|
||||
option-type="button"
|
||||
button-style="solid"
|
||||
>
|
||||
<a-radio-button value="calendar_day">در یک روز</a-radio-button>
|
||||
<a-radio-button value="from_last">از آخرین ارسال</a-radio-button>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<template #label>
|
||||
<AdminLabelWithHelp label="جهت" :help="automationPriceDirectionHelp" />
|
||||
</template>
|
||||
<a-radio-group
|
||||
v-model:value="form.trigger_config.direction"
|
||||
option-type="button"
|
||||
button-style="solid"
|
||||
>
|
||||
<a-radio-button value="up">بالا</a-radio-button>
|
||||
<a-radio-button value="down">پایین</a-radio-button>
|
||||
<a-radio-button value="both">هر دو</a-radio-button>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
</div>
|
||||
<div class="auto-editor__pair">
|
||||
<a-form-item>
|
||||
<template #label>
|
||||
<AdminLabelWithHelp label="رقم تغییر (تومان)" :help="automationPriceStepHelp" />
|
||||
</template>
|
||||
<BasicInput
|
||||
v-model:value="form.trigger_config.step_toman"
|
||||
type="price"
|
||||
addon-before="تومان"
|
||||
placeholder="۱۰۰۰۰۰"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<template #label>
|
||||
<AdminLabelWithHelp label="محصول" :help="automationPriceProductHelp" />
|
||||
</template>
|
||||
<BasicSelect
|
||||
v-model:value="form.trigger_config.product_id"
|
||||
:items="productItems"
|
||||
key-name="id"
|
||||
value-name="name"
|
||||
placeholder="خالی = قیمت تابلو"
|
||||
allow-clear
|
||||
:has-tooltip="false"
|
||||
/>
|
||||
</a-form-item>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="auto-editor__pair">
|
||||
<a-form-item v-if="form.trigger_type !== 'schedule'">
|
||||
<template #label>
|
||||
<AdminLabelWithHelp
|
||||
label="فاصلهٔ تکرار برای هر نفر (ساعت)"
|
||||
:help="automationCooldownHelp"
|
||||
/>
|
||||
</template>
|
||||
<BasicInput v-model:value="form.cooldown_hours" type="number" placeholder="۲۴" />
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<template #label>
|
||||
<AdminLabelWithHelp label="حداکثر ارسال به هر نفر" :help="automationMaxSendsHelp" />
|
||||
</template>
|
||||
<BasicInput v-model:value="form.max_sends" type="number" placeholder="خالی = نامحدود" />
|
||||
</a-form-item>
|
||||
</div>
|
||||
|
||||
<div class="auto-editor__pair">
|
||||
<a-form-item>
|
||||
<template #label>
|
||||
<AdminLabelWithHelp label="پیامک" :help="automationSmsHelp" />
|
||||
</template>
|
||||
<div class="auto-editor__channel">
|
||||
<a-switch :checked="hasSms" @change="(v) => toggleChannel('sms', !!v)" />
|
||||
<BasicSelect
|
||||
v-if="hasSms && showChannelHours"
|
||||
class="auto-editor__hour-select"
|
||||
:value="form.sms_send_hour"
|
||||
:items="catalog?.hours || []"
|
||||
key-name="key"
|
||||
value-name="name"
|
||||
placeholder="ساعت"
|
||||
:has-tooltip="false"
|
||||
@update:value="(v) => (form.sms_send_hour = v)"
|
||||
/>
|
||||
</div>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<template #label>
|
||||
<AdminLabelWithHelp label="اعلان داخل اپ" :help="automationInAppHelp" />
|
||||
</template>
|
||||
<div class="auto-editor__channel">
|
||||
<a-switch :checked="hasInApp" @change="(v) => toggleChannel('in_app', !!v)" />
|
||||
<BasicSelect
|
||||
v-if="hasInApp && showChannelHours"
|
||||
class="auto-editor__hour-select"
|
||||
:value="form.in_app_send_hour"
|
||||
:items="catalog?.hours || []"
|
||||
key-name="key"
|
||||
value-name="name"
|
||||
placeholder="ساعت"
|
||||
:has-tooltip="false"
|
||||
@update:value="(v) => (form.in_app_send_hour = v)"
|
||||
/>
|
||||
</div>
|
||||
</a-form-item>
|
||||
</div>
|
||||
|
||||
<div class="auto-editor__pair">
|
||||
<a-form-item>
|
||||
<template #label>
|
||||
<AdminLabelWithHelp label="شروع از" :help="automationStartsAtHelp" />
|
||||
</template>
|
||||
<BaseDatePicker
|
||||
v-model:value="form.starts_at"
|
||||
show-time
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
class="w-full"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item>
|
||||
<template #label>
|
||||
<AdminLabelWithHelp label="پایان در" :help="automationEndsAtHelp" />
|
||||
</template>
|
||||
<BaseDatePicker
|
||||
v-model:value="form.ends_at"
|
||||
show-time
|
||||
format="YYYY-MM-DD HH:mm:ss"
|
||||
class="w-full"
|
||||
/>
|
||||
</a-form-item>
|
||||
</div>
|
||||
<a-form-item>
|
||||
<template #label>
|
||||
<AdminLabelWithHelp label="وضعیت" :help="automationStatusHelp" />
|
||||
</template>
|
||||
<a-switch
|
||||
v-model:checked="form.is_active"
|
||||
checked-children="فعال"
|
||||
un-checked-children="غیرفعال"
|
||||
/>
|
||||
</a-form-item>
|
||||
</div>
|
||||
</div>
|
||||
</a-form>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import type { FormInstance } from 'ant-design-vue'
|
||||
import { cloneDeep, debounce, isEmpty } from 'lodash'
|
||||
import { inputRules } from '@/utils/inputRules'
|
||||
import { getAutomationCatalog, previewAutomationAudience } from '@/api/automations'
|
||||
import BaseDatePicker from '@core/components/BaseDatePicker'
|
||||
import AdminLabelWithHelp from '@/components/AdminLabelWithHelp.vue'
|
||||
import {
|
||||
automationAudienceHelp,
|
||||
automationBodyHelp,
|
||||
automationCooldownHelp,
|
||||
automationDelayHoursHelp,
|
||||
automationEndsAtHelp,
|
||||
automationEventHelp,
|
||||
automationEventTimingHelp,
|
||||
automationEveryDaysHelp,
|
||||
automationInAppHelp,
|
||||
automationMaxSendsHelp,
|
||||
automationNameHelp,
|
||||
automationPriceDirectionHelp,
|
||||
automationPriceProductHelp,
|
||||
automationPriceStepHelp,
|
||||
automationPriceWindowHelp,
|
||||
automationSmsHelp,
|
||||
automationStartsAtHelp,
|
||||
automationStatusHelp,
|
||||
automationTitleHelp,
|
||||
automationTriggerTypeHelp,
|
||||
} from '@/utils/adminHelpCopy'
|
||||
import type {
|
||||
Automation,
|
||||
AutomationCatalog,
|
||||
AutomationChannel,
|
||||
AutomationCondition,
|
||||
AutomationField,
|
||||
} from '@/model/automations'
|
||||
|
||||
const props = defineProps<{
|
||||
form: Automation
|
||||
}>()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const catalog = ref<AutomationCatalog | null>(null)
|
||||
const previewCount = ref<number | null>(null)
|
||||
const bodyAreaRef = ref<{ $el?: HTMLElement } | null>(null)
|
||||
let bodyCursorStart = 0
|
||||
let bodyCursorEnd = 0
|
||||
|
||||
const bodyVars = [
|
||||
{ token: 'نام', label: 'نام کاربر', help: 'نام کامل مشتری' },
|
||||
{ token: 'نام_کوچک', label: 'نام کوچک', help: 'نام کوچک مشتری' },
|
||||
{ token: 'موبایل', label: 'موبایل', help: 'شماره موبایل مشتری' },
|
||||
]
|
||||
|
||||
function getBodyTextarea(): HTMLTextAreaElement | null {
|
||||
const root = bodyAreaRef.value?.$el
|
||||
if (!root) return null
|
||||
if (root instanceof HTMLTextAreaElement) return root
|
||||
return root.querySelector?.('textarea') ?? null
|
||||
}
|
||||
|
||||
function rememberBodyCursor() {
|
||||
const ta = getBodyTextarea()
|
||||
if (!ta) return
|
||||
bodyCursorStart = ta.selectionStart ?? 0
|
||||
bodyCursorEnd = ta.selectionEnd ?? bodyCursorStart
|
||||
}
|
||||
|
||||
function insertBodyToken(token: string) {
|
||||
rememberBodyCursor()
|
||||
const insert = `%${token}`
|
||||
const text = props.form.body ?? ''
|
||||
const start = bodyCursorStart
|
||||
const end = bodyCursorEnd
|
||||
props.form.body = text.slice(0, start) + insert + text.slice(end)
|
||||
const nextPos = start + insert.length
|
||||
bodyCursorStart = nextPos
|
||||
bodyCursorEnd = nextPos
|
||||
nextTick(() => {
|
||||
const ta = getBodyTextarea()
|
||||
if (!ta) return
|
||||
ta.focus()
|
||||
ta.setSelectionRange(nextPos, nextPos)
|
||||
})
|
||||
}
|
||||
|
||||
const fieldItems = computed(() =>
|
||||
(catalog.value?.fields || []).map((f) => ({
|
||||
key: f.key,
|
||||
name: `${f.group} › ${f.name}`,
|
||||
}))
|
||||
)
|
||||
|
||||
const productItems = computed(() => catalog.value?.products || [])
|
||||
|
||||
const wideDropdownStyle = {
|
||||
minWidth: '22rem',
|
||||
maxWidth: 'min(40rem, 94vw)',
|
||||
}
|
||||
|
||||
const eventTiming = computed<'immediate' | 'delayed'>(() =>
|
||||
Number(props.form.trigger_config?.delay_hours ?? 0) > 0 ? 'delayed' : 'immediate'
|
||||
)
|
||||
|
||||
const audienceMode = computed<'all' | 'filter'>(() =>
|
||||
props.form.audience_mode === 'filter' || (props.form.conditions || []).some((r) => r.field)
|
||||
? 'filter'
|
||||
: 'all'
|
||||
)
|
||||
|
||||
const showChannelHours = computed(() => {
|
||||
if (props.form.trigger_type === 'event') return eventTiming.value === 'delayed'
|
||||
if (props.form.trigger_type === 'price') {
|
||||
return (props.form.trigger_config?.price_window || 'calendar_day') === 'from_last'
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
const previewText = computed(() => {
|
||||
if (previewCount.value == null) return '—'
|
||||
return `${previewCount.value.toLocaleString('fa-IR')} نفر`
|
||||
})
|
||||
|
||||
const hasSms = computed(() => (props.form.channels || []).includes('sms'))
|
||||
const hasInApp = computed(() => (props.form.channels || []).includes('in_app'))
|
||||
|
||||
function ensureTriggerConfig() {
|
||||
if (!props.form.trigger_config) {
|
||||
props.form.trigger_config = {
|
||||
delay_hours: 0,
|
||||
every_days: 1,
|
||||
direction: 'both',
|
||||
price_window: 'calendar_day',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setAudienceMode(mode: 'all' | 'filter') {
|
||||
props.form.audience_mode = mode
|
||||
if (mode === 'all') {
|
||||
props.form.conditions = []
|
||||
} else if (!props.form.conditions?.length) {
|
||||
addCondition()
|
||||
}
|
||||
}
|
||||
|
||||
function setEventTiming(mode: 'immediate' | 'delayed') {
|
||||
ensureTriggerConfig()
|
||||
if (mode === 'immediate') {
|
||||
props.form.trigger_config!.delay_hours = 0
|
||||
} else if (!(Number(props.form.trigger_config!.delay_hours) > 0)) {
|
||||
props.form.trigger_config!.delay_hours = 24
|
||||
}
|
||||
}
|
||||
|
||||
function fieldByKey(key?: string): AutomationField | undefined {
|
||||
return (catalog.value?.fields || []).find((f) => f.key === key)
|
||||
}
|
||||
|
||||
function inputsOf(field?: string) {
|
||||
return fieldByKey(field)?.inputs || []
|
||||
}
|
||||
|
||||
function onFieldChange(row: AutomationCondition, key: string) {
|
||||
row.field = key
|
||||
row.op = inputsOf(key).some((i) => i.type === 'operator') ? 'eq' : null
|
||||
row.value = null
|
||||
}
|
||||
|
||||
function addCondition() {
|
||||
if (!props.form.conditions) props.form.conditions = []
|
||||
props.form.audience_mode = 'filter'
|
||||
props.form.conditions.push({
|
||||
cr: 'and',
|
||||
field: '',
|
||||
op: null,
|
||||
value: null,
|
||||
})
|
||||
}
|
||||
|
||||
function removeCondition(idx: number) {
|
||||
props.form.conditions?.splice(idx, 1)
|
||||
if (!props.form.conditions?.length) {
|
||||
props.form.audience_mode = 'all'
|
||||
}
|
||||
}
|
||||
|
||||
function toggleChannel(ch: AutomationChannel, on: boolean) {
|
||||
const cur = props.form.channels || []
|
||||
if (on) {
|
||||
if (!cur.includes(ch)) props.form.channels = [...cur, ch]
|
||||
} else {
|
||||
props.form.channels = cur.filter((c) => c !== ch)
|
||||
}
|
||||
}
|
||||
|
||||
const runPreview = debounce(async () => {
|
||||
try {
|
||||
const cond =
|
||||
audienceMode.value === 'all'
|
||||
? []
|
||||
: (props.form.conditions || []).filter((r) => !isEmpty(r.field))
|
||||
const data = await previewAutomationAudience(cloneDeep(cond))
|
||||
previewCount.value = data?.count ?? 0
|
||||
} catch {
|
||||
previewCount.value = null
|
||||
}
|
||||
}, 400)
|
||||
|
||||
watch(
|
||||
() => cloneDeep(props.form.conditions),
|
||||
() => runPreview(),
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
watch(audienceMode, () => runPreview())
|
||||
|
||||
onMounted(async () => {
|
||||
catalog.value = await getAutomationCatalog()
|
||||
ensureTriggerConfig()
|
||||
if (props.form.trigger_config!.every_days == null) {
|
||||
props.form.trigger_config!.every_days = 1
|
||||
}
|
||||
if (!props.form.trigger_config!.price_window) {
|
||||
props.form.trigger_config!.price_window = 'calendar_day'
|
||||
}
|
||||
if (!props.form.audience_mode) {
|
||||
props.form.audience_mode = (props.form.conditions || []).some((r) => r.field)
|
||||
? 'filter'
|
||||
: 'all'
|
||||
}
|
||||
runPreview()
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
validateFields: () => formRef.value?.validate(),
|
||||
resetFields: () => formRef.value?.resetFields(),
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auto-editor {
|
||||
@apply flex flex-col;
|
||||
}
|
||||
|
||||
.auto-editor__grid {
|
||||
direction: ltr;
|
||||
@apply grid grid-cols-1 items-start gap-x-8 gap-y-6 lg:grid-cols-2;
|
||||
}
|
||||
|
||||
.auto-editor__col {
|
||||
direction: rtl;
|
||||
@apply flex min-w-0 flex-col gap-6;
|
||||
}
|
||||
|
||||
.auto-editor__pair {
|
||||
@apply grid grid-cols-1 gap-x-4 gap-y-6 sm:grid-cols-2;
|
||||
}
|
||||
|
||||
.auto-editor__body :deep(textarea),
|
||||
.auto-editor__textarea {
|
||||
min-height: 4.5rem;
|
||||
max-height: 7rem;
|
||||
background: var(--admin-bg-elevated) !important;
|
||||
border-color: var(--admin-border) !important;
|
||||
color: var(--admin-text) !important;
|
||||
}
|
||||
|
||||
.auto-editor__vars {
|
||||
@apply mt-2 flex flex-wrap gap-2;
|
||||
}
|
||||
|
||||
.auto-editor__var-chip {
|
||||
@apply inline-flex items-center gap-1.5 rounded-lg border border-solid px-2.5 py-1 text-xs;
|
||||
border-color: var(--admin-border);
|
||||
background: var(--admin-bg-elevated);
|
||||
color: var(--admin-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auto-editor__var-chip:hover {
|
||||
border-color: var(--admin-border-strong, var(--admin-border));
|
||||
}
|
||||
|
||||
.auto-editor__var-token {
|
||||
@apply font-bold;
|
||||
color: var(--admin-text-secondary);
|
||||
}
|
||||
|
||||
.auto-editor :deep(.ant-form-item) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.auto-editor :deep(.ant-form-item-label) {
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
|
||||
.auto-editor :deep(.ant-form-item-label > label) {
|
||||
color: var(--admin-text);
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.auto-editor__conds {
|
||||
@apply mb-3 flex flex-col gap-3;
|
||||
}
|
||||
|
||||
.auto-editor__cond {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
|
||||
.auto-editor__join {
|
||||
@apply flex;
|
||||
}
|
||||
|
||||
.auto-editor__cond-row {
|
||||
@apply flex flex-wrap items-center gap-2;
|
||||
}
|
||||
|
||||
.auto-editor__cond-select,
|
||||
.auto-editor__cond-input {
|
||||
width: min(100%, 22rem);
|
||||
min-width: 12rem;
|
||||
flex: 0 1 22rem;
|
||||
}
|
||||
|
||||
.auto-editor__channel {
|
||||
@apply flex items-center gap-3;
|
||||
}
|
||||
|
||||
.auto-editor__hour-select {
|
||||
min-width: 7rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.auto-editor__hour-select :deep(.ant-select-selector) {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
height: 32px !important;
|
||||
}
|
||||
|
||||
.auto-editor__hour-select :deep(.ant-select-selection-search),
|
||||
.auto-editor__hour-select :deep(.ant-select-selection-item),
|
||||
.auto-editor__hour-select :deep(.ant-select-selection-placeholder) {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
line-height: 30px !important;
|
||||
margin-top: 0 !important;
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.auto-editor__hour-select :deep(.ant-select-selection-item > div) {
|
||||
line-height: 30px;
|
||||
white-space: nowrap;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.auto-editor__hint {
|
||||
@apply mt-3 text-xs;
|
||||
color: var(--admin-text-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<div class="h-full min-h-0 min-w-0 max-w-full overflow-hidden">
|
||||
<BasicShowContext title="اتوماسیون">
|
||||
<template #extra>
|
||||
<BaseButton type="primary" size="small" icon="vuesax-linear:add" @click="emits('add')">
|
||||
افزودن
|
||||
</BaseButton>
|
||||
</template>
|
||||
|
||||
<div v-if="isMobile" class="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto p-1">
|
||||
<div
|
||||
v-for="item in mobileItems"
|
||||
:key="item.id"
|
||||
class="flex flex-col gap-2 rounded-xl border border-solid border-gray-100 bg-white p-3 dark:border-slate-700 dark:bg-slate-800/40"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<div class="truncate font-bold text-slate-800 dark:text-slate-100">{{ item.name }}</div>
|
||||
<div class="text-xs text-gray-500">{{ item.code }}</div>
|
||||
</div>
|
||||
<a-switch :checked="item.is_active" size="small" @change="emits('toggle', item)" />
|
||||
</div>
|
||||
<div class="line-clamp-2 text-sm text-slate-600 dark:text-slate-300">{{ item.body }}</div>
|
||||
<div class="flex items-center justify-between text-xs text-gray-500">
|
||||
<span>ارسال {{ Number(item.sent_count ?? 0) }} · ناموفق {{ Number(item.failed_count ?? 0) }}</span>
|
||||
<div class="flex gap-3">
|
||||
<Icon
|
||||
icon="vuesax-linear:edit"
|
||||
color="#4572E9"
|
||||
class="cursor-pointer"
|
||||
@click="emits('edit', item)"
|
||||
/>
|
||||
<Icon
|
||||
icon="vuesax-linear:trash"
|
||||
color="#C53030"
|
||||
class="cursor-pointer"
|
||||
@click="emits('delete', item.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!mobileLoading && mobileItems.length === 0" class="py-8 text-center text-gray-500">
|
||||
اتوماسیونی نیست
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BaseTable v-else class="h-full min-h-0 min-w-0" v-bind="tblProps" @register="submit" ref="tbl">
|
||||
<template #is_active="{ record }">
|
||||
<a-switch :checked="record.is_active" size="small" @change="emits('toggle', record)" />
|
||||
</template>
|
||||
<template #action="{ record }">
|
||||
<div class="flex justify-center gap-2">
|
||||
<a-tooltip placement="bottom">
|
||||
<template #title>ویرایش</template>
|
||||
<Icon
|
||||
icon="vuesax-linear:edit"
|
||||
color="#4572E9"
|
||||
class="cursor-pointer"
|
||||
@click="emits('edit', record)"
|
||||
/>
|
||||
</a-tooltip>
|
||||
<a-tooltip placement="bottom">
|
||||
<template #title>حذف</template>
|
||||
<Icon
|
||||
icon="vuesax-linear:trash"
|
||||
color="#C53030"
|
||||
class="cursor-pointer"
|
||||
@click="emits('delete', record.id)"
|
||||
/>
|
||||
</a-tooltip>
|
||||
</div>
|
||||
</template>
|
||||
</BaseTable>
|
||||
</BasicShowContext>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="tsx" setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import type { TableInput } from '@core/type/baseTable'
|
||||
import useBaseTable from '@core/components/BaseTableOld/hooks/useBaseTable'
|
||||
import Icon from '@core/components/BaseIcon/src/Icon.vue'
|
||||
import { isMobile } from '@core/utils/is'
|
||||
import { cols } from '../hooks/automationsColumns'
|
||||
import { getAutomationsTable } from '@/api/automations'
|
||||
import type { Automation } from '@/model/automations'
|
||||
import { useMobileTableList } from '@/components/mobile/composables/useMobileTableList'
|
||||
|
||||
const [submit, reload] = useBaseTable()
|
||||
|
||||
const emits = defineEmits<{
|
||||
add: []
|
||||
edit: [record: Automation]
|
||||
toggle: [record: Automation]
|
||||
delete: [id: number]
|
||||
}>()
|
||||
|
||||
const tblProps = {
|
||||
columns: cols,
|
||||
api: (params: TableInput) => getAutomationsTable(params),
|
||||
}
|
||||
|
||||
const {
|
||||
loading: mobileLoading,
|
||||
items: mobileItems,
|
||||
reload: reloadMobile,
|
||||
} = useMobileTableList<Automation>({
|
||||
fetchApi: getAutomationsTable,
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (isMobile.value) reloadMobile()
|
||||
})
|
||||
|
||||
function fetch() {
|
||||
if (isMobile.value) reloadMobile()
|
||||
else reload()
|
||||
}
|
||||
|
||||
defineExpose({ fetch })
|
||||
</script>
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { BaseTableColumn } from '@core/type/baseTable'
|
||||
|
||||
const triggerLabel: Record<string, string> = {
|
||||
schedule: 'زماندار',
|
||||
event: 'رویداد',
|
||||
price: 'حرکت قیمت',
|
||||
}
|
||||
|
||||
function channelText(record: any): string {
|
||||
const ch = Array.isArray(record.channels) ? record.channels : []
|
||||
const parts: string[] = []
|
||||
if (ch.includes('sms')) parts.push('پیامک')
|
||||
if (ch.includes('in_app')) parts.push('اعلان')
|
||||
return parts.join(' / ') || '—'
|
||||
}
|
||||
|
||||
function previewBody(text: unknown): string {
|
||||
const s = String(text || '').replace(/\s+/g, ' ').trim()
|
||||
if (!s) return '—'
|
||||
return s.length > 48 ? `${s.slice(0, 48)}…` : s
|
||||
}
|
||||
|
||||
export const cols: BaseTableColumn[] = [
|
||||
{
|
||||
title: 'ردیف',
|
||||
dataIndex: 'id',
|
||||
key: 'id',
|
||||
customRender: (record: any) => record.index + 1,
|
||||
},
|
||||
{
|
||||
title: 'نام',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
type: 'Text',
|
||||
},
|
||||
{
|
||||
title: 'کد',
|
||||
dataIndex: 'code',
|
||||
key: 'code',
|
||||
},
|
||||
{
|
||||
title: 'عنوان',
|
||||
dataIndex: 'title',
|
||||
key: 'title',
|
||||
type: 'Text',
|
||||
},
|
||||
{
|
||||
title: 'متن',
|
||||
dataIndex: 'body',
|
||||
key: 'body',
|
||||
customRender: ({ record }) => previewBody(record.body),
|
||||
},
|
||||
{
|
||||
title: 'نوع',
|
||||
dataIndex: 'trigger_type',
|
||||
key: 'trigger_type',
|
||||
customRender: ({ record }) => triggerLabel[record.trigger_type] || record.trigger_type,
|
||||
},
|
||||
{
|
||||
title: 'کانال',
|
||||
dataIndex: 'channels',
|
||||
key: 'channels',
|
||||
customRender: ({ record }) => channelText(record),
|
||||
},
|
||||
{
|
||||
title: 'فعال',
|
||||
dataIndex: 'is_active',
|
||||
key: 'is_active',
|
||||
slot: 'is_active',
|
||||
width: 90,
|
||||
},
|
||||
{
|
||||
title: 'ارسال شده',
|
||||
dataIndex: 'sent_count',
|
||||
key: 'sent_count',
|
||||
customRender: ({ record }) => Number(record.sent_count ?? 0),
|
||||
},
|
||||
{
|
||||
title: 'ناموفق',
|
||||
dataIndex: 'failed_count',
|
||||
key: 'failed_count',
|
||||
customRender: ({ record }) => Number(record.failed_count ?? 0),
|
||||
},
|
||||
{
|
||||
title: 'عملیات',
|
||||
dataIndex: 'action',
|
||||
key: 'action',
|
||||
slot: 'action',
|
||||
fixed: 'right',
|
||||
filterable: false,
|
||||
width: 120,
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<div class="flex h-full w-full">
|
||||
<tbl
|
||||
ref="tableRef"
|
||||
class="h-full w-full"
|
||||
@add="openAdd"
|
||||
@edit="openEdit"
|
||||
@toggle="toggleActive"
|
||||
@delete="deleteItem"
|
||||
/>
|
||||
|
||||
<BasicActionSheet
|
||||
v-model:visible="editorVisible"
|
||||
:title="sheetTitle"
|
||||
modal-width="80rem"
|
||||
drawer-height="80vh"
|
||||
:loading="loading"
|
||||
:show-button="true"
|
||||
:show-cancel="true"
|
||||
:show-ok="true"
|
||||
ok-text="ذخیره"
|
||||
cancel-text="انصراف"
|
||||
@ok="submitForm"
|
||||
@close="closeEditor"
|
||||
@cancel="closeEditor"
|
||||
>
|
||||
<div v-if="isMobile" class="mb-3 text-base font-bold">{{ sheetTitle }}</div>
|
||||
<AutomationEditorFormBody ref="editorRef" :form="form" />
|
||||
</BasicActionSheet>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { cloneDeep } from 'lodash'
|
||||
import tbl from './components/table.vue'
|
||||
import AutomationEditorFormBody from './components/AutomationEditorFormBody.vue'
|
||||
import { emptyAutomation, type Automation } from '@/model/automations'
|
||||
import {
|
||||
createAutomation,
|
||||
deleteAutomation,
|
||||
toggleAutomationActive,
|
||||
updateAutomation,
|
||||
} from '@/api/automations'
|
||||
import { confirmDeleteItem } from '@/utils/confirmModal'
|
||||
import { isMobile } from '@core/utils/is'
|
||||
|
||||
const tableRef = ref<null | { fetch: () => void }>(null)
|
||||
const editorRef = ref<{
|
||||
validateFields?: () => Promise<unknown>
|
||||
resetFields?: () => void
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const editorVisible = ref(false)
|
||||
const editId = ref<number | undefined>()
|
||||
const form = ref<Automation>(emptyAutomation())
|
||||
|
||||
const sheetTitle = computed(() => (editId.value ? 'ویرایش اتوماسیون' : 'افزودن اتوماسیون'))
|
||||
|
||||
function refreshTable() {
|
||||
tableRef.value?.fetch()
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
editorVisible.value = false
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
editId.value = undefined
|
||||
form.value = emptyAutomation()
|
||||
editorRef.value?.resetFields?.()
|
||||
editorVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(record: Automation) {
|
||||
editId.value = record.id
|
||||
const conditions = record.conditions || []
|
||||
form.value = cloneDeep({
|
||||
...emptyAutomation(),
|
||||
...record,
|
||||
audience_mode: conditions.length ? 'filter' : 'all',
|
||||
trigger_config: {
|
||||
delay_hours: 0,
|
||||
every_days: 1,
|
||||
direction: 'both',
|
||||
price_window: 'calendar_day',
|
||||
...(record.trigger_config || {}),
|
||||
},
|
||||
conditions,
|
||||
channels: record.channels || [],
|
||||
})
|
||||
editorVisible.value = true
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
try {
|
||||
await editorRef.value?.validateFields?.()
|
||||
loading.value = true
|
||||
const payload = cloneDeep(form.value)
|
||||
if (editId.value) {
|
||||
await updateAutomation(editId.value, payload)
|
||||
} else {
|
||||
await createAutomation(payload)
|
||||
}
|
||||
message.success('اتوماسیون ذخیره شد')
|
||||
editorVisible.value = false
|
||||
refreshTable()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleActive(record: Automation) {
|
||||
if (!record.id) return
|
||||
try {
|
||||
await toggleAutomationActive(record.id)
|
||||
message.success(record.is_active ? 'غیرفعال شد' : 'فعال شد')
|
||||
refreshTable()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
message.error('تغییر وضعیت انجام نشد')
|
||||
}
|
||||
}
|
||||
|
||||
function deleteItem(id: number) {
|
||||
confirmDeleteItem('اتوماسیون', id, deleteAutomation, refreshTable)
|
||||
}
|
||||
</script>
|
||||
@@ -215,12 +215,14 @@ import {
|
||||
helpText,
|
||||
isSettingVisible,
|
||||
isCodeManagedSettingKey,
|
||||
isSuperAdminOwnedSettingKey,
|
||||
isSettingLocked,
|
||||
isSettingActive,
|
||||
isSwitchOn,
|
||||
normalizeSettingRow,
|
||||
resolveSettingFieldType
|
||||
} from './settingTypes'
|
||||
import { TRADING_HOURS_SETTING_KEY_SET } from './tradingHoursKeys'
|
||||
|
||||
const props = withDefaults(defineProps<{ mode?: 'editable' | 'system' }>(), {
|
||||
mode: 'editable'
|
||||
@@ -309,6 +311,7 @@ const filteredRows = computed(() =>
|
||||
(baseData.value || []).filter((row) => {
|
||||
if (row.id == null || !matchesFilter(row)) return false
|
||||
if (!isSettingVisible(row.key)) return false
|
||||
if (!isSystemMode.value && TRADING_HOURS_SETTING_KEY_SET.has(row.key || '')) return false
|
||||
if (isSystemMode.value) return true
|
||||
return isEditable(row)
|
||||
})
|
||||
@@ -429,6 +432,7 @@ function rowColSpan(row: Const): number {
|
||||
|
||||
function isReadonly(row: Const): boolean {
|
||||
if (isCodeManagedSettingKey(row.key)) return true
|
||||
if (isSuperAdminOwnedSettingKey(row.key)) return true
|
||||
if (isSystemMode.value) return false
|
||||
return isSettingLocked(row.can_edit)
|
||||
}
|
||||
|
||||
@@ -31,15 +31,6 @@ export const EDITABLE_SETTING_GROUPS: SettingGroupDef[] = [
|
||||
match: (k) =>
|
||||
/^(banner_enabled|tracking_systems_enabled|jibit_verification|register_jibit_verify|show_product_details|show_product_trade_status|show_product_card_meta|products_site_link_enabled|products_site_link_url|products_site_link_text|has_chat)$/.test(k)
|
||||
},
|
||||
{
|
||||
id: 'trading_hours',
|
||||
title: 'ساعت کاری و نمایش قیمت',
|
||||
icon: 'vuesax-linear:clock',
|
||||
match: (k) =>
|
||||
/^(close_price|startDay|endDay|show_last_price|show_home_price_when_closed|show_gold_ounce_price)$/.test(
|
||||
k
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'fees',
|
||||
title: 'کارمزدها',
|
||||
|
||||
@@ -199,6 +199,31 @@ export function isCodeManagedSettingKey(key?: string | null): boolean {
|
||||
return SETTING_CODE_MANAGED_KEYS.has(String(key ?? '').trim())
|
||||
}
|
||||
|
||||
/**
|
||||
* دسترسی مشتری به قابلیتها — مالک: سوپرادمین.
|
||||
* در پنل خانگی دیده میشوند ولی ذخیرهشان ۴۰۳ میگیرد، پس فقطخواندنیاند.
|
||||
* همتراز SuperAdminProgramSettingsOverlay::SUPERADMIN_OWNED_KEYS در سرور.
|
||||
*/
|
||||
export const SUPERADMIN_OWNED_SETTING_KEYS = new Set([
|
||||
'customer_direct_sms',
|
||||
'customer_special_pricing',
|
||||
'enable_conditional_order',
|
||||
'enable_customer_level_profit',
|
||||
'enable_product_funds',
|
||||
'group_sms',
|
||||
'has_page_earn_money',
|
||||
'has_page_gift_card',
|
||||
'has_page_notification',
|
||||
'has_page_price_trigger',
|
||||
'jibit_enabled',
|
||||
'permision__enabled',
|
||||
'show_to_users_chat',
|
||||
])
|
||||
|
||||
export function isSuperAdminOwnedSettingKey(key?: string | null): boolean {
|
||||
return SUPERADMIN_OWNED_SETTING_KEYS.has(String(key ?? '').trim())
|
||||
}
|
||||
|
||||
/** کلیدهای legacy — در پنل نمایش داده نمیشوند */
|
||||
export const HIDDEN_LEGACY_SETTING_KEYS = new Set([
|
||||
'main_product_price',
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/** کلیدهای تنظیمات ساعت کاری — فقط در صفحه «ساعت کاری» ویرایش میشوند */
|
||||
export const TRADING_HOURS_SETTING_KEYS = [
|
||||
'close_price',
|
||||
'startDay',
|
||||
'endDay',
|
||||
'show_last_price',
|
||||
'show_home_price_when_closed',
|
||||
'show_gold_ounce_price',
|
||||
'trading_hours_by_day',
|
||||
] as const
|
||||
|
||||
export type TradingHoursSettingKey = (typeof TRADING_HOURS_SETTING_KEYS)[number]
|
||||
|
||||
export const TRADING_HOURS_SETTING_KEY_SET = new Set<string>(TRADING_HOURS_SETTING_KEYS)
|
||||
|
||||
export type WeekdayKey = 'sat' | 'sun' | 'mon' | 'tue' | 'wed' | 'thu' | 'fri'
|
||||
|
||||
export type DaySchedule = {
|
||||
enabled: boolean
|
||||
start: string
|
||||
end: string
|
||||
}
|
||||
|
||||
export type WeeklyScheduleDraft = {
|
||||
mode: 'uniform' | 'per_day'
|
||||
days: Record<WeekdayKey, DaySchedule>
|
||||
}
|
||||
|
||||
export const WEEKDAY_DEFS: { key: WeekdayKey; label: string; short: string }[] = [
|
||||
{ key: 'sat', label: 'شنبه', short: 'ش' },
|
||||
{ key: 'sun', label: 'یکشنبه', short: 'ی' },
|
||||
{ key: 'mon', label: 'دوشنبه', short: 'د' },
|
||||
{ key: 'tue', label: 'سهشنبه', short: 'س' },
|
||||
{ key: 'wed', label: 'چهارشنبه', short: 'چ' },
|
||||
{ key: 'thu', label: 'پنجشنبه', short: 'پ' },
|
||||
{ key: 'fri', label: 'جمعه', short: 'ج' },
|
||||
]
|
||||
|
||||
export const DEFAULT_DAY_START = '09:00:00'
|
||||
export const DEFAULT_DAY_END = '22:00:00'
|
||||
|
||||
export function defaultDaySchedule(start = DEFAULT_DAY_START, end = DEFAULT_DAY_END): DaySchedule {
|
||||
return { enabled: true, start, end }
|
||||
}
|
||||
|
||||
export function defaultWeeklySchedule(
|
||||
start = DEFAULT_DAY_START,
|
||||
end = DEFAULT_DAY_END
|
||||
): WeeklyScheduleDraft {
|
||||
const day = defaultDaySchedule(start, end)
|
||||
return {
|
||||
mode: 'uniform',
|
||||
days: {
|
||||
sat: { ...day },
|
||||
sun: { ...day },
|
||||
mon: { ...day },
|
||||
tue: { ...day },
|
||||
wed: { ...day },
|
||||
thu: { ...day },
|
||||
fri: { ...day },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
<template>
|
||||
<div
|
||||
class="th-day-row"
|
||||
:class="{
|
||||
'th-day-row--disabled': !day.enabled,
|
||||
'th-day-row--compact': compact,
|
||||
}"
|
||||
>
|
||||
<div class="th-day-row__label">
|
||||
<span class="th-day-row__badge">{{ weekday.short }}</span>
|
||||
<span class="th-day-row__name">{{ weekday.label }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="!compact" class="th-day-row__toggle">
|
||||
<a-switch
|
||||
:checked="day.enabled"
|
||||
:disabled="disabled"
|
||||
checked-children="باز"
|
||||
un-checked-children="تعطیل"
|
||||
@change="(v) => emit('update', { enabled: !!v })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="th-day-row__times">
|
||||
<TradingHoursTimeField
|
||||
:model-value="day.start"
|
||||
:disabled="disabled || !day.enabled"
|
||||
@update:model-value="(v) => emit('update', { start: v })"
|
||||
/>
|
||||
<span class="th-day-row__sep">تا</span>
|
||||
<TradingHoursTimeField
|
||||
:model-value="day.end"
|
||||
:disabled="disabled || !day.enabled"
|
||||
@update:model-value="(v) => emit('update', { end: v })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="showBar" class="th-day-row__bar-wrap">
|
||||
<div class="th-day-row__bar">
|
||||
<div class="th-day-row__bar-fill" :style="barStyle" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import TradingHoursTimeField from './TradingHoursTimeField.vue'
|
||||
import type { DaySchedule } from '@/pages/menuPages/configuration/tradingHoursKeys'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
weekday: { key: string; label: string; short: string }
|
||||
day: DaySchedule
|
||||
disabled?: boolean
|
||||
compact?: boolean
|
||||
showBar?: boolean
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
compact: false,
|
||||
showBar: true,
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
update: [patch: Partial<DaySchedule>]
|
||||
}>()
|
||||
|
||||
function timeToMinutes(raw: string): number {
|
||||
const parts = raw.split(':')
|
||||
const h = Number(parts[0] ?? 0)
|
||||
const m = Number(parts[1] ?? 0)
|
||||
return h * 60 + m
|
||||
}
|
||||
|
||||
const barStyle = computed(() => {
|
||||
if (!props.day.enabled) {
|
||||
return { left: '0%', width: '0%' }
|
||||
}
|
||||
const start = timeToMinutes(props.day.start)
|
||||
const end = timeToMinutes(props.day.end)
|
||||
const dayMinutes = 24 * 60
|
||||
const left = Math.max(0, Math.min(100, (start / dayMinutes) * 100))
|
||||
const width = Math.max(0, Math.min(100 - left, ((Math.max(end, start) - start) / dayMinutes) * 100))
|
||||
return {
|
||||
left: `${left}%`,
|
||||
width: `${width || 2}%`,
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.th-day-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(6.5rem, 8rem) minmax(0, 1fr);
|
||||
gap: 0.75rem 1rem;
|
||||
align-items: center;
|
||||
padding: 0.85rem 1rem;
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 12px;
|
||||
background: var(--admin-bg-elevated);
|
||||
}
|
||||
|
||||
.th-day-row--compact {
|
||||
grid-template-columns: minmax(5rem, 6rem) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.th-day-row--disabled {
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.th-day-row__label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.th-day-row__badge {
|
||||
display: inline-flex;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
background: color-mix(in srgb, var(--admin-accent, #d4af37) 18%, transparent);
|
||||
color: var(--admin-text);
|
||||
}
|
||||
|
||||
.th-day-row__name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--admin-text);
|
||||
}
|
||||
|
||||
.th-day-row__toggle {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.th-day-row {
|
||||
grid-template-columns: minmax(7rem, 8.5rem) auto minmax(0, 1fr);
|
||||
grid-template-rows: auto auto;
|
||||
}
|
||||
|
||||
.th-day-row__toggle {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.th-day-row__times {
|
||||
grid-column: 3;
|
||||
}
|
||||
|
||||
.th-day-row__bar-wrap {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
.th-day-row__times {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.th-day-row__sep {
|
||||
font-size: 0.75rem;
|
||||
color: var(--admin-text-secondary);
|
||||
}
|
||||
|
||||
.th-day-row__bar-wrap {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.th-day-row__bar {
|
||||
position: relative;
|
||||
height: 0.35rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--admin-border) 65%, transparent);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.th-day-row__bar-fill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
color-mix(in srgb, var(--admin-accent, #d4af37) 70%, #fff),
|
||||
var(--admin-accent, #d4af37)
|
||||
);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<a-time-picker
|
||||
:value="pickerValue"
|
||||
format="HH:mm"
|
||||
value-format="HH:mm:ss"
|
||||
:minute-step="5"
|
||||
:disabled="disabled"
|
||||
placeholder="انتخاب ساعت"
|
||||
class="th-time-field w-full"
|
||||
@change="onChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import { normalizeTimeValue } from '@/pages/menuPages/configuration/settingTypes'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue?: string
|
||||
disabled?: boolean
|
||||
}>(),
|
||||
{
|
||||
modelValue: '',
|
||||
disabled: false,
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const pickerValue = computed(() => {
|
||||
const normalized = normalizeTimeValue(props.modelValue)
|
||||
if (!normalized) return undefined
|
||||
const parsed = dayjs(normalized, 'HH:mm:ss', true)
|
||||
return parsed.isValid() ? parsed : undefined
|
||||
})
|
||||
|
||||
function onChange(val: Dayjs | string | null) {
|
||||
if (val == null || val === '') {
|
||||
emit('update:modelValue', '')
|
||||
return
|
||||
}
|
||||
const parsed = dayjs.isDayjs(val) ? val : dayjs(String(val), 'HH:mm:ss', true)
|
||||
emit('update:modelValue', parsed.isValid() ? normalizeTimeValue(parsed.format('HH:mm:ss')) : '')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.th-time-field :deep(.ant-picker) {
|
||||
width: 100%;
|
||||
border-radius: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,410 @@
|
||||
<template>
|
||||
<div class="th-page h-full min-h-0">
|
||||
<BasicShowContent title="ساعت کاری و نمایش قیمت" class="h-full">
|
||||
<header class="th-page__header">
|
||||
<p class="th-page__intro">
|
||||
ساعت باز و بسته بودن فروشگاه و رفتار نمایش قیمت در اپ مشتری
|
||||
</p>
|
||||
<AdminFieldHelp :text="tradingHoursPageIntro" size="sm" />
|
||||
<a-button class="th-page__refresh" :loading="loading" @click="load">بروزرسانی</a-button>
|
||||
</header>
|
||||
|
||||
<a-spin :spinning="loading">
|
||||
<div class="th-page__sections">
|
||||
<section class="th-card">
|
||||
<div class="th-card__head">
|
||||
<div class="th-card__title-row">
|
||||
<BaseIcon icon="vuesax-linear:clock" size="20" class="th-card__icon" />
|
||||
<div>
|
||||
<h2 class="th-card__title">ساعت کاری فروشگاه</h2>
|
||||
<p class="th-card__desc">کنترل باز و بسته بودن خرید و فروش در طول روز</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isSaving('close_price')" class="th-card__status">در حال ذخیره…</div>
|
||||
<div v-else-if="isSavedFlash('close_price')" class="th-card__status th-card__status--ok">
|
||||
ذخیره شد
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="th-card__switch-row">
|
||||
<div class="th-card__switch-copy">
|
||||
<AdminLabelWithHelp
|
||||
:label="rowMeta('close_price').title"
|
||||
:help="rowMeta('close_price').help"
|
||||
/>
|
||||
</div>
|
||||
<a-switch
|
||||
:checked="closePriceEnabled"
|
||||
checked-children="روشن"
|
||||
un-checked-children="خاموش"
|
||||
@change="(v) => setSwitch('close_price', !!v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="th-card__mode" :class="{ 'th-card__mode--disabled': !closePriceEnabled }">
|
||||
<div class="th-card__mode-head">
|
||||
<span class="th-card__mode-label">نوع برنامه</span>
|
||||
<a-radio-group
|
||||
:value="weekly.mode"
|
||||
option-type="button"
|
||||
button-style="solid"
|
||||
:disabled="!closePriceEnabled"
|
||||
@update:value="setScheduleMode"
|
||||
>
|
||||
<a-radio-button value="uniform">یکسان برای همه روزها</a-radio-button>
|
||||
<a-radio-button value="per_day">ساعت جدا برای هر روز</a-radio-button>
|
||||
</a-radio-group>
|
||||
</div>
|
||||
|
||||
<div v-if="weekly.mode === 'uniform'" class="th-uniform">
|
||||
<div class="th-uniform__pickers">
|
||||
<label class="th-field">
|
||||
<span class="th-field__label">
|
||||
<AdminLabelWithHelp
|
||||
:label="rowMeta('startDay').title"
|
||||
:help="rowMeta('startDay').help"
|
||||
/>
|
||||
</span>
|
||||
<TradingHoursTimeField
|
||||
:model-value="uniformStart"
|
||||
:disabled="!closePriceEnabled"
|
||||
@update:model-value="(v) => setUniformTimes(v, uniformEnd)"
|
||||
/>
|
||||
</label>
|
||||
<label class="th-field">
|
||||
<span class="th-field__label">
|
||||
<AdminLabelWithHelp
|
||||
:label="rowMeta('endDay').title"
|
||||
:help="rowMeta('endDay').help"
|
||||
/>
|
||||
</span>
|
||||
<TradingHoursTimeField
|
||||
:model-value="uniformEnd"
|
||||
:disabled="!closePriceEnabled"
|
||||
@update:model-value="(v) => setUniformTimes(uniformStart, v)"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="th-uniform__preview">
|
||||
<p class="th-uniform__preview-title">پیشنمایش هفته</p>
|
||||
<div class="th-uniform__days">
|
||||
<TradingHoursDayRow
|
||||
v-for="def in WEEKDAY_DEFS"
|
||||
:key="def.key"
|
||||
:weekday="def"
|
||||
:day="weekly.days[def.key]"
|
||||
compact
|
||||
disabled
|
||||
@update="() => {}"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="th-weekly">
|
||||
<p class="th-weekly__hint">
|
||||
برای هر روز میتوانید ساعت جدا بگذارید یا آن روز را کاملاً تعطیل کنید. فعلاً
|
||||
اپ از کوچکترین بازهٔ ذخیرهشده برای سازگاری استفاده میکند؛ اعمال دقیق
|
||||
روزانه در سرور بهزودی فعال میشود.
|
||||
</p>
|
||||
<div class="th-weekly__list">
|
||||
<TradingHoursDayRow
|
||||
v-for="def in WEEKDAY_DEFS"
|
||||
:key="def.key"
|
||||
:weekday="def"
|
||||
:day="weekly.days[def.key]"
|
||||
:disabled="!closePriceEnabled"
|
||||
@update="(patch) => setDaySchedule(def.key, patch)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="th-card">
|
||||
<div class="th-card__head">
|
||||
<div class="th-card__title-row">
|
||||
<BaseIcon icon="vuesax-linear:eye" size="20" class="th-card__icon" />
|
||||
<div>
|
||||
<h2 class="th-card__title">نمایش قیمت</h2>
|
||||
<p class="th-card__desc">رفتار اپ وقتی قیمت زنده نیست یا فروشگاه بسته است</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="th-display-grid">
|
||||
<article
|
||||
v-for="item in displayItems"
|
||||
:key="item.key"
|
||||
class="th-display-item"
|
||||
>
|
||||
<div class="th-display-item__head">
|
||||
<AdminLabelWithHelp :label="item.title" :help="item.help" />
|
||||
<div v-if="isSaving(item.key)" class="th-display-item__status">…</div>
|
||||
<div
|
||||
v-else-if="isSavedFlash(item.key)"
|
||||
class="th-display-item__status th-display-item__status--ok"
|
||||
>
|
||||
✓
|
||||
</div>
|
||||
</div>
|
||||
<a-switch
|
||||
:checked="isSwitchOn(draftValue(item.key))"
|
||||
checked-children="روشن"
|
||||
un-checked-children="خاموش"
|
||||
@change="(v) => setSwitch(item.key, !!v)"
|
||||
/>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</a-spin>
|
||||
</BasicShowContent>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import BasicShowContent from '@/components/BasicShowContext.vue'
|
||||
import AdminFieldHelp from '@/components/AdminFieldHelp.vue'
|
||||
import AdminLabelWithHelp from '@/components/AdminLabelWithHelp.vue'
|
||||
import { tradingHoursPageIntro } from '@/utils/adminHelpCopy'
|
||||
import { isSwitchOn } from '@/pages/menuPages/configuration/settingTypes'
|
||||
import { WEEKDAY_DEFS } from '@/pages/menuPages/configuration/tradingHoursKeys'
|
||||
import TradingHoursDayRow from './components/TradingHoursDayRow.vue'
|
||||
import TradingHoursTimeField from './components/TradingHoursTimeField.vue'
|
||||
import { useTradingHoursPage } from './useTradingHoursPage'
|
||||
|
||||
const {
|
||||
loading,
|
||||
closePriceEnabled,
|
||||
weekly,
|
||||
uniformStart,
|
||||
uniformEnd,
|
||||
draftValue,
|
||||
rowMeta,
|
||||
load,
|
||||
setSwitch,
|
||||
setUniformTimes,
|
||||
setScheduleMode,
|
||||
setDaySchedule,
|
||||
isSaving,
|
||||
isSavedFlash,
|
||||
} = useTradingHoursPage()
|
||||
|
||||
const displayItems = computed(() =>
|
||||
(['show_last_price', 'show_home_price_when_closed', 'show_gold_ounce_price'] as const).map(
|
||||
(key) => ({
|
||||
key,
|
||||
title: rowMeta(key).title,
|
||||
help: rowMeta(key).help,
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
void load()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.th-page__header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem 1rem;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.th-page__intro {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--admin-text-secondary);
|
||||
}
|
||||
|
||||
.th-page__refresh {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.th-page__sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
padding-bottom: 2rem;
|
||||
}
|
||||
|
||||
.th-card {
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 14px;
|
||||
background: var(--admin-bg-muted);
|
||||
padding: 1rem 1rem 1.25rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.th-card {
|
||||
padding: 1.25rem 1.5rem 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.th-card__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.th-card__title-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.th-card__icon {
|
||||
color: var(--admin-accent, #d4af37);
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.th-card__title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--admin-text);
|
||||
}
|
||||
|
||||
.th-card__desc {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--admin-text-secondary);
|
||||
}
|
||||
|
||||
.th-card__status {
|
||||
font-size: 0.75rem;
|
||||
color: var(--admin-text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.th-card__status--ok {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.th-card__switch-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.85rem 1rem;
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 12px;
|
||||
background: var(--admin-bg-elevated);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.th-card__switch-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.th-card__mode--disabled {
|
||||
opacity: 0.55;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.th-card__mode-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.th-card__mode-head {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
.th-card__mode-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--admin-text);
|
||||
}
|
||||
|
||||
.th-uniform__pickers {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.th-uniform__pickers {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.th-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.th-field__label {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.th-uniform__preview-title,
|
||||
.th-weekly__hint {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--admin-text-secondary);
|
||||
}
|
||||
|
||||
.th-uniform__days,
|
||||
.th-weekly__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.th-display-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.th-display-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.th-display-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
padding: 0.9rem 1rem;
|
||||
border: 1px solid var(--admin-border);
|
||||
border-radius: 12px;
|
||||
background: var(--admin-bg-elevated);
|
||||
min-height: 7.5rem;
|
||||
}
|
||||
|
||||
.th-display-item__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
min-height: 2.5rem;
|
||||
}
|
||||
|
||||
.th-display-item__status {
|
||||
font-size: 0.75rem;
|
||||
color: var(--admin-text-secondary);
|
||||
}
|
||||
|
||||
.th-display-item__status--ok {
|
||||
color: #52c41a;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,277 @@
|
||||
import { debounce } from 'lodash'
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Const } from '@/model/configuration'
|
||||
import { getEditableConfiguration, setConfiguration } from '@/api/configuration'
|
||||
import {
|
||||
formatValueForSave,
|
||||
isSwitchOn,
|
||||
normalizeTimeValue,
|
||||
resolveSettingFieldType,
|
||||
resolveSettingTitle,
|
||||
helpText,
|
||||
} from '@/pages/menuPages/configuration/settingTypes'
|
||||
import {
|
||||
DEFAULT_DAY_END,
|
||||
DEFAULT_DAY_START,
|
||||
TRADING_HOURS_SETTING_KEY_SET,
|
||||
WEEKDAY_DEFS,
|
||||
defaultWeeklySchedule,
|
||||
type DaySchedule,
|
||||
type WeekdayKey,
|
||||
type WeeklyScheduleDraft,
|
||||
} from '@/pages/menuPages/configuration/tradingHoursKeys'
|
||||
|
||||
function parseWeeklyJson(raw?: string | null): WeeklyScheduleDraft | null {
|
||||
if (!raw?.trim()) return null
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<WeeklyScheduleDraft>
|
||||
if (parsed.mode !== 'uniform' && parsed.mode !== 'per_day') return null
|
||||
if (!parsed.days || typeof parsed.days !== 'object') return null
|
||||
const base = defaultWeeklySchedule()
|
||||
for (const def of WEEKDAY_DEFS) {
|
||||
const row = (parsed.days as Record<string, Partial<DaySchedule>>)[def.key]
|
||||
if (!row) continue
|
||||
base.days[def.key] = {
|
||||
enabled: row.enabled !== false,
|
||||
start: normalizeTimeValue(row.start || base.days[def.key].start) || DEFAULT_DAY_START,
|
||||
end: normalizeTimeValue(row.end || base.days[def.key].end) || DEFAULT_DAY_END,
|
||||
}
|
||||
}
|
||||
base.mode = parsed.mode
|
||||
return base
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function serializeWeekly(draft: WeeklyScheduleDraft): string {
|
||||
return JSON.stringify(draft)
|
||||
}
|
||||
|
||||
function applyUniformTimes(draft: WeeklyScheduleDraft, start: string, end: string) {
|
||||
for (const def of WEEKDAY_DEFS) {
|
||||
draft.days[def.key] = {
|
||||
...draft.days[def.key],
|
||||
start,
|
||||
end,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function useTradingHoursPage() {
|
||||
const loading = ref(true)
|
||||
const rowsByKey = ref<Record<string, Const>>({})
|
||||
const drafts = ref<Record<string, string>>({})
|
||||
const savingKeys = ref<Set<string>>(new Set())
|
||||
const savedFlashKeys = ref<Set<string>>(new Set())
|
||||
const weekly = ref<WeeklyScheduleDraft>(defaultWeeklySchedule())
|
||||
const uniformStart = ref(DEFAULT_DAY_START)
|
||||
const uniformEnd = ref(DEFAULT_DAY_END)
|
||||
|
||||
const debouncedSave = new Map<string, ReturnType<typeof debounce>>()
|
||||
const saveTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const closePriceEnabled = computed(() => isSwitchOn(draftValue('close_price')))
|
||||
|
||||
function draftValue(key: string): string {
|
||||
const row = rowsByKey.value[key]
|
||||
if (!row?.id) return ''
|
||||
return drafts.value[String(row.id)] ?? row.value ?? ''
|
||||
}
|
||||
|
||||
function rowMeta(key: string) {
|
||||
const row = rowsByKey.value[key]
|
||||
return {
|
||||
title: row ? resolveSettingTitle(row) : key,
|
||||
help: row ? helpText(row) : '',
|
||||
exists: Boolean(row?.id),
|
||||
}
|
||||
}
|
||||
|
||||
function hydrateWeeklyFromRows() {
|
||||
const start = normalizeTimeValue(draftValue('startDay') || DEFAULT_DAY_START) || DEFAULT_DAY_START
|
||||
const end = normalizeTimeValue(draftValue('endDay') || DEFAULT_DAY_END) || DEFAULT_DAY_END
|
||||
uniformStart.value = start
|
||||
uniformEnd.value = end
|
||||
|
||||
const fromJson = parseWeeklyJson(draftValue('trading_hours_by_day'))
|
||||
if (fromJson) {
|
||||
weekly.value = fromJson
|
||||
return
|
||||
}
|
||||
|
||||
weekly.value = defaultWeeklySchedule(start, end)
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const rows = await getEditableConfiguration()
|
||||
const map: Record<string, Const> = {}
|
||||
const draftMap: Record<string, string> = {}
|
||||
for (const row of rows || []) {
|
||||
if (!row.key || !TRADING_HOURS_SETTING_KEY_SET.has(row.key)) continue
|
||||
map[row.key] = row
|
||||
if (row.id != null) draftMap[String(row.id)] = row.value ?? ''
|
||||
}
|
||||
rowsByKey.value = map
|
||||
drafts.value = draftMap
|
||||
hydrateWeeklyFromRows()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function markSavedFlash(key: string) {
|
||||
savedFlashKeys.value.add(key)
|
||||
if (saveTimers.has(key)) clearTimeout(saveTimers.get(key))
|
||||
saveTimers.set(
|
||||
key,
|
||||
setTimeout(() => {
|
||||
savedFlashKeys.value.delete(key)
|
||||
}, 1800)
|
||||
)
|
||||
}
|
||||
|
||||
async function persistKey(key: string, rawValue: string) {
|
||||
const row = rowsByKey.value[key]
|
||||
const type = resolveSettingFieldType(row ?? { key, type: 'text' })
|
||||
const value = formatValueForSave(rawValue, type, key)
|
||||
|
||||
if (row?.id) {
|
||||
const current = row.value ?? ''
|
||||
if (value === current && drafts.value[String(row.id)] === value) return
|
||||
}
|
||||
|
||||
savingKeys.value.add(key)
|
||||
savedFlashKeys.value.delete(key)
|
||||
|
||||
try {
|
||||
const payload: Const = row?.id
|
||||
? {
|
||||
id: row.id,
|
||||
key,
|
||||
value,
|
||||
description: row.description ?? '',
|
||||
is_active: true,
|
||||
title_fa: row.title_fa ?? '',
|
||||
type,
|
||||
}
|
||||
: {
|
||||
key,
|
||||
value,
|
||||
description: '',
|
||||
is_active: true,
|
||||
title_fa: key === 'trading_hours_by_day' ? 'برنامه هفتگی ساعت کاری' : '',
|
||||
type: key === 'trading_hours_by_day' ? 'longtext' : 'text',
|
||||
}
|
||||
|
||||
const saved = await setConfiguration(payload)
|
||||
rowsByKey.value[key] = { ...rowsByKey.value[key], ...saved, key }
|
||||
if (saved.id != null) {
|
||||
drafts.value[String(saved.id)] = value
|
||||
}
|
||||
markSavedFlash(key)
|
||||
} finally {
|
||||
savingKeys.value.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
function schedulePersist(key: string, value: string, options?: { flush?: boolean }) {
|
||||
if (!debouncedSave.has(key)) {
|
||||
debouncedSave.set(
|
||||
key,
|
||||
debounce((nextValue: string) => {
|
||||
void persistKey(key, nextValue)
|
||||
}, key === 'trading_hours_by_day' ? 500 : 350)
|
||||
)
|
||||
}
|
||||
const fn = debouncedSave.get(key)!
|
||||
if (options?.flush) {
|
||||
fn.cancel()
|
||||
void persistKey(key, value)
|
||||
return
|
||||
}
|
||||
fn(value)
|
||||
}
|
||||
|
||||
function setDraftByKey(key: string, value: string, options?: { flush?: boolean }) {
|
||||
const row = rowsByKey.value[key]
|
||||
if (row?.id != null) {
|
||||
drafts.value[String(row.id)] = value
|
||||
}
|
||||
schedulePersist(key, value, options)
|
||||
}
|
||||
|
||||
function setSwitch(key: string, checked: boolean) {
|
||||
setDraftByKey(key, checked ? 'true' : 'false', { flush: true })
|
||||
}
|
||||
|
||||
function setUniformTimes(start: string, end: string) {
|
||||
const normalizedStart = normalizeTimeValue(start) || DEFAULT_DAY_START
|
||||
const normalizedEnd = normalizeTimeValue(end) || DEFAULT_DAY_END
|
||||
uniformStart.value = normalizedStart
|
||||
uniformEnd.value = normalizedEnd
|
||||
applyUniformTimes(weekly.value, normalizedStart, normalizedEnd)
|
||||
weekly.value.mode = 'uniform'
|
||||
setDraftByKey('startDay', normalizedStart, { flush: true })
|
||||
setDraftByKey('endDay', normalizedEnd, { flush: true })
|
||||
setDraftByKey('trading_hours_by_day', serializeWeekly(weekly.value), { flush: true })
|
||||
}
|
||||
|
||||
function setScheduleMode(mode: 'uniform' | 'per_day') {
|
||||
weekly.value.mode = mode
|
||||
if (mode === 'uniform') {
|
||||
applyUniformTimes(weekly.value, uniformStart.value, uniformEnd.value)
|
||||
}
|
||||
setDraftByKey('trading_hours_by_day', serializeWeekly(weekly.value), { flush: true })
|
||||
}
|
||||
|
||||
function syncLegacyBoundsFromWeekly() {
|
||||
const enabled = WEEKDAY_DEFS.map((def) => weekly.value.days[def.key]).filter((day) => day.enabled)
|
||||
if (!enabled.length) return
|
||||
const starts = [...enabled].sort((a, b) => a.start.localeCompare(b.start))
|
||||
const ends = [...enabled].sort((a, b) => a.end.localeCompare(b.end))
|
||||
uniformStart.value = starts[0].start
|
||||
uniformEnd.value = ends[ends.length - 1].end
|
||||
setDraftByKey('startDay', uniformStart.value, { flush: true })
|
||||
setDraftByKey('endDay', uniformEnd.value, { flush: true })
|
||||
}
|
||||
|
||||
function setDaySchedule(day: WeekdayKey, patch: Partial<DaySchedule>) {
|
||||
weekly.value.days[day] = {
|
||||
...weekly.value.days[day],
|
||||
...patch,
|
||||
start: normalizeTimeValue(patch.start ?? weekly.value.days[day].start) || DEFAULT_DAY_START,
|
||||
end: normalizeTimeValue(patch.end ?? weekly.value.days[day].end) || DEFAULT_DAY_END,
|
||||
}
|
||||
weekly.value.mode = 'per_day'
|
||||
setDraftByKey('trading_hours_by_day', serializeWeekly(weekly.value))
|
||||
syncLegacyBoundsFromWeekly()
|
||||
}
|
||||
|
||||
function isSaving(key: string) {
|
||||
return savingKeys.value.has(key)
|
||||
}
|
||||
|
||||
function isSavedFlash(key: string) {
|
||||
return savedFlashKeys.value.has(key)
|
||||
}
|
||||
|
||||
return {
|
||||
loading,
|
||||
closePriceEnabled,
|
||||
weekly,
|
||||
uniformStart,
|
||||
uniformEnd,
|
||||
draftValue,
|
||||
rowMeta,
|
||||
load,
|
||||
setSwitch,
|
||||
setUniformTimes,
|
||||
setScheduleMode,
|
||||
setDaySchedule,
|
||||
isSaving,
|
||||
isSavedFlash,
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { RouteRecordRaw } from 'vue-router'
|
||||
import { hasSmsPanelAccess } from '@/utils/smsPanelAccess'
|
||||
import { isDeployAdmin } from '@/utils/deployAdmin'
|
||||
import { useUserStore } from '@/store/userStore'
|
||||
import { bannerEnabledSetting, trackingSystemsEnabledSetting, appNotificationEnabledSetting } from '@/utils/config'
|
||||
|
||||
@@ -59,10 +60,22 @@ const Setting: RouteRecordRaw = {
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'advancedConfigurationRedirect',
|
||||
name: 'advancedConfiguration',
|
||||
path: 'advanced-configuration',
|
||||
redirect: { name: 'configuration' },
|
||||
meta: { hideMenu: true }
|
||||
component: () => import('@/pages/menuPages/configuration/index.vue'),
|
||||
props: { mode: 'system' },
|
||||
meta: {
|
||||
deployAdminOnly: true,
|
||||
icon: 'vuesax-linear:code-circle',
|
||||
title: 'تنظیمات پیشرفته',
|
||||
order: 2.5
|
||||
},
|
||||
beforeEnter() {
|
||||
const userStore = useUserStore()
|
||||
if (!isDeployAdmin(userStore.userInfo?.mobile)) {
|
||||
return { name: 'configuration' }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'smsPanelMain',
|
||||
@@ -81,6 +94,16 @@ const Setting: RouteRecordRaw = {
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'tradingHours',
|
||||
path: 'trading-hours',
|
||||
component: () => import('@/pages/menuPages/tradingHours/index.vue'),
|
||||
meta: {
|
||||
icon: 'vuesax-linear:clock',
|
||||
title: 'ساعت کاری',
|
||||
order: 3.6
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'banners',
|
||||
path: 'banners',
|
||||
@@ -111,6 +134,16 @@ const Setting: RouteRecordRaw = {
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'automations',
|
||||
path: 'automations',
|
||||
component: () => import('@/pages/menuPages/automations/index.vue'),
|
||||
meta: {
|
||||
icon: 'vuesax-linear:flash',
|
||||
title: 'اتوماسیون',
|
||||
order: 4.7
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'smsMain',
|
||||
path: 'sms',
|
||||
|
||||
@@ -107,3 +107,66 @@ export const physicalWithdrawApproveHelp =
|
||||
|
||||
export const physicalWithdrawStatusHelp =
|
||||
'پیگیری مراحل ارسال: در انتظار ارسال، ارسال شده، لغو شده، یا در انتظار تعیین مجدد قیمت (برای محصولاتی که وزن نهایی مشخص نیست).'
|
||||
|
||||
export const automationAudienceHelp =
|
||||
'چه کسانی این پیام را بگیرند. «همه کاربران» یعنی همه مشتریان خانگی فعال. «با شرط» فقط کسانی که شرطهای زیر را دارند.'
|
||||
|
||||
export const automationNameHelp =
|
||||
'نام داخلی برای پیدا کردن این اتوماسیون در لیست پنل؛ به مشتری نشان داده نمیشود.'
|
||||
|
||||
export const automationTitleHelp =
|
||||
'عنوانی که همراه پیامک یا اعلان داخل اپ برای مشتری میآید.'
|
||||
|
||||
export const automationBodyHelp =
|
||||
'متن اصلی پیام. با دکمههای زیر فیلد میتوانید %نام، %نام_کوچک و %موبایل را در محل کرسر بگذارید تا موقع ارسال با اطلاعات هر مشتری جایگزین شود.'
|
||||
|
||||
export const automationTriggerTypeHelp =
|
||||
'زماندار: طبق فاصلهٔ روزها. رویداد: وقتی اتفاقی مثل ثبتنام بیفتد. قیمت: وقتی قیمت به اندازهٔ مشخصی حرکت کند.'
|
||||
|
||||
export const automationEveryDaysHelp =
|
||||
'هر چند روز یکبار این پیام برای مخاطبان واجد شرایط فرستاده میشود. مثلاً ۲۰ یعنی حدوداً هر ۲۰ روز یکبار برای هر نفر.'
|
||||
|
||||
export const automationEventHelp =
|
||||
'کدام رویداد سیستم باعث شروع این اتوماسیون شود (مثلاً ثبتنام یا خرید).'
|
||||
|
||||
export const automationEventTimingHelp =
|
||||
'سر ضرب: بلافاصله بعد از رویداد. با تأخیر: چند ساعت بعد از رویداد ارسال میشود.'
|
||||
|
||||
export const automationDelayHoursHelp =
|
||||
'چند ساعت بعد از رویداد پیام برود. مثلاً ۲۴ یعنی یک روز بعد.'
|
||||
|
||||
export const automationPriceWindowHelp =
|
||||
'در یک روز: تغییر قیمت از ابتدای همان روز تقویمی تا الان را میسنجد و در همان روز حداکثر یکبار میفرستد. از آخرین ارسال: پلهپله از آخرین باری که برای همان نفر فرستاده شده حساب میکند.'
|
||||
|
||||
export const automationPriceDirectionHelp =
|
||||
'بالا = فقط وقتی قیمت بالا رفته. پایین = فقط وقتی پایین آمده. هر دو = هر دو جهت.'
|
||||
|
||||
export const automationPriceStepHelp =
|
||||
'حداقل رقم تغییر قیمت به تومان که باید رخ دهد تا پیام برود. مثلاً ۱۰۰۰۰۰ یعنی صد هزار تومان.'
|
||||
|
||||
export const automationPriceProductHelp =
|
||||
'اگر خالی بگذارید قیمت تابلو/مرجع کلی استفاده میشود. اگر محصول انتخاب کنید، فقط حرکت قیمت همان محصول ملاک است.'
|
||||
|
||||
export const automationCooldownHelp =
|
||||
'حداقل فاصلهٔ ساعت بین دو ارسال موفق به یک نفر برای این اتوماسیون. جلوی اسپم شدن همان مشتری را میگیرد.'
|
||||
|
||||
export const automationMaxSendsHelp =
|
||||
'سقف تعداد دفعاتی که این اتوماسیون میتواند به یک نفر پیام بدهد. خالی = بدون سقف.'
|
||||
|
||||
export const automationSmsHelp =
|
||||
'روشن = ارسال پیامک. اگر ساعت انتخاب کنید، ارسال تا آن ساعت نگه داشته میشود؛ اگر خالی باشد طبق زمان تریگر میرود.'
|
||||
|
||||
export const automationInAppHelp =
|
||||
'روشن = اعلان داخل اپ مشتری. ساعت اختیاری مثل پیامک عمل میکند.'
|
||||
|
||||
export const automationStartsAtHelp =
|
||||
'از این تاریخ و ساعت به بعد اتوماسیون میتواند اجرا شود. خالی = از همین الان.'
|
||||
|
||||
export const automationEndsAtHelp =
|
||||
'بعد از این تاریخ و ساعت دیگر اجرا نمیشود. خالی = بدون پایان.'
|
||||
|
||||
export const automationStatusHelp =
|
||||
'فعال = موتور اتوماسیون این قانون را اجرا میکند. غیرفعال = ذخیره میماند ولی چیزی نمیفرستد.'
|
||||
|
||||
export const tradingHoursPageIntro =
|
||||
'ساعت باز و بسته بودن فروشگاه و رفتار نمایش قیمت در اپ مشتری. میتوانید برای همه روزها یک ساعت بگذارید یا برای هر روز هفته جداگانه تنظیم کنید.'
|
||||
|
||||
Reference in New Issue
Block a user