diff --git a/README-ADMIN.md b/README-ADMIN.md index 59ca26f..df8d2e4 100644 --- a/README-ADMIN.md +++ b/README-ADMIN.md @@ -71,9 +71,11 @@ brew services start mongodb-community ### 3. Запуск проекта ```bash npm install -npm run dev +npm run dev:full ``` +> ⚠️ Админка и API доступны только в режиме **Full system**. Для статичного фронта без админки используйте `npm run dev:frontend`, `npm run build` (или `npm run build:frontend`) и `npm run start` (или `npm run start:frontend`). + ## Использование ### Создание новой воронки diff --git a/README.md b/README.md index e215bc4..ef147e0 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,31 @@ -This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). +## Build & runtime modes -## Getting Started +The project can be built in two isolated configurations. The build scripts set the `FUNNEL_BUILD_VARIANT`/`NEXT_PUBLIC_FUNNEL_BUILD_VARIANT` environment variables so that unused code is tree-shaken during compilation. -First, run the development server: +- **Production (frontend-only)** – renders funnels using the baked JSON bundle. The admin UI and MongoDB access are not included in the bundle. + - Development preview: `npm run dev:frontend` + - Production build (default): `npm run build` or `npm run build:frontend` + - Production start (default): `npm run start` or `npm run start:frontend` +- **Development (full system)** – runs the public frontend together with the admin panel backed by MongoDB. + - Development (default): `npm run dev` or `npm run dev:full` + - Development build: `npm run build:full` + - Development start: `npm run start:full` + +## Local development + +1. Install dependencies: `npm install` +2. Choose the required mode: + - Production preview (frontend-only): `npm run dev:frontend` + - Full system development: `npm run dev` + +The application will be available at [http://localhost:3000](http://localhost:3000). + +## Production build ```bash -npm run dev +npm run build # frontend-only production bundle # or -yarn dev -# or -pnpm dev -# or -bun dev +npm run build:full # full system development bundle ``` -Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. - -You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. - -This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. - -## Learn More - -To learn more about Next.js, take a look at the following resources: - -- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. -- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. - -You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. +After building, start the chosen bundle with `npm run start` (frontend-only) or `npm run start:full`. diff --git a/next.config.ts b/next.config.ts index e9ffa30..c8c9573 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,18 @@ import type { NextConfig } from "next"; +const buildVariant = + process.env.FUNNEL_BUILD_VARIANT ?? + process.env.NEXT_PUBLIC_FUNNEL_BUILD_VARIANT ?? + "frontend"; + +process.env.FUNNEL_BUILD_VARIANT = buildVariant; +process.env.NEXT_PUBLIC_FUNNEL_BUILD_VARIANT = buildVariant; + const nextConfig: NextConfig = { - /* config options here */ + env: { + FUNNEL_BUILD_VARIANT: buildVariant, + NEXT_PUBLIC_FUNNEL_BUILD_VARIANT: buildVariant, + }, }; export default nextConfig; diff --git a/package.json b/package.json index c2075f8..6a63c33 100644 --- a/package.json +++ b/package.json @@ -3,13 +3,18 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev --turbopack", - "build": "next build --turbopack", - "start": "next start", + "dev": "npm run dev:full", + "dev:frontend": "node ./scripts/run-with-variant.mjs dev frontend -- --turbopack", + "dev:full": "node ./scripts/run-with-variant.mjs dev full -- --turbopack", + "build": "npm run build:frontend", + "build:frontend": "npm run bake:funnels && node ./scripts/run-with-variant.mjs build frontend -- --turbopack", + "build:full": "npm run bake:funnels && node ./scripts/run-with-variant.mjs build full -- --turbopack", + "start": "npm run start:frontend", + "start:frontend": "node ./scripts/run-with-variant.mjs start frontend", + "start:full": "node ./scripts/run-with-variant.mjs start full", "lint": "eslint", "bake:funnels": "node scripts/bake-funnels.mjs", "import:funnels": "node scripts/import-funnels-to-db.mjs", - "prebuild": "npm run bake:funnels", "storybook": "storybook dev -p 6006 --ci", "build-storybook": "storybook build" }, diff --git a/scripts/run-with-variant.mjs b/scripts/run-with-variant.mjs new file mode 100644 index 0000000..2eaabc7 --- /dev/null +++ b/scripts/run-with-variant.mjs @@ -0,0 +1,42 @@ +#!/usr/bin/env node +import { spawn } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const [command, variant, ...rawArgs] = process.argv.slice(2); + +if (!command || !variant) { + console.error('Usage: node scripts/run-with-variant.mjs [-- ]'); + process.exit(1); +} + +const allowedVariants = new Set(['frontend', 'full']); +if (!allowedVariants.has(variant)) { + console.error(`Unknown build variant '${variant}'. Use one of: ${Array.from(allowedVariants).join(', ')}`); + process.exit(1); +} + +const separatorIndex = rawArgs.indexOf('--'); +const nextArgs = separatorIndex === -1 ? rawArgs : rawArgs.slice(separatorIndex + 1); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const nextBin = path.join(__dirname, '..', 'node_modules', '.bin', 'next'); + +const env = { + ...process.env, + FUNNEL_BUILD_VARIANT: variant, + NEXT_PUBLIC_FUNNEL_BUILD_VARIANT: variant, +}; + +const child = spawn(nextBin, [command, ...nextArgs], { + stdio: 'inherit', + env, + shell: process.platform === 'win32', +}); + +child.on('exit', (code, signal) => { + if (typeof code === 'number') { + process.exit(code); + } + process.kill(process.pid, signal ?? 'SIGTERM'); +}); diff --git a/src/app/[funnelId]/[screenId]/page.tsx b/src/app/[funnelId]/[screenId]/page.tsx index 221ece2..006e0be 100644 --- a/src/app/[funnelId]/[screenId]/page.tsx +++ b/src/app/[funnelId]/[screenId]/page.tsx @@ -8,9 +8,14 @@ import { } from "@/lib/funnel/loadFunnelDefinition"; import { FunnelRuntime } from "@/components/funnel/FunnelRuntime"; import type { FunnelDefinition } from "@/lib/funnel/types"; +import { IS_FULL_SYSTEM_BUILD } from "@/lib/runtime/buildVariant"; // Функция для загрузки воронки из базы данных напрямую async function loadFunnelFromDatabase(funnelId: string): Promise { + if (!IS_FULL_SYSTEM_BUILD) { + return null; + } + try { // Импортируем модели напрямую вместо HTTP запроса const { default: connectMongoDB } = await import('@/lib/mongodb'); diff --git a/src/app/[funnelId]/page.tsx b/src/app/[funnelId]/page.tsx index b9fe26c..1a8d24c 100644 --- a/src/app/[funnelId]/page.tsx +++ b/src/app/[funnelId]/page.tsx @@ -5,9 +5,14 @@ import { peekBakedFunnelDefinition, } from "@/lib/funnel/loadFunnelDefinition"; import type { FunnelDefinition } from "@/lib/funnel/types"; +import { IS_FULL_SYSTEM_BUILD } from "@/lib/runtime/buildVariant"; // Функция для загрузки воронки из базы данных async function loadFunnelFromDatabase(funnelId: string): Promise { + if (!IS_FULL_SYSTEM_BUILD) { + return null; + } + try { // Пытаемся загрузить из базы данных через API const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/funnels/by-funnel-id/${funnelId}`, { diff --git a/src/app/admin/AdminCatalogPageClient.tsx b/src/app/admin/AdminCatalogPageClient.tsx new file mode 100644 index 0000000..c165f1f --- /dev/null +++ b/src/app/admin/AdminCatalogPageClient.tsx @@ -0,0 +1,496 @@ +"use client"; + +import { useCallback, useEffect, useState } from 'react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { Button } from '@/components/ui/button'; +import { TextInput } from '@/components/ui/TextInput/TextInput'; +import { + Plus, + Search, + Copy, + Trash2, + Edit, + Eye, + RefreshCw +} from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface FunnelListItem { + _id: string; + name: string; + description?: string; + status: 'draft' | 'published' | 'archived'; + version: number; + createdAt: string; + updatedAt: string; + publishedAt?: string; + usage: { + totalViews: number; + totalCompletions: number; + lastUsed?: string; + }; + funnelData?: { + meta?: { + id?: string; + title?: string; + description?: string; + }; + }; +} + +interface PaginationInfo { + current: number; + total: number; + count: number; + totalItems: number; +} + +export default function AdminCatalogPage() { + const [funnels, setFunnels] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const router = useRouter(); + + // Фильтры и поиск + const [searchQuery, setSearchQuery] = useState(''); + const [statusFilter, setStatusFilter] = useState('all'); + const [sortBy, setSortBy] = useState('updatedAt'); + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); + + // Пагинация + const [pagination, setPagination] = useState({ + current: 1, + total: 1, + count: 0, + totalItems: 0 + }); + + // Выделенные элементы - TODO: реализовать в будущем + // const [selectedFunnels, setSelectedFunnels] = useState>(new Set()); + + // Загрузка данных + const loadFunnels = useCallback(async (page: number = 1) => { + try { + setLoading(true); + setError(null); + + const params = new URLSearchParams({ + page: page.toString(), + limit: '20', + sortBy, + sortOrder, + ...(searchQuery && { search: searchQuery }), + ...(statusFilter !== 'all' && { status: statusFilter }) + }); + + const response = await fetch(`/api/funnels?${params}`); + if (!response.ok) { + throw new Error('Failed to fetch funnels'); + } + + const data = await response.json(); + setFunnels(data.funnels); + setPagination({ + current: data.pagination.current, + total: data.pagination.total, + count: data.pagination.count, + totalItems: data.pagination.totalItems + }); + + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setLoading(false); + } + }, [searchQuery, statusFilter, sortBy, sortOrder]); + + // Эффекты + useEffect(() => { + loadFunnels(1); + }, [loadFunnels]); + + // Создание новой воронки + const handleCreateFunnel = async () => { + try { + const newFunnelData = { + name: 'Новая воронка', + description: 'Описание новой воронки', + funnelData: { + meta: { + id: `funnel-${Date.now()}`, + title: 'Новая воронка', + description: 'Описание новой воронки', + firstScreenId: 'screen-1' + }, + defaultTexts: { + nextButton: 'Далее', + continueButton: 'Продолжить' + }, + screens: [ + { + id: 'screen-1', + template: 'info', + title: { + text: 'Добро пожаловать!', + font: 'manrope', + weight: 'bold' + }, + description: { + text: 'Это ваша новая воронка. Начните редактирование.', + color: 'muted' + }, + icon: { + type: 'emoji', + value: '🎯', + size: 'lg' + } + } + ] + } + }; + + const response = await fetch('/api/funnels', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(newFunnelData) + }); + + if (!response.ok) { + throw new Error('Failed to create funnel'); + } + + const createdFunnel = await response.json(); + + // Переходим к редактированию новой воронки + router.push(`/admin/builder/${createdFunnel._id}`); + + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create funnel'); + } + }; + + // Дублирование воронки + const handleDuplicateFunnel = async (funnelId: string, funnelName: string) => { + try { + const response = await fetch(`/api/funnels/${funnelId}/duplicate`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: `${funnelName} (копия)` + }) + }); + + if (!response.ok) { + throw new Error('Failed to duplicate funnel'); + } + + // Обновляем список + loadFunnels(pagination.current); + + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to duplicate funnel'); + } + }; + + // Удаление воронки + const handleDeleteFunnel = async (funnelId: string, funnelName: string) => { + if (!confirm(`Вы уверены, что хотите удалить воронку "${funnelName}"?`)) { + return; + } + + try { + const response = await fetch(`/api/funnels/${funnelId}`, { + method: 'DELETE' + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to delete funnel'); + } + + // Обновляем список + loadFunnels(pagination.current); + + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to delete funnel'); + } + }; + + // Статус badges + const getStatusBadge = (status: string) => { + const variants = { + draft: 'bg-yellow-100 text-yellow-800 border-yellow-200', + published: 'bg-green-100 text-green-800 border-green-200', + archived: 'bg-gray-100 text-gray-800 border-gray-200' + }; + + const labels = { + draft: 'Черновик', + published: 'Опубликована', + archived: 'Архивирована' + }; + + return ( + + {labels[status as keyof typeof labels]} + + ); + }; + + // Форматирование дат + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString('ru-RU', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + }; + + return ( +
+
+ + {/* Header */} +
+
+
+

