feat(settings): unify settings and ui-texts with canonical aliases, add PriceInput thousand separators, and synchronize financial endpoints
All checks were successful
Deploy Canina / deploy (push) Successful in 1m46s

This commit is contained in:
parsa aghaei 2026-08-17 09:57:23 +03:30
parent 45c9db133c
commit b3c60a1a7d
17 changed files with 79341 additions and 78725 deletions

View File

@ -10,6 +10,7 @@ import { RedisService } from '../redis/redis.service';
import * as bcrypt from 'bcryptjs';
import { CreateUserDto, UpdateUserDto } from './dto/user.dto';
import { normalizeMobile } from '../common/utils/phone.utils';
import { CANONICAL_ALIASES } from '../settings/settings.service';
export class PaginationQuery {
page?: number | string;
@ -620,7 +621,17 @@ export class AdminService {
}
async updateSettings(data: Record<string, string>) {
const operations = Object.entries(data).map(([key, value]) => {
if (!data || typeof data !== 'object') return this.getSettings();
const mergedData: Record<string, string> = { ...data };
Object.entries(data).forEach(([key, value]) => {
const aliases = CANONICAL_ALIASES[key] || [];
aliases.forEach((alias) => {
mergedData[alias] = String(value);
});
});
const operations = Object.entries(mergedData).map(([key, value]) => {
return this.prisma.uiText.upsert({
where: { key },
update: { value: String(value) },
@ -629,6 +640,20 @@ export class AdminService {
});
await this.prisma.$transaction(operations);
// Also sync setting table
for (const [key, value] of Object.entries(mergedData)) {
try {
await this.prisma.setting.upsert({
where: { key },
update: { value: String(value) },
create: { key, value: String(value), category: 'general' },
});
} catch (e) {
// ignore
}
}
return this.getSettings();
}

View File

@ -77,6 +77,22 @@ export class SettingsController {
return this.settingsService.updateBulkUiTexts(body);
}
@Get('financial')
@ApiOperation({ summary: 'دریافت تنظیمات مالی و ارسال' })
getFinancial() {
return this.settingsService.getFinancialSettings();
}
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('Admin')
@ApiBearerAuth()
@Patch('financial')
@Put('financial')
@ApiOperation({ summary: 'ویرایش تنظیمات مالی و ارسال' })
updateFinancial(@Body() body: any) {
return this.settingsService.updateFinancialSettings(body);
}
@Get('scientific-terms')
@ApiOperation({ summary: 'دریافت تمامی اصطلاحات واژه‌نامه علمی' })
getScientificTerms() {

View File

@ -10,6 +10,78 @@ export class ScientificTermData {
wikiId?: string;
}
export const CANONICAL_ALIASES: Record<string, string[]> = {
// Brand & Logo
BRAND_LOGO_TEXT_EN: ['site_logo_text_en'],
site_logo_text_en: ['BRAND_LOGO_TEXT_EN'],
BRAND_LOGO_TEXT_FA: ['site_logo_text_fa', 'brand_name_fa'],
site_logo_text_fa: ['BRAND_LOGO_TEXT_FA', 'brand_name_fa'],
brand_name_fa: ['BRAND_LOGO_TEXT_FA', 'site_logo_text_fa'],
BRAND_LOGO_SUBTITLE: ['site_logo_subtitle', 'brand_subtitle'],
site_logo_subtitle: ['BRAND_LOGO_SUBTITLE', 'brand_subtitle'],
brand_subtitle: ['BRAND_LOGO_SUBTITLE', 'site_logo_subtitle'],
BRAND_LOGO_URL: ['site_logo', 'brand_logo_url'],
site_logo: ['BRAND_LOGO_URL', 'brand_logo_url'],
brand_logo_url: ['BRAND_LOGO_URL', 'site_logo'],
// Contact Info
CONTACT_PHONE: ['contact_phone', 'maintenance_contact_phone', 'footer_phone', 'supportPhone'],
contact_phone: ['CONTACT_PHONE', 'maintenance_contact_phone', 'footer_phone', 'supportPhone'],
maintenance_contact_phone: ['CONTACT_PHONE', 'contact_phone', 'footer_phone', 'supportPhone'],
footer_phone: ['CONTACT_PHONE', 'contact_phone', 'maintenance_contact_phone', 'supportPhone'],
supportPhone: ['CONTACT_PHONE', 'contact_phone', 'maintenance_contact_phone', 'footer_phone'],
CONTACT_PHONE_LINK: ['contact_phone_link', 'maintenance_contact_phone_link'],
contact_phone_link: ['CONTACT_PHONE_LINK', 'maintenance_contact_phone_link'],
maintenance_contact_phone_link: ['CONTACT_PHONE_LINK', 'contact_phone_link'],
CONTACT_EMAIL: ['contact_email', 'footer_email'],
contact_email: ['CONTACT_EMAIL', 'footer_email'],
footer_email: ['CONTACT_EMAIL', 'contact_email'],
CONTACT_ADDRESS: ['contact_address', 'footer_address'],
contact_address: ['CONTACT_ADDRESS', 'footer_address'],
footer_address: ['CONTACT_ADDRESS', 'contact_address'],
SOCIAL_WHATSAPP: ['contact_whatsapp'],
contact_whatsapp: ['SOCIAL_WHATSAPP'],
SOCIAL_TELEGRAM: ['contact_telegram'],
contact_telegram: ['SOCIAL_TELEGRAM'],
SOCIAL_INSTAGRAM: ['contact_instagram'],
contact_instagram: ['SOCIAL_INSTAGRAM'],
// Financial & Shipping
SHIPPING_FEE: ['shipping_fee', 'standardShippingFee'],
shipping_fee: ['SHIPPING_FEE', 'standardShippingFee'],
standardShippingFee: ['SHIPPING_FEE', 'shipping_fee'],
MIN_ORDER_AMOUNT: ['min_order_amount', 'minOrderAmount'],
min_order_amount: ['MIN_ORDER_AMOUNT', 'minOrderAmount'],
minOrderAmount: ['MIN_ORDER_AMOUNT', 'min_order_amount'],
FREE_SHIPPING_THRESHOLD: ['free_shipping_threshold', 'freeShippingThreshold'],
free_shipping_threshold: ['FREE_SHIPPING_THRESHOLD', 'freeShippingThreshold'],
freeShippingThreshold: ['FREE_SHIPPING_THRESHOLD', 'free_shipping_threshold'],
B2B_DISCOUNT_PERCENT: ['b2b_discount_percent', 'b2bDiscountPercent'],
b2b_discount_percent: ['B2B_DISCOUNT_PERCENT', 'b2bDiscountPercent'],
b2bDiscountPercent: ['B2B_DISCOUNT_PERCENT', 'b2b_discount_percent'],
TAX_PERCENTAGE: ['tax_percentage', 'taxPercentage'],
tax_percentage: ['TAX_PERCENTAGE', 'taxPercentage'],
taxPercentage: ['TAX_PERCENTAGE', 'tax_percentage'],
CHARITY_ROUND_STEP: ['charity_round_step', 'charityRoundStep'],
charity_round_step: ['CHARITY_ROUND_STEP', 'charityRoundStep'],
charityRoundStep: ['CHARITY_ROUND_STEP', 'charity_round_step'],
// Modes
MAINTENANCE_MODE: ['maintenance_mode'],
maintenance_mode: ['MAINTENANCE_MODE'],
CATALOG_ONLY_MODE: ['catalog_mode'],
catalog_mode: ['CATALOG_ONLY_MODE'],
};
@Injectable()
export class SettingsService implements OnModuleInit {
private readonly logger = new Logger(SettingsService.name);
@ -91,21 +163,29 @@ export class SettingsService implements OnModuleInit {
async updateUiText(key: string, value: string) {
const strVal = String(value ?? '');
try {
await this.prisma.setting.upsert({
where: { key },
const allKeysToUpdate = new Set<string>([key]);
const aliases = CANONICAL_ALIASES[key] || [];
aliases.forEach((a) => allKeysToUpdate.add(a));
for (const k of allKeysToUpdate) {
try {
await this.prisma.setting.upsert({
where: { key: k },
update: { value: strVal },
create: { key: k, value: strVal, category: 'general' },
});
} catch (e) {
this.logger.warn(`Failed to sync setting key ${k}: ${e}`);
}
await this.prisma.uiText.upsert({
where: { key: k },
update: { value: strVal },
create: { key, value: strVal, category: 'general' },
create: { key: k, value: strVal },
});
} catch (e) {
this.logger.warn(`Failed to sync setting key ${key}: ${e}`);
}
return this.prisma.uiText.upsert({
where: { key },
update: { value: strVal },
create: { key, value: strVal },
});
return { success: true, key, value: strVal, syncedKeys: Array.from(allKeysToUpdate) };
}
async updateBulkUiTexts(texts: Record<string, string>) {
@ -117,6 +197,77 @@ export class SettingsService implements OnModuleInit {
return { success: true, count: entries.length };
}
async getFinancialSettings() {
const texts = await this.getUiTexts();
const map = new Map<string, string>();
texts.forEach((t) => map.set(t.key, t.value));
const standardShippingFee = Number(map.get('SHIPPING_FEE') || map.get('shipping_fee') || map.get('standardShippingFee') || '0');
const minOrderAmount = Number(map.get('MIN_ORDER_AMOUNT') || map.get('min_order_amount') || map.get('minOrderAmount') || '0');
const freeShippingThreshold = Number(map.get('FREE_SHIPPING_THRESHOLD') || map.get('free_shipping_threshold') || map.get('freeShippingThreshold') || '2000000');
const taxPercentage = Number(map.get('TAX_PERCENTAGE') || map.get('tax_percentage') || map.get('taxPercentage') || '10');
const b2bDiscountPercent = Number(map.get('B2B_DISCOUNT_PERCENT') || map.get('b2b_discount_percent') || map.get('b2bDiscountPercent') || '0');
const charityRoundStep = Number(map.get('CHARITY_ROUND_STEP') || map.get('charity_round_step') || map.get('charityRoundStep') || '10000');
const walletWithdrawalEnabled = map.get('walletWithdrawalEnabled') === 'true';
return {
standardShippingFee,
minOrderAmount,
freeShippingThreshold,
taxPercentage,
b2bDiscountPercent,
charityRoundStep,
charityDonationOptions: [10000, 20000, 50000],
walletWithdrawalEnabled,
};
}
async updateFinancialSettings(data: any) {
const updates: Record<string, string> = {};
if (data.standardShippingFee !== undefined) {
updates['SHIPPING_FEE'] = String(data.standardShippingFee);
updates['shipping_fee'] = String(data.standardShippingFee);
updates['standardShippingFee'] = String(data.standardShippingFee);
}
if (data.shippingFee !== undefined) {
updates['SHIPPING_FEE'] = String(data.shippingFee);
updates['shipping_fee'] = String(data.shippingFee);
updates['standardShippingFee'] = String(data.shippingFee);
}
if (data.minOrderAmount !== undefined) {
updates['MIN_ORDER_AMOUNT'] = String(data.minOrderAmount);
updates['min_order_amount'] = String(data.minOrderAmount);
updates['minOrderAmount'] = String(data.minOrderAmount);
}
if (data.freeShippingThreshold !== undefined) {
updates['FREE_SHIPPING_THRESHOLD'] = String(data.freeShippingThreshold);
updates['free_shipping_threshold'] = String(data.freeShippingThreshold);
updates['freeShippingThreshold'] = String(data.freeShippingThreshold);
}
if (data.taxPercentage !== undefined) {
updates['TAX_PERCENTAGE'] = String(data.taxPercentage);
updates['tax_percentage'] = String(data.taxPercentage);
updates['taxPercentage'] = String(data.taxPercentage);
}
if (data.b2bDiscountPercent !== undefined) {
updates['B2B_DISCOUNT_PERCENT'] = String(data.b2bDiscountPercent);
updates['b2b_discount_percent'] = String(data.b2bDiscountPercent);
updates['b2bDiscountPercent'] = String(data.b2bDiscountPercent);
}
if (data.charityRoundStep !== undefined) {
updates['CHARITY_ROUND_STEP'] = String(data.charityRoundStep);
updates['charity_round_step'] = String(data.charityRoundStep);
updates['charityRoundStep'] = String(data.charityRoundStep);
}
if (data.walletWithdrawalEnabled !== undefined) {
updates['walletWithdrawalEnabled'] = String(data.walletWithdrawalEnabled);
}
await this.updateBulkUiTexts(updates);
return this.getFinancialSettings();
}
async getScientificTerms() {
return this.prisma.scientificTerm.findMany();
}

View File

@ -0,0 +1,99 @@
import React, { useState, useEffect } from 'react';
interface PriceInputProps {
value: number | string;
onChange: (rawNumericValue: string) => void;
placeholder?: string;
className?: string;
suffix?: string;
disabled?: boolean;
required?: boolean;
min?: number;
max?: number;
name?: string;
id?: string;
}
// Convert Persian and Arabic digits to English digits
export function toEnglishDigits(str: string): string {
if (!str) return '';
return str
.replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 1776))
.replace(/[٠-٩]/g, (d) => String(d.charCodeAt(0) - 1632));
}
// Format numbers with thousand comma separator
export function formatPriceWithCommas(value: number | string): string {
if (value === undefined || value === null || value === '') return '';
const cleanStr = toEnglishDigits(String(value)).replace(/[^0-9]/g, '');
if (!cleanStr) return '';
return Number(cleanStr).toLocaleString('en-US');
}
export default function PriceInput({
value,
onChange,
placeholder = '۰',
className = '',
suffix = 'تومان',
disabled = false,
required = false,
min,
max,
name,
id,
}: PriceInputProps) {
const [displayValue, setDisplayValue] = useState<string>('');
useEffect(() => {
if (value === undefined || value === null || value === '') {
setDisplayValue('');
} else {
setDisplayValue(formatPriceWithCommas(value));
}
}, [value]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const rawInput = e.target.value;
const cleanNumbers = toEnglishDigits(rawInput).replace(/[^0-9]/g, '');
if (cleanNumbers === '') {
setDisplayValue('');
onChange('');
return;
}
const numVal = Number(cleanNumbers);
if (max !== undefined && numVal > max) {
return;
}
setDisplayValue(Number(cleanNumbers).toLocaleString('en-US'));
onChange(cleanNumbers);
};
return (
<div className="relative w-full flex items-center">
<input
type="text"
inputMode="numeric"
id={id}
name={name}
disabled={disabled}
required={required}
value={displayValue}
onChange={handleChange}
placeholder={placeholder}
dir="ltr"
className={`w-full text-left font-mono font-bold tracking-wider outline-none transition-all ${
suffix ? 'pr-3 pl-16' : 'px-3'
} ${className}`}
/>
{suffix && (
<span className="absolute left-3 text-xs font-bold text-gray-400 pointer-events-none select-none font-vazir">
{suffix}
</span>
)}
</div>
);
}

View File

@ -5,6 +5,7 @@ import api from '../services/api';
import Spinner from '../components/ui/Spinner';
import Pagination from '../components/ui/Pagination';
import ConfirmModal from '../components/ui/ConfirmModal';
import PriceInput from '../components/ui/PriceInput';
export interface CouponTarget {
targetType: string;
@ -337,7 +338,30 @@ function CouponModal({ formData, setFormData, onSave, onClose, isEditing }: Coup
</div>
</div>
</label>
<input required type="number" min="0" value={formData.value} onChange={e => setFormData({...formData, value: e.target.value})} dir="ltr" className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500" />
{formData.type === 'fixed' ? (
<PriceInput
required
value={formData.value}
onChange={(val) => setFormData({ ...formData, value: val })}
placeholder="مثال: ۵۰,۰۰۰"
className="px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 text-sm"
/>
) : (
<div className="relative">
<input
required
type="number"
min="0"
max="100"
value={formData.value}
onChange={(e) => setFormData({ ...formData, value: e.target.value })}
dir="ltr"
placeholder="مثال: ۲۰"
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 font-bold"
/>
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 font-bold">%</span>
</div>
)}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
@ -351,7 +375,12 @@ function CouponModal({ formData, setFormData, onSave, onClose, isEditing }: Coup
</div>
</div>
</label>
<input type="number" min="0" value={formData.minCartValue} onChange={e => setFormData({...formData, minCartValue: e.target.value})} dir="ltr" className="w-full px-3 py-2 rounded-lg border border-gray-200 focus:border-purple-500 text-sm" />
<PriceInput
value={formData.minCartValue}
onChange={(val) => setFormData({ ...formData, minCartValue: val })}
placeholder="۰ = بدون محدودیت"
className="px-3 py-2 rounded-lg border border-gray-200 focus:border-purple-500 text-sm"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-gray-700 flex items-center gap-1">
@ -363,7 +392,12 @@ function CouponModal({ formData, setFormData, onSave, onClose, isEditing }: Coup
</div>
</div>
</label>
<input type="number" min="0" value={formData.maxCartValue} onChange={e => setFormData({...formData, maxCartValue: e.target.value})} dir="ltr" className="w-full px-3 py-2 rounded-lg border border-gray-200 focus:border-purple-500 text-sm" />
<PriceInput
value={formData.maxCartValue}
onChange={(val) => setFormData({ ...formData, maxCartValue: val })}
placeholder="بدون سقف"
className="px-3 py-2 rounded-lg border border-gray-200 focus:border-purple-500 text-sm"
/>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-gray-700 flex items-center gap-1">

View File

@ -3,6 +3,7 @@ import { DollarSign, Save, Plus, X } from 'lucide-react';
import { toast } from 'react-hot-toast';
import api from '../services/api';
import Spinner from '../components/ui/Spinner';
import PriceInput from '../components/ui/PriceInput';
import type { FinancialSettings } from '../types/admin';
export default function FinancialSettingsPage() {
@ -36,7 +37,7 @@ export default function FinancialSettingsPage() {
}
})
.catch(err => {
console.warn('Failed to fetch Financial settings, using default state', err);
console.warn('Failed to fetch Financial settings', err);
})
.finally(() => {
if (isSubscribed) setIsLoading(false);
@ -113,28 +114,22 @@ export default function FinancialSettingsPage() {
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-1">هزینه ارسال استاندارد (standardShippingFee - تومان) *</label>
<input
<label className="block text-sm font-bold text-gray-700 mb-1">هزینه ارسال استاندارد (standardShippingFee) *</label>
<PriceInput
required
type="number"
min="0"
value={financial.standardShippingFee}
onChange={(e) => setFinancial({ ...financial, standardShippingFee: Number(e.target.value) })}
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none font-bold text-sm"
dir="ltr"
onChange={(val) => setFinancial({ ...financial, standardShippingFee: Number(val) })}
className="px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 text-sm"
/>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-bold text-gray-700 mb-1">آستانه ارسال رایگان (freeShippingThreshold - تومان) *</label>
<input
<label className="block text-sm font-bold text-gray-700 mb-1">آستانه ارسال رایگان (freeShippingThreshold) *</label>
<PriceInput
required
type="number"
min="0"
value={financial.freeShippingThreshold}
onChange={(e) => setFinancial({ ...financial, freeShippingThreshold: Number(e.target.value) })}
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none font-bold text-sm"
dir="ltr"
onChange={(val) => setFinancial({ ...financial, freeShippingThreshold: Number(val) })}
className="px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 text-sm"
/>
<p className="text-xs text-gray-500 mt-1">سبدهای خرید با مبلغ بالاتر از این مقدار، ارسال رایگان خواهند داشت.</p>
</div>
@ -142,18 +137,16 @@ export default function FinancialSettingsPage() {
<div className="md:col-span-2 space-y-2">
<label className="block text-sm font-bold text-gray-700 mb-1">گزینههای کمک به خیریه ردپای مهربانی (charityDonationOptions)</label>
<div className="flex gap-2 max-w-md">
<input
type="number"
placeholder="مبلغ جدید (مثال: 100000)"
<PriceInput
placeholder="مبلغ جدید (مثال: ۱۰۰,۰۰۰)"
value={donationInput}
onChange={(e) => setDonationInput(e.target.value)}
className="flex-1 px-4 py-2 rounded-xl border border-gray-200 text-xs outline-none font-bold"
dir="ltr"
onChange={(val) => setDonationInput(val)}
className="px-4 py-2 rounded-xl border border-gray-200 text-xs"
/>
<button
type="button"
onClick={addDonationOption}
className="px-4 py-2 bg-purple-600 text-white rounded-xl text-xs font-bold flex items-center gap-1"
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-xl text-xs font-bold flex items-center gap-1 shrink-0"
>
<Plus className="w-4 h-4" /> افزودن
</button>

View File

@ -6,6 +6,7 @@ import Spinner from '../components/ui/Spinner';
import Pagination from '../components/ui/Pagination';
import MediaSelector from '../components/ui/MediaSelector';
import ConfirmModal from '../components/ui/ConfirmModal';
import PriceInput from '../components/ui/PriceInput';
export interface Category {
id: string;
@ -706,13 +707,11 @@ export default function Products() {
</div>
</div>
</label>
<input
type="number"
min="0"
placeholder="مثال: 500000"
<PriceInput
placeholder="مثال: ۵۰۰,۰۰۰"
value={formData.buyPrice}
onChange={(e) => {
const bPrice = e.target.value ? Number(e.target.value) : '';
onChange={(rawVal) => {
const bPrice = rawVal ? Number(rawVal) : '';
let pPrice = formData.priceValue;
let wPrice = formData.wholesalePrice;
let pMargin = formData.priceValueMarginPercent;
@ -737,11 +736,10 @@ export default function Products() {
wholesalePrice: wPrice,
priceValueMarginPercent: pMargin,
wholesaleMarginPercent: wMargin,
priceDisplay: `${pPrice.toLocaleString('fa-IR')} تومان`,
priceDisplay: `${Number(pPrice || 0).toLocaleString('fa-IR')} تومان`,
});
}}
className="w-full px-4 py-3 rounded-xl border border-amber-300 focus:border-amber-500 outline-none bg-amber-50/20 font-bold"
dir="ltr"
className="px-4 py-3 rounded-xl border border-amber-300 focus:border-amber-500 bg-amber-50/20 text-sm"
/>
</div>
@ -752,25 +750,24 @@ export default function Products() {
<span className="text-[10px] text-purple-600 font-bold">یا سود ٪ تکفروشی</span>
</div>
<div className="grid grid-cols-5 gap-2">
<input
required
type="number"
min="0"
value={formData.priceValue}
onChange={(e) => {
const val = Number(e.target.value);
const bPrice = formData.buyPrice ? Number(formData.buyPrice) : null;
const pMargin = bPrice && val ? Math.round(((val - bPrice) / bPrice) * 100) : '';
setFormData({
...formData,
priceValue: val,
priceValueMarginPercent: pMargin,
priceDisplay: `${val.toLocaleString('fa-IR')} تومان`,
});
}}
className="col-span-3 px-3 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none font-bold"
dir="ltr"
/>
<div className="col-span-3">
<PriceInput
required
value={formData.priceValue}
onChange={(rawVal) => {
const val = Number(rawVal || 0);
const bPrice = formData.buyPrice ? Number(formData.buyPrice) : null;
const pMargin = bPrice && val ? Math.round(((val - bPrice) / bPrice) * 100) : '';
setFormData({
...formData,
priceValue: val,
priceValueMarginPercent: pMargin,
priceDisplay: `${val.toLocaleString('fa-IR')} تومان`,
});
}}
className="px-3 py-3 rounded-xl border border-gray-200 focus:border-purple-500 text-sm"
/>
</div>
<div className="col-span-2 relative">
<input
type="number"
@ -787,7 +784,7 @@ export default function Products() {
...formData,
priceValueMarginPercent: margin,
priceValue: pPrice,
priceDisplay: `${pPrice.toLocaleString('fa-IR')} تومان`,
priceDisplay: `${Number(pPrice || 0).toLocaleString('fa-IR')} تومان`,
});
}}
className="w-full px-2 py-3 rounded-xl border border-purple-200 focus:border-purple-500 text-center font-bold text-xs bg-purple-50/30 outline-none"
@ -805,24 +802,23 @@ export default function Products() {
<span className="text-[10px] text-purple-600 font-bold">یا سود ٪ B2B</span>
</div>
<div className="grid grid-cols-5 gap-2">
<input
type="number"
min="0"
placeholder="قیمت B2B"
value={formData.wholesalePrice}
onChange={(e) => {
const val = e.target.value ? Number(e.target.value) : '';
const bPrice = formData.buyPrice ? Number(formData.buyPrice) : null;
const wMargin = bPrice && val ? Math.round(((Number(val) - bPrice) / bPrice) * 100) : '';
setFormData({
...formData,
wholesalePrice: val,
wholesaleMarginPercent: wMargin,
});
}}
className="col-span-3 px-3 py-3 rounded-xl border border-purple-200 focus:border-purple-600 outline-none font-bold bg-purple-50/20"
dir="ltr"
/>
<div className="col-span-3">
<PriceInput
placeholder="قیمت B2B"
value={formData.wholesalePrice}
onChange={(rawVal) => {
const val = rawVal ? Number(rawVal) : '';
const bPrice = formData.buyPrice ? Number(formData.buyPrice) : null;
const wMargin = bPrice && val ? Math.round(((Number(val) - bPrice) / bPrice) * 100) : '';
setFormData({
...formData,
wholesalePrice: val,
wholesaleMarginPercent: wMargin,
});
}}
className="px-3 py-3 rounded-xl border border-purple-200 focus:border-purple-600 bg-purple-50/20 text-sm"
/>
</div>
<div className="col-span-2 relative">
<input
type="number"

View File

@ -4,11 +4,14 @@ import { Settings as SettingsIcon, Save, Truck, ShieldAlert, Percent, AlertCircl
import { toast } from 'react-hot-toast';
import api from '../services/api';
import Spinner from '../components/ui/Spinner';
import PriceInput from '../components/ui/PriceInput';
export default function Settings() {
const [settings, setSettings] = useState({
SHIPPING_FEE: '',
MIN_ORDER_AMOUNT: '',
FREE_SHIPPING_THRESHOLD: '2000000',
TAX_PERCENTAGE: '10',
B2B_DISCOUNT_PERCENT: '',
MAINTENANCE_MODE: 'false',
CATALOG_ONLY_MODE: 'false',
@ -49,9 +52,11 @@ export default function Settings() {
if (!isSubscribed) return;
if (response.data?.success) {
setSettings({
SHIPPING_FEE: response.data.data.SHIPPING_FEE || '0',
MIN_ORDER_AMOUNT: response.data.data.MIN_ORDER_AMOUNT || '0',
B2B_DISCOUNT_PERCENT: response.data.data.B2B_DISCOUNT_PERCENT || '0',
SHIPPING_FEE: response.data.data.SHIPPING_FEE || response.data.data.shipping_fee || response.data.data.standardShippingFee || '0',
MIN_ORDER_AMOUNT: response.data.data.MIN_ORDER_AMOUNT || response.data.data.min_order_amount || response.data.data.minOrderAmount || '0',
FREE_SHIPPING_THRESHOLD: response.data.data.FREE_SHIPPING_THRESHOLD || response.data.data.free_shipping_threshold || response.data.data.freeShippingThreshold || '2000000',
TAX_PERCENTAGE: response.data.data.TAX_PERCENTAGE || response.data.data.tax_percentage || response.data.data.taxPercentage || '10',
B2B_DISCOUNT_PERCENT: response.data.data.B2B_DISCOUNT_PERCENT || response.data.data.b2b_discount_percent || response.data.data.b2bDiscountPercent || '0',
MAINTENANCE_MODE: response.data.data.MAINTENANCE_MODE || 'false',
CATALOG_ONLY_MODE: response.data.data.CATALOG_ONLY_MODE || 'false',
CATALOG_SHOW_PRICES: response.data.data.CATALOG_SHOW_PRICES || 'true',
@ -149,41 +154,63 @@ export default function Settings() {
<div className="space-y-5">
<div>
<label className="block text-sm font-bold text-gray-700 mb-2">هزینه ثابت ارسال (تومان)</label>
<input
type="number"
<label className="block text-sm font-bold text-gray-700 mb-2">هزینه ثابت ارسال</label>
<PriceInput
value={settings.SHIPPING_FEE}
onChange={(e) => setSettings({ ...settings, SHIPPING_FEE: e.target.value })}
className="w-full border border-gray-200 rounded-xl p-3 outline-none focus:ring-2 focus:ring-purple-500 font-bold"
dir="ltr"
onChange={(val) => setSettings({ ...settings, SHIPPING_FEE: val })}
placeholder="مثال: ۸۵,۰۰۰"
className="border border-gray-200 rounded-xl p-3 focus:ring-2 focus:ring-purple-500"
/>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-2">حداقل مبلغ سفارش (تومان)</label>
<input
type="number"
<label className="block text-sm font-bold text-gray-700 mb-2">آستانه ارسال رایگان</label>
<PriceInput
value={settings.FREE_SHIPPING_THRESHOLD}
onChange={(val) => setSettings({ ...settings, FREE_SHIPPING_THRESHOLD: val })}
placeholder="مثال: ۲,۰۰۰,۰۰۰"
className="border border-gray-200 rounded-xl p-3 focus:ring-2 focus:ring-purple-500"
/>
<p className="text-xs text-gray-500 mt-2 font-medium">سفارشات بالاتر از این مبلغ شامل ارسال رایگان خواهند بود.</p>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-2">حداقل مبلغ سفارش</label>
<PriceInput
value={settings.MIN_ORDER_AMOUNT}
onChange={(e) => setSettings({ ...settings, MIN_ORDER_AMOUNT: e.target.value })}
className="w-full border border-gray-200 rounded-xl p-3 outline-none focus:ring-2 focus:ring-purple-500 font-bold"
dir="ltr"
onChange={(val) => setSettings({ ...settings, MIN_ORDER_AMOUNT: val })}
placeholder="۰ = بدون محدودیت"
className="border border-gray-200 rounded-xl p-3 focus:ring-2 focus:ring-purple-500"
/>
<p className="text-xs text-gray-500 mt-2 font-medium">سفارشات زیر این مبلغ امکان ثبت نخواهند داشت (۰ = بدون محدودیت).</p>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-2">پله رند کردن ردپای مهربانی (تومان)</label>
<input
type="number"
step="1000"
<label className="block text-sm font-bold text-gray-700 mb-2">پله رند کردن ردپای مهربانی</label>
<PriceInput
value={settings.CHARITY_ROUND_STEP}
onChange={(e) => setSettings({ ...settings, CHARITY_ROUND_STEP: e.target.value })}
className="w-full border border-gray-200 rounded-xl p-3 outline-none focus:ring-2 focus:ring-purple-500 font-bold"
dir="ltr"
onChange={(val) => setSettings({ ...settings, CHARITY_ROUND_STEP: val })}
placeholder="10000"
className="border border-gray-200 rounded-xl p-3 focus:ring-2 focus:ring-purple-500"
/>
<p className="text-xs text-gray-500 mt-2 font-medium">مبلغ فاکتور به اولین ضریب این عدد (مثلاً ۱۰,۰۰۰ یا ۵۰,۰۰۰ تومان) رند میشود.</p>
</div>
<div>
<label className="block text-sm font-bold text-gray-700 mb-2">درصد مالیات بر ارزش افزوده</label>
<div className="relative">
<input
type="number"
value={settings.TAX_PERCENTAGE}
onChange={(e) => setSettings({ ...settings, TAX_PERCENTAGE: e.target.value })}
className="w-full border border-gray-200 rounded-xl p-3 outline-none focus:ring-2 focus:ring-purple-500 font-bold"
dir="ltr"
min="0"
max="100"
/>
<span className="absolute left-3 top-3.5 text-xs text-gray-400 font-bold">%</span>
</div>
</div>
</div>
</div>

View File

@ -72,13 +72,16 @@ export default function CheckoutPage() {
const roundStep = Number(useSettingsStore((state) => state.getText('CHARITY_ROUND_STEP', '10000'))) || 10000;
const shippingFeeSetting = useSettingsStore((state) => state.getText('shipping_fee', '0'));
const shippingFee = Number(shippingFeeSetting) || 0;
const baseShippingFee = Number(shippingFeeSetting) || 0;
const freeThreshold = Number(useSettingsStore((state) => state.getText('free_shipping_threshold', '2000000'))) || 0;
const subtotal = getSubtotal();
const shippingFee = (freeThreshold > 0 && subtotal >= freeThreshold) ? 0 : baseShippingFee;
const isCardToCardEnabled = useSettingsStore((state) =>
state.getBoolean('PAY_GATEWAY_CARD_ENABLE', true) &&
state.getBoolean('cardToCardEnabled', true) &&
state.getBoolean('payment_card_to_card_enabled', true)
);
const subtotal = getSubtotal();
const remainder = subtotal % roundStep;
const roundUpDiff = remainder === 0 ? roundStep : roundStep - remainder;

View File

@ -68,6 +68,11 @@ export const useSettingsStore = create<SettingsStore>()((set, get) => ({
if (key === 'site_logo_text_en') return texts['BRAND_LOGO_TEXT_EN'] || fallback;
if (key === 'site_logo_text_fa') return texts['BRAND_LOGO_TEXT_FA'] || texts['brand_name_fa'] || fallback;
if (key === 'site_logo_subtitle') return texts['BRAND_LOGO_SUBTITLE'] || texts['brand_subtitle'] || fallback;
if (key === 'shipping_fee') return texts['SHIPPING_FEE'] || texts['standardShippingFee'] || fallback;
if (key === 'free_shipping_threshold') return texts['FREE_SHIPPING_THRESHOLD'] || texts['freeShippingThreshold'] || fallback;
if (key === 'min_order_amount') return texts['MIN_ORDER_AMOUNT'] || texts['minOrderAmount'] || fallback;
if (key === 'charity_round_step') return texts['CHARITY_ROUND_STEP'] || texts['charityRoundStep'] || fallback;
if (key === 'tax_percentage') return texts['TAX_PERCENTAGE'] || texts['taxPercentage'] || fallback;
return fallback;
}
return value;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff