This commit is contained in:
dev.daminik00 2025-09-29 06:10:56 +02:00
parent 68e990db12
commit 0ceb254f4e
22 changed files with 1096 additions and 158 deletions

View File

@ -37,7 +37,7 @@ After building, start the chosen bundle with `npm run start` (frontend-only) or
To sync published funnels from MongoDB into the codebase:
```bash
# Sync all published funnels from database
# Sync all published funnels from database (keeps JSON files)
npm run sync:funnels
# Preview what would be synced (dry-run mode)
@ -46,16 +46,18 @@ npm run sync:funnels -- --dry-run
# Sync only specific funnels
npm run sync:funnels -- --funnel-ids funnel-test,ru-career-accelerator
# Keep JSON files for debugging
npm run sync:funnels -- --keep-files
# Sync and clean up JSON files after baking
npm run sync:funnels -- --clean-files
```
This script:
1. Connects to MongoDB and fetches all latest published funnels
2. Saves them as temporary JSON files in `public/funnels/`
3. Bakes them into TypeScript (`src/lib/funnel/bakedFunnels.ts`)
4. Cleans up temporary JSON files
2. Downloads images from database and saves them to `public/images/`
3. Updates image URLs in funnels to point to local files
4. Saves them as JSON files in `public/funnels/`
5. Bakes them into TypeScript (`src/lib/funnel/bakedFunnels.ts`)
6. Keeps JSON files by default (use `--clean-files` to remove them)
### Other Funnel Commands

View File

