Ship loyalty club and form builder so production pull gets the new screens.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-06 13:50:05 +03:30
co-authored by Cursor
parent 175f1fa440
commit 820c0780d8
21 changed files with 3658 additions and 893 deletions
@@ -0,0 +1,159 @@
-- CreateTable
CREATE TABLE IF NOT EXISTS "LoyaltySettings" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"welcomePoints" INTEGER NOT NULL DEFAULT 0,
"notifyOnAdjust" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LoyaltySettings_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE IF NOT EXISTS "LoyaltyLedger" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"personId" TEXT NOT NULL,
"delta" INTEGER NOT NULL,
"reason" TEXT,
"source" TEXT NOT NULL DEFAULT 'manual',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "LoyaltyLedger_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE IF NOT EXISTS "LoyaltyReward" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"pointsCost" INTEGER NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"position" INTEGER NOT NULL DEFAULT 0,
"color" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LoyaltyReward_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE IF NOT EXISTS "LoyaltyRedemption" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"personId" TEXT NOT NULL,
"rewardId" TEXT NOT NULL,
"pointsSpent" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "LoyaltyRedemption_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX IF NOT EXISTS "LoyaltySettings_organizationId_key" ON "LoyaltySettings"("organizationId");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "LoyaltyLedger_organizationId_createdAt_idx" ON "LoyaltyLedger"("organizationId", "createdAt");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "LoyaltyLedger_personId_createdAt_idx" ON "LoyaltyLedger"("personId", "createdAt");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "LoyaltyReward_organizationId_position_idx" ON "LoyaltyReward"("organizationId", "position");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "LoyaltyRedemption_organizationId_createdAt_idx" ON "LoyaltyRedemption"("organizationId", "createdAt");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "LoyaltyRedemption_personId_idx" ON "LoyaltyRedemption"("personId");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "LoyaltyRedemption_rewardId_idx" ON "LoyaltyRedemption"("rewardId");
-- AddForeignKey
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'LoyaltySettings_organizationId_fkey'
) THEN
ALTER TABLE "LoyaltySettings"
ADD CONSTRAINT "LoyaltySettings_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'LoyaltyLedger_organizationId_fkey'
) THEN
ALTER TABLE "LoyaltyLedger"
ADD CONSTRAINT "LoyaltyLedger_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'LoyaltyLedger_personId_fkey'
) THEN
ALTER TABLE "LoyaltyLedger"
ADD CONSTRAINT "LoyaltyLedger_personId_fkey"
FOREIGN KEY ("personId") REFERENCES "Person"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'LoyaltyReward_organizationId_fkey'
) THEN
ALTER TABLE "LoyaltyReward"
ADD CONSTRAINT "LoyaltyReward_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'LoyaltyRedemption_organizationId_fkey'
) THEN
ALTER TABLE "LoyaltyRedemption"
ADD CONSTRAINT "LoyaltyRedemption_organizationId_fkey"
FOREIGN KEY ("organizationId") REFERENCES "Organization"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'LoyaltyRedemption_personId_fkey'
) THEN
ALTER TABLE "LoyaltyRedemption"
ADD CONSTRAINT "LoyaltyRedemption_personId_fkey"
FOREIGN KEY ("personId") REFERENCES "Person"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'LoyaltyRedemption_rewardId_fkey'
) THEN
ALTER TABLE "LoyaltyRedemption"
ADD CONSTRAINT "LoyaltyRedemption_rewardId_fkey"
FOREIGN KEY ("rewardId") REFERENCES "LoyaltyReward"("id")
ON DELETE RESTRICT ON UPDATE CASCADE;
END IF;
END $$;
@@ -0,0 +1,31 @@
ALTER TABLE "LeadForm" ADD COLUMN IF NOT EXISTS "description" TEXT;
ALTER TABLE "LeadForm" ADD COLUMN IF NOT EXISTS "submitLabel" TEXT NOT NULL DEFAULT 'ارسال درخواست';
ALTER TABLE "LeadForm" ADD COLUMN IF NOT EXISTS "submitColor" TEXT NOT NULL DEFAULT '#14b8a6';
ALTER TABLE "LeadForm" ADD COLUMN IF NOT EXISTS "coverStorageKey" TEXT;
ALTER TABLE "LeadFormField" ADD COLUMN IF NOT EXISTS "placeholder" TEXT;
ALTER TABLE "LeadFormField" ADD COLUMN IF NOT EXISTS "options" JSONB;
CREATE TABLE IF NOT EXISTS "LeadFormSubmission" (
"id" TEXT NOT NULL,
"formId" TEXT NOT NULL,
"values" JSONB NOT NULL,
"personId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "LeadFormSubmission_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "LeadFormSubmission_formId_createdAt_idx" ON "LeadFormSubmission"("formId", "createdAt");
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'LeadFormSubmission_formId_fkey'
) THEN
ALTER TABLE "LeadFormSubmission"
ADD CONSTRAINT "LeadFormSubmission_formId_fkey"
FOREIGN KEY ("formId") REFERENCES "LeadForm"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
END IF;
END $$;
+93 -10
View File
@@ -161,7 +161,11 @@ model Organization {
portalOtps PortalOtp[]
projectBoards ProjectBoard[]
projectCards ProjectCard[]
loyaltyTiers LoyaltyTier[]
loyaltyTiers LoyaltyTier[]
loyaltySettings LoyaltySettings?
loyaltyLedgers LoyaltyLedger[]
loyaltyRewards LoyaltyReward[]
loyaltyRedemptions LoyaltyRedemption[]
automationRules AutomationRule[]
memoryItems MemoryItem[]
noteAiSuggestions NoteAiSuggestion[]
@@ -256,6 +260,8 @@ model Person {
loyaltyPoints Int @default(0)
loyaltyTierId String?
loyaltyTier LoyaltyTier? @relation(fields: [loyaltyTierId], references: [id], onDelete: SetNull)
loyaltyLedgers LoyaltyLedger[]
loyaltyRedemptions LoyaltyRedemption[]
deals Deal[]
activities Activity[]
smartNotes Note[]
@@ -581,6 +587,7 @@ model LeadForm {
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
name String
description String?
publicToken String @unique @default(cuid())
isActive Boolean @default(true)
createPerson Boolean @default(true)
@@ -591,7 +598,11 @@ model LeadForm {
redirectUrl String?
assigneeIds String[] @default([])
roundRobinIndex Int @default(0)
submitLabel String @default("ارسال درخواست")
submitColor String @default("#14b8a6")
coverStorageKey String?
fields LeadFormField[]
submissions LeadFormSubmission[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -599,20 +610,33 @@ model LeadForm {
}
model LeadFormField {
id String @id @default(cuid())
formId String
form LeadForm @relation(fields: [formId], references: [id], onDelete: Cascade)
key String
label String
fieldType String @default("text")
required Boolean @default(false)
mapTo String
position Int @default(0)
id String @id @default(cuid())
formId String
form LeadForm @relation(fields: [formId], references: [id], onDelete: Cascade)
key String
label String
fieldType String @default("text")
required Boolean @default(false)
mapTo String
position Int @default(0)
placeholder String?
options Json?
@@unique([formId, key])
@@index([formId, position])
}
model LeadFormSubmission {
id String @id @default(cuid())
formId String
form LeadForm @relation(fields: [formId], references: [id], onDelete: Cascade)
values Json
personId String?
createdAt DateTime @default(now())
@@index([formId, createdAt])
}
model Quote {
id String @id @default(cuid())
organizationId String
@@ -958,6 +982,65 @@ model LoyaltyTier {
@@index([organizationId, minPoints])
}
model LoyaltySettings {
id String @id @default(cuid())
organizationId String @unique
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
enabled Boolean @default(true)
welcomePoints Int @default(0)
notifyOnAdjust Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model LoyaltyLedger {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
personId String
person Person @relation(fields: [personId], references: [id], onDelete: Cascade)
delta Int
reason String?
source String @default("manual")
createdAt DateTime @default(now())
@@index([organizationId, createdAt])
@@index([personId, createdAt])
}
model LoyaltyReward {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
name String
description String?
pointsCost Int
isActive Boolean @default(true)
position Int @default(0)
color String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
redemptions LoyaltyRedemption[]
@@index([organizationId, position])
}
model LoyaltyRedemption {
id String @id @default(cuid())
organizationId String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
personId String
person Person @relation(fields: [personId], references: [id], onDelete: Cascade)
rewardId String
reward LoyaltyReward @relation(fields: [rewardId], references: [id], onDelete: Restrict)
pointsSpent Int
createdAt DateTime @default(now())
@@index([organizationId, createdAt])
@@index([personId])
@@index([rewardId])
}
model AutomationRule {
id String @id @default(cuid())
organizationId String
+3
View File
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { LoyaltyModule } from '../loyalty/loyalty.module';
import { CompaniesController } from './companies.controller';
import { CompaniesService } from './companies.service';
import { PeopleController } from './people.controller';
@@ -6,8 +7,10 @@ import { PeopleService } from './people.service';
import { ContactsExcelService } from './contacts-excel.service';
@Module({
imports: [LoyaltyModule],
controllers: [PeopleController, CompaniesController],
providers: [PeopleService, CompaniesService, ContactsExcelService],
exports: [PeopleService, CompaniesService],
})
export class ContactsModule {}
+8 -1
View File
@@ -24,6 +24,7 @@ import {
ownerWhere,
resolveOwnerId,
} from '../common/record-scope';
import { LoyaltyService } from '../loyalty/loyalty.service';
const personInclude = {
company: { select: { id: true, name: true } },
@@ -62,6 +63,7 @@ export class PeopleService {
constructor(
private readonly prisma: PrismaService,
private readonly companies: CompaniesService,
private readonly loyalty: LoyaltyService,
) {}
async list(
@@ -146,7 +148,12 @@ export class PeopleService {
data,
include: personInclude,
});
return this.toDto(row);
await this.loyalty.applyWelcomePoints(actor.organizationId, row.id);
const fresh = await this.prisma.person.findFirst({
where: { id: row.id },
include: personInclude,
});
return this.toDto(fresh ?? row);
}
async update(
+48 -5
View File
@@ -11,11 +11,12 @@ import {
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { LEAD_FORM_FIELD_TYPES } from '@mento/shared';
const FIELD_TYPES = ['text', 'email', 'phone', 'textarea'] as const;
const MAP_TO = [
'person.firstName',
'person.lastName',
'person.fullName',
'person.email',
'person.mobile',
'person.phone',
@@ -23,6 +24,7 @@ const MAP_TO = [
'deal.title',
'deal.notes',
'notes',
'custom',
] as const;
export class LeadFormFieldInputDto {
@@ -36,8 +38,8 @@ export class LeadFormFieldInputDto {
@MaxLength(120)
label!: string;
@IsIn(FIELD_TYPES)
fieldType!: (typeof FIELD_TYPES)[number];
@IsIn([...LEAD_FORM_FIELD_TYPES])
fieldType!: (typeof LEAD_FORM_FIELD_TYPES)[number];
@IsOptional()
@IsBoolean()
@@ -48,6 +50,17 @@ export class LeadFormFieldInputDto {
@IsOptional()
position?: number;
@IsOptional()
@IsString()
@MaxLength(160)
placeholder?: string | null;
@IsOptional()
@IsArray()
@IsString({ each: true })
@ArrayMaxSize(40)
options?: string[];
}
export class CreateLeadFormDto {
@@ -56,6 +69,11 @@ export class CreateLeadFormDto {
@MaxLength(120)
name!: string;
@IsOptional()
@IsString()
@MaxLength(400)
description?: string | null;
@IsOptional()
@IsBoolean()
createPerson?: boolean;
@@ -89,11 +107,21 @@ export class CreateLeadFormDto {
@ArrayMaxSize(50)
assigneeIds?: string[];
@IsOptional()
@IsString()
@MaxLength(80)
submitLabel?: string;
@IsOptional()
@IsString()
@MaxLength(20)
submitColor?: string;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => LeadFormFieldInputDto)
@ArrayMaxSize(30)
@ArrayMaxSize(40)
fields?: LeadFormFieldInputDto[];
}
@@ -104,6 +132,11 @@ export class UpdateLeadFormDto {
@MaxLength(120)
name?: string;
@IsOptional()
@IsString()
@MaxLength(400)
description?: string | null;
@IsOptional()
@IsBoolean()
isActive?: boolean;
@@ -141,11 +174,21 @@ export class UpdateLeadFormDto {
@ArrayMaxSize(50)
assigneeIds?: string[];
@IsOptional()
@IsString()
@MaxLength(80)
submitLabel?: string;
@IsOptional()
@IsString()
@MaxLength(20)
submitColor?: string;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => LeadFormFieldInputDto)
@ArrayMaxSize(30)
@ArrayMaxSize(40)
fields?: LeadFormFieldInputDto[];
}
@@ -6,19 +6,30 @@ import {
Param,
Patch,
Post,
Res,
StreamableFile,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { memoryStorage } from 'multer';
import type { Response } from 'express';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { Roles } from '../auth/roles.decorator';
import { RolesGuard } from '../auth/roles.guard';
import { CreateLeadFormDto, UpdateLeadFormDto } from './dto';
import { LeadFormsService } from './lead-forms.service';
import { LocalFileStorage } from '../storage/local-file.storage';
@Controller('lead-forms')
@UseGuards(JwtAuthGuard, RolesGuard)
export class LeadFormsController {
constructor(private readonly leadFormsService: LeadFormsService) {}
constructor(
private readonly leadFormsService: LeadFormsService,
private readonly files: LocalFileStorage,
) {}
@Get()
@Roles('owner', 'admin')
@@ -54,6 +65,51 @@ export class LeadFormsController {
return this.leadFormsService.update(user.organizationId, id, dto);
}
@Get(':id/submissions')
@Roles('owner', 'admin')
submissions(
@CurrentUser() user: { organizationId: string },
@Param('id') id: string,
) {
return this.leadFormsService.listSubmissions(user.organizationId, id);
}
@Post(':id/cover')
@Roles('owner', 'admin')
@UseInterceptors(
FileInterceptor('file', { storage: memoryStorage(), limits: { fileSize: 4_000_000 } }),
)
uploadCover(
@CurrentUser() user: { organizationId: string },
@Param('id') id: string,
@UploadedFile() file: Express.Multer.File,
) {
return this.leadFormsService.saveCover(user.organizationId, id, file);
}
@Delete(':id/cover')
@Roles('owner', 'admin')
removeCover(
@CurrentUser() user: { organizationId: string },
@Param('id') id: string,
) {
return this.leadFormsService.removeCover(user.organizationId, id);
}
@Get(':id/cover')
@Roles('owner', 'admin')
async cover(
@CurrentUser() user: { organizationId: string },
@Param('id') id: string,
@Res({ passthrough: true }) res: Response,
) {
const form = await this.leadFormsService.get(user.organizationId, id);
const row = await this.leadFormsService.getByToken(form.publicToken);
if (!row?.coverStorageKey) return { ok: false };
res.set({ 'Content-Type': 'image/jpeg' });
return new StreamableFile(this.files.openReadStream(row.coverStorageKey));
}
@Delete(':id')
@Roles('owner', 'admin')
async remove(
+192 -48
View File
@@ -13,6 +13,7 @@ import type {
} from '@mento/shared';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { LocalFileStorage } from '../storage/local-file.storage';
import {
emptyToNull,
normalizeEmail,
@@ -21,44 +22,40 @@ import {
const DEFAULT_FIELDS: LeadFormFieldInput[] = [
{
key: 'firstName',
label: 'نام',
key: 'fullName',
label: 'نام و نام خانوادگی',
fieldType: 'text',
required: true,
mapTo: 'person.firstName',
mapTo: 'person.fullName',
placeholder: 'مثلاً علی منتظر',
position: 0,
},
{
key: 'lastName',
label: 'نام خانوادگی',
fieldType: 'text',
required: true,
mapTo: 'person.lastName',
position: 1,
},
{
key: 'mobile',
label: 'موبایل',
label: 'شماره تماس',
fieldType: 'phone',
required: true,
mapTo: 'person.mobile',
placeholder: '۰۹۱۲۱۲۳۴۵۶۷',
position: 1,
},
{
key: 'subject',
label: 'موضوع درخواست',
fieldType: 'select',
required: true,
mapTo: 'deal.title',
options: ['مشاوره فروش', 'پشتیبانی', 'همکاری'],
position: 2,
},
{
key: 'email',
label: 'ایمیل',
fieldType: 'email',
required: false,
mapTo: 'person.email',
position: 3,
},
{
key: 'message',
label: 'پیام',
label: 'توضیحات',
fieldType: 'textarea',
required: false,
mapTo: 'deal.notes',
position: 4,
placeholder: 'شرح درخواست خود را بنویسید',
position: 3,
},
];
@@ -66,15 +63,24 @@ type FormRow = Prisma.LeadFormGetPayload<{ include: { fields: true } }>;
@Injectable()
export class LeadFormsService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly files: LocalFileStorage,
) {}
async list(organizationId: string): Promise<LeadFormDto[]> {
const rows = await this.prisma.leadForm.findMany({
where: { organizationId },
include: { fields: { orderBy: { position: 'asc' } } },
include: {
fields: { orderBy: { position: 'asc' } },
_count: { select: { submissions: true } },
},
orderBy: { updatedAt: 'desc' },
});
return rows.map((row) => this.toDto(row));
return rows.map((row) => ({
...this.toDto(row),
submissionCount: row._count.submissions,
}));
}
async get(organizationId: string, id: string): Promise<LeadFormDto> {
@@ -94,6 +100,7 @@ export class LeadFormsService {
data: {
organizationId,
name: dto.name.trim(),
description: emptyToNull(dto.description ?? null),
createPerson: dto.createPerson ?? true,
createDeal: dto.createDeal ?? true,
pipelineId: emptyToNull(dto.pipelineId ?? null),
@@ -103,15 +110,10 @@ export class LeadFormsService {
'پیام شما ثبت شد. به‌زودی با شما تماس می‌گیریم.',
redirectUrl: emptyToNull(dto.redirectUrl ?? null),
assigneeIds: dto.assigneeIds ?? [],
submitLabel: dto.submitLabel?.trim() || 'ارسال درخواست',
submitColor: dto.submitColor?.trim() || '#14b8a6',
fields: {
create: fields.map((f, index) => ({
key: f.key!,
label: f.label,
fieldType: f.fieldType,
required: f.required ?? false,
mapTo: f.mapTo,
position: f.position ?? index,
})),
create: fields.map((f, index) => fieldCreateData(f, index)),
},
},
include: { fields: { orderBy: { position: 'asc' } } },
@@ -134,6 +136,13 @@ export class LeadFormsService {
const data: Prisma.LeadFormUpdateInput = {};
if (dto.name !== undefined) data.name = dto.name.trim();
if ('description' in dto) data.description = emptyToNull(dto.description ?? null);
if (dto.submitLabel !== undefined) {
data.submitLabel = dto.submitLabel.trim() || 'ارسال درخواست';
}
if (dto.submitColor !== undefined) {
data.submitColor = dto.submitColor.trim() || '#14b8a6';
}
if (dto.isActive !== undefined) data.isActive = dto.isActive;
if (dto.createPerson !== undefined) data.createPerson = dto.createPerson;
if (dto.createDeal !== undefined) data.createDeal = dto.createDeal;
@@ -156,14 +165,7 @@ export class LeadFormsService {
data: {
...data,
fields: {
create: fields.map((f, index) => ({
key: f.key!,
label: f.label,
fieldType: f.fieldType,
required: f.required ?? false,
mapTo: f.mapTo,
position: f.position ?? index,
})),
create: fields.map((f, index) => fieldCreateData(f, index)),
},
},
});
@@ -188,13 +190,24 @@ export class LeadFormsService {
if (!row) throw new NotFoundException('فرم پیدا نشد یا غیرفعال است');
return {
name: row.name,
description: row.description,
thankYouMessage: row.thankYouMessage,
fields: row.fields.map((f) => ({
key: f.key,
label: f.label,
fieldType: f.fieldType,
required: f.required,
})),
submitLabel: row.submitLabel || 'ارسال درخواست',
submitColor: row.submitColor || '#14b8a6',
hasCover: Boolean(row.coverStorageKey),
coverPath: row.coverStorageKey
? `/api/public/forms/${row.publicToken}/cover`
: null,
fields: row.fields
.filter((f) => f.fieldType !== 'heading' || true)
.map((f) => ({
key: f.key,
label: f.label,
fieldType: f.fieldType,
required: f.required,
placeholder: f.placeholder,
options: asOptions(f.options),
})),
};
}
@@ -209,6 +222,10 @@ export class LeadFormsService {
if (!form) throw new NotFoundException('فرم پیدا نشد یا غیرفعال است');
const mapped = mapValues(form.fields, values);
const extras = collectExtras(form.fields, values);
if (extras) {
mapped.notes = [mapped.notes, extras].filter(Boolean).join('\n');
}
if (form.createPerson) {
if (!mapped.firstName?.trim() && !mapped.lastName?.trim()) {
@@ -239,6 +256,14 @@ export class LeadFormsService {
);
}
await this.prisma.leadFormSubmission.create({
data: {
formId: form.id,
values: values as Prisma.InputJsonValue,
personId,
},
});
if (form.createDeal) {
await this.createDeal(form, mapped, personId, actorId, ownerId);
} else if (personId && mapped.notes) {
@@ -410,10 +435,70 @@ export class LeadFormsService {
}
}
async listSubmissions(organizationId: string, id: string) {
await this.findInOrg(organizationId, id);
const rows = await this.prisma.leadFormSubmission.findMany({
where: { formId: id },
orderBy: { createdAt: 'desc' },
take: 200,
});
return rows.map((r) => ({
id: r.id,
values: asStringRecord(r.values),
personId: r.personId,
createdAt: r.createdAt.toISOString(),
}));
}
async saveCover(
organizationId: string,
id: string,
file: { buffer: Buffer; originalname: string },
) {
if (!file?.buffer) throw new BadRequestException('فایل کاور ارسال نشده');
const row = await this.findInOrg(organizationId, id);
const ext = (file.originalname.split('.').pop() || 'jpg')
.toLowerCase()
.replace(/[^a-z0-9]/g, '')
.slice(0, 6);
const key = `lead-forms/${id}/cover.${ext || 'jpg'}`;
await this.files.save(key, file.buffer);
if (row.coverStorageKey && row.coverStorageKey !== key) {
await this.files.remove(row.coverStorageKey);
}
await this.prisma.leadForm.update({
where: { id },
data: { coverStorageKey: key },
});
return this.get(organizationId, id);
}
async removeCover(organizationId: string, id: string) {
const row = await this.findInOrg(organizationId, id);
if (row.coverStorageKey) await this.files.remove(row.coverStorageKey);
await this.prisma.leadForm.update({
where: { id },
data: { coverStorageKey: null },
});
return this.get(organizationId, id);
}
coverKey(row: FormRow) {
return row.coverStorageKey;
}
async getByToken(token: string, onlyActive = false) {
return this.prisma.leadForm.findFirst({
where: { publicToken: token, ...(onlyActive ? { isActive: true } : {}) },
include: { fields: true },
});
}
private toDto(row: FormRow): LeadFormDto {
return {
id: row.id,
name: row.name,
description: row.description,
publicToken: row.publicToken,
isActive: row.isActive,
createPerson: row.createPerson,
@@ -423,6 +508,9 @@ export class LeadFormsService {
thankYouMessage: row.thankYouMessage,
redirectUrl: row.redirectUrl,
assigneeIds: row.assigneeIds,
submitLabel: row.submitLabel || 'ارسال درخواست',
submitColor: row.submitColor || '#14b8a6',
hasCover: Boolean(row.coverStorageKey),
fields: row.fields.map((f) => ({
id: f.id,
key: f.key,
@@ -431,6 +519,8 @@ export class LeadFormsService {
required: f.required,
mapTo: f.mapTo,
position: f.position,
placeholder: f.placeholder,
options: asOptions(f.options),
})),
publicPath: `/f/${row.publicToken}`,
createdAt: row.createdAt.toISOString(),
@@ -452,17 +542,30 @@ type MappedValues = {
};
function mapValues(
fields: Array<{ key: string; mapTo: string; required: boolean; label: string }>,
fields: Array<{
key: string;
mapTo: string;
required: boolean;
label: string;
fieldType?: string;
}>,
values: Record<string, string>,
): MappedValues {
const mapped: MappedValues = {};
for (const field of fields) {
if (field.fieldType === 'heading') continue;
const raw = (values?.[field.key] ?? '').toString().trim();
if (field.required && !raw) {
throw new BadRequestException(`فیلد «${field.label}» الزامی است`);
}
if (!raw) continue;
switch (field.mapTo) {
case 'person.fullName': {
const parts = raw.split(/\s+/).filter(Boolean);
mapped.firstName = parts[0] || raw;
mapped.lastName = parts.slice(1).join(' ') || '—';
break;
}
case 'person.firstName':
mapped.firstName = raw;
break;
@@ -515,6 +618,47 @@ function normalizeFields(fields: LeadFormFieldInput[]): LeadFormFieldInput[] {
});
}
function fieldCreateData(f: LeadFormFieldInput, index: number) {
return {
key: f.key!,
label: f.label,
fieldType: f.fieldType,
required: f.required ?? false,
mapTo: f.mapTo,
position: f.position ?? index,
placeholder: emptyToNull(f.placeholder ?? null),
options: f.options?.length ? f.options : undefined,
};
}
function asOptions(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.map((v) => String(v).trim()).filter(Boolean);
}
function asStringRecord(value: unknown): Record<string, string> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
if (v == null) continue;
out[k] = typeof v === 'string' ? v : JSON.stringify(v);
}
return out;
}
function collectExtras(
fields: Array<{ key: string; mapTo: string; label: string; fieldType: string }>,
values: Record<string, string>,
): string {
const lines: string[] = [];
for (const field of fields) {
if (field.fieldType === 'heading' || field.mapTo !== 'custom') continue;
const raw = (values?.[field.key] ?? '').toString().trim();
if (raw) lines.push(`${field.label}: ${raw}`);
}
return lines.join('\n');
}
function slugKey(label: string): string {
return label
.trim()
@@ -1,16 +1,41 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import {
Body,
Controller,
Get,
NotFoundException,
Param,
Post,
Res,
StreamableFile,
} from '@nestjs/common';
import type { Response } from 'express';
import { SubmitLeadFormDto } from './dto';
import { LeadFormsService } from './lead-forms.service';
import { LocalFileStorage } from '../storage/local-file.storage';
@Controller('public/forms')
export class PublicLeadFormsController {
constructor(private readonly leadFormsService: LeadFormsService) {}
constructor(
private readonly leadFormsService: LeadFormsService,
private readonly files: LocalFileStorage,
) {}
@Get(':token')
get(@Param('token') token: string) {
return this.leadFormsService.getPublic(token);
}
@Get(':token/cover')
async cover(
@Param('token') token: string,
@Res({ passthrough: true }) res: Response,
) {
const row = await this.leadFormsService.getByToken(token);
if (!row?.coverStorageKey) throw new NotFoundException('کاور پیدا نشد');
res.set({ 'Content-Type': 'image/jpeg', 'Cache-Control': 'public, max-age=3600' });
return new StreamableFile(this.files.openReadStream(row.coverStorageKey));
}
@Post(':token/submit')
submit(@Param('token') token: string, @Body() dto: SubmitLeadFormDto) {
return this.leadFormsService.submit(token, dto.values ?? {});
+111
View File
@@ -1,5 +1,8 @@
import { Type } from 'class-transformer';
import {
ArrayMinSize,
IsArray,
IsBoolean,
IsInt,
IsNumber,
IsOptional,
@@ -58,3 +61,111 @@ export class AdjustLoyaltyPointsDto {
@MaxLength(200)
reason?: string;
}
export class BulkAdjustLoyaltyDto {
@IsArray()
@ArrayMinSize(1)
@IsString({ each: true })
personIds!: string[];
@Type(() => Number)
@IsNumber()
delta!: number;
@IsOptional()
@IsString()
@MaxLength(200)
reason?: string;
}
export class LoyaltyMessageDto {
@IsArray()
@ArrayMinSize(1)
@IsString({ each: true })
personIds!: string[];
@IsString()
@MinLength(2)
@MaxLength(500)
text!: string;
}
export class UpdateLoyaltySettingsDto {
@IsOptional()
@IsBoolean()
enabled?: boolean;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
welcomePoints?: number;
@IsOptional()
@IsBoolean()
notifyOnAdjust?: boolean;
}
export class CreateLoyaltyRewardDto {
@IsString()
@MinLength(2)
@MaxLength(80)
name!: string;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
@MaxLength(240)
description?: string | null;
@Type(() => Number)
@IsInt()
@Min(1)
pointsCost!: number;
@IsOptional()
@IsBoolean()
isActive?: boolean;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
@MaxLength(32)
color?: string | null;
}
export class UpdateLoyaltyRewardDto {
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(80)
name?: string;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
@MaxLength(240)
description?: string | null;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
pointsCost?: number;
@IsOptional()
@IsBoolean()
isActive?: boolean;
@IsOptional()
@ValidateIf((_, v) => v !== '' && v != null)
@IsString()
@MaxLength(32)
color?: string | null;
}
export class RedeemLoyaltyRewardDto {
@IsString()
@MinLength(1)
personId!: string;
}
+112 -3
View File
@@ -7,8 +7,11 @@ import {
Patch,
Post,
Query,
Res,
StreamableFile,
UseGuards,
} from '@nestjs/common';
import type { Response } from 'express';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { Roles } from '../auth/roles.decorator';
@@ -16,7 +19,13 @@ import { RolesGuard } from '../auth/roles.guard';
import type { Actor } from '../common/record-scope';
import {
AdjustLoyaltyPointsDto,
BulkAdjustLoyaltyDto,
CreateLoyaltyRewardDto,
CreateLoyaltyTierDto,
LoyaltyMessageDto,
RedeemLoyaltyRewardDto,
UpdateLoyaltyRewardDto,
UpdateLoyaltySettingsDto,
UpdateLoyaltyTierDto,
} from './dto';
import { LoyaltyService } from './loyalty.service';
@@ -26,6 +35,25 @@ import { LoyaltyService } from './loyalty.service';
export class LoyaltyController {
constructor(private readonly loyalty: LoyaltyService) {}
@Get('stats')
stats(@CurrentUser() user: Actor) {
return this.loyalty.getStats(user);
}
@Get('settings')
settings(@CurrentUser() user: Actor) {
return this.loyalty.getSettings(user.organizationId);
}
@Patch('settings')
@Roles('owner', 'admin')
updateSettings(
@CurrentUser() user: Actor,
@Body() dto: UpdateLoyaltySettingsDto,
) {
return this.loyalty.updateSettings(user.organizationId, dto);
}
@Get('tiers')
listTiers(@CurrentUser() user: Actor) {
return this.loyalty.listTiers(user.organizationId);
@@ -57,9 +85,50 @@ export class LoyaltyController {
return { ok: true };
}
@Get('members/export')
async exportMembers(
@CurrentUser() user: Actor,
@Query('q') q: string | undefined,
@Query('tierId') tierId: string | undefined,
@Res({ passthrough: true }) res: Response,
) {
const buffer = await this.loyalty.exportMembers(user, q, tierId);
res.set({
'Content-Type':
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'Content-Disposition': 'attachment; filename="mento-loyalty-members.xlsx"',
});
return new StreamableFile(buffer);
}
@Get('members')
listMembers(@CurrentUser() user: Actor, @Query('q') q?: string) {
return this.loyalty.listMembers(user, q);
listMembers(
@CurrentUser() user: Actor,
@Query('q') q?: string,
@Query('tierId') tierId?: string,
) {
return this.loyalty.listMembers(user, q, tierId);
}
@Get('members/:personId/ledger')
listLedger(
@CurrentUser() user: Actor,
@Param('personId') personId: string,
) {
return this.loyalty.listLedger(user, personId);
}
@Post('members/bulk-adjust')
bulkAdjust(
@CurrentUser() user: Actor,
@Body() dto: BulkAdjustLoyaltyDto,
) {
return this.loyalty.bulkAdjust(user, dto.personIds, dto.delta, dto.reason);
}
@Post('members/message')
sendMessage(@CurrentUser() user: Actor, @Body() dto: LoyaltyMessageDto) {
return this.loyalty.sendMessage(user, dto.personIds, dto.text);
}
@Post('members/:personId/adjust')
@@ -68,6 +137,46 @@ export class LoyaltyController {
@Param('personId') personId: string,
@Body() dto: AdjustLoyaltyPointsDto,
) {
return this.loyalty.adjustPoints(user, personId, dto.delta);
return this.loyalty.adjustPoints(user, personId, dto.delta, dto.reason);
}
@Get('rewards')
listRewards(@CurrentUser() user: Actor) {
return this.loyalty.listRewards(user.organizationId);
}
@Post('rewards')
@Roles('owner', 'admin')
createReward(
@CurrentUser() user: Actor,
@Body() dto: CreateLoyaltyRewardDto,
) {
return this.loyalty.createReward(user.organizationId, dto);
}
@Patch('rewards/:id')
@Roles('owner', 'admin')
updateReward(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() dto: UpdateLoyaltyRewardDto,
) {
return this.loyalty.updateReward(user.organizationId, id, dto);
}
@Delete('rewards/:id')
@Roles('owner', 'admin')
async deleteReward(@CurrentUser() user: Actor, @Param('id') id: string) {
await this.loyalty.deleteReward(user.organizationId, id);
return { ok: true };
}
@Post('rewards/:id/redeem')
redeem(
@CurrentUser() user: Actor,
@Param('id') id: string,
@Body() dto: RedeemLoyaltyRewardDto,
) {
return this.loyalty.redeemReward(user, id, dto.personId);
}
}
+2 -1
View File
@@ -1,12 +1,13 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { SmsService } from '../common/sms.service';
import { LoyaltyController } from './loyalty.controller';
import { LoyaltyService } from './loyalty.service';
@Module({
imports: [AuthModule],
controllers: [LoyaltyController],
providers: [LoyaltyService],
providers: [LoyaltyService, SmsService],
exports: [LoyaltyService],
})
export class LoyaltyModule {}
+639 -54
View File
@@ -4,23 +4,72 @@ import {
NotFoundException,
} from '@nestjs/common';
import type {
CreateLoyaltyRewardRequest,
CreateLoyaltyTierRequest,
LoyaltyLedgerDto,
LoyaltyMemberDto,
LoyaltyMessageResult,
LoyaltyRewardDto,
LoyaltySettingsDto,
LoyaltyStatsDto,
LoyaltyTierDto,
UpdateLoyaltyRewardRequest,
UpdateLoyaltySettingsRequest,
UpdateLoyaltyTierRequest,
} from '@mento/shared';
import ExcelJS from 'exceljs';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { type Actor, ownerWhere } from '../common/record-scope';
import { SmsService } from '../common/sms.service';
const DEFAULT_TIERS = [
{ name: 'برنزی', minPoints: 0, color: '#b87333' },
{ name: 'نقره‌ای', minPoints: 500, color: '#9aa0a6' },
{ name: 'طلایی', minPoints: 2000, color: '#d4af37' },
{ name: 'برنزی', minPoints: 0, color: '#c9843a' },
{ name: 'نقره‌ای', minPoints: 500, color: '#8b95a5' },
{ name: 'طلایی', minPoints: 2000, color: '#d4a017' },
] as const;
const DEFAULT_REWARDS = [
{
name: 'تخفیف ویژه',
description: 'اعمال روی فاکتور بعدی',
pointsCost: 500,
color: '#14b8a6',
},
{
name: 'مشاوره رایگان',
description: 'یک جلسه مشاوره اختصاصی',
pointsCost: 1000,
color: '#8b5cf6',
},
{
name: 'هدیه باشگاه',
description: 'هدیه منتخب باشگاه مشتریان',
pointsCost: 2000,
color: '#eab308',
},
] as const;
const DAY_MS = 86_400_000;
@Injectable()
export class LoyaltyService {
constructor(private readonly prisma: PrismaService) {}
constructor(
private readonly prisma: PrismaService,
private readonly sms: SmsService,
) {}
async ensureDefaults(organizationId: string): Promise<void> {
await Promise.all([
this.ensureDefaultTiers(organizationId),
this.ensureDefaultRewards(organizationId),
this.ensureSettings(organizationId),
]);
const unassigned = await this.prisma.person.count({
where: { organizationId, loyaltyTierId: null },
});
if (unassigned > 0) await this.recomputeAllTiers(organizationId);
}
async ensureDefaultTiers(organizationId: string): Promise<void> {
const count = await this.prisma.loyaltyTier.count({
@@ -38,11 +87,167 @@ export class LoyaltyService {
});
}
async ensureDefaultRewards(organizationId: string): Promise<void> {
const count = await this.prisma.loyaltyReward.count({
where: { organizationId },
});
if (count > 0) return;
await this.prisma.loyaltyReward.createMany({
data: DEFAULT_REWARDS.map((r, position) => ({
organizationId,
name: r.name,
description: r.description,
pointsCost: r.pointsCost,
color: r.color,
position,
})),
});
}
async ensureSettings(organizationId: string) {
const existing = await this.prisma.loyaltySettings.findUnique({
where: { organizationId },
});
if (existing) return existing;
return this.prisma.loyaltySettings.create({
data: { organizationId },
});
}
async getSettings(organizationId: string): Promise<LoyaltySettingsDto> {
const row = await this.ensureSettings(organizationId);
return {
enabled: row.enabled,
welcomePoints: row.welcomePoints,
notifyOnAdjust: row.notifyOnAdjust,
};
}
async updateSettings(
organizationId: string,
dto: UpdateLoyaltySettingsRequest,
): Promise<LoyaltySettingsDto> {
await this.ensureSettings(organizationId);
const row = await this.prisma.loyaltySettings.update({
where: { organizationId },
data: {
...(dto.enabled !== undefined ? { enabled: dto.enabled } : {}),
...(dto.welcomePoints !== undefined
? { welcomePoints: Math.max(0, Math.floor(dto.welcomePoints)) }
: {}),
...(dto.notifyOnAdjust !== undefined
? { notifyOnAdjust: dto.notifyOnAdjust }
: {}),
},
});
return {
enabled: row.enabled,
welcomePoints: row.welcomePoints,
notifyOnAdjust: row.notifyOnAdjust,
};
}
async getStats(actor: Actor): Promise<LoyaltyStatsDto> {
await this.ensureDefaults(actor.organizationId);
const scope = {
organizationId: actor.organizationId,
...ownerWhere(actor),
};
const now = new Date();
const monthStart = startOfMonth(now);
const prevMonthStart = startOfMonth(addMonths(monthStart, -1));
const prevMonthEnd = monthStart;
const retentionFrom = new Date(now.getTime() - 90 * DAY_MS);
const prevRetentionEnd = monthStart;
const prevRetentionFrom = new Date(prevRetentionEnd.getTime() - 90 * DAY_MS);
const [
memberCount,
membersAtMonthStart,
pointsThis,
pointsPrev,
rewardsThis,
rewardsPrev,
people,
] = await Promise.all([
this.prisma.person.count({ where: scope }),
this.prisma.person.count({
where: { ...scope, createdAt: { lt: monthStart } },
}),
this.prisma.loyaltyLedger.aggregate({
where: {
organizationId: actor.organizationId,
delta: { gt: 0 },
createdAt: { gte: monthStart },
},
_sum: { delta: true },
}),
this.prisma.loyaltyLedger.aggregate({
where: {
organizationId: actor.organizationId,
delta: { gt: 0 },
createdAt: { gte: prevMonthStart, lt: prevMonthEnd },
},
_sum: { delta: true },
}),
this.prisma.loyaltyRedemption.count({
where: {
organizationId: actor.organizationId,
createdAt: { gte: monthStart },
},
}),
this.prisma.loyaltyRedemption.count({
where: {
organizationId: actor.organizationId,
createdAt: { gte: prevMonthStart, lt: prevMonthEnd },
},
}),
this.prisma.person.findMany({
where: scope,
select: { id: true, updatedAt: true },
}),
]);
const lastMap = await this.lastActivityMap(
actor.organizationId,
people.map((p) => p.id),
);
const activeNow = people.filter((p) => {
const at = lastMap.get(p.id) ?? p.updatedAt;
return at >= retentionFrom;
}).length;
const activePrev = people.filter((p) => {
const at = lastMap.get(p.id) ?? p.updatedAt;
return at >= prevRetentionFrom && at < prevRetentionEnd;
}).length;
const retentionRate = memberCount
? Math.round((activeNow / memberCount) * 100)
: 0;
const prevRetention = membersAtMonthStart
? Math.round((activePrev / membersAtMonthStart) * 100)
: 0;
const pointsIssued = pointsThis._sum.delta ?? 0;
const pointsPrevVal = pointsPrev._sum.delta ?? 0;
return {
memberCount,
memberDeltaPct: pctChange(memberCount, membersAtMonthStart),
pointsIssued,
pointsIssuedDeltaPct: pctChange(pointsIssued, pointsPrevVal),
rewardsUsed: rewardsThis,
rewardsUsedDeltaPct: pctChange(rewardsThis, rewardsPrev),
retentionRate,
retentionDeltaPct: pctChange(retentionRate, prevRetention),
};
}
async listTiers(organizationId: string): Promise<LoyaltyTierDto[]> {
await this.ensureDefaultTiers(organizationId);
await this.ensureDefaults(organizationId);
const rows = await this.prisma.loyaltyTier.findMany({
where: { organizationId },
orderBy: [{ minPoints: 'asc' }, { position: 'asc' }],
orderBy: [{ minPoints: 'desc' }, { position: 'asc' }],
include: { _count: { select: { people: true } } },
});
return rows.map((r) => ({
@@ -59,7 +264,7 @@ export class LoyaltyService {
organizationId: string,
dto: CreateLoyaltyTierRequest,
): Promise<LoyaltyTierDto> {
await this.ensureDefaultTiers(organizationId);
await this.ensureDefaults(organizationId);
const maxPos = await this.prisma.loyaltyTier.aggregate({
where: { organizationId },
_max: { position: true },
@@ -132,23 +337,11 @@ export class LoyaltyService {
async listMembers(
actor: Actor,
q?: string,
tierId?: string,
): Promise<LoyaltyMemberDto[]> {
await this.ensureDefaultTiers(actor.organizationId);
await this.ensureDefaults(actor.organizationId);
const rows = await this.prisma.person.findMany({
where: {
organizationId: actor.organizationId,
...ownerWhere(actor),
...(q?.trim()
? {
OR: [
{ firstName: { contains: q.trim(), mode: 'insensitive' } },
{ lastName: { contains: q.trim(), mode: 'insensitive' } },
{ mobile: { contains: q.trim() } },
{ email: { contains: q.trim(), mode: 'insensitive' } },
],
}
: {}),
},
where: this.memberWhere(actor, q, tierId),
include: {
company: { select: { id: true, name: true } },
loyaltyTier: {
@@ -156,16 +349,71 @@ export class LoyaltyService {
},
},
orderBy: [{ loyaltyPoints: 'desc' }, { lastName: 'asc' }],
take: 200,
take: 400,
});
const lastMap = await this.lastActivityMap(
actor.organizationId,
rows.map((r) => r.id),
);
return rows.map((r) => this.toMemberDto(r, lastMap.get(r.id) ?? r.updatedAt));
}
async exportMembers(
actor: Actor,
q?: string,
tierId?: string,
): Promise<Buffer> {
const members = await this.listMembers(actor, q, tierId);
const wb = new ExcelJS.Workbook();
const sheet = wb.addWorksheet('اعضای باشگاه');
sheet.columns = [
{ header: 'نام', key: 'fullName', width: 24 },
{ header: 'شرکت', key: 'company', width: 24 },
{ header: 'موبایل', key: 'mobile', width: 16 },
{ header: 'ایمیل', key: 'email', width: 28 },
{ header: 'سطح', key: 'tier', width: 14 },
{ header: 'امتیاز', key: 'points', width: 12 },
{ header: 'آخرین فعالیت', key: 'lastActivity', width: 22 },
];
for (const m of members) {
sheet.addRow({
fullName: m.fullName,
company: m.company?.name ?? '',
mobile: m.mobile ?? '',
email: m.email ?? '',
tier: m.loyaltyTier?.name ?? '',
points: m.loyaltyPoints,
lastActivity: m.lastActivityAt ?? '',
});
}
const buf = await wb.xlsx.writeBuffer();
return Buffer.from(buf);
}
async listLedger(
actor: Actor,
personId: string,
): Promise<LoyaltyLedgerDto[]> {
const person = await this.prisma.person.findFirst({
where: {
id: personId,
organizationId: actor.organizationId,
...ownerWhere(actor),
},
select: { id: true },
});
if (!person) throw new NotFoundException('شخص پیدا نشد');
const rows = await this.prisma.loyaltyLedger.findMany({
where: { personId, organizationId: actor.organizationId },
orderBy: { createdAt: 'desc' },
take: 80,
});
return rows.map((r) => ({
id: r.id,
fullName: `${r.firstName} ${r.lastName}`.trim(),
mobile: r.mobile,
email: r.email,
loyaltyPoints: r.loyaltyPoints,
loyaltyTier: r.loyaltyTier,
company: r.company,
delta: r.delta,
reason: r.reason,
source: r.source,
createdAt: r.createdAt.toISOString(),
}));
}
@@ -173,6 +421,8 @@ export class LoyaltyService {
actor: Actor,
personId: string,
delta: number,
reason?: string,
source = 'manual',
): Promise<LoyaltyMemberDto> {
if (!Number.isFinite(delta) || delta === 0) {
throw new BadRequestException('مقدار امتیاز نامعتبر است');
@@ -186,27 +436,79 @@ export class LoyaltyService {
});
if (!person) throw new NotFoundException('شخص پیدا نشد');
const next = Math.max(0, person.loyaltyPoints + Math.trunc(delta));
const tierId = await this.resolveTierId(actor.organizationId, next);
const updated = await this.prisma.person.update({
where: { id: personId },
data: { loyaltyPoints: next, loyaltyTierId: tierId },
include: {
company: { select: { id: true, name: true } },
loyaltyTier: {
select: { id: true, name: true, minPoints: true, color: true },
},
const updated = await this.applyDelta(
actor.organizationId,
personId,
Math.trunc(delta),
reason,
source,
);
const settings = await this.ensureSettings(actor.organizationId);
if (settings.notifyOnAdjust && updated.mobile) {
const sign = delta > 0 ? '+' : '';
void this.sms.sendSmsDetailed(
updated.mobile,
`باشگاه مشتریان: ${sign}${Math.trunc(delta)} امتیاز. موجودی: ${updated.loyaltyPoints}`,
);
}
return updated;
}
async bulkAdjust(
actor: Actor,
personIds: string[],
delta: number,
reason?: string,
): Promise<{ updated: number }> {
const unique = [...new Set(personIds.filter(Boolean))];
if (!unique.length) throw new BadRequestException('عضوی انتخاب نشده');
if (!Number.isFinite(delta) || delta === 0) {
throw new BadRequestException('مقدار امتیاز نامعتبر است');
}
let updated = 0;
for (const id of unique) {
try {
await this.adjustPoints(actor, id, delta, reason, 'bulk');
updated += 1;
} catch {
/* skip inaccessible */
}
}
return { updated };
}
async sendMessage(
actor: Actor,
personIds: string[],
text: string,
): Promise<LoyaltyMessageResult> {
const unique = [...new Set(personIds.filter(Boolean))];
if (!unique.length) throw new BadRequestException('عضوی انتخاب نشده');
const body = text.trim();
if (body.length < 2) throw new BadRequestException('متن پیام کوتاه است');
const people = await this.prisma.person.findMany({
where: {
id: { in: unique },
organizationId: actor.organizationId,
...ownerWhere(actor),
},
select: { id: true, mobile: true },
});
return {
id: updated.id,
fullName: `${updated.firstName} ${updated.lastName}`.trim(),
mobile: updated.mobile,
email: updated.email,
loyaltyPoints: updated.loyaltyPoints,
loyaltyTier: updated.loyaltyTier,
company: updated.company,
};
let sent = 0;
let skipped = 0;
let failed = 0;
for (const p of people) {
if (!p.mobile) {
skipped += 1;
continue;
}
const result = await this.sms.sendSmsDetailed(p.mobile, body);
if (result.ok) sent += 1;
else failed += 1;
}
skipped += Math.max(0, unique.length - people.length);
return { sent, skipped, failed };
}
/** برای اتوماسیون — بدون ACL مالکیت */
@@ -214,18 +516,38 @@ export class LoyaltyService {
organizationId: string,
personId: string,
points: number,
reason = 'اتوماسیون',
): Promise<void> {
if (!personId || !points) return;
const person = await this.prisma.person.findFirst({
where: { id: personId, organizationId },
});
if (!person) return;
const next = Math.max(0, person.loyaltyPoints + Math.trunc(points));
const tierId = await this.resolveTierId(organizationId, next);
await this.prisma.person.update({
where: { id: personId },
data: { loyaltyPoints: next, loyaltyTierId: tierId },
});
await this.applyDelta(organizationId, personId, Math.trunc(points), reason, 'automation');
}
async applyWelcomePoints(organizationId: string, personId: string) {
const settings = await this.ensureSettings(organizationId);
if (!settings.enabled || settings.welcomePoints <= 0) {
const person = await this.prisma.person.findFirst({
where: { id: personId, organizationId },
select: { loyaltyPoints: true },
});
if (!person) return;
const tierId = await this.resolveTierId(organizationId, person.loyaltyPoints);
await this.prisma.person.update({
where: { id: personId },
data: { loyaltyTierId: tierId },
});
return;
}
await this.applyDelta(
organizationId,
personId,
settings.welcomePoints,
'امتیاز خوش‌آمدگویی',
'welcome',
);
}
async resolveTierId(
@@ -240,6 +562,208 @@ export class LoyaltyService {
return tier?.id ?? null;
}
async listRewards(organizationId: string): Promise<LoyaltyRewardDto[]> {
await this.ensureDefaults(organizationId);
const rows = await this.prisma.loyaltyReward.findMany({
where: { organizationId },
orderBy: [{ position: 'asc' }, { pointsCost: 'asc' }],
include: { _count: { select: { redemptions: true } } },
});
return rows.map((r) => this.toRewardDto(r));
}
async createReward(
organizationId: string,
dto: CreateLoyaltyRewardRequest,
): Promise<LoyaltyRewardDto> {
await this.ensureDefaults(organizationId);
const maxPos = await this.prisma.loyaltyReward.aggregate({
where: { organizationId },
_max: { position: true },
});
const row = await this.prisma.loyaltyReward.create({
data: {
organizationId,
name: dto.name.trim(),
description: emptyToNull(dto.description),
pointsCost: Math.max(1, Math.floor(dto.pointsCost)),
isActive: dto.isActive ?? true,
color: emptyToNull(dto.color),
position: (maxPos._max.position ?? -1) + 1,
},
include: { _count: { select: { redemptions: true } } },
});
return this.toRewardDto(row);
}
async updateReward(
organizationId: string,
id: string,
dto: UpdateLoyaltyRewardRequest,
): Promise<LoyaltyRewardDto> {
const existing = await this.prisma.loyaltyReward.findFirst({
where: { id, organizationId },
});
if (!existing) throw new NotFoundException('پاداش پیدا نشد');
const row = await this.prisma.loyaltyReward.update({
where: { id },
data: {
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
...('description' in dto
? { description: emptyToNull(dto.description ?? null) }
: {}),
...(dto.pointsCost !== undefined
? { pointsCost: Math.max(1, Math.floor(dto.pointsCost)) }
: {}),
...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
...('color' in dto ? { color: emptyToNull(dto.color ?? null) } : {}),
},
include: { _count: { select: { redemptions: true } } },
});
return this.toRewardDto(row);
}
async deleteReward(organizationId: string, id: string): Promise<void> {
const existing = await this.prisma.loyaltyReward.findFirst({
where: { id, organizationId },
});
if (!existing) throw new NotFoundException('پاداش پیدا نشد');
const used = await this.prisma.loyaltyRedemption.count({
where: { rewardId: id },
});
if (used > 0) {
await this.prisma.loyaltyReward.update({
where: { id },
data: { isActive: false },
});
return;
}
await this.prisma.loyaltyReward.delete({ where: { id } });
}
async redeemReward(
actor: Actor,
rewardId: string,
personId: string,
): Promise<LoyaltyMemberDto> {
const reward = await this.prisma.loyaltyReward.findFirst({
where: { id: rewardId, organizationId: actor.organizationId },
});
if (!reward || !reward.isActive) {
throw new NotFoundException('پاداش پیدا نشد');
}
const person = await this.prisma.person.findFirst({
where: {
id: personId,
organizationId: actor.organizationId,
...ownerWhere(actor),
},
});
if (!person) throw new NotFoundException('شخص پیدا نشد');
if (person.loyaltyPoints < reward.pointsCost) {
throw new BadRequestException('امتیاز عضو برای این پاداش کافی نیست');
}
await this.prisma.loyaltyRedemption.create({
data: {
organizationId: actor.organizationId,
personId,
rewardId,
pointsSpent: reward.pointsCost,
},
});
return this.applyDelta(
actor.organizationId,
personId,
-reward.pointsCost,
`پاداش: ${reward.name}`,
'reward',
);
}
private async applyDelta(
organizationId: string,
personId: string,
delta: number,
reason: string | undefined,
source: string,
): Promise<LoyaltyMemberDto> {
const person = await this.prisma.person.findFirst({
where: { id: personId, organizationId },
});
if (!person) throw new NotFoundException('شخص پیدا نشد');
const next = Math.max(0, person.loyaltyPoints + delta);
const tierId = await this.resolveTierId(organizationId, next);
const [updated] = await this.prisma.$transaction([
this.prisma.person.update({
where: { id: personId },
data: { loyaltyPoints: next, loyaltyTierId: tierId },
include: {
company: { select: { id: true, name: true } },
loyaltyTier: {
select: { id: true, name: true, minPoints: true, color: true },
},
},
}),
this.prisma.loyaltyLedger.create({
data: {
organizationId,
personId,
delta,
reason: emptyToNull(reason),
source,
},
}),
]);
return this.toMemberDto(updated, updated.updatedAt);
}
private memberWhere(
actor: Actor,
q?: string,
tierId?: string,
): Prisma.PersonWhereInput {
const query = q?.trim();
return {
organizationId: actor.organizationId,
...ownerWhere(actor),
...(tierId ? { loyaltyTierId: tierId } : {}),
...(query
? {
OR: [
{ firstName: { contains: query, mode: 'insensitive' } },
{ lastName: { contains: query, mode: 'insensitive' } },
{ mobile: { contains: query } },
{ email: { contains: query, mode: 'insensitive' } },
{ company: { name: { contains: query, mode: 'insensitive' } } },
],
}
: {}),
};
}
private async lastActivityMap(organizationId: string, personIds: string[]) {
const map = new Map<string, Date>();
if (!personIds.length) return map;
const rows = await this.prisma.activity.groupBy({
by: ['personId'],
where: {
organizationId,
personId: { in: personIds },
},
_max: { updatedAt: true, completedAt: true, createdAt: true },
});
for (const r of rows) {
if (!r.personId) continue;
const dates = [r._max.updatedAt, r._max.completedAt, r._max.createdAt].filter(
(d): d is Date => !!d,
);
if (!dates.length) continue;
map.set(r.personId, new Date(Math.max(...dates.map((d) => d.getTime()))));
}
return map;
}
private async recomputeAllTiers(organizationId: string) {
const people = await this.prisma.person.findMany({
where: { organizationId },
@@ -257,6 +781,53 @@ export class LoyaltyService {
});
}
}
private toMemberDto(
r: {
id: string;
firstName: string;
lastName: string;
mobile: string | null;
email: string | null;
loyaltyPoints: number;
loyaltyTier?: LoyaltyMemberDto['loyaltyTier'];
company?: LoyaltyMemberDto['company'];
},
lastActivity: Date,
): LoyaltyMemberDto {
return {
id: r.id,
fullName: `${r.firstName} ${r.lastName}`.trim(),
mobile: r.mobile,
email: r.email,
loyaltyPoints: r.loyaltyPoints,
loyaltyTier: r.loyaltyTier ?? null,
company: r.company ?? null,
lastActivityAt: lastActivity.toISOString(),
};
}
private toRewardDto(r: {
id: string;
name: string;
description: string | null;
pointsCost: number;
isActive: boolean;
position: number;
color: string | null;
_count: { redemptions: number };
}): LoyaltyRewardDto {
return {
id: r.id,
name: r.name,
description: r.description,
pointsCost: r.pointsCost,
isActive: r.isActive,
position: r.position,
color: r.color,
redemptionCount: r._count.redemptions,
};
}
}
function emptyToNull(v?: string | null) {
@@ -264,3 +835,17 @@ function emptyToNull(v?: string | null) {
const t = v.trim();
return t ? t : null;
}
function startOfMonth(d: Date) {
return new Date(d.getFullYear(), d.getMonth(), 1, 0, 0, 0, 0);
}
function addMonths(d: Date, n: number) {
return new Date(d.getFullYear(), d.getMonth() + n, 1, 0, 0, 0, 0);
}
function pctChange(current: number, previous: number): number | null {
if (previous <= 0 && current <= 0) return 0;
if (previous <= 0) return current > 0 ? 100 : 0;
return Math.round(((current - previous) / previous) * 100);
}
+1 -1
View File
@@ -437,7 +437,7 @@ onBeforeUnmount(() => {
.fab :deep(.ico) {
display: block;
stroke-width: 2.2;
stroke-width: 1.55;
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.15));
}
+25 -3
View File
@@ -54,7 +54,10 @@ defineProps<{
| 'eye'
| 'truck'
| 'grip'
| 'apps-plus';
| 'apps-plus'
| 'crown'
| 'gift'
| 'medal';
size?: number;
}>();
</script>
@@ -315,8 +318,27 @@ defineProps<{
<template v-else-if="name === 'apps-plus'">
<rect x="3.2" y="3.2" width="7.4" height="7.4" rx="1.6" />
<rect x="13.4" y="3.2" width="7.4" height="7.4" rx="1.6" />
<rect x="3.2" y="13.4" width="7.4" height="7.4" rx="1.6" />
<path d="M17.1 13.6v6.8M13.7 17h6.8" />
<rect x="13.4" y="13.4" width="7.4" height="7.4" rx="1.6" />
<path d="M6.9 13.6v6.8M3.5 17h6.8" />
</template>
<template v-else-if="name === 'crown'">
<path d="M4 16.5 6.2 8.2 12 12.4 17.8 8.2 20 16.5H4Z" />
<path d="M5.2 18.6h13.6" />
<circle cx="6.2" cy="7.2" r="1.15" fill="currentColor" stroke="none" />
<circle cx="12" cy="5.6" r="1.15" fill="currentColor" stroke="none" />
<circle cx="17.8" cy="7.2" r="1.15" fill="currentColor" stroke="none" />
</template>
<template v-else-if="name === 'gift'">
<rect x="4" y="11" width="16" height="9" rx="1.4" />
<path d="M4 11V8.2A1.4 1.4 0 0 1 5.4 6.8h13.2A1.4 1.4 0 0 1 20 8.2V11" />
<path d="M12 6.8v13.2" />
<path d="M12 6.8c0-2.1-1.5-3.4-3.2-2.4C7.2 5.3 8.1 7.6 12 6.8Z" />
<path d="M12 6.8c0-2.1 1.5-3.4 3.2-2.4 1.6.9.7 3.2-3.2 2.4Z" />
</template>
<template v-else-if="name === 'medal'">
<circle cx="12" cy="9" r="5.2" />
<circle cx="12" cy="9" r="2.3" />
<path d="m9.1 13.6-1.4 6.2 4.3-2.3 4.3 2.3-1.4-6.2" />
</template>
</svg>
</template>
+6
View File
@@ -180,6 +180,12 @@ export const router = createRouter({
component: () => import('@/views/LeadFormsSettingsView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'settings/lead-forms/:id',
name: 'lead-form-builder',
component: () => import('@/views/LeadFormBuilderView.vue'),
meta: { roles: ['owner', 'admin'] as UserRole[] },
},
{
path: 'settings/team',
name: 'team-settings',
+652
View File
@@ -0,0 +1,652 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import UiIcon from '@/components/UiIcon.vue';
import type {
LeadFormDto,
LeadFormFieldDto,
LeadFormFieldInput,
LeadFormFieldType,
LeadFormMapTo,
LeadFormSubmissionDto,
OrgUserDto,
PipelineDto,
} from '@mento/shared';
type Tab = 'edit' | 'settings' | 'share' | 'responses';
type IconName =
| 'user' | 'chat' | 'mail' | 'phone' | 'list' | 'layers' | 'calendar'
| 'check' | 'chart' | 'document' | 'star' | 'megaphone' | 'building';
const route = useRoute();
const router = useRouter();
const id = computed(() => String(route.params.id || ''));
const loading = ref(true);
const saving = ref(false);
const error = ref('');
const ok = ref('');
const tab = ref<Tab>('edit');
const desktopPreviewOpen = ref(false);
const advancedOpen = ref(false);
const readyOpen = ref<string | null>('personal');
const editFieldKey = ref<string | null>(null);
const dragKey = ref<string | null>(null);
const form = ref<LeadFormDto | null>(null);
const name = ref('');
const description = ref('');
const fields = ref<LeadFormFieldDto[]>([]);
const submitLabel = ref('ارسال درخواست');
const submitColor = ref('#14b8a6');
const isActive = ref(true);
const createPerson = ref(true);
const createDeal = ref(true);
const pipelineId = ref('');
const source = ref('وب‌سایت');
const thankYouMessage = ref('');
const redirectUrl = ref('');
const assigneeIds = ref<string[]>([]);
const users = ref<OrgUserDto[]>([]);
const pipelines = ref<PipelineDto[]>([]);
const submissions = ref<LeadFormSubmissionDto[]>([]);
const coverInput = ref<HTMLInputElement | null>(null);
const toolbox: Array<{ type: LeadFormFieldType; label: string; icon: IconName }> = [
{ type: 'text', label: 'متن کوتاه', icon: 'user' },
{ type: 'textarea', label: 'متن بلند', icon: 'chat' },
{ type: 'email', label: 'ایمیل', icon: 'mail' },
{ type: 'phone', label: 'شماره تماس', icon: 'phone' },
{ type: 'checkbox', label: 'چندانتخابی', icon: 'list' },
{ type: 'select', label: 'لیست کشویی', icon: 'layers' },
{ type: 'date', label: 'تاریخ', icon: 'calendar' },
{ type: 'radio', label: 'دکمه رادیویی', icon: 'check' },
{ type: 'number', label: 'عددی', icon: 'chart' },
{ type: 'file', label: 'آپلود فایل', icon: 'document' },
{ type: 'rating', label: 'امتیازدهی', icon: 'star' },
{ type: 'heading', label: 'متن توضیحی', icon: 'megaphone' },
];
const typeLabel: Record<string, string> = Object.fromEntries(
toolbox.map((t) => [t.type, t.label]),
);
const readyGroups = [
{
key: 'personal', title: 'اطلاعات فردی',
items: [
{ label: 'نام و نام خانوادگی', fieldType: 'text' as const, mapTo: 'person.fullName' as const },
{ label: 'ایمیل', fieldType: 'email' as const, mapTo: 'person.email' as const },
{ label: 'موبایل', fieldType: 'phone' as const, mapTo: 'person.mobile' as const },
],
},
{
key: 'org', title: 'اطلاعات سازمانی',
items: [
{ label: 'نام شرکت', fieldType: 'text' as const, mapTo: 'custom' as const },
{ label: 'سمت', fieldType: 'text' as const, mapTo: 'person.position' as const },
],
},
{
key: 'job', title: 'اطلاعات شغلی',
items: [{ label: 'عنوان شغلی', fieldType: 'text' as const, mapTo: 'person.position' as const }],
},
{
key: 'market', title: 'سؤالات بازاریابی',
items: [{
label: 'از کجا آشنا شدید؟', fieldType: 'select' as const, mapTo: 'custom' as const,
options: ['سایت', 'اینستاگرام', 'معرفی'],
}],
},
];
const origin = computed(() => window.location.origin);
const publicUrl = computed(() => (form.value ? `${origin.value}${form.value.publicPath}` : ''));
const iframeSnippet = computed(
() => `<iframe src="${publicUrl.value}" width="100%" height="720" style="border:0;border-radius:16px" title="${name.value}"></iframe>`,
);
const qrUrl = computed(() =>
`https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=${encodeURIComponent(publicUrl.value)}`,
);
const coverSrc = computed(() =>
form.value?.hasCover ? `/api/public/forms/${form.value.publicToken}/cover` : '',
);
const editingField = computed(() => fields.value.find((f) => f.key === editFieldKey.value) ?? null);
function newKey(label: string) {
const base = label.replace(/\s+/g, '_').slice(0, 24) || 'field';
let key = `${base}_${Math.random().toString(36).slice(2, 6)}`;
while (fields.value.some((f) => f.key === key)) key += 'x';
return key;
}
function defaultMap(type: LeadFormFieldType): LeadFormMapTo {
if (type === 'email') return 'person.email';
if (type === 'phone') return 'person.mobile';
if (type === 'text') return 'person.fullName';
if (type === 'textarea') return 'deal.notes';
return 'custom';
}
function addField(type: LeadFormFieldType, preset?: Partial<LeadFormFieldInput>) {
const meta = toolbox.find((t) => t.type === type);
const label = preset?.label || meta?.label || 'فیلد جدید';
const needsOptions = type === 'select' || type === 'radio' || type === 'checkbox';
fields.value.push({
id: newKey(label),
key: newKey(label),
label,
fieldType: type,
required: preset?.required ?? type !== 'heading',
mapTo: (preset?.mapTo as LeadFormMapTo) || defaultMap(type),
position: fields.value.length,
placeholder: preset?.placeholder ?? '',
options: preset?.options ?? (needsOptions ? ['گزینه ۱', 'گزینه ۲'] : []),
});
}
function duplicateField(field: LeadFormFieldDto) {
addField(field.fieldType as LeadFormFieldType, {
label: `${field.label} (کپی)`,
mapTo: field.mapTo as LeadFormMapTo,
options: [...field.options],
placeholder: field.placeholder,
});
}
function removeField(key: string) {
fields.value = fields.value.filter((f) => f.key !== key);
if (editFieldKey.value === key) editFieldKey.value = null;
}
function onDragStart(key: string) { dragKey.value = key; }
function onDrop(targetKey: string) {
if (!dragKey.value || dragKey.value === targetKey) return;
const list = [...fields.value];
const from = list.findIndex((f) => f.key === dragKey.value);
const to = list.findIndex((f) => f.key === targetKey);
if (from < 0 || to < 0) return;
const [item] = list.splice(from, 1);
list.splice(to, 0, item!);
fields.value = list.map((f, i) => ({ ...f, position: i }));
dragKey.value = null;
}
function applyForm(row: LeadFormDto) {
form.value = row;
name.value = row.name;
description.value = row.description ?? '';
fields.value = row.fields;
submitLabel.value = row.submitLabel;
submitColor.value = row.submitColor;
isActive.value = row.isActive;
createPerson.value = row.createPerson;
createDeal.value = row.createDeal;
pipelineId.value = row.pipelineId ?? '';
source.value = row.source ?? 'وب‌سایت';
thankYouMessage.value = row.thankYouMessage ?? '';
redirectUrl.value = row.redirectUrl ?? '';
assigneeIds.value = row.assigneeIds;
}
async function load() {
loading.value = true;
error.value = '';
try {
const [fRes, uRes, pRes] = await Promise.all([
api.get<LeadFormDto>(`/lead-forms/${id.value}`),
api.get<OrgUserDto[]>('/users'),
api.get<PipelineDto[]>('/pipelines'),
]);
applyForm(fRes.data);
users.value = uRes.data;
pipelines.value = pRes.data;
} catch {
error.value = 'بارگذاری فرم ناموفق بود.';
} finally {
loading.value = false;
}
}
async function save() {
saving.value = true;
error.value = '';
ok.value = '';
try {
const { data } = await api.patch<LeadFormDto>(`/lead-forms/${id.value}`, {
name: name.value.trim(),
description: description.value.trim() || null,
isActive: isActive.value,
createPerson: createPerson.value,
createDeal: createDeal.value,
pipelineId: pipelineId.value || null,
source: source.value.trim() || null,
thankYouMessage: thankYouMessage.value.trim() || null,
redirectUrl: redirectUrl.value.trim() || null,
assigneeIds: assigneeIds.value,
submitLabel: submitLabel.value.trim() || 'ارسال درخواست',
submitColor: submitColor.value,
fields: fields.value.map((f, i) => ({
key: f.key, label: f.label, fieldType: f.fieldType, required: f.required,
mapTo: f.mapTo, position: i, placeholder: f.placeholder, options: f.options,
})),
});
applyForm(data);
ok.value = 'تغییرات ذخیره شد.';
} catch (e) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'ذخیره ناموفق بود');
} else error.value = 'ذخیره ناموفق بود';
} finally {
saving.value = false;
}
}
async function uploadCover(ev: Event) {
const file = (ev.target as HTMLInputElement).files?.[0];
if (!file) return;
const body = new FormData();
body.append('file', file);
try {
const { data } = await api.post<LeadFormDto>(`/lead-forms/${id.value}/cover`, body);
applyForm(data);
} catch {
error.value = 'آپلود کاور ناموفق بود.';
}
}
async function clearCover() {
const { data } = await api.delete<LeadFormDto>(`/lead-forms/${id.value}/cover`);
applyForm(data);
}
async function loadResponses() {
tab.value = 'responses';
const { data } = await api.get<LeadFormSubmissionDto[]>(`/lead-forms/${id.value}/submissions`);
submissions.value = data;
}
async function copyText(text: string) {
await navigator.clipboard.writeText(text);
ok.value = 'کپی شد.';
}
function toggleAssignee(uid: string) {
assigneeIds.value = assigneeIds.value.includes(uid)
? assigneeIds.value.filter((x) => x !== uid)
: [...assigneeIds.value, uid];
}
onMounted(load);
</script>
<template>
<section class="page">
<header class="top">
<div>
<button class="back" type="button" @click="router.push({ name: 'lead-forms' })">بازگشت</button>
<h1>فرم‌ساز <UiIcon name="layers" :size="20" /></h1>
<p>فرم را بچینید، پیش‌نمایش بگیرید و پاسخ‌ها را ببینید.</p>
</div>
<div class="top-actions">
<a class="btn ghost" :href="form?.publicPath" target="_blank" rel="noopener">
<UiIcon name="eye" :size="15" /> پیش‌نمایش
</a>
<button class="btn primary" type="button" :disabled="saving" @click="save">
<UiIcon name="check" :size="15" /> ذخیره تغییرات
</button>
</div>
</header>
<p v-if="error" class="banner err">{{ error }}</p>
<p v-if="ok" class="banner ok">{{ ok }}</p>
<nav class="tabs">
<button type="button" :class="{ on: tab === 'edit' }" @click="tab = 'edit'">
<UiIcon name="pencil" :size="14" /> ویرایش فرم
</button>
<button type="button" :class="{ on: tab === 'settings' }" @click="tab = 'settings'">تنظیمات</button>
<button type="button" :class="{ on: tab === 'share' }" @click="tab = 'share'">اشتراک‌گذاری</button>
<button type="button" :class="{ on: tab === 'responses' }" @click="loadResponses">پاسخ‌ها</button>
</nav>
<div v-if="tab === 'edit'" class="workspace">
<aside class="preview-col">
<div class="device-toggle">
<button type="button" @click="desktopPreviewOpen = true">دسکتاپ</button>
<button type="button" class="on">موبایل</button>
</div>
<div class="phone">
<div class="cover" :style="{ background: submitColor }">
<img v-if="coverSrc" :src="coverSrc" alt="" />
<UiIcon v-else name="layers" :size="28" />
</div>
<div class="preview-body">
<h3>{{ name || 'عنوان فرم' }}</h3>
<p>{{ description || 'توضیح کوتاه فرم اینجاست.' }}</p>
<template v-for="f in fields" :key="f.key">
<p v-if="f.fieldType === 'heading'" class="heading">{{ f.label }}</p>
<label v-else>
{{ f.label }}
<textarea v-if="f.fieldType === 'textarea'" rows="3" :placeholder="f.placeholder || ''" disabled />
<select v-else-if="f.fieldType === 'select'" disabled>
<option>{{ f.placeholder || 'انتخاب کنید' }}</option>
<option v-for="o in f.options" :key="o">{{ o }}</option>
</select>
<div v-else-if="f.fieldType === 'radio' || f.fieldType === 'checkbox'" class="opts">
<span v-for="o in f.options" :key="o">{{ f.fieldType === 'checkbox' ? '☐' : '○' }} {{ o }}</span>
</div>
<div v-else-if="f.fieldType === 'rating'" class="stars">★★★★★</div>
<input v-else disabled :placeholder="f.placeholder || ''" />
</label>
</template>
<button type="button" class="send" :style="{ background: submitColor }">
<UiIcon name="send" :size="14" /> {{ submitLabel }}
</button>
<small>اطلاعات شما محرمانه می‌ماند.</small>
</div>
</div>
</aside>
<section class="canvas">
<button class="cover-box" type="button" @click="coverInput?.click()">
<img v-if="coverSrc" :src="coverSrc" alt="" />
<span v-else>+ افزودن لوگو یا تصویر کاور</span>
</button>
<input ref="coverInput" type="file" accept="image/*" hidden @change="uploadCover" />
<button v-if="coverSrc" class="link" type="button" @click="clearCover">حذف کاور</button>
<div class="title-card">
<input v-model="name" class="title" />
<textarea v-model="description" rows="2" placeholder="توضیح فرم را بنویسید" />
</div>
<article
v-for="f in fields"
:key="f.key"
class="field-card"
draggable="true"
@dragstart="onDragStart(f.key)"
@dragover.prevent
@drop="onDrop(f.key)"
>
<span class="grip"><UiIcon name="grip" :size="16" /></span>
<div>
<strong>{{ f.label }}</strong>
<small>{{ typeLabel[f.fieldType] || f.fieldType }}</small>
</div>
<div class="fops">
<button type="button" title="کپی" @click="duplicateField(f)"><UiIcon name="layers" :size="14" /></button>
<button type="button" class="danger" title="حذف" @click="removeField(f.key)"><UiIcon name="trash" :size="14" /></button>
<button type="button" title="ویرایش" @click="editFieldKey = f.key"><UiIcon name="pencil" :size="14" /></button>
</div>
</article>
<button class="add-field" type="button" @click="addField('text')">+ افزودن فیلد جدید</button>
<div class="submit-card">
<label>متن دکمه ارسال
<input v-model="submitLabel" />
</label>
<label>رنگ دکمه
<input v-model="submitColor" type="color" />
</label>
</div>
<button class="adv" type="button" @click="advancedOpen = !advancedOpen">تنظیمات پیشرفته</button>
<div v-if="advancedOpen" class="adv-box">
<label class="chk"><input v-model="createPerson" type="checkbox" /> ساخت شخص</label>
<label class="chk"><input v-model="createDeal" type="checkbox" /> ساخت معامله</label>
</div>
</section>
<aside class="toolbox">
<h3>افزودن فیلد</h3>
<div class="tools">
<button v-for="t in toolbox" :key="t.type" type="button" @click="addField(t.type)">
<UiIcon :name="t.icon" :size="16" />
{{ t.label }}
</button>
</div>
<h3>فیلدهای آماده</h3>
<div v-for="g in readyGroups" :key="g.key" class="ready">
<button type="button" @click="readyOpen = readyOpen === g.key ? null : g.key">{{ g.title }}</button>
<div v-if="readyOpen === g.key">
<button
v-for="item in g.items"
:key="item.label"
type="button"
@click="addField(item.fieldType, item)"
>
{{ item.label }}
</button>
</div>
</div>
</aside>
</div>
<section v-else-if="tab === 'settings'" class="panel">
<label class="chk"><input v-model="isActive" type="checkbox" /> فرم فعال باشد</label>
<label>منبع بازاریابی <input v-model="source" /></label>
<label>کاریز مقصد
<select v-model="pipelineId">
<option value="">پیش‌فرض سازمان</option>
<option v-for="p in pipelines" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</label>
<label>پیام تشکر <input v-model="thankYouMessage" /></label>
<label>آدرس بعد از ارسال <input v-model="redirectUrl" /></label>
<p>توزیع نوبتی مسئولان</p>
<div class="chips">
<button
v-for="u in users"
:key="u.id"
type="button"
class="chip"
:class="{ on: assigneeIds.includes(u.id) }"
@click="toggleAssignee(u.id)"
>
{{ u.fullName }}
</button>
</div>
<button class="btn primary" type="button" @click="save">ذخیره تنظیمات</button>
</section>
<section v-else-if="tab === 'share'" class="panel">
<label>لینک عمومی
<div class="row">
<input :value="publicUrl" readonly />
<button class="btn ghost" type="button" @click="copyText(publicUrl)">کپی</button>
</div>
</label>
<label>کد iframe
<div class="row">
<input :value="iframeSnippet" readonly />
<button class="btn ghost" type="button" @click="copyText(iframeSnippet)">کپی</button>
</div>
</label>
<img v-if="publicUrl" :src="qrUrl" alt="QR" width="140" height="140" />
</section>
<section v-else class="panel">
<p v-if="!submissions.length">هنوز پاسخی ثبت نشده.</p>
<article v-for="s in submissions" :key="s.id" class="sub">
<small>{{ new Date(s.createdAt).toLocaleString('fa-IR') }}</small>
<p v-for="(val, key) in s.values" :key="key"><b>{{ key }}:</b> {{ val }}</p>
</article>
</section>
<div v-if="desktopPreviewOpen" class="overlay desk-overlay" @click.self="desktopPreviewOpen = false">
<div class="desk-sheet" role="dialog" aria-label="پیش‌نمایش دسکتاپ">
<header>
<strong>پیش‌نمایش دسکتاپ</strong>
<button class="btn ghost" type="button" @click="desktopPreviewOpen = false">بستن</button>
</header>
<div class="desk-form">
<div class="cover" :style="{ background: submitColor }">
<img v-if="coverSrc" :src="coverSrc" alt="" />
<UiIcon v-else name="layers" :size="36" />
</div>
<div class="preview-body desk-body">
<h3>{{ name || 'عنوان فرم' }}</h3>
<p>{{ description || 'توضیح کوتاه فرم اینجاست.' }}</p>
<div class="desk-fields">
<template v-for="f in fields" :key="'d-' + f.key">
<p v-if="f.fieldType === 'heading'" class="heading wide">{{ f.label }}</p>
<label v-else :class="{ wide: f.fieldType === 'textarea' || f.fieldType === 'checkbox' }">
{{ f.label }}
<textarea v-if="f.fieldType === 'textarea'" rows="3" :placeholder="f.placeholder || ''" disabled />
<select v-else-if="f.fieldType === 'select'" disabled>
<option>{{ f.placeholder || 'انتخاب کنید' }}</option>
<option v-for="o in f.options" :key="o">{{ o }}</option>
</select>
<div v-else-if="f.fieldType === 'radio' || f.fieldType === 'checkbox'" class="opts">
<span v-for="o in f.options" :key="o">{{ f.fieldType === 'checkbox' ? '☐' : '○' }} {{ o }}</span>
</div>
<div v-else-if="f.fieldType === 'rating'" class="stars">★★★★★</div>
<input v-else disabled :placeholder="f.placeholder || ''" />
</label>
</template>
</div>
<button type="button" class="send" :style="{ background: submitColor }">
<UiIcon name="send" :size="14" /> {{ submitLabel }}
</button>
<small>اطلاعات شما محرمانه می‌ماند.</small>
</div>
</div>
</div>
</div>
<div v-if="editingField" class="overlay" @click.self="editFieldKey = null">
<div class="sheet">
<h3>ویرایش فیلد</h3>
<label>عنوان <input v-model="editingField.label" /></label>
<label>متن راهنما <input v-model="editingField.placeholder" /></label>
<label class="chk"><input v-model="editingField.required" type="checkbox" /> الزامی</label>
<label>اتصال به
<select v-model="editingField.mapTo">
<option value="person.fullName">نام کامل</option>
<option value="person.firstName">نام</option>
<option value="person.lastName">نام خانوادگی</option>
<option value="person.mobile">موبایل</option>
<option value="person.email">ایمیل</option>
<option value="person.phone">تلفن</option>
<option value="person.position">سمت</option>
<option value="deal.title">عنوان معامله</option>
<option value="deal.notes">یادداشت معامله</option>
<option value="notes">یادداشت شخص</option>
<option value="custom">فقط در پاسخ فرم</option>
</select>
</label>
<label v-if="['select','radio','checkbox'].includes(editingField.fieldType)">
گزینه‌ها (هر خط یکی)
<textarea
:value="editingField.options.join('\n')"
rows="4"
@input="editingField.options = ($event.target as HTMLTextAreaElement).value.split('\n').map(s => s.trim()).filter(Boolean)"
/>
</label>
<button class="btn primary" type="button" @click="editFieldKey = null">تمام</button>
</div>
</div>
</section>
</template>
<style scoped>
.page { --teal: #14b8a6; display: grid; gap: 0.85rem; }
.top { display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start; }
h1 { margin: 0; font-size: 1.55rem; font-weight: 800; display: inline-flex; gap: 0.35rem; align-items: center; }
h1 :deep(.ico) { color: var(--teal); }
.top p, .back { color: #64748b; }
.back { background: none; border: 0; font: inherit; cursor: pointer; padding: 0; }
.top-actions { display: flex; gap: 0.45rem; flex-wrap: wrap; }
.btn { display: inline-flex; align-items: center; gap: 0.3rem; min-height: 40px; padding: 0.45rem 0.9rem; border-radius: 12px; font: inherit; font-weight: 700; border: 1px solid transparent; cursor: pointer; text-decoration: none; }
.btn.primary { background: var(--teal); color: #fff; }
.btn.ghost { background: #fff; color: #0f766e; border-color: #99f6e4; }
.banner { margin: 0; padding: 0.6rem 0.8rem; border-radius: 12px; }
.banner.err { background: #fef2f2; color: #b91c1c; }
.banner.ok { background: #ecfdf5; color: #047857; }
.tabs { display: flex; gap: 0.35rem; flex-wrap: wrap; background: #fff; padding: 0.35rem; border-radius: 14px; }
.tabs button { border: 0; background: transparent; min-height: 38px; padding: 0 0.85rem; border-radius: 10px; font: inherit; font-weight: 700; color: #0f766e; cursor: pointer; display: inline-flex; align-items: center; gap: 0.3rem; }
.tabs button.on { background: var(--teal); color: #fff; }
.workspace { display: grid; grid-template-columns: 280px minmax(0,1fr) 240px; gap: 0.85rem; align-items: start; }
.preview-col, .canvas, .toolbox, .panel { background: #fff; border-radius: 16px; padding: 0.9rem; box-shadow: 0 8px 22px rgba(15,23,42,.04); }
.device-toggle { display: flex; gap: 0.3rem; margin-bottom: 0.7rem; }
.device-toggle button { flex: 1; border: 0; min-height: 34px; border-radius: 10px; font: inherit; font-weight: 700; background: #f1f5f9; cursor: pointer; }
.device-toggle button.on { background: var(--teal); color: #fff; }
.phone { border: 8px solid #0f172a; border-radius: 28px; overflow: hidden; background: #fff; max-width: 260px; margin: 0 auto; }
.desk-overlay { padding: 1.2rem; }
.desk-sheet {
width: min(920px, 100%);
max-height: calc(100vh - 2.4rem);
overflow: auto;
background: #eef2f6;
border-radius: 20px;
padding: 1rem 1.1rem 1.2rem;
display: grid;
gap: 0.75rem;
}
.desk-sheet header { display: flex; justify-content: space-between; align-items: center; }
.desk-form { background: #fff; border-radius: 18px; overflow: hidden; box-shadow: 0 16px 40px rgba(15,23,42,.08); }
.desk-form .cover { height: 140px; }
.desk-form .cover img { width: 100%; height: 140px; object-fit: cover; }
.desk-body { padding: 1.2rem 1.4rem 1.4rem; }
.desk-body h3 { font-size: 1.35rem; }
.desk-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem 1rem; }
.desk-fields .wide { grid-column: 1 / -1; }
.desk-form .send { justify-self: start; min-width: 200px; }
.cover { height: 86px; display: grid; place-items: center; color: #fff; overflow: hidden; }
.cover img { width: 100%; height: 100%; object-fit: cover; }
.preview-body { padding: 0.8rem; display: grid; gap: 0.5rem; }
.preview-body h3 { margin: 0; font-size: 1rem; }
.preview-body p, .preview-body small { margin: 0; color: #64748b; font-size: 0.78rem; }
.preview-body label { display: grid; gap: 0.25rem; font-size: 0.78rem; color: #475569; }
.preview-body input, .preview-body textarea, .preview-body select { border: 1px solid #e2e8f0; border-radius: 10px; padding: 0.45rem 0.6rem; font: inherit; background: #f8fafc; }
.send { border: 0; color: #fff; border-radius: 12px; min-height: 40px; font: inherit; font-weight: 700; display: inline-flex; align-items: center; justify-content: center; gap: 0.3rem; }
.opts, .stars { color: #64748b; font-size: 0.75rem; display: grid; gap: 0.2rem; }
.cover-box { width: 100%; min-height: 88px; border: 1.5px dashed #99f6e4; border-radius: 14px; background: #f0fdfa; color: #0f766e; font: inherit; font-weight: 700; cursor: pointer; overflow: hidden; }
.cover-box img { width: 100%; height: 88px; object-fit: cover; display: block; }
.title-card, .field-card, .submit-card, .adv-box { border: 1px solid #eef2f7; border-radius: 14px; padding: 0.8rem; display: grid; gap: 0.45rem; }
.title { font-size: 1.15rem; font-weight: 800; border: 0; background: transparent; font: inherit; }
.title-card textarea, .submit-card input, .panel input, .panel select, .sheet input, .sheet select, .sheet textarea {
border: 1px solid #e2e8f0; border-radius: 10px; padding: 0.55rem 0.7rem; font: inherit; width: 100%;
}
.field-card { grid-template-columns: auto 1fr auto; align-items: center; cursor: grab; }
.field-card small { color: #94a3b8; display: block; }
.fops { display: flex; gap: 0.2rem; }
.fops button { width: 30px; height: 30px; border: 0; border-radius: 8px; background: #f8fafc; cursor: pointer; color: #64748b; }
.fops .danger { color: #dc2626; }
.add-field { width: 100%; min-height: 44px; border: 1.5px dashed #14b8a6; background: #f0fdfa; color: #0f766e; border-radius: 12px; font: inherit; font-weight: 700; cursor: pointer; }
.adv { background: none; border: 0; font: inherit; font-weight: 700; color: #0f766e; cursor: pointer; text-align: right; }
.toolbox h3 { margin: 0 0 0.5rem; font-size: 0.95rem; }
.tools { display: grid; grid-template-columns: 1fr 1fr; gap: 0.4rem; margin-bottom: 1rem; }
.tools button, .ready div button { border: 1px solid #eef2f7; background: #f8fafc; border-radius: 10px; min-height: 52px; font: inherit; font-size: 0.75rem; cursor: pointer; display: grid; place-items: center; gap: 0.15rem; color: #334155; }
.ready { border-bottom: 1px solid #f1f5f9; }
.ready > button { width: 100%; background: none; border: 0; text-align: right; font: inherit; font-weight: 700; padding: 0.55rem 0; cursor: pointer; }
.panel { display: grid; gap: 0.7rem; }
.panel label, .sheet label { display: grid; gap: 0.3rem; font-size: 0.88rem; font-weight: 700; color: #334155; }
.chk { display: flex !important; align-items: center; gap: 0.4rem; }
.chips { display: flex; flex-wrap: wrap; gap: 0.35rem; }
.chip { border: 1px solid #e2e8f0; background: #fff; border-radius: 999px; padding: 0.3rem 0.7rem; font: inherit; cursor: pointer; }
.chip.on { background: #ccfbf1; border-color: #99f6e4; color: #0f766e; font-weight: 700; }
.row { display: flex; gap: 0.4rem; }
.row input { flex: 1; }
.sub { border-bottom: 1px solid #f1f5f9; padding: 0.5rem 0; }
.overlay { position: fixed; inset: 0; background: rgba(15,23,42,.35); display: grid; place-items: center; z-index: 40; padding: 1rem; }
.sheet { width: min(420px,100%); background: #fff; border-radius: 16px; padding: 1rem; display: grid; gap: 0.65rem; }
.link { background: none; border: 0; color: #0f766e; font: inherit; font-weight: 700; cursor: pointer; }
@media (max-width: 1100px) {
.workspace { grid-template-columns: 1fr; }
.preview-col { order: 3; }
.toolbox { order: 1; }
.canvas { order: 2; }
.tools { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 700px) {
.top, .top-actions { flex-direction: column; }
.tabs { display: grid; grid-template-columns: 1fr 1fr; }
}
</style>
+113 -107
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { computed, onMounted, reactive, ref } from 'vue';
import { useRoute } from 'vue-router';
import axios from 'axios';
import type { PublicLeadFormDto, SubmitLeadFormResponse } from '@mento/shared';
@@ -12,9 +12,12 @@ const done = ref(false);
const thankYou = ref('');
const form = ref<PublicLeadFormDto | null>(null);
const values = reactive<Record<string, string>>({});
const token = String(route.params.token || '');
const coverUrl = computed(() =>
form.value?.coverPath || (form.value?.hasCover ? `/api/public/forms/${token}/cover` : ''),
);
async function load() {
loading.value = true;
error.value = '';
@@ -22,6 +25,7 @@ async function load() {
const { data } = await axios.get<PublicLeadFormDto>(`/api/public/forms/${token}`);
form.value = data;
for (const field of data.fields) {
if (field.fieldType === 'heading') continue;
values[field.key] = values[field.key] ?? '';
}
} catch {
@@ -32,6 +36,17 @@ async function load() {
}
}
function setMulti(key: string, option: string, on: boolean) {
const cur = new Set((values[key] || '').split(',').map((s) => s.trim()).filter(Boolean));
if (on) cur.add(option);
else cur.delete(option);
values[key] = [...cur].join(', ');
}
function hasMulti(key: string, option: string) {
return (values[key] || '').split(',').map((s) => s.trim()).includes(option);
}
async function submit() {
if (!form.value) return;
saving.value = true;
@@ -43,16 +58,12 @@ async function submit() {
);
thankYou.value = data.thankYouMessage || 'ثبت شد. متشکریم.';
done.value = true;
if (data.redirectUrl) {
window.location.assign(data.redirectUrl);
}
if (data.redirectUrl) window.location.assign(data.redirectUrl);
} catch (e) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'ارسال ناموفق بود');
} else {
error.value = 'ارسال ناموفق بود';
}
} else error.value = 'ارسال ناموفق بود';
} finally {
saving.value = false;
}
@@ -64,10 +75,15 @@ onMounted(load);
<template>
<main class="shell">
<div class="panel">
<div v-if="coverUrl" class="cover" :style="{ background: form?.submitColor || '#14b8a6' }">
<img :src="coverUrl" alt="" />
</div>
<div v-else class="cover" :style="{ background: form?.submitColor || '#14b8a6' }" />
<header>
<p class="brand">منتو</p>
<h1 v-if="form">{{ form.name }}</h1>
<h1 v-else>فرم سرنخ</h1>
<h1 v-else>فرم</h1>
<p v-if="form?.description">{{ form.description }}</p>
</header>
<p v-if="loading" class="muted">در حال بارگذاری…</p>
@@ -79,26 +95,72 @@ onMounted(load);
</div>
<form v-else-if="form" class="fields" @submit.prevent="submit">
<label v-for="field in form.fields" :key="field.key">
{{ field.label }}
<span v-if="field.required" class="req">*</span>
<textarea
v-if="field.fieldType === 'textarea'"
v-model="values[field.key]"
rows="4"
:required="field.required"
/>
<input
v-else
v-model="values[field.key]"
:type="field.fieldType === 'email' ? 'email' : field.fieldType === 'phone' ? 'tel' : 'text'"
:required="field.required"
/>
</label>
<template v-for="field in form.fields" :key="field.key">
<p v-if="field.fieldType === 'heading'" class="heading">{{ field.label }}</p>
<label v-else>
{{ field.label }}
<span v-if="field.required" class="req">*</span>
<textarea
v-if="field.fieldType === 'textarea'"
v-model="values[field.key]"
rows="4"
:placeholder="field.placeholder || ''"
:required="field.required"
/>
<select
v-else-if="field.fieldType === 'select'"
v-model="values[field.key]"
:required="field.required"
>
<option value="">{{ field.placeholder || 'انتخاب کنید' }}</option>
<option v-for="o in field.options" :key="o" :value="o">{{ o }}</option>
</select>
<div v-else-if="field.fieldType === 'radio'" class="opts">
<label v-for="o in field.options" :key="o">
<input v-model="values[field.key]" type="radio" :value="o" :required="field.required" />
{{ o }}
</label>
</div>
<div v-else-if="field.fieldType === 'checkbox'" class="opts">
<label v-for="o in field.options" :key="o">
<input
type="checkbox"
:checked="hasMulti(field.key, o)"
@change="setMulti(field.key, o, ($event.target as HTMLInputElement).checked)"
/>
{{ o }}
</label>
</div>
<div v-else-if="field.fieldType === 'rating'" class="stars">
<button
v-for="n in 5"
:key="n"
type="button"
:class="{ on: Number(values[field.key]) >= n }"
@click="values[field.key] = String(n)"
>
★
</button>
</div>
<input
v-else
v-model="values[field.key]"
:type="field.fieldType === 'email' ? 'email' : field.fieldType === 'phone' ? 'tel' : field.fieldType === 'date' ? 'date' : field.fieldType === 'number' ? 'number' : field.fieldType === 'file' ? 'text' : 'text'"
:placeholder="field.placeholder || (field.fieldType === 'file' ? 'نام فایل یا لینک' : '')"
:required="field.required"
/>
</label>
</template>
<p v-if="error" class="error">{{ error }}</p>
<button class="btn" type="submit" :disabled="saving">
{{ saving ? 'در حال ارسال…' : 'ارسال' }}
<button
class="btn"
type="submit"
:disabled="saving"
:style="{ background: form.submitColor || '#14b8a6' }"
>
{{ saving ? 'در حال ارسال…' : (form.submitLabel || 'ارسال درخواست') }}
</button>
<small>اطلاعات شما محرمانه می‌ماند.</small>
</form>
</div>
</main>
@@ -110,88 +172,32 @@ onMounted(load);
display: grid;
place-items: center;
padding: 1.5rem;
background:
radial-gradient(circle at top left, rgba(15, 107, 92, 0.12), transparent 30%),
linear-gradient(180deg, #f7faf8 0%, #eef2f0 100%);
background: #f4f7f8;
}
.panel {
width: min(520px, 100%);
width: min(440px, 100%);
background: #fff;
border: 1px solid #d5ddd8;
border-radius: 18px;
box-shadow: 0 16px 40px rgba(23, 33, 28, 0.08);
padding: 1.35rem;
display: grid;
gap: 1rem;
border-radius: 22px;
overflow: hidden;
box-shadow: 0 16px 40px rgba(15, 23, 42, 0.08);
}
.brand {
margin: 0;
color: #0f6b5c;
font-weight: 700;
}
h1 {
margin: 0.25rem 0 0;
font-size: 1.45rem;
}
.fields {
display: grid;
gap: 0.85rem;
}
label {
display: grid;
gap: 0.35rem;
color: #5b6b63;
font-size: 0.92rem;
}
.req {
color: #b42318;
}
input,
textarea {
border: 1px solid #d5ddd8;
border-radius: 12px;
padding: 0.75rem 0.9rem;
font: inherit;
width: 100%;
}
.btn {
border: 0;
background: #0f6b5c;
color: #fff;
border-radius: 12px;
padding: 0.85rem 1rem;
font: inherit;
font-weight: 700;
cursor: pointer;
}
.muted {
color: #5b6b63;
}
.error {
color: #b42318;
margin: 0;
}
.success {
display: grid;
gap: 0.4rem;
background: #e7f6ef;
border-radius: 12px;
padding: 1rem;
}
.success p {
margin: 0;
color: #5b6b63;
.cover { height: 110px; }
.cover img { width: 100%; height: 110px; object-fit: cover; display: block; }
header, .fields, .success { padding: 1.1rem 1.2rem 1.25rem; display: grid; gap: 0.75rem; }
h1 { margin: 0; font-size: 1.35rem; }
header p, .muted, small { margin: 0; color: #64748b; }
label { display: grid; gap: 0.35rem; color: #475569; font-size: 0.92rem; }
.req { color: #b42318; }
input, textarea, select {
border: 1px solid #e2e8f0; border-radius: 12px; padding: 0.75rem 0.9rem; font: inherit; width: 100%; background: #f8fafc;
}
.btn { border: 0; color: #fff; border-radius: 12px; padding: 0.85rem 1rem; font: inherit; font-weight: 700; cursor: pointer; }
.error { color: #b42318; margin: 0; }
.success { background: #e7f6ef; }
.opts { display: grid; gap: 0.35rem; }
.opts label { display: flex; align-items: center; gap: 0.4rem; }
.stars { display: flex; gap: 0.25rem; }
.stars button { border: 0; background: none; font-size: 1.3rem; color: #cbd5e1; cursor: pointer; }
.stars button.on { color: #eab308; }
.heading { margin: 0; font-weight: 700; color: #0f172a; }
</style>
+58 -375
View File
@@ -1,121 +1,59 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { RouterLink, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import type {
CreateLeadFormRequest,
LeadFormDto,
OrgUserDto,
PipelineDto,
} from '@mento/shared';
import UiIcon from '@/components/UiIcon.vue';
import type { LeadFormDto } from '@mento/shared';
import { toFaDigits } from '@/lib/jalali';
const router = useRouter();
const loading = ref(false);
const saving = ref(false);
const error = ref('');
const forms = ref<LeadFormDto[]>([]);
const users = ref<OrgUserDto[]>([]);
const pipelines = ref<PipelineDto[]>([]);
const name = ref('فرم تماس سایت');
const source = ref('وب‌سایت');
const pipelineId = ref('');
const thankYouMessage = ref('پیام شما ثبت شد. به‌زودی با شما تماس می‌گیریم.');
const assigneeIds = ref<string[]>([]);
const origin = computed(() => window.location.origin);
async function load() {
loading.value = true;
error.value = '';
try {
const [formsRes, usersRes, pipesRes] = await Promise.all([
api.get<LeadFormDto[]>('/lead-forms'),
api.get<OrgUserDto[]>('/users'),
api.get<PipelineDto[]>('/pipelines'),
]);
forms.value = formsRes.data;
users.value = usersRes.data;
pipelines.value = pipesRes.data;
if (!pipelineId.value && pipesRes.data[0]) {
pipelineId.value = pipesRes.data.find((p) => p.isDefault)?.id ?? pipesRes.data[0].id;
}
const { data } = await api.get<LeadFormDto[]>('/lead-forms');
forms.value = data;
} catch (e) {
if (axios.isAxiosError(e) && !e.response) {
error.value = 'اتصال به API برقرار نشد.';
} else {
error.value = 'بارگذاری فرم‌ها ناموفق بود.';
}
error.value = axios.isAxiosError(e) && !e.response
? 'اتصال به API برقرار نشد.'
: 'بارگذاری فرم‌ها ناموفق بود.';
} finally {
loading.value = false;
}
}
async function createForm() {
if (!name.value.trim()) {
error.value = 'نام فرم را وارد کنید.';
return;
}
saving.value = true;
error.value = '';
try {
const payload: CreateLeadFormRequest = {
name: name.value.trim(),
source: source.value.trim() || 'فرم وب',
pipelineId: pipelineId.value || null,
thankYouMessage: thankYouMessage.value.trim() || null,
assigneeIds: assigneeIds.value,
const { data } = await api.post<LeadFormDto>('/lead-forms', {
name: 'درخواست مشاوره',
description: 'فرم دریافت درخواست مشاوره مشتریان',
source: 'وب‌سایت',
createPerson: true,
createDeal: true,
};
await api.post('/lead-forms', payload);
name.value = 'فرم تماس سایت';
await load();
} catch (e) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'ساخت فرم ناموفق بود');
} else {
error.value = 'ساخت فرم ناموفق بود';
}
});
await router.push({ name: 'lead-form-builder', params: { id: data.id } });
} catch {
error.value = 'ساخت فرم ناموفق بود.';
} finally {
saving.value = false;
}
}
async function toggleActive(form: LeadFormDto) {
await api.patch(`/lead-forms/${form.id}`, { isActive: !form.isActive });
await load();
}
async function removeForm(form: LeadFormDto) {
if (!confirm(`فرم «${form.name}» حذف شود؟`)) return;
await api.delete(`/lead-forms/${form.id}`);
await load();
}
function publicUrl(form: LeadFormDto) {
return `${origin.value}${form.publicPath}`;
}
function iframeSnippet(form: LeadFormDto) {
return `<iframe src="${publicUrl(form)}" width="100%" height="640" style="border:0;border-radius:12px" title="${form.name}"></iframe>`;
}
function qrUrl(form: LeadFormDto) {
return `https://api.qrserver.com/v1/create-qr-code/?size=160x160&data=${encodeURIComponent(publicUrl(form))}`;
}
async function copyText(text: string) {
await navigator.clipboard.writeText(text);
}
function toggleAssignee(id: string) {
if (assigneeIds.value.includes(id)) {
assigneeIds.value = assigneeIds.value.filter((x) => x !== id);
} else {
assigneeIds.value = [...assigneeIds.value, id];
}
}
const countLabel = computed(() => toFaDigits(forms.value.length));
onMounted(load);
</script>
@@ -123,312 +61,57 @@ onMounted(load);
<template>
<section class="page">
<header class="page-head">
<div class="head-copy">
<h1>فرم‌ساز سرنخ</h1>
<p class="lead">
فرم وب بسازید، لینک یا iframe بگیرید؛ ارسال‌ها به‌صورت شخص و معامله در کاریز ثبت می‌شوند.
</p>
<div>
<h1>
فرم‌ساز
<UiIcon name="layers" :size="22" />
</h1>
<p class="lead">فرم وب بسازید، فیلدها را بچینید و پاسخ‌ها را در باشگاه مشتریان و کاریز ببینید.</p>
</div>
</header>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<form class="panel" @submit.prevent="createForm">
<h2>فرم جدید</h2>
<div class="grid">
<label>
نام فرم
<input v-model="name" required maxlength="120" />
</label>
<label>
منبع بازاریابی
<input v-model="source" maxlength="80" placeholder="وب‌سایت / لندینگ" />
</label>
<label>
کاریز مقصد
<select v-model="pipelineId">
<option v-for="p in pipelines" :key="p.id" :value="p.id">
{{ p.name }}{{ p.isDefault ? ' (پیش‌فرض)' : '' }}
</option>
</select>
</label>
<label class="full">
پیام تشکر
<input v-model="thankYouMessage" maxlength="500" />
</label>
</div>
<div class="assignees">
<p class="muted">توزیع نوبتی بین مسئولان (اختیاری)</p>
<div class="chips">
<button
v-for="u in users"
:key="u.id"
type="button"
class="chip"
:class="{ on: assigneeIds.includes(u.id) }"
@click="toggleAssignee(u.id)"
>
{{ u.fullName }}
</button>
</div>
</div>
<button class="btn primary" type="submit" :disabled="saving">
{{ saving ? 'در حال ساخت…' : 'ساخت فرم با فیلدهای پیش‌فرض' }}
<button class="btn primary" type="button" :disabled="saving" @click="createForm">
+ فرم جدید
</button>
</form>
</header>
<p v-if="error" class="err">{{ error }}</p>
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
<p v-else class="muted">{{ countLabel }} فرم</p>
<div class="list">
<article v-for="form in forms" :key="form.id" class="card">
<header class="card-head">
<div>
<strong>{{ form.name }}</strong>
<p class="muted">
{{ form.isActive ? 'فعال' : 'غیرفعال' }}
· منبع: {{ form.source || '—' }}
· {{ form.fields.length }} فیلد
</p>
</div>
<div class="actions">
<button type="button" @click="toggleActive(form)">
{{ form.isActive ? 'غیرفعال' : 'فعال' }}
</button>
<button type="button" class="danger" @click="removeForm(form)">حذف</button>
</div>
</header>
<div class="share">
<label>
لینک عمومی
<div class="row">
<input :value="publicUrl(form)" readonly />
<button type="button" class="btn ghost" @click="copyText(publicUrl(form))">کپی</button>
<a class="btn ghost" :href="form.publicPath" target="_blank" rel="noopener">بازکردن</a>
</div>
</label>
<label>
کد iframe
<div class="row">
<input :value="iframeSnippet(form)" readonly />
<button type="button" class="btn ghost" @click="copyText(iframeSnippet(form))">کپی</button>
</div>
</label>
<div class="qr">
<img :src="qrUrl(form)" :alt="`QR ${form.name}`" width="120" height="120" />
<p class="muted">QR برای اشتراک‌گذاری سریع</p>
</div>
<div>
<strong>{{ form.name }}</strong>
<p>{{ form.isActive ? 'فعال' : 'غیرفعال' }} · {{ toFaDigits(form.fields.length) }} فیلد · {{ toFaDigits(form.submissionCount ?? 0) }} پاسخ</p>
</div>
<div class="ops">
<RouterLink class="btn ghost" :to="{ name: 'lead-form-builder', params: { id: form.id } }">ویرایش</RouterLink>
<a class="btn ghost" :href="form.publicPath" target="_blank" rel="noopener">پیش‌نمایش</a>
<button class="link danger" type="button" @click="removeForm(form)">حذف</button>
</div>
</article>
<p v-if="!loading && !forms.length" class="muted empty">هنوز فرمی نساخته‌اید.</p>
<p v-if="!loading && !forms.length" class="empty">هنوز فرمی نساخته‌اید.</p>
</div>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 1rem;
max-width: 900px;
min-width: 0;
overflow-x: clip;
}
.page-head {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.head-copy {
min-width: 0;
}
h1 {
margin: 0 0 0.3rem;
font-size: clamp(1.45rem, 2.5vw, 1.85rem);
font-weight: 800;
}
.lead,
.muted {
margin: 0;
color: #64748b;
}
.error {
margin: 0;
color: #b91c1c;
}
.panel,
.card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 18px;
box-shadow: 0 8px 22px rgba(15, 23, 42, 0.04);
padding: 1.1rem;
display: grid;
gap: 0.85rem;
}
.panel h2 {
margin: 0;
font-size: 1rem;
font-weight: 700;
color: #0f172a;
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.full {
grid-column: 1 / -1;
}
label {
display: grid;
gap: 0.35rem;
font-size: 0.9rem;
color: #64748b;
}
input,
select {
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 0.7rem 0.85rem;
font: inherit;
background: #fff;
color: #0f172a;
width: 100%;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 42px;
padding: 0.55rem 1rem;
border-radius: 12px;
font-weight: 700;
border: 1px solid transparent;
cursor: pointer;
font: inherit;
text-decoration: none;
}
.btn.primary {
background: #14b8a6;
color: #fff;
box-shadow: 0 8px 18px rgba(20, 184, 166, 0.24);
}
.btn.ghost {
background: #fff;
color: #0f766e;
border-color: #99f6e4;
box-shadow: none;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.chip {
border: 1px solid #e2e8f0;
background: #fff;
border-radius: 999px;
padding: 0.35rem 0.75rem;
font: inherit;
cursor: pointer;
}
.chip.on {
background: #f0fdfa;
border-color: #99f6e4;
color: #0f766e;
font-weight: 700;
}
.list {
display: grid;
gap: 0.85rem;
}
.card-head,
.actions,
.row {
display: flex;
gap: 0.55rem;
align-items: center;
flex-wrap: wrap;
}
.card-head {
justify-content: space-between;
}
.card-head p {
margin: 0.25rem 0 0;
}
.actions button {
border: 0;
background: transparent;
color: #64748b;
cursor: pointer;
font: inherit;
font-weight: 600;
}
.actions .danger {
color: #b91c1c;
}
.share {
display: grid;
gap: 0.75rem;
}
.row input {
flex: 1;
min-width: 0;
}
.qr {
display: flex;
gap: 0.85rem;
align-items: center;
}
.qr img {
border-radius: 12px;
border: 1px solid #e2e8f0;
background: #fff;
}
.empty {
padding: 1rem;
border: 1px dashed #e2e8f0;
border-radius: 18px;
}
.page { display: grid; gap: 1rem; }
.page-head { display: flex; justify-content: space-between; gap: 1rem; align-items: flex-start; }
h1 { margin: 0; font-size: clamp(1.45rem, 2.4vw, 1.85rem); font-weight: 800; display: inline-flex; gap: 0.4rem; align-items: center; }
h1 :deep(.ico) { color: #14b8a6; }
.lead, .muted { margin: 0.25rem 0 0; color: #64748b; }
.err { color: #b91c1c; }
.btn, .link { font: inherit; cursor: pointer; }
.btn { display: inline-flex; align-items: center; justify-content: center; min-height: 40px; padding: 0.5rem 0.95rem; border-radius: 12px; font-weight: 700; border: 1px solid transparent; text-decoration: none; }
.btn.primary { background: #14b8a6; color: #fff; }
.btn.ghost { background: #fff; color: #0f766e; border-color: #99f6e4; }
.link { background: none; border: 0; font-weight: 700; }
.danger { color: #b91c1c; }
.list { display: grid; gap: 0.75rem; }
.card { background: #fff; border-radius: 16px; padding: 1rem 1.05rem; box-shadow: 0 8px 22px rgba(15,23,42,.04); display: flex; justify-content: space-between; gap: 1rem; align-items: center; }
.card p { margin: 0.25rem 0 0; color: #64748b; font-size: 0.88rem; }
.ops { display: flex; gap: 0.45rem; flex-wrap: wrap; align-items: center; }
.empty { padding: 1rem; border: 1px dashed #e2e8f0; border-radius: 16px; color: #64748b; text-align: center; }
@media (max-width: 700px) {
.grid {
grid-template-columns: 1fr;
}
.card-head,
.row,
.actions {
flex-direction: column;
align-items: stretch;
}
.page-head, .card, .ops { flex-direction: column; align-items: stretch; }
}
</style>
File diff suppressed because it is too large Load Diff
+117 -2
View File
@@ -817,18 +817,34 @@ export interface NotificationsListDto {
unreadCount: number;
}
export type LeadFormFieldType = "text" | "email" | "phone" | "textarea";
export const LEAD_FORM_FIELD_TYPES = [
"text",
"textarea",
"email",
"phone",
"select",
"radio",
"checkbox",
"date",
"number",
"file",
"rating",
"heading",
] as const;
export type LeadFormFieldType = (typeof LEAD_FORM_FIELD_TYPES)[number];
export type LeadFormMapTo =
| "person.firstName"
| "person.lastName"
| "person.fullName"
| "person.email"
| "person.mobile"
| "person.phone"
| "person.position"
| "deal.title"
| "deal.notes"
| "notes";
| "notes"
| "custom";
export interface LeadFormFieldDto {
id: string;
@@ -838,11 +854,14 @@ export interface LeadFormFieldDto {
required: boolean;
mapTo: LeadFormMapTo | string;
position: number;
placeholder: string | null;
options: string[];
}
export interface LeadFormDto {
id: string;
name: string;
description: string | null;
publicToken: string;
isActive: boolean;
createPerson: boolean;
@@ -852,7 +871,11 @@ export interface LeadFormDto {
thankYouMessage: string | null;
redirectUrl: string | null;
assigneeIds: string[];
submitLabel: string;
submitColor: string;
hasCover: boolean;
fields: LeadFormFieldDto[];
submissionCount?: number;
publicPath: string;
createdAt: string;
updatedAt: string;
@@ -865,10 +888,13 @@ export interface LeadFormFieldInput {
required?: boolean;
mapTo: LeadFormMapTo | string;
position?: number;
placeholder?: string | null;
options?: string[];
}
export interface CreateLeadFormRequest {
name: string;
description?: string | null;
createPerson?: boolean;
createDeal?: boolean;
pipelineId?: string | null;
@@ -876,11 +902,14 @@ export interface CreateLeadFormRequest {
thankYouMessage?: string | null;
redirectUrl?: string | null;
assigneeIds?: string[];
submitLabel?: string;
submitColor?: string;
fields?: LeadFormFieldInput[];
}
export interface UpdateLeadFormRequest {
name?: string;
description?: string | null;
isActive?: boolean;
createPerson?: boolean;
createDeal?: boolean;
@@ -889,20 +918,36 @@ export interface UpdateLeadFormRequest {
thankYouMessage?: string | null;
redirectUrl?: string | null;
assigneeIds?: string[];
submitLabel?: string;
submitColor?: string;
fields?: LeadFormFieldInput[];
}
export interface PublicLeadFormDto {
name: string;
description: string | null;
thankYouMessage: string | null;
submitLabel: string;
submitColor: string;
hasCover: boolean;
coverPath: string | null;
fields: Array<{
key: string;
label: string;
fieldType: LeadFormFieldType | string;
required: boolean;
placeholder: string | null;
options: string[];
}>;
}
export interface LeadFormSubmissionDto {
id: string;
values: Record<string, string>;
personId: string | null;
createdAt: string;
}
export interface SubmitLeadFormRequest {
values: Record<string, string>;
}
@@ -1604,6 +1649,76 @@ export interface LoyaltyMemberDto {
loyaltyPoints: number;
loyaltyTier: LoyaltyTierSummaryDto | null;
company: CompanySummaryDto | null;
lastActivityAt: string | null;
}
export interface LoyaltyStatsDto {
memberCount: number;
memberDeltaPct: number | null;
pointsIssued: number;
pointsIssuedDeltaPct: number | null;
rewardsUsed: number;
rewardsUsedDeltaPct: number | null;
retentionRate: number;
retentionDeltaPct: number | null;
}
export interface LoyaltySettingsDto {
enabled: boolean;
welcomePoints: number;
notifyOnAdjust: boolean;
}
export type UpdateLoyaltySettingsRequest = Partial<LoyaltySettingsDto>;
export interface LoyaltyRewardDto {
id: string;
name: string;
description: string | null;
pointsCost: number;
isActive: boolean;
position: number;
color: string | null;
redemptionCount?: number;
}
export interface CreateLoyaltyRewardRequest {
name: string;
description?: string | null;
pointsCost: number;
isActive?: boolean;
color?: string | null;
}
export type UpdateLoyaltyRewardRequest = Partial<CreateLoyaltyRewardRequest>;
export interface LoyaltyLedgerDto {
id: string;
delta: number;
reason: string | null;
source: string;
createdAt: string;
}
export interface BulkAdjustLoyaltyRequest {
personIds: string[];
delta: number;
reason?: string;
}
export interface LoyaltyMessageRequest {
personIds: string[];
text: string;
}
export interface LoyaltyMessageResult {
sent: number;
skipped: number;
failed: number;
}
export interface RedeemLoyaltyRewardRequest {
personId: string;
}
/** اتوماسیون اگر/آنگاه */