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. Запуск проекта
|
### 3. Запуск проекта
|
||||||
```bash
|
```bash
|
||||||
npm install
|
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
|
```bash
|
||||||
npm run dev
|
npm run build # frontend-only production bundle
|
||||||
# or
|
# or
|
||||||
yarn dev
|
npm run build:full # full system development bundle
|
||||||
# or
|
|
||||||
pnpm dev
|
|
||||||
# or
|
|
||||||
bun dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
After building, start the chosen bundle with `npm run start` (frontend-only) or `npm run start:full`.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|||||||
@ -1,7 +1,18 @@
|
|||||||
import type { NextConfig } from "next";
|
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 = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
env: {
|
||||||
|
FUNNEL_BUILD_VARIANT: buildVariant,
|
||||||
|
NEXT_PUBLIC_FUNNEL_BUILD_VARIANT: buildVariant,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
13
package.json
13
package.json
@ -3,13 +3,18 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack",
|
"dev": "npm run dev:full",
|
||||||
"build": "next build --turbopack",
|
"dev:frontend": "node ./scripts/run-with-variant.mjs dev frontend -- --turbopack",
|
||||||
"start": "next start",
|
"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",
|
"lint": "eslint",
|
||||||
"bake:funnels": "node scripts/bake-funnels.mjs",
|
"bake:funnels": "node scripts/bake-funnels.mjs",
|
||||||
"import:funnels": "node scripts/import-funnels-to-db.mjs",
|
"import:funnels": "node scripts/import-funnels-to-db.mjs",
|
||||||
"prebuild": "npm run bake:funnels",
|
|
||||||
"storybook": "storybook dev -p 6006 --ci",
|
"storybook": "storybook dev -p 6006 --ci",
|
||||||
"build-storybook": "storybook build"
|
"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";
|
} from "@/lib/funnel/loadFunnelDefinition";
|
||||||
import { FunnelRuntime } from "@/components/funnel/FunnelRuntime";
|
import { FunnelRuntime } from "@/components/funnel/FunnelRuntime";
|
||||||
import type { FunnelDefinition } from "@/lib/funnel/types";
|
import type { FunnelDefinition } from "@/lib/funnel/types";
|
||||||
|
import { IS_FULL_SYSTEM_BUILD } from "@/lib/runtime/buildVariant";
|
||||||
|
|
||||||
// Функция для загрузки воронки из базы данных напрямую
|
// Функция для загрузки воронки из базы данных напрямую
|
||||||
async function loadFunnelFromDatabase(funnelId: string): Promise<FunnelDefinition | null> {
|
async function loadFunnelFromDatabase(funnelId: string): Promise<FunnelDefinition | null> {
|
||||||
|
if (!IS_FULL_SYSTEM_BUILD) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Импортируем модели напрямую вместо HTTP запроса
|
// Импортируем модели напрямую вместо HTTP запроса
|
||||||
const { default: connectMongoDB } = await import('@/lib/mongodb');
|
const { default: connectMongoDB } = await import('@/lib/mongodb');
|
||||||
|
|||||||
@ -5,9 +5,14 @@ import {
|
|||||||
peekBakedFunnelDefinition,
|
peekBakedFunnelDefinition,
|
||||||
} from "@/lib/funnel/loadFunnelDefinition";
|
} from "@/lib/funnel/loadFunnelDefinition";
|
||||||
import type { FunnelDefinition } from "@/lib/funnel/types";
|
import type { FunnelDefinition } from "@/lib/funnel/types";
|
||||||
|
import { IS_FULL_SYSTEM_BUILD } from "@/lib/runtime/buildVariant";
|
||||||
|
|
||||||
// Функция для загрузки воронки из базы данных
|
// Функция для загрузки воронки из базы данных
|
||||||
async function loadFunnelFromDatabase(funnelId: string): Promise<FunnelDefinition | null> {
|
async function loadFunnelFromDatabase(funnelId: string): Promise<FunnelDefinition | null> {
|
||||||
|
if (!IS_FULL_SYSTEM_BUILD) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Пытаемся загрузить из базы данных через API
|
// Пытаемся загрузить из базы данных через API
|
||||||
const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/funnels/by-funnel-id/${funnelId}`, {
|
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 { IS_FULL_SYSTEM_BUILD } from "@/lib/runtime/buildVariant";
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
export default async function FunnelBuilderPage() {
|
||||||
import { BuilderProvider } from "@/lib/admin/builder/context";
|
if (!IS_FULL_SYSTEM_BUILD) {
|
||||||
import {
|
notFound();
|
||||||
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
|
const { default: FunnelBuilderPageClient } = await import(
|
||||||
if (error) {
|
"./FunnelBuilderPageClient"
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return <FunnelBuilderPageClient />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,496 +1,14 @@
|
|||||||
"use client";
|
import { notFound } from "next/navigation";
|
||||||
|
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { IS_FULL_SYSTEM_BUILD } from "@/lib/runtime/buildVariant";
|
||||||
import Link from 'next/link';
|
export default async function AdminCatalogPage() {
|
||||||
import { useRouter } from 'next/navigation';
|
if (!IS_FULL_SYSTEM_BUILD) {
|
||||||
import { Button } from '@/components/ui/button';
|
notFound();
|
||||||
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 {
|
const { default: AdminCatalogPageClient } = await import(
|
||||||
_id: string;
|
"./AdminCatalogPageClient"
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return <AdminCatalogPageClient />;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import connectMongoDB from '@/lib/mongodb';
|
|
||||||
import FunnelModel from '@/lib/models/Funnel';
|
import { adminApiDisabledResponse, isAdminApiEnabled } from '@/lib/runtime/adminApi';
|
||||||
import FunnelHistoryModel from '@/lib/models/FunnelHistory';
|
|
||||||
|
|
||||||
interface RouteParams {
|
interface RouteParams {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
@ -11,8 +10,22 @@ interface RouteParams {
|
|||||||
|
|
||||||
// POST /api/funnels/[id]/duplicate - создать копию воронки
|
// POST /api/funnels/[id]/duplicate - создать копию воронки
|
||||||
export async function POST(request: NextRequest, { params }: RouteParams) {
|
export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||||
|
if (!isAdminApiEnabled()) {
|
||||||
|
return adminApiDisabledResponse();
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
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();
|
await connectMongoDB();
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
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 {
|
interface RouteParams {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
@ -10,8 +10,20 @@ interface RouteParams {
|
|||||||
|
|
||||||
// GET /api/funnels/[id]/history - получить историю изменений воронки
|
// GET /api/funnels/[id]/history - получить историю изменений воронки
|
||||||
export async function GET(request: NextRequest, { params }: RouteParams) {
|
export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||||
|
if (!isAdminApiEnabled()) {
|
||||||
|
return adminApiDisabledResponse();
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
const [
|
||||||
|
{ default: connectMongoDB },
|
||||||
|
{ default: FunnelHistoryModel },
|
||||||
|
] = await Promise.all([
|
||||||
|
import('@/lib/mongodb'),
|
||||||
|
import('@/lib/models/FunnelHistory'),
|
||||||
|
]);
|
||||||
|
|
||||||
await connectMongoDB();
|
await connectMongoDB();
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
@ -55,8 +67,20 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
|
|
||||||
// POST /api/funnels/[id]/history - создать новую запись в истории
|
// POST /api/funnels/[id]/history - создать новую запись в истории
|
||||||
export async function POST(request: NextRequest, { params }: RouteParams) {
|
export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||||
|
if (!isAdminApiEnabled()) {
|
||||||
|
return adminApiDisabledResponse();
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
const [
|
||||||
|
{ default: connectMongoDB },
|
||||||
|
{ default: FunnelHistoryModel },
|
||||||
|
] = await Promise.all([
|
||||||
|
import('@/lib/mongodb'),
|
||||||
|
import('@/lib/models/FunnelHistory'),
|
||||||
|
]);
|
||||||
|
|
||||||
await connectMongoDB();
|
await connectMongoDB();
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import connectMongoDB from '@/lib/mongodb';
|
|
||||||
import FunnelModel from '@/lib/models/Funnel';
|
import { adminApiDisabledResponse, isAdminApiEnabled } from '@/lib/runtime/adminApi';
|
||||||
import FunnelHistoryModel from '@/lib/models/FunnelHistory';
|
|
||||||
import type { FunnelDefinition } from '@/lib/funnel/types';
|
import type { FunnelDefinition } from '@/lib/funnel/types';
|
||||||
|
|
||||||
interface RouteParams {
|
interface RouteParams {
|
||||||
@ -14,8 +13,17 @@ interface RouteParams {
|
|||||||
|
|
||||||
// GET /api/funnels/[id] - получить конкретную воронку
|
// GET /api/funnels/[id] - получить конкретную воронку
|
||||||
export async function GET(request: NextRequest, { params }: RouteParams) {
|
export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||||
|
if (!isAdminApiEnabled()) {
|
||||||
|
return adminApiDisabledResponse();
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
|
const [{ default: connectMongoDB }, { default: FunnelModel }] = await Promise.all([
|
||||||
|
import('@/lib/mongodb'),
|
||||||
|
import('@/lib/models/Funnel'),
|
||||||
|
]);
|
||||||
|
|
||||||
await connectMongoDB();
|
await connectMongoDB();
|
||||||
|
|
||||||
const funnel = await FunnelModel.findById(id);
|
const funnel = await FunnelModel.findById(id);
|
||||||
@ -51,8 +59,22 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
|
|
||||||
// PUT /api/funnels/[id] - обновить воронку
|
// PUT /api/funnels/[id] - обновить воронку
|
||||||
export async function PUT(request: NextRequest, { params }: RouteParams) {
|
export async function PUT(request: NextRequest, { params }: RouteParams) {
|
||||||
|
if (!isAdminApiEnabled()) {
|
||||||
|
return adminApiDisabledResponse();
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
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();
|
await connectMongoDB();
|
||||||
|
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
@ -164,8 +186,22 @@ export async function PUT(request: NextRequest, { params }: RouteParams) {
|
|||||||
|
|
||||||
// DELETE /api/funnels/[id] - удалить воронку
|
// DELETE /api/funnels/[id] - удалить воронку
|
||||||
export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
export async function DELETE(request: NextRequest, { params }: RouteParams) {
|
||||||
|
if (!isAdminApiEnabled()) {
|
||||||
|
return adminApiDisabledResponse();
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
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();
|
await connectMongoDB();
|
||||||
|
|
||||||
const funnel = await FunnelModel.findById(id);
|
const funnel = await FunnelModel.findById(id);
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
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 {
|
interface RouteParams {
|
||||||
params: Promise<{
|
params: Promise<{
|
||||||
@ -12,8 +12,17 @@ interface RouteParams {
|
|||||||
// Этот endpoint обеспечивает совместимость с существующим кодом, который ожидает
|
// Этот endpoint обеспечивает совместимость с существующим кодом, который ожидает
|
||||||
// загрузку воронки по funnel ID из JSON файлов
|
// загрузку воронки по funnel ID из JSON файлов
|
||||||
export async function GET(request: NextRequest, { params }: RouteParams) {
|
export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||||
|
if (!isAdminApiEnabled()) {
|
||||||
|
return adminApiDisabledResponse();
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { funnelId } = await params;
|
const { funnelId } = await params;
|
||||||
|
const [{ default: connectMongoDB }, { default: FunnelModel }] = await Promise.all([
|
||||||
|
import('@/lib/mongodb'),
|
||||||
|
import('@/lib/models/Funnel'),
|
||||||
|
]);
|
||||||
|
|
||||||
await connectMongoDB();
|
await connectMongoDB();
|
||||||
|
|
||||||
const funnel = await FunnelModel.findOne({
|
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
|
// PUT /api/funnels/by-funnel-id/[funnelId] - обновить воронку по funnel ID
|
||||||
export async function PUT(request: NextRequest, { params }: RouteParams) {
|
export async function PUT(request: NextRequest, { params }: RouteParams) {
|
||||||
|
if (!isAdminApiEnabled()) {
|
||||||
|
return adminApiDisabledResponse();
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { funnelId } = await params;
|
const { funnelId } = await params;
|
||||||
|
const [{ default: connectMongoDB }, { default: FunnelModel }] = await Promise.all([
|
||||||
|
import('@/lib/mongodb'),
|
||||||
|
import('@/lib/models/Funnel'),
|
||||||
|
]);
|
||||||
|
|
||||||
await connectMongoDB();
|
await connectMongoDB();
|
||||||
|
|
||||||
const funnelData = await request.json();
|
const funnelData = await request.json();
|
||||||
|
|||||||
@ -1,12 +1,20 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import connectMongoDB from '@/lib/mongodb';
|
|
||||||
import FunnelModel from '@/lib/models/Funnel';
|
import { adminApiDisabledResponse, isAdminApiEnabled } from '@/lib/runtime/adminApi';
|
||||||
import FunnelHistoryModel from '@/lib/models/FunnelHistory';
|
|
||||||
import type { FunnelDefinition } from '@/lib/funnel/types';
|
import type { FunnelDefinition } from '@/lib/funnel/types';
|
||||||
|
|
||||||
// GET /api/funnels - получить список всех воронок
|
// GET /api/funnels - получить список всех воронок
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
|
if (!isAdminApiEnabled()) {
|
||||||
|
return adminApiDisabledResponse();
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const [{ default: connectMongoDB }, { default: FunnelModel }] = await Promise.all([
|
||||||
|
import('@/lib/mongodb'),
|
||||||
|
import('@/lib/models/Funnel'),
|
||||||
|
]);
|
||||||
|
|
||||||
await connectMongoDB();
|
await connectMongoDB();
|
||||||
|
|
||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
@ -72,7 +80,21 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
// POST /api/funnels - создать новую воронку
|
// POST /api/funnels - создать новую воронку
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
|
if (!isAdminApiEnabled()) {
|
||||||
|
return adminApiDisabledResponse();
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const [
|
||||||
|
{ default: connectMongoDB },
|
||||||
|
{ default: FunnelModel },
|
||||||
|
{ default: FunnelHistoryModel },
|
||||||
|
] = await Promise.all([
|
||||||
|
import('@/lib/mongodb'),
|
||||||
|
import('@/lib/models/Funnel'),
|
||||||
|
import('@/lib/models/FunnelHistory'),
|
||||||
|
]);
|
||||||
|
|
||||||
await connectMongoDB();
|
await connectMongoDB();
|
||||||
|
|
||||||
const body = await request.json();
|
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