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:
root
2026-03-23 06:07:36 +00:00
parent fe07f3e13f
commit 022cb21eda
36 changed files with 4271 additions and 483 deletions

View File

@@ -2149,6 +2149,7 @@ model TgBotScenario {
name String name String
description String? description String?
scenario Json // full JSON scenario scenario Json // full JSON scenario
positions Json? // { nodeId: { x, y } } for visual editor
version Int @default(1) version Int @default(1)
isPublished Boolean @default(false) isPublished Boolean @default(false)
isDraft Boolean @default(true) isDraft Boolean @default(true)

View File

@@ -37,6 +37,7 @@ import { CrmModule } from './modules/crm';
import { RolesModule } from './modules/roles/roles.module'; import { RolesModule } from './modules/roles/roles.module';
import { BookingModule } from './modules/booking'; import { BookingModule } from './modules/booking';
import { TelegramBotModule } from './modules/telegram-bot'; import { TelegramBotModule } from './modules/telegram-bot';
import { ScreenshotsModule } from './modules/screenshots';
@Module({ @Module({
imports: [ imports: [
@@ -80,6 +81,7 @@ import { TelegramBotModule } from './modules/telegram-bot';
RolesModule, RolesModule,
BookingModule, BookingModule,
TelegramBotModule, TelegramBotModule,
ScreenshotsModule,
], ],
providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }], providers: [{ provide: APP_GUARD, useClass: ThrottlerGuard }],
}) })
@@ -93,6 +95,7 @@ export class AppModule implements NestModule {
'v1/crm/deals/webhook/(.*)', 'v1/crm/deals/webhook/(.*)',
'v1/booking/public/(.*)', 'v1/booking/public/(.*)',
'telegram-bot/webhook/(.*)', 'telegram-bot/webhook/(.*)',
'v1/screenshots/(.*)',
) )
.forRoutes('*'); .forRoutes('*');
} }

View File

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

View File

@@ -0,0 +1,2 @@
export * from './screenshots.module';
export * from './screenshots.service';

View 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 };
}
}

View 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 {}

View 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;
}
}

View File

@@ -27,8 +27,8 @@ export class BroadcastController {
async create(@CurrentUser() user: { clubId: string }, @Body() dto: CreateBroadcastDto) { async create(@CurrentUser() user: { clubId: string }, @Body() dto: CreateBroadcastDto) {
return this.broadcasts.create(user.clubId, dto.botId, { return this.broadcasts.create(user.clubId, dto.botId, {
name: dto.name, name: dto.name,
message: dto.message as BroadcastMessage, message: dto.message as unknown as BroadcastMessage,
filters: dto.filters as BroadcastFilters, filters: dto.filters as unknown as BroadcastFilters,
scheduledAt: dto.scheduledAt, scheduledAt: dto.scheduledAt,
}); });
} }

View File

@@ -28,7 +28,10 @@ export class WebhookInputController {
@Param('botId') botId: string, @Param('botId') botId: string,
@Body() update: TgUpdate, @Body() update: TgUpdate,
): Promise<{ ok: true }> { ): 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 // Enqueue for async processing — respond 200 immediately to Telegram
try {
await this.incomingQueue.add( await this.incomingQueue.add(
'process-update', 'process-update',
{ botId, update }, { botId, update },
@@ -39,6 +42,11 @@ export class WebhookInputController {
removeOnFail: 500, removeOnFail: 500,
}, },
); );
} catch (err: unknown) {
this.logger.error(
`Failed to enqueue update: ${err instanceof Error ? err.message : String(err)}`,
);
}
return { ok: true }; return { ok: true };
} }

View File

@@ -27,4 +27,9 @@ export class UpdateScenarioDto {
@IsOptional() @IsOptional()
@IsObject() @IsObject()
scenario?: Record<string, unknown>; scenario?: Record<string, unknown>;
@ApiPropertyOptional({ description: 'Node positions for visual editor { nodeId: { x, y } }' })
@IsOptional()
@IsObject()
positions?: Record<string, unknown>;
} }

View File

