diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4d8096a --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +# لوکال: درخواست‌ها مستقیم به دامنه سوپرادمین می‌روند (نه localhost). +# پروداکشن/بیلد روی سرور: این خط را خالی بگذار تا same-origin یعنی /api باشد. +VITE_API_BASE=https://adm.montzar.ir/api diff --git a/src/components/SettingsCatalogPanel.vue b/src/components/SettingsCatalogPanel.vue new file mode 100644 index 0000000..3c97ff9 --- /dev/null +++ b/src/components/SettingsCatalogPanel.vue @@ -0,0 +1,190 @@ + + + + + diff --git a/src/components/program-settings/SettingCard.vue b/src/components/program-settings/SettingCard.vue new file mode 100644 index 0000000..028528b --- /dev/null +++ b/src/components/program-settings/SettingCard.vue @@ -0,0 +1,212 @@ + + + + + diff --git a/src/components/program-settings/SettingToggle.vue b/src/components/program-settings/SettingToggle.vue new file mode 100644 index 0000000..0bce494 --- /dev/null +++ b/src/components/program-settings/SettingToggle.vue @@ -0,0 +1,113 @@ + + + + + diff --git a/src/components/program-settings/SettingValueField.vue b/src/components/program-settings/SettingValueField.vue new file mode 100644 index 0000000..191d6ab --- /dev/null +++ b/src/components/program-settings/SettingValueField.vue @@ -0,0 +1,222 @@ + + + + + diff --git a/src/config/khanegiSettingCatalog.ts b/src/config/khanegiSettingCatalog.ts new file mode 100644 index 0000000..52bb053 --- /dev/null +++ b/src/config/khanegiSettingCatalog.ts @@ -0,0 +1,1259 @@ +import type { KhanegiSettingRow as Const } from '@/config/khanegiSettingRow' +import { sanitizeNumericString } from '@/config/numeric' + +const isStoreApp = () => false +const conditionalOrderEnabledSetting = { value: true } +const priceChannelAccessSetting = { value: true } +const priceProviderSetting = { value: 'montzar' } +const showToUsersChatSetting = { value: true } + +export type SettingFieldType = + | 'text' + | 'number' + | 'price' + | 'switch' + | 'select' + | 'multiselect' + | 'longtext' + | 'time' + +export const MAIN_WALLET_PRODUCTS_DEFAULT = '13' + +/** پارس setting `main_wallet_products` — id با کاما یا JSON */ +export function parseMainWalletProductIds(value: string | undefined | null): string[] { + const raw = (value ?? '').trim() + if (!raw) return [] + + try { + const decoded = JSON.parse(raw) + if (Array.isArray(decoded)) { + return decoded + .map((part) => String(part).trim()) + .filter((part) => /^\d+$/.test(part)) + } + } catch { + // not JSON — comma-separated + } + + return raw + .split(',') + .map((part) => part.trim()) + .filter((part) => /^\d+$/.test(part)) +} + +export function formatMainWalletProductIds(ids: Array): string { + const unique = [...new Set(ids.map((id) => String(id).trim()).filter((id) => /^\d+$/.test(id)))] + if (unique.length === 0) { + return MAIN_WALLET_PRODUCTS_DEFAULT + } + return unique.join(',') +} + +export function normalizeMainWalletProductsValue(value: string | undefined | null): string { + const formatted = formatMainWalletProductIds(parseMainWalletProductIds(value)) + return formatted || MAIN_WALLET_PRODUCTS_DEFAULT +} + +export type SupportMessengerEntryDraft = { + messenger: string + id: string +} + +const SUPPORT_MESSENGER_TYPES = [ + 'bale', + 'telegram', + 'whatsapp', + 'rubika', + 'eitaa', + 'soroush', + 'call', + 'sms', +] as const + +export function isSupportMessengerType(value: string | undefined | null): boolean { + const type = (value ?? '').trim().toLowerCase() + return SUPPORT_MESSENGER_TYPES.includes(type as (typeof SUPPORT_MESSENGER_TYPES)[number]) +} + +/** پارس setting `support_messengers` — JSON یا legacy تک‌مورد (فقط موارد کامل) */ +export function parseSupportMessengers(value: string | undefined | null): SupportMessengerEntryDraft[] { + return parseSupportMessengersDraft(value).filter( + (row) => isSupportMessengerType(row.messenger) && row.id !== '', + ) +} + +/** پارس برای UI — لیست می‌تواند خالی باشد (چت مستقیم) */ +export function parseSupportMessengersDraft( + value: string | undefined | null, +): SupportMessengerEntryDraft[] { + const raw = (value ?? '').trim() + if (!raw) return [] + + try { + const decoded = JSON.parse(raw) + if (!Array.isArray(decoded) || decoded.length === 0) { + return [] + } + + return decoded + .map((row) => ({ + messenger: String(row?.messenger ?? '').trim().toLowerCase(), + id: String(row?.id ?? '').trim(), + })) + .filter((row) => isSupportMessengerType(row.messenger)) + } catch { + return [] + } +} + +export function formatSupportMessengers(entries: SupportMessengerEntryDraft[]): string { + const normalized = entries + .map((row) => ({ + messenger: (row.messenger ?? '').trim().toLowerCase(), + id: (row.id ?? '').trim(), + })) + .filter((row) => isSupportMessengerType(row.messenger) && row.id !== '') + + if (normalized.length === 0) return '[]' + return JSON.stringify(normalized, null, 0) +} + +export type PartnerUrlRow = { url: string } + +export const PARTNER_SERVER_URLS_MAX = 5 + +/** پارس setting `montzar_base_url` برای UI — JSON آرایه، یا مقدار تکی/legacy با کاما/خط جدید */ +export function parsePartnerServerUrlsDraft(value: string | undefined | null): PartnerUrlRow[] { + const raw = (value ?? '').trim() + if (!raw) return [{ url: '' }] + + let urls: string[] = [] + try { + const decoded = JSON.parse(raw) + if (Array.isArray(decoded)) { + urls = decoded.map((item) => + typeof item === 'string' ? item.trim() : String(item?.url ?? '').trim(), + ) + } else { + throw new Error('not-array') + } + } catch { + urls = raw.split(/[\r\n,]+/).map((part) => part.trim()) + } + + urls = urls.filter((u) => u !== '') + const rows = urls.slice(0, PARTNER_SERVER_URLS_MAX).map((url) => ({ url })) + return rows.length > 0 ? rows : [{ url: '' }] +} + +/** خروجی ذخیره: تکی → رشتهٔ ساده (سازگاری قبل)، چندتایی → JSON آرایه */ +export function formatPartnerServerUrls(rows: PartnerUrlRow[]): string { + const urls = rows + .map((row) => (row.url ?? '').trim().replace(/\/+$/, '')) + .filter((url) => url !== '') + + const unique = [...new Set(urls)].slice(0, PARTNER_SERVER_URLS_MAX) + + if (unique.length === 0) return '' + if (unique.length === 1) return unique[0] + return JSON.stringify(unique, null, 0) +} + +export function supportMessengerIdPlaceholder(messenger?: string): string { + switch ((messenger ?? '').trim().toLowerCase()) { + case 'whatsapp': + return 'شماره با کد کشور، مثلاً 989121234567' + case 'bale': + return 'یوزرنیم بله یا لینک ble.ir' + case 'telegram': + return 'یوزرنیم تلگرام بدون @' + case 'rubika': + case 'eitaa': + case 'soroush': + return 'یوزرنیم یا آیدی' + case 'call': + case 'sms': + return 'شماره موبایل، مثلاً 09123456789' + default: + return 'شناسه / یوزرنیم / شماره' + } +} + +/** کلیدهای legacy — در پنل نمایش داده نمی‌شوند */ +export const HIDDEN_LEGACY_SETTING_KEYS = new Set([ + 'main_product_price', + 'montzar_price', + 'fee_single', + 'etehadie_api_url', + 'goldir_price', + 'price_primary_source', + 'price_method', + 'montzar_last_product_log', + 'montzar_order_response', + 'service_charge_percentage', + 'support_messenger', + 'support_messenger_id', + // JSON از صفحهٔ اختصاصی «سایت‌های ری‌گیری» مدیریت می‌شود؛ سوییچ نمایش در تنظیمات می‌ماند + 'tracking_systems', +]) + +/** فقط وقتی is_store فعال است در پنل دیده می‌شوند */ +export const STORE_ONLY_SETTING_KEYS = new Set(['store_status']) + +export function isSettingLocked(canEdit: Const['can_edit']): boolean { + return canEdit === 0 || canEdit === false || canEdit === '0' +} + +export function isSettingActive(isActive: Const['is_active']): boolean { + if (isActive === 0 || isActive === false || isActive === '0') return false + return isActive !== null && isActive !== undefined +} + +export function isSettingVisible(key: string | undefined | null): boolean { + const k = (key || '').trim() + if (!k) return false + if (HIDDEN_LEGACY_SETTING_KEYS.has(k)) return false + if (STORE_ONLY_SETTING_KEYS.has(k) && !isStoreApp()) return false + if (k === 'fee_conditional_order' && !conditionalOrderEnabledSetting.value) return false + // سوییچ مشتری has_chat فقط وقتی قابلیت چت دیپلوی روشن است + if (k === 'has_chat' && !showToUsersChatSetting.value) return false + // گروه همکار Montzar فقط وقتی منبع قیمت montzar است + if ((k.startsWith('montzar_') || k === 'display_montzar') && priceProviderSetting.value !== 'montzar') { + return false + } + // تنظیمات قابل‌ویرایش کانال فقط وقتی دسترسی دیپلوی روشن است + if (PRICE_CHANNEL_PANEL_KEYS.has(k) && !priceChannelAccessSetting.value) return false + return true +} + +/** کلیدهای گروه کانال در تنظیمات عادی (نه دسترسی دیپلوی و نه مقصد/توکن) */ +const PRICE_CHANNEL_PANEL_KEYS = new Set([ + 'price_channel_enabled', + 'price_channel_messengers', + 'price_channel_diff', + 'price_channel_min_seconds', + 'price_channel_message_text', + 'price_channel_footer_id', + 'price_channel_footer_phone', + 'price_channel_footer_link', + 'change_mesghal', +]) + +export const SETTING_TYPE_OPTIONS = [ + { key: 'text', value: 'متن کوتاه' }, + { key: 'number', value: 'عدد / شماره' }, + { key: 'price', value: 'مبلغ (ریال)' }, + { key: 'switch', value: 'روشن/خاموش' }, + { key: 'select', value: 'انتخابی' }, + { key: 'multiselect', value: 'انتخاب چندتایی' }, + { key: 'longtext', value: 'متن بلند' }, + { key: 'time', value: 'ساعت' } +] + +const KEY_TYPE_FALLBACK: Record = { + chart_use_local_data: 'switch', + price_provider: 'select', + referral_id: 'switch', + payping_gateway_name: 'select', + text_rule: 'longtext', + text_about: 'longtext', + fee_service_charge_percentage: 'number', + fee_deduct_from_gold: 'switch', + require_zero_main_gold_before_other_buy: 'switch', + fee_single: 'price', + buy_fee: 'price', + sell_fee: 'price', + fee_free: 'number', + fee_conditional_order: 'price', + enable_conditional_order: 'switch', + enable_product_funds: 'switch', + has_page_price_trigger: 'switch', + has_page_notification: 'switch', + has_page_gift_card: 'switch', + enable_customer_level_profit: 'switch', + min_withdraw: 'price', + min_deposit: 'price', + min_first_buy: 'price', + minimum_sell: 'price', + gift_for_referrer: 'number', + gift_for_referral: 'number', + referral_bonus_amount: 'text', + montzar_product_id: 'number', + balance_report_exclude_customer_id: 'number', + jibit_verification: 'switch', + register_jibit_verify: 'switch', + jibit_enabled: 'switch', + register_referral_sms_to_referrer: 'switch', + register_referral_bonus_message: 'switch', + referral_gift_on_first_order: 'switch', + referral_gift_buy_from_montzar: 'switch', + open_register: 'switch', + sms_panel: 'switch', + auto_active_customer: 'switch', + close_price: 'switch', + startDay: 'time', + endDay: 'time', + show_last_price: 'switch', + show_home_price_when_closed: 'switch', + show_gold_ounce_price: 'switch', + show_product_details: 'switch', + products_site_link_enabled: 'switch', + products_site_link_url: 'text', + products_site_link_text: 'text', + show_product_trade_status: 'switch', + show_product_card_meta: 'switch', + send_to_tahesab: 'switch', + accounting_provider: 'select', + profit_gold_api_enabled: 'switch', + payping_round_toman: 'switch', + payping_strict_failed_withdraw: 'switch', + has_page_earn_money: 'switch', + customer_direct_sms: 'switch', + charge_notif: 'switch', + sms_notif_order_admin: 'switch', + sms_notif_order_customer: 'switch', + sms_notif_withdraw_admin: 'switch', + sms_notif_withdraw_otp: 'switch', + sms_notif_login_otp: 'switch', + sms_notif_gateway_charge_admin: 'switch', + sms_notif_register_customer: 'switch', + sms_notif_register_admin: 'switch', + sms_notif_jibit_admin: 'switch', + sms_notif_bank_card_admin: 'switch', + sms_notif_bank_card_customer: 'switch', + sms_notif_fund_withdraw_admin: 'switch', + sms_notif_fund_withdraw_customer: 'switch', + sms_notif_price_trigger: 'switch', + sms_notif_wallet_alert: 'switch', + sms_notif_support_admin: 'switch', + group_sms: 'switch', + customer_special_pricing: 'switch', + montzar_closed: 'switch', + montzar_open_buy: 'switch', + montzar_open_sell: 'switch', + payping_fin_sync_enabled: 'switch', + external_order_api_enabled: 'switch', + external_payment_enabled: 'switch', + external_payment_gateway: 'select', + wallet_payment_provider: 'select', + is_store: 'switch', + banner_enabled: 'switch', + permision__enabled: 'switch', + apk_download_link: 'text', + node_client_id: 'text', + node_shared_secret: 'text', + tahesab_host: 'text', + tahesab_port: 'number', + tahesab_token: 'text', + tahesab_db: 'text', + tahesab_bank_account: 'text', + zarin_api: 'text', + zarin_token: 'text', + zarin_noe_kar: 'number', + zarin_gorohe_kar: 'number', + zarin_name_g: 'text', + zarin_uid_prefix: 'text', + pargar_username: 'text', + pargar_password: 'text', + pargar_api_token: 'text', + pargar_item_id_rial: 'number', + pargar_item_id_gold: 'number', + support_messengers: 'longtext', + show_to_users_chat: 'switch', + has_chat: 'switch', + chat_api: 'text', + chat_api_key: 'text', + chat_upload_url: 'text', + chat_download_url: 'text', + tracking_systems: 'longtext', + tracking_systems_enabled: 'switch', + support_messenger_id: 'text', + support_messenger_text: 'longtext', + deposit_support_message_text: 'longtext', + deposit_account_holder: 'text', + deposit_card_number: 'text', + deposit_account_number: 'text', + deposit_sheba: 'text', + deposit_method_card_to_card: 'switch', + deposit_method_gateway: 'switch', + deposit_method_identified: 'switch', + deposit_method_shetab: 'switch', + invite_link_text: 'longtext', + support_messenger: 'select', + deposit_sms_support_number: 'number', + zaarbox_price_key: 'select', + price_channel_enabled: 'switch', + price_channel_access: 'switch', + price_channel_messengers: 'multiselect', + price_channel_diff: 'price', + price_channel_min_seconds: 'number', + price_channel_footer_id: 'text', + price_channel_footer_phone: 'text', + price_channel_footer_link: 'text', + 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' +} + +const SELECT_LABELS_BY_KEY: Record> = { + accounting_provider: { + none: 'هیچکدام', + pargar: 'پرگار', + tahesab: 'ته‌حساب', + zarin: 'زرین' + }, + support_messenger: { + bale: 'بله', + telegram: 'تلگرام', + whatsapp: 'واتساپ', + rubika: 'روبیکا', + eitaa: 'ایتا', + soroush: 'سروش', + call: 'تماس تلفنی', + sms: 'پیامک', + }, + price_channel_messengers: { + bale: 'بله', + telegram: 'تلگرام', + rubika: 'روبیکا' + } +} + +const SELECT_OPTIONS_BY_KEY: Record = { + accounting_provider: 'none,pargar,tahesab,zarin', + price_provider: 'montzar,zaarbox,main_product', + payping_gateway_name: 'sepal,', + external_payment_gateway: 'zarinpall,zibal,payping,sepal', + wallet_payment_provider: 'none,montify', + unit: 'گرم,کیلوگرم', + support_messenger: 'bale,telegram,whatsapp,rubika,eitaa,soroush,call,sms', + price_channel_messengers: 'bale,telegram,rubika' +} + +/** سوییچ‌هایی که مقدار required/خالی دارند (نه true/false) */ +export const REFERRAL_REQUIRED_KEY = 'referral_id' + +/** سوییچ‌هایی که مقدار activate/deactivate دارند (نه true/false) */ +export const ACTIVATE_DEACTIVATE_SWITCH_KEYS = new Set(['show_to_users_chat']) + +export function isActivateDeactivateSwitch(key: string | undefined): boolean { + return Boolean(key && ACTIVATE_DEACTIVATE_SWITCH_KEYS.has(key)) +} + +export function isReferralRequired(value: string | undefined): boolean { + return (value ?? '').trim().toLowerCase() === 'required' +} + +export function referralRequiredToString(checked: boolean): string { + return checked ? 'required' : '' +} + +export function resolveSettingFieldType(row: Pick): SettingFieldType { + // کاتالوگ پنل بر نوعِ دیتابیس اولویت دارد (هم‌راستا با عنوان/توضیح/can_edit) + if (row.key && KEY_TYPE_FALLBACK[row.key]) { + return KEY_TYPE_FALLBACK[row.key] + } + const raw = (row.type || '').trim().toLowerCase() + if (['text', 'number', 'price', 'switch', 'select', 'multiselect', 'longtext', 'time'].includes(raw)) { + return raw as SettingFieldType + } + return 'text' +} + +/** گزینه‌های select: اول از description (با | یا کاما)، بعد از نقشه کلید */ +export function parseSelectOptions( + row: Pick +): Array<{ key: string; value: string }> { + const preset = row.key ? SELECT_OPTIONS_BY_KEY[row.key] : '' + let source = preset || row.description || '' + + if (source.includes('|')) { + source = source.split('|').pop() || '' + } + + const deprecatedOpts = new Set(['etehadie']) + const opts = source + .split(',') + .map((s) => s.trim()) + .filter((opt) => opt !== '' && !(row.key === 'price_provider' && deprecatedOpts.has(opt))) + const labels = row.key ? SELECT_LABELS_BY_KEY[row.key] : undefined + return opts.map((opt) => ({ + key: opt, + value: labels?.[opt] ?? (opt === '' ? '(خالی)' : opt) + })) +} + +export function isSwitchOn(value: string | undefined): boolean { + const v = (value ?? '').trim().toLowerCase() + return v === 'true' || v === '1' || v === 'yes' || v === 'activate' +} + +export function switchToString(checked: boolean): string { + return checked ? 'true' : 'false' +} + +export function resolveSwitchChecked(key: string | undefined, value: string | undefined): boolean { + if (key === REFERRAL_REQUIRED_KEY) { + return isReferralRequired(value) + } + if (isActivateDeactivateSwitch(key)) { + const v = (value ?? '').trim().toLowerCase() + return v === 'activate' || v === 'true' || v === '1' || v === 'yes' + } + return isSwitchOn(value) +} + +export function switchValueFromChecked(key: string | undefined, checked: boolean): string { + if (key === REFERRAL_REQUIRED_KEY) { + return referralRequiredToString(checked) + } + if (isActivateDeactivateSwitch(key)) { + return checked ? 'activate' : 'deactivate' + } + return switchToString(checked) +} + +export function formatValueForSave( + value: string, + type: SettingFieldType, + key?: string +): string { + if (type === 'switch') { + return switchValueFromChecked( + key, + resolveSwitchChecked(key, value) + ) + } + if (type === 'price') { + return value === '' ? '0' : sanitizeNumericString(value, { allowDecimal: false }) + } + if (type === 'number') { + return sanitizeNumericString(value ?? '', { allowDecimal: true }) + } + if (type === 'time') { + return normalizeTimeValue(value) + } + if (key === 'main_wallet_products') { + return normalizeMainWalletProductsValue(value) + } + if (key === 'support_messengers') { + return formatSupportMessengers(parseSupportMessengers(value)) + } + return value ?? '' +} + +export function switchLabels(key: string | undefined): { on: string; off: string } { + if (key === REFERRAL_REQUIRED_KEY) { + return { on: 'اجباری', off: 'اختیاری' } + } + if (key === 'chart_use_local_data') { + return { on: 'داده خود سایت', off: 'Zaarbox' } + } + 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 = { + name_fa: + 'نام فارسی برند که در پیامک‌ها، عنوان صفحات و بخش‌های عمومی اپ به مشتری نشان داده می‌شود.', + banner_enabled: + 'روشن: منوی «بنرها» در ادمین و اسلایدر صفحه اصلی اپ مشتری نمایش داده می‌شود. خاموش: مخفی است.', + permision__enabled: + 'روشن: آیتم «مجوز ها» در منوی تنظیمات اپ خانگی (تم جدید) نمایش داده می‌شود. خاموش: مخفی است.', + open_register: + 'روشن: کاربران جدید می‌توانند ثبت‌نام کنند. خاموش: فقط ورود کاربران قبلی مجاز است.', + sms_panel: + 'روشن: پنل پیامک و کیف پول billing فعال است. خاموش: پیامک‌ها مثل قبل ارسال می‌شوند بدون billing.', + customer_direct_sms: + 'روشن: در لیست مشتریان دکمه ارسال پیامک تکی نمایش داده می‌شود و ادمین می‌تواند برای هر مشتری متن دلخواه بفرستد. خاموش: دکمه مخفی است. فقط از صفحه دیپلوی تغییر می‌کند.', + auto_active_customer: + 'روشن: بعد از ثبت‌نام، کاربر می‌تواند وارد اپ شود (بدون تأیید دستی ادمین). خاموش: ورود فقط بعد از فعال‌سازی دستی شما در پنل. این سوییچ اجازهٔ خرید/فروش نمی‌دهد — معامله فقط بعد از احراز هویت (is_legal) ممکن است.', + close_price: + 'فیلتر اصلی ساعت کاری فروشگاه. روشن: خرید و فروش فقط بین «ساعت شروع» و «ساعت پایان» انجام می‌شود. نمایش قیمت صفحه اصلی در خارج از این بازه با سوییچ «نمایش قیمت صفحه اصلی خارج از ساعت کاری» کنترل می‌شود. خاموش: محدودیت ساعتی سراسری اعمال نمی‌شود (مگر برای محصولی ساعت اختصاصی تعریف کرده باشید).', + startDay: + 'ساعت شروع فعالیت روزانه فروشگاه. فقط وقتی «بستن خودکار خارج از ساعت کاری» روشن باشد اعمال می‌شود. از ساعت‌شمار انتخاب کنید (مثلاً ۰۹:۰۰).', + endDay: + 'ساعت پایان فعالیت روزانه فروشگاه. فقط وقتی «بستن خودکار خارج از ساعت کاری» روشن باشد اعمال می‌شود. بعد از این ساعت فروشگاه بسته می‌شود (مثلاً ۱۷:۰۰ یا ۲۲:۰۰).', + show_last_price: + 'رفتار هنگام در دسترس نبودن قیمت زنده. روشن: آخرین قیمت ذخیره‌شده نشان داده می‌شود. خاموش: به‌جای صفر پیام «مظنه در دسترس نیست» نمایش داده می‌شود.', + show_home_price_when_closed: + 'روشن: وقتی فروشگاه خارج از ساعت کاری است، قیمت صفحه اصلی اپ همچنان نمایش داده می‌شود و پیام «فروشگاه بسته است» نشان داده نمی‌شود. خرید/فروش همچنان بسته می‌ماند. خاموش (پیش‌فرض): خارج از ساعت پیام بسته بودن نشان داده می‌شود.', + show_gold_ounce_price: + 'روشن: اگر آدرس API تابلو (zaarbox_api_url) تنظیم شده باشد، قیمت انس طلا در اپ مشتری نمایش داده می‌شود. خاموش: نمایش داده نمی‌شود.', + show_product_details: + 'روشن: بعد از انتخاب محصول در صفحه خرید/فروش، متن توضیح محصول نمایش داده می‌شود. خاموش: توضیح نشان داده نمی‌شود.', + products_site_link_enabled: + 'روشن: در صفحه خرید/فروش (بالای تب دسته‌ها) یک دکمه تمام‌عرض نشان داده می‌شود که با کلیک، لینک تنظیم‌شده را در تب جدید مرورگر باز می‌کند. خاموش: دکمه مخفی است.', + products_site_link_url: + 'آدرس کامل سایت یا صفحه خارجی (مثلاً https://example.com). با کلیک روی دکمه در اپ کاربر، همین آدرس در تب جدید مرورگر باز می‌شود. اگر خالی باشد، با وجود روشن بودن سوییچ، دکمه کاری انجام نمی‌دهد.', + products_site_link_text: + 'متنی که روی دکمه لینک خارجی در صفحه خرید/فروش نمایش داده می‌شود. اگر خالی باشد، پیش‌فرض «ورود به سایت» همراه آیکون کره زمین نشان داده می‌شود.', + show_product_trade_status: + 'روشن: روی کارت محصول برچسب خرید باز/بسته و فروش باز/بسته نمایش داده می‌شود. خاموش: مخفی است.', + show_product_card_meta: + 'روشن: روی کارت محصول دسته‌بندی، وضعیت تحویل فیزیکی و مقدار/وزن نمایش داده می‌شود. خاموش: مخفی است.', + fee_single: + 'منسوخ — دیگر استفاده نمی‌شود. اختلاف فقط از buy_fee / sell_fee (خالی = صفر).', + buy_fee: + 'مبلغ اضافه (ریال) روی قیمت پایه برای سمت خرید. خالی یا صفر = بدون اختلاف.', + sell_fee: + 'مبلغ کسر (ریال) از قیمت پایه برای سمت فروش. خالی یا صفر = بدون اختلاف.', + fee_service_charge_percentage: + 'درصد کارمزد هر معامله (اعشاری؛ مثلاً ۰٫۰۰۵). روی همهٔ سفارش‌ها اعمال می‌شود و با اختلاف قیمت محصول یا buy_fee/sell_fee قاطی نمی‌شود. محصول مقداری: اگر مبلغ ریالی قفل باشد کارمزد از فلز، اگر وزن قفل باشد از ریال. محصول تعدادی: کارمزد فقط از ریال.', + fee_deduct_from_gold: + 'این کلید دیگر روی محاسبه اثری ندارد. مبنای کارمزد اکنون فیلدی است که مشتری وارد می‌کند: اگر «مبلغ ریالی» را وارد کند (مثلاً دقیقاً ۱۰۰ میلیون ریال) همان مبلغ معامله می‌شود و کارمزد از طلا کسر می‌شود؛ اگر «وزن» را وارد کند (مثلاً دقیقاً ۲ گرم) همان وزن معامله می‌شود و کارمزد از ریال کسر می‌شود. این قانون برای خرید و فروش یکسان است.', + require_zero_main_gold_before_other_buy: + 'روشن: اگر مانده طلای کیف اصلی صفر یا زیر صفر باشد (بدهی)، مشتری ابتدا باید آبشده/مانده کیف اصلی را صفر کند و بعد می‌تواند محصول دیگر بخرد. خاموش: بدون این محدودیت. معامله خودِ محصولات کیف اصلی همیشه مجاز است.', + fee_free: + 'تعداد اولین سفارش‌های هر کاربر که از کارمزد معاف می‌شوند. صفر یعنی غیرفعال.', + fee_conditional_order: + 'مبلغ ریالی که با هر بار ثبت تریگر سفارش شرطی از کیف پول ریالی مشتری کم می‌شود. صفر = رایگان.', + enable_conditional_order: + 'روشن: قابلیت سفارش شرطی سراسری فعال است. خاموش: منو و ارزیابی اجرا نمی‌شود. فقط از صفحه دیپلوی قابل تغییر است.', + enable_product_funds: + 'روشن: ساخت و مدیریت قلک per-product در اپ مشتری فعال است (یک محصول در هر قلک؛ انتقال بدون سند حسابداری). خاموش: منو و API ساخت قلک جدید در دسترس نیست. فقط از دیپلوی قابل تغییر است.', + has_page_gift_card: + 'روشن: منوی «ثبت کارت هدیه» و صفحه ثبت کد در اپ کاربر فعال است. خاموش: منو و صفحه مخفی است و API استفاده از کد هدیه در دسترس نیست. فقط از دیپلوی قابل تغییر است.', + enable_customer_level_profit: + 'روشن: در لیست مشتریان آیکون تنظیم درصد سود هر سطح زیرمجموعه فعال است و پس از سفارش، سهم کارمزد طبق درصدهای هر معرف به کیف طلا واریز می‌شود. خاموش: آیکون و واریز سود چندسطحی غیرفعال است. فقط از دیپلوی قابل تغییر است.', + has_page_price_trigger: + 'روشن: منوی هشدار قیمت در اپ مشتری نمایش داده می‌شود. خاموش: منو و صفحه مخفی است. فقط از صفحه دیپلوی قابل تغییر است.', + has_page_notification: + 'روشن: زنگوله اعلان در اپ مشتری و منوی ثبت اعلان در ادمین فعال است. خاموش: مخفی است. فقط از صفحه دیپلوی قابل تغییر است.', + min_withdraw: + 'حداقل مبلغ درخواست برداشت (ریال). درخواست‌های کمتر پذیرفته نمی‌شوند.', + min_deposit: + 'حداقل مبلغ واریز به کیف پول (ریال). مبالغ کمتر در اپ و درگاه پذیرفته نمی‌شوند.', + min_first_buy: + 'حداقل مبلغ اولین خرید هر کاربر (ریال).', + minimum_sell: + 'حداقل مبلغ مجاز برای ثبت سفارش فروش (ریال). اگر ارزش ریالی فروش کمتر از این مقدار باشد، سفارش رد می‌شود.', + balance_report_exclude_customer_id: + 'شناسه عددی مشتری که از گزارش تراز و آمار حذف می‌شود (مثلاً حساب آزمایشی). چند شناسه را با ویرگول جدا کنید.', + text_rule: + 'متن کامل قوانین و مقررات که در صفحه قوانین اپ به مشتری نشان داده می‌شود.', + text_about: + 'متن «درباره ما» شامل معرفی مجموعه، راه‌های ارتباطی و توضیح خدمات.', + referral_id: + 'روشن: وارد کردن کد معرف در ثبت‌نام اجباری است. خاموش: کد معرف اختیاری است.', + gift_for_referrer: + 'مقدار پاداش (گرم) به کیف معرف. صفر = بدون پاداش. خرید پوشش از همکار با کلید «خرید پاداش معرفی از همکار» کنترل می‌شود.', + gift_for_referral: + 'مقدار پاداش (گرم) به معرف‌شونده. صفر = بدون پاداش. خرید پوشش از همکار با کلید «خرید پاداش معرفی از همکار» کنترل می‌شود.', + register_referral_sms_to_referrer: + 'روشن: پس از ثبت‌نام کاربر جدید، پیامک اطلاع‌رسانی برای معرف ارسال می‌شود. خاموش: پیامکی برای معرف ارسال نمی‌شود.', + register_referral_bonus_message: + 'روشن: متن پاداش معرف در پیامک خوش‌آمد ثبت‌نام گنجانده می‌شود. خاموش: متن پاداش در پیامک خوش‌آمد نمی‌آید.', + referral_gift_on_first_order: + 'روشن: پاداش معرف (gift_for_referrer) و معرف‌شونده (gift_for_referral) پس از تکمیل اولین سفارش معرف‌شونده به کیف واریز می‌شود — فقط اگر مقدار هر پاداش بیشتر از صفر باشد. خاموش: پاداش بلافاصله پس از ثبت‌نام موفق یا تأیید احراز هویت واریز می‌شود (باز هم فقط برای مقادیر بیشتر از صفر).', + referral_gift_buy_from_montzar: + 'روشن: اگر منبع قیمت montzar باشد، قبل از واریز پاداش معرف/معرف‌شونده همان وزن از همکار خرید می‌شود؛ شکست خرید = واریز نمی‌شود. خاموش: مثل قبل فقط به کیف واریز می‌شود بدون خرید از همکار.', + referral_limit: + 'حداکثر تعداد دعوت مجاز برای هر کاربر. خالی = بدون محدودیت.', + referral_bonus_amount: + 'مبلغ یا مقدار پاداش معرف برای نمایش در پیامک‌ها و متن‌های دعوت.', + jibit_verification: + 'روشن: هنگام ثبت یا ویرایش کارت بانکی، تطبیق با سرویس احراز هویت جیبیت انجام می‌شود. خاموش: کارت بدون تطبیق جیبیت ثبت می‌شود.', + register_jibit_verify: + 'روشن: در ثبت‌نام، تطبیق کد ملی و شماره موبایل از طریق جیبیت انجام می‌شود. خاموش: ثبت‌نام بدون تطبیق جیبیت انجام می‌شود.', + jibit_enabled: + 'روشن: سرویس احراز هویت جیبیت (تطبیق کد ملی/موبایل و کارت بانکی) فعال است. خاموش: هیچ فراخوانی به جیبیت انجام نمی‌شود و احراز هویت خودکار کار نمی‌کند. فقط از صفحه دیپلوی قابل تغییر است.', + has_page_earn_money: + 'روشن: منوی «کسب درآمد / دعوت دوستان» در اپ مشتری نمایش داده می‌شود. خاموش: این منو در اپ نمایش داده نمی‌شود.', + invite_link_text: + 'متن پیام اشتراک‌گذاری دعوت. متن را بنویسید و متغیرها (نام کاربر، لینک ثبت‌نام) را با کلیک اضافه کنید؛ پیش‌نمایش نتیجه را با مقادیر نمونه نشان می‌دهد. خالی = متن پیش‌فرض اپ.', + montzar_mobile: + 'شماره موبایل حساب اتصال به سرویس همکار قیمت.', + montzar_password: + 'رمز عبور حساب اتصال به سرویس همکار قیمت. محرمانه نگه دارید.', + montzar_product_id: + 'شناسه محصول مرجع در سرویس همکار قیمت.', + montzar_base_url: + 'آدرس پایه سرور سرویس همکار (بدون اسلش انتهایی).', + zaarbox_api_url: + 'آدرس سرور(های) فید قیمت (/api/free). تا ۵ آدرس می‌توانید اضافه کنید؛ اگر آدرس اول پاسخ ندهد به‌ترتیب سراغ بعدی می‌رود. آدرس فعلی در ردیف اول حفظ می‌شود.', + montzar_open_buy: + 'وضعیت واقعی از سرویس خارجی خوانده می‌شود. روشن: ثبت سفارش خرید از طریق قیمت همکار مجاز است. خاموش: ثبت سفارش خرید از طریق قیمت همکار غیرفعال است.', + montzar_open_sell: + 'وضعیت واقعی از سرویس خارجی خوانده می‌شود. روشن: ثبت سفارش فروش از طریق قیمت همکار مجاز است. خاموش: ثبت سفارش فروش از طریق قیمت همکار غیرفعال است.', + montzar_closed: + 'وضعیت گزارش‌شده از سرویس همکار؛ فقط برای نمایش — این سوییچ را دستی تغییر ندهید.', + external_order_api_enabled: + 'روشن: دریافت کیف پول و ثبت سفارش از سرویس فروشگاه خارجی فعال است. خاموش: اتصال به سرویس فروشگاه خارجی غیرفعال است.', + external_payment_enabled: + 'روشن: در صورت کامل بودن BASE_URL و client_id و client_secret و gateway، دکمه پرداخت آنلاین در اپ نمایش داده می‌شود. خاموش: فقط واریز دستی.', + external_payment_base_url: + 'آدرس پایه API سرویس Montify (بدون اسلش انتهایی)، مثلاً https://node.example.com — فقط سرور؛ در اپ کاربر ارسال نمی‌شود.', + external_payment_client_id: + 'شناسه partner (client_id) — فقط سرور؛ در اپ کاربر یا پاسخ get_settings عمومی نیست.', + external_payment_client_secret: + 'رمز مخفی partner (client_secret) — فقط سرور. در فرانت و Network تب کاربر دیده نمی‌شود.', + external_payment_gateway: + 'نام درگاه فعال داخل Montify: zarinpall، zibal، payping یا sepal.', + external_payment_return_url: + 'آدرس صفحه فرانت بعد از پرداخت. مثال: https://abaeigold.ir/wallet/payment-result — باید در whitelist پارتنر Montify باشد. خالی = دامنه سایت + /wallet/payment-result', + external_payment_webhook_url: + 'آدرس بک‌اند برای webhook (شارژ قطعی کیف). مثال: https://abaeigold.ir/server/customer/wallet/payment/webhook/montify — خالی = همین مسیر پیش‌فرض سرور', + wallet_payment_provider: + 'نوع درگاه فعال: none=خاموش، montify=پارتنر Montify. برای شارژ آنلاین معمولاً montify را انتخاب کنید.', + node_client_id: + 'شناسه کلاینت برای اتصال امن به سرویس سفارش خارجی.', + node_shared_secret: + 'کلید مشترک امضای درخواست‌ها به سرویس سفارش خارجی. محرمانه نگه دارید.', + support_messengers: + 'فقط مواردی که در این لیست ثبت شوند در منوی شناور پشتیبانی اپ مشتری نمایش داده می‌شوند. برای تماس تلفنی یا پیامک، نوع «تماس تلفنی» / «پیامک» را با شماره اضافه کنید. لیست می‌تواند خالی باشد؛ اگر چت فعال باشد، پشتیبانی مستقیم به صفحه چت می‌رود.', + show_to_users_chat: + 'روشن: قابلیت چت پشتیبانی برای این دیپلوی فعال است (منوی ادمین و پیش‌نیاز نمایش به کاربر). خاموش: چت در ادمین و اپ کاربر مخفی است و سوییچ «چت پشتیبانی» (has_chat) از تنظیمات عادی هم برداشته می‌شود.', + has_chat: + 'روشن: چت پشتیبانی در اپ مشتری نمایش داده می‌شود (فقط وقتی show_to_users_chat هم روشن باشد). خاموش: دکمه و صفحه چت برای کاربر مخفی است.', + chat_api: + 'هاست سرور چت جدا (بدون https)، مثلاً chat.example.ir — برای WebSocket و ساخت روم.', + chat_api_key: + 'کلید API سرور چت جدا.', + chat_upload_url: + 'URL کامل آپلود مدیا برای چت پشتیبانی.', + chat_download_url: + 'پایهٔ URL دانلود/نمایش فایل‌های چت پشتیبانی.', + tracking_systems: + 'لیست سایت‌های ری‌گیری (name/url) — از منوی «سایت‌های ری‌گیری» ویرایش کنید.', + tracking_systems_enabled: + 'روشن: منوی «سایت‌های ری‌گیری» در ادمین و اپ مشتری نمایش داده می‌شود. خاموش: مخفی است.', + support_messenger: + 'پیام‌رسانی که آیکون کناری و دکمه شناور پشتیبانی در اپ مشتری (درباره ما، قوانین، آموزش و راهنمای واریز) به آن هدایت می‌کند.', + support_messenger_id: + 'شناسه تماس در پیام‌رسان انتخاب‌شده: یوزرنیم، آیدی یا لینک کامل. فقط روی آیکون پیام‌رسان و دکمه شناور اعمال می‌شود؛ تماس تلفنی از فیلد «شماره پشتیبانی» خوانده می‌شود.', + support_messenger_text: + 'متن راهنمای واریز در مودال شارژ حساب. متن را بنویسید و متغیرها (نام صاحب حساب، شماره کارت، شماره حساب، شبا) را با کلیک اضافه کنید؛ پیش‌نمایش نتیجه را با مقادیر نمونه نشان می‌دهد. خالی = متن پیش‌فرض اپ.', + deposit_support_message_text: + 'متن پیش‌نویس فقط وقتی کاربر از مودال راهنمای واریز روی «پیام به پشتیبانی» یا SMS می‌زند. منوی شناور پشتیبانی این متن را نمی‌گذارد. خالی = متن پیش‌فرض اپ.', + deposit_account_holder: + 'نام صاحب حساب واریز — در متن راهنمای واریز با ٪نام یا %نام جایگزین می‌شود. در پیام پشتیبانی فقط وقتی نام مشتری موجود نباشد استفاده می‌شود.', + deposit_card_number: + 'شماره کارت واریز — در متن‌ها با ٪کارت یا %کارت جایگزین می‌شود.', + deposit_account_number: + 'شماره حساب بانکی واریز — در مودال راهنمای شارژ کنار کارت و شبا نمایش داده می‌شود. در متن‌ها با ٪حساب یا %حساب جایگزین می‌شود.', + deposit_sheba: + 'شماره شبا واریز — در متن‌ها با ٪شبا یا %شبا جایگزین می‌شود.', + deposit_method_card_to_card: + 'روشن (پیش‌فرض): روش کارت به کارت در صفحه واریز اپ نمایش داده می‌شود. خاموش: این روش از لیست روش‌های واریز حذف می‌شود.', + deposit_method_gateway: + 'روشن: روش درگاه پرداخت در صفحه واریز نمایش داده می‌شود. خاموش: مخفی است. اتصال و کدهای درگاه در تنظیمات پیشرفته → روش‌های واریز تنظیم می‌شوند.', + deposit_method_identified: + 'روشن: روش شناسه واریز در صفحه واریز نمایش داده می‌شود. خاموش: مخفی است.', + deposit_method_shetab: + 'روشن: روش شبکه شتابی در صفحه واریز نمایش داده می‌شود. خاموش: مخفی است.', + deposit_sms_support_number: + 'شماره تماس پشتیبانی برای راهنمای واریز و دکمه پیامک داخل مودال شارژ. برای نمایش در منوی شناور، باید «تماس تلفنی» یا «پیامک» را جداگانه در لیست پیام‌رسان‌های پشتیبانی اضافه کنید.', + charge_notif: + 'روشن: بعد از شارژ یا کسر کیف توسط ادمین، پیامک به مشتری و ادمین‌های دارای «ارسال پیامک» می‌رود. خاموش: هیچ‌کدام ارسال نمی‌شود.', + sms_notif_order_admin: + 'روشن: بعد از خرید یا فروش موفق، پیامک خلاصه معامله به ادمین‌های دارای «ارسال پیامک» می‌رود. خاموش: ادمین پیامک معامله نمی‌گیرد.', + sms_notif_order_customer: + 'روشن: بعد از خرید یا فروش موفق، پیامک رسید (مقدار، مبلغ، موجودی) به مشتری می‌رود. خاموش: مشتری پیامک معامله نمی‌گیرد.', + sms_notif_withdraw_admin: + 'روشن: وقتی مشتری درخواست برداشت ریالی می‌دهد، پیامک به ادمین‌های دارای «ارسال پیامک» می‌رود. خاموش: اعلان برداشت به ادمین نمی‌رود.', + sms_notif_withdraw_otp: + 'روشن: رمز یکبارمصرف برداشت برای مشتری پیامک می‌شود. خاموش: رمز پیامک نمی‌شود و برداشت با OTP عملاً کار نمی‌کند. خاموش کردن توصیه نمی‌شود.', + sms_notif_login_otp: + 'روشن: رمز یکبارمصرف ورود پیامک می‌شود. خاموش: ورود با OTP ممکن نیست. خاموش کردن توصیه نمی‌شود.', + sms_notif_gateway_charge_admin: + 'روشن: بعد از شارژ موفق از درگاه پرداخت، پیامک به ادمین‌های دارای «ارسال پیامک» می‌رود. خاموش: اعلان شارژ درگاه نمی‌رود.', + sms_notif_register_customer: + 'روشن: بعد از ثبت‌نام، پیامک خوش‌آمد (نام کاربری و لینک ورود) به مشتری می‌رود. خاموش: پیامک خوش‌آمد ارسال نمی‌شود.', + sms_notif_register_admin: + 'روشن: وقتی کسی با کد معرف ثبت‌نام می‌کند و پیامک به معرف خاموش است، اعلان به ادمین می‌رود. خاموش: این اعلان ادمین نمی‌رود.', + sms_notif_jibit_admin: + 'روشن: نتیجه احراز هویت جیبیت در ثبت‌نام برای ادمین پیامک می‌شود. خاموش: اعلان جیبیت نمی‌رود.', + sms_notif_bank_card_admin: + 'روشن: ثبت کارت بانکی جدید یا شکست احراز کارت برای ادمین پیامک می‌شود. خاموش: اعلان کارت به ادمین نمی‌رود.', + sms_notif_bank_card_customer: + 'روشن: بعد از تایید کارت بانکی توسط ادمین، پیامک تایید به مشتری می‌رود. خاموش: مشتری پیامک تایید کارت نمی‌گیرد.', + sms_notif_fund_withdraw_admin: + 'روشن: برداشت از قلک برای ادمین پیامک می‌شود. خاموش: اعلان برداشت قلک به ادمین نمی‌رود.', + sms_notif_fund_withdraw_customer: + 'روشن: بعد از برداشت از قلک، پیامک رسید به مشتری می‌رود. خاموش: مشتری پیامک برداشت قلک نمی‌گیرد.', + sms_notif_price_trigger: + 'روشن: وقتی هشدار قیمت مشتری (اقدام=پیامک) فعال شود، پیامک به مشتری می‌رود. خاموش: هشدار قیمت پیامک نمی‌فرستد.', + sms_notif_wallet_alert: + 'روشن: وقتی اعتبار پنل پیامک صفر یا منفی شود، هشدار به سوپرادمین و ادمین‌ها می‌رود. خاموش: این هشدار نمی‌رود.', + sms_notif_support_admin: + 'روشن: وقتی مشتری اولین پیام خوانده‌نشده را در چت پشتیبانی می‌فرستد و ادمین همان گفتگو را باز و visible ندارد، پیامک به ادمین‌های دارای «ارسال پیامک» می‌رود. خاموش: اعلان پیامک چت نمی‌رود.', + group_sms: + 'روشن: دکمه ارسال پیامک گروهی در پنل ادمین فعال است (ارسال ۱۰تایی). خاموش: ارسال پیامک گروهی غیرفعال است. فقط از تنظیمات پیشرفته.', + customer_special_pricing: + 'روشن: زیرمنوی قیمت ویژه در مشتریان و اعمال سود خرید/فروش پکیج روی مظنه و سفارش برای کاربران انتخاب‌شده. خاموش: غیرفعال. فقط از دیپلوی قابل تغییر است.', + profit_gold_api_enabled: + 'روشن: محاسبه سود طلا از API خارجی فعال است. خاموش: محاسبه سود از API خارجی انجام نمی‌شود (حالت پیش‌فرض اکثر نصب‌ها).', + main_wallet_products: + 'محصولاتی که موجودی‌شان از کیف طلای اصلی خوانده می‌شود. می‌توانید چند محصول انتخاب کنید. اگر خالی بماند، پیش‌فرض محصول ۱۳ است.', + unit: + 'واحد نمایش وزن در دارایی‌ها، کیف پول و جزئیات معاملات (گرم یا کیلوگرم).', + chart_use_local_data: + 'روشن: نمودار از تاریخچه ذخیره‌شده در دیتابیس خودتان. خاموش: از سرویس تابلو آنلاین.', + chart_id: + 'شناسه نمودار در سرویس تابلو. خالی = شناسه پیش‌فرض سرویس.', + price_provider: + 'منبع دریافت قیمت لحظه‌ای برای اپ مشتری و همگام‌سازی محصولات. مقدار montzar یعنی قیمت از همکار (Montzar).', + main_product: + 'شناسه یا نام محصول مرجع آبشده. وقتی منبع قیمت «محصول مرجع» باشد برای محاسبه استفاده می‌شود.', + zaarbox_price_key: + 'شناسه ردیف قیمت در پاسخ سرویس تابلو.', + payping_fin_sync_enabled: + 'روشن: پس از واریز یا برداشت، تراکنش به سامانه حسابداری مالی همگام می‌شود. خاموش: تراکنش‌ها به سامانه حسابداری مالی ارسال نمی‌شوند.', + payping_fin_url: + 'آدرس ثبت تراکنش در سامانه حسابداری مالی.', + payping_fin_token: + 'توکن احراز هویت سامانه حسابداری مالی.', + payping_fin_customer_id: + 'شناسه مشتری در سامانه حسابداری که تراکنش‌ها به نام آن ثبت می‌شوند.', + payping_gateway_name: + 'نام درگاه پرداخت فعال که به واسط درگاه ارسال می‌شود.', + payping_gateway_items: + 'لیست کدهای درگاه با ویرگول برای چرخش بین چند پذیرنده.', + payping_start_url: + 'آدرس API شروع پرداخت (هدایت مشتری به درگاه).', + payping_verify_url: + 'آدرس API تأیید پرداخت پس از بازگشت از درگاه.', + payping_round_toman: + 'روشن: مبلغ پرداخت به نزدیک‌ترین تومان گرد می‌شود. خاموش: مبلغ دقیق ریالی.', + payping_strict_failed_withdraw: + 'روشن: در صورت برداشت ناموفق قبلی، تسویه جدید با احتیاط بیشتر انجام می‌شود. خاموش: تسویه به‌صورت عادی و بدون احتیاط اضافه انجام می‌شود.', + send_to_tahesab: + 'تنظیم قدیمی — از «سیستم حسابداری» استفاده کنید.', + accounting_provider: + 'سیستم حسابداری متصل: پرگار، ته‌حساب، زرین، یا هیچکدام.', + send_to_pargar: + 'تنظیم قدیمی — از «سیستم حسابداری» استفاده کنید.', + pargar_username: + 'نام کاربری که نرم‌افزار پرگار برای login به /pargar/login استفاده می‌کند.', + pargar_password: + 'رمز عبور login API پرگار — در پنل ثبت می‌شود و به پرگار داده می‌شود.', + pargar_api_token: + 'توکن ثابت اختیاری برای احراز API پرگار (جایگزین توکن login).', + pargar_item_id_rial: + 'کد کالای ریال در پرگار برای اسناد واریز/برداشت ریالی. اگر خالی یا صفر باشد از کد طلا استفاده می‌شود و وزن صفر ارسال می‌شود.', + pargar_item_id_gold: + 'کد کالای طلا (کیف اصلی) در پرگار — پیش‌فرض ۱۱.', + tahesab_host: + 'آدرس سرور نرم‌افزار ته‌حساب در شبکه.', + tahesab_port: + 'شماره پورت اتصال به ته‌حساب.', + tahesab_token: + 'توکن اتصال به ته‌حساب. از پنل ته‌حساب دریافت می‌شود.', + tahesab_db: + 'نام پایگاه داده در ته‌حساب که سند به آن ارسال می‌شود.', + tahesab_bank_account: + 'نام یا کد حساب بانکی در سند واریز/برداشت ته‌حساب.', + zarin_api: + 'آدرس پایه وب‌سرویس زرین (بدون /ZarrinService/accounting).', + zarin_token: + 'توکن مکمل API زرین که در نرم‌افزار زرین تعریف می‌شود.', + zarin_noe_kar: + 'کد noeKar هنگام ساخت کار ساخته در زرین.', + zarin_gorohe_kar: + 'کد gorooheKar هنگام ساخت/جستجوی کار در زرین. اگر چند پنل یک زرین دارند، برای هر پنل گروه کار متفاوت بگذارید.', + zarin_name_g: + 'نام گروه حساب مشتریان در زرین (nameG). برای اپ خانگی معمولاً «مشتری نقدی (خریدار)» (گروه ۶). گروه همکار استفاده نشود چون مظنه فرق دارد. فقط از تنظیمات/دیپلوی تغییر می‌کند.', + zarin_uid_prefix: + 'پیشوند عددی uid برای جلوگیری از تداخل بین چند پنل روی یک زرین. فقط عدد (مثلاً ۱۱).', + montify_name: + 'نام فروشگاه که در درگاه پرداخت و رسید بانکی به مشتری نشان داده می‌شود.', + app_url: + 'آدرس کامل وب‌اپ مشتری (با https). در پیامک‌ها و بازگشت از درگاه استفاده می‌شود.', + admin_url: + 'آدرس کامل پنل مدیریت (با https).', + main_product_price: + 'تنظیم قدیمی — از «منبع قیمت» استفاده کنید. روشن نگه داشتن توصیه نمی‌شود.', + price_channel_enabled: + 'روشن: بعد از هر تغییر قیمت، مظنه به‌صورت خودکار به کانال‌های پیام‌رسان انتخاب‌شده ارسال می‌شود.', + price_channel_access: + 'روشن: گروه «ارسال قیمت به کانال» در تنظیمات عادی پنل دیده می‌شود. خاموش: مخفی است (فقط از تنظیمات پیشرفته).', + price_channel_messengers: + 'کدام پیام‌رسان‌ها قیمت را دریافت کنند؟ می‌توانید چند مورد را انتخاب کنید (بله، تلگرام، روبیکا). برای هر پیام‌رسان باید توکن بات و «مقصد ارسال» هم پر شده باشد.', + price_channel_diff: + 'اسپرد اضافه فقط برای کانال (ریال): به قیمت خرید کانال اضافه و از قیمت فروش کم می‌شود. صفر یعنی همان قیمت صفحه اصلی اپلیکیشن.', + price_channel_min_seconds: + 'حداقل فاصله (ثانیه) بین دو ارسال متوالی به کانال. جلوی سیل پیام هنگام نوسان قیمت را می‌گیرد. صفر = هر تغییر قیمت ارسال شود.', + price_channel_footer_id: + 'اگر پر باشد، در انتهای پیام کانال نمایش داده می‌شود (مثلاً یوزرنیم کانال). خالی = این خط نمایش داده نمی‌شود.', + price_channel_footer_phone: + 'شماره تماسی که در انتهای پیام کانال نشان داده می‌شود. خالی = نمایش داده نمی‌شود.', + price_channel_footer_link: + 'لینکی که در انتهای پیام کانال نشان داده می‌شود. خالی = نمایش داده نمی‌شود.', + price_channel_bale_to: + 'آیدی چنل یا گروه بله که قیمت برایش ارسال می‌شود. چند مقصد را با ویرگول جدا کنید.', + price_channel_telegram_to: + 'آیدی چنل یا گروه تلگرام که قیمت برایش ارسال می‌شود. چند مقصد را با ویرگول جدا کنید.', + price_channel_rubika_to: + 'آیدی چنل یا گروه روبیکا که قیمت برایش ارسال می‌شود. چند مقصد را با ویرگول جدا کنید.', + price_channel_message_text: + 'قالب پیامی که با تغییر قیمت به کانال ارسال می‌شود. متن را بنویسید و متغیرها را با کلیک اضافه کنید؛ پیش‌نمایش، پیام نهایی را با مقادیر نمونه نشان می‌دهد. خالی = قالب پیش‌فرض.', + rubika_bot_token: + 'توکن ربات روبیکا برای ارسال قیمت به کانال. محرمانه نگه دارید.', + change_mesghal: + 'وزن هر مثقال به گرم؛ برای نمایش خط «هر مثقال» در پیام کانال (مثقال = قیمت هر گرم × این عدد، پیش‌فرض ۴٫۳۳۱۸).' +} + +/** برچسب فارسی — وقتی در کاتالوگ تعریف شده، بر title_fa دیتابیس اولویت دارد */ +export const KEY_LABEL_FALLBACK: Record = { + close_price: 'بستن خودکار خارج از ساعت کاری', + startDay: 'ساعت شروع', + endDay: 'ساعت پایان', + show_last_price: 'نمایش آخرین قیمت ذخیره‌شده', + show_home_price_when_closed: 'نمایش قیمت صفحه اصلی خارج از ساعت کاری', + show_gold_ounce_price: 'نمایش قیمت انس طلا', + show_product_details: 'نمایش جزئیات اطلاعات محصول', + products_site_link_enabled: 'دکمه لینک خارجی در خرید/فروش', + products_site_link_url: 'آدرس لینک دکمه خرید/فروش', + products_site_link_text: 'متن دکمه لینک خرید/فروش', + show_product_trade_status: 'نمایش خرید/فروش باز روی کارت محصول', + show_product_card_meta: 'نمایش دسته و تحویل و مقدار روی کارت محصول', + fee_single: 'اختلاف پایه خرید/فروش', + buy_fee: 'اضافه قیمت خرید', + sell_fee: 'کسر قیمت فروش', + fee_service_charge_percentage: 'درصد کارمزد معامله', + fee_deduct_from_gold: 'اعمال مبلغ دقیق در معامله مشتری', + require_zero_main_gold_before_other_buy: 'الزام صفر بودن کیف اصلی قبل از خرید محصول دیگر', + fee_free: 'معافیت کارمزد سفارش اول', + fee_conditional_order: 'کارمزد سفارش شرطی', + enable_conditional_order: 'سفارش شرطی', + enable_product_funds: 'قلک محصول', + has_page_gift_card: 'کارت هدیه', + enable_customer_level_profit: 'سود چندسطحی معرف', + has_page_price_trigger: 'هشدار قیمت', + has_page_notification: 'اعلان داخل اپ', + jibit_enabled: 'احراز هویت جیبیت', + external_payment_enabled: 'فعال‌سازی شارژ آنلاین', + external_payment_base_url: 'آدرس API درگاه (BASE_URL)', + external_payment_client_id: 'شناسه کلاینت (client_id)', + external_payment_client_secret: 'رمز کلاینت (client_secret)', + external_payment_gateway: 'نام درگاه داخل Montify', + external_payment_return_url: 'آدرس برگشت (return_url)', + external_payment_webhook_url: 'آدرس Webhook', + wallet_payment_provider: 'نوع درگاه شارژ آنلاین', + jibit_verification: 'تأیید کارت بانکی با جیبیت', + register_jibit_verify: 'تأیید کدملی/موبایل در ثبت‌نام', + min_withdraw: 'حداقل مبلغ برداشت', + min_deposit: 'حداقل مبلغ واریز کیف پول', + min_first_buy: 'حداقل اولین خرید', + minimum_sell: 'حداقل مبلغ فروش (ریال)', + referral_id: 'کد معرف اجباری', + referral_gift_on_first_order: 'پاداش معرف پس از اولین سفارش', + referral_gift_buy_from_montzar: 'خرید پاداش معرفی از همکار', + open_register: 'ثبت‌نام باز', + sms_panel: 'پنل پیامک (کیف پول)', + customer_direct_sms: 'پیامک تکی به مشتری از لیست', + customer_special_pricing: 'قیمت ویژه مشتری', + charge_notif: 'پیامک شارژ ادمین', + sms_notif_order_admin: 'پیامک خرید/فروش به ادمین', + sms_notif_order_customer: 'پیامک خرید/فروش به مشتری', + sms_notif_withdraw_admin: 'پیامک درخواست برداشت به ادمین', + sms_notif_withdraw_otp: 'پیامک رمز برداشت', + sms_notif_login_otp: 'پیامک رمز ورود', + sms_notif_gateway_charge_admin: 'پیامک شارژ درگاه به ادمین', + sms_notif_register_customer: 'پیامک خوش‌آمد ثبت‌نام', + sms_notif_register_admin: 'پیامک ثبت‌نام به ادمین', + sms_notif_jibit_admin: 'پیامک احراز هویت به ادمین', + sms_notif_bank_card_admin: 'پیامک کارت بانکی به ادمین', + sms_notif_bank_card_customer: 'پیامک تایید کارت به مشتری', + sms_notif_fund_withdraw_admin: 'پیامک برداشت قلک به ادمین', + sms_notif_fund_withdraw_customer: 'پیامک برداشت قلک به مشتری', + sms_notif_price_trigger: 'پیامک هشدار قیمت', + pargar_item_id_rial: 'کد کالای ریال پرگار', + pargar_item_id_gold: 'کد کالای طلا پرگار', + sms_notif_wallet_alert: 'پیامک هشدار اعتبار پنل پیامک', + sms_notif_support_admin: 'پیامک پیام پشتیبانی به ادمین', + auto_active_customer: 'فعال‌سازی خودکار پس از ثبت‌نام', + balance_report_exclude_customer_id: 'حذف مشتری از گزارش تراز', + chart_use_local_data: 'نمودار از داده داخلی', + chart_id: 'شناسه نمودار تابلو', + price_provider: 'منبع قیمت', + main_product: 'محصول مرجع قیمت', + montzar_open_buy: 'خرید از همکار', + montzar_open_sell: 'فروش از همکار', + external_order_api_enabled: 'API سفارش خارجی', + payping_fin_sync_enabled: 'همگام‌سازی با حسابداری مالی', + accounting_provider: 'سیستم حسابداری', + send_to_tahesab: 'ارسال خودکار به ته‌حساب (قدیمی)', + send_to_pargar: 'ارسال خودکار به پرگار (قدیمی)', + zarin_api: 'آدرس API زرین', + zarin_token: 'توکن زرین', + zarin_noe_kar: 'نوع کار پیش‌فرض زرین', + zarin_gorohe_kar: 'گروه کار پیش‌فرض زرین', + zarin_name_g: 'نام گروه حساب مشتریان زرین', + zarin_uid_prefix: 'پیشوند عددی uid زرین', + support_messengers: 'پیام‌رسان‌های پشتیبانی', + show_to_users_chat: 'نمایش چت به کاربران', + has_chat: 'چت پشتیبانی', + chat_api: 'API چت', + chat_api_key: 'کلید API چت', + chat_upload_url: 'آدرس آپلود فایل چت', + chat_download_url: 'آدرس دانلود فایل چت', + tracking_systems: 'سایت‌های ری‌گیری', + tracking_systems_enabled: 'نمایش سایت‌های ری‌گیری', + permision__enabled: 'نمایش منوی مجوزها', + support_messenger: 'پیام‌رسان پشتیبانی', + support_messenger_id: 'شناسه پشتیبانی', + support_messenger_text: 'متن راهنمای واریز', + deposit_support_message_text: 'متن پیام به پشتیبانی (واریز)', + deposit_account_holder: 'نام صاحب حساب واریز', + deposit_card_number: 'شماره کارت واریز', + deposit_account_number: 'شماره حساب واریز', + deposit_sheba: 'شبا واریز', + deposit_method_card_to_card: 'فعال‌سازی کارت به کارت', + deposit_method_gateway: 'فعال‌سازی درگاه پرداخت', + deposit_method_identified: 'فعال‌سازی شناسه واریز', + deposit_method_shetab: 'فعال‌سازی شبکه شتابی', + deposit_sms_support_number: 'شماره تماس پشتیبانی', + price_channel_enabled: 'ارسال قیمت به کانال', + price_channel_access: 'دسترسی ارسال قیمت به کانال', + price_channel_messengers: 'پیام‌رسان‌های ارسال قیمت', + price_channel_diff: 'اختلاف قیمت کانال', + price_channel_min_seconds: 'حداقل فاصله ارسال (ثانیه)', + price_channel_footer_id: 'آیدی کانال در پیام', + price_channel_footer_phone: 'شماره تماس در پیام', + price_channel_footer_link: 'لینک در پیام', + price_channel_bale_to: 'مقصد ارسال بله', + price_channel_telegram_to: 'مقصد ارسال تلگرام', + price_channel_rubika_to: 'مقصد ارسال روبیکا', + price_channel_message_text: 'قالب متن پیام کانال', + rubika_bot_token: 'توکن بات روبیکا', + change_mesghal: 'وزن مثقال (گرم)' +} + +/** غیرقابل ویرایش در پنل — هم‌تراز deploy_settings_const.sql */ +export const SETTING_LOCKED_KEYS = new Set([ + 'admin_url', 'apk_download_link', 'app_url', + 'bale_bot_token', 'banner_enabled', + 'chart_id', 'chart_use_local_data', + 'external_order_api_enabled', + 'external_payment_enabled', + 'external_payment_base_url', + 'external_payment_client_id', + 'external_payment_client_secret', + 'external_payment_gateway', + 'external_payment_return_url', + 'external_payment_webhook_url', + 'wallet_payment_provider', + 'fee_deduct_from_gold', + 'referral_bonus_amount', + 'group_sms', + 'customer_special_pricing', + 'customer_direct_sms', + 'enable_conditional_order', + 'enable_product_funds', + 'has_page_price_trigger', + 'has_page_notification', + 'has_page_gift_card', + 'enable_customer_level_profit', + 'jibit_enabled', + 'has_page_earn_money', + 'is_store', + 'sms_panel', + 'logo_login', 'logo_register', 'title_pwa', 'title_fav', + 'main_product', 'main_wallet_products', 'metal_type', 'main_metal', + 'montify_name', + 'montzar_base_url', 'montzar_closed', + 'montzar_diff', 'montzar_last_product_log', 'montzar_live_price', 'montzar_mazaneh', + 'montzar_order_response', 'montzar_token', + 'montzar_truly_buy_mazaneh', 'montzar_truly_sell_mazaneh', + 'node_client_id', 'node_shared_secret', + 'payping_fin_customer_id', 'payping_fin_sync_enabled', 'payping_fin_token', 'payping_fin_url', + 'payping_gateway_items', 'payping_gateway_name', + 'payping_round_toman', 'payping_start_url', 'payping_strict_failed_withdraw', 'payping_verify_url', + 'permision__enabled', + 'price_extreme_date', 'price_provider', 'profit_gold_api_enabled', + 'send_to_tahesab', 'send_to_pargar', 'accounting_provider', 'service_charge_percentage', + 'sse_path', + 'tahesab_bank_account', 'tahesab_db', 'tahesab_host', 'tahesab_port', 'tahesab_token', + 'zarin_api', 'zarin_token', 'zarin_noe_kar', 'zarin_gorohe_kar', 'zarin_name_g', 'zarin_uid_prefix', + 'tracking_systems', 'tracking_systems_enabled', + 'pargar_api_token', 'pargar_password', 'pargar_username', + 'pargar_item_id_rial', 'pargar_item_id_gold', + 'telegram_bot_token', 'rubika_bot_token', + 'price_channel_access', + 'price_channel_bale_to', 'price_channel_telegram_to', 'price_channel_rubika_to', + 'unit', + 'up_price', 'down_price', + 'wss_enabled', + 'zaarbox_api_url', + 'show_to_users_chat', 'chat_api', 'chat_api_key', 'chat_upload_url', 'chat_download_url' +]) + +/** قابل ویرایش در پنل — هم‌تراز deploy_settings_const.sql */ +export const SETTING_EDITABLE_KEYS = new Set([ + 'name_fa', + 'auto_active_customer', 'balance_report_exclude_customer_id', + 'buy_fee', 'charge_notif', 'close_price', + 'require_zero_main_gold_before_other_buy', + 'sms_notif_order_admin', 'sms_notif_order_customer', + 'sms_notif_withdraw_admin', 'sms_notif_withdraw_otp', 'sms_notif_login_otp', + 'sms_notif_gateway_charge_admin', 'sms_notif_register_customer', 'sms_notif_register_admin', + 'sms_notif_jibit_admin', 'sms_notif_bank_card_admin', 'sms_notif_bank_card_customer', + 'sms_notif_fund_withdraw_admin', 'sms_notif_fund_withdraw_customer', + 'sms_notif_price_trigger', 'sms_notif_wallet_alert', 'sms_notif_support_admin', + 'deposit_sms_support_number', 'deposit_support_message_text', + 'deposit_account_holder', 'deposit_card_number', 'deposit_account_number', 'deposit_sheba', + 'deposit_method_card_to_card', 'deposit_method_gateway', 'deposit_method_identified', 'deposit_method_shetab', + 'endDay', + 'fee_free', 'fee_service_charge_percentage', + 'gift_for_referral', 'gift_for_referrer', + 'invite_link_text', + 'jibit_verification', + 'main_product_price', + 'min_first_buy', 'min_withdraw', 'min_deposit', 'minimum_sell', + 'montzar_mobile', 'montzar_open_buy', 'montzar_open_sell', + 'montzar_password', 'montzar_product_id', + 'open_register', + 'referral_id', 'referral_limit', 'referral_gift_on_first_order', 'referral_gift_buy_from_montzar', + 'register_jibit_verify', 'register_referral_bonus_message', 'register_referral_sms_to_referrer', + 'sell_fee', 'show_last_price', 'show_home_price_when_closed', 'show_gold_ounce_price', 'show_product_details', + 'show_product_trade_status', 'show_product_card_meta', + 'products_site_link_enabled', 'products_site_link_url', 'products_site_link_text', + 'startDay', + 'support_messengers', 'support_messenger_text', + 'has_chat', + 'deposit_support_message_text', 'deposit_account_holder', 'deposit_card_number', 'deposit_account_number', 'deposit_sheba', + 'deposit_method_card_to_card', 'deposit_method_gateway', 'deposit_method_identified', 'deposit_method_shetab', + 'text_about', 'text_rule', + 'zaarbox_price_key', + 'price_channel_enabled', 'price_channel_messengers', 'price_channel_diff', + 'price_channel_min_seconds', 'price_channel_footer_id', 'price_channel_footer_phone', + 'price_channel_footer_link', 'change_mesghal' +]) + +/** عنوان فارسی — کاتالوگ پنل بر title_fa دیتابیس اولویت دارد */ +export function resolveSettingTitle(row: Pick): string { + const key = (row.key ?? '').trim() + if (key && KEY_LABEL_FALLBACK[key]) return KEY_LABEL_FALLBACK[key] + return row.title_fa?.trim() || key || '—' +} + +/** توضیح — کاتالوگ پنل بر description دیتابیس اولویت دارد */ +export function resolveSettingDescription(row: Pick): string { + const key = (row.key ?? '').trim() + const help = key ? KEY_HELP_FALLBACK[key] : undefined + if (help) { + const fieldType = resolveSettingFieldType(row) + if (fieldType === 'select') { + if (SELECT_OPTIONS_BY_KEY[key]) return `${help}|${SELECT_OPTIONS_BY_KEY[key]}` + const dbDesc = (row.description ?? '').trim() + if (dbDesc.includes('|')) { + const options = dbDesc.split('|').pop()?.trim() + if (options) return `${help}|${options}` + } + } + return help + } + return row.description ?? '' +} + +/** can_edit — مقدار دیتابیس اولویت دارد؛ لیست‌های ثابت فقط fallback */ +export function resolveSettingCanEdit(row: Pick): Const['can_edit'] { + const key = (row.key ?? '').trim() + if (row.can_edit !== undefined && row.can_edit !== null) { + return row.can_edit + } + if (SETTING_LOCKED_KEYS.has(key)) return 0 + if (SETTING_EDITABLE_KEYS.has(key)) return 1 + return 1 +} + +/** اعمال override متادیتا (عنوان، توضیح، نوع، can_edit) روی ردیف API */ +export function normalizeSettingRow(row: Const): Const { + const key = (row.key ?? '').trim() + if (!key) return row + + const withType = { + ...row, + type: row.type || KEY_TYPE_FALLBACK[key] || row.type + } + + return { + ...withType, + title_fa: resolveSettingTitle(withType), + description: resolveSettingDescription(withType), + can_edit: resolveSettingCanEdit(withType), + type: resolveSettingFieldType(withType) + } +} + +export function helpText(row: Const): string { + const key = (row.key ?? '').trim() + if (key && KEY_HELP_FALLBACK[key]) { + return KEY_HELP_FALLBACK[key] + } + + const desc = resolveSettingDescription(row).trim() + if (!desc) return '' + + if (resolveSettingFieldType(row) === 'select' && desc.includes('|')) { + return desc.split('|')[0]?.trim() ?? '' + } + if (resolveSettingFieldType(row) === 'select' && desc.includes(',') && !/[روشن|خاموش]/.test(desc)) { + return '' + } + return desc +} + +/** نرمال‌سازی ساعت به فرمت HH:mm:ss برای ذخیره و سازگاری با بک‌اند */ +export function normalizeTimeValue(value: string | undefined | null): string { + const raw = String(value ?? '').trim() + if (!raw) return '' + const parts = raw.split(':') + const pad = (n: string) => n.padStart(2, '0') + if (parts.length >= 3) return `${pad(parts[0])}:${pad(parts[1])}:${pad(parts[2])}` + if (parts.length === 2) return `${pad(parts[0])}:${pad(parts[1])}:00` + return raw +} diff --git a/src/config/khanegiSettingRow.ts b/src/config/khanegiSettingRow.ts new file mode 100644 index 0000000..2939acd --- /dev/null +++ b/src/config/khanegiSettingRow.ts @@ -0,0 +1,10 @@ +export type KhanegiSettingRow = { + id?: number + key?: string + value?: string + description?: string + title_fa?: string + type?: string + is_active?: boolean | number | string + can_edit?: number | boolean | string +} diff --git a/src/config/khanegiSystemSettingGroups.ts b/src/config/khanegiSystemSettingGroups.ts new file mode 100644 index 0000000..23b6b74 --- /dev/null +++ b/src/config/khanegiSystemSettingGroups.ts @@ -0,0 +1,165 @@ +export type SettingSubsectionDef = { + id: string + title: string + match: (key: string) => boolean +} + +export type SettingGroupDef = { + id: string + title: string + icon: string + match: (key: string) => boolean + subsections?: SettingSubsectionDef[] +} + +/** گروه‌های تنظیمات دیپلوی خانگی (can_edit=0) — هم‌تراز پنل ادمین. + * کلید جدید برای همهٔ نسخه‌ها: اول `controlpanel_server/.../program-setting-catalog.ts` + * بعد این‌جا گروه را مشخص کن تا در «متفرقه» نماند. + */ +export const KHANEGI_SYSTEM_SETTING_GROUPS: SettingGroupDef[] = [ + { + id: 'customer_features', + title: 'دسترسی مشتری به قابلیت‌ها', + icon: 'TeamOutlined', + match: (k) => + /^(enable_conditional_order|enable_product_funds|has_page_price_trigger|has_page_notification|has_page_gift_card|enable_customer_level_profit|banner_enabled|jibit_enabled|customer_direct_sms|has_page_earn_money|group_sms|customer_special_pricing|tracking_systems_enabled|permision__enabled|show_to_users_chat)$/.test( + k, + ), + }, + { + id: 'support_chat', + title: 'چت پشتیبانی', + icon: 'MessageOutlined', + match: (k) => /^(chat_api|chat_api_key|chat_upload_url|chat_download_url)$/.test(k), + }, + { + id: 'general', + title: 'عمومی و برند', + icon: 'HomeOutlined', + match: (k) => + /^(name_fa|name_en|app_url|admin_url|apk_download_link|bace_url|server_url|logo_|title_pwa|title_fav|metal_type|main_metal|unit$|open_register|auto_active_customer|store_status|key$|is_store|sms_panel|main_wallet_products)/.test( + k, + ), + }, + { + id: 'chart', + title: 'نمودار قیمت', + icon: 'LineChartOutlined', + match: (k) => /^chart_/.test(k), + }, + { + id: 'pricing', + title: 'منبع قیمت و تابلو', + icon: 'DollarOutlined', + match: (k) => /^(price_provider|zaarbox_|main_product$)/.test(k) || k.includes('charge_percentage'), + }, + { + id: 'montzar', + title: 'Montzar', + icon: 'LinkOutlined', + match: (k) => /^montzar_/.test(k) || k === 'display_montzar', + }, + { + id: 'store', + title: 'اتصال فروشگاه', + icon: 'ShopOutlined', + match: (k) => /^node_/.test(k) || k === 'montify_name' || k === 'external_order_api_enabled', + }, + { + id: 'deposit_methods', + title: 'روش‌های واریز', + icon: 'CreditCardOutlined', + match: (k) => + k.startsWith('external_payment_') || k === 'wallet_payment_provider', + subsections: [ + { + id: 'gateway', + title: 'درگاه فروشگاه / پرداخت آنلاین', + match: (k) => k.startsWith('external_payment_') || k === 'wallet_payment_provider', + }, + ], + }, + { + id: 'payping', + title: 'درگاه Payping', + icon: 'WalletOutlined', + match: (k) => k.startsWith('payping_'), + }, + { + id: 'accounting_provider', + title: 'انتخاب سیستم حسابداری', + icon: 'AccountBookOutlined', + match: (k) => k === 'accounting_provider', + }, + { + id: 'accounting_zarin', + title: 'حسابداری زرین', + icon: 'BankOutlined', + match: (k) => k.startsWith('zarin_'), + }, + { + id: 'accounting_tahesab', + title: 'حسابداری ته‌حساب', + icon: 'CalculatorOutlined', + match: (k) => k.startsWith('tahesab_') || k === 'send_to_tahesab', + }, + { + id: 'accounting_pargar', + title: 'حسابداری پرگار', + icon: 'FileTextOutlined', + match: (k) => k.startsWith('pargar_') || k === 'send_to_pargar', + }, + { + id: 'price_channel', + title: 'ارسال قیمت به کانال', + icon: 'SendOutlined', + match: (k) => + /^price_channel_/.test(k) || /^(telegram_bot|bale_bot|rubika_bot)/.test(k) || k === 'change_mesghal', + }, + { + id: 'infra', + title: 'زیرساخت', + icon: 'ClusterOutlined', + match: (k) => /^(wss_enabled|sse_path|profit_gold_api_enabled)$/.test(k), + }, + { + id: 'misc', + title: 'سایر', + icon: 'EllipsisOutlined', + match: () => true, + }, +] + +export function getKhanegiSystemGroupId(key: string): string { + for (const group of KHANEGI_SYSTEM_SETTING_GROUPS) { + if (group.id === 'misc') continue + if (group.match(key)) return group.id + } + return 'misc' +} + +export function getKhanegiSubsectionId(key: string, group: SettingGroupDef | undefined): string | null { + if (!group?.subsections?.length) return null + for (const sub of group.subsections) { + if (sub.match(key)) return sub.id + } + return null +} + +export function isSecretSettingKey(key: string): boolean { + return /(password|secret|token|api_key|apikey|private_key|shared_secret)/i.test(key) +} + +export function isSwitchSetting(type: string | null | undefined, value: unknown): boolean { + const t = String(type || '').toLowerCase() + if (t === 'switch' || t === 'boolean' || t === 'bool') return true + if (typeof value === 'boolean') return true + const v = String(value ?? '').trim().toLowerCase() + return v === 'true' || v === 'false' +} + +export function isSettingOn(value: unknown): boolean { + if (value === true || value === 1) return true + const v = String(value ?? '').trim().toLowerCase() + return v === 'true' || v === '1' || v === 'on' +} diff --git a/src/config/numeric.ts b/src/config/numeric.ts new file mode 100644 index 0000000..d032181 --- /dev/null +++ b/src/config/numeric.ts @@ -0,0 +1,45 @@ +const PERSIAN_DIGITS = '۰۱۲۳۴۵۶۷۸۹' +const ARABIC_DIGITS = '٠١٢٣٤٥٦٧٨٩' + +export function persian2english(s: string): string { + return String(s ?? '') + .replace(/[۰-۹]/g, (d) => String(PERSIAN_DIGITS.indexOf(d))) + .replace(/[٠-٩]/g, (d) => String(ARABIC_DIGITS.indexOf(d))) +} + +export function sanitizeNumericString( + raw: unknown, + options: { allowDecimal?: boolean } = {}, +): string { + const allowDecimal = options.allowDecimal !== false + let s = persian2english(String(raw ?? '').trim()) + .replace(/[٬,\s]/g, '') + .replace(/[−–—]/g, '-') + .replace(/٫/g, '.') + .replace(/\//g, '.') + + if (!allowDecimal) { + return s.replace(/[^\d-]/g, '').replace(/(?!^)-/g, '') + } + + let out = '' + let hasDot = false + let hasSign = false + for (const ch of s) { + if (ch === '-' && !hasSign && out === '') { + hasSign = true + out += ch + } else if (ch === '.' && !hasDot) { + hasDot = true + out += ch + } else if (ch >= '0' && ch <= '9') { + out += ch + } + } + return out +} + +export function parseNumericValue(raw: unknown): number { + const n = Number(sanitizeNumericString(raw, { allowDecimal: true })) + return Number.isFinite(n) ? n : NaN +} diff --git a/src/pages/ProductVersionsPage.vue b/src/pages/ProductVersionsPage.vue index c42daa4..9c0083a 100644 --- a/src/pages/ProductVersionsPage.vue +++ b/src/pages/ProductVersionsPage.vue @@ -6,6 +6,7 @@ import { ArrowRightOutlined, PlusOutlined, SearchOutlined } from '@ant-design/ic import api from '@/api/http' import { useAuthStore } from '@/stores/auth' import { formatIranDateTime } from '@/utils/datetime' +import SettingsCatalogPanel from '@/components/SettingsCatalogPanel.vue' type Tenant = { id: string @@ -20,6 +21,7 @@ type Tenant = { nodeServer: string notes?: string | null hasAppUiConfig?: boolean + hasProgramSettings?: boolean uiVariant?: 'modular' | 'newTheme' createdAt?: string } @@ -123,6 +125,8 @@ onMounted(load) + +
@@ -154,6 +158,7 @@ onMounted(load) بررسی محصول ظاهر اپ + تنظیمات برنامه {{ item.uiVariant === 'newTheme' ? 'New theme' : 'Modular' }} @@ -164,6 +169,20 @@ onMounted(load) مدیریت + + تنظیمات برنامه + +import { computed, onMounted, reactive, ref, watch, type Component } from 'vue' +import { useRoute, useRouter } from 'vue-router' +import { message } from 'ant-design-vue' +import { + AccountBookOutlined, + ArrowRightOutlined, + BankOutlined, + CalculatorOutlined, + ClusterOutlined, + CreditCardOutlined, + DollarOutlined, + EllipsisOutlined, + FileTextOutlined, + GlobalOutlined, + HomeOutlined, + LineChartOutlined, + LinkOutlined, + MessageOutlined, + SearchOutlined, + SendOutlined, + ShopOutlined, + TeamOutlined, + WalletOutlined, +} from '@ant-design/icons-vue' +import api from '@/api/http' +import SettingCard from '@/components/program-settings/SettingCard.vue' +import { + KHANEGI_SYSTEM_SETTING_GROUPS, + getKhanegiSystemGroupId, + type SettingGroupDef, +} from '@/config/khanegiSystemSettingGroups' +import { + SETTING_TYPE_OPTIONS, + formatValueForSave, + isSettingVisible, + normalizeSettingRow, + resolveSettingFieldType, +} from '@/config/khanegiSettingCatalog' +import type { KhanegiSettingRow } from '@/config/khanegiSettingRow' +import { useAuthStore } from '@/stores/auth' + +type TenantMeta = { + id: string + nameFa: string + slug: string + domain: string + product: 'hamkar' | 'khanegi' + appUrl?: string | null + adminUrl?: string | null + uiVariant?: 'modular' | 'newTheme' + nodeServer?: string +} + +const route = useRoute() +const router = useRouter() +const auth = useAuthStore() +const canWrite = computed(() => auth.can('khanegi:write')) +const loading = ref(true) +const importing = ref(false) +const search = ref('') +const filterType = ref() +const tenant = ref(null) +const apiBase = ref(null) +const items = ref([]) +const drafts = ref>({}) +const savingKeys = ref>(new Set()) +const savedFlashKeys = ref>(new Set()) +const saveTimers = new Map>() +const flashTimers = new Map>() +const activeKey = ref(undefined) + +const creds = reactive({ + mobile: '', + password: '', +}) + +type ImportPreviewRow = { + key: string + title_fa: string + khanegi_value: string + superadmin_value: string | null + status: 'new_in_khanegi' | 'both' + scope: 'none' | 'this' | 'all' +} + +const previewRows = ref([]) +const onlySaKeys = ref([]) +const previewOpen = ref(false) +const applying = ref(false) + +const typeSelectOptions = SETTING_TYPE_OPTIONS.map((t) => ({ + value: t.key, + label: t.value, +})) + +const GROUP_ICONS: Record = { + customer_features: TeamOutlined, + support_chat: MessageOutlined, + general: HomeOutlined, + chart: LineChartOutlined, + pricing: DollarOutlined, + montzar: LinkOutlined, + store: ShopOutlined, + deposit_methods: CreditCardOutlined, + payping: WalletOutlined, + accounting_provider: AccountBookOutlined, + accounting_zarin: BankOutlined, + accounting_tahesab: CalculatorOutlined, + accounting_pargar: FileTextOutlined, + price_channel: SendOutlined, + infra: ClusterOutlined, + misc: EllipsisOutlined, +} + +const count = computed(() => items.value.length) + +function asRow(raw: Record): KhanegiSettingRow { + return normalizeSettingRow({ + id: typeof raw.id === 'number' ? raw.id : undefined, + key: String(raw.key || ''), + value: raw.value == null ? '' : String(raw.value), + title_fa: raw.title_fa == null ? '' : String(raw.title_fa), + description: raw.description == null ? '' : String(raw.description), + type: raw.type == null ? '' : String(raw.type), + is_active: (raw.is_active as KhanegiSettingRow['is_active']) ?? 1, + can_edit: (raw.can_edit as KhanegiSettingRow['can_edit']) ?? 0, + }) +} + +const filteredRows = computed(() => { + const q = search.value.trim().toLowerCase() + return [...items.value] + .filter((row) => { + if (!isSettingVisible(row.key)) return false + if (filterType.value && resolveSettingFieldType(row) !== filterType.value) return false + if (!q) return true + return ( + (row.key || '').toLowerCase().includes(q) || + (row.title_fa || '').toLowerCase().includes(q) || + (row.value || '').toLowerCase().includes(q) || + (row.description || '').toLowerCase().includes(q) + ) + }) + .sort((a, b) => + (a.title_fa || a.key || '').localeCompare(b.title_fa || b.key || '', 'fa'), + ) +}) + +const groupedFiltered = computed(() => { + const map: Record = {} + for (const g of KHANEGI_SYSTEM_SETTING_GROUPS) map[g.id] = [] + for (const row of filteredRows.value) { + const gid = getKhanegiSystemGroupId(row.key || '') + map[gid].push(row) + } + return map +}) + +const visibleSettingGroups = computed(() => + KHANEGI_SYSTEM_SETTING_GROUPS.filter((g) => (groupedFiltered.value[g.id]?.length ?? 0) > 0), +) + +function subsectionBlocks(group: SettingGroupDef) { + const rows = groupedFiltered.value[group.id] ?? [] + if (!group.subsections?.length) { + return [{ id: 'all', title: group.title, rows }] + } + const used = new Set() + const blocks = group.subsections + .map((sub) => { + const subRows = rows.filter((row) => { + const key = row.key || '' + if (used.has(key) || !sub.match(key)) return false + used.add(key) + return true + }) + return { id: sub.id, title: sub.title, rows: subRows } + }) + .filter((b) => b.rows.length > 0) + const leftovers = rows.filter((row) => !used.has(row.key || '')) + if (leftovers.length) { + blocks.push({ id: `${group.id}-other`, title: 'سایر', rows: leftovers }) + } + return blocks +} + +function rowColSpan(row: KhanegiSettingRow): number { + return resolveSettingFieldType(row) === 'longtext' ? 24 : 8 +} + +function rowKey(row: KhanegiSettingRow) { + return String(row.key || row.id || '') +} + +function draftValue(row: KhanegiSettingRow) { + const k = rowKey(row) + if (drafts.value[k] !== undefined) return drafts.value[k] + return row.value ?? '' +} + +function isSaving(row: KhanegiSettingRow) { + return savingKeys.value.has(rowKey(row)) +} + +function isSavedFlash(row: KhanegiSettingRow) { + return savedFlashKeys.value.has(rowKey(row)) +} + +function onValueChange(row: KhanegiSettingRow, value: string) { + if (!canWrite.value) return + drafts.value[rowKey(row)] = value + scheduleSave(row) +} + +function onFieldBlur(row: KhanegiSettingRow) { + if (!canWrite.value) return + scheduleSave(row, true) +} + +function scheduleSave(row: KhanegiSettingRow, flush = false) { + const k = rowKey(row) + const prev = saveTimers.get(k) + if (prev) clearTimeout(prev) + const type = resolveSettingFieldType(row) + const delay = flush ? 0 : type === 'switch' ? 200 : type === 'longtext' ? 2500 : 700 + saveTimers.set( + k, + setTimeout(() => { + void persistChanged() + }, delay), + ) +} + +async function persistChanged() { + if (!canWrite.value) return + const keys = Object.keys(drafts.value) + if (!keys.length) return + const patchItems = keys + .map((k) => { + const row = items.value.find((item) => rowKey(item) === k) + if (!row) return null + const raw = drafts.value[k] !== undefined ? drafts.value[k] : (row.value ?? '') + const type = resolveSettingFieldType(row) + return { + id: row.id, + key: row.key, + value: formatValueForSave(raw, type, row.key), + title_fa: row.title_fa ?? '', + description: row.description ?? '', + type: row.type ?? type, + is_active: row.is_active ?? 1, + can_edit: 0, + } + }) + .filter((row): row is NonNullable => Boolean(row?.key)) + if (!patchItems.length) return + keys.forEach((k) => savingKeys.value.add(k)) + savingKeys.value = new Set(savingKeys.value) + try { + const { data } = await api.patch(`/tenants/${route.params.id}/program-settings`, { + items: patchItems, + }) + applyItems(Array.isArray(data.items) ? data.items : items.value) + drafts.value = {} + keys.forEach((k) => { + savedFlashKeys.value.add(k) + const old = flashTimers.get(k) + if (old) clearTimeout(old) + flashTimers.set( + k, + setTimeout(() => { + savedFlashKeys.value.delete(k) + savedFlashKeys.value = new Set(savedFlashKeys.value) + }, 1800), + ) + }) + savedFlashKeys.value = new Set(savedFlashKeys.value) + } catch (e: any) { + message.error(errText(e) || 'ذخیره تنظیمات ناموفق بود') + } finally { + keys.forEach((k) => savingKeys.value.delete(k)) + savingKeys.value = new Set(savingKeys.value) + } +} + +function uiRouteName() { + return tenant.value?.uiVariant === 'newTheme' ? 'version-ui' : 'version-ui-modular' +} + +function openUrl(url?: string | null) { + const href = String(url || '').trim() + if (!href) { + message.warning('آدرس اپ برای این نسخه ثبت نشده') + return + } + const withProto = /^https?:\/\//i.test(href) ? href : `https://${href}` + window.open(withProto, '_blank', 'noopener,noreferrer') +} + +function errText(e: any) { + const msg = e?.response?.data?.message + return Array.isArray(msg) ? msg.join('، ') : msg || 'خطا' +} + +function applyItems(raw: unknown[]) { + items.value = raw.map((row) => asRow((row || {}) as Record)) +} + +async function load() { + loading.value = true + try { + const { data } = await api.get(`/tenants/${route.params.id}/program-settings`) + tenant.value = data.tenant + apiBase.value = data.apiBase || null + applyItems(Array.isArray(data.items) ? data.items : []) + } catch (e: any) { + message.error(errText(e) || 'خطا در بارگذاری تنظیمات برنامه') + if (e?.response?.status === 403 || e?.response?.status === 404) { + router.push({ name: 'home' }) + } + } finally { + loading.value = false + } +} + +async function importSettings() { + if (!canWrite.value) { + message.warning('دسترسی نوشتن ندارید') + return + } + if (!creds.mobile.trim() || !creds.password) { + message.warning('نام کاربری و رمز عبور ادمین خانگی را وارد کنید') + return + } + importing.value = true + try { + const { data } = await api.post(`/tenants/${route.params.id}/program-settings/import`, { + mobile: creds.mobile.trim(), + password: creds.password, + preview: true, + }) + previewRows.value = (data.decisionsNeeded || []).map((row: ImportPreviewRow) => ({ + ...row, + scope: 'this', + })) + onlySaKeys.value = data.onlySuperAdminKeys || [] + apiBase.value = data.apiBase || apiBase.value + previewOpen.value = true + } catch (e: any) { + message.error(errText(e) || 'پیش‌نمایش ایمپورت ناموفق بود') + } finally { + importing.value = false + } +} + +async function applyImport() { + applying.value = true + try { + const { data } = await api.post(`/tenants/${route.params.id}/program-settings/import`, { + mobile: creds.mobile.trim(), + password: creds.password, + preview: false, + decisions: previewRows.value.map((row) => ({ key: row.key, scope: row.scope })), + }) + tenant.value = data.tenant + apiBase.value = data.apiBase || apiBase.value + applyItems(Array.isArray(data.items) ? data.items : []) + creds.password = '' + previewOpen.value = false + message.success('ایمپورت اعمال شد. کلیدهای فقط-سوپرادمین با پیش‌فرض قالب پر شدند.') + } catch (e: any) { + message.error(errText(e) || 'اعمال ایمپورت ناموفق بود') + } finally { + applying.value = false + } +} + +const defaultOpenGroupId = computed(() => { + const visibleIds = visibleSettingGroups.value.map((g) => g.id) + const preferred = ['customer_features', 'chart', 'pricing', 'payping', 'store', 'general'] + return preferred.find((id) => visibleIds.includes(id)) ?? visibleIds[0] +}) + +watch( + visibleSettingGroups, + (groups) => { + const ids = new Set(groups.map((g) => g.id)) + if (activeKey.value && !ids.has(activeKey.value)) { + activeKey.value = groups[0]?.id + } + if (!activeKey.value && groups.length) { + activeKey.value = defaultOpenGroupId.value + } + }, + { flush: 'post' }, +) + +onMounted(load) + + + + + diff --git a/src/pages/TenantFormPage.vue b/src/pages/TenantFormPage.vue index 0d4de2b..013cc21 100644 --- a/src/pages/TenantFormPage.vue +++ b/src/pages/TenantFormPage.vue @@ -23,6 +23,7 @@ const form = reactive({ nameFa: '', nameEn: '', domain: '', + domains: [] as string[], avatarUrl: '', appVersion: '1', uiVariant: 'modular' as 'modular' | 'newTheme', @@ -51,6 +52,7 @@ async function load() { if (!isEdit.value) { const q = String(route.query.product || '') form.product = q === 'khanegi' ? 'khanegi' : 'hamkar' + form.domains = [] createdAt.value = null return } @@ -65,6 +67,7 @@ async function load() { nameFa: data.nameFa, nameEn: data.nameEn || '', domain: data.domain, + domains: Array.isArray(data.domains) ? data.domains.map(String) : [], avatarUrl: data.avatarUrl || '', appVersion: data.appVersion || '1', uiVariant: data.uiVariant === 'newTheme' ? 'newTheme' : 'modular', @@ -119,7 +122,10 @@ async function save() { } saving.value = true try { - const payload = { ...form } + const payload = { + ...form, + domains: form.domains.map((item) => item.trim()).filter(Boolean), + } if (!payload.remoteApiToken) { delete (payload as any).remoteApiToken } @@ -153,19 +159,30 @@ onMounted(load)

{{ isEdit ? 'مدیریت نسخه' : 'نسخه فروشی جدید' }}

محصول: {{ productLabel }} · تنظیمات رجیستری برند (/get_user)

- - ظاهر اپ - + + + تنظیمات برنامه + + + ظاهر اپ + + @@ -189,6 +206,29 @@ onMounted(load) + +

+ برای /get_user. داخل همان محصول (خانگی یا همکار) هر دامنه/IP فقط روی یک نسخه می‌ماند؛ + اگر از قبل روی نسخه دیگری از همین محصول باشد، از آنجا برداشته و به این نسخه اضافه می‌شود. + خانگی و همکار روی هم اثر ندارند. +

+
+
+ + + + +
+ + افزودن دامنه / IP + +
+
@@ -322,4 +362,19 @@ onMounted(load) max-height: 100%; object-fit: contain; } + +.alias-list { + display: grid; + gap: 8px; +} + +.alias-row { + display: flex; + gap: 8px; + align-items: center; +} + +.alias-row :deep(.ant-input) { + flex: 1; +} diff --git a/src/router/index.ts b/src/router/index.ts index 5173a50..df73fbc 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -113,6 +113,12 @@ const router = createRouter({ component: () => import('@/pages/KhanegiModularUiConfigPage.vue'), meta: { permissions: ['khanegi:read'] }, }, + { + path: 'versions/:id/settings', + name: 'version-program-settings', + component: () => import('@/pages/ProgramSettingsPage.vue'), + meta: { permissions: ['khanegi:read'] }, + }, { path: 'versions/:id/ui-hamkar', name: 'version-ui-hamkar', diff --git a/vite.config.ts b/vite.config.ts index 5151154..c87964c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,33 +1,47 @@ -import { defineConfig } from 'vite' +import { defineConfig, loadEnv } from 'vite' import vue from '@vitejs/plugin-vue' import { fileURLToPath, URL } from 'node:url' -export default defineConfig({ - plugins: [vue()], - resolve: { - alias: { - '@': fileURLToPath(new URL('./src', import.meta.url)), - }, - }, - server: { - port: 5173, - proxy: { - '/api': { - target: 'http://localhost:3001', - changeOrigin: true, - }, - '/get_user': { - target: 'http://localhost:3001', - changeOrigin: true, - }, - '/get_version': { - target: 'http://localhost:3001', - changeOrigin: true, - }, - '/uploads': { - target: 'http://localhost:3001', - changeOrigin: true, +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), '') + const proxyTarget = env.VITE_DEV_PROXY_TARGET || 'https://adm.montzar.ir' + + return { + plugins: [vue()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), }, }, - }, + server: { + port: 5173, + proxy: { + '/api': { + target: proxyTarget, + changeOrigin: true, + secure: true, + }, + '/get_user': { + target: proxyTarget, + changeOrigin: true, + secure: true, + }, + '/get_settings': { + target: proxyTarget, + changeOrigin: true, + secure: true, + }, + '/get_version': { + target: proxyTarget, + changeOrigin: true, + secure: true, + }, + '/uploads': { + target: proxyTarget, + changeOrigin: true, + secure: true, + }, + }, + }, + } })