Каталог воронок

+

+ Управляйте своими воронками и создавайте новые +

+
+ +
+
+ + {/* Фильтры и поиск */} +
+
+ + {/* Поиск */} +
+
+ + setSearchQuery(e.target.value)} + placeholder="Поиск по названию, описанию..." + className="pl-10" + /> +
+
+ + {/* Фильтр статуса */} + + + {/* Сортировка */} + + + +
+
+ + {/* Ошибка */} + {error && ( +
+
{error}
+
+ )} + + {/* Список воронок */} +
+ + {loading ? ( +
+ + Загружается... +
+ ) : funnels.length === 0 ? ( +
+
Воронки не найдены
+ +
+ ) : ( +
+ + + + + + + + + + + + {funnels.map((funnel) => ( + + + + + + + + ))} + +
+ Название + + Статус + + Статистика + + Обновлена + + Действия +
+
+
+ {funnel.name} +
+
+ ID: {funnel.funnelData?.meta?.id || 'N/A'} +
+ {funnel.description && ( +
+ {funnel.description} +
+ )} +
+
+ {getStatusBadge(funnel.status)} + +
+ {funnel.usage.totalViews} просмотров +
+
+ {funnel.usage.totalCompletions} завершений +
+
+
+ {formatDate(funnel.updatedAt)} +
+
+ v{funnel.version} +
+
+
+ + {/* Просмотр воронки */} + + + + + {/* Редактирование */} + + + + + {/* Дублировать */} + + + {/* Удалить (только черновики) */} + {funnel.status === 'draft' && ( + + )} +
+
+
+ )} +
+ + {/* Пагинация */} + {pagination.total > 1 && ( +
+
+ Показано {pagination.count} из {pagination.totalItems} воронок +
+
+ + + {pagination.current} / {pagination.total} + + +
+
+ )} + +
+
+ ); +} diff --git a/src/app/admin/builder/[id]/FunnelBuilderPageClient.tsx b/src/app/admin/builder/[id]/FunnelBuilderPageClient.tsx new file mode 100644 index 0000000..fe82244 --- /dev/null +++ b/src/app/admin/builder/[id]/FunnelBuilderPageClient.tsx @@ -0,0 +1,280 @@ +"use client"; + +import { useEffect, useState } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { BuilderProvider } from "@/lib/admin/builder/context"; +import { + BuilderUndoRedoProvider, + BuilderTopBar, + BuilderSidebar, + BuilderCanvas, + BuilderPreview +} from "@/components/admin/builder"; +import type { BuilderState } from '@/lib/admin/builder/context'; +import type { FunnelDefinition } from '@/lib/funnel/types'; +import { deserializeFunnelDefinition } from '@/lib/admin/builder/utils'; + +interface FunnelData { + _id: string; + name: string; + description?: string; + status: 'draft' | 'published' | 'archived'; + version: number; + funnelData: FunnelDefinition; + createdAt: string; + updatedAt: string; +} + +export default function FunnelBuilderPage() { + const params = useParams(); + const router = useRouter(); + const funnelId = params.id as string; + + const [funnelData, setFunnelData] = useState(null); + const [initialBuilderState, setInitialBuilderState] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + + // Генерируем уникальный sessionId для истории изменений + const [sessionId] = useState(() => `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`); + + // Загрузка воронки из базы данных + const loadFunnel = async () => { + try { + setLoading(true); + setError(null); + + const response = await fetch(`/api/funnels/${funnelId}`); + if (!response.ok) { + if (response.status === 404) { + throw new Error('Воронка не найдена'); + } + throw new Error('Ошибка загрузки воронки'); + } + + const data: FunnelData = await response.json(); + setFunnelData(data); + + // Конвертируем данные воронки в состояние билдера + const builderState = deserializeFunnelDefinition(data.funnelData); + setInitialBuilderState({ + ...builderState, + selectedScreenId: builderState.screens[0]?.id || null, + isDirty: false + }); + + } catch (err) { + setError(err instanceof Error ? err.message : 'Неизвестная ошибка'); + } finally { + setLoading(false); + } + }; + + // Сохранение воронки + const saveFunnel = async (builderState: BuilderState, publish: boolean = false) => { + if (!funnelData || saving) return; + + try { + setSaving(true); + + // Конвертируем состояние билдера обратно в FunnelDefinition + const updatedFunnelData: FunnelDefinition = { + meta: builderState.meta, + defaultTexts: { + nextButton: 'Далее', + continueButton: 'Продолжить' + }, + screens: builderState.screens + }; + + const response = await fetch(`/api/funnels/${funnelId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: builderState.meta.title || funnelData.name, + description: builderState.meta.description || funnelData.description, + funnelData: updatedFunnelData, + status: publish ? 'published' : funnelData.status, + sessionId, + actionDescription: publish ? 'Воронка опубликована' : 'Воронка сохранена' + }) + }); + + if (!response.ok) { + throw new Error('Ошибка сохранения воронки'); + } + + const updatedFunnel = await response.json(); + setFunnelData(updatedFunnel); + + // Показываем уведомление об успешном сохранении + // TODO: Добавить toast уведомления + + return true; + + } catch (err) { + setError(err instanceof Error ? err.message : 'Ошибка сохранения'); + return false; + } finally { + setSaving(false); + } + }; + + // Создание записи в истории для текущего изменения + const createHistoryEntry = async ( + builderState: BuilderState, + actionType: string, + description: string + ) => { + try { + const funnelSnapshot: FunnelDefinition = { + meta: builderState.meta, + defaultTexts: { + nextButton: 'Далее', + continueButton: 'Продолжить' + }, + screens: builderState.screens + }; + + await fetch(`/api/funnels/${funnelId}/history`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + sessionId, + funnelSnapshot, + actionType, + description + }) + }); + } catch (error) { + console.error('Failed to create history entry:', error); + // Не прерываем работу, если не удалось создать запись в истории + } + }; + + // Обработчики для топ бара + const handleSave = async (builderState: BuilderState): Promise => { + const success = await saveFunnel(builderState, false); + if (success) { + // Создаем запись в истории как базовую точку + await createHistoryEntry(builderState, 'save', 'Изменения сохранены'); + } + return success || false; + }; + + const handlePublish = async (builderState: BuilderState): Promise => { + const success = await saveFunnel(builderState, true); + if (success) { + await createHistoryEntry(builderState, 'publish', 'Воронка опубликована'); + } + return success || false; + }; + + const handleNew = () => { + router.push('/admin'); + }; + + const handleBackToCatalog = () => { + router.push('/admin'); + }; + + useEffect(() => { + loadFunnel(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // loadFunnel создается заново при каждом рендере, но нам нужен только первый вызов + + // Loading state + if (loading) { + return ( +
+
+
+
Загрузка воронки...
+
+
+ ); + } + + // Error state + if (error) { + return ( +
+
+
{error}
+ +
+
+ ); + } + + // Main render + if (!initialBuilderState || !funnelData) { + return null; + } + + return ( + + +
+ + {/* Top Bar */} + + + {/* Main Content */} +
+ + {/* Sidebar */} + + + {/* Canvas Area */} +
+ + {/* Canvas */} +
+ +
+ + {/* Preview Panel */} +
+
+