@@ -67,6 +67,24 @@ export class TelegramIncomingProcessor extends WorkerHost {
const from = message.from; const from = message.from;
const text = message.text ?? ''; 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 // Parse /start command and referral
let source: string | undefined; let source: string | undefined;
let referrerId: 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 scenario = bot.scenario.scenario as unknown as BotScenario;
const botCtx = { id: bot.id, clubId: bot.clubId, token: bot.token, username: bot.username }; 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 new user came via referral — notify referrer and route to friendStartNode
if (isNew && referrerId) { if (isNew && referrerId) {
const friendStartNode = await this.referralService.onFriendJoined( const friendStartNode = await this.referralService.onFriendJoined(

View File

@@ -5,7 +5,7 @@ export interface BotAnalytics {
subscribers: { subscribers: {
total: number; total: number;
subscribed: number; subscribed: number;
blocked: number; unsubscribed: number;
withPhone: number; withPhone: number;
bonusGranted: number; bonusGranted: number;
mailingConsent: number; mailingConsent: number;
@@ -48,17 +48,16 @@ export class AnalyticsService {
} }
private async getSubscriberStats(botId: string) { 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 } }),
this.prisma.tgBotSubscriber.count({ where: { botId, isSubscribed: true } }), 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, phone: { not: null } } }),
this.prisma.tgBotSubscriber.count({ where: { botId, bonusGranted: true } }), this.prisma.tgBotSubscriber.count({ where: { botId, bonusGranted: true } }),
this.prisma.tgBotSubscriber.count({ where: { botId, mailingConsent: true } }), this.prisma.tgBotSubscriber.count({ where: { botId, mailingConsent: true } }),
], ]);
); return { total, subscribed, unsubscribed, withPhone, bonusGranted, mailingConsent };
return { total, subscribed, blocked, withPhone, bonusGranted, mailingConsent };
} }
private async getReferralStats(botId: string) { private async getReferralStats(botId: string) {

View 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));
}
}

View File

@@ -143,6 +143,15 @@ export class ScenarioEngineService {
return; 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]; const node = ctx.scenario.nodes[nodeId];
if (!node) { if (!node) {
this.logger.warn(`Node "${nodeId}" not found in scenario`); this.logger.warn(`Node "${nodeId}" not found in scenario`);

View File

@@ -66,7 +66,7 @@ export class ScenarioService {
async update( async update(
clubId: string, clubId: string,
id: 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 } }); const existing = await this.prisma.tgBotScenario.findFirst({ where: { id, clubId } });
if (!existing) throw new NotFoundException('Scenario not found'); if (!existing) throw new NotFoundException('Scenario not found');
@@ -81,6 +81,9 @@ export class ScenarioService {
updateData.isDraft = true; updateData.isDraft = true;
updateData.isPublished = false; updateData.isPublished = false;
} }
if (data.positions !== undefined) {
updateData.positions = data.positions as Prisma.InputJsonValue;
}
return this.prisma.tgBotScenario.update({ where: { id }, data: updateData }); return this.prisma.tgBotScenario.update({ where: { id }, data: updateData });
} }

View File

