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
All checks were successful
Deploy Canina / deploy (push) Successful in 1m46s
This commit is contained in:
parent
45c9db133c
commit
b3c60a1a7d
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
99
frontend/admin-panel/src/components/ui/PriceInput.tsx
Normal file
99
frontend/admin-panel/src/components/ui/PriceInput.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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
2
graphify-out/cache/stat-index.json
vendored
2
graphify-out/cache/stat-index.json
vendored
File diff suppressed because one or more lines are too long
59660
graphify-out/graph.json
59660
graphify-out/graph.json
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user