fix bug 22/4 2

This commit is contained in:
2026-07-13 19:03:03 +03:30
parent 61235b3bf8
commit 76813df062
15 changed files with 1020 additions and 70 deletions
@@ -12,7 +12,18 @@
<span class="setting-card__title">{{ title }}</span>
</div>
<div class="setting-card__key">کلید: {{ settingKey }}</div>
<div v-if="help" class="setting-card__help">{{ help }}</div>
<div v-if="switchHelp" class="setting-card__help setting-card__help--switch">
<div v-if="switchHelp.note" class="setting-card__help-note">{{ switchHelp.note }}</div>
<div v-if="switchHelp.on" class="setting-card__help-state">
<span class="setting-card__help-badge setting-card__help-badge--on">روشن</span>
<span class="setting-card__help-text">{{ switchHelp.on }}</span>
</div>
<div v-if="switchHelp.off" class="setting-card__help-state">
<span class="setting-card__help-badge setting-card__help-badge--off">خاموش</span>
<span class="setting-card__help-text">{{ switchHelp.off }}</span>
</div>
</div>
<div v-else-if="help" class="setting-card__help">{{ help }}</div>
</div>
<div
class="setting-card__body"
@@ -41,6 +52,7 @@
import SettingValueField from './SettingValueField.vue'
import {
helpText,
parseSwitchHelp,
resolveSettingTitle,
resolveSettingFieldType,
type SettingFieldType
@@ -70,6 +82,10 @@ const fieldType = computed<SettingFieldType>(() =>
)
const help = computed(() => helpText(props.row))
const switchHelp = computed(() =>
fieldType.value === 'switch' ? parseSwitchHelp(help.value) : null
)
</script>
<style lang="less" scoped>
@@ -136,6 +152,54 @@ const help = computed(() => helpText(props.row))
line-height: 1.35;
}
&__help--switch {
display: flex;
flex-direction: column;
gap: 4px;
}
&__help-note {
font-size: 11px;
line-height: 1.5;
color: var(--admin-text-secondary);
}
&__help-state {
display: flex;
align-items: flex-start;
gap: 6px;
font-size: 11px;
line-height: 1.6;
}
&__help-badge {
flex-shrink: 0;
min-width: 42px;
text-align: center;
padding: 1px 8px;
border-radius: 6px;
font-size: 11px;
font-weight: 700;
line-height: 1.6;
&--on {
color: #047857;
background: #d1fae5;
border: 1px solid #a7f3d0;
}
&--off {
color: #b91c1c;
background: #fee2e2;
border: 1px solid #fecaca;
}
}
&__help-text {
color: var(--admin-text-secondary);
padding-top: 1px;
}
&__body {
margin-top: 12px;
flex: 1;
@@ -0,0 +1,124 @@
<template>
<div
class="setting-toggle"
:class="{
'setting-toggle--on': checked,
'setting-toggle--off': !checked,
'setting-toggle--disabled': disabled,
}"
role="switch"
:aria-checked="checked"
>
<button
type="button"
class="setting-toggle__seg"
:class="{ 'setting-toggle__seg--active': !checked }"
:disabled="disabled"
@click="setChecked(false)"
>
<BaseIcon icon="vuesax-linear:close-circle" size="15" />
<span>{{ labels.off }}</span>
</button>
<button
type="button"
class="setting-toggle__seg"
:class="{ 'setting-toggle__seg--active': checked }"
:disabled="disabled"
@click="setChecked(true)"
>
<BaseIcon icon="vuesax-linear:tick-circle" size="15" />
<span>{{ labels.on }}</span>
</button>
</div>
</template>
<script setup lang="ts">
import BaseIcon from '@core/components/BaseIcon/src/Icon.vue'
const props = withDefaults(
defineProps<{
checked: boolean
labels: { on: string; off: string }
disabled?: boolean
}>(),
{ disabled: false },
)
const emit = defineEmits<{
change: [checked: boolean]
}>()
function setChecked(next: boolean) {
if (props.disabled || next === props.checked) return
emit('change', next)
}
</script>
<style lang="less" scoped>
.setting-toggle {
display: inline-flex;
align-items: stretch;
gap: 4px;
padding: 4px;
border-radius: 12px;
background: var(--admin-bg-muted, #f1f5f9);
border: 1px solid var(--admin-border, #e2e8f0);
user-select: none;
&--disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.setting-toggle__seg {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
min-width: 84px;
padding: 6px 14px;
border: none;
border-radius: 9px;
background: transparent;
color: var(--admin-text-secondary, #64748b);
font-size: 12px;
font-weight: 600;
font-family: inherit;
cursor: pointer;
transition: all 0.18s ease;
:deep(svg) {
opacity: 0.75;
}
&:disabled {
cursor: not-allowed;
}
&--active {
background: #fff;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
}
}
/* حالت روشن → سگمنت فعال سبز */
.setting-toggle--on .setting-toggle__seg--active {
background: #10b981;
color: #fff;
:deep(svg) {
opacity: 1;
}
}
/* حالت خاموش → سگمنت فعال خاکستری قرمزفام */
.setting-toggle--off .setting-toggle__seg--active {
background: #fff;
color: #ef4444;
:deep(svg) {
opacity: 1;
}
}
</style>
@@ -1,14 +1,12 @@
<template>
<div class="setting-value-field">
<a-switch
<SettingToggle
v-if="fieldType === 'switch'"
:checked="switchChecked"
:labels="labels"
:disabled="disabled"
@change="onSwitchChange"
>
<template #checkedChildren>{{ labels.on }}</template>
<template #unCheckedChildren>{{ labels.off }}</template>
</a-switch>
/>
<SupportMessengersField
v-else-if="isSupportMessengers"
@@ -38,6 +36,15 @@
@update:value="emit('update:value', $event)"
/>
<TemplateTextField
v-else-if="isTemplateField"
:model-value="modelValue"
:setting-key="settingKey || ''"
:disabled="disabled"
@update:value="emit('update:value', $event)"
@blur="emit('blur')"
/>
<a-select
v-else-if="fieldType === 'select'"
:value="modelValue || undefined"
@@ -124,8 +131,11 @@
<script setup lang="ts">
import MainWalletProductsField from './MainWalletProductsField.vue'
import PartnerServerUrlsField from './PartnerServerUrlsField.vue'
import SettingToggle from './SettingToggle.vue'
import SupportMessengersField from './SupportMessengersField.vue'
import TemplateTextField from './TemplateTextField.vue'
import ZaarboxPriceKeyField from './ZaarboxPriceKeyField.vue'
import { hasTemplateField } from './templateFields'
import {
parseSelectOptions,
resolveSettingFieldType,
@@ -163,6 +173,7 @@ const isZaarboxPriceKey = computed(() => props.settingKey === 'zaarbox_price_key
const isMainWalletProducts = computed(() => props.settingKey === 'main_wallet_products')
const isSupportMessengers = computed(() => props.settingKey === 'support_messengers')
const isPartnerServerUrls = computed(() => props.settingKey === 'zaarbox_api_url')
const isTemplateField = computed(() => hasTemplateField(props.settingKey))
const selectItems = computed(() =>
parseSelectOptions({
@@ -0,0 +1,332 @@
<template>
<div class="tpl-field">
<div class="tpl-field__grid">
<!-- ویرایشگر قالب -->
<div class="tpl-field__editor">
<div class="tpl-field__section-title">
<BaseIcon icon="vuesax-linear:edit-2" size="15" />
<span>{{ config.editorTitle }}</span>
</div>
<textarea
ref="textareaRef"
class="tpl-field__textarea"
:value="modelValue"
:disabled="disabled"
:rows="config.rows"
spellcheck="false"
:placeholder="config.placeholder"
@input="onInput"
@blur="emit('blur')"
/>
<div class="tpl-field__hint">
<BaseIcon icon="vuesax-linear:info-circle" size="13" />
<span>
{{ config.hint }} متغیرها با <code>%</code> یا <code>٪</code> نوشته می‌شوند.
</span>
</div>
<!-- پالت متغیرها -->
<div class="tpl-field__vars">
<div v-for="group in config.variableGroups" :key="group.title" class="tpl-vargroup">
<div class="tpl-vargroup__head">
<span class="tpl-vargroup__dot" :style="{ background: group.color }" />
<span class="tpl-vargroup__title">{{ group.title }}</span>
</div>
<div class="tpl-vargroup__chips">
<button
v-for="v in group.items"
:key="v.token"
type="button"
class="tpl-chip"
:class="{ 'tpl-chip--disabled': disabled }"
:disabled="disabled"
:title="v.help"
@click="insertToken(v.token)"
>
<span class="tpl-chip__token">%{{ v.token }}</span>
<span class="tpl-chip__label">{{ v.label }}</span>
</button>
</div>
</div>
</div>
</div>
<!-- پیش‌نمایش -->
<div class="tpl-field__preview">
<div class="tpl-field__section-title">
<BaseIcon icon="vuesax-linear:eye" size="15" />
<span>{{ config.previewTitle }}</span>
<span class="tpl-field__preview-tag">نمونه</span>
</div>
<div class="tpl-preview">
<div class="tpl-preview__bubble" dir="auto">{{ previewText }}</div>
</div>
<div class="tpl-field__legend">
{{ config.legend }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue'
import BaseIcon from '@core/components/BaseIcon/src/Icon.vue'
import { getTemplateFieldConfig, TEMPLATE_FIELD_CONFIGS, type TemplateFieldConfig } from './templateFields'
const props = withDefaults(
defineProps<{
modelValue: string
settingKey: string
disabled?: boolean
}>(),
{ disabled: false },
)
const emit = defineEmits<{
'update:value': [value: string]
blur: []
}>()
const textareaRef = ref<HTMLTextAreaElement>()
const config = computed<TemplateFieldConfig>(
() => getTemplateFieldConfig(props.settingKey) ?? TEMPLATE_FIELD_CONFIGS.price_channel_message_text,
)
function escapeRegExp(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
const previewText = computed(() => {
const cfg = config.value
let out = (props.modelValue ?? '').trim() || cfg.defaultTemplate
const keys = Object.keys(cfg.sampleValues).sort((a, b) => b.length - a.length)
for (const key of keys) {
const re = new RegExp('[%٪]\\s*' + escapeRegExp(key), 'gu')
out = out.replace(re, cfg.sampleValues[key])
}
return out.replace(/\n{3,}/g, '\n\n').trim()
})
function onInput(event: Event) {
emit('update:value', (event.target as HTMLTextAreaElement).value)
}
function insertToken(token: string) {
if (props.disabled) return
const insert = `%${token}`
const current = props.modelValue ?? ''
const el = textareaRef.value
if (!el) {
emit('update:value', current ? `${current}${insert}` : insert)
return
}
const start = el.selectionStart ?? current.length
const end = el.selectionEnd ?? current.length
const next = current.slice(0, start) + insert + current.slice(end)
emit('update:value', next)
nextTick(() => {
el.focus()
const pos = start + insert.length
el.setSelectionRange(pos, pos)
})
}
</script>
<style lang="less" scoped>
.tpl-field {
width: 100%;
}
.tpl-field__grid {
display: grid;
grid-template-columns: 1fr;
gap: 16px;
@media (min-width: 992px) {
grid-template-columns: 1.15fr 0.85fr;
}
}
.tpl-field__section-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: var(--admin-text);
margin-bottom: 8px;
:deep(svg) {
color: #d4af37;
}
}
.tpl-field__preview-tag {
margin-inline-start: auto;
font-size: 10px;
font-weight: 500;
color: #b45309;
background: #fff7ed;
border: 1px solid #fed7aa;
border-radius: 6px;
padding: 1px 8px;
}
.tpl-field__textarea {
width: 100%;
min-height: 160px;
resize: vertical;
padding: 12px 14px;
border: 1px solid var(--admin-border);
border-radius: 10px;
background: var(--admin-bg-elevated);
color: var(--admin-text);
font-size: 13px;
line-height: 2;
font-family: 'Vazirmatn', 'IRANSans', ui-monospace, monospace;
direction: rtl;
transition: border-color 0.2s, box-shadow 0.2s;
&:focus {
outline: none;
border-color: #d4af37;
box-shadow: 0 0 0 3px rgba(212, 175, 55, 0.15);
}
&:disabled {
background: var(--admin-bg-muted);
cursor: not-allowed;
opacity: 0.7;
}
}
.tpl-field__hint {
display: flex;
align-items: flex-start;
gap: 6px;
margin-top: 8px;
font-size: 11px;
line-height: 1.7;
color: var(--admin-text-secondary);
:deep(svg) {
flex-shrink: 0;
margin-top: 2px;
color: #3b82f6;
}
code {
background: var(--admin-bg-muted);
border-radius: 4px;
padding: 0 4px;
}
}
.tpl-field__vars {
margin-top: 14px;
display: flex;
flex-direction: column;
gap: 12px;
}
.tpl-vargroup__head {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
.tpl-vargroup__dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.tpl-vargroup__title {
font-size: 12px;
font-weight: 600;
color: var(--admin-text-secondary);
}
.tpl-vargroup__chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tpl-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
border: 1px solid var(--admin-border);
border-radius: 8px;
background: var(--admin-bg-elevated);
cursor: pointer;
transition: all 0.15s ease;
font-family: inherit;
&:hover:not(.tpl-chip--disabled) {
border-color: #d4af37;
background: #fffaf0;
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(212, 175, 55, 0.15);
}
&--disabled {
cursor: not-allowed;
opacity: 0.5;
}
&__token {
font-size: 12px;
font-weight: 700;
color: #b45309;
direction: ltr;
}
&__label {
font-size: 11px;
color: var(--admin-text-secondary);
}
}
.tpl-preview {
background: linear-gradient(160deg, #e8f5e9 0%, #eef7ff 100%);
border: 1px solid var(--admin-border);
border-radius: 12px;
padding: 14px;
min-height: 160px;
}
.tpl-preview__bubble {
background: #fff;
border: 1px solid #e5e7eb;
border-radius: 12px 12px 12px 4px;
padding: 12px 14px;
font-size: 13px;
line-height: 2;
color: #111827;
white-space: pre-wrap;
word-break: break-word;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
}
.tpl-field__legend {
margin-top: 8px;
font-size: 11px;
line-height: 1.7;
color: var(--admin-text-secondary);
}
</style>
@@ -290,6 +290,7 @@ const KEY_TYPE_FALLBACK: Record<string, SettingFieldType> = {
price_channel_bale_to: 'text',
price_channel_telegram_to: 'text',
price_channel_rubika_to: 'text',
price_channel_message_text: 'longtext',
rubika_bot_token: 'text',
change_mesghal: 'number'
}
@@ -429,6 +430,54 @@ export function switchLabels(key: string | undefined): { on: string; off: string
return { on: 'فعال', off: 'غیرفعال' }
}
export interface SwitchHelpParts {
note: string
on: string
off: string
}
/**
* تفکیک توضیح سوییچ به بخش‌های «روشن» و «خاموش» برای نمایش خط‌به‌خط.
* از نشانگرهای «روشن:»/«روشن =» و «خاموش:»/«خاموش =» استفاده می‌کند.
* اگر هیچ نشانگری نبود null برمی‌گرداند تا متن ساده نمایش داده شود.
*/
export function parseSwitchHelp(help: string | undefined | null): SwitchHelpParts | null {
const text = (help ?? '').trim()
if (!text) return null
const onMatch = /روشن\s*[:=]\s*/.exec(text)
const offMatch = /خاموش\s*[:=]\s*/.exec(text)
if (!onMatch && !offMatch) return null
const trim = (s: string) => s.replace(/^[\s،؛.:]+|[\s،؛:]+$/g, '').trim()
let note = ''
let on = ''
let off = ''
if (onMatch && offMatch) {
const onIdx = onMatch.index
const offIdx = offMatch.index
if (onIdx < offIdx) {
note = text.slice(0, onIdx)
on = text.slice(onIdx + onMatch[0].length, offIdx)
off = text.slice(offIdx + offMatch[0].length)
} else {
note = text.slice(0, offIdx)
off = text.slice(offIdx + offMatch[0].length, onIdx)
on = text.slice(onIdx + onMatch[0].length)
}
} else if (onMatch) {
note = text.slice(0, onMatch.index)
on = text.slice(onMatch.index + onMatch[0].length)
} else if (offMatch) {
note = text.slice(0, offMatch.index)
off = text.slice(offMatch.index + offMatch[0].length)
}
return { note: trim(note), on: trim(on), off: trim(off) }
}
/** توضیحات fallback — وقتی در کاتالوگ تعریف شده، بر description دیتابیس اولویت دارد */
const KEY_HELP_FALLBACK: Record<string, string> = {
name_fa:
@@ -442,13 +491,13 @@ const KEY_HELP_FALLBACK: Record<string, string> = {
auto_active_customer:
'روشن: حساب بلافاصله پس از ثبت‌نام فعال می‌شود. خاموش: نیاز به تأیید دستی شما در پنل دارد.',
close_price:
'فیلتر اصلی فروشگاه. وقتی روشن باشد، خرید و فروش و نمایش قیمت فقط بین «ساعت شروع» و «ساعت پایان» انجام می‌شود و خارج از این بازه به مشتری «فروشگاه بسته است» نشان داده می‌شود. خاموش: محدودیت ساعتی سراسری اعمال نمی‌شود (مگر برای محصولی ساعت اختصاصی تعریف کرده باشید).',
'فیلتر اصلی ساعت کاری فروشگاه. روشن: خرید، فروش و نمایش قیمت فقط بین «ساعت شروع» و «ساعت پایان» انجام می‌شود و خارج از این بازه به مشتری «فروشگاه بسته است» نشان داده می‌شود. خاموش: محدودیت ساعتی سراسری اعمال نمی‌شود (مگر برای محصولی ساعت اختصاصی تعریف کرده باشید).',
startDay:
'ساعت شروع فعالیت روزانه فروشگاه. فقط وقتی «بستن خودکار خارج از ساعت کاری» روشن باشد اعمال می‌شود. از ساعت‌شمار انتخاب کنید (مثلاً ۰۹:۰۰).',
endDay:
'ساعت پایان فعالیت روزانه فروشگاه. فقط وقتی «بستن خودکار خارج از ساعت کاری» روشن باشد اعمال می‌شود. بعد از این ساعت فروشگاه بسته می‌شود (مثلاً ۱۷:۰۰ یا ۲۲:۰۰).',
show_last_price:
'وقتی قیمت زنده در دسترس نباشد: روشن = آخرین قیمت ذخیره‌شده نشان داده می‌شود؛ خاموش = به‌جای صفر پیام «مظنه در دسترس نیست» نمایش داده می‌شود.',
'رفتار هنگام در دسترس نبودن قیمت زنده. روشن: آخرین قیمت ذخیره‌شده نشان داده می‌شود. خاموش: به‌جای صفر پیام «مظنه در دسترس نیست» نمایش داده می‌شود.',
show_gold_ounce_price:
'روشن: اگر آدرس API تابلو (zaarbox_api_url) تنظیم شده باشد، قیمت انس طلا در اپ مشتری نمایش داده می‌شود. خاموش: نمایش داده نمی‌شود.',
fee_single:
@@ -482,9 +531,9 @@ const KEY_HELP_FALLBACK: Record<string, string> = {
gift_for_referral:
'مقدار پاداش (معمولاً وزن فلز) که به معرف‌شونده واریز می‌شود. صفر = بدون پاداش. زمان واریز بسته به «پاداش معرف پس از اولین سفارش» است.',
register_referral_sms_to_referrer:
'روشن: پس از ثبت‌نام کاربر جدید، پیامک اطلاع‌رسانی برای معرف ارسال می‌شود.',
'روشن: پس از ثبت‌نام کاربر جدید، پیامک اطلاع‌رسانی برای معرف ارسال می‌شود. خاموش: پیامکی برای معرف ارسال نمی‌شود.',
register_referral_bonus_message:
'روشن: متن پاداش معرف در پیامک خوش‌آمد ثبت‌نام گنجانده می‌شود.',
'روشن: متن پاداش معرف در پیامک خوش‌آمد ثبت‌نام گنجانده می‌شود. خاموش: متن پاداش در پیامک خوش‌آمد نمی‌آید.',
referral_gift_on_first_order:
'روشن: پاداش معرف (gift_for_referrer) و معرف‌شونده (gift_for_referral) پس از تکمیل اولین سفارش معرف‌شونده به کیف واریز می‌شود — فقط اگر مقدار هر پاداش بیشتر از صفر باشد. خاموش: پاداش بلافاصله پس از ثبت‌نام موفق یا تأیید احراز هویت واریز می‌شود (باز هم فقط برای مقادیر بیشتر از صفر).',
referral_limit:
@@ -492,13 +541,13 @@ const KEY_HELP_FALLBACK: Record<string, string> = {
referral_bonus_amount:
'مبلغ یا مقدار پاداش معرف برای نمایش در پیامک‌ها و متن‌های دعوت.',
jibit_verification:
'روشن: هنگام ثبت یا ویرایش کارت بانکی، تطبیق با سرویس احراز هویت جیبیت انجام می‌شود.',
'روشن: هنگام ثبت یا ویرایش کارت بانکی، تطبیق با سرویس احراز هویت جیبیت انجام می‌شود. خاموش: کارت بدون تطبیق جیبیت ثبت می‌شود.',
register_jibit_verify:
'روشن: در ثبت‌نام، تطبیق کد ملی و شماره موبایل از طریق جیبیت انجام می‌شود.',
'روشن: در ثبت‌نام، تطبیق کد ملی و شماره موبایل از طریق جیبیت انجام می‌شود. خاموش: ثبت‌نام بدون تطبیق جیبیت انجام می‌شود.',
has_page_earn_money:
'روشن: منوی «کسب درآمد / دعوت دوستان» در اپ مشتری نمایش داده می‌شود.',
'روشن: منوی «کسب درآمد / دعوت دوستان» در اپ مشتری نمایش داده می‌شود. خاموش: این منو در اپ نمایش داده نمی‌شود.',
invite_link_text:
'متن پیام اشتراک‌گذاری دعوت. می‌توانید از ٪نام برای نام کاربر و ٪لینک برای لینک ثبت‌نام با کد معرف استفاده کنید. خالی = متن پیش‌فرض اپ.',
'متن پیام اشتراک‌گذاری دعوت. متن را بنویسید و متغیرها (نام کاربر، لینک ثبت‌نام) را با کلیک اضافه کنید؛ پیش‌نمایش نتیجه را با مقادیر نمونه نشان می‌دهد. خالی = متن پیش‌فرض اپ.',
montzar_mobile:
'شماره موبایل حساب اتصال به سرویس همکار قیمت.',
montzar_password:
@@ -510,13 +559,13 @@ const KEY_HELP_FALLBACK: Record<string, string> = {
zaarbox_api_url:
'آدرس سرور(های) فید قیمت (/api/free). تا ۵ آدرس می‌توانید اضافه کنید؛ اگر آدرس اول پاسخ ندهد به‌ترتیب سراغ بعدی می‌رود. آدرس فعلی در ردیف اول حفظ می‌شود.',
montzar_open_buy:
'روشن: ثبت سفارش خرید از طریق قیمت همکار مجاز است (وضعیت واقعی از سرویس خارجی خوانده می‌شود).',
'وضعیت واقعی از سرویس خارجی خوانده می‌شود. روشن: ثبت سفارش خرید از طریق قیمت همکار مجاز است. خاموش: ثبت سفارش خرید از طریق قیمت همکار غیرفعال است.',
montzar_open_sell:
'روشن: ثبت سفارش فروش از طریق قیمت همکار مجاز است.',
'وضعیت واقعی از سرویس خارجی خوانده می‌شود. روشن: ثبت سفارش فروش از طریق قیمت همکار مجاز است. خاموش: ثبت سفارش فروش از طریق قیمت همکار غیرفعال است.',
montzar_closed:
'وضعیت گزارش‌شده از سرویس همکار؛ فقط برای نمایش — این سوییچ را دستی تغییر ندهید.',
external_order_api_enabled:
'روشن: دریافت کیف پول و ثبت سفارش از سرویس فروشگاه خارجی فعال است.',
'روشن: دریافت کیف پول و ثبت سفارش از سرویس فروشگاه خارجی فعال است. خاموش: اتصال به سرویس فروشگاه خارجی غیرفعال است.',
node_client_id:
'شناسه کلاینت برای اتصال امن به سرویس سفارش خارجی.',
node_shared_secret:
@@ -532,15 +581,9 @@ const KEY_HELP_FALLBACK: Record<string, string> = {
support_messenger_id:
'شناسه تماس در پیام‌رسان انتخاب‌شده: یوزرنیم، آیدی یا لینک کامل. فقط روی آیکون پیام‌رسان و دکمه شناور اعمال می‌شود؛ تماس تلفنی از فیلد «شماره پشتیبانی» خوانده می‌شود.',
support_messenger_text:
'متن راهنمای واریز در مودال شارژ حساب. متغیرها: ٪نام یا %نام = نام صاحب حساب؛ ٪کارت یا %کارت = شماره کارت؛ ٪شبا یا %شبا = شبا. فاصله بعد از ٪ اختیاری است. اگر خالی باشد از متن پیش‌فرض اپ استفاده می‌شود.',
'متن راهنمای واریز در مودال شارژ حساب. متن را بنویسید و متغیرها (نام صاحب حساب، شماره کارت، شبا) را با کلیک اضافه کنید؛ پیش‌نمایش نتیجه را با مقادیر نمونه نشان می‌دهد. خالی = متن پیش‌فرض اپ.',
deposit_support_message_text:
`متن پیام هنگام کلیک «پیام به پشتیبانی» در مودال واریز (SMS و پیام‌رسان).
متغیر:
• %نام یا ٪نام = نام مشتری واردشده (اگر خالی بود، نام صاحب حساب از تنظیمات)
پیش‌فرض اپ:
باسلام، %نام هستم. کدپیگیری و مبلغ واریزی من:`,
'متن پیام هنگام کلیک «پیام به پشتیبانی» در مودال واریز (SMS و پیام‌رسان). متن را بنویسید و متغیر «نام مشتری» را با کلیک اضافه کنید؛ پیش‌نمایش نتیجه را نشان می‌دهد. خالی = متن پیش‌فرض اپ.',
deposit_account_holder:
'نام صاحب حساب واریز — در متن راهنمای واریز با ٪نام یا %نام جایگزین می‌شود. در پیام پشتیبانی فقط وقتی نام مشتری موجود نباشد استفاده می‌شود.',
deposit_card_number:
@@ -550,11 +593,11 @@ const KEY_HELP_FALLBACK: Record<string, string> = {
deposit_sms_support_number:
'شماره تماس پشتیبانی؛ با کلیک روی «تماس با پشتیبانی» در اپ، تماس تلفنی برقرار می‌شود. در صفحه واریز، درباره ما و قوانین هم نمایش داده می‌شود.',
charge_notif:
'روشن: پس از شارژ کیف توسط ادمین، پیامک به مشتری ارسال می‌شود.',
'روشن: پس از شارژ کیف توسط ادمین، پیامک به مشتری ارسال می‌شود. خاموش: پیامکی پس از شارژ ارسال نمی‌شود.',
group_sms:
'روشن: امکان ارسال پیامک گروهی از پنل ادمین فعال است.',
'روشن: امکان ارسال پیامک گروهی از پنل ادمین فعال است. خاموش: ارسال پیامک گروهی غیرفعال است.',
profit_gold_api_enabled:
'روشن: محاسبه سود طلا از API خارجی فعال است. در اکثر نصب‌ها خاموش می‌ماند.',
'روشن: محاسبه سود طلا از API خارجی فعال است. خاموش: محاسبه سود از API خارجی انجام نمی‌شود (حالت پیش‌فرض اکثر نصب‌ها).',
main_wallet_products:
'محصولاتی که موجودی‌شان از کیف طلای اصلی خوانده می‌شود. می‌توانید چند محصول انتخاب کنید. اگر خالی بماند، پیش‌فرض محصول ۱۳ است.',
unit:
@@ -572,7 +615,7 @@ const KEY_HELP_FALLBACK: Record<string, string> = {
zaarbox_price_key:
'شناسه ردیف قیمت در پاسخ سرویس تابلو.',
payping_fin_sync_enabled:
'روشن: پس از واریز یا برداشت، تراکنش به سامانه حسابداری مالی همگام می‌شود.',
'روشن: پس از واریز یا برداشت، تراکنش به سامانه حسابداری مالی همگام می‌شود. خاموش: تراکنش‌ها به سامانه حسابداری مالی ارسال نمی‌شوند.',
payping_fin_url:
'آدرس ثبت تراکنش در سامانه حسابداری مالی.',
payping_fin_token:
@@ -590,7 +633,7 @@ const KEY_HELP_FALLBACK: Record<string, string> = {
payping_round_toman:
'روشن: مبلغ پرداخت به نزدیک‌ترین تومان گرد می‌شود. خاموش: مبلغ دقیق ریالی.',
payping_strict_failed_withdraw:
'روشن: در صورت برداشت ناموفق قبلی، تسویه جدید با احتیاط بیشتر انجام می‌شود.',
'روشن: در صورت برداشت ناموفق قبلی، تسویه جدید با احتیاط بیشتر انجام می‌شود. خاموش: تسویه به‌صورت عادی و بدون احتیاط اضافه انجام می‌شود.',
send_to_tahesab:
'تنظیم قدیمی — از «سیستم حسابداری» استفاده کنید.',
accounting_provider:
@@ -641,6 +684,8 @@ const KEY_HELP_FALLBACK: Record<string, string> = {
'آیدی چنل یا گروه تلگرام که قیمت برایش ارسال می‌شود. چند مقصد را با ویرگول جدا کنید.',
price_channel_rubika_to:
'آیدی چنل یا گروه روبیکا که قیمت برایش ارسال می‌شود. چند مقصد را با ویرگول جدا کنید.',
price_channel_message_text:
'قالب پیامی که با تغییر قیمت به کانال ارسال می‌شود. متن را بنویسید و متغیرها را با کلیک اضافه کنید؛ پیش‌نمایش، پیام نهایی را با مقادیر نمونه نشان می‌دهد. خالی = قالب پیش‌فرض.',
rubika_bot_token:
'توکن ربات روبیکا برای ارسال قیمت به کانال. محرمانه نگه دارید.',
change_mesghal:
@@ -701,6 +746,7 @@ export const KEY_LABEL_FALLBACK: Record<string, string> = {
price_channel_bale_to: 'مقصد ارسال بله',
price_channel_telegram_to: 'مقصد ارسال تلگرام',
price_channel_rubika_to: 'مقصد ارسال روبیکا',
price_channel_message_text: 'قالب متن پیام کانال',
rubika_bot_token: 'توکن بات روبیکا',
change_mesghal: 'وزن مثقال (گرم)'
}
@@ -0,0 +1,234 @@
/**
* کاتالوگ فیلدهای «قالب متن با متغیر» — همهٔ تنظیماتی که placeholder با % یا ٪ دارند.
* هر کلید یک ویرایشگر متن با چیپ‌های متغیر و پیش‌نمایش زنده می‌گیرد.
*/
export interface TemplateVariableItem {
/** توکن بدون % (مثلاً «نام») */
token: string
/** برچسب کوتاه فارسی روی چیپ */
label: string
/** توضیح کامل (tooltip) */
help: string
}
export interface TemplateVariableGroup {
title: string
color: string
items: TemplateVariableItem[]
}
export interface TemplateFieldConfig {
/** گروه‌های متغیر برای پالت چیپ‌ها */
variableGroups: TemplateVariableGroup[]
/** مقدار نمونه هر توکن برای پیش‌نمایش */
sampleValues: Record<string, string>
/** قالب پیش‌فرض (وقتی فیلد خالی است در پیش‌نمایش نشان داده می‌شود) */
defaultTemplate: string
/** عنوان بالای ویرایشگر */
editorTitle: string
/** عنوان بالای پیش‌نمایش */
previewTitle: string
/** placeholder داخل textarea */
placeholder: string
/** متن راهنمای زیر textarea */
hint: string
/** توضیح زیر پیش‌نمایش */
legend: string
/** تعداد پیش‌فرض ردیف‌های textarea */
rows: number
}
function toFa(input: string): string {
const fa = '۰۱۲۳۴۵۶۷۸۹'
return input.replace(/[0-9]/g, (d) => fa[Number(d)])
}
const PRICE_CHANNEL_DEFAULT = [
'سلام 👋',
'%عنوان',
'📅 %تاریخ 🕐 %ساعت',
'',
'🔴 خرید گرم: %گرم_خرید',
'🟢 فروش گرم: %گرم_فروش',
'🔴 خرید مثقال: %مثقال_خرید',
'🟢 فروش مثقال: %مثقال_فروش',
'',
'📞 %شماره',
'%آیدی',
'%لینک',
].join('\n')
const INVITE_DEFAULT = [
'⭐️الان طلا بخر؛ آخر ماه سودشو ببر!',
'با خرید طلای آبشده در مقدارهای کم ولی مستمر، سود بیشتری ببر!',
'',
'این دعوت نامه از طرف "%نام" عزیز برای شما ارسال شده تا شما هم از بازار طلای ما جا نمونید.',
'روی لینک زیر کلیک کنید و ثبت نام خود را تکمیل کنید.',
'',
'%لینک',
].join('\n')
const DEPOSIT_GUIDE_DEFAULT = [
'برای شارژ حساب، مبلغ دلخواه را به کارت زیر واریز کنید:',
'',
'صاحب حساب: %نام',
'شماره کارت: %کارت',
'شماره شبا: %شبا',
'',
'پس از واریز، از دکمه «پیام به پشتیبانی» فیش را ارسال کنید.',
].join('\n')
const DEPOSIT_SUPPORT_DEFAULT = 'باسلام، %نام هستم. کدپیگیری و مبلغ واریزی من:'
export const TEMPLATE_FIELD_CONFIGS: Record<string, TemplateFieldConfig> = {
price_channel_message_text: {
editorTitle: 'متن قالب پیام',
previewTitle: 'پیش‌نمایش پیام',
placeholder: 'متن پیام کانال را بنویسید و متغیرها را از پایین اضافه کنید…',
hint: 'روی هر متغیر کلیک کنید تا در محل نشانگر متن اضافه شود. اگر متن را خالی بگذارید، قالب پیش‌فرض ارسال می‌شود.',
legend:
'مقادیر بالا نمونه‌اند؛ در پیام واقعی، قیمت‌ها زنده و نام/شماره/آیدی/لینک از تنظیمات همین بخش پر می‌شوند.',
rows: 12,
defaultTemplate: PRICE_CHANNEL_DEFAULT,
variableGroups: [
{
title: 'قیمت تابلو',
color: '#d4af37',
items: [
{ token: 'گرم_خرید', label: 'خرید هر گرم', help: 'قیمت خرید هر گرم' },
{ token: 'گرم_فروش', label: 'فروش هر گرم', help: 'قیمت فروش هر گرم (عدد بزرگ صفحهٔ اصلی اپ)' },
{ token: 'مثقال_خرید', label: 'خرید هر مثقال', help: 'قیمت خرید هر مثقال' },
{ token: 'مثقال_فروش', label: 'فروش هر مثقال', help: 'قیمت فروش هر مثقال' },
],
},
{
title: 'زمان',
color: '#3b82f6',
items: [
{ token: 'تاریخ', label: 'تاریخ شمسی', help: 'تاریخ شمسی امروز' },
{ token: 'ساعت', label: 'ساعت', help: 'ساعت لحظهٔ ارسال' },
],
},
{
title: 'نام و عنوان',
color: '#8b5cf6',
items: [
{ token: 'نام', label: 'نام فروشگاه', help: 'فقط نام فروشگاه (از تنظیم «نام فارسی»)' },
{ token: 'عنوان', label: 'عنوان آماده', help: 'خط آمادهٔ «📊 قیمت لحظه‌ای + نام فروشگاه»' },
],
},
{
title: 'تماس (از فیلدهای همین بخش)',
color: '#10b981',
items: [
{ token: 'شماره', label: 'شماره تماس', help: 'از فیلد «شماره تماس در پیام»' },
{ token: 'آیدی', label: 'آیدی کانال', help: 'از فیلد «آیدی کانال در پیام»' },
{ token: 'لینک', label: 'لینک', help: 'از فیلد «لینک در پیام»' },
],
},
{
title: 'اختیاری',
color: '#6b7280',
items: [
{ token: 'محصولات', label: 'لیست محصولات', help: 'چند خط قیمت محصولات فعال' },
{ token: 'فوتر', label: 'فوتر آماده', help: 'بلوک آمادهٔ پایان پیام (آیدی/شماره/لینک)' },
],
},
],
sampleValues: {
گرم_خرید: '۷۱,۲۵۰,۰۰۰',
گرم_فروش: '۷۱,۵۰۰,۰۰۰',
مثقال_خرید: '۳۰۸,۷۰۰,۰۰۰',
مثقال_فروش: '۳۰۹,۸۰۰,۰۰۰',
تاریخ: '۱۴۰۴/۴/۲۲',
ساعت: toFa('12:30'),
نام: 'طلای نمونه',
عنوان: '📊 قیمت لحظه‌ای طلای نمونه',
شماره: '۰۹۱۲۳۴۵۶۷۸۹',
آیدی: '@gold_channel',
لینک: 'https://example.com',
محصولات: '• سکه تمام: ۹۲۱,۱۰۰,۰۰۰\n• نیم سکه: ۴۷۵,۰۰۰,۰۰۰',
فوتر: '📞 ۰۹۱۲۳۴۵۶۷۸۹\n@gold_channel',
},
},
invite_link_text: {
editorTitle: 'متن پیام دعوت',
previewTitle: 'پیش‌نمایش پیام دعوت',
placeholder: 'متن دعوت از دوستان را بنویسید و متغیرها را از پایین اضافه کنید…',
hint: 'روی هر متغیر کلیک کنید تا در محل نشانگر اضافه شود. اگر خالی بگذارید، متن پیش‌فرض اپ استفاده می‌شود.',
legend: 'مقادیر بالا نمونه‌اند؛ در پیام واقعی «نام» نام کاربر دعوت‌کننده و «لینک» آدرس ثبت‌نام با کد معرف اوست.',
rows: 10,
defaultTemplate: INVITE_DEFAULT,
variableGroups: [
{
title: 'متغیرها',
color: '#8b5cf6',
items: [
{ token: 'نام', label: 'نام کاربر', help: 'نام کامل کاربر دعوت‌کننده' },
{ token: 'لینک', label: 'لینک ثبت‌نام', help: 'آدرس کامل ثبت‌نام همراه کد معرف' },
],
},
],
sampleValues: {
نام: 'علی رضایی',
لینک: 'https://gold.example.com/?ref=AB12CD',
},
},
support_messenger_text: {
editorTitle: 'متن راهنمای واریز',
previewTitle: 'پیش‌نمایش راهنما',
placeholder: 'متن راهنمای واریز در مودال شارژ حساب را بنویسید…',
hint: 'روی هر متغیر کلیک کنید تا در محل نشانگر اضافه شود. اگر خالی بگذارید، متن پیش‌فرض اپ استفاده می‌شود.',
legend: 'مقادیر بالا نمونه‌اند؛ در اپ، نام/کارت/شبا از فیلدهای «نام صاحب حساب واریز»، «شماره کارت واریز» و «شبا واریز» پر می‌شوند.',
rows: 8,
defaultTemplate: DEPOSIT_GUIDE_DEFAULT,
variableGroups: [
{
title: 'اطلاعات حساب واریز',
color: '#10b981',
items: [
{ token: 'نام', label: 'صاحب حساب', help: 'نام صاحب حساب (از فیلد «نام صاحب حساب واریز»)' },
{ token: 'کارت', label: 'شماره کارت', help: 'شماره کارت (از فیلد «شماره کارت واریز»)' },
{ token: 'شبا', label: 'شماره شبا', help: 'شماره شبا (از فیلد «شبا واریز»)' },
],
},
],
sampleValues: {
نام: 'علی رضایی',
کارت: '۶۰۳۷-۹۹۷۹-۱۲۳۴-۵۶۷۸',
شبا: 'IR۸۲۰۵۴۰۱۰۲۶۸۰۰۲۰۸۱۷۹۰۹۰۰۲',
},
},
deposit_support_message_text: {
editorTitle: 'متن پیام به پشتیبانی',
previewTitle: 'پیش‌نمایش پیام',
placeholder: 'متنی که با کلیک «پیام به پشتیبانی» برای مشتری آماده می‌شود…',
hint: 'روی متغیر کلیک کنید تا در محل نشانگر اضافه شود. اگر خالی بگذارید، متن پیش‌فرض اپ استفاده می‌شود.',
legend: 'مقدار بالا نمونه است؛ در اپ «نام» نام مشتری واردشده است (و اگر خالی بود، نام صاحب حساب از تنظیمات).',
rows: 5,
defaultTemplate: DEPOSIT_SUPPORT_DEFAULT,
variableGroups: [
{
title: 'متغیر',
color: '#3b82f6',
items: [{ token: 'نام', label: 'نام مشتری', help: 'نام مشتری واردشده (یا نام صاحب حساب)' }],
},
],
sampleValues: {
نام: 'علی رضایی',
},
},
}
export function hasTemplateField(key: string | undefined | null): boolean {
return !!key && Object.prototype.hasOwnProperty.call(TEMPLATE_FIELD_CONFIGS, key)
}
export function getTemplateFieldConfig(key: string | undefined | null): TemplateFieldConfig | undefined {
if (!key) return undefined
return TEMPLATE_FIELD_CONFIGS[key]
}