Align build variants with prod/dev expectations
This commit is contained in:
parent
b28d22967f
commit
6393508a8d
@ -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`).
|
||||
|
||||
## Использование
|
||||
|
||||
### Создание новой воронки
|
||||
|
||||
53
README.md
53
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`.
|
||||
|
||||
@ -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;
|
||||
|
||||
13
package.json
13
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"
|
||||
},
|
||||
|
||||
42
scripts/run-with-variant.mjs
Normal file
42
scripts/run-with-variant.mjs
Normal 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');
|
||||
});
|
||||
@ -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');
|
||||
|
||||
@ -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}`, {
|
||||
|
||||
496
src/app/admin/AdminCatalogPageClient.tsx
Normal file
496
src/app/admin/AdminCatalogPageClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
280
src/app/admin/builder/[id]/FunnelBuilderPageClient.tsx
Normal file
280
src/app/admin/builder/[id]/FunnelBuilderPageClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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 />;
|
||||
}
|
||||
|
||||
@ -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 />;
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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();
|
||||
|
||||
14
src/lib/runtime/adminApi.ts
Normal file
14
src/lib/runtime/adminApi.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
21
src/lib/runtime/buildVariant.ts
Normal file
21
src/lib/runtime/buildVariant.ts
Normal 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}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user