Align build variants with prod/dev expectations

This commit is contained in:
pennyteenycat 2025-09-28 17:30:32 +02:00
parent b28d22967f
commit 6393508a8d
18 changed files with 1056 additions and 815 deletions

View File

@ -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`).
## Использование
### Создание новой воронки

View File

@ -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`.

View File

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

View File

@ -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"
},

View File

@ -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 <command> <variant> [-- <next args...>]');
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');
});

View File

@ -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<FunnelDefinition | null> {
if (!IS_FULL_SYSTEM_BUILD) {
return null;
}
try {
// Импортируем модели напрямую вместо HTTP запроса
const { default: connectMongoDB } = await import('@/lib/mongodb');

View File

@ -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<FunnelDefinition | null> {
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}`, {

View File

@ -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<FunnelListItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
// Фильтры и поиск
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [sortBy, setSortBy] = useState('updatedAt');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
// Пагинация
const [pagination, setPagination] = useState<PaginationInfo>({
current: 1,
total: 1,
count: 0,
totalItems: 0
});
// Выделенные элементы - TODO: реализовать в будущем
// const [selectedFunnels, setSelectedFunnels] = useState<Set<string>>(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 (
<span className={cn(
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border',
variants[status as keyof typeof variants]
)}>
{labels[status as keyof typeof labels]}
</span>
);
};
// Форматирование дат
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 (
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Каталог воронок</h1>
<p className="mt-2 text-gray-600">
Управляйте своими воронками и создавайте новые
</p>
</div>
<Button onClick={handleCreateFunnel} className="flex items-center gap-2">
<Plus className="h-4 w-4" />
Создать воронку
</Button>
</div>
</div>
{/* Фильтры и поиск */}
<div className="mb-6 bg-white rounded-lg border border-gray-200 p-6">
<div className="flex flex-col sm:flex-row gap-4">
{/* Поиск */}
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<TextInput
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Поиск по названию, описанию..."
className="pl-10"
/>
</div>
</div>
{/* Фильтр статуса */}
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-md bg-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="all">Все статусы</option>
<option value="draft">Черновики</option>
<option value="published">Опубликованные</option>
<option value="archived">Архивированные</option>
</select>
{/* Сортировка */}
<select
value={`${sortBy}-${sortOrder}`}
onChange={(e) => {
const [field, order] = e.target.value.split('-');
setSortBy(field);
setSortOrder(order as 'asc' | 'desc');
}}
className="px-3 py-2 border border-gray-300 rounded-md bg-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="updatedAt-desc">Сначала новые</option>
<option value="updatedAt-asc">Сначала старые</option>
<option value="name-asc">По названию А-Я</option>
<option value="name-desc">По названию Я-А</option>
<option value="usage.totalViews-desc">По популярности</option>
</select>
<Button
variant="outline"
onClick={() => loadFunnels(pagination.current)}
disabled={loading}
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</Button>
</div>
</div>
{/* Ошибка */}
{error && (
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4">
<div className="text-red-800">{error}</div>
</div>
)}
{/* Список воронок */}
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
{loading ? (
<div className="p-8 text-center text-gray-500">
<RefreshCw className="h-6 w-6 animate-spin mx-auto mb-2" />
Загружается...
</div>
) : funnels.length === 0 ? (
<div className="p-8 text-center text-gray-500">
<div className="mb-4">Воронки не найдены</div>
<Button onClick={handleCreateFunnel} variant="outline">
<Plus className="h-4 w-4 mr-2" />
Создать первую воронку
</Button>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Название
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Статус
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Статистика
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Обновлена
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Действия
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{funnels.map((funnel) => (
<tr key={funnel._id} className="hover:bg-gray-50">
<td className="px-6 py-4">
<div className="flex flex-col">
<div className="text-sm font-medium text-gray-900">
{funnel.name}
</div>
<div className="text-sm text-gray-500">
ID: {funnel.funnelData?.meta?.id || 'N/A'}
</div>
{funnel.description && (
<div className="text-sm text-gray-500 mt-1">
{funnel.description}
</div>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{getStatusBadge(funnel.status)}
</td>
<td className="px-6 py-4">
<div className="text-sm text-gray-900">
{funnel.usage.totalViews} просмотров
</div>
<div className="text-sm text-gray-500">
{funnel.usage.totalCompletions} завершений
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{formatDate(funnel.updatedAt)}
</div>
<div className="text-sm text-gray-500">
v{funnel.version}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<div className="flex items-center justify-end gap-2">
{/* Просмотр воронки */}
<Link href={`/${funnel.funnelData?.meta?.id || funnel._id}`}>
<Button variant="ghost" title="Просмотр" className="h-8 w-8 p-0">
<Eye className="h-4 w-4" />
</Button>
</Link>
{/* Редактирование */}
<Link href={`/admin/builder/${funnel._id}`}>
<Button variant="ghost" title="Редактировать" className="h-8 w-8 p-0">
<Edit className="h-4 w-4" />
</Button>
</Link>
{/* Дублировать */}
<Button
variant="ghost"
title="Дублировать"
onClick={() => handleDuplicateFunnel(funnel._id, funnel.name)}
className="h-8 w-8 p-0"
>
<Copy className="h-4 w-4" />
</Button>
{/* Удалить (только черновики) */}
{funnel.status === 'draft' && (
<Button
variant="ghost"
title="Удалить"
onClick={() => handleDeleteFunnel(funnel._id, funnel.name)}
className="h-8 w-8 p-0 text-red-600 hover:text-red-700"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Пагинация */}
{pagination.total > 1 && (
<div className="mt-6 flex items-center justify-between">
<div className="text-sm text-gray-700">
Показано {pagination.count} из {pagination.totalItems} воронок
</div>
<div className="flex gap-2">
<Button
variant="outline"
disabled={pagination.current <= 1}
onClick={() => loadFunnels(pagination.current - 1)}
>
Предыдущая
</Button>
<span className="px-3 py-1 bg-gray-100 rounded text-sm">
{pagination.current} / {pagination.total}
</span>
<Button
variant="outline"
disabled={pagination.current >= pagination.total}
onClick={() => loadFunnels(pagination.current + 1)}
>
Следующая
</Button>
</div>
</div>
)}
</div>
</div>
);
}

View File

@ -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<FunnelData | null>(null);
const [initialBuilderState, setInitialBuilderState] = useState<BuilderState | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<boolean> => {
const success = await saveFunnel(builderState, false);
if (success) {
// Создаем запись в истории как базовую точку
await createHistoryEntry(builderState, 'save', 'Изменения сохранены');
}
return success || false;
};
const handlePublish = async (builderState: BuilderState): Promise<boolean> => {
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 (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
<div className="text-gray-600">Загрузка воронки...</div>
</div>
</div>
);
}
// Error state
if (error) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="text-red-600 mb-4">{error}</div>
<button
onClick={handleBackToCatalog}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
Вернуться к каталогу
</button>
</div>
</div>
);
}
// Main render
if (!initialBuilderState || !funnelData) {
return null;
}
return (
<BuilderProvider initialState={initialBuilderState}>
<BuilderUndoRedoProvider>
<div className="h-screen flex flex-col bg-gray-50 overflow-hidden">
{/* Top Bar */}
<BuilderTopBar
onNew={handleNew}
onSave={handleSave}
onPublish={handlePublish}
onBackToCatalog={handleBackToCatalog}
saving={saving}
funnelInfo={{
name: funnelData.name,
status: funnelData.status,
version: funnelData.version,
lastSaved: funnelData.updatedAt
}}
/>
{/* Main Content */}
<div className="flex-1 flex overflow-hidden">
{/* Sidebar */}
<aside className="w-[360px] shrink-0 overflow-y-auto border-r border-border/60 bg-background/95">
<BuilderSidebar />
</aside>
{/* Canvas Area */}
<div className="flex-1 flex overflow-hidden">
{/* Canvas */}
<div className="flex-1 overflow-hidden">
<BuilderCanvas />
</div>
{/* Preview Panel */}
<div className="w-[360px] shrink-0 border-l border-border/60 bg-background overflow-y-auto">
<div className="p-4 border-b border-border/60">
<h3 className="font-semibold text-sm">Предпросмотр</h3>
<p className="text-xs text-muted-foreground">
Как выглядит экран в браузере
</p>
</div>
<div className="p-4">
<BuilderPreview />
</div>
</div>
</div>
</div>
</div>
</BuilderUndoRedoProvider>
</BuilderProvider>
);
}

View File

@ -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<FunnelData | null>(null);
const [initialBuilderState, setInitialBuilderState] = useState<BuilderState | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<boolean> => {
const success = await saveFunnel(builderState, false);
if (success) {
// Создаем запись в истории как базовую точку
await createHistoryEntry(builderState, 'save', 'Изменения сохранены');
}
return success || false;
};
const handlePublish = async (builderState: BuilderState): Promise<boolean> => {
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 (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
<div className="text-gray-600">Загрузка воронки...</div>
</div>
</div>
);
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 (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<div className="text-red-600 mb-4">{error}</div>
<button
onClick={handleBackToCatalog}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
Вернуться к каталогу
</button>
</div>
</div>
);
}
// Main render
if (!initialBuilderState || !funnelData) {
return null;
}
return (
<BuilderProvider initialState={initialBuilderState}>
<BuilderUndoRedoProvider>
<div className="h-screen flex flex-col bg-gray-50 overflow-hidden">
{/* Top Bar */}
<BuilderTopBar
onNew={handleNew}
onSave={handleSave}
onPublish={handlePublish}
onBackToCatalog={handleBackToCatalog}
saving={saving}
funnelInfo={{
name: funnelData.name,
status: funnelData.status,
version: funnelData.version,
lastSaved: funnelData.updatedAt
}}
/>
{/* Main Content */}
<div className="flex-1 flex overflow-hidden">
{/* Sidebar */}
<aside className="w-[360px] shrink-0 overflow-y-auto border-r border-border/60 bg-background/95">
<BuilderSidebar />
</aside>
{/* Canvas Area */}
<div className="flex-1 flex overflow-hidden">
{/* Canvas */}
<div className="flex-1 overflow-hidden">
<BuilderCanvas />
</div>
{/* Preview Panel */}
<div className="w-[360px] shrink-0 border-l border-border/60 bg-background overflow-y-auto">
<div className="p-4 border-b border-border/60">
<h3 className="font-semibold text-sm">Предпросмотр</h3>
<p className="text-xs text-muted-foreground">
Как выглядит экран в браузере
</p>
</div>
<div className="p-4">
<BuilderPreview />
</div>
</div>
</div>
</div>
</div>
</BuilderUndoRedoProvider>
</BuilderProvider>
const { default: FunnelBuilderPageClient } = await import(
"./FunnelBuilderPageClient"
);
return <FunnelBuilderPageClient />;
}

View File

@ -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<FunnelListItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const router = useRouter();
// Фильтры и поиск
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [sortBy, setSortBy] = useState('updatedAt');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
// Пагинация
const [pagination, setPagination] = useState<PaginationInfo>({
current: 1,
total: 1,
count: 0,
totalItems: 0
});
// Выделенные элементы - TODO: реализовать в будущем
// const [selectedFunnels, setSelectedFunnels] = useState<Set<string>>(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 (
<span className={cn(
'inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border',
variants[status as keyof typeof variants]
)}>
{labels[status as keyof typeof labels]}
</span>
);
};
// Форматирование дат
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 (
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Каталог воронок</h1>
<p className="mt-2 text-gray-600">
Управляйте своими воронками и создавайте новые
</p>
</div>
<Button onClick={handleCreateFunnel} className="flex items-center gap-2">
<Plus className="h-4 w-4" />
Создать воронку
</Button>
</div>
</div>
{/* Фильтры и поиск */}
<div className="mb-6 bg-white rounded-lg border border-gray-200 p-6">
<div className="flex flex-col sm:flex-row gap-4">
{/* Поиск */}
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<TextInput
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Поиск по названию, описанию..."
className="pl-10"
/>
</div>
</div>
{/* Фильтр статуса */}
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-md bg-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="all">Все статусы</option>
<option value="draft">Черновики</option>
<option value="published">Опубликованные</option>
<option value="archived">Архивированные</option>
</select>
{/* Сортировка */}
<select
value={`${sortBy}-${sortOrder}`}
onChange={(e) => {
const [field, order] = e.target.value.split('-');
setSortBy(field);
setSortOrder(order as 'asc' | 'desc');
}}
className="px-3 py-2 border border-gray-300 rounded-md bg-white text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="updatedAt-desc">Сначала новые</option>
<option value="updatedAt-asc">Сначала старые</option>
<option value="name-asc">По названию А-Я</option>
<option value="name-desc">По названию Я-А</option>
<option value="usage.totalViews-desc">По популярности</option>
</select>
<Button
variant="outline"
onClick={() => loadFunnels(pagination.current)}
disabled={loading}
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} />
</Button>
</div>
</div>
{/* Ошибка */}
{error && (
<div className="mb-6 bg-red-50 border border-red-200 rounded-lg p-4">
<div className="text-red-800">{error}</div>
</div>
)}
{/* Список воронок */}
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
{loading ? (
<div className="p-8 text-center text-gray-500">
<RefreshCw className="h-6 w-6 animate-spin mx-auto mb-2" />
Загружается...
</div>
) : funnels.length === 0 ? (
<div className="p-8 text-center text-gray-500">
<div className="mb-4">Воронки не найдены</div>
<Button onClick={handleCreateFunnel} variant="outline">
<Plus className="h-4 w-4 mr-2" />
Создать первую воронку
</Button>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Название
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Статус
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Статистика
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Обновлена
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Действия
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{funnels.map((funnel) => (
<tr key={funnel._id} className="hover:bg-gray-50">
<td className="px-6 py-4">
<div className="flex flex-col">
<div className="text-sm font-medium text-gray-900">
{funnel.name}
</div>
<div className="text-sm text-gray-500">
ID: {funnel.funnelData?.meta?.id || 'N/A'}
</div>
{funnel.description && (
<div className="text-sm text-gray-500 mt-1">
{funnel.description}
</div>
)}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
{getStatusBadge(funnel.status)}
</td>
<td className="px-6 py-4">
<div className="text-sm text-gray-900">
{funnel.usage.totalViews} просмотров
</div>
<div className="text-sm text-gray-500">
{funnel.usage.totalCompletions} завершений
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">
{formatDate(funnel.updatedAt)}
</div>
<div className="text-sm text-gray-500">
v{funnel.version}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<div className="flex items-center justify-end gap-2">
{/* Просмотр воронки */}
<Link href={`/${funnel.funnelData?.meta?.id || funnel._id}`}>
<Button variant="ghost" title="Просмотр" className="h-8 w-8 p-0">
<Eye className="h-4 w-4" />
</Button>
</Link>
{/* Редактирование */}
<Link href={`/admin/builder/${funnel._id}`}>
<Button variant="ghost" title="Редактировать" className="h-8 w-8 p-0">
<Edit className="h-4 w-4" />
</Button>
</Link>
{/* Дублировать */}
<Button
variant="ghost"
title="Дублировать"
onClick={() => handleDuplicateFunnel(funnel._id, funnel.name)}
className="h-8 w-8 p-0"
>
<Copy className="h-4 w-4" />
</Button>
{/* Удалить (только черновики) */}
{funnel.status === 'draft' && (
<Button
variant="ghost"
title="Удалить"
onClick={() => handleDeleteFunnel(funnel._id, funnel.name)}
className="h-8 w-8 p-0 text-red-600 hover:text-red-700"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* Пагинация */}
{pagination.total > 1 && (
<div className="mt-6 flex items-center justify-between">
<div className="text-sm text-gray-700">
Показано {pagination.count} из {pagination.totalItems} воронок
</div>
<div className="flex gap-2">
<Button
variant="outline"
disabled={pagination.current <= 1}
onClick={() => loadFunnels(pagination.current - 1)}
>
Предыдущая
</Button>
<span className="px-3 py-1 bg-gray-100 rounded text-sm">
{pagination.current} / {pagination.total}
</span>
<Button
variant="outline"
disabled={pagination.current >= pagination.total}
onClick={() => loadFunnels(pagination.current + 1)}
>
Следующая
</Button>
</div>
</div>
)}
</div>
</div>
const { default: AdminCatalogPageClient } = await import(
"./AdminCatalogPageClient"
);
return <AdminCatalogPageClient />;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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