diff --git a/README.md b/README.md index 42f14d8..6a09bc4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/scripts/README.md b/scripts/README.md index 8b93b6d..1555e04 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -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 `** - Синхронизировать только определенные воронки: @@ -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` diff --git a/scripts/sync-funnels-from-db.mjs b/scripts/sync-funnels-from-db.mjs index 63904de..0186744 100755 --- a/scripts/sync-funnels-from-db.mjs +++ b/scripts/sync-funnels-from-db.mjs @@ -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 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!'); diff --git a/src/app/admin/builder/[id]/FunnelBuilderPageClient.tsx b/src/app/admin/builder/[id]/FunnelBuilderPageClient.tsx index 99d8d79..7c35e85 100644 --- a/src/app/admin/builder/[id]/FunnelBuilderPageClient.tsx +++ b/src/app/admin/builder/[id]/FunnelBuilderPageClient.tsx @@ -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 (
@@ -249,6 +265,35 @@ export default function FunnelBuilderPage() {
+ {/* Error Toast - показывается поверх интерфейса */} + {error && funnelData && ( +
+
+
+ + + +
+
+

+ Ошибка сохранения +

+
+ {error} +
+
+ +
+
+
+
+ )} + {/* Top Bar */} }; + 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 } ); } diff --git a/src/app/api/images/[filename]/route.ts b/src/app/api/images/[filename]/route.ts new file mode 100644 index 0000000..31cdc16 --- /dev/null +++ b/src/app/api/images/[filename]/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/images/route.ts b/src/app/api/images/route.ts new file mode 100644 index 0000000..d219399 --- /dev/null +++ b/src/app/api/images/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/images/upload/route.ts b/src/app/api/images/upload/route.ts new file mode 100644 index 0000000..130fbcf --- /dev/null +++ b/src/app/api/images/upload/route.ts @@ -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 } + ); + } +} diff --git a/src/app/api/raw-image/route.ts b/src/app/api/raw-image/route.ts new file mode 100644 index 0000000..893160d --- /dev/null +++ b/src/app/api/raw-image/route.ts @@ -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 }); + } +} diff --git a/src/app/api/test-image/route.ts b/src/app/api/test-image/route.ts new file mode 100644 index 0000000..aabeecd --- /dev/null +++ b/src/app/api/test-image/route.ts @@ -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(' - {screen.description?.text &&

{screen.description.text}

} + {screen.subtitle?.text &&

{screen.subtitle.text}

} {screen.icon?.value && (
{screen.icon.value} diff --git a/src/components/admin/builder/Sidebar/BuilderSidebar.tsx b/src/components/admin/builder/Sidebar/BuilderSidebar.tsx index c8e71e4..a1d0262 100644 --- a/src/components/admin/builder/Sidebar/BuilderSidebar.tsx +++ b/src/components/admin/builder/Sidebar/BuilderSidebar.tsx @@ -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; } diff --git a/src/components/admin/builder/forms/ImageUpload.tsx b/src/components/admin/builder/forms/ImageUpload.tsx new file mode 100644 index 0000000..ffa93ad --- /dev/null +++ b/src/components/admin/builder/forms/ImageUpload.tsx @@ -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(null); + const [uploadedImages, setUploadedImages] = useState([]); + const [showGallery, setShowGallery] = useState(false); + const [isLoadingGallery, setIsLoadingGallery] = useState(false); + + const fileInputRef = useRef(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) => { + 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 ( +
+ {/* Текущее изображение */} + {currentValue && ( +
+
+
+
+ + + {currentValue.startsWith('/api/images/') ? 'Загруженное изображение' : currentValue} + +
+ +
+
+
+ )} + + {!currentValue && ( +
+ {/* URL Input */} +
+ + onImageSelect(e.target.value)} + /> +
+ + {/* Upload Section (только в full режиме) */} + {isFullMode && ( + <> +
или
+ + {/* Drag & Drop Zone */} +
fileInputRef.current?.click()} + > + + + {isUploading ? ( +
+ +

Загрузка...

+
+ ) : ( +
+ +

+ Перетащите изображение сюда или нажмите для выбора +

+

+ PNG, JPG, GIF, WebP до 5MB +

+
+ )} +
+ + {/* Gallery Button */} + + + )} +
+ )} + + {/* Error Message */} + {error && ( +
+ {error} +
+ )} + + {/* Gallery Modal */} + {showGallery && ( +
+
+
+

Выберите изображение

+ +
+ + {isLoadingGallery ? ( +
+ +
+ ) : uploadedImages.length > 0 ? ( +
+ {uploadedImages.map((image, index) => ( +
{ + onImageSelect(image.url); + setShowGallery(false); + }} + > + {/* Используем обычный img для тестирования */} + {image.originalName} { + console.error('Image load error:', image.url, e); + // Показываем placeholder + (e.target as HTMLImageElement).style.display = 'none'; + }} + onLoad={() => { + console.log('Image loaded successfully:', image.url); + }} + /> +
+ {image.originalName} +
+
+ ))} +
+ ) : ( +
+ Пока нет загруженных изображений +
+ )} +
+
+ )} +
+ ); +} diff --git a/src/components/admin/builder/templates/InfoScreenConfig.tsx b/src/components/admin/builder/templates/InfoScreenConfig.tsx index ada74bd..4129ca1 100644 --- a/src/components/admin/builder/templates/InfoScreenConfig.tsx +++ b/src/components/admin/builder/templates/InfoScreenConfig.tsx @@ -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 = >( 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 (
-
-

- Информационный контент -

-
- - - {/* 🎨 ПРЕВЬЮ РАЗМЕТКИ */} - {infoScreen.description?.text && ( - - )} -
-
-

Иконка

@@ -99,16 +72,30 @@ export function InfoScreenConfig({ screen, onUpdate }: InfoScreenConfigProps) {
- + {infoScreen.icon?.type === "image" ? ( +
+ + Изображение иконки + + handleIconChange("value", url)} + onImageRemove={() => handleIconChange("value", undefined)} + funnelId={screen.id} + /> +
+ ) : ( + + )}
); diff --git a/src/components/funnel/templates/InfoTemplate/InfoTemplate.tsx b/src/components/funnel/templates/InfoTemplate/InfoTemplate.tsx index 2d02665..bb8e7b5 100644 --- a/src/components/funnel/templates/InfoTemplate/InfoTemplate.tsx +++ b/src/components/funnel/templates/InfoTemplate/InfoTemplate.tsx @@ -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 ? ( +
+ {screen.icon.type === "emoji" ? ( +
+ {screen.icon.value} +
+ ) : (screen.icon.value && isValidUrl(screen.icon.value)) ? ( +
+ { + console.error('Preview image load error:', screen.icon?.value, e); + }} + onLoad={() => { + console.log('Preview image loaded successfully:', screen.icon?.value); + }} + /> + {/* Fallback для проблемных изображений */} + { + console.error('Fallback image load error:', screen.icon?.value, e); + }} + onLoad={() => { + console.log('Fallback image loaded successfully:', screen.icon?.value); + }} + /> +
+ ) : ( +
+ 📷 +
+ )} +
+ ) : null; + return ( -
- {/* Icon */} - {screen.icon && ( -
- {screen.icon.type === "emoji" ? ( -
- {screen.icon.value} -
- ) : ( - - )} -
- )} - - {/* Description */} - {screen.description && ( -
- - {screen.description.text} - -
- )} + {/* Пустые дети - весь контент теперь в заголовке, подзаголовке и иконке */} +
+
+ {/* Дополнительный контент если нужен */} +
); diff --git a/src/components/funnel/templates/layouts/TemplateLayout.stories.tsx b/src/components/funnel/templates/layouts/TemplateLayout.stories.tsx index 746d04b..aaff853 100644 --- a/src/components/funnel/templates/layouts/TemplateLayout.stories.tsx +++ b/src/components/funnel/templates/layouts/TemplateLayout.stories.tsx @@ -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, diff --git a/src/components/funnel/templates/layouts/TemplateLayout.tsx b/src/components/funnel/templates/layouts/TemplateLayout.tsx index 74a56f7..2867abd 100644 --- a/src/components/funnel/templates/layouts/TemplateLayout.tsx +++ b/src/components/funnel/templates/layouts/TemplateLayout.tsx @@ -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 (
- + {children} diff --git a/src/components/layout/LayoutQuestion/LayoutQuestion.tsx b/src/components/layout/LayoutQuestion/LayoutQuestion.tsx index e64deb4..77711d1 100644 --- a/src/components/layout/LayoutQuestion/LayoutQuestion.tsx +++ b/src/components/layout/LayoutQuestion/LayoutQuestion.tsx @@ -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 && ( ( 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", diff --git a/src/lib/funnel/types.ts b/src/lib/funnel/types.ts index c3d2132..b8ab4f0 100644 --- a/src/lib/funnel/types.ts +++ b/src/lib/funnel/types.ts @@ -129,7 +129,7 @@ export interface InfoScreenDefinition { template: "info"; header?: HeaderDefinition; title: TitleDefinition; - description?: TypographyVariant; + subtitle?: SubtitleDefinition; icon?: IconDefinition; bottomActionButton?: BottomActionButtonDefinition; navigation?: NavigationDefinition; diff --git a/src/lib/models/Image.ts b/src/lib/models/Image.ts new file mode 100644 index 0000000..77b787e --- /dev/null +++ b/src/lib/models/Image.ts @@ -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({ + 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('Image', ImageSchema);