@ -15,9 +15,9 @@ npm run import:funnels
Синхронизирует опубликованные воронки из MongoDB обратно в проект:
1. Извлекает все последние версии опубликованных воронок из БД
2. Сохраняет их во временные JSON файлы в `public/funnels/`
2. Сохраняет их в JSON файлы в `public/funnels/`
3. Запекает их в TypeScript (`src/lib/funnel/bakedFunnels.ts`)
4. Удаляет временные JSON файлы
4. Сохраняет JSON файлы по умолчанию
#### Основное использование:
@ -36,9 +36,9 @@ npm run sync:funnels -- --help
npm run sync:funnels -- --dry-run
```
**`--keep-files`** - Сохранить JSON файлы после запекания (полезно для отладки):
**`--clean-files`** - Удалить JSON файлы после запекания (по умолчанию сохраняются):
```bash
npm run sync:funnels -- --keep-files
npm run sync:funnels -- --clean-files
```
**`--funnel-ids <ids>`** - Синхронизировать только определенные воронки:
@ -49,7 +49,7 @@ npm run sync:funnels -- --funnel-ids funnel-test,ru-career-accelerator
**Комбинирование опций:**
```bash
npm run sync:funnels -- --dry-run --funnel-ids funnel-test
npm run sync:funnels -- --keep-files --dry-run
npm run sync:funnels -- --clean-files --dry-run
```
### 🔥 `bake-funnels.mjs`

View File

@ -6,7 +6,6 @@ import { fileURLToPath } from 'url';
import { execSync } from 'child_process';
import mongoose from 'mongoose';
import dotenv from 'dotenv';
// Load environment variables
dotenv.config({ path: '.env.local' });
@ -67,8 +66,7 @@ const FunnelSchema = new mongoose.Schema({
totalViews: { type: Number, default: 0, min: 0 },
totalCompletions: { type: Number, default: 0, min: 0 },
lastUsed: Date
},
publishedAt: { type: Date, default: Date.now }
}
}, {
timestamps: true,
collection: 'funnels'
@ -76,6 +74,124 @@ const FunnelSchema = new mongoose.Schema({
const Funnel = mongoose.model('Funnel', FunnelSchema);
// Schema for images
const ImageSchema = new mongoose.Schema({
filename: {
type: String,
required: true,
unique: true,
trim: true
},
originalName: {
type: String,
required: true,
trim: true
},
mimetype: {
type: String,
required: true
},
size: {
type: Number,
required: true
},
data: {
type: Buffer,
required: true
},
uploadedAt: {
type: Date,
default: Date.now
},
uploadedBy: {
type: String,
default: 'admin'
},
funnelId: {
type: String,
index: { sparse: true }
},
description: {
type: String,
maxlength: 500
}
}, {
timestamps: true,
collection: 'images'
});
const Image = mongoose.model('Image', ImageSchema);
async function downloadImagesFromDatabase(funnels) {
const imagesDir = path.join(projectRoot, 'public', 'images');
try {
// Создаем папку для изображений
await fs.mkdir(imagesDir, { recursive: true });
console.log('📁 Created images directory');
// Собираем все ссылки на изображения из воронок
const imageUrls = new Set();
for (const funnel of funnels) {
for (const screen of funnel.funnelData.screens) {
if (screen.icon?.type === 'image' && screen.icon.value?.startsWith('/api/images/')) {
imageUrls.add(screen.icon.value);
}
}
}
if (imageUrls.size === 0) {
console.log(' No images to download');
return {};
}
console.log(`🖼️ Found ${imageUrls.size} images to download`);
// Скачиваем каждое изображение из БД
const imageMapping = {};
for (const imageUrl of imageUrls) {
const filename = imageUrl.replace('/api/images/', '');
try {
const image = await Image.findOne({ filename }).lean();
if (image) {
const localPath = path.join(imagesDir, filename);
await fs.writeFile(localPath, image.data);
// Создаем маппинг: старый URL → новый локальный путь
imageMapping[imageUrl] = `/images/${filename}`;
console.log(`💾 Downloaded ${filename}`);
} else {
console.warn(`⚠️ Image not found in database: ${filename}`);
}
} catch (error) {
console.error(`❌ Error downloading ${filename}:`, error.message);
}
}
return imageMapping;
} catch (error) {
console.error('❌ Error downloading images:', error.message);
return {};
}
}
function updateImageUrlsInFunnels(funnels, imageMapping) {
for (const funnel of funnels) {
for (const screen of funnel.funnelData.screens) {
if (screen.icon?.type === 'image' && screen.icon.value && imageMapping[screen.icon.value]) {
const oldUrl = screen.icon.value;
const newUrl = imageMapping[oldUrl];
screen.icon.value = newUrl;
console.log(`🔗 Updated image URL: ${oldUrl}${newUrl}`);
}
}
}
}
async function connectDB() {
try {
await mongoose.connect(MONGODB_URI);
@ -175,7 +291,7 @@ const args = process.argv.slice(2);
const options = {
funnelIds: [],
dryRun: false,
keepFiles: false,
cleanFiles: false, // По умолчанию сохраняем файлы
};
// Парсим опции
@ -184,8 +300,8 @@ for (let i = 0; i < args.length; i++) {
if (arg === '--dry-run') {
options.dryRun = true;
} else if (arg === '--keep-files') {
options.keepFiles = true;
} else if (arg === '--clean-files') {
options.cleanFiles = true;
} else if (arg === '--funnel-ids') {
// Следующий аргумент должен содержать ID воронок через запятую
const idsArg = args[++i];
@ -200,15 +316,15 @@ Usage: npm run sync:funnels [options]
Options:
--dry-run Show what would be synced without actually doing it
--keep-files Keep JSON files after baking (useful for debugging)
--clean-files Delete JSON files after baking (default: keep files)
--funnel-ids <ids> Sync only specific funnel IDs (comma-separated)
--help, -h Show this help message
Examples:
npm run sync:funnels
npm run sync:funnels -- --dry-run
npm run sync:funnels # Sync all and keep JSON files
npm run sync:funnels -- --dry-run # Preview what would be synced
npm run sync:funnels -- --clean-files # Sync all and clean up JSON files
npm run sync:funnels -- --funnel-ids funnel-test,ru-career-accelerator
npm run sync:funnels -- --keep-files --dry-run
`);
process.exit(0);
}
@ -249,7 +365,18 @@ async function syncFunnelsWithOptions() {
return;
}
// 4. Сохраняем каждую воронку в JSON файл
// 4. Загружаем изображения из базы данных
let imageMapping = {};
if (!options.dryRun) {
imageMapping = await downloadImagesFromDatabase(funnels);
if (Object.keys(imageMapping).length > 0) {
updateImageUrlsInFunnels(funnels, imageMapping);
}
} else {
console.log('🔍 Would download images from database and update URLs');
}
// 5. Сохраняем каждую воронку в JSON файл
for (const funnel of funnels) {
if (options.dryRun) {
console.log(`🔍 Would save ${funnel.funnelData.meta.id}.json (v${funnel.version})`);
@ -258,20 +385,23 @@ async function syncFunnelsWithOptions() {
}
}
// 5. Запекаем воронки в TypeScript
// 6. Запекаем воронки в TypeScript
if (!options.dryRun) {
await bakeFunnels();
} else {
console.log('🔍 Would bake funnels to TypeScript');
}
// 6. Удаляем JSON файлы после запекания (если не указано сохранить)
if (!options.dryRun && !options.keepFiles) {
// 7. Удаляем JSON файлы после запекания (только если указано)
if (!options.dryRun && options.cleanFiles) {
await clearFunnelsDir();
} else if (options.keepFiles) {
console.log('📁 Keeping JSON files as requested');
} else if (options.dryRun) {
console.log('🧹 Cleaned up JSON files as requested');
} else if (!options.dryRun) {
console.log('📁 Keeping JSON files (use --clean-files to remove them)');
} else if (options.dryRun && options.cleanFiles) {
console.log('🔍 Would clean up JSON files');
} else if (options.dryRun) {
console.log('🔍 Would keep JSON files');
}
console.log('\n🎉 Funnel sync completed successfully!');

View File

@ -121,20 +121,36 @@ export default function FunnelBuilderPage() {
description: builderState.meta.description || funnelData.description,
funnelData: updatedFunnelData,
status: publish ? 'published' : funnelData.status,
sessionId,
actionDescription: publish ? 'Воронка опубликована' : 'Воронка сохранена'
})
});
if (!response.ok) {
throw new Error('Ошибка сохранения воронки');
// Пытаемся получить детальную информацию об ошибке от API
let errorMessage = 'Ошибка сохранения воронки';
try {
const errorData = await response.json();
if (errorData.error) {
errorMessage = errorData.error;
}
// Если есть детали ошибки валидации, добавляем их
if (errorData.details) {
errorMessage += `: ${errorData.details}`;
}
} catch {
// Если не удалось распарсить JSON ошибки, используем общее сообщение
errorMessage = `Ошибка сохранения воронки (${response.status})`;
}
throw new Error(errorMessage);
}
const updatedFunnel = await response.json();
setFunnelData(updatedFunnel);
// Очищаем ошибку при успешном сохранении
setError(null);
// Показываем уведомление об успешном сохранении
// TODO: Добавить toast уведомления
return true;
@ -222,8 +238,8 @@ export default function FunnelBuilderPage() {
);
}
// Error state
if (error) {
// Error state - только для критических ошибок загрузки
if (error && !funnelData) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
@ -249,6 +265,35 @@ export default function FunnelBuilderPage() {
<BuilderUndoRedoProvider>
<div className="h-screen flex flex-col bg-gray-50 overflow-hidden">
{/* Error Toast - показывается поверх интерфейса */}
{error && funnelData && (
<div className="fixed top-4 right-4 z-50 max-w-md bg-red-50 border border-red-200 rounded-lg p-4 shadow-lg">
<div className="flex items-start">
<div className="flex-shrink-0">
<svg className="h-5 w-5 text-red-400" viewBox="0 0 20 20" fill="currentColor">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
</div>
<div className="ml-3 flex-1">
<h3 className="text-sm font-medium text-red-800">
Ошибка сохранения
</h3>
<div className="mt-2 text-sm text-red-700">
{error}
</div>
<div className="mt-3 flex space-x-2">
<button
onClick={() => setError(null)}
className="text-sm bg-red-100 text-red-800 px-3 py-1 rounded-md hover:bg-red-200"
>
Закрыть
</button>
</div>
</div>
</div>
</div>
)}
{/* Top Bar */}
<BuilderTopBar
onNew={handleNew}

View File

@ -177,8 +177,34 @@ export async function PUT(request: NextRequest, { params }: RouteParams) {
} catch (error) {
console.error('PUT /api/funnels/[id] error:', error);
// Обработка ошибок валидации Mongoose
if (error instanceof Error && error.name === 'ValidationError') {
const validationError = error as Error & { errors: Record<string, { message: string }> };
const details = [];
// Собираем все ошибки валидации
for (const field in validationError.errors) {
const fieldError = validationError.errors[field];
details.push(`${field}: ${fieldError.message}`);
}
return NextResponse.json(
{
error: 'Ошибка валидации данных воронки',
details: details.join('; ')
},
{ status: 400 }
);
}
// Обработка других ошибок
const errorMessage = error instanceof Error ? error.message : 'Неизвестная ошибка';
return NextResponse.json(
{ error: 'Failed to update funnel' },
{
error: 'Ошибка сохранения воронки',
details: errorMessage
},
{ status: 500 }
);
}

View File

@ -0,0 +1,114 @@
import { NextRequest, NextResponse } from 'next/server';
import connectMongoDB from '@/lib/mongodb';
import { Image, type IImage } from '@/lib/models/Image';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ filename: string }> }
) {
try {
// Проверяем что это полная сборка (с БД)
if (process.env.NEXT_PUBLIC_FUNNEL_BUILD_VARIANT === 'frontend') {
return NextResponse.json(
{ error: 'Image serving not available in frontend-only mode' },
{ status: 403 }
);
}
await connectMongoDB();
const { filename } = await params;
if (!filename) {
return NextResponse.json(
{ error: 'Filename is required' },
{ status: 400 }
);
}
const image = await Image.findOne({ filename }).lean() as IImage | null;
if (!image) {
return NextResponse.json(
{ error: 'Image not found' },
{ status: 404 }
);
}
// Возвращаем изображение с правильными заголовками
const buffer = image.data instanceof Buffer ? image.data : Buffer.from(image.data);
// Специальная обработка для SVG файлов
let contentType = image.mimetype;
if (filename.endsWith('.svg') && contentType === 'image/svg+xml') {
contentType = 'image/svg+xml; charset=utf-8';
}
return new NextResponse(buffer, {
status: 200,
headers: {
'Content-Type': contentType,
'Content-Length': buffer.length.toString(),
'Cache-Control': 'public, max-age=31536000, immutable',
'Content-Disposition': `inline; filename="${image.originalName}"`,
'Access-Control-Allow-Origin': '*',
// Дополнительные заголовки для SVG
'X-Content-Type-Options': 'nosniff',
},
});
} catch (error) {
console.error('Image serving error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ filename: string }> }
) {
try {
// Проверяем что это полная сборка (с БД)
if (process.env.NEXT_PUBLIC_FUNNEL_BUILD_VARIANT === 'frontend') {
return NextResponse.json(
{ error: 'Image deletion not available in frontend-only mode' },
{ status: 403 }
);
}
await connectMongoDB();
const { filename } = await params;
if (!filename) {
return NextResponse.json(
{ error: 'Filename is required' },
{ status: 400 }
);
}
const deletedImage = await Image.findOneAndDelete({ filename });
if (!deletedImage) {
return NextResponse.json(
{ error: 'Image not found' },
{ status: 404 }
);
}
return NextResponse.json({
message: 'Image deleted successfully',
filename: deletedImage.filename
});
} catch (error) {
console.error('Image deletion error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,53 @@
import { NextRequest, NextResponse } from 'next/server';
import connectMongoDB from '@/lib/mongodb';
import { Image } from '@/lib/models/Image';
export async function GET(request: NextRequest) {
try {
// Проверяем что это полная сборка (с БД)
if (process.env.NEXT_PUBLIC_FUNNEL_BUILD_VARIANT === 'frontend') {
return NextResponse.json(
{ error: 'Image listing not available in frontend-only mode' },
{ status: 403 }
);
}
await connectMongoDB();
const { searchParams } = new URL(request.url);
const funnelId = searchParams.get('funnelId');
const limit = parseInt(searchParams.get('limit') || '50');
const page = parseInt(searchParams.get('page') || '1');
const query = funnelId ? { funnelId } : {};
const images = await Image.find(query)
.select('-data') // Исключаем binary данные из списка
.sort({ uploadedAt: -1 })
.limit(limit)
.skip((page - 1) * limit)
.lean();
const total = await Image.countDocuments(query);
// Добавляем URL к каждому изображению
const imagesWithUrls = images.map(image => ({
...image,
url: `/api/images/${image.filename}`
}));
return NextResponse.json({
images: imagesWithUrls,
total,
page,
totalPages: Math.ceil(total / limit)
});
} catch (error) {
console.error('Image listing error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,88 @@
import { NextRequest, NextResponse } from 'next/server';
import connectMongoDB from '@/lib/mongodb';
import { Image } from '@/lib/models/Image';
import crypto from 'crypto';
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ALLOWED_TYPES = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
export async function POST(request: NextRequest) {
try {
// Проверяем что это полная сборка (с БД)
if (process.env.NEXT_PUBLIC_FUNNEL_BUILD_VARIANT === 'frontend') {
return NextResponse.json(
{ error: 'Image upload not available in frontend-only mode' },
{ status: 403 }
);
}
await connectMongoDB();
const formData = await request.formData();
const file = formData.get('file') as File;
const funnelId = formData.get('funnelId') as string || undefined;
const description = formData.get('description') as string || undefined;
if (!file) {
return NextResponse.json(
{ error: 'No file provided' },
{ status: 400 }
);
}
// Валидация файла
if (file.size > MAX_FILE_SIZE) {
return NextResponse.json(
{ error: 'File too large. Maximum size is 5MB' },
{ status: 400 }
);
}
if (!ALLOWED_TYPES.includes(file.type)) {
return NextResponse.json(
{ error: 'Invalid file type. Only images are allowed' },
{ status: 400 }
);
}
// Генерируем уникальное имя файла
const ext = file.name.split('.').pop() || 'bin';
const filename = `${crypto.randomUUID()}.${ext}`;
// Конвертируем файл в Buffer
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
// Сохраняем в БД
const image = new Image({
filename,
originalName: file.name,
mimetype: file.type,
size: file.size,
data: buffer,
funnelId,
description,
uploadedBy: 'admin' // TODO: получать из сессии когда будет аутентификация
});
await image.save();
// Возвращаем информацию без Buffer данных
return NextResponse.json({
id: image._id,
filename: image.filename,
originalName: image.originalName,
mimetype: image.mimetype,
size: image.size,
uploadedAt: image.uploadedAt,
url: `/api/images/${image.filename}`
});
} catch (error) {
console.error('Image upload error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

View File

@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server';
import connectMongoDB from '@/lib/mongodb';
import { Image } from '@/lib/models/Image';
export async function GET(request: NextRequest) {
try {
await connectMongoDB();
// Получаем конкретное проблемное изображение
const filename = 'aef03704-4188-46d0-891f-771b84a03e90.svg';
const image = await Image.findOne({ filename }).lean();
if (!image) {
return NextResponse.json({ error: 'Image not found', filename }, { status: 404 });
}
// Возвращаем raw данные как text для анализа
const buffer = Buffer.isBuffer(image.data) ? image.data : Buffer.from(image.data);
return new NextResponse(buffer.toString('utf8'), {
status: 200,
headers: {
'Content-Type': 'text/plain; charset=utf-8',
},
});
} catch (error) {
console.error('Raw image error:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@ -0,0 +1,42 @@
import { NextRequest, NextResponse } from 'next/server';
import connectMongoDB from '@/lib/mongodb';
import { Image, type IImage } from '@/lib/models/Image';
export async function GET(request: NextRequest) {
try {
await connectMongoDB();
// Получаем конкретное проблемное изображение
const filename = 'aef03704-4188-46d0-891f-771b84a03e90.svg';
const image = await Image.findOne({ filename }).lean() as any;
if (!image) {
return NextResponse.json({ message: 'Image not found', filename });
}
// Проверяем начало данных изображения
const buffer = Buffer.isBuffer(image.data) ? image.data : Buffer.from(image.data);
const first100Chars = buffer.slice(0, 100).toString('utf8');
const first100Bytes = Array.from(buffer.slice(0, 20)).map(b => b.toString(16).padStart(2, '0')).join(' ');
// Возвращаем детальную информацию об изображении
return NextResponse.json({
filename: image.filename,
originalName: image.originalName,
mimetype: image.mimetype,
size: image.size,
dataType: typeof image.data,
dataLength: image.data ? image.data.length : 'null',
isBuffer: Buffer.isBuffer(image.data),
actualBufferLength: buffer.length,
first100Chars,
first100Bytes,
isValidSvg: first100Chars.includes('<svg'),
url: `/api/images/${image.filename}`
});
} catch (error) {
console.error('Test error:', error);
return NextResponse.json({ error: error.message }, { status: 500 });
}
}

View File

@ -82,7 +82,7 @@ export function TemplateSummary({ screen }: TemplateSummaryProps) {
case "info": {
return (
<div className="space-y-2 text-xs text-muted-foreground">
{screen.description?.text && <p>{screen.description.text}</p>}
{screen.subtitle?.text && <p>{screen.subtitle.text}</p>}
{screen.icon?.value && (
<div className="inline-flex items-center gap-2 rounded-lg bg-muted px-2 py-1">
<span className="text-base">{screen.icon.value}</span>

View File

@ -63,7 +63,20 @@ export function BuilderSidebar() {
};
const handleScreenIdChange = (currentId: string, newId: string) => {
if (newId.trim() === "" || newId === currentId) {
if (newId === currentId) {
return;
}
// Разрешаем пустые ID для полного переименования
if (newId.trim() === "") {
// Просто обновляем на пустое значение, пользователь сможет ввести новое
dispatch({
type: "update-screen",
payload: {
screenId: currentId,
screen: { id: newId }
}
});
return;
}

View File

@ -0,0 +1,304 @@
"use client";
import { useState, useRef, useCallback } from 'react';
import Image from 'next/image';
import { Button } from '@/components/ui/button';
import { TextInput } from '@/components/ui/TextInput/TextInput';
import { Upload, X, Image as ImageIcon, Loader2 } from 'lucide-react';
interface UploadedImage {
id: string;
filename: string;
originalName: string;
url: string;
size: number;
mimetype: string;
}
interface ImageUploadProps {
currentValue?: string;
onImageSelect: (url: string) => void;
onImageRemove: () => void;
funnelId?: string;
}
export function ImageUpload({
currentValue,
onImageSelect,
onImageRemove,
funnelId
}: ImageUploadProps) {
const [isUploading, setIsUploading] = useState(false);
const [dragActive, setDragActive] = useState(false);
const [error, setError] = useState<string | null>(null);
const [uploadedImages, setUploadedImages] = useState<UploadedImage[]>([]);
const [showGallery, setShowGallery] = useState(false);
const [isLoadingGallery, setIsLoadingGallery] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const loadImages = useCallback(async () => {
if (process.env.NEXT_PUBLIC_FUNNEL_BUILD_VARIANT === 'frontend') {
return; // В frontend режиме загрузка недоступна
}
setIsLoadingGallery(true);
try {
const params = new URLSearchParams();
if (funnelId) params.append('funnelId', funnelId);
params.append('limit', '20');
const response = await fetch(`/api/images?${params}`);
if (response.ok) {
const data = await response.json();
setUploadedImages(data.images);
} else {
console.error('Failed to load images');
}
} catch (error) {
console.error('Error loading images:', error);
} finally {
setIsLoadingGallery(false);
}
}, [funnelId]);
const handleFileUpload = async (file: File) => {
if (process.env.NEXT_PUBLIC_FUNNEL_BUILD_VARIANT === 'frontend') {
setError('Загрузка изображений недоступна в frontend режиме');
return;
}
setIsUploading(true);
setError(null);
try {
const formData = new FormData();
formData.append('file', file);
if (funnelId) formData.append('funnelId', funnelId);
const response = await fetch('/api/images/upload', {
method: 'POST',
body: formData,
});
if (response.ok) {
const uploadedImage = await response.json();
onImageSelect(uploadedImage.url);
setUploadedImages(prev => [uploadedImage, ...prev]);
} else {
const errorData = await response.json();
setError(errorData.error || 'Ошибка загрузки файла');
}
} catch (error) {
setError('Произошла ошибка при загрузке файла');
console.error('Upload error:', error);
} finally {
setIsUploading(false);
}
};
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
handleFileUpload(file);
}
};
const handleDrag = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (e.type === "dragenter" || e.type === "dragover") {
setDragActive(true);
} else if (e.type === "dragleave") {
setDragActive(false);
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
const file = e.dataTransfer.files?.[0];
if (file && file.type.startsWith('image/')) {
handleFileUpload(file);
} else {
setError('Пожалуйста, выберите файл изображения');
}
};
const openGallery = () => {
setShowGallery(true);
loadImages();
};
const isFullMode = process.env.NEXT_PUBLIC_FUNNEL_BUILD_VARIANT !== 'frontend';
return (
<div className="space-y-3">
{/* Текущее изображение */}
{currentValue && (
<div className="relative">
<div className="rounded-lg border border-border p-2 bg-muted/5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<ImageIcon className="h-4 w-4 text-muted-foreground" />
<span className="text-sm text-muted-foreground truncate">
{currentValue.startsWith('/api/images/') ? 'Загруженное изображение' : currentValue}
</span>
</div>
<Button
variant="ghost"
onClick={onImageRemove}
className="h-6 w-6 p-0 text-destructive hover:bg-destructive/10"
>
<X className="h-3 w-3" />
</Button>
</div>
</div>
</div>
)}
{!currentValue && (
<div className="space-y-3">
{/* URL Input */}
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
Ссылка на изображение
</label>
<TextInput
placeholder="https://example.com/image.jpg"
value=""
onChange={(e) => onImageSelect(e.target.value)}
/>
</div>
{/* Upload Section (только в full режиме) */}
{isFullMode && (
<>
<div className="text-center text-sm text-muted-foreground">или</div>
{/* Drag & Drop Zone */}
<div
className={`
relative border-2 border-dashed rounded-lg p-6 text-center transition-colors
${dragActive
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50'
}
${isUploading ? 'pointer-events-none opacity-50' : 'cursor-pointer'}
`}
onDragEnter={handleDrag}
onDragLeave={handleDrag}
onDragOver={handleDrag}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleFileSelect}
className="hidden"
/>
{isUploading ? (
<div className="flex flex-col items-center gap-2">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">Загрузка...</p>
</div>
) : (
<div className="flex flex-col items-center gap-2">
<Upload className="h-8 w-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">
Перетащите изображение сюда или нажмите для выбора
</p>
<p className="text-xs text-muted-foreground">
PNG, JPG, GIF, WebP до 5MB
</p>
</div>
)}
</div>
{/* Gallery Button */}
<Button
variant="outline"
onClick={openGallery}
disabled={isUploading}
className="w-full"
>
<ImageIcon className="h-4 w-4 mr-2" />
Выбрать из загруженных
</Button>
</>
)}
</div>
)}
{/* Error Message */}
{error && (
<div className="text-sm text-destructive bg-destructive/10 border border-destructive/20 rounded p-2">
{error}
</div>
)}
{/* Gallery Modal */}
{showGallery && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-background rounded-lg p-6 max-w-2xl max-h-[80vh] overflow-auto">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">Выберите изображение</h3>
<Button
variant="ghost"
onClick={() => setShowGallery(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
{isLoadingGallery ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : uploadedImages.length > 0 ? (
<div className="grid grid-cols-3 gap-3">
{uploadedImages.map((image, index) => (
<div
key={image.filename || `image-${index}`}
className="relative aspect-square border border-border rounded cursor-pointer hover:bg-muted/50 overflow-hidden"
onClick={() => {
onImageSelect(image.url);
setShowGallery(false);
}}
>
{/* Используем обычный img для тестирования */}
<img
src={image.url}
alt={image.originalName}
className="w-full h-full object-cover"
onError={(e) => {
console.error('Image load error:', image.url, e);
// Показываем placeholder
(e.target as HTMLImageElement).style.display = 'none';
}}
onLoad={() => {
console.log('Image loaded successfully:', image.url);
}}
/>
<div className="absolute bottom-0 left-0 right-0 bg-black/75 text-white text-xs p-1 truncate">
{image.originalName}
</div>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
Пока нет загруженных изображений
</div>
)}
</div>
</div>
)}
</div>
);
}

View File

@ -1,8 +1,7 @@
"use client";
import { TextInput } from "@/components/ui/TextInput/TextInput";
import { TextAreaInput } from "@/components/ui/TextAreaInput/TextAreaInput";
import { MarkupPreview } from "@/components/ui/MarkupText/MarkupText";
import { ImageUpload } from "@/components/admin/builder/forms/ImageUpload";
import type { InfoScreenDefinition } from "@/lib/funnel/types";
import type { BuilderScreen } from "@/lib/admin/builder/types";
@ -14,16 +13,6 @@ interface InfoScreenConfigProps {
export function InfoScreenConfig({ screen, onUpdate }: InfoScreenConfigProps) {
const infoScreen = screen as InfoScreenDefinition;
const handleDescriptionChange = (text: string) => {
onUpdate({
description: text
? {
...(infoScreen.description ?? {}),
text,
}
: undefined,
});
};
const handleIconChange = <T extends keyof NonNullable<InfoScreenDefinition["icon"]>>(
field: T,
@ -40,34 +29,18 @@ export function InfoScreenConfig({ screen, onUpdate }: InfoScreenConfigProps) {
return;
}
// При изменении типа иконки сбрасываем значение
if (field === "type") {
const defaultValue = value === "emoji" ? "✨" : "";
onUpdate({ icon: { ...baseIcon, type: value as "emoji" | "image", value: defaultValue } });
return;
}
onUpdate({ icon: { ...baseIcon, [field]: value } });
};
return (
<div className="space-y-6 rounded-2xl border border-border/70 bg-background/80 p-5 shadow-sm">
<div className="space-y-3">
<h3 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Информационный контент
</h3>
<div className="space-y-3">
<label className="flex flex-col gap-2 text-sm">
<span className="text-xs font-medium text-muted-foreground">Описание (необязательно)</span>
<TextAreaInput
placeholder="Введите пояснение для пользователя. Используйте **текст** для выделения жирным."
value={infoScreen.description?.text ?? ""}
onChange={(event) => handleDescriptionChange(event.target.value)}
rows={3}
className="resize-y"
/>
</label>
{/* 🎨 ПРЕВЬЮ РАЗМЕТКИ */}
{infoScreen.description?.text && (
<MarkupPreview text={infoScreen.description.text} />
)}
</div>
</div>
<div className="space-y-3">
<h4 className="text-sm font-semibold text-foreground">Иконка</h4>
<div className="grid grid-cols-2 gap-2 text-xs">
@ -99,16 +72,30 @@ export function InfoScreenConfig({ screen, onUpdate }: InfoScreenConfigProps) {
</label>
</div>
<label className="flex flex-col gap-2 text-sm">
<span className="text-xs font-medium text-muted-foreground">
{infoScreen.icon?.type === "image" ? "Ссылка на изображение" : "Emoji символ"}
</span>
<TextInput
placeholder={infoScreen.icon?.type === "image" ? "https://..." : "Например, ✨"}
value={infoScreen.icon?.value ?? ""}
onChange={(event) => handleIconChange("value", event.target.value || undefined)}
/>
</label>
{infoScreen.icon?.type === "image" ? (
<div>
<span className="text-xs font-medium text-muted-foreground mb-2 block">
Изображение иконки
</span>
<ImageUpload
currentValue={infoScreen.icon?.value}
onImageSelect={(url) => handleIconChange("value", url)}
onImageRemove={() => handleIconChange("value", undefined)}
funnelId={screen.id}
/>
</div>
) : (
<label className="flex flex-col gap-2 text-sm">
<span className="text-xs font-medium text-muted-foreground">
Emoji символ
</span>
<TextInput
placeholder="Например, ✨"
value={infoScreen.icon?.value ?? ""}
onChange={(event) => handleIconChange("value", event.target.value || undefined)}
/>
</label>
)}
</div>
</div>
);

View File

@ -2,8 +2,6 @@
import { useMemo } from "react";
import Image from "next/image";
import Typography from "@/components/ui/Typography/Typography";
import { buildTypographyProps } from "@/lib/funnel/mappers";
import type { InfoScreenDefinition, DefaultTexts } from "@/lib/funnel/types";
import { TemplateLayout } from "../layouts/TemplateLayout";
import { cn } from "@/lib/utils";
@ -40,6 +38,79 @@ export function InfoTemplate({
}
}, [screen.icon?.size]);
// Функция для проверки валидности URL
const isValidUrl = (value: string): boolean => {
if (!value || value.trim() === '') return false;
try {
new URL(value);
return true;
} catch {
// Проверяем относительные пути (начинаются с /) и API пути
return value.startsWith('/') || value.startsWith('/api/');
}
};
// Создаем иконку для передачи в childrenAboveTitle
const iconElement = screen.icon ? (
<div className={cn("mb-8", screen.icon.className)}>
{screen.icon.type === "emoji" ? (
<div className={cn(iconSizeClasses, "leading-none")}>
{screen.icon.value}
</div>
) : (screen.icon.value && isValidUrl(screen.icon.value)) ? (
<div className="relative">
<Image
src={screen.icon.value}
alt=""
width={
iconSizeClasses.includes("text-8xl") ? 128 :
iconSizeClasses.includes("text-6xl") ? 64 :
iconSizeClasses.includes("text-5xl") ? 48 : 36
}
height={
iconSizeClasses.includes("text-8xl") ? 128 :
iconSizeClasses.includes("text-6xl") ? 64 :
iconSizeClasses.includes("text-5xl") ? 48 : 36
}
className={cn("object-contain")}
unoptimized={screen.icon.value.startsWith('/api/images/')}
onError={(e) => {
console.error('Preview image load error:', screen.icon?.value, e);
}}
onLoad={() => {
console.log('Preview image loaded successfully:', screen.icon?.value);
}}
/>
{/* Fallback для проблемных изображений */}
<img
src={screen.icon.value}
alt=""
className={cn("absolute inset-0 object-contain opacity-0 hover:opacity-100")}
style={{
width: iconSizeClasses.includes("text-8xl") ? 128 :
iconSizeClasses.includes("text-6xl") ? 64 :
iconSizeClasses.includes("text-5xl") ? 48 : 36,
height: iconSizeClasses.includes("text-8xl") ? 128 :
iconSizeClasses.includes("text-6xl") ? 64 :
iconSizeClasses.includes("text-5xl") ? 48 : 36
}}
onError={(e) => {
console.error('Fallback image load error:', screen.icon?.value, e);
}}
onLoad={() => {
console.log('Fallback image loaded successfully:', screen.icon?.value);
}}
/>
</div>
) : (
<div className={cn(iconSizeClasses, "leading-none text-muted-foreground flex items-center justify-center")}>
📷
</div>
)}
</div>
) : null;
return (
<TemplateLayout
screen={screen}
@ -53,66 +124,16 @@ export function InfoTemplate({
disabled: false,
onClick: onContinue,
}}
childrenAboveTitle={iconElement}
>
<div className={cn(
"w-full flex flex-col items-center justify-center text-center",
screen.icon ? "mt-[60px]" : "-mt-[20px]"
)}>
{/* Icon */}
{screen.icon && (
<div className={cn("mb-8", screen.icon.className)}>
{screen.icon.type === "emoji" ? (
<div className={cn(iconSizeClasses, "leading-none")}>
{screen.icon.value}
</div>
) : (
<Image
src={screen.icon.value}
alt=""
width={
iconSizeClasses.includes("text-8xl") ? 128 :
iconSizeClasses.includes("text-6xl") ? 64 :
iconSizeClasses.includes("text-5xl") ? 48 : 36
}
height={
iconSizeClasses.includes("text-8xl") ? 128 :
iconSizeClasses.includes("text-6xl") ? 64 :
iconSizeClasses.includes("text-5xl") ? 48 : 36
}
className={cn("object-contain")}
/>
)}
</div>
)}
{/* Description */}
{screen.description && (
<div className={cn(
"max-w-[280px]",
screen.icon ? "mt-6" : "mt-0"
)}>
<Typography
as="p"
font="inter"
weight="medium"
color="default"
size="lg"
align="center"
{...buildTypographyProps(screen.description, {
as: "p",
defaults: {
font: "inter",
weight: "medium",
color: "default",
align: "center",
},
})}
className={cn("leading-[26px]", screen.description.className)}
>
{screen.description.text}
</Typography>
</div>
)}
{/* Пустые дети - весь контент теперь в заголовке, подзаголовке и иконке */}
<div className="w-full flex justify-center">
<div className={cn(
"w-full max-w-[320px] text-center",
screen.icon ? "mt-[30px]" : "mt-[60px]"
)}>
{/* Дополнительный контент если нужен */}
</div>
</div>
</TemplateLayout>
);

View File

@ -14,19 +14,18 @@ const mockScreen: InfoScreenDefinition = {
},
title: {
text: "TemplateLayout Demo",
font: "manrope",
weight: "bold",
align: "center",
size: "2xl",
color: "default",
},
description: {
subtitle: {
show: true,
text: "Это демонстрация **TemplateLayout** - централизованного layout wrapper для всех funnel templates. Он управляет header, progress bar, кнопкой назад и нижней кнопкой.",
font: "inter",
weight: "regular",
align: "center",
size: "md",
color: "default",
align: "center",
},
bottomActionButton: {
show: true,

View File

@ -42,6 +42,9 @@ interface TemplateLayoutProps {
childrenAboveButton?: React.ReactNode;
childrenUnderButton?: React.ReactNode;
// Дополнительные props для Title
childrenAboveTitle?: React.ReactNode;
// Контент template
children: React.ReactNode;
}
@ -60,6 +63,7 @@ export function TemplateLayout({
actionButtonOptions,
childrenAboveButton,
childrenUnderButton,
childrenAboveTitle,
children,
}: TemplateLayoutProps) {
// 🎛️ ЦЕНТРАЛИЗОВАННАЯ ЛОГИКА BOTTOM BUTTON
@ -117,7 +121,7 @@ export function TemplateLayout({
// 🎨 ЦЕНТРАЛИЗОВАННЫЙ РЕНДЕРИНГ
return (
<div className="w-full">
<LayoutQuestion {...layoutQuestionProps}>
<LayoutQuestion {...layoutQuestionProps} childrenAboveTitle={childrenAboveTitle}>
{children}
</LayoutQuestion>

View File

@ -12,6 +12,7 @@ export interface LayoutQuestionProps
children: React.ReactNode;
contentProps?: React.ComponentProps<"div">;
childrenWrapperProps?: React.ComponentProps<"div">;
childrenAboveTitle?: React.ReactNode; // Контент над заголовком
}
function LayoutQuestion({
@ -22,6 +23,7 @@ function LayoutQuestion({
children,
contentProps,
childrenWrapperProps,
childrenAboveTitle,
...props
}: LayoutQuestionProps) {
return (
@ -43,6 +45,9 @@ function LayoutQuestion({
contentProps?.className
)}
>
{/* Контент над заголовком */}
{childrenAboveTitle}
{title && (
<Typography
as="h2"

View File

@ -4,7 +4,6 @@ import {
buildDefaultTitle,
buildDefaultBottomActionButton,
buildDefaultNavigation,
buildDefaultDescription,
buildDefaultSubtitle
} from "./blocks";
@ -18,13 +17,15 @@ export function buildInfoDefaults(id: string): BuilderScreen {
align: "center",
}),
subtitle: buildDefaultSubtitle({
show: false,
text: undefined,
}),
description: buildDefaultDescription({
text: "Добавьте описание для информационного экрана. Используйте **жирный текст** для выделения важного.",
show: true,
text: "Добавьте подзаголовок для информационного экрана",
align: "center",
}),
icon: {
type: "emoji" as const,
value: "",
size: "xl" as const,
},
bottomActionButton: buildDefaultBottomActionButton(),
navigation: buildDefaultNavigation(),
} as BuilderScreen;

View File

@ -38,6 +38,11 @@ export function buildTypographyProps<T extends TypographyAs>(
return undefined;
}
// Проверяем поле show - если false, не показываем
if ('show' in variant && variant.show === false) {
return undefined;
}
const { as, defaults } = options;
return {
@ -193,12 +198,9 @@ export function buildLayoutQuestionProps(
onBack: showBackButton ? onBack : undefined,
showBackButton,
} : undefined,
title: screen.title ? (buildTypographyProps(screen.title, {
title: screen.title ? buildTypographyProps(screen.title, {
as: "h2",
defaults: titleDefaults,
}) ?? {
as: "h2",
children: screen.title.text,
}) : undefined,
subtitle: 'subtitle' in screen ? buildTypographyProps(screen.subtitle, {
as: "p",

View File

@ -129,7 +129,7 @@ export interface InfoScreenDefinition {
template: "info";
header?: HeaderDefinition;
title: TitleDefinition;
description?: TypographyVariant;
subtitle?: SubtitleDefinition;
icon?: IconDefinition;
bottomActionButton?: BottomActionButtonDefinition;
navigation?: NavigationDefinition;

71
src/lib/models/Image.ts Normal file
View File

@ -0,0 +1,71 @@
import mongoose from 'mongoose';
export interface IImage {
_id: string;
filename: string;
originalName: string;
mimetype: string;
size: number;
data: Buffer;
uploadedAt: Date;
uploadedBy: string;
funnelId?: string; // Связь с воронкой для возможной очистки
description?: string;
}
const ImageSchema = new mongoose.Schema<IImage>({
filename: {
type: String,
required: true,
unique: true,
trim: true
},
originalName: {
type: String,
required: true,
trim: true
},
mimetype: {
type: String,
required: true,
validate: {
validator: function(v: string) {
return /^image\/(jpeg|jpg|png|gif|webp|svg\+xml)$/i.test(v);
},
message: 'Only image files are allowed'
}
},
size: {
type: Number,
required: true,
max: 5 * 1024 * 1024 // 5MB максимум
},
data: {
type: Buffer,
required: true
},
uploadedAt: {
type: Date,
default: Date.now
},
uploadedBy: {
type: String,
default: 'admin'
},
funnelId: {
type: String,
index: { sparse: true } // Индекс только для не-null значений
},
description: {
type: String,
maxlength: 500
}
}, {
timestamps: true,
collection: 'images'
});
// Дополнительные индексы для производительности
ImageSchema.index({ uploadedAt: -1 });
export const Image = mongoose.models.Image || mongoose.model<IImage>('Image', ImageSchema);