feat(telegram-bot): visual scenario editor with 3 modes (step/schema/json)
- Step Editor: form-based editing for content managers (no JSON knowledge needed) - Editable text/buttons/targets via dropdowns - Main flow + collapsible sidebar branches - Add/delete steps, buttons, conditions - Schema (React Flow): WatBot-style canvas with draggable nodes - NodeShell + PortRow components for clean card design - White cards with colored left accent borders - Blue bezier curves for all connections - Auto-layout: main column + BFS side columns - Pan/zoom, snap-to-grid, MiniMap - Backend: polling service, /stop command, screenshot API, analytics Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2149,6 +2149,7 @@ model TgBotScenario {
|
||||
name String
|
||||
description String?
|
||||
scenario Json // full JSON scenario
|
||||
positions Json? // { nodeId: { x, y } } for visual editor
|
||||
version Int @default(1)
|
||||
isPublished Boolean @default(false)
|
||||
isDraft Boolean @default(true)
|
||||
|
||||
@@ -37,6 +37,7 @@ import { CrmModule } from './modules/crm';
|
||||
import { RolesModule } from './modules/roles/roles.module';
|
||||
import { BookingModule } from './modules/booking';
|
||||
import { TelegramBotModule } from './modules/telegram-bot';
|
||||
import { ScreenshotsModule } from './modules/screenshots';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -80,6 +81,7 @@ import { TelegramBotModule } from './modules/telegram-bot';
|
||||
RolesModule,
|
||||
BookingModule,
|
||||
TelegramBotModule,
|
||||
ScreenshotsModule,
|
||||
],
|
||||
providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }],
|
||||
})
|
||||
@@ -93,6 +95,7 @@ export class AppModule implements NestModule {
|
||||
'v1/crm/deals/webhook/(.*)',
|
||||
'v1/booking/public/(.*)',
|
||||
'telegram-bot/webhook/(.*)',
|
||||
'v1/screenshots/(.*)',
|
||||
)
|
||||
.forRoutes('*');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Request } from 'express';
|
||||
|
||||
@Injectable()
|
||||
export class ScreenshotTokenGuard implements CanActivate {
|
||||
private readonly tokens: Set<string>;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
const raw = this.configService.get<string>('SCREENSHOT_API_TOKENS', '');
|
||||
this.tokens = new Set(
|
||||
raw
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const auth = request.headers.authorization;
|
||||
|
||||
if (!auth?.startsWith('Bearer ')) {
|
||||
throw new UnauthorizedException('Missing Bearer token');
|
||||
}
|
||||
|
||||
const token = auth.slice(7);
|
||||
if (!this.tokens.has(token)) {
|
||||
throw new UnauthorizedException('Invalid token');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
2
apps/api/src/modules/screenshots/index.ts
Normal file
2
apps/api/src/modules/screenshots/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './screenshots.module';
|
||||
export * from './screenshots.service';
|
||||
82
apps/api/src/modules/screenshots/screenshots.controller.ts
Normal file
82
apps/api/src/modules/screenshots/screenshots.controller.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Get,
|
||||
Delete,
|
||||
Param,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { diskStorage } from 'multer';
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiConsumes, ApiResponse } from '@nestjs/swagger';
|
||||
import { ScreenshotsService } from './screenshots.service';
|
||||
import { ScreenshotTokenGuard } from './guards/screenshot-token.guard';
|
||||
|
||||
const TMP_DIR = join(process.cwd(), '..', '..', 'data', 'screenshots', '.tmp');
|
||||
|
||||
@ApiTags('Screenshots')
|
||||
@Controller('screenshots')
|
||||
export class ScreenshotsController {
|
||||
constructor(private readonly screenshotsService: ScreenshotsService) {
|
||||
if (!existsSync(TMP_DIR)) {
|
||||
mkdirSync(TMP_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
@Post('upload')
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@UseGuards(ScreenshotTokenGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Upload a screenshot' })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiResponse({ status: 201, description: 'Screenshot uploaded' })
|
||||
@ApiResponse({ status: 401, description: 'Invalid token' })
|
||||
@ApiResponse({ status: 413, description: 'File too large' })
|
||||
@ApiResponse({ status: 422, description: 'Invalid format' })
|
||||
@UseInterceptors(
|
||||
FileInterceptor('image', {
|
||||
storage: diskStorage({
|
||||
destination: (_req, _file, cb) => {
|
||||
if (!existsSync(TMP_DIR)) mkdirSync(TMP_DIR, { recursive: true });
|
||||
cb(null, TMP_DIR);
|
||||
},
|
||||
filename: (_req, file, cb) => {
|
||||
cb(null, `upload_${Date.now()}_${Math.random().toString(36).slice(2)}`);
|
||||
},
|
||||
}),
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
}),
|
||||
)
|
||||
upload(@UploadedFile() file: Express.Multer.File) {
|
||||
const data = this.screenshotsService.upload(file);
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(ScreenshotTokenGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Delete a screenshot' })
|
||||
@ApiResponse({ status: 200, description: 'Screenshot deleted' })
|
||||
@ApiResponse({ status: 401, description: 'Invalid token' })
|
||||
@ApiResponse({ status: 404, description: 'Screenshot not found' })
|
||||
delete(@Param('id') id: string) {
|
||||
this.screenshotsService.delete(id);
|
||||
return { success: true, message: 'Screenshot deleted' };
|
||||
}
|
||||
|
||||
@Get('info')
|
||||
@UseGuards(ScreenshotTokenGuard)
|
||||
@ApiBearerAuth()
|
||||
@ApiOperation({ summary: 'Server info' })
|
||||
@ApiResponse({ status: 200, description: 'Server information' })
|
||||
info() {
|
||||
const data = this.screenshotsService.getInfo();
|
||||
return { success: true, data };
|
||||
}
|
||||
}
|
||||
9
apps/api/src/modules/screenshots/screenshots.module.ts
Normal file
9
apps/api/src/modules/screenshots/screenshots.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ScreenshotsController } from './screenshots.controller';
|
||||
import { ScreenshotsService } from './screenshots.service';
|
||||
|
||||
@Module({
|
||||
controllers: [ScreenshotsController],
|
||||
providers: [ScreenshotsService],
|
||||
})
|
||||
export class ScreenshotsModule {}
|
||||
122
apps/api/src/modules/screenshots/screenshots.service.ts
Normal file
122
apps/api/src/modules/screenshots/screenshots.service.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
PayloadTooLargeException,
|
||||
UnprocessableEntityException,
|
||||
} from '@nestjs/common';
|
||||
import { randomBytes } from 'crypto';
|
||||
import { existsSync, mkdirSync, unlinkSync, renameSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const SCREENSHOTS_DIR = join(process.cwd(), '..', '..', 'data', 'screenshots');
|
||||
const MAX_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
const ALLOWED_MIMETYPES: Record<string, string> = {
|
||||
'image/png': 'png',
|
||||
'image/jpeg': 'jpg',
|
||||
'image/gif': 'gif',
|
||||
'image/webp': 'webp',
|
||||
};
|
||||
const ALLOWED_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp']);
|
||||
const BASE62 = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
|
||||
@Injectable()
|
||||
export class ScreenshotsService {
|
||||
constructor() {
|
||||
if (!existsSync(SCREENSHOTS_DIR)) {
|
||||
mkdirSync(SCREENSHOTS_DIR, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
generateId(): string {
|
||||
const bytes = randomBytes(8);
|
||||
let id = '';
|
||||
for (let i = 0; i < 8; i++) {
|
||||
id += BASE62[bytes[i] % 62];
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
validateFile(file: Express.Multer.File): string {
|
||||
if (!file) {
|
||||
throw new UnprocessableEntityException('No image provided');
|
||||
}
|
||||
|
||||
if (file.size > MAX_SIZE) {
|
||||
throw new PayloadTooLargeException(
|
||||
`File too large: ${(file.size / 1024 / 1024).toFixed(1)}MB, max 10MB`,
|
||||
);
|
||||
}
|
||||
|
||||
const ext = ALLOWED_MIMETYPES[file.mimetype];
|
||||
if (!ext) {
|
||||
throw new UnprocessableEntityException(
|
||||
`Invalid format: ${file.mimetype}. Allowed: png, jpg, gif, webp`,
|
||||
);
|
||||
}
|
||||
|
||||
return ext;
|
||||
}
|
||||
|
||||
getFilePath(id: string, ext: string): string {
|
||||
return join(SCREENSHOTS_DIR, `${id}.${ext}`);
|
||||
}
|
||||
|
||||
findFile(id: string): { path: string; ext: string } | null {
|
||||
for (const ext of ALLOWED_EXTENSIONS) {
|
||||
const path = this.getFilePath(id, ext);
|
||||
if (existsSync(path)) {
|
||||
return { path, ext };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
upload(file: Express.Multer.File): {
|
||||
id: string;
|
||||
url: string;
|
||||
delete_url: string;
|
||||
size: number;
|
||||
created_at: string;
|
||||
} {
|
||||
const ext = this.validateFile(file);
|
||||
const id = this.generateId();
|
||||
|
||||
// File already saved by multer diskStorage, just rename
|
||||
const targetPath = this.getFilePath(id, ext);
|
||||
renameSync(file.path, targetPath);
|
||||
|
||||
return {
|
||||
id,
|
||||
url: `https://platform.myfitcrm.ru/s/${id}.${ext}`,
|
||||
delete_url: `https://api.myfitcrm.ru/v1/screenshots/${id}`,
|
||||
size: file.size,
|
||||
created_at: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
delete(id: string): void {
|
||||
const found = this.findFile(id);
|
||||
if (!found) {
|
||||
throw new NotFoundException(`Screenshot ${id} not found`);
|
||||
}
|
||||
unlinkSync(found.path);
|
||||
}
|
||||
|
||||
getInfo(): {
|
||||
server_name: string;
|
||||
max_upload_size: string;
|
||||
allowed_formats: string[];
|
||||
version: string;
|
||||
} {
|
||||
return {
|
||||
server_name: 'MyFitCRM Screenshots',
|
||||
max_upload_size: '10MB',
|
||||
allowed_formats: ['png', 'jpg', 'gif', 'webp'],
|
||||
version: '1.0.0',
|
||||
};
|
||||
}
|
||||
|
||||
getStorageDir(): string {
|
||||
return SCREENSHOTS_DIR;
|
||||
}
|
||||
}
|
||||
@@ -27,8 +27,8 @@ export class BroadcastController {
|
||||
async create(@CurrentUser() user: { clubId: string }, @Body() dto: CreateBroadcastDto) {
|
||||
return this.broadcasts.create(user.clubId, dto.botId, {
|
||||
name: dto.name,
|
||||
message: dto.message as BroadcastMessage,
|
||||
filters: dto.filters as BroadcastFilters,
|
||||
message: dto.message as unknown as BroadcastMessage,
|
||||
filters: dto.filters as unknown as BroadcastFilters,
|
||||
scheduledAt: dto.scheduledAt,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -28,7 +28,10 @@ export class WebhookInputController {
|
||||
@Param('botId') botId: string,
|
||||
@Body() update: TgUpdate,
|
||||
): Promise<{ ok: true }> {
|
||||
this.logger.log(`Webhook received for bot ${botId}, update_id=${update.update_id}`);
|
||||
|
||||
// Enqueue for async processing — respond 200 immediately to Telegram
|
||||
try {
|
||||
await this.incomingQueue.add(
|
||||
'process-update',
|
||||
{ botId, update },
|
||||
@@ -39,6 +42,11 @@ export class WebhookInputController {
|
||||
removeOnFail: 500,
|
||||
},
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
this.logger.error(
|
||||
`Failed to enqueue update: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
@@ -27,4 +27,9 @@ export class UpdateScenarioDto {
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
scenario?: Record<string, unknown>;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Node positions for visual editor { nodeId: { x, y } }' })
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
positions?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -67,6 +67,24 @@ export class TelegramIncomingProcessor extends WorkerHost {
|
||||
const from = message.from;
|
||||
const text = message.text ?? '';
|
||||
|
||||
// Handle /stop — unsubscribe
|
||||
if (text === '/stop') {
|
||||
const existing = await this.subscribers.findByBotAndChat(bot.id, String(chat.id));
|
||||
if (existing) {
|
||||
await this.subscribers.markUnsubscribed(bot.id, String(chat.id));
|
||||
await this.telegramApi.sendMessage(bot.token, {
|
||||
chat_id: chat.id,
|
||||
text: 'Вы отписались от бота. Чтобы подписаться снова, отправьте /start',
|
||||
});
|
||||
await this.botWebhooks
|
||||
.dispatch(bot.id, 'subscriber.blocked', {
|
||||
subscriber: { id: existing.id, chatId: existing.chatId, reason: 'stop_command' },
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse /start command and referral
|
||||
let source: string | undefined;
|
||||
let referrerId: string | undefined;
|
||||
@@ -137,6 +155,22 @@ export class TelegramIncomingProcessor extends WorkerHost {
|
||||
const scenario = bot.scenario.scenario as unknown as BotScenario;
|
||||
const botCtx = { id: bot.id, clubId: bot.clubId, token: bot.token, username: bot.username };
|
||||
|
||||
// Self-referral protection: if user clicked their own link
|
||||
if (referrerId && referrerId === subscriber.id) {
|
||||
await this.telegramApi.sendMessage(bot.token, {
|
||||
chat_id: chat.id,
|
||||
text: '😊 Это ваша собственная реферальная ссылка — отправьте её другу!',
|
||||
parse_mode: 'HTML',
|
||||
reply_markup: this.telegramApi.buildInlineKeyboard([
|
||||
{
|
||||
text: '📤 Поделиться ссылкой',
|
||||
url: `https://t.me/share/url?url=${encodeURIComponent(`https://t.me/${bot.username ?? ''}?start=ref_${subscriber.id}`)}&text=${encodeURIComponent('Попробуй бесплатную тренировку! 🏋️')}`,
|
||||
},
|
||||
]),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// If new user came via referral — notify referrer and route to friendStartNode
|
||||
if (isNew && referrerId) {
|
||||
const friendStartNode = await this.referralService.onFriendJoined(
|
||||
|
||||
@@ -5,7 +5,7 @@ export interface BotAnalytics {
|
||||
subscribers: {
|
||||
total: number;
|
||||
subscribed: number;
|
||||
blocked: number;
|
||||
unsubscribed: number;
|
||||
withPhone: number;
|
||||
bonusGranted: number;
|
||||
mailingConsent: number;
|
||||
@@ -48,17 +48,16 @@ export class AnalyticsService {
|
||||
}
|
||||
|
||||
private async getSubscriberStats(botId: string) {
|
||||
const [total, subscribed, blocked, withPhone, bonusGranted, mailingConsent] = await Promise.all(
|
||||
[
|
||||
const [total, subscribed, unsubscribed, withPhone, bonusGranted, mailingConsent] =
|
||||
await Promise.all([
|
||||
this.prisma.tgBotSubscriber.count({ where: { botId } }),
|
||||
this.prisma.tgBotSubscriber.count({ where: { botId, isSubscribed: true } }),
|
||||
this.prisma.tgBotSubscriber.count({ where: { botId, blockedAt: { not: null } } }),
|
||||
this.prisma.tgBotSubscriber.count({ where: { botId, isSubscribed: false } }),
|
||||
this.prisma.tgBotSubscriber.count({ where: { botId, phone: { not: null } } }),
|
||||
this.prisma.tgBotSubscriber.count({ where: { botId, bonusGranted: true } }),
|
||||
this.prisma.tgBotSubscriber.count({ where: { botId, mailingConsent: true } }),
|
||||
],
|
||||
);
|
||||
return { total, subscribed, blocked, withPhone, bonusGranted, mailingConsent };
|
||||
]);
|
||||
return { total, subscribed, unsubscribed, withPhone, bonusGranted, mailingConsent };
|
||||
}
|
||||
|
||||
private async getReferralStats(botId: string) {
|
||||
|
||||
128
apps/api/src/modules/telegram-bot/services/polling.service.ts
Normal file
128
apps/api/src/modules/telegram-bot/services/polling.service.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Queue } from 'bullmq';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { TelegramApiService, TgUpdate } from './telegram-api.service';
|
||||
import { TgIncomingJobData } from '../processors/telegram-incoming.processor';
|
||||
|
||||
/**
|
||||
* Long-polling fallback for Telegram bots.
|
||||
* Used when webhook delivery is unreliable (e.g. hosting/CDN issues).
|
||||
*
|
||||
* On startup:
|
||||
* - Deletes webhook for each active bot
|
||||
* - Starts polling loop with getUpdates (30s timeout)
|
||||
* - Enqueues each update into BullMQ for processing
|
||||
*/
|
||||
@Injectable()
|
||||
export class TelegramPollingService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(TelegramPollingService.name);
|
||||
private running = false;
|
||||
private readonly pollers = new Map<
|
||||
string,
|
||||
{ token: string; offset: number; abort: AbortController }
|
||||
>();
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly telegramApi: TelegramApiService,
|
||||
@InjectQueue('telegram-bot-incoming')
|
||||
private readonly incomingQueue: Queue<TgIncomingJobData>,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
const bots = await this.prisma.tgBot.findMany({
|
||||
where: { isActive: true },
|
||||
select: { id: true, token: true, username: true },
|
||||
});
|
||||
|
||||
if (bots.length === 0) {
|
||||
this.logger.log('No active bots — polling not started');
|
||||
return;
|
||||
}
|
||||
|
||||
this.running = true;
|
||||
|
||||
for (const bot of bots) {
|
||||
// Delete webhook so we can use getUpdates
|
||||
await this.telegramApi.deleteWebhook(bot.token).catch(() => {});
|
||||
this.logger.log(`Starting polling for @${bot.username ?? bot.id}`);
|
||||
|
||||
const abort = new AbortController();
|
||||
this.pollers.set(bot.id, { token: bot.token, offset: 0, abort });
|
||||
|
||||
// Fire and forget — loop runs in background
|
||||
this.pollLoop(bot.id).catch((err) => {
|
||||
this.logger.error(`Poll loop crashed for ${bot.id}: ${err}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
this.running = false;
|
||||
for (const [id, poller] of this.pollers) {
|
||||
poller.abort.abort();
|
||||
this.logger.log(`Stopped polling for ${id}`);
|
||||
}
|
||||
this.pollers.clear();
|
||||
}
|
||||
|
||||
private async pollLoop(botId: string) {
|
||||
const poller = this.pollers.get(botId);
|
||||
if (!poller) return;
|
||||
|
||||
while (this.running) {
|
||||
try {
|
||||
const updates = await this.getUpdates(poller.token, poller.offset, poller.abort.signal);
|
||||
|
||||
for (const update of updates) {
|
||||
await this.incomingQueue.add(
|
||||
'process-update',
|
||||
{ botId, update },
|
||||
{
|
||||
attempts: 3,
|
||||
backoff: { type: 'exponential', delay: 2000 },
|
||||
removeOnComplete: 100,
|
||||
removeOnFail: 500,
|
||||
},
|
||||
);
|
||||
// Confirm offset
|
||||
poller.offset = update.update_id + 1;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (!this.running) break;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(`Polling error for ${botId}: ${msg}`);
|
||||
// Back off on error
|
||||
await this.sleep(5000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async getUpdates(
|
||||
token: string,
|
||||
offset: number,
|
||||
signal: AbortSignal,
|
||||
): Promise<TgUpdate[]> {
|
||||
const url = `https://api.telegram.org/bot${token}/getUpdates`;
|
||||
const params = new URLSearchParams({
|
||||
offset: String(offset),
|
||||
limit: '100',
|
||||
timeout: '30',
|
||||
allowed_updates: JSON.stringify(['message', 'callback_query']),
|
||||
});
|
||||
|
||||
const res = await fetch(`${url}?${params.toString()}`, { signal });
|
||||
const data = (await res.json()) as { ok: boolean; result?: TgUpdate[] };
|
||||
|
||||
if (!data.ok) {
|
||||
throw new Error(`getUpdates failed`);
|
||||
}
|
||||
|
||||
return data.result ?? [];
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
@@ -143,6 +143,15 @@ export class ScenarioEngineService {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't send messages to unsubscribed users
|
||||
if (depth === 0) {
|
||||
const sub = await this.prisma.tgBotSubscriber.findUnique({
|
||||
where: { id: ctx.subscriberId },
|
||||
select: { isSubscribed: true },
|
||||
});
|
||||
if (sub && !sub.isSubscribed) return;
|
||||
}
|
||||
|
||||
const node = ctx.scenario.nodes[nodeId];
|
||||
if (!node) {
|
||||
this.logger.warn(`Node "${nodeId}" not found in scenario`);
|
||||
|
||||
@@ -66,7 +66,7 @@ export class ScenarioService {
|
||||
async update(
|
||||
clubId: string,
|
||||
id: string,
|
||||
data: { name?: string; description?: string; scenario?: unknown },
|
||||
data: { name?: string; description?: string; scenario?: unknown; positions?: unknown },
|
||||
) {
|
||||
const existing = await this.prisma.tgBotScenario.findFirst({ where: { id, clubId } });
|
||||
if (!existing) throw new NotFoundException('Scenario not found');
|
||||
@@ -81,6 +81,9 @@ export class ScenarioService {
|
||||
updateData.isDraft = true;
|
||||
updateData.isPublished = false;
|
||||
}
|
||||
if (data.positions !== undefined) {
|
||||
updateData.positions = data.positions as Prisma.InputJsonValue;
|
||||
}
|
||||
|
||||
return this.prisma.tgBotScenario.update({ where: { id }, data: updateData });
|
||||
}
|
||||
|
||||
@@ -104,6 +104,18 @@ export class SubscriberService {
|
||||
});
|
||||
}
|
||||
|
||||
async markUnsubscribed(botId: string, chatId: string) {
|
||||
const sub = await this.prisma.tgBotSubscriber.findUnique({
|
||||
where: { botId_chatId: { botId, chatId } },
|
||||
});
|
||||
if (!sub) return;
|
||||
|
||||
await this.prisma.tgBotSubscriber.update({
|
||||
where: { id: sub.id },
|
||||
data: { isSubscribed: false, blockedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
async markBlocked(botId: string, chatId: string) {
|
||||
const sub = await this.prisma.tgBotSubscriber.findUnique({
|
||||
where: { botId_chatId: { botId, chatId } },
|
||||
|
||||
@@ -11,6 +11,7 @@ import { BroadcastService } from './services/broadcast.service';
|
||||
import { BotWebhookService } from './services/bot-webhook.service';
|
||||
import { CrmIntegrationService } from './services/crm-integration.service';
|
||||
import { AnalyticsService } from './services/analytics.service';
|
||||
import { TelegramPollingService } from './services/polling.service';
|
||||
|
||||
// Processors
|
||||
import { TelegramIncomingProcessor } from './processors/telegram-incoming.processor';
|
||||
@@ -49,6 +50,7 @@ import { WebhookInputController } from './controllers/webhook-input.controller';
|
||||
BotWebhookService,
|
||||
CrmIntegrationService,
|
||||
AnalyticsService,
|
||||
TelegramPollingService,
|
||||
// Processors
|
||||
TelegramIncomingProcessor,
|
||||
DelayNodeProcessor,
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dagrejs/dagre": "^2.0.4",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
|
||||
@@ -13,9 +13,11 @@ import {
|
||||
Plus,
|
||||
Code,
|
||||
Workflow,
|
||||
ListOrdered,
|
||||
} from 'lucide-react';
|
||||
import { api } from '@/lib/api';
|
||||
import { FlowEditor } from '@/components/telegram-bot/flow-editor/flow-editor';
|
||||
import { StepEditor } from '@/components/telegram-bot/flow-editor/step-editor';
|
||||
import type { ScenarioJson } from '@/components/telegram-bot/flow-editor/scenario-converter';
|
||||
|
||||
interface Scenario {
|
||||
@@ -41,7 +43,7 @@ export default function ScenarioPage() {
|
||||
const [scenarios, setScenarios] = useState<Scenario[]>([]);
|
||||
const [selected, setSelected] = useState<Scenario | null>(null);
|
||||
const [jsonText, setJsonText] = useState('');
|
||||
const [editorMode, setEditorMode] = useState<'visual' | 'json'>('json');
|
||||
const [editorMode, setEditorMode] = useState<'steps' | 'visual' | 'json'>('steps');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [validating, setValidating] = useState(false);
|
||||
const [validationResult, setValidationResult] = useState<{
|
||||
@@ -239,6 +241,16 @@ export default function ScenarioPage() {
|
||||
|
||||
{/* Editor mode toggle */}
|
||||
<div className="mb-4 flex items-center gap-1 rounded-lg bg-[var(--background)] p-1 w-fit border border-[var(--border)]">
|
||||
<button
|
||||
onClick={() => setEditorMode('steps')}
|
||||
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
editorMode === 'steps'
|
||||
? 'bg-[var(--primary)] text-[var(--primary-foreground)]'
|
||||
: 'text-[var(--muted)] hover:text-[var(--text)]'
|
||||
}`}
|
||||
>
|
||||
<ListOrdered className="h-3.5 w-3.5" /> Редактор
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditorMode('visual')}
|
||||
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${
|
||||
@@ -247,11 +259,11 @@ export default function ScenarioPage() {
|
||||
: 'text-[var(--muted)] hover:text-[var(--text)]'
|
||||
}`}
|
||||
>
|
||||
<Workflow className="h-3.5 w-3.5" /> Визуальный
|
||||
<Workflow className="h-3.5 w-3.5" /> Схема
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (editorMode === 'visual' && selected) {
|
||||
if (editorMode !== 'json' && selected) {
|
||||
setJsonText(JSON.stringify(selected.scenario, null, 2));
|
||||
}
|
||||
setEditorMode('json');
|
||||
@@ -266,10 +278,36 @@ export default function ScenarioPage() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Visual Editor */}
|
||||
{/* Step Editor (form-based) */}
|
||||
{editorMode === 'steps' && selected?.scenario && (
|
||||
<StepEditor
|
||||
scenario={selected.scenario as unknown as ScenarioJson}
|
||||
onChange={async (updatedScenario) => {
|
||||
try {
|
||||
setSaving(true);
|
||||
await api.patch(`/telegram-bot/scenarios/${selected.id}`, {
|
||||
scenario: updatedScenario,
|
||||
});
|
||||
setSuccess('Сценарий сохранён');
|
||||
setTimeout(() => setSuccess(''), 3000);
|
||||
await fetchData();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Ошибка');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Visual Editor (React Flow) */}
|
||||
{editorMode === 'visual' && selected?.scenario && (
|
||||
<FlowEditor
|
||||
scenario={selected.scenario as unknown as ScenarioJson}
|
||||
positions={
|
||||
(selected as unknown as { positions?: Record<string, { x: number; y: number }> })
|
||||
.positions
|
||||
}
|
||||
onSave={async (updatedScenario) => {
|
||||
try {
|
||||
setSaving(true);
|
||||
@@ -285,6 +323,11 @@ export default function ScenarioPage() {
|
||||
setSaving(false);
|
||||
}
|
||||
}}
|
||||
onPositionsChange={async (positions) => {
|
||||
await api
|
||||
.patch(`/telegram-bot/scenarios/${selected.id}`, { positions })
|
||||
.catch(() => {});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -349,8 +392,8 @@ export default function ScenarioPage() {
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-xs text-[var(--muted)]">
|
||||
Визуальный редактор (React Flow) будет доступен в следующем обновлении. Сейчас можно
|
||||
редактировать JSON напрямую.
|
||||
Редактор: пошаговое редактирование сценария. Схема: визуальная карта (React Flow). JSON:
|
||||
прямое редактирование.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,146 +1,177 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
Panel,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
addEdge,
|
||||
type Connection,
|
||||
MarkerType,
|
||||
type Node,
|
||||
type Edge,
|
||||
type NodeTypes,
|
||||
type OnNodesChange,
|
||||
type OnEdgesChange,
|
||||
applyNodeChanges,
|
||||
applyEdgeChanges,
|
||||
type NodeMouseHandler,
|
||||
BackgroundVariant,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
|
||||
import type { ScenarioJson } from './scenario-converter';
|
||||
import { scenarioToFlow, extractPositions } from './scenario-converter';
|
||||
import { MessageNode } from './nodes/message-node';
|
||||
import { ConditionNode } from './nodes/condition-node';
|
||||
import { ActionNode } from './nodes/action-node';
|
||||
import { ConditionNode } from './nodes/condition-node';
|
||||
import { InputNode } from './nodes/input-node';
|
||||
import { DelayNode } from './nodes/delay-node';
|
||||
import { ReferralNode } from './nodes/referral-node';
|
||||
import { scenarioToFlow, type ScenarioJson } from './scenario-converter';
|
||||
import { NodePropertiesPanel } from './panels/node-properties-panel';
|
||||
|
||||
// ─── Props ──────────────────────────────────────────────────────
|
||||
|
||||
interface FlowEditorProps {
|
||||
scenario: ScenarioJson;
|
||||
positions?: Record<string, { x: number; y: number }> | null;
|
||||
onSave: (scenario: ScenarioJson) => void;
|
||||
onPositionsChange?: (positions: Record<string, { x: number; y: number }>) => void;
|
||||
}
|
||||
|
||||
// ─── Node type registry ─────────────────────────────────────────
|
||||
|
||||
const nodeTypes: NodeTypes = {
|
||||
'scenario-message': MessageNode,
|
||||
'scenario-condition': ConditionNode,
|
||||
'scenario-action': ActionNode,
|
||||
'scenario-condition': ConditionNode,
|
||||
'scenario-input': InputNode,
|
||||
'scenario-delay': DelayNode,
|
||||
'scenario-referral': ReferralNode,
|
||||
};
|
||||
|
||||
const NODE_TEMPLATES: Array<{ type: string; label: string; color: string }> = [
|
||||
{ type: 'message', label: 'Сообщение', color: '#3b82f6' },
|
||||
{ type: 'condition', label: 'Условие', color: '#f59e0b' },
|
||||
{ type: 'action', label: 'Действие', color: '#a855f7' },
|
||||
{ type: 'input', label: 'Ввод', color: '#14b8a6' },
|
||||
{ type: 'delay', label: 'Задержка', color: '#f97316' },
|
||||
{ type: 'referral', label: 'Реферал', color: '#ec4899' },
|
||||
// ─── Toolbar templates ──────────────────────────────────────────
|
||||
|
||||
const NODE_TEMPLATES: Array<{ type: string; label: string; icon: string }> = [
|
||||
{ type: 'message', label: 'Сообщение', icon: '\uD83D\uDCAC' },
|
||||
{ type: 'condition', label: 'Условие', icon: '\uD83D\uDD00' },
|
||||
{ type: 'action', label: 'Действие', icon: '\u26A1' },
|
||||
{ type: 'input', label: 'Ввод', icon: '\u270F\uFE0F' },
|
||||
{ type: 'delay', label: 'Задержка', icon: '\u23F0' },
|
||||
{ type: 'referral', label: 'Реферал', icon: '\uD83C\uDF81' },
|
||||
];
|
||||
|
||||
export function FlowEditor({ scenario, onSave: _onSave }: FlowEditorProps) {
|
||||
// ─── MiniMap color mapping ──────────────────────────────────────
|
||||
|
||||
function miniMapNodeColor(node: Node): string {
|
||||
switch (node.type) {
|
||||
case 'scenario-message':
|
||||
return '#29b6f6';
|
||||
case 'scenario-action':
|
||||
return '#ab47bc';
|
||||
case 'scenario-condition':
|
||||
return '#ffa726';
|
||||
case 'scenario-input':
|
||||
return '#26a69a';
|
||||
case 'scenario-delay':
|
||||
return '#ff7043';
|
||||
case 'scenario-referral':
|
||||
return '#ec407a';
|
||||
default:
|
||||
return '#9e9e9e';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Component ──────────────────────────────────────────────────
|
||||
|
||||
export function FlowEditor({
|
||||
scenario,
|
||||
positions: savedPositions,
|
||||
onSave: _onSave,
|
||||
onPositionsChange,
|
||||
}: FlowEditorProps) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const initial = useMemo(() => scenarioToFlow(scenario), [scenario]);
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(initial.nodes);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(initial.edges);
|
||||
const [selectedNode, setSelectedNode] = useState<Node | null>(null);
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
|
||||
// Debounce position saves
|
||||
const positionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Convert scenario -> flow data (memoized on scenario + positions)
|
||||
const initialFlow = useMemo(
|
||||
() => scenarioToFlow(scenario, savedPositions),
|
||||
// eslint-disable-next-line
|
||||
[scenario],
|
||||
);
|
||||
|
||||
const [nodes, setNodes] = useState<Node[]>(initialFlow.nodes);
|
||||
const [edges, setEdges] = useState<Edge[]>(initialFlow.edges);
|
||||
|
||||
// Re-sync when scenario prop changes
|
||||
useEffect(() => {
|
||||
const flow = scenarioToFlow(scenario, savedPositions);
|
||||
setNodes(flow.nodes);
|
||||
setEdges(flow.edges);
|
||||
// eslint-disable-next-line
|
||||
}, [scenario]);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const onConnect = useCallback(
|
||||
(params: Connection) => {
|
||||
setEdges((eds) => addEdge({ ...params, type: 'smoothstep', style: { stroke: '#888' } }, eds));
|
||||
},
|
||||
[setEdges],
|
||||
);
|
||||
// Node changes (drag, select, etc.)
|
||||
const onNodesChange: OnNodesChange = useCallback(
|
||||
(changes) => {
|
||||
setNodes((nds) => applyNodeChanges(changes, nds));
|
||||
|
||||
const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => {
|
||||
setSelectedNode(node);
|
||||
}, []);
|
||||
|
||||
const onPaneClick = useCallback(() => {
|
||||
setSelectedNode(null);
|
||||
}, []);
|
||||
|
||||
const addNode = useCallback(
|
||||
(type: string) => {
|
||||
const id = `node_${Date.now()}`;
|
||||
const defaultData: Record<string, unknown> = { nodeType: type, nodeId: id };
|
||||
|
||||
switch (type) {
|
||||
case 'message':
|
||||
defaultData.text = 'Новое сообщение';
|
||||
defaultData.buttons = [];
|
||||
defaultData.buttonsLayout = 'inline';
|
||||
break;
|
||||
case 'condition':
|
||||
defaultData.rules = [];
|
||||
break;
|
||||
case 'action':
|
||||
defaultData.actions = [];
|
||||
break;
|
||||
case 'input':
|
||||
defaultData.prompt = 'Введите значение:';
|
||||
defaultData.inputType = 'text';
|
||||
defaultData.saveAs = 'value';
|
||||
break;
|
||||
case 'delay':
|
||||
defaultData.duration = '30m';
|
||||
break;
|
||||
case 'referral':
|
||||
defaultData.generateLink = true;
|
||||
defaultData.trackVariable = 'invited_count';
|
||||
defaultData.targetCount = 2;
|
||||
defaultData.onReach = '';
|
||||
defaultData.friendStartNode = '';
|
||||
break;
|
||||
// Debounce position persistence
|
||||
const hasDrag = changes.some((c) => c.type === 'position' && c.dragging === false);
|
||||
if (hasDrag && onPositionsChange) {
|
||||
if (positionTimerRef.current) clearTimeout(positionTimerRef.current);
|
||||
positionTimerRef.current = setTimeout(() => {
|
||||
setNodes((currentNodes) => {
|
||||
onPositionsChange(extractPositions(currentNodes));
|
||||
return currentNodes;
|
||||
});
|
||||
}, 500);
|
||||
}
|
||||
|
||||
const newNode: Node = {
|
||||
id,
|
||||
type: `scenario-${type}`,
|
||||
position: { x: 100 + Math.random() * 200, y: 100 + Math.random() * 200 },
|
||||
data: defaultData,
|
||||
};
|
||||
|
||||
setNodes((nds) => [...nds, newNode]);
|
||||
},
|
||||
[setNodes],
|
||||
[onPositionsChange],
|
||||
);
|
||||
|
||||
const deleteNode = useCallback(
|
||||
(nodeId: string) => {
|
||||
// Edge changes (select, remove)
|
||||
const onEdgesChange: OnEdgesChange = useCallback((changes) => {
|
||||
setEdges((eds) => applyEdgeChanges(changes, eds));
|
||||
}, []);
|
||||
|
||||
// Node click -> show properties panel
|
||||
const onNodeClick: NodeMouseHandler = useCallback((_event, node) => {
|
||||
setSelectedNodeId(node.id);
|
||||
}, []);
|
||||
|
||||
// Pane click -> deselect
|
||||
const onPaneClick = useCallback(() => {
|
||||
setSelectedNodeId(null);
|
||||
}, []);
|
||||
|
||||
// Properties panel: update node data
|
||||
const handleNodeUpdate = useCallback((nodeId: string, data: Record<string, unknown>) => {
|
||||
setNodes((nds) =>
|
||||
nds.map((n) => (n.id === nodeId ? { ...n, data: { ...n.data, ...data } } : n)),
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Properties panel: delete node
|
||||
const handleNodeDelete = useCallback((nodeId: string) => {
|
||||
setNodes((nds) => nds.filter((n) => n.id !== nodeId));
|
||||
setEdges((eds) => eds.filter((e) => e.source !== nodeId && e.target !== nodeId));
|
||||
setSelectedNode(null);
|
||||
},
|
||||
[setNodes, setEdges],
|
||||
setSelectedNodeId(null);
|
||||
}, []);
|
||||
|
||||
const selectedNode = useMemo(
|
||||
() => nodes.find((n) => n.id === selectedNodeId) ?? null,
|
||||
[nodes, selectedNodeId],
|
||||
);
|
||||
|
||||
const updateNodeData = useCallback(
|
||||
(nodeId: string, newData: Record<string, unknown>) => {
|
||||
setNodes((nds) =>
|
||||
nds.map((n) => (n.id === nodeId ? { ...n, data: { ...n.data, ...newData } } : n)),
|
||||
);
|
||||
setSelectedNode((prev) =>
|
||||
prev && prev.id === nodeId ? { ...prev, data: { ...prev.data, ...newData } } : prev,
|
||||
);
|
||||
},
|
||||
[setNodes],
|
||||
);
|
||||
const nodeCount = Object.keys(scenario.nodes).length;
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
@@ -151,66 +182,74 @@ export function FlowEditor({ scenario, onSave: _onSave }: FlowEditorProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-[700px] w-full rounded-xl border border-[var(--border)] overflow-hidden relative">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onNodeClick={onNodeClick}
|
||||
onPaneClick={onPaneClick}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
snapToGrid
|
||||
snapGrid={[20, 20]}
|
||||
defaultEdgeOptions={{ type: 'smoothstep' }}
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#ddd" />
|
||||
<Controls />
|
||||
<MiniMap
|
||||
nodeStrokeWidth={2}
|
||||
nodeColor={(n) => {
|
||||
const type = n.type?.replace('scenario-', '') ?? '';
|
||||
const tpl = NODE_TEMPLATES.find((t) => t.type === type);
|
||||
return tpl?.color ?? '#888';
|
||||
}}
|
||||
maskColor="rgba(0,0,0,0.1)"
|
||||
/>
|
||||
|
||||
{/* Add node toolbar */}
|
||||
<Panel position="top-left">
|
||||
<div className="flex gap-1.5 rounded-lg bg-white/90 p-2 shadow-md backdrop-blur">
|
||||
{NODE_TEMPLATES.map((tpl) => (
|
||||
<div className="w-full rounded-xl border border-[var(--border)] overflow-hidden relative">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between border-b border-[var(--border)] bg-white px-3 py-2 z-10 relative">
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{NODE_TEMPLATES.map((t) => (
|
||||
<button
|
||||
key={tpl.type}
|
||||
onClick={() => addNode(tpl.type)}
|
||||
className="rounded-md px-2.5 py-1.5 text-xs font-medium text-white transition-opacity hover:opacity-80"
|
||||
style={{ backgroundColor: tpl.color }}
|
||||
title={`Добавить: ${tpl.label}`}
|
||||
key={t.type}
|
||||
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-[12px] font-medium text-[#424242] bg-[#fafafa] border border-[#e0e0e0] hover:bg-[#f5f5f5] hover:border-[#bdbdbd] transition-colors"
|
||||
title={`Добавить: ${t.label}`}
|
||||
>
|
||||
+ {tpl.label}
|
||||
<span>{t.icon}</span> {t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{/* Node count */}
|
||||
<Panel position="bottom-left">
|
||||
<div className="rounded-lg bg-white/90 px-3 py-1.5 text-xs text-gray-500 shadow-sm backdrop-blur">
|
||||
{nodes.length} нод · {edges.length} связей
|
||||
<div className="text-[11px] text-[#757575] ml-4 shrink-0">{nodeCount} нод</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
|
||||
{/* Properties panel */}
|
||||
{/* React Flow Canvas */}
|
||||
<div style={{ minHeight: 700, height: 700, width: '100%' }}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onNodeClick={onNodeClick}
|
||||
onPaneClick={onPaneClick}
|
||||
snapToGrid
|
||||
snapGrid={[20, 20]}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.15 }}
|
||||
minZoom={0.1}
|
||||
maxZoom={2}
|
||||
defaultEdgeOptions={{
|
||||
type: 'default',
|
||||
style: { strokeWidth: 2, stroke: '#29b6f6' },
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
width: 14,
|
||||
height: 14,
|
||||
color: '#29b6f6',
|
||||
},
|
||||
}}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#e0e0e0" />
|
||||
<Controls showInteractive={false} position="bottom-left" />
|
||||
<MiniMap
|
||||
nodeColor={miniMapNodeColor}
|
||||
nodeStrokeWidth={2}
|
||||
pannable
|
||||
zoomable
|
||||
position="bottom-right"
|
||||
style={{ width: 160, height: 100 }}
|
||||
/>
|
||||
</ReactFlow>
|
||||
</div>
|
||||
|
||||
{/* Properties Panel */}
|
||||
{selectedNode && (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<NodePropertiesPanel
|
||||
node={selectedNode}
|
||||
onUpdate={updateNodeData}
|
||||
onDelete={deleteNode}
|
||||
onClose={() => setSelectedNode(null)}
|
||||
onUpdate={handleNodeUpdate}
|
||||
onDelete={handleNodeDelete}
|
||||
onClose={() => setSelectedNodeId(null)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,35 +1,117 @@
|
||||
'use client';
|
||||
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
import { Zap } from 'lucide-react';
|
||||
import { NodeShell } from './node-shell';
|
||||
|
||||
interface ActionNodeData {
|
||||
actions?: Array<{ type: string; variable?: string; value?: unknown }>;
|
||||
isStart?: boolean;
|
||||
nodeId?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export function ActionNode({ data }: { data: ActionNodeData }) {
|
||||
const actions = (data.actions ?? []) as Array<{ type: string; variable?: string }>;
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
set_variable: 'Переменная',
|
||||
grant_bonus: 'Бонус',
|
||||
set_consent: 'Согласие',
|
||||
crm_deal: 'Сделка CRM',
|
||||
webhook: 'Webhook',
|
||||
};
|
||||
|
||||
const ACTION_ICONS: Record<string, string> = {
|
||||
set_variable: '\uD83D\uDCCC',
|
||||
grant_bonus: '\uD83C\uDF81',
|
||||
set_consent: '\u2705',
|
||||
crm_deal: '\uD83D\uDCCB',
|
||||
webhook: '\uD83D\uDD17',
|
||||
};
|
||||
|
||||
export function ActionNode({ data, id }: { data: ActionNodeData; id: string }) {
|
||||
const actions = (data.actions ?? []) as Array<{
|
||||
type: string;
|
||||
variable?: string;
|
||||
value?: unknown;
|
||||
}>;
|
||||
const isStart = data.isStart;
|
||||
const nodeId = (data.nodeId as string) || id;
|
||||
|
||||
return (
|
||||
<div className="min-w-[180px] max-w-[240px] rounded-xl border-2 border-purple-400 bg-white shadow-sm">
|
||||
<Handle type="target" position={Position.Left} className="!bg-purple-400 !w-3 !h-3" />
|
||||
<NodeShell
|
||||
accentColor="#ab47bc"
|
||||
icon={'\u26A1'}
|
||||
title="Действие"
|
||||
nodeId={nodeId}
|
||||
isStart={isStart}
|
||||
>
|
||||
{actions.length === 0 && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontStyle: 'italic',
|
||||
color: '#bdbdbd',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
Нет действий
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-purple-50 rounded-t-lg">
|
||||
<Zap className="h-4 w-4 text-purple-600" />
|
||||
<span className="text-xs font-semibold text-purple-800">Действие</span>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2 space-y-1">
|
||||
{actions.map((a, i) => (
|
||||
<div key={i} className="rounded bg-purple-50 px-2 py-0.5 text-[10px] text-purple-700">
|
||||
{a.type}
|
||||
{a.variable ? `: ${a.variable}` : ''}
|
||||
{actions.slice(0, 3).map((action, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
padding: '2px 0',
|
||||
borderTop: i > 0 ? '1px solid #f5f5f5' : undefined,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 10, lineHeight: 1 }}>
|
||||
{ACTION_ICONS[action.type] ?? '\u2022'}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: '#555',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
>
|
||||
{ACTION_LABELS[action.type] ?? action.type}
|
||||
</span>
|
||||
{action.variable && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 9,
|
||||
color: '#9e9e9e',
|
||||
fontFamily: 'monospace',
|
||||
marginLeft: 'auto',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: 80,
|
||||
}}
|
||||
>
|
||||
{action.variable}
|
||||
{action.value !== undefined
|
||||
? ` = ${typeof action.value === 'object' ? JSON.stringify(action.value) : String(action.value as string | number | boolean)}`
|
||||
: ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Handle type="source" position={Position.Right} className="!bg-purple-400 !w-3 !h-3" />
|
||||
</div>
|
||||
{actions.length > 3 && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: 9,
|
||||
color: '#bdbdbd',
|
||||
margin: '2px 0 0',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
+{actions.length - 3} ещё
|
||||
</p>
|
||||
)}
|
||||
</NodeShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
|
||||
interface ButtonNodeData {
|
||||
text?: string;
|
||||
action?: string;
|
||||
target?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export function ButtonNode({ data }: { data: ButtonNodeData }) {
|
||||
const text = data.text ?? 'Кнопка';
|
||||
const action = data.action ?? 'goto';
|
||||
const target = data.target ?? '';
|
||||
|
||||
const targetLabel =
|
||||
action === 'goto' && target
|
||||
? target
|
||||
: action === 'url'
|
||||
? '🔗 ссылка'
|
||||
: action === 'share_contact'
|
||||
? '📞 контакт'
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border-2 border-[#42a5f5] bg-[#e3f2fd] shadow-sm text-center px-2 py-1"
|
||||
style={{ width: 130, minHeight: 44 }}
|
||||
>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
id="target-top"
|
||||
className="!bg-[#42a5f5] !w-2 !h-2"
|
||||
/>
|
||||
<p className="text-[11px] font-semibold text-[#1565c0] leading-tight line-clamp-2">{text}</p>
|
||||
{targetLabel && <p className="text-[8px] text-[#90caf9] mt-0.5 truncate">→ {targetLabel}</p>}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
id="source-bottom"
|
||||
className="!bg-[#42a5f5] !w-2 !h-2"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,41 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
import { GitBranch } from 'lucide-react';
|
||||
import { NodeShell } from './node-shell';
|
||||
import { PortRow } from './port-row';
|
||||
|
||||
interface ConditionNodeData {
|
||||
rules?: Array<{ condition: string; target: string }>;
|
||||
default?: string;
|
||||
isStart?: boolean;
|
||||
nodeId?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export function ConditionNode({ data }: { data: ConditionNodeData }) {
|
||||
const rules = (data.rules ?? []) as Array<{ condition: string }>;
|
||||
export function ConditionNode({ data, id }: { data: ConditionNodeData; id: string }) {
|
||||
const rules = (data.rules ?? []) as Array<{ condition: string; target: string }>;
|
||||
const defaultTarget = data.default;
|
||||
const isStart = data.isStart;
|
||||
const nodeId = (data.nodeId as string) || id;
|
||||
|
||||
return (
|
||||
<div className="min-w-[200px] max-w-[260px] rounded-xl border-2 border-amber-400 bg-white shadow-sm">
|
||||
<Handle type="target" position={Position.Left} className="!bg-amber-400 !w-3 !h-3" />
|
||||
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-amber-50 rounded-t-lg">
|
||||
<GitBranch className="h-4 w-4 text-amber-600" />
|
||||
<span className="text-xs font-semibold text-amber-800">Условие</span>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2 space-y-1">
|
||||
{rules.map((rule, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded bg-amber-50 px-2 py-0.5 text-[10px] text-amber-700 font-mono"
|
||||
<NodeShell
|
||||
accentColor="#ffa726"
|
||||
icon={'\uD83D\uDD00'}
|
||||
title="Условие"
|
||||
nodeId={nodeId}
|
||||
isStart={isStart}
|
||||
>
|
||||
{rule.condition}
|
||||
</div>
|
||||
))}
|
||||
{data.default && (
|
||||
<div className="rounded bg-gray-50 px-2 py-0.5 text-[10px] text-gray-500">default →</div>
|
||||
{rules.length === 0 && !defaultTarget && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontStyle: 'italic',
|
||||
color: '#bdbdbd',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
Нет условий
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Handle type="source" position={Position.Right} className="!bg-amber-400 !w-3 !h-3" />
|
||||
{/* Rule rows + default — as PortRows */}
|
||||
<div style={{ margin: '0 -10px' }}>
|
||||
{rules.map((rule, i) => (
|
||||
<PortRow key={`rule-${i}`} label={rule.condition} handleId={`source-rule-${i}`} />
|
||||
))}
|
||||
{defaultTarget && <PortRow label="Иначе" handleId="source-default" dotColor="#ffa726" />}
|
||||
</div>
|
||||
</NodeShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
import { Clock } from 'lucide-react';
|
||||
import { NodeShell } from './node-shell';
|
||||
|
||||
interface DelayNodeData {
|
||||
duration?: string;
|
||||
cancelOn?: string;
|
||||
reminder?: string;
|
||||
isStart?: boolean;
|
||||
nodeId?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export function DelayNode({ data }: { data: DelayNodeData }) {
|
||||
export function DelayNode({ data, id }: { data: DelayNodeData; id: string }) {
|
||||
const duration = data.duration ?? '?';
|
||||
const cancelOn = data.cancelOn;
|
||||
const isStart = data.isStart;
|
||||
const nodeId = (data.nodeId as string) || id;
|
||||
|
||||
return (
|
||||
<div className="min-w-[160px] max-w-[220px] rounded-xl border-2 border-orange-400 bg-white shadow-sm">
|
||||
<Handle type="target" position={Position.Left} className="!bg-orange-400 !w-3 !h-3" />
|
||||
<NodeShell
|
||||
accentColor="#ff7043"
|
||||
icon={'\u23F0'}
|
||||
title="Задержка"
|
||||
nodeId={nodeId}
|
||||
isStart={isStart}
|
||||
>
|
||||
{/* Duration — large text */}
|
||||
<p
|
||||
style={{
|
||||
fontSize: 16,
|
||||
fontWeight: 700,
|
||||
color: '#ff7043',
|
||||
textAlign: 'center',
|
||||
margin: '4px 0',
|
||||
}}
|
||||
>
|
||||
{duration}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-orange-50 rounded-t-lg">
|
||||
<Clock className="h-4 w-4 text-orange-600" />
|
||||
<span className="text-xs font-semibold text-orange-800">Задержка</span>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2">
|
||||
<p className="text-sm font-mono text-orange-700">{(data.duration as string) ?? '?'}</p>
|
||||
{data.cancelOn && <p className="text-[10px] text-gray-500 mt-1">Отмена: {data.cancelOn}</p>}
|
||||
</div>
|
||||
|
||||
<Handle type="source" position={Position.Right} className="!bg-orange-400 !w-3 !h-3" />
|
||||
</div>
|
||||
{/* cancelOn below */}
|
||||
{cancelOn && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: '#999',
|
||||
textAlign: 'center',
|
||||
margin: '0 0 2px',
|
||||
}}
|
||||
>
|
||||
Отмена: {cancelOn}
|
||||
</p>
|
||||
)}
|
||||
</NodeShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,36 +1,86 @@
|
||||
'use client';
|
||||
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
import { TextCursorInput } from 'lucide-react';
|
||||
import { NodeShell } from './node-shell';
|
||||
|
||||
interface InputNodeData {
|
||||
prompt?: string;
|
||||
inputType?: string;
|
||||
saveAs?: string;
|
||||
isStart?: boolean;
|
||||
nodeId?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export function InputNode({ data }: { data: InputNodeData }) {
|
||||
const prompt = (data.prompt ?? '').replace(/<[^>]+>/g, '');
|
||||
const preview = prompt.length > 60 ? prompt.slice(0, 60) + '...' : prompt;
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
text: 'Текст',
|
||||
phone: 'Телефон',
|
||||
email: 'Email',
|
||||
number: 'Число',
|
||||
choice: 'Выбор',
|
||||
};
|
||||
|
||||
export function InputNode({ data, id }: { data: InputNodeData; id: string }) {
|
||||
const prompt = data.prompt ?? '';
|
||||
const inputType = data.inputType ?? 'text';
|
||||
const saveAs = data.saveAs ?? '';
|
||||
const isStart = data.isStart;
|
||||
const nodeId = (data.nodeId as string) || id;
|
||||
|
||||
return (
|
||||
<div className="min-w-[200px] max-w-[260px] rounded-xl border-2 border-teal-400 bg-white shadow-sm">
|
||||
<Handle type="target" position={Position.Left} className="!bg-teal-400 !w-3 !h-3" />
|
||||
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-teal-50 rounded-t-lg">
|
||||
<TextCursorInput className="h-4 w-4 text-teal-600" />
|
||||
<span className="text-xs font-semibold text-teal-800">
|
||||
Ввод ({data.inputType ?? 'text'})
|
||||
<NodeShell
|
||||
accentColor="#26a69a"
|
||||
icon={'\u270F\uFE0F'}
|
||||
title="Ввод данных"
|
||||
nodeId={nodeId}
|
||||
isStart={isStart}
|
||||
>
|
||||
{/* Type badge */}
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 9,
|
||||
fontWeight: 600,
|
||||
color: '#26a69a',
|
||||
backgroundColor: '#e0f2f1',
|
||||
borderRadius: 8,
|
||||
padding: '1px 8px',
|
||||
lineHeight: '16px',
|
||||
display: 'inline-block',
|
||||
}}
|
||||
>
|
||||
{TYPE_LABELS[inputType] ?? inputType}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2">
|
||||
<p className="text-xs text-gray-700">{preview || 'Ожидание ввода'}</p>
|
||||
{data.saveAs && <p className="text-[10px] text-teal-600 mt-1 font-mono">→ {data.saveAs}</p>}
|
||||
</div>
|
||||
{/* Prompt preview (2 lines) */}
|
||||
<p
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: '#666',
|
||||
lineHeight: '15px',
|
||||
margin: 0,
|
||||
overflow: 'hidden',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}
|
||||
>
|
||||
{prompt || <span style={{ fontStyle: 'italic', color: '#bdbdbd' }}>Введите запрос...</span>}
|
||||
</p>
|
||||
|
||||
<Handle type="source" position={Position.Right} className="!bg-teal-400 !w-3 !h-3" />
|
||||
</div>
|
||||
{/* saveAs */}
|
||||
{saveAs && (
|
||||
<p
|
||||
style={{
|
||||
marginTop: 4,
|
||||
fontSize: 10,
|
||||
color: '#26a69a',
|
||||
fontFamily: 'monospace',
|
||||
}}
|
||||
>
|
||||
{'\u2192'} {saveAs}
|
||||
</p>
|
||||
)}
|
||||
</NodeShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,58 +1,109 @@
|
||||
'use client';
|
||||
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
import { MessageSquare } from 'lucide-react';
|
||||
import { NodeShell } from './node-shell';
|
||||
import { PortRow } from './port-row';
|
||||
|
||||
interface ButtonData {
|
||||
text: string;
|
||||
action: string;
|
||||
target?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
interface MessageNodeData {
|
||||
text?: string;
|
||||
buttons?: Array<{ text: string; action: string }>;
|
||||
image?: string;
|
||||
buttons?: ButtonData[];
|
||||
isStart?: boolean;
|
||||
nodeId?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export function MessageNode({ data }: { data: MessageNodeData }) {
|
||||
export function MessageNode({ data, id }: { data: MessageNodeData; id: string }) {
|
||||
const text = data.text ?? '';
|
||||
const buttons = (data.buttons ?? []) as Array<{ text: string; action: string }>;
|
||||
const truncated = text.length > 80 ? text.slice(0, 80) + '...' : text;
|
||||
// Strip HTML tags for preview
|
||||
const preview = truncated.replace(/<[^>]+>/g, '');
|
||||
const image = data.image;
|
||||
const buttons = data.buttons ?? [];
|
||||
const isStart = data.isStart;
|
||||
const nodeId = (data.nodeId as string) || id;
|
||||
|
||||
const preview = text.replace(/<[^>]+>/g, '');
|
||||
const truncated = preview.length > 100 ? preview.slice(0, 100) + '...' : preview;
|
||||
|
||||
const gotoButtons = buttons.filter((btn) => btn.action === 'goto' && btn.target);
|
||||
const urlButtons = buttons.filter((btn) => btn.url);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[220px] max-w-[280px] rounded-xl border-2 bg-white shadow-sm ${data.isStart ? 'border-green-500' : 'border-blue-400'}`}
|
||||
<NodeShell
|
||||
accentColor="#29b6f6"
|
||||
icon={'\uD83D\uDCAC'}
|
||||
title="Сообщение"
|
||||
nodeId={nodeId}
|
||||
isStart={isStart}
|
||||
>
|
||||
<Handle type="target" position={Position.Left} className="!bg-blue-400 !w-3 !h-3" />
|
||||
|
||||
{/* Image indicator */}
|
||||
{image && (
|
||||
<div
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-t-lg ${data.isStart ? 'bg-green-50' : 'bg-blue-50'}`}
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: '#999',
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
<MessageSquare className="h-4 w-4 text-blue-600" />
|
||||
<span className="text-xs font-semibold text-blue-800">
|
||||
{data.isStart ? '▶ Сообщение (старт)' : 'Сообщение'}
|
||||
</span>
|
||||
{'\uD83D\uDDBC'} Картинка
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="px-3 py-2">
|
||||
<p className="text-xs text-gray-700 leading-relaxed">{preview || 'Пустое сообщение'}</p>
|
||||
|
||||
{buttons.length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{buttons.slice(0, 3).map((btn, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded bg-blue-50 px-2 py-0.5 text-[10px] text-blue-700 truncate"
|
||||
{/* Text preview (2 lines) */}
|
||||
<p
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: '#666',
|
||||
lineHeight: '15px',
|
||||
margin: 0,
|
||||
overflow: 'hidden',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}
|
||||
>
|
||||
{btn.text}
|
||||
{truncated || (
|
||||
<span style={{ fontStyle: 'italic', color: '#bdbdbd' }}>Пустое сообщение</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* URL buttons — text only, no handles */}
|
||||
{urlButtons.length > 0 && (
|
||||
<div style={{ marginTop: 4 }}>
|
||||
{urlButtons.map((btn, i) => (
|
||||
<div
|
||||
key={`url-${i}`}
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: '#90a4ae',
|
||||
padding: '2px 0',
|
||||
}}
|
||||
>
|
||||
{'\uD83D\uDD17'} {btn.text || 'URL'}
|
||||
</div>
|
||||
))}
|
||||
{buttons.length > 3 && (
|
||||
<p className="text-[10px] text-gray-400">+{buttons.length - 3} ещё</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Handle type="source" position={Position.Right} className="!bg-blue-400 !w-3 !h-3" />
|
||||
{/* Goto buttons — PortRows with handles */}
|
||||
{gotoButtons.length > 0 && (
|
||||
<div style={{ margin: '4px -10px 0' }}>
|
||||
{gotoButtons.map((btn) => {
|
||||
const originalIndex = buttons.indexOf(btn);
|
||||
return (
|
||||
<PortRow
|
||||
key={`btn-${originalIndex}`}
|
||||
label={btn.text || '---'}
|
||||
handleId={`source-btn-${originalIndex}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</NodeShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
'use client';
|
||||
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
|
||||
interface NodeShellProps {
|
||||
accentColor: string;
|
||||
icon: string;
|
||||
title: string;
|
||||
nodeId: string;
|
||||
isStart?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function NodeShell({ accentColor, icon, title, nodeId, isStart, children }: NodeShellProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: 220,
|
||||
background: '#ffffff',
|
||||
border: isStart ? '1px dashed #4caf50' : '1px solid #e0e0e0',
|
||||
borderRadius: 10,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
|
||||
borderLeft: isStart ? '3px dashed #4caf50' : `3px solid ${accentColor}`,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Target handle (top) */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
id="target-top"
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
background: '#9e9e9e',
|
||||
border: 'none',
|
||||
top: -4,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '6px 10px',
|
||||
borderBottom: '1px solid #f0f0f0',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 12, lineHeight: 1 }}>{icon}</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: '#333',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
{isStart && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 9,
|
||||
fontWeight: 600,
|
||||
color: '#4caf50',
|
||||
backgroundColor: '#e8f5e9',
|
||||
borderRadius: 8,
|
||||
padding: '1px 6px',
|
||||
lineHeight: '14px',
|
||||
}}
|
||||
>
|
||||
START
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div style={{ padding: '6px 10px 4px' }}>{children}</div>
|
||||
|
||||
{/* Footer: node ID */}
|
||||
<div
|
||||
style={{
|
||||
padding: '2px 10px 4px',
|
||||
fontSize: 8,
|
||||
color: '#bdbdbd',
|
||||
textAlign: 'right',
|
||||
}}
|
||||
>
|
||||
{nodeId}
|
||||
</div>
|
||||
|
||||
{/* Source handle (bottom) — "next" */}
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
id="source-bottom"
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
background: '#9e9e9e',
|
||||
border: 'none',
|
||||
bottom: -4,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
|
||||
interface PortRowProps {
|
||||
label: string;
|
||||
handleId: string;
|
||||
dotColor?: string;
|
||||
}
|
||||
|
||||
export function PortRow({ label, handleId, dotColor = '#29b6f6' }: PortRowProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
padding: '4px 8px',
|
||||
borderTop: '1px solid #f5f5f5',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: '#555',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: 170,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
id={handleId}
|
||||
style={{
|
||||
width: 8,
|
||||
height: 8,
|
||||
background: dotColor,
|
||||
border: 'none',
|
||||
right: -4,
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,34 +1,73 @@
|
||||
'use client';
|
||||
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
import { UserPlus } from 'lucide-react';
|
||||
import { NodeShell } from './node-shell';
|
||||
import { PortRow } from './port-row';
|
||||
|
||||
interface ReferralNodeData {
|
||||
trackVariable?: string;
|
||||
targetCount?: number;
|
||||
onReach?: string;
|
||||
friendStartNode?: string;
|
||||
isStart?: boolean;
|
||||
nodeId?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export function ReferralNode({ data }: { data: ReferralNodeData }) {
|
||||
export function ReferralNode({ data, id }: { data: ReferralNodeData; id: string }) {
|
||||
const targetCount = data.targetCount ?? 0;
|
||||
const trackVar = data.trackVariable ?? 'invited_count';
|
||||
const hasReach = !!data.onReach;
|
||||
const hasFriend = !!data.friendStartNode;
|
||||
const isStart = data.isStart;
|
||||
const nodeId = (data.nodeId as string) || id;
|
||||
|
||||
return (
|
||||
<div className="min-w-[180px] max-w-[240px] rounded-xl border-2 border-pink-400 bg-white shadow-sm">
|
||||
<Handle type="target" position={Position.Left} className="!bg-pink-400 !w-3 !h-3" />
|
||||
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-pink-50 rounded-t-lg">
|
||||
<UserPlus className="h-4 w-4 text-pink-600" />
|
||||
<span className="text-xs font-semibold text-pink-800">Реферал</span>
|
||||
<NodeShell
|
||||
accentColor="#ec407a"
|
||||
icon={'\uD83C\uDF81'}
|
||||
title="Реферал"
|
||||
nodeId={nodeId}
|
||||
isStart={isStart}
|
||||
>
|
||||
{/* Target count + variable */}
|
||||
<div style={{ marginBottom: 4 }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 13,
|
||||
fontWeight: 700,
|
||||
color: '#ec407a',
|
||||
}}
|
||||
>
|
||||
{targetCount}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: '#666',
|
||||
marginLeft: 4,
|
||||
}}
|
||||
>
|
||||
друзей
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2">
|
||||
<p className="text-xs text-gray-700">
|
||||
Цель: <b>{(data.targetCount as number) ?? '?'}</b> приглашённых
|
||||
<p
|
||||
style={{
|
||||
fontSize: 9,
|
||||
color: '#9e9e9e',
|
||||
fontFamily: 'monospace',
|
||||
margin: '0 0 4px',
|
||||
}}
|
||||
>
|
||||
{trackVar}
|
||||
</p>
|
||||
{data.trackVariable && (
|
||||
<p className="text-[10px] text-pink-600 mt-1 font-mono">{data.trackVariable}</p>
|
||||
|
||||
{/* Branch PortRows */}
|
||||
<div style={{ margin: '0 -10px' }}>
|
||||
{hasReach && <PortRow label="Цель достигнута" handleId="source-reach" dotColor="#66bb6a" />}
|
||||
{hasFriend && (
|
||||
<PortRow label="Сценарий друга" handleId="source-friend" dotColor="#ab47bc" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Handle type="source" position={Position.Right} className="!bg-pink-400 !w-3 !h-3" />
|
||||
</div>
|
||||
</NodeShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
|
||||
export function StartNode() {
|
||||
return (
|
||||
<div className="rounded-full border-2 border-[#4caf50] bg-[#e8f5e9] px-6 py-2.5 shadow-sm">
|
||||
<span className="text-sm font-bold text-[#2e7d32]">▶ Начало</span>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
id="source-bottom"
|
||||
className="!bg-[#4caf50] !w-3 !h-3"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { Node, Edge } from '@xyflow/react';
|
||||
import { MarkerType } from '@xyflow/react';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────
|
||||
|
||||
export interface ScenarioJson {
|
||||
id: string;
|
||||
@@ -16,47 +19,89 @@ export interface ScenarioNodeJson {
|
||||
next?: string | null;
|
||||
}
|
||||
|
||||
const NODE_SPACING_X = 320;
|
||||
const NODE_SPACING_Y = 180;
|
||||
// ─── Layout Constants (n8n style) ───────────────────────────────
|
||||
|
||||
/**
|
||||
* Convert JSON scenario → React Flow nodes + edges
|
||||
*/
|
||||
export function scenarioToFlow(scenario: ScenarioJson): { nodes: Node[]; edges: Edge[] } {
|
||||
const nodes: Node[] = [];
|
||||
const edges: Edge[] = [];
|
||||
const visited = new Set<string>();
|
||||
const NODE_WIDTH = 220;
|
||||
const VERTICAL_GAP = 60;
|
||||
const HORIZONTAL_GAP = 300;
|
||||
const GRID_SIZE = 20;
|
||||
|
||||
// BFS to layout nodes in a tree
|
||||
const positions = new Map<string, { x: number; y: number }>();
|
||||
const queue: Array<{ id: string; col: number; row: number }> = [];
|
||||
const NODE_HEIGHTS: Record<string, number> = {
|
||||
message: 140,
|
||||
action: 100,
|
||||
condition: 110,
|
||||
input: 110,
|
||||
delay: 100,
|
||||
referral: 140,
|
||||
};
|
||||
|
||||
if (scenario.startNodeId && scenario.nodes[scenario.startNodeId]) {
|
||||
queue.push({ id: scenario.startNodeId, col: 0, row: 0 });
|
||||
function getNodeHeight(node: ScenarioNodeJson): number {
|
||||
const base = NODE_HEIGHTS[node.type] ?? 80;
|
||||
// Message nodes with buttons are taller
|
||||
if (node.type === 'message') {
|
||||
const buttons = (node.data.buttons ?? []) as unknown[];
|
||||
if (buttons.length > 0) return base + 30;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
const rowCountPerCol = new Map<number, number>();
|
||||
function snapToGrid(val: number): number {
|
||||
return Math.round(val / GRID_SIZE) * GRID_SIZE;
|
||||
}
|
||||
|
||||
while (queue.length > 0) {
|
||||
const { id, col, row } = queue.shift()!;
|
||||
if (visited.has(id)) continue;
|
||||
visited.add(id);
|
||||
// ─── Edge helpers ──────────────────────────────────────────────
|
||||
|
||||
const currentRow = rowCountPerCol.get(col) ?? row;
|
||||
rowCountPerCol.set(col, currentRow + 1);
|
||||
const EDGE_COLOR = '#29b6f6';
|
||||
|
||||
positions.set(id, { x: col * NODE_SPACING_X, y: currentRow * NODE_SPACING_Y });
|
||||
const edgeMarker = {
|
||||
type: MarkerType.ArrowClosed as const,
|
||||
width: 14,
|
||||
height: 14,
|
||||
color: EDGE_COLOR,
|
||||
};
|
||||
|
||||
const node = scenario.nodes[id];
|
||||
if (!node) continue;
|
||||
function mkEdge(
|
||||
id: string,
|
||||
source: string,
|
||||
target: string,
|
||||
_color = EDGE_COLOR,
|
||||
opts?: { sourceHandle?: string; dash?: boolean },
|
||||
): Edge {
|
||||
return {
|
||||
id,
|
||||
source,
|
||||
sourceHandle: opts?.sourceHandle ?? 'source-bottom',
|
||||
target,
|
||||
targetHandle: 'target-top',
|
||||
type: 'default',
|
||||
style: {
|
||||
strokeWidth: 2,
|
||||
stroke: EDGE_COLOR,
|
||||
...(opts?.dash ? { strokeDasharray: '6,3' } : {}),
|
||||
},
|
||||
markerEnd: edgeMarker,
|
||||
};
|
||||
}
|
||||
|
||||
// Collect targets
|
||||
// ─── Get all targets from a node (for layout) ──────────────────
|
||||
|
||||
function getNodeTargets(node: ScenarioNodeJson): string[] {
|
||||
const targets: string[] = [];
|
||||
|
||||
if (node.next) targets.push(node.next);
|
||||
if (node.type === 'message') {
|
||||
const buttons = (node.data.buttons ?? []) as Array<{
|
||||
action: string;
|
||||
target?: string;
|
||||
}>;
|
||||
for (const btn of buttons) {
|
||||
if (btn.action === 'goto' && btn.target) {
|
||||
if (!targets.includes(btn.target)) targets.push(btn.target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (node.type === 'condition' && node.data.rules) {
|
||||
const rules = node.data.rules as Array<{ target: string }>;
|
||||
if (node.type === 'condition') {
|
||||
const rules = (node.data.rules ?? []) as Array<{ target: string }>;
|
||||
for (const rule of rules) {
|
||||
if (rule.target && !targets.includes(rule.target)) targets.push(rule.target);
|
||||
}
|
||||
@@ -64,160 +109,282 @@ export function scenarioToFlow(scenario: ScenarioJson): { nodes: Node[]; edges:
|
||||
if (def && !targets.includes(def)) targets.push(def);
|
||||
}
|
||||
|
||||
if (node.type === 'message' && node.data.buttons) {
|
||||
const buttons = node.data.buttons as Array<{ action: string; target?: string }>;
|
||||
for (const btn of buttons) {
|
||||
if (btn.action === 'goto' && btn.target && !targets.includes(btn.target)) {
|
||||
targets.push(btn.target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (node.type === 'referral') {
|
||||
const onReach = node.data.onReach as string | undefined;
|
||||
const friendStart = node.data.friendStartNode as string | undefined;
|
||||
if (onReach && !targets.includes(onReach)) targets.push(onReach);
|
||||
if (friendStart && !targets.includes(friendStart)) targets.push(friendStart);
|
||||
const reach = node.data.onReach as string | undefined;
|
||||
const friend = node.data.friendStartNode as string | undefined;
|
||||
if (reach && !targets.includes(reach)) targets.push(reach);
|
||||
if (friend && !targets.includes(friend)) targets.push(friend);
|
||||
}
|
||||
|
||||
for (let i = 0; i < targets.length; i++) {
|
||||
const t = targets[i];
|
||||
if (t && !visited.has(t)) {
|
||||
queue.push({ id: t, col: col + 1, row: i });
|
||||
return targets;
|
||||
}
|
||||
|
||||
// ─── Auto-layout algorithm ─────────────────────────────────────
|
||||
|
||||
function autoLayout(scenario: ScenarioJson): Map<string, { x: number; y: number }> {
|
||||
const positions = new Map<string, { x: number; y: number }>();
|
||||
const placed = new Set<string>();
|
||||
|
||||
// 1. Main column: follow next chain from startNode
|
||||
let y = 0;
|
||||
let currentId: string | undefined | null = scenario.startNodeId;
|
||||
const mainColumn: string[] = [];
|
||||
|
||||
while (currentId && !placed.has(currentId) && scenario.nodes[currentId]) {
|
||||
mainColumn.push(currentId);
|
||||
placed.add(currentId);
|
||||
positions.set(currentId, { x: snapToGrid(0), y: snapToGrid(y) });
|
||||
y += getNodeHeight(scenario.nodes[currentId]!) + VERTICAL_GAP;
|
||||
currentId = scenario.nodes[currentId]!.next;
|
||||
}
|
||||
|
||||
// 2. BFS: collect unplaced nodes reachable from main column
|
||||
let colIndex = 1;
|
||||
let queue = [...mainColumn];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const nextQueue: string[] = [];
|
||||
const rightCol: string[] = [];
|
||||
const leftCol: string[] = [];
|
||||
|
||||
for (const srcId of queue) {
|
||||
const srcNode = scenario.nodes[srcId];
|
||||
if (!srcNode) continue;
|
||||
const targets = getNodeTargets(srcNode);
|
||||
|
||||
for (const tgt of targets) {
|
||||
if (!placed.has(tgt) && scenario.nodes[tgt]) {
|
||||
// Alternate right/left
|
||||
if (colIndex % 2 === 1) {
|
||||
rightCol.push(tgt);
|
||||
} else {
|
||||
leftCol.push(tgt);
|
||||
}
|
||||
placed.add(tgt);
|
||||
|
||||
// Also follow this node's next chain
|
||||
let chainId = scenario.nodes[tgt].next;
|
||||
while (chainId && !placed.has(chainId) && scenario.nodes[chainId]) {
|
||||
if (colIndex % 2 === 1) {
|
||||
rightCol.push(chainId);
|
||||
} else {
|
||||
leftCol.push(chainId);
|
||||
}
|
||||
placed.add(chainId);
|
||||
chainId = scenario.nodes[chainId]!.next;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also add unvisited nodes
|
||||
let orphanRow = 0;
|
||||
for (const [id] of Object.entries(scenario.nodes)) {
|
||||
if (!visited.has(id)) {
|
||||
positions.set(id, { x: -NODE_SPACING_X, y: orphanRow * NODE_SPACING_Y });
|
||||
orphanRow++;
|
||||
// Place right column
|
||||
if (rightCol.length > 0) {
|
||||
let cy = 0;
|
||||
for (const nid of rightCol) {
|
||||
positions.set(nid, {
|
||||
x: snapToGrid(colIndex * HORIZONTAL_GAP),
|
||||
y: snapToGrid(cy),
|
||||
});
|
||||
cy += getNodeHeight(scenario.nodes[nid]!) + VERTICAL_GAP;
|
||||
nextQueue.push(nid);
|
||||
}
|
||||
}
|
||||
|
||||
// Build React Flow nodes
|
||||
// Place left column
|
||||
if (leftCol.length > 0) {
|
||||
let cy = 0;
|
||||
for (const nid of leftCol) {
|
||||
positions.set(nid, {
|
||||
x: snapToGrid(-colIndex * HORIZONTAL_GAP),
|
||||
y: snapToGrid(cy),
|
||||
});
|
||||
cy += getNodeHeight(scenario.nodes[nid]!) + VERTICAL_GAP;
|
||||
nextQueue.push(nid);
|
||||
}
|
||||
}
|
||||
|
||||
if (rightCol.length > 0 || leftCol.length > 0) colIndex++;
|
||||
queue = nextQueue;
|
||||
}
|
||||
|
||||
// 3. Orphans: nodes not reachable from startNode
|
||||
let orphanX = (colIndex + 1) * HORIZONTAL_GAP;
|
||||
for (const id of Object.keys(scenario.nodes)) {
|
||||
if (!placed.has(id)) {
|
||||
positions.set(id, { x: snapToGrid(orphanX), y: 0 });
|
||||
orphanX += NODE_WIDTH + 60;
|
||||
placed.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
return positions;
|
||||
}
|
||||
|
||||
// ─── Convert scenario JSON -> React Flow ────────────────────────
|
||||
|
||||
export function scenarioToFlow(
|
||||
scenario: ScenarioJson,
|
||||
savedPositions?: Record<string, { x: number; y: number }> | null,
|
||||
): { nodes: Node[]; edges: Edge[] } {
|
||||
const nodes: Node[] = [];
|
||||
const edges: Edge[] = [];
|
||||
|
||||
// Compute positions: use saved or auto-layout
|
||||
const layoutPositions = autoLayout(scenario);
|
||||
const useSaved = savedPositions && Object.keys(savedPositions).length > 0;
|
||||
|
||||
function getPosition(id: string): { x: number; y: number } {
|
||||
if (useSaved && savedPositions[id]) {
|
||||
return savedPositions[id];
|
||||
}
|
||||
return layoutPositions.get(id) ?? { x: 0, y: 0 };
|
||||
}
|
||||
|
||||
// Build nodes + edges
|
||||
for (const [id, nodeJson] of Object.entries(scenario.nodes)) {
|
||||
const pos = positions.get(id) ?? { x: 0, y: 0 };
|
||||
const isStart = id === scenario.startNodeId;
|
||||
const position = getPosition(id);
|
||||
|
||||
if (nodeJson.type === 'message') {
|
||||
const buttons = (nodeJson.data.buttons ?? []) as Array<{
|
||||
text: string;
|
||||
action: string;
|
||||
target?: string;
|
||||
url?: string;
|
||||
}>;
|
||||
|
||||
nodes.push({
|
||||
id,
|
||||
type: 'scenario-message',
|
||||
position,
|
||||
data: {
|
||||
...nodeJson.data,
|
||||
nodeId: id,
|
||||
nodeType: 'message',
|
||||
next: nodeJson.next,
|
||||
isStart,
|
||||
buttons,
|
||||
},
|
||||
});
|
||||
|
||||
// Edge: next (main flow)
|
||||
if (nodeJson.next) {
|
||||
edges.push(mkEdge(`${id}->next`, id, nodeJson.next));
|
||||
}
|
||||
|
||||
// Edges: button goto targets
|
||||
buttons.forEach((btn, i) => {
|
||||
if (btn.action === 'goto' && btn.target && scenario.nodes[btn.target]) {
|
||||
edges.push(
|
||||
mkEdge(`${id}->btn${i}`, id, btn.target, EDGE_COLOR, {
|
||||
sourceHandle: `source-btn-${i}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (nodeJson.type === 'condition') {
|
||||
nodes.push({
|
||||
id,
|
||||
type: 'scenario-condition',
|
||||
position,
|
||||
data: {
|
||||
...nodeJson.data,
|
||||
nodeId: id,
|
||||
nodeType: 'condition',
|
||||
next: nodeJson.next,
|
||||
isStart,
|
||||
},
|
||||
});
|
||||
|
||||
const rules = (nodeJson.data.rules ?? []) as Array<{ condition: string; target: string }>;
|
||||
rules.forEach((rule, i) => {
|
||||
if (rule.target && scenario.nodes[rule.target]) {
|
||||
edges.push(
|
||||
mkEdge(`${id}->rule${i}`, id, rule.target, EDGE_COLOR, {
|
||||
sourceHandle: `source-rule-${i}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const def = nodeJson.data.default as string | undefined;
|
||||
if (def && scenario.nodes[def]) {
|
||||
edges.push(mkEdge(`${id}->def`, id, def, EDGE_COLOR, { sourceHandle: 'source-default' }));
|
||||
}
|
||||
|
||||
if (nodeJson.next) {
|
||||
edges.push(mkEdge(`${id}->next`, id, nodeJson.next));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (nodeJson.type === 'referral') {
|
||||
nodes.push({
|
||||
id,
|
||||
type: 'scenario-referral',
|
||||
position,
|
||||
data: {
|
||||
...nodeJson.data,
|
||||
nodeId: id,
|
||||
nodeType: 'referral',
|
||||
next: nodeJson.next,
|
||||
isStart,
|
||||
},
|
||||
});
|
||||
|
||||
const onReach = nodeJson.data.onReach as string | undefined;
|
||||
const friendStart = nodeJson.data.friendStartNode as string | undefined;
|
||||
|
||||
if (onReach && scenario.nodes[onReach]) {
|
||||
edges.push(
|
||||
mkEdge(`${id}->reach`, id, onReach, EDGE_COLOR, {
|
||||
sourceHandle: 'source-reach',
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (friendStart && scenario.nodes[friendStart]) {
|
||||
edges.push(
|
||||
mkEdge(`${id}->friend`, id, friendStart, EDGE_COLOR, {
|
||||
sourceHandle: 'source-friend',
|
||||
dash: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (nodeJson.next) {
|
||||
edges.push(mkEdge(`${id}->next`, id, nodeJson.next));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// action, input, delay — generic
|
||||
nodes.push({
|
||||
id,
|
||||
type: `scenario-${nodeJson.type}`,
|
||||
position: pos,
|
||||
position,
|
||||
data: {
|
||||
...nodeJson.data,
|
||||
nodeId: id,
|
||||
nodeType: nodeJson.type,
|
||||
next: nodeJson.next,
|
||||
isStart: id === scenario.startNodeId,
|
||||
isStart,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Build edges
|
||||
for (const [id, nodeJson] of Object.entries(scenario.nodes)) {
|
||||
// next edge
|
||||
if (nodeJson.next) {
|
||||
edges.push({
|
||||
id: `${id}->next->${nodeJson.next}`,
|
||||
source: id,
|
||||
target: nodeJson.next,
|
||||
type: 'smoothstep',
|
||||
label: 'next',
|
||||
style: { stroke: '#888' },
|
||||
});
|
||||
}
|
||||
|
||||
// condition edges
|
||||
if (nodeJson.type === 'condition' && nodeJson.data.rules) {
|
||||
const rules = nodeJson.data.rules as Array<{ condition: string; target: string }>;
|
||||
rules.forEach((rule, i) => {
|
||||
if (rule.target) {
|
||||
edges.push({
|
||||
id: `${id}->rule${i}->${rule.target}`,
|
||||
source: id,
|
||||
target: rule.target,
|
||||
type: 'smoothstep',
|
||||
label: rule.condition,
|
||||
style: { stroke: '#22c55e' },
|
||||
labelStyle: { fontSize: 10 },
|
||||
});
|
||||
}
|
||||
});
|
||||
const def = nodeJson.data.default as string | undefined;
|
||||
if (def) {
|
||||
edges.push({
|
||||
id: `${id}->default->${def}`,
|
||||
source: id,
|
||||
target: def,
|
||||
type: 'smoothstep',
|
||||
label: 'default',
|
||||
style: { stroke: '#f59e0b' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// button edges
|
||||
if (nodeJson.type === 'message' && nodeJson.data.buttons) {
|
||||
const buttons = nodeJson.data.buttons as Array<{
|
||||
text: string;
|
||||
action: string;
|
||||
target?: string;
|
||||
}>;
|
||||
buttons.forEach((btn, i) => {
|
||||
if (btn.action === 'goto' && btn.target) {
|
||||
// Don't duplicate if same as next
|
||||
if (btn.target !== nodeJson.next) {
|
||||
edges.push({
|
||||
id: `${id}->btn${i}->${btn.target}`,
|
||||
source: id,
|
||||
target: btn.target,
|
||||
type: 'smoothstep',
|
||||
label: btn.text.substring(0, 20),
|
||||
style: { stroke: '#3b82f6' },
|
||||
labelStyle: { fontSize: 10 },
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// referral edges
|
||||
if (nodeJson.type === 'referral') {
|
||||
const onReach = nodeJson.data.onReach as string | undefined;
|
||||
const friendStart = nodeJson.data.friendStartNode as string | undefined;
|
||||
if (onReach) {
|
||||
edges.push({
|
||||
id: `${id}->onReach->${onReach}`,
|
||||
source: id,
|
||||
target: onReach,
|
||||
type: 'smoothstep',
|
||||
label: 'goal reached',
|
||||
style: { stroke: '#22c55e' },
|
||||
});
|
||||
}
|
||||
if (friendStart) {
|
||||
edges.push({
|
||||
id: `${id}->friend->${friendStart}`,
|
||||
source: id,
|
||||
target: friendStart,
|
||||
type: 'smoothstep',
|
||||
label: 'friend flow',
|
||||
style: { stroke: '#a855f7', strokeDasharray: '5,5' },
|
||||
});
|
||||
}
|
||||
edges.push(mkEdge(`${id}->next`, id, nodeJson.next));
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert React Flow nodes + edges → JSON scenario
|
||||
*/
|
||||
// ─── Convert React Flow -> scenario JSON ────────────────────────
|
||||
|
||||
export function flowToScenario(
|
||||
flowNodes: Node[],
|
||||
_flowEdges: Edge[],
|
||||
meta: {
|
||||
name: string;
|
||||
startNodeId: string;
|
||||
@@ -231,20 +398,14 @@ export function flowToScenario(
|
||||
const nodeType = (raw.nodeType as string) ?? fn.type?.replace('scenario-', '') ?? 'message';
|
||||
const next = raw.next as string | undefined;
|
||||
|
||||
// Strip internal fields, keep only scenario data
|
||||
const data: Record<string, unknown> = {};
|
||||
for (const [k, v] of Object.entries(raw)) {
|
||||
if (k !== 'nodeId' && k !== 'nodeType' && k !== 'isStart' && k !== 'next') {
|
||||
if (!['nodeId', 'nodeType', 'isStart', 'next'].includes(k)) {
|
||||
data[k] = v;
|
||||
}
|
||||
}
|
||||
|
||||
nodes[fn.id] = {
|
||||
id: fn.id,
|
||||
type: nodeType,
|
||||
data,
|
||||
next: next ?? null,
|
||||
};
|
||||
nodes[fn.id] = { id: fn.id, type: nodeType, data, next: next ?? null };
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -256,3 +417,16 @@ export function flowToScenario(
|
||||
nodes,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Extract positions ─────────────────────────────────────────
|
||||
|
||||
export function extractPositions(nodes: Node[]): Record<string, { x: number; y: number }> {
|
||||
const positions: Record<string, { x: number; y: number }> = {};
|
||||
for (const node of nodes) {
|
||||
positions[node.id] = {
|
||||
x: Math.round(node.position.x),
|
||||
y: Math.round(node.position.y),
|
||||
};
|
||||
}
|
||||
return positions;
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,139 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* CSS-only arrow components for the Bitrix-style tree renderer.
|
||||
* No SVG — just divs, borders, and CSS pseudo-elements.
|
||||
*/
|
||||
|
||||
// ─── Vertical Arrow ─────────────────────────────────────────────
|
||||
// 40px tall, centered 2px grey line with arrow head at bottom.
|
||||
|
||||
export function VerticalArrow() {
|
||||
return (
|
||||
<div className="flex flex-col items-center" style={{ height: 40 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 2,
|
||||
flex: 1,
|
||||
backgroundColor: '#C0C1C3',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: '5px solid transparent',
|
||||
borderRight: '5px solid transparent',
|
||||
borderTop: '6px solid #C0C1C3',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── FanOut ──────────────────────────────────────────────────────
|
||||
// Horizontal connector line from center of parent down to each branch column.
|
||||
// Uses CSS pseudo-element approach:
|
||||
// - Each column has a top border spanning half-width for first/last,
|
||||
// full-width for middle columns.
|
||||
// - Center of each column has a short vertical line going down.
|
||||
|
||||
interface FanOutProps {
|
||||
columnCount: number;
|
||||
}
|
||||
|
||||
export function FanOut({ columnCount }: FanOutProps) {
|
||||
if (columnCount <= 0) return null;
|
||||
if (columnCount === 1) {
|
||||
return <VerticalArrow />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center" style={{ width: '100%' }}>
|
||||
{/* Vertical line from parent down to horizontal bar */}
|
||||
<div style={{ width: 2, height: 16, backgroundColor: '#C0C1C3' }} />
|
||||
|
||||
{/* Horizontal bar spanning all columns */}
|
||||
<div className="flex" style={{ width: '100%' }}>
|
||||
{Array.from({ length: columnCount }).map((_, i) => (
|
||||
<div key={i} className="flex-1 flex flex-col items-center">
|
||||
{/* Horizontal line segment */}
|
||||
<div
|
||||
style={{
|
||||
height: 2,
|
||||
backgroundColor: '#C0C1C3',
|
||||
width: i === 0 ? '50%' : i === columnCount - 1 ? '50%' : '100%',
|
||||
marginLeft: i === 0 ? '50%' : undefined,
|
||||
marginRight: i === columnCount - 1 ? '50%' : undefined,
|
||||
}}
|
||||
/>
|
||||
{/* Vertical drop-down to branch */}
|
||||
<div style={{ width: 2, height: 16, backgroundColor: '#C0C1C3' }} />
|
||||
{/* Arrow head */}
|
||||
<div
|
||||
style={{
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: '5px solid transparent',
|
||||
borderRight: '5px solid transparent',
|
||||
borderTop: '6px solid #C0C1C3',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── FanIn ───────────────────────────────────────────────────────
|
||||
// Merging line at the bottom of branches, converging back to center.
|
||||
// Mirror of FanOut but upside-down.
|
||||
|
||||
interface FanInProps {
|
||||
columnCount: number;
|
||||
}
|
||||
|
||||
export function FanIn({ columnCount }: FanInProps) {
|
||||
if (columnCount <= 0) return null;
|
||||
if (columnCount === 1) {
|
||||
return <VerticalArrow />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center" style={{ width: '100%' }}>
|
||||
{/* Horizontal bar with vertical risers from each branch */}
|
||||
<div className="flex" style={{ width: '100%' }}>
|
||||
{Array.from({ length: columnCount }).map((_, i) => (
|
||||
<div key={i} className="flex-1 flex flex-col items-center">
|
||||
{/* Vertical riser from branch */}
|
||||
<div style={{ width: 2, height: 16, backgroundColor: '#C0C1C3' }} />
|
||||
{/* Horizontal line segment */}
|
||||
<div
|
||||
style={{
|
||||
height: 2,
|
||||
backgroundColor: '#C0C1C3',
|
||||
width: i === 0 ? '50%' : i === columnCount - 1 ? '50%' : '100%',
|
||||
marginLeft: i === 0 ? '50%' : undefined,
|
||||
marginRight: i === columnCount - 1 ? '50%' : undefined,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Vertical line down from horizontal bar to next node */}
|
||||
<div style={{ width: 2, height: 16, backgroundColor: '#C0C1C3' }} />
|
||||
{/* Arrow head */}
|
||||
<div
|
||||
style={{
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: '5px solid transparent',
|
||||
borderRight: '5px solid transparent',
|
||||
borderTop: '6px solid #C0C1C3',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,557 @@
|
||||
'use client';
|
||||
|
||||
import type { ScenarioNodeJson } from './scenario-converter';
|
||||
|
||||
// ─── Shared types ───────────────────────────────────────────────
|
||||
|
||||
interface BlockProps {
|
||||
node: ScenarioNodeJson;
|
||||
onClick?: (nodeId: string) => void;
|
||||
}
|
||||
|
||||
interface SimpleBlockProps {
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────
|
||||
|
||||
function truncate(text: string, maxLines: number, maxChars: number): string {
|
||||
const stripped = text.replace(/<[^>]+>/g, '');
|
||||
const lines = stripped.split('\n').slice(0, maxLines);
|
||||
const joined = lines.join('\n');
|
||||
return joined.length > maxChars ? joined.slice(0, maxChars) + '...' : joined;
|
||||
}
|
||||
|
||||
// ─── StartBlock ─────────────────────────────────────────────────
|
||||
|
||||
export function StartBlock({ onClick }: SimpleBlockProps) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="cursor-pointer select-none"
|
||||
style={{
|
||||
width: 120,
|
||||
padding: '8px 0',
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#4caf50',
|
||||
color: '#fff',
|
||||
textAlign: 'center',
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
lineHeight: '20px',
|
||||
}}
|
||||
>
|
||||
▶ Начало
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── EndBlock ───────────────────────────────────────────────────
|
||||
|
||||
export function EndBlock({ onClick }: SimpleBlockProps) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="cursor-pointer select-none"
|
||||
style={{
|
||||
width: 120,
|
||||
padding: '8px 0',
|
||||
borderRadius: 20,
|
||||
backgroundColor: '#4caf50',
|
||||
color: '#fff',
|
||||
textAlign: 'center',
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
lineHeight: '20px',
|
||||
}}
|
||||
>
|
||||
■ Конец
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── MessageBlock ───────────────────────────────────────────────
|
||||
|
||||
export function MessageBlock({ node, onClick }: BlockProps) {
|
||||
const text = (node.data.text ?? '') as string;
|
||||
const image = node.data.image as string | undefined;
|
||||
const buttons = (node.data.buttons ?? []) as Array<{ text: string }>;
|
||||
const preview = truncate(text, 3, 120);
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => onClick?.(node.id)}
|
||||
className="cursor-pointer select-none rounded-xl overflow-hidden"
|
||||
style={{
|
||||
width: 200,
|
||||
backgroundColor: '#fff8e1',
|
||||
border: '2px solid #ffb300',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-3 py-2"
|
||||
style={{ borderBottom: '1px solid #ffe082' }}
|
||||
>
|
||||
<span style={{ fontSize: 13 }}>💬</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: '#e65100' }}>Сообщение</span>
|
||||
{buttons.length > 0 && (
|
||||
<span
|
||||
className="ml-auto"
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: '#ff8f00',
|
||||
backgroundColor: '#fff3e0',
|
||||
borderRadius: 10,
|
||||
padding: '1px 6px',
|
||||
}}
|
||||
>
|
||||
{buttons.length} кн.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Image indicator */}
|
||||
{image && (
|
||||
<div className="px-2 pt-1.5">
|
||||
<div
|
||||
className="flex items-center justify-center rounded"
|
||||
style={{
|
||||
height: 28,
|
||||
backgroundColor: '#ffe0b2',
|
||||
fontSize: 10,
|
||||
color: '#e65100',
|
||||
}}
|
||||
>
|
||||
🖼 Картинка
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text preview */}
|
||||
<div className="px-3 py-2">
|
||||
<p
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: '#4e342e',
|
||||
lineHeight: '16px',
|
||||
margin: 0,
|
||||
whiteSpace: 'pre-wrap',
|
||||
overflow: 'hidden',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 3,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}
|
||||
>
|
||||
{preview || (
|
||||
<span style={{ fontStyle: 'italic', color: '#bcaaa4' }}>Пустое сообщение</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── ButtonBlock ────────────────────────────────────────────────
|
||||
|
||||
interface ButtonBlockProps {
|
||||
text: string;
|
||||
action: string;
|
||||
target?: string;
|
||||
url?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function ButtonBlock({ text, action, target, url, onClick }: ButtonBlockProps) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="cursor-pointer select-none rounded-lg overflow-hidden"
|
||||
style={{
|
||||
width: 130,
|
||||
backgroundColor: '#e3f2fd',
|
||||
border: '2px solid #42a5f5',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<div className="px-2 py-2">
|
||||
<p
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: '#1565c0',
|
||||
lineHeight: '16px',
|
||||
margin: 0,
|
||||
overflow: 'hidden',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}
|
||||
>
|
||||
{text || '---'}
|
||||
</p>
|
||||
</div>
|
||||
{action === 'goto' && target && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 9,
|
||||
color: '#90a4ae',
|
||||
padding: '2px 4px 4px',
|
||||
borderTop: '1px solid #bbdefb',
|
||||
}}
|
||||
>
|
||||
→ {target.length > 16 ? target.slice(0, 16) + '...' : target}
|
||||
</div>
|
||||
)}
|
||||
{action === 'url' && url && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 9,
|
||||
color: '#90a4ae',
|
||||
padding: '2px 4px 4px',
|
||||
borderTop: '1px solid #bbdefb',
|
||||
}}
|
||||
>
|
||||
🔗 URL
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── ConditionBlock ─────────────────────────────────────────────
|
||||
|
||||
export function ConditionBlock({ node, onClick }: BlockProps) {
|
||||
const rules = (node.data.rules ?? []) as Array<{ condition: string; target: string }>;
|
||||
const defaultTarget = node.data.default as string | undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => onClick?.(node.id)}
|
||||
className="cursor-pointer select-none rounded-xl overflow-hidden"
|
||||
style={{
|
||||
width: 200,
|
||||
backgroundColor: '#fff3e0',
|
||||
border: '2px solid #ff9800',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-3 py-2"
|
||||
style={{ borderBottom: '1px solid #ffe0b2' }}
|
||||
>
|
||||
<span style={{ fontSize: 13 }}>🔀</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: '#e65100' }}>Условие</span>
|
||||
</div>
|
||||
|
||||
<div className="px-2 py-2 space-y-1">
|
||||
{rules.map((rule, i) => (
|
||||
<div key={i} className="flex items-center gap-1">
|
||||
<span style={{ fontSize: 9, color: '#e65100', fontWeight: 600, flexShrink: 0 }}>
|
||||
если
|
||||
</span>
|
||||
<span
|
||||
className="truncate"
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: '#bf360c',
|
||||
fontFamily: 'monospace',
|
||||
flex: 1,
|
||||
backgroundColor: '#fff',
|
||||
border: '1px solid #ffcc80',
|
||||
borderRadius: 4,
|
||||
padding: '1px 4px',
|
||||
}}
|
||||
>
|
||||
{rule.condition}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{defaultTarget && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span style={{ fontSize: 9, color: '#9e9e9e', fontWeight: 600, flexShrink: 0 }}>
|
||||
иначе
|
||||
</span>
|
||||
<span style={{ fontSize: 9, color: '#ffa726', fontFamily: 'monospace' }}>
|
||||
→{' '}
|
||||
{defaultTarget.length > 12 ? defaultTarget.slice(0, 12) + '...' : defaultTarget}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{rules.length === 0 && !defaultTarget && (
|
||||
<p style={{ fontSize: 10, fontStyle: 'italic', color: '#ffb74d', margin: 0 }}>
|
||||
Нет условий
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── ActionBlock ────────────────────────────────────────────────
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
set_variable: '📌 Переменная',
|
||||
grant_bonus: '🎁 Бонус',
|
||||
set_consent: '✅ Согласие',
|
||||
crm_deal: '📋 Сделка CRM',
|
||||
webhook: '🔗 Webhook',
|
||||
};
|
||||
|
||||
export function ActionBlock({ node, onClick }: BlockProps) {
|
||||
const actions = (node.data.actions ?? []) as Array<{
|
||||
type: string;
|
||||
variable?: string;
|
||||
value?: unknown;
|
||||
}>;
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => onClick?.(node.id)}
|
||||
className="cursor-pointer select-none rounded-xl overflow-hidden"
|
||||
style={{
|
||||
width: 200,
|
||||
backgroundColor: '#f3e5f5',
|
||||
border: '2px solid #ab47bc',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-3 py-2"
|
||||
style={{ borderBottom: '1px solid #ce93d8' }}
|
||||
>
|
||||
<span style={{ fontSize: 13 }}>⚡</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: '#6a1b9a' }}>Действие</span>
|
||||
</div>
|
||||
|
||||
<div className="px-2 py-2 space-y-1">
|
||||
{actions.length === 0 && (
|
||||
<p style={{ fontSize: 10, fontStyle: 'italic', color: '#ba68c8', margin: 0 }}>
|
||||
Нет действий
|
||||
</p>
|
||||
)}
|
||||
{actions.map((action, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded"
|
||||
style={{
|
||||
backgroundColor: '#fff',
|
||||
border: '1px solid #e1bee7',
|
||||
padding: '2px 6px',
|
||||
fontSize: 10,
|
||||
color: '#6a1b9a',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
className="font-medium"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: ACTION_LABELS[action.type] ?? action.type,
|
||||
}}
|
||||
/>
|
||||
{action.variable && (
|
||||
<span
|
||||
style={{ marginLeft: 4, color: '#9c27b0', fontFamily: 'monospace', fontSize: 9 }}
|
||||
>
|
||||
{action.variable}
|
||||
{action.value !== undefined
|
||||
? ` = ${typeof action.value === 'object' ? JSON.stringify(action.value) : String(action.value as string | number | boolean)}`
|
||||
: ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── InputBlock ─────────────────────────────────────────────────
|
||||
|
||||
export function InputBlock({ node, onClick }: BlockProps) {
|
||||
const prompt = (node.data.prompt ?? '') as string;
|
||||
const inputType = (node.data.inputType ?? 'text') as string;
|
||||
const saveAs = (node.data.saveAs ?? '') as string;
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
text: 'Текст',
|
||||
phone: 'Телефон',
|
||||
email: 'Email',
|
||||
number: 'Число',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => onClick?.(node.id)}
|
||||
className="cursor-pointer select-none rounded-xl overflow-hidden"
|
||||
style={{
|
||||
width: 200,
|
||||
backgroundColor: '#e0f2f1',
|
||||
border: '2px solid #26a69a',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-3 py-2"
|
||||
style={{ borderBottom: '1px solid #80cbc4' }}
|
||||
>
|
||||
<span style={{ fontSize: 13 }}>✏️</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: '#004d40' }}>Ввод данных</span>
|
||||
<span
|
||||
className="ml-auto"
|
||||
style={{
|
||||
fontSize: 9,
|
||||
color: '#00897b',
|
||||
backgroundColor: '#b2dfdb',
|
||||
borderRadius: 10,
|
||||
padding: '1px 6px',
|
||||
}}
|
||||
>
|
||||
{typeLabels[inputType] ?? inputType}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2">
|
||||
<p style={{ fontSize: 11, color: '#00695c', lineHeight: '15px', margin: 0 }}>
|
||||
{prompt || (
|
||||
<span style={{ fontStyle: 'italic', color: '#80cbc4' }}>Введите запрос...</span>
|
||||
)}
|
||||
</p>
|
||||
{saveAs && (
|
||||
<p style={{ marginTop: 4, fontSize: 9, color: '#26a69a', fontFamily: 'monospace' }}>
|
||||
→ {saveAs}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── DelayBlock ─────────────────────────────────────────────────
|
||||
|
||||
export function DelayBlock({ node, onClick }: BlockProps) {
|
||||
const duration = (node.data.duration ?? '?') as string;
|
||||
const cancelOn = node.data.cancelOn as string | undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => onClick?.(node.id)}
|
||||
className="cursor-pointer select-none rounded-xl overflow-hidden"
|
||||
style={{
|
||||
width: 200,
|
||||
backgroundColor: '#fbe9e7',
|
||||
border: '2px solid #ff7043',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-3 py-2"
|
||||
style={{ borderBottom: '1px solid #ffab91' }}
|
||||
>
|
||||
<span style={{ fontSize: 13 }}>⏰</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: '#bf360c' }}>Задержка</span>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2">
|
||||
<p
|
||||
style={{
|
||||
fontSize: 14,
|
||||
fontWeight: 700,
|
||||
color: '#d84315',
|
||||
textAlign: 'center',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{duration}
|
||||
</p>
|
||||
{cancelOn && (
|
||||
<p
|
||||
style={{
|
||||
marginTop: 4,
|
||||
fontSize: 9,
|
||||
color: '#e64a19',
|
||||
backgroundColor: '#fff',
|
||||
border: '1px solid #ffccbc',
|
||||
borderRadius: 6,
|
||||
padding: '2px 6px',
|
||||
}}
|
||||
>
|
||||
Отмена: {cancelOn}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── ReferralBlock ──────────────────────────────────────────────
|
||||
|
||||
export function ReferralBlock({ node, onClick }: BlockProps) {
|
||||
const targetCount = (node.data.targetCount ?? 0) as number;
|
||||
const trackVar = (node.data.trackVariable ?? 'invited_count') as string;
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => onClick?.(node.id)}
|
||||
className="cursor-pointer select-none rounded-xl overflow-hidden"
|
||||
style={{
|
||||
width: 200,
|
||||
backgroundColor: '#fce4ec',
|
||||
border: '2px solid #ec407a',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-1.5 px-3 py-2"
|
||||
style={{ borderBottom: '1px solid #f48fb1' }}
|
||||
>
|
||||
<span style={{ fontSize: 13 }}>🎁</span>
|
||||
<span style={{ fontSize: 12, fontWeight: 700, color: '#880e4f' }}>Реферал</span>
|
||||
</div>
|
||||
|
||||
<div className="px-2 py-2 space-y-1">
|
||||
<div
|
||||
className="rounded"
|
||||
style={{
|
||||
backgroundColor: '#fff',
|
||||
border: '1px solid #f8bbd0',
|
||||
padding: '2px 6px',
|
||||
fontSize: 11,
|
||||
color: '#ad1457',
|
||||
}}
|
||||
>
|
||||
Цель: <span style={{ fontWeight: 700 }}>{targetCount}</span> друзей
|
||||
</div>
|
||||
<div
|
||||
className="rounded"
|
||||
style={{
|
||||
backgroundColor: '#fff',
|
||||
border: '1px solid #f8bbd0',
|
||||
padding: '2px 6px',
|
||||
fontSize: 10,
|
||||
color: '#c2185b',
|
||||
fontFamily: 'monospace',
|
||||
}}
|
||||
>
|
||||
{trackVar}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── CollapsedBlock ─────────────────────────────────────────────
|
||||
|
||||
export function CollapsedBlock({ onClick }: SimpleBlockProps) {
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
className="cursor-pointer select-none rounded-lg"
|
||||
style={{
|
||||
width: 160,
|
||||
padding: '10px 12px',
|
||||
border: '2px dashed #bdbdbd',
|
||||
backgroundColor: '#fafafa',
|
||||
textAlign: 'center',
|
||||
fontSize: 11,
|
||||
color: '#9e9e9e',
|
||||
}}
|
||||
>
|
||||
нажмите, чтобы развернуть
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,622 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { ScenarioJson, ScenarioNodeJson } from './scenario-converter';
|
||||
import {
|
||||
StartBlock,
|
||||
EndBlock,
|
||||
MessageBlock,
|
||||
ButtonBlock,
|
||||
ConditionBlock,
|
||||
ActionBlock,
|
||||
InputBlock,
|
||||
DelayBlock,
|
||||
ReferralBlock,
|
||||
} from './tree-blocks';
|
||||
import { VerticalArrow } from './tree-arrows';
|
||||
|
||||
const MAX_DEPTH = 15;
|
||||
|
||||
interface TreeRendererProps {
|
||||
scenario: ScenarioJson;
|
||||
onNodeClick?: (nodeId: string) => void;
|
||||
}
|
||||
|
||||
export function TreeRenderer({ scenario, onNodeClick }: TreeRendererProps) {
|
||||
const nodes = scenario.nodes;
|
||||
const [zoom, setZoom] = useState(0.7);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Pan (drag) support
|
||||
const [isPanning, setIsPanning] = useState(false);
|
||||
const panStart = useRef({ x: 0, y: 0, scrollLeft: 0, scrollTop: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
// Only pan on middle button or when clicking empty space
|
||||
if (
|
||||
e.button === 1 ||
|
||||
(e.button === 0 && (e.target as HTMLElement).closest('.overflow-auto') === el)
|
||||
) {
|
||||
setIsPanning(true);
|
||||
panStart.current = {
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
scrollLeft: el.scrollLeft,
|
||||
scrollTop: el.scrollTop,
|
||||
};
|
||||
el.style.cursor = 'grabbing';
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
const onMouseMove = (e: MouseEvent) => {
|
||||
if (!isPanning) return;
|
||||
el.scrollLeft = panStart.current.scrollLeft - (e.clientX - panStart.current.x);
|
||||
el.scrollTop = panStart.current.scrollTop - (e.clientY - panStart.current.y);
|
||||
};
|
||||
const onMouseUp = () => {
|
||||
setIsPanning(false);
|
||||
el.style.cursor = 'grab';
|
||||
};
|
||||
|
||||
el.addEventListener('mousedown', onMouseDown);
|
||||
window.addEventListener('mousemove', onMouseMove);
|
||||
window.addEventListener('mouseup', onMouseUp);
|
||||
el.style.cursor = 'grab';
|
||||
|
||||
return () => {
|
||||
el.removeEventListener('mousedown', onMouseDown);
|
||||
window.removeEventListener('mousemove', onMouseMove);
|
||||
window.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
}, [isPanning]);
|
||||
|
||||
const handleNodeClick = useCallback((nodeId: string) => onNodeClick?.(nodeId), [onNodeClick]);
|
||||
|
||||
// Pre-compute the render order: walk the tree, record which nodes expand.
|
||||
// This avoids React StrictMode double-render issues with mutable refs.
|
||||
const expandedNodes = useMemo(() => {
|
||||
const expanded = new Set<string>();
|
||||
function walk(nodeId: string, depth: number) {
|
||||
if (expanded.has(nodeId) || depth > MAX_DEPTH || !nodes[nodeId]) return;
|
||||
expanded.add(nodeId);
|
||||
const node = nodes[nodeId];
|
||||
|
||||
// Walk button goto targets
|
||||
if (node.type === 'message') {
|
||||
const buttons = (node.data.buttons ?? []) as Array<{ action: string; target?: string }>;
|
||||
for (const btn of buttons) {
|
||||
if (btn.action === 'goto' && btn.target) walk(btn.target, depth + 1);
|
||||
}
|
||||
}
|
||||
// Walk condition targets
|
||||
if (node.type === 'condition') {
|
||||
const rules = (node.data.rules ?? []) as Array<{ target: string }>;
|
||||
for (const r of rules) if (r.target) walk(r.target, depth + 1);
|
||||
const def = node.data.default as string | undefined;
|
||||
if (def) walk(def, depth + 1);
|
||||
}
|
||||
// Walk referral targets
|
||||
if (node.type === 'referral') {
|
||||
const reach = node.data.onReach as string | undefined;
|
||||
const friend = node.data.friendStartNode as string | undefined;
|
||||
if (reach) walk(reach, depth + 1);
|
||||
if (friend) walk(friend, depth + 1);
|
||||
}
|
||||
// Walk next
|
||||
if (node.next) walk(node.next, depth + 1);
|
||||
}
|
||||
if (scenario.startNodeId) walk(scenario.startNodeId, 0);
|
||||
return expanded;
|
||||
}, [scenario.startNodeId, nodes]);
|
||||
|
||||
// Center scroll on mount
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (el) {
|
||||
requestAnimationFrame(() => {
|
||||
el.scrollLeft = (el.scrollWidth - el.clientWidth) / 2;
|
||||
});
|
||||
}
|
||||
}, [expandedNodes]);
|
||||
|
||||
if (!scenario.startNodeId || !nodes[scenario.startNodeId]) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-center rounded-xl border-2 border-dashed border-gray-300"
|
||||
style={{ minHeight: 300 }}
|
||||
>
|
||||
<p className="text-sm text-gray-400">Сценарий пуст</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Zoom controls */}
|
||||
<div className="sticky top-0 right-0 z-10 flex items-center gap-1 p-2 justify-end">
|
||||
<button
|
||||
onClick={() => setZoom((z) => Math.max(0.3, z - 0.1))}
|
||||
className="w-7 h-7 rounded bg-white border text-sm shadow-sm hover:bg-gray-50"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="text-[10px] text-gray-500 w-10 text-center">
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setZoom((z) => Math.min(1.5, z + 0.1))}
|
||||
className="w-7 h-7 rounded bg-white border text-sm shadow-sm hover:bg-gray-50"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setZoom(0.85)}
|
||||
className="w-7 h-7 rounded bg-white border text-[9px] shadow-sm hover:bg-gray-50"
|
||||
>
|
||||
⊙
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="overflow-auto"
|
||||
style={{
|
||||
maxHeight: '75vh',
|
||||
minHeight: 400,
|
||||
backgroundColor: '#fafafa',
|
||||
userSelect: isPanning ? 'none' : 'auto',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
transform: `scale(${zoom})`,
|
||||
transformOrigin: 'top center',
|
||||
padding: '24px 40px 60px',
|
||||
minWidth: 'fit-content',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<StartBlock onClick={() => handleNodeClick('__start__')} />
|
||||
<VerticalArrow />
|
||||
|
||||
<SequenceRenderer
|
||||
nodeId={scenario.startNodeId}
|
||||
nodes={nodes}
|
||||
expandedNodes={expandedNodes}
|
||||
renderVisited={new Set()}
|
||||
depth={0}
|
||||
onNodeClick={handleNodeClick}
|
||||
/>
|
||||
|
||||
<VerticalArrow />
|
||||
<EndBlock onClick={() => handleNodeClick('__end__')} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Ref stub (already visited node) ────────────────────────────
|
||||
|
||||
function RefStub({ nodeId, nodeType }: { nodeId: string; nodeType?: string }) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg text-center cursor-default"
|
||||
style={{
|
||||
padding: '8px 14px',
|
||||
border: '2px dashed #90caf9',
|
||||
backgroundColor: '#e3f2fd',
|
||||
fontSize: 10,
|
||||
color: '#1565c0',
|
||||
maxWidth: 150,
|
||||
}}
|
||||
>
|
||||
↗ {nodeType ? `${nodeType}: ` : ''}
|
||||
{nodeId.length > 20 ? nodeId.slice(0, 20) + '…' : nodeId}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── SequenceRenderer ───────────────────────────────────────────
|
||||
|
||||
interface SeqProps {
|
||||
nodeId: string;
|
||||
nodes: Record<string, ScenarioNodeJson>;
|
||||
expandedNodes: Set<string>;
|
||||
renderVisited: Set<string>;
|
||||
depth: number;
|
||||
onNodeClick: (nodeId: string) => void;
|
||||
}
|
||||
|
||||
function SequenceRenderer({
|
||||
nodeId,
|
||||
nodes,
|
||||
expandedNodes,
|
||||
renderVisited,
|
||||
depth,
|
||||
onNodeClick,
|
||||
}: SeqProps) {
|
||||
if (depth > MAX_DEPTH) return <RefStub nodeId={nodeId} />;
|
||||
|
||||
// Already rendered in this render pass → show stub
|
||||
if (renderVisited.has(nodeId)) {
|
||||
return <RefStub nodeId={nodeId} nodeType={nodes[nodeId]?.type} />;
|
||||
}
|
||||
|
||||
const node = nodes[nodeId];
|
||||
if (!node) {
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg"
|
||||
style={{
|
||||
padding: '6px 12px',
|
||||
border: '2px dashed #bdbdbd',
|
||||
backgroundColor: '#f5f5f5',
|
||||
fontSize: 10,
|
||||
color: '#9e9e9e',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
? {nodeId}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Create a new visited set with this node added (immutable approach for StrictMode)
|
||||
const newVisited = new Set(renderVisited);
|
||||
newVisited.add(nodeId);
|
||||
|
||||
const common = { nodes, expandedNodes, renderVisited: newVisited, depth, onNodeClick };
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<NodeBlock node={node} onNodeClick={onNodeClick} />
|
||||
|
||||
{node.type === 'message' && <MessageBranches node={node} {...common} />}
|
||||
{node.type === 'condition' && <ConditionBranches node={node} {...common} />}
|
||||
{node.type === 'referral' && <ReferralBranches node={node} {...common} />}
|
||||
|
||||
{node.next && nodes[node.next] && (
|
||||
<>
|
||||
<VerticalArrow />
|
||||
<SequenceRenderer nodeId={node.next} {...common} depth={depth + 1} />
|
||||
</>
|
||||
)}
|
||||
{node.next && !nodes[node.next] && (
|
||||
<>
|
||||
<VerticalArrow />
|
||||
<RefStub nodeId={node.next} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Message buttons + fan-out ──────────────────────────────────
|
||||
|
||||
function MessageBranches({
|
||||
node,
|
||||
nodes,
|
||||
expandedNodes,
|
||||
renderVisited,
|
||||
depth,
|
||||
onNodeClick,
|
||||
}: {
|
||||
node: ScenarioNodeJson;
|
||||
nodes: Record<string, ScenarioNodeJson>;
|
||||
expandedNodes: Set<string>;
|
||||
renderVisited: Set<string>;
|
||||
depth: number;
|
||||
onNodeClick: (id: string) => void;
|
||||
}) {
|
||||
const buttons = (node.data.buttons ?? []) as Array<{
|
||||
text: string;
|
||||
action: string;
|
||||
target?: string;
|
||||
url?: string;
|
||||
}>;
|
||||
if (buttons.length === 0) return null;
|
||||
|
||||
// Collect goto targets that can be expanded
|
||||
const gotoButtons = buttons
|
||||
.filter((b) => b.action === 'goto' && b.target && nodes[b.target])
|
||||
.map((b) => ({ text: b.text, targetId: b.target! }));
|
||||
|
||||
// Buttons that can branch (target not yet visited)
|
||||
const expandable = gotoButtons.filter((b) => !renderVisited.has(b.targetId));
|
||||
|
||||
// Bitrix-style: integrated columns — each column has its line segment + arrow + branch content
|
||||
return (
|
||||
<>
|
||||
<VerticalArrow />
|
||||
{/* Buttons row */}
|
||||
<div className="flex gap-2 justify-center flex-wrap">
|
||||
{buttons.map((btn, i) => (
|
||||
<ButtonBlock
|
||||
key={i}
|
||||
text={btn.text}
|
||||
action={btn.action}
|
||||
target={btn.target}
|
||||
url={btn.url}
|
||||
onClick={() => onNodeClick(node.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Integrated fan-out + branches in one flex container */}
|
||||
{expandable.length > 0 && (
|
||||
<div style={{ width: '100%' }}>
|
||||
{/* Vertical line from buttons to horizontal bar */}
|
||||
<div className="flex justify-center">
|
||||
<div style={{ width: 2, height: 16, backgroundColor: '#C0C1C3' }} />
|
||||
</div>
|
||||
|
||||
{/* Columns: horizontal line + vertical drop + branch */}
|
||||
<div className="flex">
|
||||
{expandable.map((b, i) => (
|
||||
<div key={i} className="flex-1 flex flex-col items-center" style={{ minWidth: 150 }}>
|
||||
{/* Horizontal line segment */}
|
||||
<div
|
||||
style={{
|
||||
height: 2,
|
||||
backgroundColor: '#C0C1C3',
|
||||
width:
|
||||
expandable.length === 1
|
||||
? 0
|
||||
: i === 0
|
||||
? '50%'
|
||||
: i === expandable.length - 1
|
||||
? '50%'
|
||||
: '100%',
|
||||
alignSelf:
|
||||
i === 0 ? 'flex-end' : i === expandable.length - 1 ? 'flex-start' : 'stretch',
|
||||
}}
|
||||
/>
|
||||
{/* Vertical drop + arrow */}
|
||||
<div style={{ width: 2, height: 14, backgroundColor: '#C0C1C3' }} />
|
||||
<div
|
||||
style={{
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: '4px solid transparent',
|
||||
borderRight: '4px solid transparent',
|
||||
borderTop: '5px solid #C0C1C3',
|
||||
}}
|
||||
/>
|
||||
{/* Branch content */}
|
||||
<div className="mt-1">
|
||||
<SequenceRenderer
|
||||
nodeId={b.targetId}
|
||||
nodes={nodes}
|
||||
expandedNodes={expandedNodes}
|
||||
renderVisited={renderVisited}
|
||||
depth={depth + 1}
|
||||
onNodeClick={onNodeClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stubs for already-visited targets */}
|
||||
{gotoButtons.filter((b) => renderVisited.has(b.targetId)).length > 0 &&
|
||||
expandable.length === 0 && (
|
||||
<>
|
||||
<VerticalArrow />
|
||||
<div className="flex gap-2 justify-center flex-wrap">
|
||||
{gotoButtons
|
||||
.filter((b) => renderVisited.has(b.targetId))
|
||||
.map((b, i) => (
|
||||
<RefStub key={i} nodeId={b.targetId} nodeType={nodes[b.targetId]?.type} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Condition branches ─────────────────────────────────────────
|
||||
|
||||
function ConditionBranches({
|
||||
node,
|
||||
nodes,
|
||||
expandedNodes,
|
||||
renderVisited,
|
||||
depth,
|
||||
onNodeClick,
|
||||
}: {
|
||||
node: ScenarioNodeJson;
|
||||
nodes: Record<string, ScenarioNodeJson>;
|
||||
expandedNodes: Set<string>;
|
||||
renderVisited: Set<string>;
|
||||
depth: number;
|
||||
onNodeClick: (id: string) => void;
|
||||
}) {
|
||||
const rules = (node.data.rules ?? []) as Array<{ condition: string; target: string }>;
|
||||
const def = node.data.default as string | undefined;
|
||||
|
||||
const branches: Array<{ label: string; targetId: string }> = [];
|
||||
for (const rule of rules) {
|
||||
if (rule.target && nodes[rule.target]) {
|
||||
branches.push({ label: rule.condition, targetId: rule.target });
|
||||
}
|
||||
}
|
||||
if (def && nodes[def]) {
|
||||
branches.push({ label: 'иначе', targetId: def });
|
||||
}
|
||||
|
||||
if (branches.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%' }}>
|
||||
<div className="flex justify-center">
|
||||
<div style={{ width: 2, height: 16, backgroundColor: '#C0C1C3' }} />
|
||||
</div>
|
||||
<div className="flex">
|
||||
{branches.map((b, i) => (
|
||||
<div key={i} className="flex-1 flex flex-col items-center" style={{ minWidth: 150 }}>
|
||||
<div
|
||||
style={{
|
||||
height: 2,
|
||||
backgroundColor: '#C0C1C3',
|
||||
width:
|
||||
branches.length === 1
|
||||
? 0
|
||||
: i === 0
|
||||
? '50%'
|
||||
: i === branches.length - 1
|
||||
? '50%'
|
||||
: '100%',
|
||||
alignSelf:
|
||||
i === 0 ? 'flex-end' : i === branches.length - 1 ? 'flex-start' : 'stretch',
|
||||
}}
|
||||
/>
|
||||
<div style={{ width: 2, height: 14, backgroundColor: '#C0C1C3' }} />
|
||||
<div
|
||||
style={{
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: '4px solid transparent',
|
||||
borderRight: '4px solid transparent',
|
||||
borderTop: '5px solid #C0C1C3',
|
||||
}}
|
||||
/>
|
||||
<div className="text-[9px] text-[#ff9800] font-semibold mb-1 mt-1 text-center">
|
||||
{b.label}
|
||||
</div>
|
||||
<SequenceRenderer
|
||||
nodeId={b.targetId}
|
||||
nodes={nodes}
|
||||
expandedNodes={expandedNodes}
|
||||
renderVisited={renderVisited}
|
||||
depth={depth + 1}
|
||||
onNodeClick={onNodeClick}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Referral branches ──────────────────────────────────────────
|
||||
|
||||
function ReferralBranches({
|
||||
node,
|
||||
nodes,
|
||||
expandedNodes,
|
||||
renderVisited,
|
||||
depth,
|
||||
onNodeClick,
|
||||
}: {
|
||||
node: ScenarioNodeJson;
|
||||
nodes: Record<string, ScenarioNodeJson>;
|
||||
expandedNodes: Set<string>;
|
||||
renderVisited: Set<string>;
|
||||
depth: number;
|
||||
onNodeClick: (id: string) => void;
|
||||
}) {
|
||||
const reach = node.data.onReach as string | undefined;
|
||||
const friend = node.data.friendStartNode as string | undefined;
|
||||
const branches: Array<{ label: string; targetId: string }> = [];
|
||||
if (reach && nodes[reach]) branches.push({ label: '🏆 Цель', targetId: reach });
|
||||
if (friend && nodes[friend]) branches.push({ label: '👋 Друг', targetId: friend });
|
||||
|
||||
if (branches.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%' }}>
|
||||
<div className="flex justify-center">
|
||||
<div style={{ width: 2, height: 16, backgroundColor: '#C0C1C3' }} />
|
||||
</div>
|
||||
<div className="flex">
|
||||
{branches.map((b, i) => (
|
||||
<div key={i} className="flex-1 flex flex-col items-center" style={{ minWidth: 150 }}>
|
||||
<div
|
||||
style={{
|
||||
height: 2,
|
||||
backgroundColor: '#C0C1C3',
|
||||
width:
|
||||
branches.length === 1
|
||||
? 0
|
||||
: i === 0
|
||||
? '50%'
|
||||
: i === branches.length - 1
|
||||
? '50%'
|
||||
: '100%',
|
||||
alignSelf:
|
||||
i === 0 ? 'flex-end' : i === branches.length - 1 ? 'flex-start' : 'stretch',
|
||||
}}
|
||||
/>
|
||||
<div style={{ width: 2, height: 14, backgroundColor: '#C0C1C3' }} />
|
||||
<div
|
||||
style={{
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: '4px solid transparent',
|
||||
borderRight: '4px solid transparent',
|
||||
borderTop: '5px solid #C0C1C3',
|
||||
}}
|
||||
/>
|
||||
<div className="text-[9px] text-[#ec407a] font-semibold mb-1 mt-1 text-center">
|
||||
{b.label}
|
||||
</div>
|
||||
<SequenceRenderer
|
||||
nodeId={b.targetId}
|
||||
nodes={nodes}
|
||||
expandedNodes={expandedNodes}
|
||||
renderVisited={renderVisited}
|
||||
depth={depth + 1}
|
||||
onNodeClick={onNodeClick}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── NodeBlock dispatcher ───────────────────────────────────────
|
||||
|
||||
function NodeBlock({
|
||||
node,
|
||||
onNodeClick,
|
||||
}: {
|
||||
node: ScenarioNodeJson;
|
||||
onNodeClick: (id: string) => void;
|
||||
}) {
|
||||
const click = () => onNodeClick(node.id);
|
||||
|
||||
switch (node.type) {
|
||||
case 'message':
|
||||
return <MessageBlock node={node} onClick={click} />;
|
||||
case 'condition':
|
||||
return <ConditionBlock node={node} onClick={click} />;
|
||||
case 'action':
|
||||
return <ActionBlock node={node} onClick={click} />;
|
||||
case 'input':
|
||||
return <InputBlock node={node} onClick={click} />;
|
||||
case 'delay':
|
||||
return <DelayBlock node={node} onClick={click} />;
|
||||
case 'referral':
|
||||
return <ReferralBlock node={node} onClick={click} />;
|
||||
default:
|
||||
return (
|
||||
<div
|
||||
onClick={click}
|
||||
className="rounded-lg border border-gray-300 bg-white px-3 py-2 text-xs text-gray-600 cursor-pointer"
|
||||
>
|
||||
{node.type}: {node.id}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
15
pnpm-lock.yaml
generated
15
pnpm-lock.yaml
generated
@@ -390,6 +390,9 @@ importers:
|
||||
|
||||
apps/web-club-admin:
|
||||
dependencies:
|
||||
'@dagrejs/dagre':
|
||||
specifier: ^2.0.4
|
||||
version: 2.0.4
|
||||
'@dnd-kit/core':
|
||||
specifier: ^6.3.1
|
||||
version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
@@ -1171,6 +1174,12 @@ packages:
|
||||
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@dagrejs/dagre@2.0.4':
|
||||
resolution: {integrity: sha512-J6vCWTNpicHF4zFlZG1cS5DkGzMr9941gddYkakjrg3ZNev4bbqEgLHFTWiFrcJm7UCRu7olO3K6IRDd9gSGhA==}
|
||||
|
||||
'@dagrejs/graphlib@3.0.4':
|
||||
resolution: {integrity: sha512-HxZ7fCvAwTLCWCO0WjDkzAFQze8LdC6iOpKbetDKHIuDfIgMlIzYzqZ4nxwLlclQX+3ZVeZ1K2OuaOE2WWcyOg==}
|
||||
|
||||
'@dnd-kit/accessibility@3.1.1':
|
||||
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
|
||||
peerDependencies:
|
||||
@@ -8679,6 +8688,12 @@ snapshots:
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.9
|
||||
|
||||
'@dagrejs/dagre@2.0.4':
|
||||
dependencies:
|
||||
'@dagrejs/graphlib': 3.0.4
|
||||
|
||||
'@dagrejs/graphlib@3.0.4': {}
|
||||
|
||||
'@dnd-kit/accessibility@3.1.1(react@19.2.3)':
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
|
||||
Reference in New Issue
Block a user