feat(sms): implement dynamic multi-recipient SMS automation rules with custom patterns and variable mapping
This commit is contained in:
parent
c247f21d83
commit
52cc601345
148
backend/src/common/services/sms-events.constants.ts
Normal file
148
backend/src/common/services/sms-events.constants.ts
Normal file
@ -0,0 +1,148 @@
|
||||
export interface SmsRule {
|
||||
id: string;
|
||||
title: string;
|
||||
event: string; // ORDER_PAID, ORDER_CREATED, ORDER_SHIPPED, B2B_SUBMITTED, CONTACT_SUBMITTED, OTP
|
||||
enabled: boolean;
|
||||
recipientType: 'customer' | 'admin' | 'custom';
|
||||
customPhone?: string;
|
||||
patternId: number;
|
||||
variables: string[]; // Ordered parameter keys: e.g. ['orderNumber', 'recipientName', 'address']
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface SmsEventVariable {
|
||||
key: string;
|
||||
label: string;
|
||||
sample: string;
|
||||
}
|
||||
|
||||
export interface SmsEventDefinition {
|
||||
event: string;
|
||||
label: string;
|
||||
description: string;
|
||||
variables: SmsEventVariable[];
|
||||
}
|
||||
|
||||
export const SMS_EVENT_DEFINITIONS: SmsEventDefinition[] = [
|
||||
{
|
||||
event: 'ORDER_PAID',
|
||||
label: 'پرداخت موفق سفارش (Order Paid)',
|
||||
description: 'هنگام پرداخت موفق آنلاین در درگاه بانکی یا تسویه حساب از طریق کیف پول',
|
||||
variables: [
|
||||
{ key: 'orderNumber', label: 'شماره سفارش / فاکتور', sample: 'CN-140308-01' },
|
||||
{ key: 'amount', label: 'مبلغ پرداختی (تومان)', sample: '۱,۴۵۰,۰۰۰' },
|
||||
{ key: 'customerName', label: 'نام خریدار', sample: 'علی محمدی' },
|
||||
{ key: 'customerPhone', label: 'شماره موبایل خریدار', sample: '09123456789' },
|
||||
{ key: 'recipientName', label: 'نام تحویلگیرنده', sample: 'رضا حسینی' },
|
||||
{ key: 'recipientPhone', label: 'تلفن تماس تحویلگیرنده', sample: '09351234567' },
|
||||
{ key: 'provinceCity', label: 'استان و شهر مقصد', sample: 'تهران - تهران' },
|
||||
{ key: 'postalCode', label: 'کد پستی ۱۰ رقمی', sample: '1967812345' },
|
||||
{ key: 'address', label: 'نشانی کامل پستی', sample: 'خیابان ولیعصر، کوچه بهار، پلاک ۴' },
|
||||
{ key: 'itemsSummary', label: 'خلاصه اقلام سفارش', sample: 'شیر خشک توله سگ (۲)' },
|
||||
{ key: 'itemCount', label: 'تعداد کل اقلام', sample: '۲' },
|
||||
{ key: 'paymentMethod', label: 'روش پرداخت', sample: 'آنلاین' },
|
||||
{ key: 'orderDate', label: 'تاریخ ثبت سفارش', sample: '۱۴۰۳/۰۶/۱۵' },
|
||||
{ key: 'refNumber', label: 'شماره پیگیری پرداخت بانکی', sample: '83920192' },
|
||||
],
|
||||
},
|
||||
{
|
||||
event: 'ORDER_CREATED',
|
||||
label: 'ثبت سفارش کارت به کارت یا غیرآنلاین',
|
||||
description: 'هنگامی که سفارش با روش کارت به کارت در انتظار پرداخت ثبت میشود',
|
||||
variables: [
|
||||
{ key: 'orderNumber', label: 'شماره سفارش / فاکتور', sample: 'CN-140308-01' },
|
||||
{ key: 'amount', label: 'مبلغ کل سفارش', sample: '۱,۴۵۰,۰۰۰' },
|
||||
{ key: 'customerName', label: 'نام خریدار', sample: 'علی محمدی' },
|
||||
{ key: 'customerPhone', label: 'شماره موبایل خریدار', sample: '09123456789' },
|
||||
{ key: 'recipientName', label: 'نام تحویلگیرنده', sample: 'رضا حسینی' },
|
||||
{ key: 'recipientPhone', label: 'تلفن تحویلگیرنده', sample: '09351234567' },
|
||||
{ key: 'provinceCity', label: 'استان و شهر مقصد', sample: 'تهران - تهران' },
|
||||
{ key: 'postalCode', label: 'کد پستی ۱۰ رقمی', sample: '1967812345' },
|
||||
{ key: 'address', label: 'نشانی پستی', sample: 'خیابان ولیعصر، کوچه بهار، پلاک ۴' },
|
||||
{ key: 'paymentMethod', label: 'روش پرداخت', sample: 'کارت به کارت' },
|
||||
{ key: 'orderDate', label: 'تاریخ ثبت سفارش', sample: '۱۴۰۳/۰۶/۱۵' },
|
||||
],
|
||||
},
|
||||
{
|
||||
event: 'ORDER_SHIPPED',
|
||||
label: 'ارسال مرسوله و صدور بارکد پست',
|
||||
description: 'هنگام تغییر وضعیت سفارش به ارسال شده یا درج کد رهگیری پستی',
|
||||
variables: [
|
||||
{ key: 'orderNumber', label: 'شماره سفارش', sample: 'CN-140308-01' },
|
||||
{ key: 'shippingTrackingCode', label: 'کد رهگیری ۲۴ رقمی پست', sample: '184920394857283948571928' },
|
||||
{ key: 'trackingCode', label: 'کد رهگیری پستی (مترادف)', sample: '184920394857283948571928' },
|
||||
{ key: 'customerName', label: 'نام خریدار', sample: 'علی محمدی' },
|
||||
{ key: 'recipientName', label: 'نام تحویلگیرنده', sample: 'رضا حسینی' },
|
||||
{ key: 'customerPhone', label: 'شماره همراه مشتری', sample: '09123456789' },
|
||||
{ key: 'shippingMethod', label: 'روش ارسال', sample: 'پست پیشتاز' },
|
||||
],
|
||||
},
|
||||
{
|
||||
event: 'B2B_SUBMITTED',
|
||||
label: 'درخواست همکاری و عمدهفروشی B2B',
|
||||
description: 'هنگام ارسال فرم همکاری همکاران و کلینیکهای دامپزشکی',
|
||||
variables: [
|
||||
{ key: 'applicantName', label: 'نام متقاضی / فروشگاه', sample: 'پتشاپ آلفا' },
|
||||
{ key: 'applicantPhone', label: 'شماره همراه متقاضی', sample: '09123456789' },
|
||||
{ key: 'companyName', label: 'نام شرکت یا مجموعه', sample: 'فروشگاه آلفا' },
|
||||
{ key: 'businessType', label: 'نوع فعالیت', sample: 'پتشاپ' },
|
||||
{ key: 'date', label: 'تاریخ ثبت درخواست', sample: '۱۴۰۳/۰۶/۱۵' },
|
||||
],
|
||||
},
|
||||
{
|
||||
event: 'CONTACT_SUBMITTED',
|
||||
label: 'ثبت پیام در فرم تماس با ما',
|
||||
description: 'هنگامی که کاربری در فرم تماس پیامی ارسال میکند',
|
||||
variables: [
|
||||
{ key: 'senderName', label: 'نام فرستنده پیام', sample: 'سارا احمدی' },
|
||||
{ key: 'senderPhone', label: 'شماره همراه فرستنده', sample: '09123456789' },
|
||||
{ key: 'email', label: 'ایمیل فرستنده', sample: 'user@example.com' },
|
||||
{ key: 'subject', label: 'موضوع پیام', sample: 'مشاوره خرید مکمل' },
|
||||
{ key: 'message', label: 'متن پیام', sample: 'درخواست راهنمایی در مورد دوز کلسیم' },
|
||||
{ key: 'date', label: 'تاریخ ارسال', sample: '۱۴۰۳/۰۶/۱۵' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_SMS_RULES: SmsRule[] = [
|
||||
{
|
||||
id: 'default-order-customer',
|
||||
title: 'ارسال فاکتور و تایید سفارش به خریدار',
|
||||
event: 'ORDER_PAID',
|
||||
enabled: true,
|
||||
recipientType: 'customer',
|
||||
patternId: 508081,
|
||||
variables: ['customerName', 'orderNumber', 'amount'],
|
||||
description: 'ارسال شماره فاکتور و مبلغ سفارش به شماره همراه مشتری بلافاصله پس از پرداخت',
|
||||
},
|
||||
{
|
||||
id: 'default-order-admin',
|
||||
title: 'اطلاعرسانی سفارش جدید به مدیر سایت',
|
||||
event: 'ORDER_PAID',
|
||||
enabled: true,
|
||||
recipientType: 'admin',
|
||||
patternId: 508081,
|
||||
variables: ['customerName', 'orderNumber', 'amount'],
|
||||
description: 'ارسال پیامک سفارش جدید و مبلغ به مدیران فروشگاه',
|
||||
},
|
||||
{
|
||||
id: 'default-shipping-customer',
|
||||
title: 'ارسال کد رهگیری پست به خریدار',
|
||||
event: 'ORDER_SHIPPED',
|
||||
enabled: true,
|
||||
recipientType: 'customer',
|
||||
patternId: 508082,
|
||||
variables: ['orderNumber', 'shippingTrackingCode'],
|
||||
description: 'ارسال بارکد ۲۴ رقمی اداره پست هنگام تحویل مرسوله',
|
||||
},
|
||||
{
|
||||
id: 'default-b2b-customer',
|
||||
title: 'تایید ثبت درخواست همکاری B2B',
|
||||
event: 'B2B_SUBMITTED',
|
||||
enabled: true,
|
||||
recipientType: 'customer',
|
||||
patternId: 508083,
|
||||
variables: ['applicantName'],
|
||||
description: 'پیامک ثبت درخواست متقاضیان عمدهفروشی',
|
||||
},
|
||||
];
|
||||
@ -2,6 +2,11 @@ import { Injectable, Logger } from '@nestjs/common';
|
||||
import * as https from 'https';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import {
|
||||
DEFAULT_SMS_RULES,
|
||||
SMS_EVENT_DEFINITIONS,
|
||||
SmsEventDefinition,
|
||||
} from './sms-events.constants';
|
||||
|
||||
export interface SendPatternSmsOptions {
|
||||
to: string;
|
||||
@ -10,16 +15,30 @@ export interface SendPatternSmsOptions {
|
||||
type?: string; // OTP, ORDER_CONFIRMATION, SHIPPING_TRACKING, B2B_NOTIFICATION, PET_CARE_REMINDER, TEST, GENERIC
|
||||
}
|
||||
|
||||
export interface SmsRule {
|
||||
id: string;
|
||||
title: string;
|
||||
event: string; // ORDER_PAID, ORDER_CREATED, ORDER_SHIPPED, B2B_SUBMITTED, CONTACT_SUBMITTED, OTP
|
||||
enabled: boolean;
|
||||
recipientType: 'customer' | 'admin' | 'custom';
|
||||
customPhone?: string;
|
||||
patternId: number;
|
||||
variables: string[]; // Ordered parameter keys: e.g. ['orderNumber', 'recipientName', 'address']
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface SmsConfig {
|
||||
enabled: boolean;
|
||||
username: string;
|
||||
password: string;
|
||||
fromNumber?: string;
|
||||
adminPhone?: string;
|
||||
otpBodyId: number;
|
||||
orderBodyId: number;
|
||||
shippingBodyId: number;
|
||||
b2bBodyId: number;
|
||||
petCareBodyId: number;
|
||||
rules?: SmsRule[];
|
||||
}
|
||||
|
||||
export interface MeliPayamakPattern {
|
||||
@ -78,6 +97,10 @@ export class SmsService {
|
||||
typeof dbConfig.fromNumber === 'string' && dbConfig.fromNumber
|
||||
? dbConfig.fromNumber
|
||||
: process.env.MELIPAYAMAK_FROM_NUMBER || '',
|
||||
adminPhone:
|
||||
typeof dbConfig.adminPhone === 'string' && dbConfig.adminPhone
|
||||
? dbConfig.adminPhone
|
||||
: process.env.ADMIN_MOBILE || '09364100228',
|
||||
otpBodyId: Number(
|
||||
dbConfig.otpBodyId || process.env.MELIPAYAMAK_OTP_BODY_ID || '508079',
|
||||
),
|
||||
@ -99,6 +122,9 @@ export class SmsService {
|
||||
process.env.MELIPAYAMAK_PET_CARE_BODY_ID ||
|
||||
'0',
|
||||
),
|
||||
rules: Array.isArray(dbConfig.rules)
|
||||
? (dbConfig.rules as SmsRule[])
|
||||
: DEFAULT_SMS_RULES,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
@ -106,6 +132,7 @@ export class SmsService {
|
||||
username: process.env.MELIPAYAMAK_USERNAME || '9364100228',
|
||||
password: process.env.MELIPAYAMAK_PASSWORD || '',
|
||||
fromNumber: process.env.MELIPAYAMAK_FROM_NUMBER || '',
|
||||
adminPhone: process.env.ADMIN_MOBILE || '09364100228',
|
||||
otpBodyId: Number(process.env.MELIPAYAMAK_OTP_BODY_ID || '508079'),
|
||||
orderBodyId: Number(process.env.MELIPAYAMAK_ORDER_BODY_ID || '508081'),
|
||||
shippingBodyId: Number(
|
||||
@ -113,10 +140,216 @@ export class SmsService {
|
||||
),
|
||||
b2bBodyId: Number(process.env.MELIPAYAMAK_B2B_BODY_ID || '508083'),
|
||||
petCareBodyId: Number(process.env.MELIPAYAMAK_PET_CARE_BODY_ID || '0'),
|
||||
rules: DEFAULT_SMS_RULES,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch dynamic SMS notification rules for an event
|
||||
*/
|
||||
async triggerEvent(
|
||||
event: string,
|
||||
data: Record<string, any>,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const config = await this.getSmsConfig();
|
||||
if (!config.enabled) {
|
||||
this.logger.log(
|
||||
`[SMS Trigger] SMS gateway disabled. Skipping event ${event}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
const activeRules = (config.rules || []).filter(
|
||||
(rule) =>
|
||||
rule.enabled &&
|
||||
rule.event === event &&
|
||||
Number(rule.patternId) > 0,
|
||||
);
|
||||
|
||||
if (activeRules.length > 0) {
|
||||
this.logger.log(
|
||||
`[SMS Trigger] Found ${activeRules.length} active rule(s) for event ${event}`,
|
||||
);
|
||||
|
||||
for (const rule of activeRules) {
|
||||
const phones: string[] = [];
|
||||
|
||||
if (rule.recipientType === 'customer') {
|
||||
const customerPhone =
|
||||
data.customerPhone ||
|
||||
data.phone ||
|
||||
data.mobile ||
|
||||
data.recipientPhone;
|
||||
if (customerPhone) phones.push(String(customerPhone).trim());
|
||||
} else if (rule.recipientType === 'admin') {
|
||||
const rawAdminPhones =
|
||||
config.adminPhone ||
|
||||
process.env.ADMIN_MOBILE ||
|
||||
process.env.ADMIN_MOBILE_ALERT ||
|
||||
'';
|
||||
const list = rawAdminPhones
|
||||
.split(/[,;\s]+/)
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
if (list.length > 0) {
|
||||
phones.push(...list);
|
||||
}
|
||||
} else if (rule.recipientType === 'custom' && rule.customPhone) {
|
||||
const list = rule.customPhone
|
||||
.split(/[,;\s]+/)
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
if (list.length > 0) {
|
||||
phones.push(...list);
|
||||
}
|
||||
}
|
||||
|
||||
if (phones.length === 0) {
|
||||
this.logger.warn(
|
||||
`[SMS Trigger] Rule "${rule.title}" has no recipient phone number.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const args: string[] = (rule.variables || []).map((varKey) => {
|
||||
const val = data[varKey];
|
||||
if (val === undefined || val === null) return '';
|
||||
return String(val).trim();
|
||||
});
|
||||
|
||||
for (const targetPhone of phones) {
|
||||
this.sendPatternSms({
|
||||
to: targetPhone,
|
||||
bodyId: Number(rule.patternId),
|
||||
args,
|
||||
type: `${event}_${rule.recipientType.toUpperCase()}`,
|
||||
}).catch((err) => {
|
||||
this.logger.error(
|
||||
`[SMS Trigger Error] Failed to execute rule "${rule.title}" to ${targetPhone}: ${err}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Legacy fallback when no rules are configured for this event
|
||||
this.logger.log(
|
||||
`[SMS Trigger] No custom rules configured for ${event}. Using standard fallback.`,
|
||||
);
|
||||
if (event === 'ORDER_PAID' || event === 'ORDER_CREATED') {
|
||||
const phone = data.customerPhone || data.phone || data.mobile;
|
||||
if (phone && config.orderBodyId) {
|
||||
await this.sendOrderConfirmation(
|
||||
phone,
|
||||
data.customerName || 'مشتری',
|
||||
data.orderNumber || '',
|
||||
data.amount || '',
|
||||
);
|
||||
}
|
||||
} else if (event === 'ORDER_SHIPPED') {
|
||||
const phone = data.customerPhone || data.phone || data.mobile;
|
||||
if (phone && config.shippingBodyId) {
|
||||
await this.sendShippingNotification(
|
||||
phone,
|
||||
data.orderNumber || '',
|
||||
data.trackingCode || '',
|
||||
);
|
||||
}
|
||||
} else if (event === 'B2B_SUBMITTED') {
|
||||
const phone = data.applicantPhone || data.phone;
|
||||
if (phone && config.b2bBodyId) {
|
||||
await this.sendB2bNotification(phone, data.applicantName || '');
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
this.logger.error(
|
||||
`[SMS Trigger Exception] Error triggering event ${event}: ${err?.message || err}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all supported SMS events and their dynamic variables
|
||||
*/
|
||||
getEventDefinitions(): SmsEventDefinition[] {
|
||||
return SMS_EVENT_DEFINITIONS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Test an SMS rule with sample/mock event data
|
||||
*/
|
||||
async testRule(
|
||||
rule: SmsRule,
|
||||
testPhone?: string,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
message: string;
|
||||
targetPhone?: string;
|
||||
args?: string[];
|
||||
rawResponse?: any;
|
||||
}> {
|
||||
const config = await this.getSmsConfig();
|
||||
if (!config.username || !config.password) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'مشخصات نام کاربری و رمز عبور سامانه ملی پیامک تنظیم نشده است.',
|
||||
};
|
||||
}
|
||||
|
||||
if (!rule.patternId) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'کد پترن (Body ID) برای این سناریو مشخص نشده است.',
|
||||
};
|
||||
}
|
||||
|
||||
// Determine target recipient phone
|
||||
let phone = testPhone ? testPhone.trim() : '';
|
||||
if (!phone) {
|
||||
if (rule.recipientType === 'customer') {
|
||||
phone = config.adminPhone || '09123456789';
|
||||
} else if (rule.recipientType === 'admin') {
|
||||
phone = config.adminPhone || process.env.ADMIN_MOBILE || '09123456789';
|
||||
} else if (rule.recipientType === 'custom') {
|
||||
phone = (rule.customPhone || '').split(/[,;\s]+/)[0] || '';
|
||||
}
|
||||
}
|
||||
|
||||
if (!phone) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'شماره گیرنده جهت ارسال تست یافت نشد. لطفاً یک شماره موبایل وارد کنید.',
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve sample values for variables
|
||||
const eventDef = SMS_EVENT_DEFINITIONS.find((d) => d.event === rule.event);
|
||||
const sampleData: Record<string, string> = {};
|
||||
if (eventDef) {
|
||||
for (const v of eventDef.variables) {
|
||||
sampleData[v.key] = v.sample;
|
||||
}
|
||||
}
|
||||
|
||||
const args = (rule.variables || []).map((varKey) => {
|
||||
return sampleData[varKey] || varKey;
|
||||
});
|
||||
|
||||
const result = await this.testSms(phone, Number(rule.patternId), args);
|
||||
return {
|
||||
success: result.success,
|
||||
message: result.message,
|
||||
targetPhone: phone,
|
||||
args,
|
||||
rawResponse: result.rawResponse,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper HTTP POST request to Payamak-Panel ASMX endpoints
|
||||
*/
|
||||
|
||||
@ -38,27 +38,17 @@ export class ContactService {
|
||||
},
|
||||
});
|
||||
|
||||
// Send SMS confirmation to User & notification to Admin
|
||||
// Send SMS confirmation & notifications via triggerEvent
|
||||
try {
|
||||
const userPatternId = parseInt(
|
||||
process.env.MELIPAYAMAK_CONTACT_USER_BODY_ID || '508081',
|
||||
10,
|
||||
);
|
||||
await this.smsService.sendPatternSms({
|
||||
to: dto.phone,
|
||||
bodyId: userPatternId,
|
||||
args: [dto.name, 'فرم تماس'],
|
||||
});
|
||||
|
||||
const adminPhone = process.env.ADMIN_MOBILE || '09364100228';
|
||||
const adminPatternId = parseInt(
|
||||
process.env.MELIPAYAMAK_CONTACT_ADMIN_BODY_ID || '508083',
|
||||
10,
|
||||
);
|
||||
await this.smsService.sendPatternSms({
|
||||
to: adminPhone,
|
||||
bodyId: adminPatternId,
|
||||
args: [dto.name, dto.phone],
|
||||
await this.smsService.triggerEvent('CONTACT_SUBMITTED', {
|
||||
senderName: dto.name,
|
||||
customerName: dto.name,
|
||||
senderPhone: dto.phone,
|
||||
customerPhone: dto.phone,
|
||||
email: dto.email || '',
|
||||
subject: dto.subject || 'پیام جدید',
|
||||
message: dto.message || '',
|
||||
date: new Date().toLocaleDateString('fa-IR'),
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
|
||||
@ -256,16 +256,11 @@ export class OrdersService {
|
||||
|
||||
// Send Order Confirmation SMS for wallet orders
|
||||
if (user.mobile) {
|
||||
const customerName = user.firstName || 'مشتری';
|
||||
const orderData = this.buildOrderEventData(createdWalletOrder, user);
|
||||
this.smsService
|
||||
.sendOrderConfirmation(
|
||||
user.mobile,
|
||||
customerName,
|
||||
trackingNumber,
|
||||
Number(finalAmount).toLocaleString('fa-IR'),
|
||||
)
|
||||
.triggerEvent('ORDER_PAID', orderData)
|
||||
.catch((err) => {
|
||||
this.logger.warn(`Failed to send wallet order SMS: ${err}`);
|
||||
this.logger.warn(`Failed to trigger wallet order SMS: ${err}`);
|
||||
});
|
||||
}
|
||||
|
||||
@ -304,14 +299,9 @@ export class OrdersService {
|
||||
where: { id: userId },
|
||||
});
|
||||
if (user?.mobile) {
|
||||
const customerName = user.firstName || 'مشتری';
|
||||
await this.smsService
|
||||
.sendOrderConfirmation(
|
||||
user.mobile,
|
||||
customerName,
|
||||
trackingNumber,
|
||||
Number(finalAmount).toLocaleString('fa-IR'),
|
||||
)
|
||||
const orderData = this.buildOrderEventData(createdOrder, user);
|
||||
this.smsService
|
||||
.triggerEvent('ORDER_CREATED', orderData)
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
@ -319,10 +309,53 @@ export class OrdersService {
|
||||
return createdOrder;
|
||||
}
|
||||
|
||||
private buildOrderEventData(order: any, user: any): Record<string, any> {
|
||||
const shipping = (order.shippingAddress as Record<string, any>) || {};
|
||||
const items = order.orderItems || [];
|
||||
const itemsSummary = items
|
||||
.map(
|
||||
(i: any) =>
|
||||
`${i.product?.nameFa || 'محصول'} (${i.quantity})`,
|
||||
)
|
||||
.join('، ');
|
||||
const itemCount = items.reduce(
|
||||
(sum: number, i: any) => sum + (i.quantity || 1),
|
||||
0,
|
||||
);
|
||||
|
||||
const customerFullName = (
|
||||
user?.firstName
|
||||
? `${user.firstName} ${user.lastName || ''}`
|
||||
: shipping.fullName || 'مشتری'
|
||||
).trim();
|
||||
|
||||
return {
|
||||
orderNumber: order.trackingNumber || order.id.slice(0, 8),
|
||||
amount: Number(order.totalAmount || 0).toLocaleString('fa-IR'),
|
||||
customerName: customerFullName,
|
||||
customerPhone: user?.mobile || shipping.phone || '',
|
||||
recipientName: shipping.fullName || customerFullName,
|
||||
recipientPhone: shipping.phone || user?.mobile || '',
|
||||
provinceCity:
|
||||
`${shipping.province || ''} - ${shipping.city || ''}`.trim(),
|
||||
postalCode: shipping.postalCode || '',
|
||||
address: shipping.address || '',
|
||||
itemsSummary,
|
||||
itemCount: String(itemCount),
|
||||
paymentMethod:
|
||||
order.paymentMethod === 'online'
|
||||
? 'آنلاین'
|
||||
: order.paymentMethod === 'wallet'
|
||||
? 'کیف پول'
|
||||
: 'کارت به کارت',
|
||||
orderDate: new Date().toLocaleDateString('fa-IR'),
|
||||
};
|
||||
}
|
||||
|
||||
async updateStatus(orderId: string, status: string, trackingCode?: string) {
|
||||
const order = await this.prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
include: { user: true },
|
||||
include: { user: true, orderItems: { include: { product: true } } },
|
||||
});
|
||||
|
||||
if (!order) {
|
||||
@ -335,15 +368,20 @@ export class OrdersService {
|
||||
status,
|
||||
...(trackingCode ? { trackingNumber: trackingCode } : {}),
|
||||
},
|
||||
include: { user: true },
|
||||
include: { user: true, orderItems: { include: { product: true } } },
|
||||
});
|
||||
|
||||
// Send Shipping SMS with Tracking Code
|
||||
const mobile = updatedOrder.user?.mobile;
|
||||
const trackingNum = updatedOrder.trackingNumber || trackingCode || '';
|
||||
if (status === 'shipped' && mobile && trackingCode) {
|
||||
const orderData = this.buildOrderEventData(updatedOrder, updatedOrder.user);
|
||||
orderData.trackingCode = trackingCode;
|
||||
orderData.shippingTrackingCode = trackingCode;
|
||||
orderData.shippingMethod = 'پست پیشتاز';
|
||||
|
||||
this.smsService
|
||||
.sendShippingNotification(mobile, trackingNum, trackingCode)
|
||||
.triggerEvent('ORDER_SHIPPED', orderData)
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
@ -514,14 +552,9 @@ export class OrdersService {
|
||||
});
|
||||
|
||||
if (user.mobile) {
|
||||
const customerName = user.firstName || 'مشتری';
|
||||
const orderData = this.buildOrderEventData(order, user);
|
||||
await this.smsService
|
||||
.sendOrderConfirmation(
|
||||
user.mobile,
|
||||
customerName,
|
||||
order.trackingNumber || order.id.slice(0, 8),
|
||||
finalAmount.toLocaleString('fa-IR'),
|
||||
)
|
||||
.triggerEvent('ORDER_PAID', orderData)
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
|
||||
@ -479,33 +479,69 @@ export class PaymentService {
|
||||
});
|
||||
|
||||
// Send SMS notification if order payment
|
||||
const customerMobile =
|
||||
transaction.order?.user?.mobile ||
|
||||
transaction.user?.mobile ||
|
||||
(transaction.rawRequest as { mobile?: string })?.mobile;
|
||||
if (transaction.type === 'ORDER') {
|
||||
const order = transaction.orderId
|
||||
? await this.prisma.order.findUnique({
|
||||
where: { id: transaction.orderId },
|
||||
include: {
|
||||
user: true,
|
||||
orderItems: { include: { product: true } },
|
||||
},
|
||||
})
|
||||
: transaction.order;
|
||||
|
||||
if (transaction.type === 'ORDER' && customerMobile) {
|
||||
const trackingNum =
|
||||
transaction.order?.trackingNumber ||
|
||||
transaction.orderId ||
|
||||
transaction.id;
|
||||
const formattedAmount = Number(transaction.amount).toLocaleString(
|
||||
'fa-IR',
|
||||
const customerMobile =
|
||||
order?.user?.mobile ||
|
||||
transaction.user?.mobile ||
|
||||
(transaction.rawRequest as { mobile?: string })?.mobile;
|
||||
|
||||
const shipping = (order?.shippingAddress as unknown as Record<string, any>) || {};
|
||||
const items = order?.orderItems || [];
|
||||
const itemsSummary = items
|
||||
.map(
|
||||
(i: any) =>
|
||||
`${i.product?.nameFa || 'محصول'} (${i.quantity})`,
|
||||
)
|
||||
.join('، ');
|
||||
const itemCount = items.reduce(
|
||||
(sum: number, i: any) => sum + (i.quantity || 1),
|
||||
0,
|
||||
);
|
||||
const customerName =
|
||||
transaction.user?.firstName ||
|
||||
transaction.order?.user?.firstName ||
|
||||
'مشتری';
|
||||
|
||||
const customerName = (
|
||||
transaction.user?.firstName
|
||||
? `${transaction.user.firstName} ${transaction.user.lastName || ''}`
|
||||
: order?.user?.firstName
|
||||
? `${order.user.firstName} ${order.user.lastName || ''}`
|
||||
: shipping.fullName || 'مشتری'
|
||||
).trim();
|
||||
|
||||
const orderData = {
|
||||
orderNumber:
|
||||
order?.trackingNumber ||
|
||||
transaction.order?.trackingNumber ||
|
||||
transaction.orderId ||
|
||||
transaction.id,
|
||||
amount: Number(transaction.amount).toLocaleString('fa-IR'),
|
||||
customerName,
|
||||
customerPhone: customerMobile || '',
|
||||
recipientName: shipping.fullName || customerName,
|
||||
recipientPhone: shipping.phone || customerMobile || '',
|
||||
provinceCity:
|
||||
`${shipping.province || ''} - ${shipping.city || ''}`.trim(),
|
||||
postalCode: shipping.postalCode || '',
|
||||
address: shipping.address || '',
|
||||
itemsSummary,
|
||||
itemCount: String(itemCount),
|
||||
paymentMethod: 'آنلاین',
|
||||
refNumber: refNumber || '',
|
||||
orderDate: new Date().toLocaleDateString('fa-IR'),
|
||||
};
|
||||
|
||||
this.smsService
|
||||
.sendOrderConfirmation(
|
||||
customerMobile,
|
||||
customerName,
|
||||
trackingNum,
|
||||
formattedAmount,
|
||||
)
|
||||
.triggerEvent('ORDER_PAID', orderData)
|
||||
.catch((err) => {
|
||||
this.logger.warn(`Could not send SMS confirmation: ${err}`);
|
||||
this.logger.warn(`Could not trigger SMS event ORDER_PAID: ${err}`);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -219,6 +219,27 @@ export class SettingsController {
|
||||
return this.settingsService.testSms(phone, patternId, args);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('Admin')
|
||||
@ApiBearerAuth()
|
||||
@Get('sms/events')
|
||||
@ApiOperation({ summary: 'دریافت لیست تمامی رویدادهای سیستم و متغیرهای داینامیک قابل استفاده در پترنها' })
|
||||
getSmsEvents() {
|
||||
return this.settingsService.getSmsEvents();
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('Admin')
|
||||
@ApiBearerAuth()
|
||||
@Post('sms/test-rule')
|
||||
@ApiOperation({ summary: 'تست ارسال پیامک برای یک سناریوی تعریفشده با دیتای تستی' })
|
||||
testSmsRule(
|
||||
@Body('rule') rule: any,
|
||||
@Body('testPhone') testPhone?: string,
|
||||
) {
|
||||
return this.settingsService.testSmsRule(rule, testPhone);
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('Admin')
|
||||
@ApiBearerAuth()
|
||||
|
||||
@ -659,6 +659,14 @@ export class SettingsService implements OnModuleInit {
|
||||
return this.smsService.testSms(targetPhone, patternId, args);
|
||||
}
|
||||
|
||||
async getSmsEvents() {
|
||||
return this.smsService.getEventDefinitions();
|
||||
}
|
||||
|
||||
async testSmsRule(rule: any, testPhone?: string) {
|
||||
return this.smsService.testRule(rule, testPhone);
|
||||
}
|
||||
|
||||
async getPatterns() {
|
||||
return this.smsService.getPatterns();
|
||||
}
|
||||
|
||||
@ -23,20 +23,17 @@ export class WholesaleService {
|
||||
},
|
||||
});
|
||||
|
||||
// Alert admin via SMS if admin mobile is configured
|
||||
const adminMobile = process.env.ADMIN_MOBILE_ALERT;
|
||||
if (adminMobile) {
|
||||
this.smsService
|
||||
.sendPatternSms({
|
||||
to: adminMobile,
|
||||
bodyId: parseInt(
|
||||
process.env.MELIPAYAMAK_ADMIN_ALERT_BODY_ID || '0',
|
||||
10,
|
||||
),
|
||||
args: [dto.businessName || 'نامشخص', user.mobile || ''],
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
// Alert via SMS event
|
||||
this.smsService
|
||||
.triggerEvent('B2B_SUBMITTED', {
|
||||
applicantName: dto.businessName || user.firstName || 'متقاضی B2B',
|
||||
applicantPhone: user.mobile || '',
|
||||
customerPhone: user.mobile || '',
|
||||
companyName: dto.businessName || '',
|
||||
businessType: (dto as any).businessType || '',
|
||||
date: new Date().toLocaleDateString('fa-IR'),
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
||||
@ -35,24 +35,55 @@ import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Filter,
|
||||
Zap,
|
||||
CheckSquare,
|
||||
Square,
|
||||
Users,
|
||||
Play,
|
||||
FileText,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import api from '../services/api';
|
||||
import Spinner from '../components/ui/Spinner';
|
||||
import Button from '../components/ui/Button';
|
||||
|
||||
export interface SmsRule {
|
||||
id: string;
|
||||
title: string;
|
||||
event: string;
|
||||
enabled: boolean;
|
||||
recipientType: 'customer' | 'admin' | 'custom';
|
||||
customPhone?: string;
|
||||
patternId: number;
|
||||
variables: string[];
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface SmsEventVariable {
|
||||
key: string;
|
||||
label: string;
|
||||
sample: string;
|
||||
}
|
||||
|
||||
export interface SmsEventDefinition {
|
||||
event: string;
|
||||
label: string;
|
||||
description: string;
|
||||
variables: SmsEventVariable[];
|
||||
}
|
||||
|
||||
interface SmsConfigState {
|
||||
enabled: boolean;
|
||||
username: string;
|
||||
password: string;
|
||||
fromNumber: string;
|
||||
adminPhone: string;
|
||||
otpBodyId: number | string;
|
||||
orderBodyId: number | string;
|
||||
shippingBodyId: number | string;
|
||||
b2bBodyId: number | string;
|
||||
petCareBodyId: number | string;
|
||||
rules: SmsRule[];
|
||||
}
|
||||
|
||||
interface PatternItem {
|
||||
@ -91,7 +122,7 @@ const toPersianDigits = (n: string | number | null | undefined): string => {
|
||||
};
|
||||
|
||||
export default function SmsSettingsPage() {
|
||||
const [activeTab, setActiveTab] = useState<'settings' | 'patterns' | 'logs'>('settings');
|
||||
const [activeTab, setActiveTab] = useState<'settings' | 'triggers' | 'patterns' | 'logs'>('triggers');
|
||||
|
||||
// Config State
|
||||
const [config, setConfig] = useState<SmsConfigState>({
|
||||
@ -99,14 +130,17 @@ export default function SmsSettingsPage() {
|
||||
username: '',
|
||||
password: '',
|
||||
fromNumber: '',
|
||||
adminPhone: '',
|
||||
otpBodyId: '508079',
|
||||
orderBodyId: '508081',
|
||||
shippingBodyId: '508082',
|
||||
b2bBodyId: '508083',
|
||||
petCareBodyId: '0',
|
||||
rules: [],
|
||||
});
|
||||
|
||||
const [patterns, setPatterns] = useState<PatternItem[]>([]);
|
||||
const [eventDefinitions, setEventDefinitions] = useState<SmsEventDefinition[]>([]);
|
||||
const [credit, setCredit] = useState<number | null>(null);
|
||||
const [isLoadingCredit, setIsLoadingCredit] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@ -115,6 +149,19 @@ export default function SmsSettingsPage() {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [copiedId, setCopiedId] = useState<number | null>(null);
|
||||
|
||||
// Trigger Rules Modal State
|
||||
const [isRuleModalOpen, setIsRuleModalOpen] = useState(false);
|
||||
const [editingRule, setEditingRule] = useState<SmsRule | null>(null);
|
||||
const [ruleFormTitle, setRuleFormTitle] = useState('');
|
||||
const [ruleFormEvent, setRuleFormEvent] = useState('ORDER_PAID');
|
||||
const [ruleFormEnabled, setRuleFormEnabled] = useState(true);
|
||||
const [ruleFormRecipientType, setRuleFormRecipientType] = useState<'customer' | 'admin' | 'custom'>('customer');
|
||||
const [ruleFormCustomPhone, setRuleFormCustomPhone] = useState('');
|
||||
const [ruleFormPatternId, setRuleFormPatternId] = useState('');
|
||||
const [ruleFormVariables, setRuleFormVariables] = useState<string[]>([]);
|
||||
const [ruleFormDescription, setRuleFormDescription] = useState('');
|
||||
const [isTestingRuleId, setIsTestingRuleId] = useState<string | null>(null);
|
||||
|
||||
// Pattern Modals
|
||||
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
@ -172,10 +219,11 @@ export default function SmsSettingsPage() {
|
||||
const fetchSettingsAndPatterns = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const [resConfig, resPatterns, resCredit] = await Promise.allSettled([
|
||||
const [resConfig, resPatterns, resCredit, resEvents] = await Promise.allSettled([
|
||||
api.get('/settings/sms'),
|
||||
api.get('/settings/sms/patterns'),
|
||||
api.get('/settings/sms/credit'),
|
||||
api.get('/settings/sms/events'),
|
||||
]);
|
||||
|
||||
if (resConfig.status === 'fulfilled') {
|
||||
@ -186,11 +234,13 @@ export default function SmsSettingsPage() {
|
||||
username: data.username || '',
|
||||
password: data.password || '',
|
||||
fromNumber: data.fromNumber || '',
|
||||
adminPhone: data.adminPhone || '',
|
||||
otpBodyId: data.otpBodyId || '508079',
|
||||
orderBodyId: data.orderBodyId || '508081',
|
||||
shippingBodyId: data.shippingBodyId || '508082',
|
||||
b2bBodyId: data.b2bBodyId || '508083',
|
||||
petCareBodyId: data.petCareBodyId || '0',
|
||||
rules: Array.isArray(data.rules) ? data.rules : [],
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -206,6 +256,11 @@ export default function SmsSettingsPage() {
|
||||
setCredit(Number(cData.credit) || 0);
|
||||
}
|
||||
}
|
||||
|
||||
if (resEvents.status === 'fulfilled') {
|
||||
const eData = resEvents.value.data?.data || resEvents.value.data || [];
|
||||
setEventDefinitions(Array.isArray(eData) ? eData : []);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load SMS data:', err);
|
||||
} finally {
|
||||
@ -278,11 +333,13 @@ export default function SmsSettingsPage() {
|
||||
username: config.username.trim(),
|
||||
password: config.password.trim(),
|
||||
fromNumber: config.fromNumber.trim(),
|
||||
adminPhone: (config.adminPhone || '').trim(),
|
||||
otpBodyId: Number(config.otpBodyId) || 0,
|
||||
orderBodyId: Number(config.orderBodyId) || 0,
|
||||
shippingBodyId: Number(config.shippingBodyId) || 0,
|
||||
b2bBodyId: Number(config.b2bBodyId) || 0,
|
||||
petCareBodyId: Number(config.petCareBodyId) || 0,
|
||||
rules: config.rules || [],
|
||||
};
|
||||
|
||||
await api.patch('/settings/sms', payload);
|
||||
@ -296,6 +353,159 @@ export default function SmsSettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Rule Handlers
|
||||
const handleOpenAddRuleModal = () => {
|
||||
setEditingRule(null);
|
||||
setRuleFormTitle('');
|
||||
setRuleFormEvent('ORDER_PAID');
|
||||
setRuleFormEnabled(true);
|
||||
setRuleFormRecipientType('customer');
|
||||
setRuleFormCustomPhone('');
|
||||
setRuleFormPatternId(String(config.orderBodyId || ''));
|
||||
setRuleFormVariables(['customerName', 'orderNumber', 'amount']);
|
||||
setRuleFormDescription('');
|
||||
setIsRuleModalOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenEditRuleModal = (rule: SmsRule) => {
|
||||
setEditingRule(rule);
|
||||
setRuleFormTitle(rule.title);
|
||||
setRuleFormEvent(rule.event);
|
||||
setRuleFormEnabled(rule.enabled);
|
||||
setRuleFormRecipientType(rule.recipientType);
|
||||
setRuleFormCustomPhone(rule.customPhone || '');
|
||||
setRuleFormPatternId(String(rule.patternId || ''));
|
||||
setRuleFormVariables(rule.variables || []);
|
||||
setRuleFormDescription(rule.description || '');
|
||||
setIsRuleModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveRule = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!ruleFormTitle.trim()) {
|
||||
toast.error('لطفاً عنوان سناریو را وارد کنید');
|
||||
return;
|
||||
}
|
||||
const pid = Number(ruleFormPatternId);
|
||||
if (!pid || pid <= 0) {
|
||||
toast.error('لطفاً شناسه الگو (Pattern ID) را مشخص کنید');
|
||||
return;
|
||||
}
|
||||
if (ruleFormRecipientType === 'custom' && !ruleFormCustomPhone.trim()) {
|
||||
toast.error('لطفاً شماره موبایل دلخواه گیرنده را وارد نمایید');
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedRule: SmsRule = {
|
||||
id: editingRule ? editingRule.id : `rule_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`,
|
||||
title: ruleFormTitle.trim(),
|
||||
event: ruleFormEvent,
|
||||
enabled: ruleFormEnabled,
|
||||
recipientType: ruleFormRecipientType,
|
||||
customPhone: ruleFormRecipientType === 'custom' ? ruleFormCustomPhone.trim() : undefined,
|
||||
patternId: pid,
|
||||
variables: ruleFormVariables,
|
||||
description: ruleFormDescription.trim() || undefined,
|
||||
};
|
||||
|
||||
let newRules: SmsRule[] = [];
|
||||
if (editingRule) {
|
||||
newRules = (config.rules || []).map((r) => (r.id === editingRule.id ? updatedRule : r));
|
||||
} else {
|
||||
newRules = [...(config.rules || []), updatedRule];
|
||||
}
|
||||
|
||||
const newConfig = { ...config, rules: newRules };
|
||||
setConfig(newConfig);
|
||||
setIsRuleModalOpen(false);
|
||||
|
||||
try {
|
||||
await api.patch('/settings/sms', {
|
||||
...newConfig,
|
||||
otpBodyId: Number(newConfig.otpBodyId) || 0,
|
||||
orderBodyId: Number(newConfig.orderBodyId) || 0,
|
||||
shippingBodyId: Number(newConfig.shippingBodyId) || 0,
|
||||
b2bBodyId: Number(newConfig.b2bBodyId) || 0,
|
||||
petCareBodyId: Number(newConfig.petCareBodyId) || 0,
|
||||
});
|
||||
toast.success(editingRule ? 'سناریو با موفقیت ویرایش و ذخیره شد' : 'سناریوی جدید اضافه و ذخیره شد');
|
||||
} catch (err) {
|
||||
console.error('Failed to save rules to backend:', err);
|
||||
toast.error('خطا در ذخیره سناریو در سرور');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleRule = async (ruleId: string) => {
|
||||
const newRules = (config.rules || []).map((r) => {
|
||||
if (r.id === ruleId) {
|
||||
return { ...r, enabled: !r.enabled };
|
||||
}
|
||||
return r;
|
||||
});
|
||||
|
||||
const newConfig = { ...config, rules: newRules };
|
||||
setConfig(newConfig);
|
||||
|
||||
try {
|
||||
await api.patch('/settings/sms', {
|
||||
...newConfig,
|
||||
otpBodyId: Number(newConfig.otpBodyId) || 0,
|
||||
orderBodyId: Number(newConfig.orderBodyId) || 0,
|
||||
shippingBodyId: Number(newConfig.shippingBodyId) || 0,
|
||||
b2bBodyId: Number(newConfig.b2bBodyId) || 0,
|
||||
petCareBodyId: Number(newConfig.petCareBodyId) || 0,
|
||||
});
|
||||
toast.success('وضعیت سناریو بروزرسانی شد');
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle rule:', err);
|
||||
toast.error('خطا در بهروزرسانی وضعیت سناریو');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRule = async (ruleId: string) => {
|
||||
if (!window.confirm('آیا از حذف این سناریوی پیامکی اطمینان دارید؟')) return;
|
||||
|
||||
const newRules = (config.rules || []).filter((r) => r.id !== ruleId);
|
||||
const newConfig = { ...config, rules: newRules };
|
||||
setConfig(newConfig);
|
||||
|
||||
try {
|
||||
await api.patch('/settings/sms', {
|
||||
...newConfig,
|
||||
otpBodyId: Number(newConfig.otpBodyId) || 0,
|
||||
orderBodyId: Number(newConfig.orderBodyId) || 0,
|
||||
shippingBodyId: Number(newConfig.shippingBodyId) || 0,
|
||||
b2bBodyId: Number(newConfig.b2bBodyId) || 0,
|
||||
petCareBodyId: Number(newConfig.petCareBodyId) || 0,
|
||||
});
|
||||
toast.success('سناریوی پیامک با موفقیت حذف شد');
|
||||
} catch (err) {
|
||||
console.error('Failed to delete rule:', err);
|
||||
toast.error('خطا در حذف سناریو');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestRule = async (rule: SmsRule) => {
|
||||
try {
|
||||
setIsTestingRuleId(rule.id);
|
||||
const res = await api.post('/settings/sms/test-rule', {
|
||||
rule,
|
||||
testPhone: testPhone ? testPhone.trim() : undefined,
|
||||
});
|
||||
const data = res.data?.data || res.data;
|
||||
if (data && data.success) {
|
||||
toast.success(`تست سناریو ارسال شد به: ${data.targetPhone || ''}`);
|
||||
} else {
|
||||
toast.error(data?.message || 'خطا در ارسال تست سناریو');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to test rule:', err);
|
||||
toast.error(err.response?.data?.message || 'خطای شبکه در تست سناریو');
|
||||
} finally {
|
||||
setIsTestingRuleId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreatePattern = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newPatternTitle || !newPatternBody) {
|
||||
@ -517,6 +727,24 @@ export default function SmsSettingsPage() {
|
||||
پیکربندی حساب
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('triggers')}
|
||||
className={`px-3.5 py-2 rounded-xl text-xs font-bold transition-all flex items-center gap-1.5 ${
|
||||
activeTab === 'triggers'
|
||||
? 'bg-white text-purple-700 shadow-sm'
|
||||
: 'text-gray-600 hover:text-gray-900'
|
||||
}`}
|
||||
>
|
||||
<Zap className="w-4 h-4 text-amber-500" />
|
||||
سناریوهای پیامک (Triggers)
|
||||
{config.rules && config.rules.length > 0 && (
|
||||
<span className="bg-amber-100 text-amber-800 text-[10px] px-1.5 py-0.2 rounded-full font-mono font-bold">
|
||||
{config.rules.length}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('patterns')}
|
||||
@ -651,6 +879,24 @@ export default function SmsSettingsPage() {
|
||||
در ارسال پترن خدماتی، ملی پیامک به صورت خودکار از خط اشتراکی استفاده میکند و پر کردن این فیلد اختیاری است.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-xs font-bold text-gray-700 mb-1 flex items-center gap-1.5">
|
||||
<Users className="w-3.5 h-3.5 text-purple-600" />
|
||||
شماره همراه مدیران جهت دریافت پیامکهای سیستم (Admin Phone)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.adminPhone}
|
||||
onChange={(e) => setConfig({ ...config, adminPhone: e.target.value })}
|
||||
placeholder="مثلاً: 09364100228 (برای چند شماره با ویرگول یا کاما جدا کنید)"
|
||||
className="w-full border border-gray-200 rounded-xl p-3 outline-none focus:ring-2 focus:ring-purple-500 text-sm font-mono font-bold bg-white"
|
||||
dir="ltr"
|
||||
/>
|
||||
<p className="text-[11px] text-gray-500 mt-1">
|
||||
سناریوهایی که گیرنده آنها «مدیر سایت» تنظیم شده است، به این شمارهها پیامک ارسال خواهند کرد.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -941,6 +1187,199 @@ export default function SmsSettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : activeTab === 'triggers' ? (
|
||||
/* Triggers / Scenarios Tab */
|
||||
<div className="space-y-6">
|
||||
{/* Top Actions & Overview */}
|
||||
<div className="bg-white p-5 rounded-2xl shadow-sm border border-gray-200 flex flex-col md:flex-row items-center justify-between gap-4">
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-gray-900 flex items-center gap-2">
|
||||
<Zap className="w-5 h-5 text-amber-500" />
|
||||
سناریوها و قوانین ارسال پیامک خودکار (SMS Automation Rules)
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
برای هر رویداد (مانند ثبت یا پرداخت سفارش) میتوانید بینهایت پیامک همزمان به مشتری، مدیران یا شمارههای دلخواه (نظیر انبار و شرکت پست) با پترنهای اختصاصی تعریف کنید.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenAddRuleModal}
|
||||
className="bg-amber-600 hover:bg-amber-700 text-white text-xs font-bold py-3 px-5 rounded-xl transition-all flex items-center gap-2 shadow-md shadow-amber-200 shrink-0"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
تعریف سناریوی جدید (Add Rule)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Rules List / Grid */}
|
||||
{(!config.rules || config.rules.length === 0) ? (
|
||||
<div className="bg-white p-12 rounded-2xl border border-gray-200 text-center space-y-4">
|
||||
<div className="w-16 h-16 bg-amber-50 text-amber-600 rounded-2xl flex items-center justify-center mx-auto">
|
||||
<Zap className="w-8 h-8" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<h4 className="font-bold text-gray-800 text-sm">هیچ سناریوی پیامکی فعالی وجود ندارد</h4>
|
||||
<p className="text-xs text-gray-500 max-w-md mx-auto">
|
||||
سیستم در این حالت از تنظیمات پیشفرض پترنها در تب پیکربندی استفاده میکند. با کلیک بر روی دکمه زیر میتوانید سناریوهای چندمقصدی بسازید.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpenAddRuleModal}
|
||||
className="bg-amber-600 hover:bg-amber-700 text-white text-xs font-bold py-2.5 px-5 rounded-xl transition-all inline-flex items-center gap-2 shadow-sm"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
تعریف اولین سناریوی پیامکی
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||
{config.rules.map((rule) => {
|
||||
const eventDef = eventDefinitions.find((e) => e.event === rule.event);
|
||||
const matchedPattern = patterns.find((p) => Number(p.id) === Number(rule.patternId));
|
||||
|
||||
return (
|
||||
<div
|
||||
key={rule.id}
|
||||
className={`bg-white rounded-2xl border transition-all shadow-sm flex flex-col justify-between overflow-hidden ${
|
||||
rule.enabled ? 'border-gray-200 hover:border-amber-400' : 'border-gray-200 opacity-60 bg-gray-50/50'
|
||||
}`}
|
||||
>
|
||||
<div className="p-5 space-y-4">
|
||||
{/* Header: Title, Active Switch, Badges */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1 flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span
|
||||
className={`text-[10px] font-black px-2 py-0.5 rounded-full border ${
|
||||
rule.recipientType === 'customer'
|
||||
? 'bg-blue-50 text-blue-700 border-blue-200'
|
||||
: rule.recipientType === 'admin'
|
||||
? 'bg-purple-50 text-purple-700 border-purple-200'
|
||||
: 'bg-emerald-50 text-emerald-700 border-emerald-200'
|
||||
}`}
|
||||
>
|
||||
{rule.recipientType === 'customer'
|
||||
? 'خریدار / مشتری'
|
||||
: rule.recipientType === 'admin'
|
||||
? 'مدیران سایت'
|
||||
: `شماره دلخواه: ${rule.customPhone || ''}`}
|
||||
</span>
|
||||
|
||||
<span className="text-[10px] bg-slate-100 text-slate-700 font-bold px-2 py-0.5 rounded-full border border-slate-200">
|
||||
{eventDef ? eventDef.label : rule.event}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h4 className="font-bold text-gray-900 text-sm truncate pt-1">{rule.title}</h4>
|
||||
{rule.description && (
|
||||
<p className="text-xs text-gray-500 leading-relaxed line-clamp-2">
|
||||
{rule.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Toggle Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleRule(rule.id)}
|
||||
className={`p-1.5 rounded-xl border transition-colors shrink-0 ${
|
||||
rule.enabled
|
||||
? 'bg-emerald-50 border-emerald-200 text-emerald-600 hover:bg-emerald-100'
|
||||
: 'bg-gray-100 border-gray-300 text-gray-400 hover:bg-gray-200'
|
||||
}`}
|
||||
title={rule.enabled ? 'سناریو فعال است (کلیک برای غیرفعال کردن)' : 'سناریو غیرفعال است (کلیک برای فعالسازی)'}
|
||||
>
|
||||
{rule.enabled ? <CheckSquare className="w-5 h-5" /> : <Square className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Pattern ID & Variables Details */}
|
||||
<div className="bg-gray-50 rounded-xl p-3 border border-gray-100 space-y-2.5 text-xs">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-gray-500 font-medium">شناسه پترن ملی پیامک:</span>
|
||||
<span className="font-mono font-black text-purple-700 bg-purple-50 px-2 py-0.5 rounded border border-purple-200">
|
||||
#{rule.patternId}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{matchedPattern && (
|
||||
<div className="text-[11px] text-gray-600 bg-white p-2 rounded-lg border border-gray-200 leading-relaxed font-sans">
|
||||
<span className="font-bold text-gray-700 block mb-0.5">متن الگو:</span>
|
||||
{matchedPattern.body}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<span className="text-gray-500 font-medium block">ترتیب متغیرهای انتسابی:</span>
|
||||
{rule.variables && rule.variables.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5 pt-0.5">
|
||||
{rule.variables.map((vKey, vIdx) => {
|
||||
const varMeta = eventDef?.variables.find((v) => v.key === vKey);
|
||||
return (
|
||||
<span
|
||||
key={vIdx}
|
||||
className="bg-white border border-gray-200 text-gray-700 text-[11px] font-mono font-bold px-2 py-0.5 rounded shadow-2xs flex items-center gap-1"
|
||||
>
|
||||
<span className="text-amber-600 font-black">{`{${vIdx}}`}:</span>
|
||||
<span className="font-sans font-medium text-[10px]">
|
||||
{varMeta ? varMeta.label : vKey}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-gray-400 text-[11px]">بدون متغیر داینامیک</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Action Footer */}
|
||||
<div className="px-5 py-3 bg-gray-50 border-t border-gray-100 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isTestingRuleId === rule.id}
|
||||
onClick={() => handleTestRule(rule)}
|
||||
className="text-xs font-bold text-amber-700 hover:text-amber-900 flex items-center gap-1.5 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isTestingRuleId === rule.id ? (
|
||||
<Spinner size="sm" className="text-amber-600" />
|
||||
) : (
|
||||
<Play className="w-3.5 h-3.5 fill-current" />
|
||||
)}
|
||||
<span>تست ارسال سناریو</span>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleOpenEditRuleModal(rule)}
|
||||
className="p-1.5 text-gray-500 hover:text-blue-600 hover:bg-white rounded-lg border border-transparent hover:border-gray-200 transition-all text-xs font-bold flex items-center gap-1"
|
||||
title="ویرایش سناریو"
|
||||
>
|
||||
<Edit3 className="w-3.5 h-3.5" />
|
||||
<span>ویرایش</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeleteRule(rule.id)}
|
||||
className="p-1.5 text-gray-400 hover:text-rose-600 hover:bg-white rounded-lg border border-transparent hover:border-gray-200 transition-all"
|
||||
title="حذف سناریو"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : activeTab === 'patterns' ? (
|
||||
/* Patterns Management Tab */
|
||||
<div className="space-y-6">
|
||||
@ -1417,6 +1856,291 @@ export default function SmsSettingsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal: Add/Edit SMS Automation Rule */}
|
||||
{isRuleModalOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
|
||||
<div className="bg-white w-full max-w-2xl rounded-2xl shadow-2xl border border-gray-100 overflow-hidden font-vazir animate-in fade-in zoom-in duration-200 max-h-[90vh] flex flex-col">
|
||||
<div className="flex items-center justify-between p-5 border-b border-gray-100 shrink-0">
|
||||
<h3 className="text-base font-bold text-gray-900 flex items-center gap-2">
|
||||
<Zap className="w-5 h-5 text-amber-500" />
|
||||
{editingRule ? 'ویرایش سناریوی پیامک' : 'تعریف سناریوی پیامکی جدید'}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsRuleModalOpen(false)}
|
||||
className="text-gray-400 hover:text-gray-600 p-1 rounded-lg"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSaveRule} className="p-6 space-y-4 overflow-y-auto">
|
||||
{/* Rule Title */}
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-700 mb-1">
|
||||
عنوان سناریو (جهت تفکیک در لیست) *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={ruleFormTitle}
|
||||
onChange={(e) => setRuleFormTitle(e.target.value)}
|
||||
placeholder="مثلاً: ارسال مشخصات گیرنده به شرکت پست / انبار"
|
||||
className="w-full border border-gray-200 rounded-xl p-3 outline-none focus:ring-2 focus:ring-amber-500 text-sm font-bold"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Event & Recipient Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{/* Trigger Event */}
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-700 mb-1">رویداد تحریککننده (Trigger Event) *</label>
|
||||
<select
|
||||
value={ruleFormEvent}
|
||||
onChange={(e) => {
|
||||
const newEv = e.target.value;
|
||||
setRuleFormEvent(newEv);
|
||||
const def = eventDefinitions.find((ed) => ed.event === newEv);
|
||||
if (def && def.variables.length > 0) {
|
||||
setRuleFormVariables(def.variables.slice(0, 3).map((v) => v.key));
|
||||
}
|
||||
}}
|
||||
className="w-full border border-gray-200 rounded-xl p-3 outline-none focus:ring-2 focus:ring-amber-500 text-xs font-bold bg-white"
|
||||
>
|
||||
{eventDefinitions.length > 0 ? (
|
||||
eventDefinitions.map((ed) => (
|
||||
<option key={ed.event} value={ed.event}>
|
||||
{ed.label}
|
||||
</option>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<option value="ORDER_PAID">پرداخت موفق سفارش (Order Paid)</option>
|
||||
<option value="ORDER_CREATED">ثبت سفارش کارت به کارت / در انتظار</option>
|
||||
<option value="ORDER_SHIPPED">ارسال مرسوله و صدور بارکد پستی</option>
|
||||
<option value="B2B_SUBMITTED">ثبت درخواست همکاری B2B</option>
|
||||
<option value="CONTACT_SUBMITTED">ثبت پیام در فرم تماس با ما</option>
|
||||
</>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Recipient Type */}
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-700 mb-1">گیرنده پیامک (Recipient) *</label>
|
||||
<select
|
||||
value={ruleFormRecipientType}
|
||||
onChange={(e) => setRuleFormRecipientType(e.target.value as any)}
|
||||
className="w-full border border-gray-200 rounded-xl p-3 outline-none focus:ring-2 focus:ring-amber-500 text-xs font-bold bg-white"
|
||||
>
|
||||
<option value="customer">خریدار / مشتری (شماره ثبتشده در سفارش یا فرم)</option>
|
||||
<option value="admin">مدیر سایت (شمارههای تنظیمشده در پنل)</option>
|
||||
<option value="custom">شماره همراه دلخواه (انبار، واحد ارسال، حسابداری)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom Phone Number Input if custom */}
|
||||
{ruleFormRecipientType === 'custom' && (
|
||||
<div className="bg-amber-50/70 p-3.5 rounded-xl border border-amber-200 space-y-1">
|
||||
<label className="block text-xs font-bold text-amber-900">
|
||||
شمارههای موبایل دریافتکننده (Custom Phone Numbers) *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={ruleFormCustomPhone}
|
||||
onChange={(e) => setRuleFormCustomPhone(e.target.value)}
|
||||
placeholder="مثلاً: 09123456789 (برای چند شماره با کاما یا ویرگول جدا کنید)"
|
||||
className="w-full border border-amber-300 rounded-xl p-2.5 outline-none focus:ring-2 focus:ring-amber-500 text-xs font-mono font-bold bg-white"
|
||||
dir="ltr"
|
||||
/>
|
||||
<p className="text-[11px] text-amber-700">
|
||||
پیامک این سناریو به این شمارهها ارسال خواهد شد (مانند تلفن همراه راننده، مسئول انبار یا واحد هماهنگی).
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pattern ID Selection */}
|
||||
<div className="space-y-2 bg-gray-50 p-4 rounded-xl border border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-bold text-gray-800">
|
||||
شناسه الگوی ملی پیامک (Pattern Body ID) *
|
||||
</label>
|
||||
<span className="text-[11px] text-gray-500">انتخاب از الگوها یا درج دستی</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{/* Select from registered patterns */}
|
||||
<div>
|
||||
<select
|
||||
value={ruleFormPatternId}
|
||||
onChange={(e) => setRuleFormPatternId(e.target.value)}
|
||||
className="w-full border border-gray-200 rounded-xl p-2.5 outline-none focus:ring-2 focus:ring-amber-500 text-xs font-bold bg-white"
|
||||
>
|
||||
<option value="">انتخاب از لیست الگوهای موجود...</option>
|
||||
{patterns.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
#{p.id} - {p.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Or Manual Body ID Input */}
|
||||
<div>
|
||||
<input
|
||||
type="number"
|
||||
required
|
||||
value={ruleFormPatternId}
|
||||
onChange={(e) => setRuleFormPatternId(e.target.value)}
|
||||
placeholder="یا وارد کردن شناسه پترن جدید (مثلاً: 508081)"
|
||||
className="w-full border border-gray-200 rounded-xl p-2.5 outline-none focus:ring-2 focus:ring-amber-500 text-xs font-mono font-bold bg-white"
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Show Preview of Selected Pattern Text if found */}
|
||||
{(() => {
|
||||
const selPat = patterns.find((p) => String(p.id) === String(ruleFormPatternId));
|
||||
if (!selPat) return null;
|
||||
return (
|
||||
<div className="text-[11px] text-gray-600 bg-white p-2.5 rounded-lg border border-gray-200 leading-relaxed font-sans mt-1">
|
||||
<span className="font-bold text-gray-700 block mb-0.5">متن الگو:</span>
|
||||
{selPat.body}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Dynamic Variables Mapper */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-bold text-gray-800">
|
||||
تخصیص متغیرها به ترتیب پارامترهای پترن ({'{0}'}, {'{1}'}, ...)
|
||||
</label>
|
||||
<span className="text-[11px] text-amber-700 font-medium">
|
||||
کلیک روی متغیرها برای افزودن یا حذف
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Current mapped variables */}
|
||||
<div className="bg-amber-50/40 p-3 rounded-xl border border-amber-200/80 min-h-[50px] flex items-center flex-wrap gap-2">
|
||||
{ruleFormVariables.length === 0 ? (
|
||||
<span className="text-xs text-gray-400 italic">
|
||||
هیچ متغیری انتخاب نشده است. از لیست زیر متغیرهای مورد نظر را اضافه کنید.
|
||||
</span>
|
||||
) : (
|
||||
ruleFormVariables.map((vKey, idx) => {
|
||||
const curEventDef = eventDefinitions.find((ed) => ed.event === ruleFormEvent);
|
||||
const varMeta = curEventDef?.variables.find((v) => v.key === vKey);
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="bg-white border border-amber-300 text-gray-800 text-xs font-bold px-2.5 py-1 rounded-lg shadow-2xs flex items-center gap-1.5 animate-in fade-in zoom-in duration-100"
|
||||
>
|
||||
<span className="text-amber-600 font-mono font-black">{`{${idx}}`}:</span>
|
||||
<span>{varMeta ? varMeta.label : vKey}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const updated = [...ruleFormVariables];
|
||||
updated.splice(idx, 1);
|
||||
setRuleFormVariables(updated);
|
||||
}}
|
||||
className="text-gray-400 hover:text-rose-600 p-0.5 rounded"
|
||||
title="حذف این متغیر"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Available Variables for the selected event */}
|
||||
{(() => {
|
||||
const curEventDef = eventDefinitions.find((ed) => ed.event === ruleFormEvent);
|
||||
if (!curEventDef || curEventDef.variables.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5 pt-1">
|
||||
<span className="text-[11px] text-gray-500 font-bold block">
|
||||
متغیرهای در دسترس برای رویداد «{curEventDef.label}»:
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{curEventDef.variables.map((v) => (
|
||||
<button
|
||||
key={v.key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setRuleFormVariables([...ruleFormVariables, v.key]);
|
||||
}}
|
||||
className="text-[11px] bg-gray-100 hover:bg-amber-100 hover:text-amber-900 text-gray-700 px-2.5 py-1 rounded-lg border border-gray-200 transition-colors flex items-center gap-1 font-medium"
|
||||
>
|
||||
<Plus className="w-3 h-3 text-gray-500" />
|
||||
<span>{v.label}</span>
|
||||
<span className="text-[9px] text-gray-400 font-mono">({v.sample})</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Optional Description */}
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-gray-700 mb-1">
|
||||
توضیحات و یادداشت داخلی (اختیاری)
|
||||
</label>
|
||||
<textarea
|
||||
rows={2}
|
||||
value={ruleFormDescription}
|
||||
onChange={(e) => setRuleFormDescription(e.target.value)}
|
||||
placeholder="مثلاً: ارسال فاکتور خرید و آدرس مشتری به واحد بستهبندی انبار تهران"
|
||||
className="w-full border border-gray-200 rounded-xl p-2.5 outline-none focus:ring-2 focus:ring-amber-500 text-xs leading-relaxed font-medium"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Enabled toggle */}
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-xl border border-gray-200">
|
||||
<span className="text-xs font-bold text-gray-800">فعال بودن این سناریو</span>
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={ruleFormEnabled}
|
||||
onChange={(e) => setRuleFormEnabled(e.target.checked)}
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-amber-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Submit Buttons */}
|
||||
<div className="flex justify-end gap-3 pt-4 border-t border-gray-100 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsRuleModalOpen(false)}
|
||||
className="px-5 py-2.5 rounded-xl border border-gray-200 text-xs font-bold text-gray-600 hover:bg-gray-50"
|
||||
>
|
||||
انصراف
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-amber-600 hover:bg-amber-700 text-white font-bold px-6 py-2.5 rounded-xl text-xs flex items-center gap-2 shadow-md shadow-amber-200"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{editingRule ? 'ذخیره تغییرات سناریو' : 'ثبت و فعالسازی سناریو'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal: Add New Pattern */}
|
||||
{isAddModalOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4 backdrop-blur-sm">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user