Предпросмотр

+

+ Как выглядит экран в браузере +

+
+
+ +
+
+ +
+
+
+
+
+ ); +} diff --git a/src/app/admin/builder/[id]/page.tsx b/src/app/admin/builder/[id]/page.tsx index fe82244..158fd37 100644 --- a/src/app/admin/builder/[id]/page.tsx +++ b/src/app/admin/builder/[id]/page.tsx @@ -1,280 +1,14 @@ -"use client"; +import { notFound } from "next/navigation"; -import { useEffect, useState } from 'react'; -import { useParams, useRouter } from 'next/navigation'; -import { BuilderProvider } from "@/lib/admin/builder/context"; -import { - BuilderUndoRedoProvider, - BuilderTopBar, - BuilderSidebar, - BuilderCanvas, - BuilderPreview -} from "@/components/admin/builder"; -import type { BuilderState } from '@/lib/admin/builder/context'; -import type { FunnelDefinition } from '@/lib/funnel/types'; -import { deserializeFunnelDefinition } from '@/lib/admin/builder/utils'; - -interface FunnelData { - _id: string; - name: string; - description?: string; - status: 'draft' | 'published' | 'archived'; - version: number; - funnelData: FunnelDefinition; - createdAt: string; - updatedAt: string; -} - -export default function FunnelBuilderPage() { - const params = useParams(); - const router = useRouter(); - const funnelId = params.id as string; - - const [funnelData, setFunnelData] = useState(null); - const [initialBuilderState, setInitialBuilderState] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [saving, setSaving] = useState(false); - - // Генерируем уникальный sessionId для истории изменений - const [sessionId] = useState(() => `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`); - - // Загрузка воронки из базы данных - const loadFunnel = async () => { - try { - setLoading(true); - setError(null); - - const response = await fetch(`/api/funnels/${funnelId}`); - if (!response.ok) { - if (response.status === 404) { - throw new Error('Воронка не найдена'); - } - throw new Error('Ошибка загрузки воронки'); - } - - const data: FunnelData = await response.json(); - setFunnelData(data); - - // Конвертируем данные воронки в состояние билдера - const builderState = deserializeFunnelDefinition(data.funnelData); - setInitialBuilderState({ - ...builderState, - selectedScreenId: builderState.screens[0]?.id || null, - isDirty: false - }); - - } catch (err) { - setError(err instanceof Error ? err.message : 'Неизвестная ошибка'); - } finally { - setLoading(false); - } - }; - - // Сохранение воронки - const saveFunnel = async (builderState: BuilderState, publish: boolean = false) => { - if (!funnelData || saving) return; - - try { - setSaving(true); - - // Конвертируем состояние билдера обратно в FunnelDefinition - const updatedFunnelData: FunnelDefinition = { - meta: builderState.meta, - defaultTexts: { - nextButton: 'Далее', - continueButton: 'Продолжить' - }, - screens: builderState.screens - }; - - const response = await fetch(`/api/funnels/${funnelId}`, { - method: 'PUT', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - name: builderState.meta.title || funnelData.name, - description: builderState.meta.description || funnelData.description, - funnelData: updatedFunnelData, - status: publish ? 'published' : funnelData.status, - sessionId, - actionDescription: publish ? 'Воронка опубликована' : 'Воронка сохранена' - }) - }); - - if (!response.ok) { - throw new Error('Ошибка сохранения воронки'); - } - - const updatedFunnel = await response.json(); - setFunnelData(updatedFunnel); - - // Показываем уведомление об успешном сохранении - // TODO: Добавить toast уведомления - - return true; - - } catch (err) { - setError(err instanceof Error ? err.message : 'Ошибка сохранения'); - return false; - } finally { - setSaving(false); - } - }; - - // Создание записи в истории для текущего изменения - const createHistoryEntry = async ( - builderState: BuilderState, - actionType: string, - description: string - ) => { - try { - const funnelSnapshot: FunnelDefinition = { - meta: builderState.meta, - defaultTexts: { - nextButton: 'Далее', - continueButton: 'Продолжить' - }, - screens: builderState.screens - }; - - await fetch(`/api/funnels/${funnelId}/history`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - sessionId, - funnelSnapshot, - actionType, - description - }) - }); - } catch (error) { - console.error('Failed to create history entry:', error); - // Не прерываем работу, если не удалось создать запись в истории - } - }; - - // Обработчики для топ бара - const handleSave = async (builderState: BuilderState): Promise => { - const success = await saveFunnel(builderState, false); - if (success) { - // Создаем запись в истории как базовую точку - await createHistoryEntry(builderState, 'save', 'Изменения сохранены'); - } - return success || false; - }; - - const handlePublish = async (builderState: BuilderState): Promise => { - const success = await saveFunnel(builderState, true); - if (success) { - await createHistoryEntry(builderState, 'publish', 'Воронка опубликована'); - } - return success || false; - }; - - const handleNew = () => { - router.push('/admin'); - }; - - const handleBackToCatalog = () => { - router.push('/admin'); - }; - - useEffect(() => { - loadFunnel(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); // loadFunnel создается заново при каждом рендере, но нам нужен только первый вызов - - // Loading state - if (loading) { - return ( -
-
-
-
Загрузка воронки...
-
-
- ); +import { IS_FULL_SYSTEM_BUILD } from "@/lib/runtime/buildVariant"; +export default async function FunnelBuilderPage() { + if (!IS_FULL_SYSTEM_BUILD) { + notFound(); } - // Error state - if (error) { - return ( -
-
-
{error}
- -
-
- ); - } - - // Main render - if (!initialBuilderState || !funnelData) { - return null; - } - - return ( - - -
- - {/* Top Bar */} - - - {/* Main Content */} -
- - {/* Sidebar */} - - - {/* Canvas Area */} -
- - {/* Canvas */} -
- -
- - {/* Preview Panel */} -
-
-