@@ -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) { async markBlocked(botId: string, chatId: string) {
const sub = await this.prisma.tgBotSubscriber.findUnique({ const sub = await this.prisma.tgBotSubscriber.findUnique({
where: { botId_chatId: { botId, chatId } }, where: { botId_chatId: { botId, chatId } },

View File

@@ -11,6 +11,7 @@ import { BroadcastService } from './services/broadcast.service';
import { BotWebhookService } from './services/bot-webhook.service'; import { BotWebhookService } from './services/bot-webhook.service';
import { CrmIntegrationService } from './services/crm-integration.service'; import { CrmIntegrationService } from './services/crm-integration.service';
import { AnalyticsService } from './services/analytics.service'; import { AnalyticsService } from './services/analytics.service';
import { TelegramPollingService } from './services/polling.service';
// Processors // Processors
import { TelegramIncomingProcessor } from './processors/telegram-incoming.processor'; import { TelegramIncomingProcessor } from './processors/telegram-incoming.processor';
@@ -49,6 +50,7 @@ import { WebhookInputController } from './controllers/webhook-input.controller';
BotWebhookService, BotWebhookService,
CrmIntegrationService, CrmIntegrationService,
AnalyticsService, AnalyticsService,
TelegramPollingService,
// Processors // Processors
TelegramIncomingProcessor, TelegramIncomingProcessor,
DelayNodeProcessor, DelayNodeProcessor,

View File

@@ -10,6 +10,7 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@dagrejs/dagre": "^2.0.4",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",

View File

@@ -13,9 +13,11 @@ import {
Plus, Plus,
Code, Code,
Workflow, Workflow,
ListOrdered,
} from 'lucide-react'; } from 'lucide-react';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { FlowEditor } from '@/components/telegram-bot/flow-editor/flow-editor'; 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'; import type { ScenarioJson } from '@/components/telegram-bot/flow-editor/scenario-converter';
interface Scenario { interface Scenario {
@@ -41,7 +43,7 @@ export default function ScenarioPage() {
const [scenarios, setScenarios] = useState<Scenario[]>([]); const [scenarios, setScenarios] = useState<Scenario[]>([]);
const [selected, setSelected] = useState<Scenario | null>(null); const [selected, setSelected] = useState<Scenario | null>(null);
const [jsonText, setJsonText] = useState(''); 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 [saving, setSaving] = useState(false);
const [validating, setValidating] = useState(false); const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState<{ const [validationResult, setValidationResult] = useState<{
@@ -239,6 +241,16 @@ export default function ScenarioPage() {
{/* Editor mode toggle */} {/* 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)]"> <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 <button
onClick={() => setEditorMode('visual')} onClick={() => setEditorMode('visual')}
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition-colors ${ 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)]' : '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>
<button <button
onClick={() => { onClick={() => {
if (editorMode === 'visual' && selected) { if (editorMode !== 'json' && selected) {
setJsonText(JSON.stringify(selected.scenario, null, 2)); setJsonText(JSON.stringify(selected.scenario, null, 2));
} }
setEditorMode('json'); setEditorMode('json');
@@ -266,10 +278,36 @@ export default function ScenarioPage() {
</button> </button>
</div> </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 && ( {editorMode === 'visual' && selected?.scenario && (
<FlowEditor <FlowEditor
scenario={selected.scenario as unknown as ScenarioJson} scenario={selected.scenario as unknown as ScenarioJson}
positions={
(selected as unknown as { positions?: Record<string, { x: number; y: number }> })
.positions
}
onSave={async (updatedScenario) => { onSave={async (updatedScenario) => {
try { try {
setSaving(true); setSaving(true);
@@ -285,6 +323,11 @@ export default function ScenarioPage() {
setSaving(false); setSaving(false);
} }
}} }}
onPositionsChange={async (positions) => {
await api
.patch(`/telegram-bot/scenarios/${selected.id}`, { positions })
.catch(() => {});
}}
/> />
)} )}
@@ -349,8 +392,8 @@ export default function ScenarioPage() {
</div> </div>
<p className="mt-3 text-xs text-[var(--muted)]"> <p className="mt-3 text-xs text-[var(--muted)]">
Визуальный редактор (React Flow) будет доступен в следующем обновлении. Сейчас можно Редактор: пошаговое редактирование сценария. Схема: визуальная карта (React Flow). JSON:
редактировать JSON напрямую. прямое редактирование.
</p> </p>
</> </>
)} )}

View File

@@ -1,146 +1,177 @@
'use client'; 'use client';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
ReactFlow, ReactFlow,
Background, Background,
Controls, Controls,
MiniMap, MiniMap,
Panel, MarkerType,
useNodesState,
useEdgesState,
addEdge,
type Connection,
type Node, type Node,
type Edge,
type NodeTypes, type NodeTypes,
type OnNodesChange,
type OnEdgesChange,
applyNodeChanges,
applyEdgeChanges,
type NodeMouseHandler,
BackgroundVariant, BackgroundVariant,
} from '@xyflow/react'; } from '@xyflow/react';
import '@xyflow/react/dist/style.css'; 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 { MessageNode } from './nodes/message-node';
import { ConditionNode } from './nodes/condition-node';
import { ActionNode } from './nodes/action-node'; import { ActionNode } from './nodes/action-node';
import { ConditionNode } from './nodes/condition-node';
import { InputNode } from './nodes/input-node'; import { InputNode } from './nodes/input-node';
import { DelayNode } from './nodes/delay-node'; import { DelayNode } from './nodes/delay-node';
import { ReferralNode } from './nodes/referral-node'; import { ReferralNode } from './nodes/referral-node';
import { scenarioToFlow, type ScenarioJson } from './scenario-converter';
import { NodePropertiesPanel } from './panels/node-properties-panel'; import { NodePropertiesPanel } from './panels/node-properties-panel';
// ─── Props ──────────────────────────────────────────────────────
interface FlowEditorProps { interface FlowEditorProps {
scenario: ScenarioJson; scenario: ScenarioJson;
positions?: Record<string, { x: number; y: number }> | null;
onSave: (scenario: ScenarioJson) => void; onSave: (scenario: ScenarioJson) => void;
onPositionsChange?: (positions: Record<string, { x: number; y: number }>) => void;
} }
// ─── Node type registry ─────────────────────────────────────────
const nodeTypes: NodeTypes = { const nodeTypes: NodeTypes = {
'scenario-message': MessageNode, 'scenario-message': MessageNode,
'scenario-condition': ConditionNode,
'scenario-action': ActionNode, 'scenario-action': ActionNode,
'scenario-condition': ConditionNode,
'scenario-input': InputNode, 'scenario-input': InputNode,
'scenario-delay': DelayNode, 'scenario-delay': DelayNode,
'scenario-referral': ReferralNode, 'scenario-referral': ReferralNode,
}; };
const NODE_TEMPLATES: Array<{ type: string; label: string; color: string }> = [ // ─── Toolbar templates ──────────────────────────────────────────
{ type: 'message', label: 'Сообщение', color: '#3b82f6' },
{ type: 'condition', label: 'Условие', color: '#f59e0b' }, const NODE_TEMPLATES: Array<{ type: string; label: string; icon: string }> = [
{ type: 'action', label: 'Действие', color: '#a855f7' }, { type: 'message', label: 'Сообщение', icon: '\uD83D\uDCAC' },
{ type: 'input', label: 'Ввод', color: '#14b8a6' }, { type: 'condition', label: 'Условие', icon: '\uD83D\uDD00' },
{ type: 'delay', label: 'Задержка', color: '#f97316' }, { type: 'action', label: 'Действие', icon: '\u26A1' },
{ type: 'referral', label: 'Реферал', color: '#ec4899' }, { 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 [mounted, setMounted] = useState(false);
const initial = useMemo(() => scenarioToFlow(scenario), [scenario]); const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [nodes, setNodes, onNodesChange] = useNodesState(initial.nodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initial.edges); // Debounce position saves
const [selectedNode, setSelectedNode] = useState<Node | null>(null); 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(() => { useEffect(() => {
setMounted(true); setMounted(true);
}, []); }, []);
const onConnect = useCallback( // Node changes (drag, select, etc.)
(params: Connection) => { const onNodesChange: OnNodesChange = useCallback(
setEdges((eds) => addEdge({ ...params, type: 'smoothstep', style: { stroke: '#888' } }, eds)); (changes) => {
}, setNodes((nds) => applyNodeChanges(changes, nds));
[setEdges],
);
const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => { // Debounce position persistence
setSelectedNode(node); const hasDrag = changes.some((c) => c.type === 'position' && c.dragging === false);
}, []); if (hasDrag && onPositionsChange) {
if (positionTimerRef.current) clearTimeout(positionTimerRef.current);
const onPaneClick = useCallback(() => { positionTimerRef.current = setTimeout(() => {
setSelectedNode(null); setNodes((currentNodes) => {
}, []); onPositionsChange(extractPositions(currentNodes));
return currentNodes;
const addNode = useCallback( });
(type: string) => { }, 500);
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;
} }
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( // Edge changes (select, remove)
(nodeId: string) => { 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)); setNodes((nds) => nds.filter((n) => n.id !== nodeId));
setEdges((eds) => eds.filter((e) => e.source !== nodeId && e.target !== nodeId)); setEdges((eds) => eds.filter((e) => e.source !== nodeId && e.target !== nodeId));
setSelectedNode(null); setSelectedNodeId(null);
}, }, []);
[setNodes, setEdges],
const selectedNode = useMemo(
() => nodes.find((n) => n.id === selectedNodeId) ?? null,
[nodes, selectedNodeId],
); );
const updateNodeData = useCallback( const nodeCount = Object.keys(scenario.nodes).length;
(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],
);
if (!mounted) { if (!mounted) {
return ( return (
@@ -151,66 +182,74 @@ export function FlowEditor({ scenario, onSave: _onSave }: FlowEditorProps) {
} }
return ( return (
<div className="h-[700px] w-full rounded-xl border border-[var(--border)] overflow-hidden relative"> <div className="w-full rounded-xl border border-[var(--border)] overflow-hidden relative">
<ReactFlow {/* Toolbar */}
nodes={nodes} <div className="flex items-center justify-between border-b border-[var(--border)] bg-white px-3 py-2 z-10 relative">
edges={edges} <div className="flex flex-wrap gap-1.5">
onNodesChange={onNodesChange} {NODE_TEMPLATES.map((t) => (
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) => (
<button <button
key={tpl.type} key={t.type}
onClick={() => addNode(tpl.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"
className="rounded-md px-2.5 py-1.5 text-xs font-medium text-white transition-opacity hover:opacity-80" title={`Добавить: ${t.label}`}
style={{ backgroundColor: tpl.color }}
title={`Добавить: ${tpl.label}`}
> >
+ {tpl.label} <span>{t.icon}</span> {t.label}
</button> </button>
))} ))}
</div> </div>
</Panel> <div className="text-[11px] text-[#757575] ml-4 shrink-0">{nodeCount} нод</div>
{/* 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> </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 && ( {selectedNode && (
<div onClick={(e) => e.stopPropagation()}>
<NodePropertiesPanel <NodePropertiesPanel
node={selectedNode} node={selectedNode}
onUpdate={updateNodeData} onUpdate={handleNodeUpdate}
onDelete={deleteNode} onDelete={handleNodeDelete}
onClose={() => setSelectedNode(null)} onClose={() => setSelectedNodeId(null)}
/> />
</div>
)} )}
</div> </div>
); );

View File

@@ -1,35 +1,117 @@
'use client'; 'use client';
import { Handle, Position } from '@xyflow/react'; import { NodeShell } from './node-shell';
import { Zap } from 'lucide-react';
interface ActionNodeData { interface ActionNodeData {
actions?: Array<{ type: string; variable?: string; value?: unknown }>; actions?: Array<{ type: string; variable?: string; value?: unknown }>;
isStart?: boolean;
nodeId?: string;
[key: string]: unknown; [key: string]: unknown;
} }
export function ActionNode({ data }: { data: ActionNodeData }) { const ACTION_LABELS: Record<string, string> = {
const actions = (data.actions ?? []) as Array<{ type: string; variable?: 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 ( return (
<div className="min-w-[180px] max-w-[240px] rounded-xl border-2 border-purple-400 bg-white shadow-sm"> <NodeShell
<Handle type="target" position={Position.Left} className="!bg-purple-400 !w-3 !h-3" /> 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"> {actions.slice(0, 3).map((action, i) => (
<Zap className="h-4 w-4 text-purple-600" /> <div
<span className="text-xs font-semibold text-purple-800">Действие</span> key={i}
</div> style={{
display: 'flex',
<div className="px-3 py-2 space-y-1"> alignItems: 'center',
{actions.map((a, i) => ( gap: 4,
<div key={i} className="rounded bg-purple-50 px-2 py-0.5 text-[10px] text-purple-700"> padding: '2px 0',
{a.type} borderTop: i > 0 ? '1px solid #f5f5f5' : undefined,
{a.variable ? `: ${a.variable}` : ''} }}
>
<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>
))} ))}
</div>
<Handle type="source" position={Position.Right} className="!bg-purple-400 !w-3 !h-3" /> {actions.length > 3 && (
</div> <p
style={{
fontSize: 9,
color: '#bdbdbd',
margin: '2px 0 0',
textAlign: 'center',
}}
>
+{actions.length - 3} ещё
</p>
)}
</NodeShell>
); );
} }

View File

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

View File

@@ -1,41 +1,50 @@
'use client'; 'use client';
import { Handle, Position } from '@xyflow/react'; import { NodeShell } from './node-shell';
import { GitBranch } from 'lucide-react'; import { PortRow } from './port-row';
interface ConditionNodeData { interface ConditionNodeData {
rules?: Array<{ condition: string; target: string }>; rules?: Array<{ condition: string; target: string }>;
default?: string; default?: string;
isStart?: boolean;
nodeId?: string;
[key: string]: unknown; [key: string]: unknown;
} }
export function ConditionNode({ data }: { data: ConditionNodeData }) { export function ConditionNode({ data, id }: { data: ConditionNodeData; id: string }) {
const rules = (data.rules ?? []) as Array<{ condition: 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 ( return (
<div className="min-w-[200px] max-w-[260px] rounded-xl border-2 border-amber-400 bg-white shadow-sm"> <NodeShell
<Handle type="target" position={Position.Left} className="!bg-amber-400 !w-3 !h-3" /> accentColor="#ffa726"
icon={'\uD83D\uDD00'}
<div className="flex items-center gap-2 px-3 py-2 bg-amber-50 rounded-t-lg"> title="Условие"
<GitBranch className="h-4 w-4 text-amber-600" /> nodeId={nodeId}
<span className="text-xs font-semibold text-amber-800">Условие</span> isStart={isStart}
</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"
> >
{rule.condition} {rules.length === 0 && !defaultTarget && (
</div> <p
))} style={{
{data.default && ( fontSize: 10,
<div className="rounded bg-gray-50 px-2 py-0.5 text-[10px] text-gray-500">default </div> 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> </div>
</NodeShell>
); );
} }

View File

@@ -1,30 +1,56 @@
'use client'; 'use client';
import { Handle, Position } from '@xyflow/react'; import { NodeShell } from './node-shell';
import { Clock } from 'lucide-react';
interface DelayNodeData { interface DelayNodeData {
duration?: string; duration?: string;
cancelOn?: string; cancelOn?: string;
reminder?: string;
isStart?: boolean;
nodeId?: string;
[key: string]: unknown; [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 ( return (
<div className="min-w-[160px] max-w-[220px] rounded-xl border-2 border-orange-400 bg-white shadow-sm"> <NodeShell
<Handle type="target" position={Position.Left} className="!bg-orange-400 !w-3 !h-3" /> 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"> {/* cancelOn below */}
<Clock className="h-4 w-4 text-orange-600" /> {cancelOn && (
<span className="text-xs font-semibold text-orange-800">Задержка</span> <p
</div> style={{
fontSize: 10,
<div className="px-3 py-2"> color: '#999',
<p className="text-sm font-mono text-orange-700">{(data.duration as string) ?? '?'}</p> textAlign: 'center',
{data.cancelOn && <p className="text-[10px] text-gray-500 mt-1">Отмена: {data.cancelOn}</p>} margin: '0 0 2px',
</div> }}
>
<Handle type="source" position={Position.Right} className="!bg-orange-400 !w-3 !h-3" /> Отмена: {cancelOn}
</div> </p>
)}
</NodeShell>
); );
} }

View File

@@ -1,36 +1,86 @@
'use client'; 'use client';
import { Handle, Position } from '@xyflow/react'; import { NodeShell } from './node-shell';
import { TextCursorInput } from 'lucide-react';
interface InputNodeData { interface InputNodeData {
prompt?: string; prompt?: string;
inputType?: string; inputType?: string;
saveAs?: string; saveAs?: string;
isStart?: boolean;
nodeId?: string;
[key: string]: unknown; [key: string]: unknown;
} }
export function InputNode({ data }: { data: InputNodeData }) { const TYPE_LABELS: Record<string, string> = {
const prompt = (data.prompt ?? '').replace(/<[^>]+>/g, ''); text: 'Текст',
const preview = prompt.length > 60 ? prompt.slice(0, 60) + '...' : prompt; 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 ( return (
<div className="min-w-[200px] max-w-[260px] rounded-xl border-2 border-teal-400 bg-white shadow-sm"> <NodeShell
<Handle type="target" position={Position.Left} className="!bg-teal-400 !w-3 !h-3" /> accentColor="#26a69a"
icon={'\u270F\uFE0F'}
<div className="flex items-center gap-2 px-3 py-2 bg-teal-50 rounded-t-lg"> title="Ввод данных"
<TextCursorInput className="h-4 w-4 text-teal-600" /> nodeId={nodeId}
<span className="text-xs font-semibold text-teal-800"> isStart={isStart}
Ввод ({data.inputType ?? 'text'}) >
{/* 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> </span>
</div> </div>
<div className="px-3 py-2"> {/* Prompt preview (2 lines) */}
<p className="text-xs text-gray-700">{preview || 'Ожидание ввода'}</p> <p
{data.saveAs && <p className="text-[10px] text-teal-600 mt-1 font-mono"> {data.saveAs}</p>} style={{
</div> 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" /> {/* saveAs */}
</div> {saveAs && (
<p
style={{
marginTop: 4,
fontSize: 10,
color: '#26a69a',
fontFamily: 'monospace',
}}
>
{'\u2192'} {saveAs}
</p>
)}
</NodeShell>
); );
} }

View File

@@ -1,58 +1,109 @@
'use client'; 'use client';
import { Handle, Position } from '@xyflow/react'; import { NodeShell } from './node-shell';
import { MessageSquare } from 'lucide-react'; import { PortRow } from './port-row';
interface ButtonData {
text: string;
action: string;
target?: string;
url?: string;
}
interface MessageNodeData { interface MessageNodeData {
text?: string; text?: string;
buttons?: Array<{ text: string; action: string }>; image?: string;
buttons?: ButtonData[];
isStart?: boolean; isStart?: boolean;
nodeId?: string;
[key: string]: unknown; [key: string]: unknown;
} }
export function MessageNode({ data }: { data: MessageNodeData }) { export function MessageNode({ data, id }: { data: MessageNodeData; id: string }) {
const text = data.text ?? ''; const text = data.text ?? '';
const buttons = (data.buttons ?? []) as Array<{ text: string; action: string }>; const image = data.image;
const truncated = text.length > 80 ? text.slice(0, 80) + '...' : text; const buttons = data.buttons ?? [];
// Strip HTML tags for preview const isStart = data.isStart;
const preview = truncated.replace(/<[^>]+>/g, ''); 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 ( return (
<div <NodeShell
className={`min-w-[220px] max-w-[280px] rounded-xl border-2 bg-white shadow-sm ${data.isStart ? 'border-green-500' : 'border-blue-400'}`} 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 <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" /> {'\uD83D\uDDBC'} Картинка
<span className="text-xs font-semibold text-blue-800">
{data.isStart ? '▶ Сообщение (старт)' : 'Сообщение'}
</span>
</div> </div>
)}
<div className="px-3 py-2"> {/* Text preview (2 lines) */}
<p className="text-xs text-gray-700 leading-relaxed">{preview || 'Пустое сообщение'}</p> <p
style={{
{buttons.length > 0 && ( fontSize: 11,
<div className="mt-2 space-y-1"> color: '#666',
{buttons.slice(0, 3).map((btn, i) => ( lineHeight: '15px',
<div margin: 0,
key={i} overflow: 'hidden',
className="rounded bg-blue-50 px-2 py-0.5 text-[10px] text-blue-700 truncate" 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> </div>
))} ))}
{buttons.length > 3 && (
<p className="text-[10px] text-gray-400">+{buttons.length - 3} ещё</p>
)}
</div> </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> </div>
)}
</NodeShell>
); );
} }

View File

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

View File

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

View File

@@ -1,34 +1,73 @@
'use client'; 'use client';
import { Handle, Position } from '@xyflow/react'; import { NodeShell } from './node-shell';
import { UserPlus } from 'lucide-react'; import { PortRow } from './port-row';
interface ReferralNodeData { interface ReferralNodeData {
trackVariable?: string; trackVariable?: string;
targetCount?: number; targetCount?: number;
onReach?: string;
friendStartNode?: string;
isStart?: boolean;
nodeId?: string;
[key: string]: unknown; [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 ( return (
<div className="min-w-[180px] max-w-[240px] rounded-xl border-2 border-pink-400 bg-white shadow-sm"> <NodeShell
<Handle type="target" position={Position.Left} className="!bg-pink-400 !w-3 !h-3" /> accentColor="#ec407a"
icon={'\uD83C\uDF81'}
<div className="flex items-center gap-2 px-3 py-2 bg-pink-50 rounded-t-lg"> title="Реферал"
<UserPlus className="h-4 w-4 text-pink-600" /> nodeId={nodeId}
<span className="text-xs font-semibold text-pink-800">Реферал</span> 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>
<p
<div className="px-3 py-2"> style={{
<p className="text-xs text-gray-700"> fontSize: 9,
Цель: <b>{(data.targetCount as number) ?? '?'}</b> приглашённых color: '#9e9e9e',
fontFamily: 'monospace',
margin: '0 0 4px',
}}
>
{trackVar}
</p> </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> </div>
</NodeShell>
<Handle type="source" position={Position.Right} className="!bg-pink-400 !w-3 !h-3" />
</div>
); );
} }

View File

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

View File

@@ -1,4 +1,7 @@
import type { Node, Edge } from '@xyflow/react'; import type { Node, Edge } from '@xyflow/react';
import { MarkerType } from '@xyflow/react';
// ─── Types ──────────────────────────────────────────────────────
export interface ScenarioJson { export interface ScenarioJson {
id: string; id: string;
@@ -16,47 +19,89 @@ export interface ScenarioNodeJson {
next?: string | null; next?: string | null;
} }
const NODE_SPACING_X = 320; // ─── Layout Constants (n8n style) ───────────────────────────────
const NODE_SPACING_Y = 180;
/** const NODE_WIDTH = 220;
* Convert JSON scenario → React Flow nodes + edges const VERTICAL_GAP = 60;
*/ const HORIZONTAL_GAP = 300;
export function scenarioToFlow(scenario: ScenarioJson): { nodes: Node[]; edges: Edge[] } { const GRID_SIZE = 20;
const nodes: Node[] = [];
const edges: Edge[] = [];
const visited = new Set<string>();
// BFS to layout nodes in a tree const NODE_HEIGHTS: Record<string, number> = {
const positions = new Map<string, { x: number; y: number }>(); message: 140,
const queue: Array<{ id: string; col: number; row: number }> = []; action: 100,
condition: 110,
input: 110,
delay: 100,
referral: 140,
};
if (scenario.startNodeId && scenario.nodes[scenario.startNodeId]) { function getNodeHeight(node: ScenarioNodeJson): number {
queue.push({ id: scenario.startNodeId, col: 0, row: 0 }); 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) { // ─── Edge helpers ──────────────────────────────────────────────
const { id, col, row } = queue.shift()!;
if (visited.has(id)) continue;
visited.add(id);
const currentRow = rowCountPerCol.get(col) ?? row; const EDGE_COLOR = '#29b6f6';
rowCountPerCol.set(col, currentRow + 1);
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]; function mkEdge(
if (!node) continue; 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[] = []; 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) { if (node.type === 'condition') {
const rules = node.data.rules as Array<{ target: string }>; const rules = (node.data.rules ?? []) as Array<{ target: string }>;
for (const rule of rules) { for (const rule of rules) {
if (rule.target && !targets.includes(rule.target)) targets.push(rule.target); 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 (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') { if (node.type === 'referral') {
const onReach = node.data.onReach as string | undefined; const reach = node.data.onReach as string | undefined;
const friendStart = node.data.friendStartNode as string | undefined; const friend = node.data.friendStartNode as string | undefined;
if (onReach && !targets.includes(onReach)) targets.push(onReach); if (reach && !targets.includes(reach)) targets.push(reach);
if (friendStart && !targets.includes(friendStart)) targets.push(friendStart); if (friend && !targets.includes(friend)) targets.push(friend);
} }
for (let i = 0; i < targets.length; i++) { return targets;
const t = targets[i]; }
if (t && !visited.has(t)) {
queue.push({ id: t, col: col + 1, row: i }); // ─── 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 // Place right column
let orphanRow = 0; if (rightCol.length > 0) {
for (const [id] of Object.entries(scenario.nodes)) { let cy = 0;
if (!visited.has(id)) { for (const nid of rightCol) {
positions.set(id, { x: -NODE_SPACING_X, y: orphanRow * NODE_SPACING_Y }); positions.set(nid, {
orphanRow++; 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)) { 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({ nodes.push({
id, id,
type: `scenario-${nodeJson.type}`, type: `scenario-${nodeJson.type}`,
position: pos, position,
data: { data: {
...nodeJson.data, ...nodeJson.data,
nodeId: id, nodeId: id,
nodeType: nodeJson.type, nodeType: nodeJson.type,
next: nodeJson.next, next: nodeJson.next,
isStart: id === scenario.startNodeId, isStart,
}, },
}); });
}
// Build edges
for (const [id, nodeJson] of Object.entries(scenario.nodes)) {
// next edge
if (nodeJson.next) { if (nodeJson.next) {
edges.push({ edges.push(mkEdge(`${id}->next`, id, nodeJson.next));
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' },
});
}
} }
} }
return { nodes, edges }; return { nodes, edges };
} }
/** // ─── Convert React Flow -> scenario JSON ────────────────────────
* Convert React Flow nodes + edges → JSON scenario
*/
export function flowToScenario( export function flowToScenario(
flowNodes: Node[], flowNodes: Node[],
_flowEdges: Edge[],
meta: { meta: {
name: string; name: string;
startNodeId: string; startNodeId: string;
@@ -231,20 +398,14 @@ export function flowToScenario(
const nodeType = (raw.nodeType as string) ?? fn.type?.replace('scenario-', '') ?? 'message'; const nodeType = (raw.nodeType as string) ?? fn.type?.replace('scenario-', '') ?? 'message';
const next = raw.next as string | undefined; const next = raw.next as string | undefined;
// Strip internal fields, keep only scenario data
const data: Record<string, unknown> = {}; const data: Record<string, unknown> = {};
for (const [k, v] of Object.entries(raw)) { 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; data[k] = v;
} }
} }
nodes[fn.id] = { nodes[fn.id] = { id: fn.id, type: nodeType, data, next: next ?? null };
id: fn.id,
type: nodeType,
data,
next: next ?? null,
};
} }
return { return {
@@ -256,3 +417,16 @@ export function flowToScenario(
nodes, 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

View File

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

View File

@@ -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',
}}
>
&#9654; Начало
</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',
}}
>
&#9632; Конец
</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 }}>&#128172;</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',
}}
>
&#128444; Картинка
</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',
}}
>
&#8594; {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',
}}
>
&#128279; 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 }}>&#128256;</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' }}>
&#8594;{' '}
{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: '&#128204; Переменная',
grant_bonus: '&#127873; Бонус',
set_consent: '&#9989; Согласие',
crm_deal: '&#128203; Сделка CRM',
webhook: '&#128279; 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 }}>&#9889;</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 }}>&#9999;&#65039;</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' }}>
&#8594; {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 }}>&#9200;</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 }}>&#127873;</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>
);
}

View File

@@ -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
View File

@@ -390,6 +390,9 @@ importers:
apps/web-club-admin: apps/web-club-admin:
dependencies: dependencies:
'@dagrejs/dagre':
specifier: ^2.0.4
version: 2.0.4
'@dnd-kit/core': '@dnd-kit/core':
specifier: ^6.3.1 specifier: ^6.3.1
version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) 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==} resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'} 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': '@dnd-kit/accessibility@3.1.1':
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
peerDependencies: peerDependencies:
@@ -8679,6 +8688,12 @@ snapshots:
dependencies: dependencies:
'@jridgewell/trace-mapping': 0.3.9 '@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)': '@dnd-kit/accessibility@3.1.1(react@19.2.3)':
dependencies: dependencies:
react: 19.2.3 react: 19.2.3