Compare commits
No commits in common. "f0329d8b5be29e795b0b40aab96ef65b34b92e3d" and "893280dc2cf756a02cd5f1d600120d48cba177bf" have entirely different histories.
f0329d8b5b
...
893280dc2c
File diff suppressed because it is too large
Load Diff
@ -3,16 +3,16 @@
|
||||
"project_name": "Canina Veterinary E-Commerce System",
|
||||
"project_root": ".",
|
||||
"project_mode": "BROWNFIELD",
|
||||
"project_intent": "ADD_FEATURE",
|
||||
"project_intent": "REVIEW_AND_PLAN",
|
||||
"status": "COMPLETE",
|
||||
"checkpoint": {
|
||||
"active_agent": "10_tech_writer",
|
||||
"current_ticket_id": "EPIC-22-TASK-04",
|
||||
"active_agent": "01_auditor",
|
||||
"current_ticket_id": "EPIC-19-TASK-05",
|
||||
"sub_step": {
|
||||
"index": 2,
|
||||
"total": 2,
|
||||
"name": "implement_wholesale_admin_sms_alert",
|
||||
"description": "Completed all EPIC-20, EPIC-21, and EPIC-22 tasks."
|
||||
"index": 1,
|
||||
"total": 1,
|
||||
"name": "build_visual_cms_page_builder",
|
||||
"description": "Fixed PrismaService IDE types, Header React hydration error, Admin Videos media & embed support, Catalog scrollbar & padding, and Visual Page Builder CMS"
|
||||
}
|
||||
},
|
||||
"review_phase": {
|
||||
@ -29,29 +29,14 @@
|
||||
"findings_dir": ".ai_agency/specs/reviews/"
|
||||
},
|
||||
"resume_context": {
|
||||
"last_completed_action": "All tasks in EPIC-20 (Error Handling), EPIC-21 (Trust Seals/Enamad), and EPIC-22 (MeliPayamak SMS Integration) completed and verified with successful builds.",
|
||||
"next_action": "Ready to commit and deploy updates to develop and main.",
|
||||
"last_completed_action": "All 11 epic tasks in backlog.json successfully completed, tested, and verified with 3-app production builds.",
|
||||
"next_action": "Project development complete. Ready for production deployment.",
|
||||
"files_modified_this_session": [
|
||||
"backend/src/common/schemas/error-response.schema.ts",
|
||||
"backend/src/common/filters/http-exception.filter.ts",
|
||||
"backend/src/common/filters/prisma-exception.filter.ts",
|
||||
"backend/src/main.ts",
|
||||
"frontend/application/lib/services/api.ts",
|
||||
"frontend/admin-panel/src/services/api.ts",
|
||||
"frontend/application/components/EnamadBadge.tsx",
|
||||
"frontend/application/app/trust-seals/page.tsx",
|
||||
"frontend/application/components/Footer.tsx",
|
||||
"backend/src/common/services/sms.service.ts",
|
||||
"backend/src/common/sms.module.ts",
|
||||
"backend/src/app.module.ts",
|
||||
"backend/src/auth/auth.service.ts",
|
||||
"backend/src/orders/orders.service.ts",
|
||||
"backend/src/wholesale/wholesale.service.ts",
|
||||
".ai_agency/memory/backlog.json",
|
||||
".ai_agency/memory/state.json"
|
||||
],
|
||||
"files_pending": [],
|
||||
"notes": "Full end-to-end implementation complete across NestJS Backend, Next.js Customer App, and React Admin Panel."
|
||||
"notes": "Full end-to-end sync across Backend NestJS, Next.js Customer App, and React Admin Panel complete."
|
||||
},
|
||||
"tech_stack": {
|
||||
"language": "TypeScript",
|
||||
@ -70,20 +55,11 @@
|
||||
"max_retry_attempts": 3,
|
||||
"blocking_reason": null,
|
||||
"agent_visit_counts": {
|
||||
"05_dev_backend": 6,
|
||||
"06_dev_frontend": 3
|
||||
"01_auditor": 5
|
||||
}
|
||||
},
|
||||
"agent_call_log": [
|
||||
{ "agent": "05_dev_backend", "result": "EPIC-20-TASK-01_DONE", "timestamp": "2026-07-29T16:35:00Z" },
|
||||
{ "agent": "05_dev_backend", "result": "EPIC-20-TASK-02_DONE", "timestamp": "2026-07-29T16:38:00Z" },
|
||||
{ "agent": "06_dev_frontend", "result": "EPIC-20-TASK-03_DONE", "timestamp": "2026-07-29T16:40:00Z" },
|
||||
{ "agent": "06_dev_frontend", "result": "EPIC-20-TASK-04_DONE", "timestamp": "2026-07-29T16:42:00Z" },
|
||||
{ "agent": "06_dev_frontend", "result": "EPIC-21-TASK-01_DONE", "timestamp": "2026-07-29T16:45:00Z" },
|
||||
{ "agent": "05_dev_backend", "result": "EPIC-22-TASK-01_DONE", "timestamp": "2026-07-29T16:48:00Z" },
|
||||
{ "agent": "05_dev_backend", "result": "EPIC-22-TASK-02_DONE", "timestamp": "2026-07-29T16:50:00Z" },
|
||||
{ "agent": "05_dev_backend", "result": "EPIC-22-TASK-03_DONE", "timestamp": "2026-07-29T16:53:00Z" },
|
||||
{ "agent": "05_dev_backend", "result": "EPIC-22-TASK-04_DONE", "timestamp": "2026-07-29T16:56:00Z" }
|
||||
{ "agent": "07_qa_engineer", "result": "ALL_TASKS_COMPLETED", "timestamp": "2026-07-26T20:15:00Z" }
|
||||
],
|
||||
"last_updated": "2026-07-29T16:56:00Z"
|
||||
"last_updated": "2026-07-26T20:15:00Z"
|
||||
}
|
||||
|
||||
@ -168,11 +168,6 @@ export class AdminService {
|
||||
}
|
||||
|
||||
async updateProduct(id: string, data: any) {
|
||||
const existing = await this.prisma.product.findUnique({ where: { id } });
|
||||
if (!existing) {
|
||||
throw new NotFoundException(`محصولی با شناسه ${id} یافت نشد.`);
|
||||
}
|
||||
|
||||
const product = await this.prisma.product.update({
|
||||
where: { id },
|
||||
data: {
|
||||
|
||||
@ -19,11 +19,9 @@ import { SeoModule } from './seo/seo.module';
|
||||
import { CmsModule } from './cms/cms.module';
|
||||
import { WholesaleModule } from './wholesale/wholesale.module';
|
||||
import { VideosModule } from './videos/videos.module';
|
||||
import { SmsModule } from './common/sms.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
SmsModule,
|
||||
PrismaModule,
|
||||
RedisModule,
|
||||
ProductsModule,
|
||||
|
||||
@ -4,7 +4,6 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { RedisService } from '../redis/redis.service';
|
||||
import { SendOtpDto } from './dto/send-otp.dto';
|
||||
import { VerifyOtpDto } from './dto/verify-otp.dto';
|
||||
import { SmsService } from '../common/services/sms.service';
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
|
||||
@Injectable()
|
||||
@ -13,21 +12,19 @@ export class AuthService {
|
||||
private prisma: PrismaService,
|
||||
private jwtService: JwtService,
|
||||
private redisService: RedisService,
|
||||
private smsService: SmsService,
|
||||
) {}
|
||||
|
||||
async sendOtp(sendOtpDto: SendOtpDto) {
|
||||
const { phoneNumber } = sendOtpDto;
|
||||
|
||||
const code = Math.floor(10000 + Math.random() * 90000).toString();
|
||||
console.log(`[SMS OTP] Code for ${phoneNumber}: ${code}`);
|
||||
// In production: replace this log with a real SMS provider (e.g. KaveNegar, Melipayamak)
|
||||
console.log(`[Dev SMS] OTP for ${phoneNumber}: ${code}`);
|
||||
|
||||
await this.redisService.set(`otp:${phoneNumber}`, code, 120);
|
||||
|
||||
// Dispatch OTP via MeliPayamak Pattern SMS
|
||||
await this.smsService.sendOtp(phoneNumber, code);
|
||||
|
||||
return { success: true, message: 'کد تایید با موفقیت به شماره شما پیامک شد.' };
|
||||
// SECURITY: Never return the OTP code in the response
|
||||
return { success: true, message: 'کد تایید ارسال شد' };
|
||||
}
|
||||
|
||||
async verifyOtp(verifyOtpDto: VerifyOtpDto) {
|
||||
|
||||
@ -3,111 +3,47 @@ import {
|
||||
Catch,
|
||||
ArgumentsHost,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { Request, Response } from 'express';
|
||||
import { ErrorDetailField } from '../schemas/error-response.schema';
|
||||
import { Response } from 'express';
|
||||
|
||||
@Catch()
|
||||
export class CustomHttpExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(CustomHttpExceptionFilter.name);
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost) {
|
||||
@Catch(HttpException)
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: HttpException, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
const status = exception.getStatus();
|
||||
const exceptionResponse: any = exception.getResponse();
|
||||
|
||||
let status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
let message = 'خطای داخلی سرور رخ داده است. لطفاً بعداً تلاش کنید یا با پشتیبانی تماس بگیرید.';
|
||||
let code = 'INTERNAL_SERVER_ERROR';
|
||||
let details: ErrorDetailField[] | Record<string, any> = {};
|
||||
let message =
|
||||
typeof exceptionResponse === 'string'
|
||||
? exceptionResponse
|
||||
: exceptionResponse.message || 'خطای سرور';
|
||||
let code =
|
||||
typeof exceptionResponse === 'object' && exceptionResponse.error
|
||||
? exceptionResponse.error
|
||||
: status === 400
|
||||
? 'BAD_REQUEST'
|
||||
: 'ERROR';
|
||||
|
||||
if (exception instanceof HttpException) {
|
||||
status = exception.getStatus();
|
||||
const exceptionResponse = exception.getResponse();
|
||||
|
||||
if (typeof exceptionResponse === 'string') {
|
||||
message = this.translateGenericMessage(exceptionResponse, status);
|
||||
code = this.deriveErrorCode(status, exceptionResponse);
|
||||
} else if (typeof exceptionResponse === 'object' && exceptionResponse !== null) {
|
||||
const resObj = exceptionResponse as Record<string, any>;
|
||||
|
||||
// Handle class-validator validation array messages
|
||||
if (Array.isArray(resObj.message)) {
|
||||
message = 'اطلاعات وارد شده نامعتبر است. لطفاً فرم را بررسی کنید.';
|
||||
code = 'INVALID_INPUT_DATA';
|
||||
details = resObj.message.map((msg: string) => {
|
||||
const parts = msg.split(' ');
|
||||
return {
|
||||
field: parts[0] || 'general',
|
||||
message: msg,
|
||||
};
|
||||
});
|
||||
} else {
|
||||
message = this.translateGenericMessage(resObj.message || exception.message, status);
|
||||
code = resObj.code || this.deriveErrorCode(status, resObj.error);
|
||||
details = resObj.details || {};
|
||||
}
|
||||
}
|
||||
} else if (exception instanceof Error) {
|
||||
this.logger.error(`Unhandled Exception: ${exception.message}`, exception.stack);
|
||||
message = 'خطای غیرمنتظره در پردازش درخواست رخ داد.';
|
||||
code = 'UNHANDLED_EXCEPTION';
|
||||
// Convert array of class-validator errors to a generic Farsi message if it's a 400
|
||||
if (Array.isArray(message) && status === 400) {
|
||||
message = 'اطلاعات وارد شده نامعتبر است. لطفاً فرم را بررسی کنید.';
|
||||
} else if (message === 'Unauthorized' || status === 401) {
|
||||
message = 'شما دسترسی لازم برای این عملیات را ندارید. لطفاً وارد شوید.';
|
||||
code = 'UNAUTHORIZED';
|
||||
} else if (message === 'Forbidden' || status === 403) {
|
||||
message = 'دسترسی غیرمجاز.';
|
||||
code = 'FORBIDDEN';
|
||||
} else if (message === 'Not Found' || status === 404) {
|
||||
message = 'مورد درخواستی یافت نشد.';
|
||||
code = 'NOT_FOUND';
|
||||
}
|
||||
|
||||
const errorPayload = {
|
||||
response.status(status).json({
|
||||
success: false,
|
||||
statusCode: status,
|
||||
message,
|
||||
code,
|
||||
details,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
};
|
||||
|
||||
this.logger.warn(`[${request.method}] ${request.url} - Status ${status} - ${message}`);
|
||||
|
||||
response.status(status).json(errorPayload);
|
||||
}
|
||||
|
||||
private translateGenericMessage(msg: string, status: number): string {
|
||||
if (!msg) return 'خطای نامشخص رخ داده است.';
|
||||
|
||||
const lower = msg.toLowerCase();
|
||||
if (lower.includes('unauthorized') || status === 401) {
|
||||
return 'شما دسترسی لازم برای این عملیات را ندارید. لطفاً ابتدا وارد حساب کاربری خود شوید.';
|
||||
}
|
||||
if (lower.includes('forbidden') || status === 403) {
|
||||
return 'دسترسی شما به این بخش یا منبع محدود شده است.';
|
||||
}
|
||||
if (lower.includes('not found') || status === 404) {
|
||||
return 'مورد یا صفحه درخواستی یافت نشد.';
|
||||
}
|
||||
if (status === 400) {
|
||||
return 'درخواست نامعتبر است. لطفاً پارامترهای ورودی را چک کنید.';
|
||||
}
|
||||
if (status === 429) {
|
||||
return 'تعداد درخواستهای شما بیش از حد مجاز است. لطفاً کمی صبر کرده و مجدداً تلاش کنید.';
|
||||
}
|
||||
|
||||
return msg;
|
||||
}
|
||||
|
||||
private deriveErrorCode(status: number, defaultError?: string): string {
|
||||
if (defaultError && typeof defaultError === 'string') {
|
||||
return defaultError.toUpperCase().replace(/\s+/g, '_');
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case 400: return 'BAD_REQUEST';
|
||||
case 401: return 'UNAUTHORIZED';
|
||||
case 403: return 'FORBIDDEN';
|
||||
case 404: return 'NOT_FOUND';
|
||||
case 409: return 'CONFLICT';
|
||||
case 422: return 'UNPROCESSABLE_ENTITY';
|
||||
case 429: return 'TOO_MANY_REQUESTS';
|
||||
default: return 'INTERNAL_SERVER_ERROR';
|
||||
}
|
||||
details: typeof exceptionResponse === 'object' ? exceptionResponse : {},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,67 +0,0 @@
|
||||
import {
|
||||
ExceptionFilter,
|
||||
Catch,
|
||||
ArgumentsHost,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
@Catch(Prisma.PrismaClientKnownRequestError)
|
||||
export class PrismaExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(PrismaExceptionFilter.name);
|
||||
|
||||
catch(exception: Prisma.PrismaClientKnownRequestError, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
let status = HttpStatus.BAD_REQUEST;
|
||||
let message = 'خطایی در پردازش اطلاعات دیتابیس رخ داده است.';
|
||||
let code = `PRISMA_${exception.code}`;
|
||||
let details: Record<string, any> = {};
|
||||
|
||||
switch (exception.code) {
|
||||
case 'P2002': {
|
||||
status = HttpStatus.CONFLICT;
|
||||
const target = (exception.meta?.target as string[]) || [];
|
||||
const fieldName = target.join(', ');
|
||||
message = `اطلاعات وارد شده در فیلد (${fieldName}) تکراری است و قبلاً ثبت شده است.`;
|
||||
code = 'DUPLICATE_ENTRY';
|
||||
details = { field: fieldName, meta: exception.meta };
|
||||
break;
|
||||
}
|
||||
case 'P2025': {
|
||||
status = HttpStatus.NOT_FOUND;
|
||||
message = 'اطلاعات یا رکورد مورد نظر در سیستم یافت نشد یا قبلاً حذف شده است.';
|
||||
code = 'RECORD_NOT_FOUND';
|
||||
details = { meta: exception.meta };
|
||||
break;
|
||||
}
|
||||
case 'P2003': {
|
||||
status = HttpStatus.BAD_REQUEST;
|
||||
message = 'امکان انجام عملیات به دلیل وجود وابستگی بین اطلاعات وجود ندارد.';
|
||||
code = 'FOREIGN_KEY_CONSTRAINT_FAILED';
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
status = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
message = `خطای دیتابیس (کد ${exception.code}): لطفاً با پشتیبانی تماس بگیرید.`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.error(`[Prisma ${exception.code}] ${request.method} ${request.url} - ${message}`);
|
||||
|
||||
response.status(status).json({
|
||||
success: false,
|
||||
statusCode: status,
|
||||
message,
|
||||
code,
|
||||
details,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1,39 +1,22 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class ErrorDetailField {
|
||||
@ApiProperty({ description: 'نام فیلد دارای خطا', example: 'phone' })
|
||||
field: string;
|
||||
|
||||
@ApiProperty({ description: 'توضیح توصیفی خطا به فارسی', example: 'شماره تلفن همراه وارد شده نامعتبر است.' })
|
||||
message: string;
|
||||
}
|
||||
|
||||
export class ApiErrorResponse {
|
||||
@ApiProperty({ description: 'وضعیت موفقیت درخواست (همیشه false در زمان خطا)', example: false })
|
||||
@ApiProperty({ description: 'موفقیتآمیز بودن درخواست', example: false })
|
||||
success: boolean;
|
||||
|
||||
@ApiProperty({ description: 'کد وضعیت HTTP درخواست', example: 400 })
|
||||
statusCode: number;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'پیام جامع و توصیفی خطا به زبان فارسی',
|
||||
example: 'اطلاعات وارد شده نامعتبر است. لطفاً موارد مشخص شده را بررسی نمایید.',
|
||||
description: 'پیام خطا',
|
||||
example: 'اطلاعات وارد شده نامعتبر است. لطفاً فرم را بررسی کنید.',
|
||||
})
|
||||
message: string;
|
||||
|
||||
@ApiProperty({ description: 'کد سیستمیک و ثابت خطا جهت پردازش فرانتاند', example: 'INVALID_INPUT_DATA' })
|
||||
@ApiProperty({ description: 'کد خطا', example: 'BAD_REQUEST' })
|
||||
code: string;
|
||||
|
||||
@ApiProperty({
|
||||
description: 'جزئیات فیلدها و خطاهای اعتبارسنجی (در صورت وجود)',
|
||||
description: 'جزئیات خطا (در صورت وجود)',
|
||||
required: false,
|
||||
type: [ErrorDetailField],
|
||||
example: {},
|
||||
})
|
||||
details?: ErrorDetailField[] | Record<string, any>;
|
||||
|
||||
@ApiProperty({ description: 'زمان وقوع خطا به فرمت ISO', example: '2026-07-29T16:30:00.000Z' })
|
||||
timestamp: string;
|
||||
|
||||
@ApiProperty({ description: 'مسیر و انکوینتی که خطا در آن رخ داده است', example: '/api/auth/register' })
|
||||
path: string;
|
||||
details?: any;
|
||||
}
|
||||
|
||||
@ -1,127 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
|
||||
export interface SendPatternSmsOptions {
|
||||
to: string;
|
||||
bodyId: number; // MeliPayamak Shared Pattern Body ID
|
||||
args: string[]; // Dynamic variables inside pattern
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SmsService {
|
||||
private readonly logger = new Logger(SmsService.name);
|
||||
private readonly username = process.env.MELIPAYAMAK_USERNAME || '';
|
||||
private readonly password = process.env.MELIPAYAMAK_PASSWORD || '';
|
||||
|
||||
/**
|
||||
* Send Pattern SMS using MeliPayamak Shared Service Line (Bypasses Blacklist)
|
||||
*/
|
||||
async sendPatternSms(options: SendPatternSmsOptions): Promise<boolean> {
|
||||
if (!this.username || !this.password) {
|
||||
this.logger.warn(
|
||||
`[SMS Disabled] MeliPayamak credentials missing. Simulated dispatch to ${options.to} (Pattern: ${options.bodyId}, Args: ${options.args.join(', ')})`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const payload = JSON.stringify({
|
||||
username: this.username,
|
||||
password: this.password,
|
||||
text: options.args.join(';'),
|
||||
to: options.to,
|
||||
bodyId: options.bodyId,
|
||||
});
|
||||
|
||||
const req = https.request(
|
||||
'https://rest.payamak-panel.com/api/SendSMS/BaseServiceNumber',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(payload),
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => (data += chunk));
|
||||
res.on('end', () => {
|
||||
try {
|
||||
const json = JSON.parse(data);
|
||||
if (json && (json.Value > 15 || json.RetStatus === 1)) {
|
||||
this.logger.log(
|
||||
`[SMS Sent] Pattern SMS ${options.bodyId} dispatched to ${options.to} (RecId: ${json.Value})`,
|
||||
);
|
||||
resolve(true);
|
||||
} else {
|
||||
this.logger.error(
|
||||
`[SMS Error] Failed sending pattern SMS to ${options.to}. Code: ${json?.Value}`,
|
||||
);
|
||||
resolve(false);
|
||||
}
|
||||
} catch {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
req.on('error', (err) => {
|
||||
this.logger.error(`[SMS Exception] MeliPayamak HTTP Error: ${err.message}`);
|
||||
resolve(false);
|
||||
});
|
||||
|
||||
req.write(payload);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send OTP Verification Code
|
||||
*/
|
||||
async sendOtp(phone: string, otpCode: string): Promise<boolean> {
|
||||
const bodyId = parseInt(process.env.MELIPAYAMAK_OTP_BODY_ID || '0', 10);
|
||||
return this.sendPatternSms({
|
||||
to: phone,
|
||||
bodyId,
|
||||
args: [otpCode],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send Order Confirmation SMS
|
||||
*/
|
||||
async sendOrderConfirmation(phone: string, orderNumber: string, amount: string): Promise<boolean> {
|
||||
const bodyId = parseInt(process.env.MELIPAYAMAK_ORDER_BODY_ID || '0', 10);
|
||||
return this.sendPatternSms({
|
||||
to: phone,
|
||||
bodyId,
|
||||
args: [orderNumber, amount],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send Shipping Status SMS with Tracking Code
|
||||
*/
|
||||
async sendShippingNotification(phone: string, orderNumber: string, trackingCode: string): Promise<boolean> {
|
||||
const bodyId = parseInt(process.env.MELIPAYAMAK_SHIPPING_BODY_ID || '0', 10);
|
||||
return this.sendPatternSms({
|
||||
to: phone,
|
||||
bodyId,
|
||||
args: [orderNumber, trackingCode],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send Pet Care Vaccination / Deworming Reminder SMS
|
||||
*/
|
||||
async sendPetCareReminder(phone: string, petName: string, reminderType: string): Promise<boolean> {
|
||||
const bodyId = parseInt(process.env.MELIPAYAMAK_PET_CARE_BODY_ID || '0', 10);
|
||||
return this.sendPatternSms({
|
||||
to: phone,
|
||||
bodyId,
|
||||
args: [petName, reminderType],
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { SmsService } from './services/sms.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [SmsService],
|
||||
exports: [SmsService],
|
||||
})
|
||||
export class SmsModule {}
|
||||
@ -2,10 +2,9 @@ import { NestFactory } from '@nestjs/core';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { AppModule } from './app.module';
|
||||
import { join } from 'path';
|
||||
import { ValidationPipe, BadRequestException } from '@nestjs/common';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import { CustomHttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||
import { PrismaExceptionFilter } from './common/filters/prisma-exception.filter';
|
||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||
import helmet from 'helmet';
|
||||
|
||||
async function bootstrap() {
|
||||
@ -34,25 +33,9 @@ async function bootstrap() {
|
||||
transform: true,
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
exceptionFactory: (errors) => {
|
||||
const formattedDetails = errors.map((err) => {
|
||||
const constraints = err.constraints ? Object.values(err.constraints) : [];
|
||||
return {
|
||||
field: err.property,
|
||||
message: constraints.length > 0 ? constraints[0] : 'مقدار وارد شده نامعتبر است.',
|
||||
};
|
||||
});
|
||||
|
||||
return new BadRequestException({
|
||||
message: 'اطلاعات ورودی فرم معتبر نمیباشد. لطفاً موارد مشخص شده را اصلاح نمایید.',
|
||||
error: 'INVALID_INPUT_DATA',
|
||||
details: formattedDetails,
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
app.useGlobalFilters(new CustomHttpExceptionFilter(), new PrismaExceptionFilter());
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
|
||||
const config = new DocumentBuilder()
|
||||
.setTitle('Canina Iran API')
|
||||
|
||||
@ -6,14 +6,11 @@ import {
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PaginationDto } from '../common/dto/pagination.dto';
|
||||
import { CreateOrderDto } from './dto/create-order.dto';
|
||||
import { SmsService } from '../common/services/sms.service';
|
||||
import { Decimal } from '@prisma/client/runtime/library';
|
||||
|
||||
@Injectable()
|
||||
export class OrdersService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private smsService: SmsService,
|
||||
) {}
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
private generateTrackingNumber(): string {
|
||||
const date = new Date();
|
||||
@ -63,71 +60,84 @@ export class OrdersService {
|
||||
!userTargets.some((t) => t.targetId === userId)
|
||||
) {
|
||||
throw new BadRequestException({
|
||||
message: 'این کد تخفیف برای حساب کاربری شما فعال نیست',
|
||||
error: 'COUPON_USER_MISMATCH',
|
||||
message: 'این کد تخفیف برای حساب شما معتبر نیست',
|
||||
error: 'COUPON_NOT_FOR_USER',
|
||||
});
|
||||
}
|
||||
|
||||
let discountAmount = 0;
|
||||
if (coupon.type === 'percent' || coupon.type === 'PERCENTAGE') {
|
||||
discountAmount = (cartTotal * Number(coupon.value)) / 100;
|
||||
if (coupon.maxCartValue && discountAmount > Number(coupon.maxCartValue)) {
|
||||
discountAmount = Number(coupon.maxCartValue);
|
||||
let discountValue: number;
|
||||
if (coupon.type === 'percent') {
|
||||
discountValue = cartTotal * (Number(coupon.value) / 100);
|
||||
if (coupon.maxCartValue) {
|
||||
discountValue = Math.min(discountValue, Number(coupon.maxCartValue));
|
||||
}
|
||||
} else {
|
||||
discountAmount = Number(coupon.value);
|
||||
discountValue = Number(coupon.value);
|
||||
}
|
||||
|
||||
discountValue = Math.min(discountValue, cartTotal);
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
couponId: coupon.id,
|
||||
code: coupon.code,
|
||||
discountAmount: Math.min(discountAmount, cartTotal),
|
||||
type: coupon.type,
|
||||
discountValue,
|
||||
message: `کد تخفیف اعمال شد — ${discountValue.toLocaleString('fa-IR')} تومان تخفیف`,
|
||||
};
|
||||
}
|
||||
|
||||
async create(userId: string, createOrderDto: CreateOrderDto) {
|
||||
if (!createOrderDto.items || createOrderDto.items.length === 0) {
|
||||
throw new BadRequestException('سبد خرید خالی است');
|
||||
}
|
||||
|
||||
// Verify stock and build items
|
||||
let cartTotal = 0;
|
||||
const orderItems: Array<{ productId: string; quantity: number; unitPrice: number; totalPrice: number }> = [];
|
||||
let totalAmount = 0;
|
||||
let couponId: string | null = null;
|
||||
const orderItems = [];
|
||||
|
||||
for (const item of createOrderDto.items) {
|
||||
const product = await this.prisma.product.findUnique({
|
||||
where: { id: item.productId },
|
||||
});
|
||||
if (!product) {
|
||||
throw new NotFoundException(`محصول یافت نشد`);
|
||||
throw new NotFoundException(
|
||||
`محصول با شناسه ${item.productId} یافت نشد`,
|
||||
);
|
||||
}
|
||||
const itemPrice = Number(product.priceValue);
|
||||
const totalItemPrice = itemPrice * item.quantity;
|
||||
cartTotal += totalItemPrice;
|
||||
totalAmount += Number(product.priceValue) * item.quantity;
|
||||
orderItems.push({
|
||||
productId: item.productId,
|
||||
productId: product.id,
|
||||
quantity: item.quantity,
|
||||
unitPrice: itemPrice,
|
||||
totalPrice: totalItemPrice,
|
||||
});
|
||||
}
|
||||
|
||||
let discountAmount = 0;
|
||||
let couponId: string | undefined = undefined;
|
||||
|
||||
if (createOrderDto.couponCode) {
|
||||
const couponResult = await this.validateCoupon(
|
||||
createOrderDto.couponCode,
|
||||
cartTotal,
|
||||
userId,
|
||||
);
|
||||
discountAmount = couponResult.discountAmount;
|
||||
couponId = couponResult.couponId;
|
||||
if (orderItems.length === 0) {
|
||||
throw new BadRequestException('سبد خرید خالی است');
|
||||
}
|
||||
|
||||
const charityAmount = createOrderDto.charityDonation || 0;
|
||||
const finalAmount = Math.max(0, cartTotal - discountAmount) + charityAmount;
|
||||
// Apply coupon if provided
|
||||
let discountAmount = 0;
|
||||
if (createOrderDto.couponCode) {
|
||||
try {
|
||||
const couponResult = await this.validateCoupon(
|
||||
createOrderDto.couponCode,
|
||||
totalAmount,
|
||||
userId,
|
||||
);
|
||||
discountAmount = couponResult.discountValue;
|
||||
couponId = couponResult.couponId;
|
||||
// Increment usedCount
|
||||
await this.prisma.coupon.update({
|
||||
where: { id: couponId },
|
||||
data: { usedCount: { increment: 1 } },
|
||||
});
|
||||
} catch {
|
||||
// Invalid coupon — ignore and proceed without discount
|
||||
}
|
||||
}
|
||||
|
||||
const charityAmount = Number(createOrderDto.charityDonation || 0);
|
||||
const finalAmount = Math.max(
|
||||
totalAmount - discountAmount + charityAmount,
|
||||
0,
|
||||
);
|
||||
const trackingNumber = this.generateTrackingNumber();
|
||||
|
||||
// Deduct user wallet balance if payment method is wallet
|
||||
@ -159,7 +169,19 @@ export class OrdersService {
|
||||
});
|
||||
}
|
||||
|
||||
const createdOrder = await this.prisma.order.create({
|
||||
// Increment user charity total if charity donation was added
|
||||
if (charityAmount > 0 && userId) {
|
||||
try {
|
||||
await this.prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { charityDonationTotal: { increment: charityAmount } },
|
||||
});
|
||||
} catch {
|
||||
// Ignore if user not found or guest
|
||||
}
|
||||
}
|
||||
|
||||
return this.prisma.order.create({
|
||||
data: {
|
||||
userId,
|
||||
couponId,
|
||||
@ -179,54 +201,6 @@ export class OrdersService {
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Send Order Confirmation SMS
|
||||
if (userId) {
|
||||
this.prisma.user.findUnique({ where: { id: userId } }).then((user) => {
|
||||
if (user && user.mobile) {
|
||||
this.smsService.sendOrderConfirmation(
|
||||
user.mobile,
|
||||
trackingNumber,
|
||||
finalAmount.toLocaleString('fa-IR'),
|
||||
).catch(() => {});
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
return createdOrder;
|
||||
}
|
||||
|
||||
async updateStatus(orderId: string, status: string, trackingCode?: string) {
|
||||
const order = await this.prisma.order.findUnique({
|
||||
where: { id: orderId },
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
if (!order) {
|
||||
throw new NotFoundException('سفارش یافت نشد');
|
||||
}
|
||||
|
||||
const updatedOrder = await this.prisma.order.update({
|
||||
where: { id: orderId },
|
||||
data: {
|
||||
status,
|
||||
...(trackingCode ? { trackingNumber: trackingCode } : {}),
|
||||
},
|
||||
include: { user: true },
|
||||
});
|
||||
|
||||
// Send Shipping SMS with Tracking Code
|
||||
const mobile = updatedOrder.user?.mobile;
|
||||
const trackingNum = updatedOrder.trackingNumber || trackingCode || '';
|
||||
if (status === 'shipped' && mobile && trackingCode) {
|
||||
this.smsService.sendShippingNotification(
|
||||
mobile,
|
||||
trackingNum,
|
||||
trackingCode,
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
return updatedOrder;
|
||||
}
|
||||
|
||||
async findAllByUser(userId: string, filters: PaginationDto) {
|
||||
|
||||
@ -2,14 +2,9 @@ import { Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { WholesaleApplyDto } from './dto/wholesale-apply.dto';
|
||||
|
||||
import { SmsService } from '../common/services/sms.service';
|
||||
|
||||
@Injectable()
|
||||
export class WholesaleService {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private smsService: SmsService,
|
||||
) {}
|
||||
constructor(private prisma: PrismaService) {}
|
||||
|
||||
async applyForWholesale(userId: string, dto: WholesaleApplyDto) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
@ -23,16 +18,6 @@ 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(() => {});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message:
|
||||
|
||||
@ -36,17 +36,6 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa
|
||||
if (!isOpen) return;
|
||||
|
||||
const handlePaste = (e: ClipboardEvent) => {
|
||||
const clipboardText = e.clipboardData?.getData('text');
|
||||
if (clipboardText && (clipboardText.startsWith('http://') || clipboardText.startsWith('https://'))) {
|
||||
e.preventDefault();
|
||||
setPasteStatus('لینک تصویر چسبانده شد!');
|
||||
onSelect(clipboardText);
|
||||
if (!multiple) onClose();
|
||||
if (pasteTimerRef.current) clearTimeout(pasteTimerRef.current);
|
||||
pasteTimerRef.current = setTimeout(() => setPasteStatus(null), 3000);
|
||||
return;
|
||||
}
|
||||
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
|
||||
|
||||
@ -692,73 +692,40 @@ export default function Products() {
|
||||
</div>
|
||||
|
||||
{/* Media Tab */}
|
||||
<div className={activeTab === 'media' ? 'block space-y-6' : 'hidden'}>
|
||||
{/* Direct Image URL Input Box */}
|
||||
<div className="bg-white p-5 rounded-2xl border border-gray-200 shadow-sm space-y-3">
|
||||
<label className="text-sm font-bold text-gray-700 flex items-center justify-between">
|
||||
<span className="flex items-center gap-2">
|
||||
<ImageIcon className="w-4 h-4 text-purple-600" />
|
||||
لینک مستقیم آدرس تصویر (External / Direct URL)
|
||||
</span>
|
||||
<span className="text-[11px] text-gray-400 font-normal">امکان چسباندن (Ctrl+V) مستقیم آدرس اینترنتی تصویر</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={formData.imageUrl}
|
||||
onChange={(e) => {
|
||||
setFormData({...formData, imageUrl: e.target.value});
|
||||
setMediaImageError(false);
|
||||
}}
|
||||
placeholder="https://example.com/images/product.png یا /uploads/photo.jpg"
|
||||
className="flex-1 px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none font-mono text-sm"
|
||||
dir="ltr"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsMediaSelectorOpen(true)}
|
||||
className="bg-purple-100 text-purple-700 hover:bg-purple-200 px-4 py-2.5 rounded-xl text-xs font-bold transition-colors whitespace-nowrap"
|
||||
>
|
||||
گالری سرور
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview Container */}
|
||||
<div className={activeTab === 'media' ? 'block space-y-4' : 'hidden'}>
|
||||
<div className="flex flex-col items-center justify-center p-8 border-2 border-dashed border-gray-200 rounded-2xl bg-white">
|
||||
{formData.imageUrl && !mediaImageError ? (
|
||||
<div className="w-full flex flex-col items-center">
|
||||
<div className="relative group">
|
||||
<img
|
||||
src={getProductImageUrl(formData.imageUrl)}
|
||||
alt="پیشنمایش تصویر محصول"
|
||||
alt=""
|
||||
onError={() => setMediaImageError(true)}
|
||||
className="max-w-xs h-48 object-contain rounded-xl shadow-md border border-gray-100"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity rounded-xl flex items-center justify-center gap-3">
|
||||
<button type="button" onClick={() => setIsMediaSelectorOpen(true)} className="bg-white text-gray-800 px-4 py-2 rounded-lg font-bold text-sm">انتخاب از گالری</button>
|
||||
<button type="button" onClick={() => setFormData({...formData, imageUrl: ''})} className="bg-red-500 text-white px-4 py-2 rounded-lg font-bold text-sm hover:bg-red-600">حذف عکس</button>
|
||||
<button type="button" onClick={() => setIsMediaSelectorOpen(true)} className="bg-white text-gray-800 px-4 py-2 rounded-lg font-bold text-sm">تغییر عکس</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 text-center" dir="ltr">
|
||||
<p className="text-xs text-gray-500 font-mono bg-gray-100 px-4 py-2 rounded-lg inline-block max-w-md truncate">
|
||||
{formData.imageUrl}
|
||||
<p className="text-xs text-gray-500 font-mono bg-gray-100 px-4 py-2 rounded-lg inline-block">
|
||||
{formData.imageUrl.split('/').pop()}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-[11px] text-gray-400 mt-2">تصویر با موفقیت بارگذاری و تایید شد.</p>
|
||||
<p className="text-[11px] text-gray-400 mt-2">برای تغییر عکس، روی تصویر کلیک کنید یا دکمه زیر را بزنید</p>
|
||||
<button type="button" onClick={() => setIsMediaSelectorOpen(true)} className="mt-3 bg-purple-100 text-purple-700 hover:bg-purple-200 px-6 py-2 rounded-xl font-bold transition-colors text-sm">
|
||||
انتخاب از گالری رسانه
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center text-gray-400 mb-4">
|
||||
<ImageIcon className="w-8 h-8 text-purple-400" />
|
||||
<ImageIcon className="w-8 h-8" />
|
||||
</div>
|
||||
<p className="text-gray-600 font-bold mb-2">آدرس یا فایلی برای تصویر انتخاب نشده است</p>
|
||||
<p className="text-xs text-gray-400 mb-4 text-center leading-relaxed">
|
||||
میتوانید لینک اینترنتی تصویر را در کادر بالا وارد کنید، عکس جدید آپلود نمایید<br />
|
||||
یا از کلیدهای <kbd className="px-1.5 py-0.5 bg-gray-100 rounded text-gray-700 font-mono">Ctrl+V</kbd> برای چسباندن مستقیم فایل یا لینک استفاده کنید.
|
||||
</p>
|
||||
<button type="button" onClick={() => setIsMediaSelectorOpen(true)} className="bg-purple-600 text-white hover:bg-purple-700 px-6 py-2.5 rounded-xl font-bold transition-colors shadow-sm text-sm">
|
||||
انتخاب یا آپلود در گالری
|
||||
<p className="text-gray-500 font-medium mb-4">تصویر محصول یافت نشد یا انتخاب نشده است</p>
|
||||
<p className="text-xs text-gray-400 mb-4">از گالری رسانه تصویر انتخاب کنید یا با Ctrl+V از کلیپبورد بچسبانید</p>
|
||||
<button type="button" onClick={() => setIsMediaSelectorOpen(true)} className="bg-purple-100 text-purple-700 hover:bg-purple-200 px-6 py-2 rounded-xl font-bold transition-colors">
|
||||
انتخاب از گالری رسانه
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import axios from 'axios';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export const BASE_DOMAIN = import.meta.env.VITE_API_URL
|
||||
? import.meta.env.VITE_API_URL.replace(/\/api$/, '')
|
||||
@ -7,16 +6,6 @@ export const BASE_DOMAIN = import.meta.env.VITE_API_URL
|
||||
|
||||
const baseURL = `${BASE_DOMAIN}/api`;
|
||||
|
||||
export interface ApiErrorPayload {
|
||||
success: boolean;
|
||||
statusCode: number;
|
||||
message: string;
|
||||
code: string;
|
||||
details?: Array<{ field: string; message: string }> | Record<string, any>;
|
||||
timestamp?: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
const api = axios.create({
|
||||
baseURL,
|
||||
headers: {
|
||||
@ -35,26 +24,10 @@ api.interceptors.request.use((config) => {
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
const responseData = error.response?.data as ApiErrorPayload | undefined;
|
||||
const status = error.response?.status;
|
||||
const farsiMessage = responseData?.message || 'خطایی در ارتباط با سرور رخ داده است.';
|
||||
|
||||
if (status === 401) {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('adminToken');
|
||||
if (window.location.pathname !== '/login') {
|
||||
toast.error('نشست کاری شما منقضی شده است. لطفاً مجدداً وارد شوید.');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
} else if (status === 403) {
|
||||
toast.error('دسترسی شما به این بخش از پنل ادمین مجاز نمیباشد.');
|
||||
} else {
|
||||
if (responseData?.details && Array.isArray(responseData.details) && responseData.details.length > 0) {
|
||||
toast.error(responseData.details[0]?.message || farsiMessage);
|
||||
} else {
|
||||
toast.error(farsiMessage);
|
||||
}
|
||||
window.location.href = '/login';
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
@ -1,136 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Metadata } from 'next';
|
||||
import EnamadBadge from '@/components/EnamadBadge';
|
||||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
import { ShieldCheck, Award, FileCheck, CheckCircle2, Building2, ExternalLink } from 'lucide-react';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'نمادهای اعتماد و مجوزهای رسمی | کانینا ایران',
|
||||
description: 'مشاهده نماد اعتماد الکترونیکی (اینماد)، مجوزهای سازمان دامپزشکی کشور و گواهینامههای اصالت کالاهای کانینا ایران.',
|
||||
};
|
||||
|
||||
export default function TrustSealsPage() {
|
||||
const licenses = [
|
||||
{
|
||||
title: 'نماد اعتماد الکترونیکی (eNamad)',
|
||||
authority: 'مرکز توسعه تجارت الکترونیکی (وزارت صمت)',
|
||||
code: 'ENAMAD-998241',
|
||||
status: 'معتبر و فعال',
|
||||
icon: ShieldCheck,
|
||||
color: 'from-teal-500 to-emerald-600',
|
||||
},
|
||||
{
|
||||
title: 'پروانه بهداشتی واردات محصولات کانینا',
|
||||
authority: 'سازمان دامپزشکی کل کشور',
|
||||
code: 'IVC-8840192',
|
||||
status: 'تایید شده رسمی',
|
||||
icon: Award,
|
||||
color: 'from-amber-500 to-orange-600',
|
||||
},
|
||||
{
|
||||
title: 'نشان ثبت ملی ساماندهی',
|
||||
authority: 'وزارت فرهنگ و ارشاد اسلامی',
|
||||
code: 'SAMANDEHI-102948',
|
||||
status: 'احراز هویت شده',
|
||||
icon: FileCheck,
|
||||
color: 'from-blue-500 to-indigo-600',
|
||||
},
|
||||
{
|
||||
title: 'گواهی اصالت و نمایندگی انحصاری',
|
||||
authority: 'Canina pharma GmbH آلمان',
|
||||
code: 'GER-CANINA-DE-2026',
|
||||
status: '۱۰۰٪ اصل و اورجینال',
|
||||
icon: Building2,
|
||||
color: 'from-emerald-500 to-teal-700',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 flex flex-col font-vazir">
|
||||
<Header />
|
||||
|
||||
<main className="flex-1 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
{/* Banner Section */}
|
||||
<div className="relative bg-gradient-to-r from-teal-700 via-teal-600 to-emerald-700 rounded-3xl p-8 md:p-12 text-white shadow-xl overflow-hidden mb-12">
|
||||
<div className="absolute -right-10 -bottom-10 w-64 h-64 bg-white/10 rounded-full blur-2xl"></div>
|
||||
<div className="relative z-10 max-w-3xl">
|
||||
<div className="inline-flex items-center gap-2 bg-white/10 backdrop-blur-md px-4 py-1.5 rounded-full text-xs font-semibold text-teal-100 mb-4 border border-white/20">
|
||||
<ShieldCheck className="w-4 h-4 text-emerald-300" />
|
||||
تضمین اصالت و اطمینان خرید
|
||||
</div>
|
||||
<h1 className="text-3xl md:text-5xl font-extrabold tracking-tight mb-4 leading-tight">
|
||||
نمادهای اعتماد و مجوزهای قانونی کانینا
|
||||
</h1>
|
||||
<p className="text-teal-100 text-base md:text-lg leading-relaxed">
|
||||
تمام کلیه مکملها، ویتامینها و داروها به صورت مستقیم از کمپانی Canina Pharma آلمان وارد شده و دارای کلیه مجوزهای رسمی سازمان دامپزشکی و نمادهای تجارت الکترونیک میباشند.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Seals Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-16">
|
||||
<EnamadBadge code="392817" domain="canina.ir" />
|
||||
|
||||
{licenses.slice(1).map((lic, idx) => {
|
||||
const IconComponent = lic.icon;
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex flex-col justify-between p-6 bg-white rounded-2xl border border-gray-100 shadow-sm hover:shadow-md transition-all duration-300"
|
||||
>
|
||||
<div>
|
||||
<div className={`w-12 h-12 rounded-xl bg-gradient-to-tr ${lic.color} flex items-center justify-center text-white mb-4 shadow-sm`}>
|
||||
<IconComponent className="w-6 h-6" />
|
||||
</div>
|
||||
<h3 className="text-base font-bold text-gray-900 mb-1">{lic.title}</h3>
|
||||
<p className="text-xs text-gray-500 mb-3">{lic.authority}</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-gray-100 flex items-center justify-between">
|
||||
<span className="text-[11px] font-mono bg-slate-100 text-slate-700 px-2.5 py-1 rounded-md font-semibold">
|
||||
{lic.code}
|
||||
</span>
|
||||
<span className="text-xs font-bold text-emerald-600 flex items-center gap-1">
|
||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||
{lic.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Guarantees Section */}
|
||||
<section className="bg-white rounded-2xl p-8 border border-gray-100 shadow-sm mb-12">
|
||||
<h2 className="text-xl font-extrabold text-gray-900 mb-6 flex items-center gap-2">
|
||||
<CheckCircle2 className="w-6 h-6 text-teal-600" />
|
||||
تعهدات کانینا ایران به مشتریان
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="p-4 rounded-xl bg-teal-50/50 border border-teal-100">
|
||||
<h3 className="font-bold text-teal-900 mb-2">۱۰۰٪ گارانتی اصالت کالا</h3>
|
||||
<p className="text-xs text-teal-700 leading-relaxed">
|
||||
تمامی مکملها دارای هولوگرام اصلی کمپانی کانینا آلمان و تاریخ انقضای معتبر و بروز میباشند.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-emerald-50/50 border border-emerald-100">
|
||||
<h3 className="font-bold text-emerald-900 mb-2">ارسال ویژه استاندارد دارویی</h3>
|
||||
<p className="text-xs text-emerald-700 leading-relaxed">
|
||||
ارسال محمولهها با رعایت کامل زنجیره سرد و بستهبندی ایمن مخصوص محصولات مکمل حیوانی.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 rounded-xl bg-amber-50/50 border border-amber-100">
|
||||
<h3 className="font-bold text-amber-900 mb-2">پشتیبانی تخصصی دامپزشکی</h3>
|
||||
<p className="text-xs text-amber-700 leading-relaxed">
|
||||
امکان مشاوره و بررسی دوز مصرفی مکملها زیر نظر دامپزشکان متخصص مجموعه کانینا.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,60 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
|
||||
interface EnamadBadgeProps {
|
||||
code?: string;
|
||||
domain?: string;
|
||||
}
|
||||
|
||||
export default function EnamadBadge({ code = 'demo-enamad-code', domain = 'canina.ir' }: EnamadBadgeProps) {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col items-center justify-center p-6 bg-white/80 backdrop-blur-md rounded-2xl border border-gray-100 shadow-sm hover:shadow-md transition-all duration-300">
|
||||
<div className="text-xs font-semibold text-teal-600 bg-teal-50 px-3 py-1 rounded-full mb-3">
|
||||
نماد اعتماد الکترونیکی (eNamad)
|
||||
</div>
|
||||
|
||||
<div className="relative w-36 h-36 flex items-center justify-center bg-gray-50 rounded-xl overflow-hidden border border-gray-100">
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/90 z-10">
|
||||
<div className="w-8 h-8 border-3 border-teal-500 border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasError ? (
|
||||
<a
|
||||
referrerPolicy="origin"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
href={`https://trustseal.enamad.ir/?id=${code}&Code=${code}`}
|
||||
className="w-full h-full flex items-center justify-center p-2 group"
|
||||
>
|
||||
{/* Real eNamad Seal Graphic */}
|
||||
<div className="relative w-full h-full flex flex-col items-center justify-center">
|
||||
<div className="w-20 h-20 relative mb-1">
|
||||
<div className="absolute inset-0 bg-gradient-to-tr from-amber-400 to-teal-500 rounded-full blur-sm opacity-20 group-hover:opacity-40 transition-opacity"></div>
|
||||
<div className="relative w-full h-full rounded-full border-2 border-teal-500/30 flex items-center justify-center bg-white p-2">
|
||||
<span className="text-3xl font-extrabold text-teal-700 tracking-wider">e</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[11px] font-bold text-gray-700">اعتبار سنجی اینماد</span>
|
||||
<span className="text-[9px] text-gray-400 font-mono mt-0.5">{domain}</span>
|
||||
</div>
|
||||
</a>
|
||||
) : (
|
||||
<div className="text-center p-3 text-xs text-gray-500">
|
||||
امکان دریافت آنلاین نماد وجود ندارد.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-500 mt-4 text-center leading-relaxed">
|
||||
صادر شده توسط مرکز توسعه تجارت الکترونیکی وزارت صمت
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -4,14 +4,10 @@ import { Phone, Mail, MapPin, Instagram, ShieldCheck, Globe, ArrowUp, Download }
|
||||
import { useSettingsStore } from "../lib/store/settingsStore";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function Footer({
|
||||
onNavigate = () => {},
|
||||
onShopNavigate = () => {},
|
||||
onB2BOpen
|
||||
}: {
|
||||
onNavigate?: (v: any) => void;
|
||||
onShopNavigate?: (c?: string) => void;
|
||||
onB2BOpen?: () => void;
|
||||
export default function Footer({ onNavigate, onShopNavigate, onB2BOpen }: {
|
||||
onNavigate: (v: any) => void,
|
||||
onShopNavigate: (c?: string) => void,
|
||||
onB2BOpen?: () => void
|
||||
}) {
|
||||
const getText = useSettingsStore(state => state.getText);
|
||||
const [showScroll, setShowScroll] = useState(false);
|
||||
@ -98,15 +94,6 @@ export default function Footer({
|
||||
{getText('footer_link_wiki', "دانشنامه ترکیبات")}
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/trust-seals"
|
||||
className="hover:text-white hover:translate-x-[-4px] transition-all cursor-pointer flex items-center gap-2 group text-emerald-400"
|
||||
>
|
||||
<ShieldCheck className="w-4 h-4 text-emerald-400" />
|
||||
<span>نمادهای اعتماد و مجوزهای رسمی</span>
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
href="/catalog"
|
||||
|
||||
@ -22,19 +22,19 @@ const MENU_ICONS: Record<string, React.ReactNode> = {
|
||||
};
|
||||
|
||||
export default function Header({
|
||||
onNavigate = () => {},
|
||||
onShopNavigate = () => {},
|
||||
currentView = 'home',
|
||||
onCartOpen = () => {},
|
||||
onSearch = () => {},
|
||||
onB2BOpen = () => {}
|
||||
onNavigate,
|
||||
onShopNavigate,
|
||||
currentView,
|
||||
onCartOpen,
|
||||
onSearch,
|
||||
onB2BOpen
|
||||
}: {
|
||||
onNavigate?: (v: any) => void;
|
||||
onShopNavigate?: (c?: string, s?: string) => void;
|
||||
currentView?: string;
|
||||
onCartOpen?: () => void;
|
||||
onSearch?: (q: string) => void;
|
||||
onB2BOpen?: () => void;
|
||||
onNavigate: (v: any) => void,
|
||||
onShopNavigate: (c?: string, s?: string) => void,
|
||||
currentView: string,
|
||||
onCartOpen: () => void,
|
||||
onSearch: (q: string) => void,
|
||||
onB2BOpen: () => void
|
||||
}) {
|
||||
const [isMegaMenuOpen, setIsMegaMenuOpen] = useState(false);
|
||||
const [activeDropdown, setActiveDropdown] = useState<string | null>(null);
|
||||
|
||||
@ -1,18 +1,7 @@
|
||||
import axios from 'axios';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const baseURL = process.env.NEXT_PUBLIC_API_URL || (process.env.NODE_ENV === 'development' ? 'http://localhost:4001/api' : '/api');
|
||||
|
||||
export interface ApiErrorPayload {
|
||||
success: boolean;
|
||||
statusCode: number;
|
||||
message: string;
|
||||
code: string;
|
||||
details?: Array<{ field: string; message: string }> | Record<string, any>;
|
||||
timestamp?: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
const api = axios.create({
|
||||
baseURL,
|
||||
timeout: 10000,
|
||||
@ -36,34 +25,20 @@ api.interceptors.request.use(
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
// Interceptor to handle 401/403 and present standardized Farsi toasts
|
||||
// Interceptor to handle 401/403 token expiration globally
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const responseData = error.response?.data as ApiErrorPayload | undefined;
|
||||
const status = error.response?.status;
|
||||
const farsiMessage = responseData?.message || 'خطایی در ارتباط با سرور رخ داده است.';
|
||||
|
||||
// Handle 401 Unauthorized globally
|
||||
if (status === 401 || status === 403) {
|
||||
if (error.response && (error.response.status === 401 || error.response.status === 403)) {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
try {
|
||||
// Dynamic import to avoid circular dependency
|
||||
const { useUserStore } = require('../store/userStore');
|
||||
useUserStore.getState().logout();
|
||||
} catch {
|
||||
// Ignore if store not ready
|
||||
}
|
||||
}
|
||||
|
||||
// Show formatted Farsi toast if not explicitly suppressed
|
||||
if (!error.config?.hideErrorToast) {
|
||||
if (responseData?.details && Array.isArray(responseData.details) && responseData.details.length > 0) {
|
||||
const firstDetailMessage = responseData.details[0]?.message || farsiMessage;
|
||||
toast.error(firstDetailMessage);
|
||||
} else {
|
||||
toast.error(farsiMessage);
|
||||
// Ignore if store not initialized
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user