Предпросмотр

-

- Как выглядит экран в браузере -

-
-
- -
-
- -
-
-
-
-
+ const { default: FunnelBuilderPageClient } = await import( + "./FunnelBuilderPageClient" ); + + return ; } diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index c165f1f..5470b23 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -1,496 +1,14 @@ -"use client"; +import { notFound } from "next/navigation"; -import { useCallback, useEffect, useState } from 'react'; -import Link from 'next/link'; -import { useRouter } from 'next/navigation'; -import { Button } from '@/components/ui/button'; -import { TextInput } from '@/components/ui/TextInput/TextInput'; -import { - Plus, - Search, - Copy, - Trash2, - Edit, - Eye, - RefreshCw -} from 'lucide-react'; -import { cn } from '@/lib/utils'; +import { IS_FULL_SYSTEM_BUILD } from "@/lib/runtime/buildVariant"; +export default async function AdminCatalogPage() { + if (!IS_FULL_SYSTEM_BUILD) { + notFound(); + } -interface FunnelListItem { - _id: string; - name: string; - description?: string; - status: 'draft' | 'published' | 'archived'; - version: number; - createdAt: string; - updatedAt: string; - publishedAt?: string; - usage: { - totalViews: number; - totalCompletions: number; - lastUsed?: string; - }; - funnelData?: { - meta?: { - id?: string; - title?: string; - description?: string; - }; - }; -} - -interface PaginationInfo { - current: number; - total: number; - count: number; - totalItems: number; -} - -export default function AdminCatalogPage() { - const [funnels, setFunnels] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const router = useRouter(); - - // Фильтры и поиск - const [searchQuery, setSearchQuery] = useState(''); - const [statusFilter, setStatusFilter] = useState('all'); - const [sortBy, setSortBy] = useState('updatedAt'); - const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); - - // Пагинация - const [pagination, setPagination] = useState({ - current: 1, - total: 1, - count: 0, - totalItems: 0 - }); - - // Выделенные элементы - TODO: реализовать в будущем - // const [selectedFunnels, setSelectedFunnels] = useState>(new Set()); - - // Загрузка данных - const loadFunnels = useCallback(async (page: number = 1) => { - try { - setLoading(true); - setError(null); - - const params = new URLSearchParams({ - page: page.toString(), - limit: '20', - sortBy, - sortOrder, - ...(searchQuery && { search: searchQuery }), - ...(statusFilter !== 'all' && { status: statusFilter }) - }); - - const response = await fetch(`/api/funnels?${params}`); - if (!response.ok) { - throw new Error('Failed to fetch funnels'); - } - - const data = await response.json(); - setFunnels(data.funnels); - setPagination({ - current: data.pagination.current, - total: data.pagination.total, - count: data.pagination.count, - totalItems: data.pagination.totalItems - }); - - } catch (err) { - setError(err instanceof Error ? err.message : 'Unknown error'); - } finally { - setLoading(false); - } - }, [searchQuery, statusFilter, sortBy, sortOrder]); - - // Эффекты - useEffect(() => { - loadFunnels(1); - }, [loadFunnels]); - - // Создание новой воронки - const handleCreateFunnel = async () => { - try { - const newFunnelData = { - name: 'Новая воронка', - description: 'Описание новой воронки', - funnelData: { - meta: { - id: `funnel-${Date.now()}`, - title: 'Новая воронка', - description: 'Описание новой воронки', - firstScreenId: 'screen-1' - }, - defaultTexts: { - nextButton: 'Далее', - continueButton: 'Продолжить' - }, - screens: [ - { - id: 'screen-1', - template: 'info', - title: { - text: 'Добро пожаловать!', - font: 'manrope', - weight: 'bold' - }, - description: { - text: 'Это ваша новая воронка. Начните редактирование.', - color: 'muted' - }, - icon: { - type: 'emoji', - value: '🎯', - size: 'lg' - } - } - ] - } - }; - - const response = await fetch('/api/funnels', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(newFunnelData) - }); - - if (!response.ok) { - throw new Error('Failed to create funnel'); - } - - const createdFunnel = await response.json(); - - // Переходим к редактированию новой воронки - router.push(`/admin/builder/${createdFunnel._id}`); - - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to create funnel'); - } - }; - - // Дублирование воронки - const handleDuplicateFunnel = async (funnelId: string, funnelName: string) => { - try { - const response = await fetch(`/api/funnels/${funnelId}/duplicate`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - name: `${funnelName} (копия)` - }) - }); - - if (!response.ok) { - throw new Error('Failed to duplicate funnel'); - } - - // Обновляем список - loadFunnels(pagination.current); - - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to duplicate funnel'); - } - }; - - // Удаление воронки - const handleDeleteFunnel = async (funnelId: string, funnelName: string) => { - if (!confirm(`Вы уверены, что хотите удалить воронку "${funnelName}"?`)) { - return; - } - - try { - const response = await fetch(`/api/funnels/${funnelId}`, { - method: 'DELETE' - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.error || 'Failed to delete funnel'); - } - - // Обновляем список - loadFunnels(pagination.current); - - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to delete funnel'); - } - }; - - // Статус badges - const getStatusBadge = (status: string) => { - const variants = { - draft: 'bg-yellow-100 text-yellow-800 border-yellow-200', - published: 'bg-green-100 text-green-800 border-green-200', - archived: 'bg-gray-100 text-gray-800 border-gray-200' - }; - - const labels = { - draft: 'Черновик', - published: 'Опубликована', - archived: 'Архивирована' - }; - - return ( - - {labels[status as keyof typeof labels]} - - ); - }; - - // Форматирование дат - const formatDate = (dateString: string) => { - return new Date(dateString).toLocaleDateString('ru-RU', { - day: '2-digit', - month: '2-digit', - year: 'numeric', - hour: '2-digit', - minute: '2-digit' - }); - }; - - return ( -
-
- - {/* Header */} -
-
-
-

Каталог воронок

-

- Управляйте своими воронками и создавайте новые -

-
- -
-
- - {/* Фильтры и поиск */} -
-
- - {/* Поиск */} -
-
- - setSearchQuery(e.target.value)} - placeholder="Поиск по названию, описанию..." - className="pl-10" - /> -
-
- - {/* Фильтр статуса */} - - - {/* Сортировка */} - - - -
-
- - {/* Ошибка */} - {error && ( -
-
{error}
-
- )} - - {/* Список воронок */} -
- - {loading ? ( -
- - Загружается... -
- ) : funnels.length === 0 ? ( -
-
Воронки не найдены
- -
- ) : ( -
- - - - - - - - - - - - {funnels.map((funnel) => ( - - - - - - - - ))} - -
- Название - - Статус - - Статистика - - Обновлена - - Действия -
-
-
- {funnel.name} -
-
- ID: {funnel.funnelData?.meta?.id || 'N/A'} -
- {funnel.description && ( -
- {funnel.description} -
- )} -
-
- {getStatusBadge(funnel.status)} - -
- {funnel.usage.totalViews} просмотров -
-
- {funnel.usage.totalCompletions} завершений -
-
-
- {formatDate(funnel.updatedAt)} -
-
- v{funnel.version} -
-
-
- - {/* Просмотр воронки */} - - - - - {/* Редактирование */} - - - - - {/* Дублировать */} - - - {/* Удалить (только черновики) */} - {funnel.status === 'draft' && ( - - )} -
-
-
- )} -
- - {/* Пагинация */} - {pagination.total > 1 && ( -
-
- Показано {pagination.count} из {pagination.totalItems} воронок -
-
- - - {pagination.current} / {pagination.total} - - -
-
- )} - -
-
+ const { default: AdminCatalogPageClient } = await import( + "./AdminCatalogPageClient" ); + + return ; } diff --git a/src/app/api/funnels/[id]/duplicate/route.ts b/src/app/api/funnels/[id]/duplicate/route.ts index d08bee3..d9b4f1e 100644 --- a/src/app/api/funnels/[id]/duplicate/route.ts +++ b/src/app/api/funnels/[id]/duplicate/route.ts @@ -1,7 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; -import connectMongoDB from '@/lib/mongodb'; -import FunnelModel from '@/lib/models/Funnel'; -import FunnelHistoryModel from '@/lib/models/FunnelHistory'; + +import { adminApiDisabledResponse, isAdminApiEnabled } from '@/lib/runtime/adminApi'; interface RouteParams { params: Promise<{ @@ -11,8 +10,22 @@ interface RouteParams { // POST /api/funnels/[id]/duplicate - создать копию воронки export async function POST(request: NextRequest, { params }: RouteParams) { + if (!isAdminApiEnabled()) { + return adminApiDisabledResponse(); + } + try { const { id } = await params; + const [ + { default: connectMongoDB }, + { default: FunnelModel }, + { default: FunnelHistoryModel }, + ] = await Promise.all([ + import('@/lib/mongodb'), + import('@/lib/models/Funnel'), + import('@/lib/models/FunnelHistory'), + ]); + await connectMongoDB(); const body = await request.json(); diff --git a/src/app/api/funnels/[id]/history/route.ts b/src/app/api/funnels/[id]/history/route.ts index 0a18383..ef533cb 100644 --- a/src/app/api/funnels/[id]/history/route.ts +++ b/src/app/api/funnels/[id]/history/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; -import connectMongoDB from '@/lib/mongodb'; -import FunnelHistoryModel from '@/lib/models/FunnelHistory'; + +import { adminApiDisabledResponse, isAdminApiEnabled } from '@/lib/runtime/adminApi'; interface RouteParams { params: Promise<{ @@ -10,8 +10,20 @@ interface RouteParams { // GET /api/funnels/[id]/history - получить историю изменений воронки export async function GET(request: NextRequest, { params }: RouteParams) { + if (!isAdminApiEnabled()) { + return adminApiDisabledResponse(); + } + try { const { id } = await params; + const [ + { default: connectMongoDB }, + { default: FunnelHistoryModel }, + ] = await Promise.all([ + import('@/lib/mongodb'), + import('@/lib/models/FunnelHistory'), + ]); + await connectMongoDB(); const { searchParams } = new URL(request.url); @@ -55,8 +67,20 @@ export async function GET(request: NextRequest, { params }: RouteParams) { // POST /api/funnels/[id]/history - создать новую запись в истории export async function POST(request: NextRequest, { params }: RouteParams) { + if (!isAdminApiEnabled()) { + return adminApiDisabledResponse(); + } + try { const { id } = await params; + const [ + { default: connectMongoDB }, + { default: FunnelHistoryModel }, + ] = await Promise.all([ + import('@/lib/mongodb'), + import('@/lib/models/FunnelHistory'), + ]); + await connectMongoDB(); const body = await request.json(); diff --git a/src/app/api/funnels/[id]/route.ts b/src/app/api/funnels/[id]/route.ts index e993ffd..afab2c1 100644 --- a/src/app/api/funnels/[id]/route.ts +++ b/src/app/api/funnels/[id]/route.ts @@ -1,7 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; -import connectMongoDB from '@/lib/mongodb'; -import FunnelModel from '@/lib/models/Funnel'; -import FunnelHistoryModel from '@/lib/models/FunnelHistory'; + +import { adminApiDisabledResponse, isAdminApiEnabled } from '@/lib/runtime/adminApi'; import type { FunnelDefinition } from '@/lib/funnel/types'; interface RouteParams { @@ -14,8 +13,17 @@ interface RouteParams { // GET /api/funnels/[id] - получить конкретную воронку export async function GET(request: NextRequest, { params }: RouteParams) { + if (!isAdminApiEnabled()) { + return adminApiDisabledResponse(); + } + try { const { id } = await params; + const [{ default: connectMongoDB }, { default: FunnelModel }] = await Promise.all([ + import('@/lib/mongodb'), + import('@/lib/models/Funnel'), + ]); + await connectMongoDB(); const funnel = await FunnelModel.findById(id); @@ -51,8 +59,22 @@ export async function GET(request: NextRequest, { params }: RouteParams) { // PUT /api/funnels/[id] - обновить воронку export async function PUT(request: NextRequest, { params }: RouteParams) { + if (!isAdminApiEnabled()) { + return adminApiDisabledResponse(); + } + try { const { id } = await params; + const [ + { default: connectMongoDB }, + { default: FunnelModel }, + { default: FunnelHistoryModel }, + ] = await Promise.all([ + import('@/lib/mongodb'), + import('@/lib/models/Funnel'), + import('@/lib/models/FunnelHistory'), + ]); + await connectMongoDB(); const body = await request.json(); @@ -164,8 +186,22 @@ export async function PUT(request: NextRequest, { params }: RouteParams) { // DELETE /api/funnels/[id] - удалить воронку export async function DELETE(request: NextRequest, { params }: RouteParams) { + if (!isAdminApiEnabled()) { + return adminApiDisabledResponse(); + } + try { const { id } = await params; + const [ + { default: connectMongoDB }, + { default: FunnelModel }, + { default: FunnelHistoryModel }, + ] = await Promise.all([ + import('@/lib/mongodb'), + import('@/lib/models/Funnel'), + import('@/lib/models/FunnelHistory'), + ]); + await connectMongoDB(); const funnel = await FunnelModel.findById(id); diff --git a/src/app/api/funnels/by-funnel-id/[funnelId]/route.ts b/src/app/api/funnels/by-funnel-id/[funnelId]/route.ts index f8876db..42d826c 100644 --- a/src/app/api/funnels/by-funnel-id/[funnelId]/route.ts +++ b/src/app/api/funnels/by-funnel-id/[funnelId]/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; -import connectMongoDB from '@/lib/mongodb'; -import FunnelModel from '@/lib/models/Funnel'; + +import { adminApiDisabledResponse, isAdminApiEnabled } from '@/lib/runtime/adminApi'; interface RouteParams { params: Promise<{ @@ -12,8 +12,17 @@ interface RouteParams { // Этот endpoint обеспечивает совместимость с существующим кодом, который ожидает // загрузку воронки по funnel ID из JSON файлов export async function GET(request: NextRequest, { params }: RouteParams) { + if (!isAdminApiEnabled()) { + return adminApiDisabledResponse(); + } + try { const { funnelId } = await params; + const [{ default: connectMongoDB }, { default: FunnelModel }] = await Promise.all([ + import('@/lib/mongodb'), + import('@/lib/models/Funnel'), + ]); + await connectMongoDB(); const funnel = await FunnelModel.findOne({ @@ -48,8 +57,17 @@ export async function GET(request: NextRequest, { params }: RouteParams) { // PUT /api/funnels/by-funnel-id/[funnelId] - обновить воронку по funnel ID export async function PUT(request: NextRequest, { params }: RouteParams) { + if (!isAdminApiEnabled()) { + return adminApiDisabledResponse(); + } + try { const { funnelId } = await params; + const [{ default: connectMongoDB }, { default: FunnelModel }] = await Promise.all([ + import('@/lib/mongodb'), + import('@/lib/models/Funnel'), + ]); + await connectMongoDB(); const funnelData = await request.json(); diff --git a/src/app/api/funnels/route.ts b/src/app/api/funnels/route.ts index 7a681ae..cf309b7 100644 --- a/src/app/api/funnels/route.ts +++ b/src/app/api/funnels/route.ts @@ -1,12 +1,20 @@ import { NextRequest, NextResponse } from 'next/server'; -import connectMongoDB from '@/lib/mongodb'; -import FunnelModel from '@/lib/models/Funnel'; -import FunnelHistoryModel from '@/lib/models/FunnelHistory'; + +import { adminApiDisabledResponse, isAdminApiEnabled } from '@/lib/runtime/adminApi'; import type { FunnelDefinition } from '@/lib/funnel/types'; // GET /api/funnels - получить список всех воронок export async function GET(request: NextRequest) { + if (!isAdminApiEnabled()) { + return adminApiDisabledResponse(); + } + try { + const [{ default: connectMongoDB }, { default: FunnelModel }] = await Promise.all([ + import('@/lib/mongodb'), + import('@/lib/models/Funnel'), + ]); + await connectMongoDB(); const { searchParams } = new URL(request.url); @@ -72,7 +80,21 @@ export async function GET(request: NextRequest) { // POST /api/funnels - создать новую воронку export async function POST(request: NextRequest) { + if (!isAdminApiEnabled()) { + return adminApiDisabledResponse(); + } + try { + const [ + { default: connectMongoDB }, + { default: FunnelModel }, + { default: FunnelHistoryModel }, + ] = await Promise.all([ + import('@/lib/mongodb'), + import('@/lib/models/Funnel'), + import('@/lib/models/FunnelHistory'), + ]); + await connectMongoDB(); const body = await request.json(); diff --git a/src/lib/runtime/adminApi.ts b/src/lib/runtime/adminApi.ts new file mode 100644 index 0000000..4bb06b3 --- /dev/null +++ b/src/lib/runtime/adminApi.ts @@ -0,0 +1,14 @@ +import { NextResponse } from 'next/server'; + +import { IS_FULL_SYSTEM_BUILD } from './buildVariant'; + +export function isAdminApiEnabled(): boolean { + return IS_FULL_SYSTEM_BUILD; +} + +export function adminApiDisabledResponse() { + return NextResponse.json( + { error: 'Admin API is disabled in the frontend-only build' }, + { status: 503 } + ); +} diff --git a/src/lib/runtime/buildVariant.ts b/src/lib/runtime/buildVariant.ts new file mode 100644 index 0000000..d01572a --- /dev/null +++ b/src/lib/runtime/buildVariant.ts @@ -0,0 +1,21 @@ +export type BuildVariant = "frontend" | "full"; + +const rawVariant = + (typeof process !== "undefined" + ? process.env.FUNNEL_BUILD_VARIANT ?? process.env.NEXT_PUBLIC_FUNNEL_BUILD_VARIANT + : undefined) ?? "frontend"; + +export const BUILD_VARIANT: BuildVariant = + rawVariant === "frontend" ? "frontend" : "full"; + +export const IS_FULL_SYSTEM_BUILD = BUILD_VARIANT === "full"; +export const IS_FRONTEND_ONLY_BUILD = BUILD_VARIANT === "frontend"; + +export function assertFullSystemBuild(feature?: string): void { + if (!IS_FULL_SYSTEM_BUILD) { + const scope = feature ? ` for ${feature}` : ""; + throw new Error( + `This operation is only available in the full system build${scope}.` + ); + } +}