feat(sms): implement dynamic multi-recipient SMS automation rules with custom patterns and variable mapping
Some checks failed
Deploy Canina / deploy (push) Successful in 46s
E2E Playwright Tests / Run Full E2E & Security Suites (push) Failing after 7s

This commit is contained in:
parsa aghaei 2026-09-05 08:26:24 +03:30
parent c247f21d83
commit 52cc601345
9 changed files with 1274 additions and 84 deletions

View 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: 'پیامک ثبت درخواست متقاضیان عمده‌فروشی',
},
];

View File

@ -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
*/

View File

@ -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(

View File

@ -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(() => {});
}

View File

@ -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}`);
});
}

View File

@ -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()

View File

@ -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();
}

View File

@ -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,

View File

@ -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">