commit
94ce6da68e
@ -1,659 +0,0 @@
|
||||
# 🔍 ГЛУБОКИЙ АНАЛИЗ ПРОЕКТА - НАЙДЕННЫЕ ПРОБЛЕМЫ
|
||||
|
||||
## 📊 ОБЩАЯ СТАТИСТИКА:
|
||||
- **Всего строк кода:** ~21,000
|
||||
- **Тестов:** 0 (!)
|
||||
- **Самые большие файлы:** 692, 617, 515 строк
|
||||
- **Console.log/error:** 21 файлов
|
||||
- **Process.env usage:** 7 файлов
|
||||
|
||||
---
|
||||
|
||||
## 🔴 КРИТИЧЕСКИЕ ПРОБЛЕМЫ:
|
||||
|
||||
### 1. ❌ ПОЛНОЕ ОТСУТСТВИЕ ТЕСТОВ
|
||||
**Статус:** 🔴 КРИТИЧНО
|
||||
|
||||
```bash
|
||||
# Найдено тестов: 0
|
||||
find src -name "*.test.ts" -o -name "*.test.tsx" | wc -l
|
||||
# Output: 0
|
||||
```
|
||||
|
||||
**Проблема:**
|
||||
- Нет unit тестов
|
||||
- Нет integration тестов
|
||||
- Нет e2e тестов
|
||||
- 21,000 строк кода без покрытия
|
||||
|
||||
**Риски:**
|
||||
- ❌ Регрессии не обнаруживаются
|
||||
- ❌ Рефакторинг опасен
|
||||
- ❌ Сложно онбординг новых разработчиков
|
||||
- ❌ Баги попадают в production
|
||||
|
||||
**Рекомендации:**
|
||||
```typescript
|
||||
// Приоритет 1: Критичная логика
|
||||
src/lib/funnel/navigation.ts // 🔴 Условная навигация
|
||||
src/lib/admin/builder/validation.ts // 🔴 Валидация воронок
|
||||
src/lib/funnel/screenRenderer.tsx // 🔴 Рендеринг экранов
|
||||
|
||||
// Приоритет 2: API endpoints
|
||||
src/app/api/**/*.ts // 🟡 Все API routes
|
||||
|
||||
// Приоритет 3: UI компоненты
|
||||
src/components/funnel/templates/** // 🟢 Templates
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 🔴 МОНСТР-ФАЙЛЫ НЕ РАЗБИТЫ
|
||||
|
||||
**Топ-3 проблемных файла:**
|
||||
|
||||
#### **ScreenVariantsConfig.tsx - 692 строки**
|
||||
```
|
||||
Функции:
|
||||
- ensureCondition
|
||||
- VariantOverridesEditor
|
||||
- ScreenVariantsConfig
|
||||
- Множество внутренней логики
|
||||
|
||||
Должно быть разбито на:
|
||||
├── hooks/
|
||||
│ ├── useVariantState.ts
|
||||
│ └── useVariantValidation.ts
|
||||
├── components/
|
||||
│ ├── VariantConditionEditor.tsx
|
||||
│ ├── VariantOverridesEditor.tsx
|
||||
│ ├── VariantList.tsx
|
||||
│ └── VariantPanel.tsx
|
||||
└── ScreenVariantsConfig.tsx (orchestrator)
|
||||
```
|
||||
|
||||
#### **BuilderSidebar.tsx - 617 строк**
|
||||
```
|
||||
Проблема: Всё в одном файле
|
||||
- Funnel settings
|
||||
- Screen settings
|
||||
- Navigation
|
||||
- Variants
|
||||
- Validation
|
||||
|
||||
Решение: Уже созданы модули, но НЕ ИСПОЛЬЗУЮТСЯ!
|
||||
✅ FunnelSettingsPanel.tsx (80 строк)
|
||||
✅ ScreenSettingsPanel.tsx (110 строк)
|
||||
✅ NavigationPanel.tsx (190 строк)
|
||||
|
||||
❌ Но BuilderSidebar всё еще 617 строк!
|
||||
```
|
||||
|
||||
#### **TemplateConfig.tsx - 515 строк**
|
||||
```
|
||||
Проблема: Switch-case для всех templates
|
||||
Решение: Template-specific конфигураторы уже есть!
|
||||
|
||||
✅ InfoScreenConfig.tsx
|
||||
✅ DateScreenConfig.tsx
|
||||
✅ ListScreenConfig.tsx
|
||||
✅ FormScreenConfig.tsx
|
||||
|
||||
❌ Но всё равно огромный switch в TemplateConfig
|
||||
```
|
||||
|
||||
**Метрика сложности:**
|
||||
```
|
||||
> 500 строк = 🔴 Требует немедленной разбивки
|
||||
> 300 строк = 🟡 Желательна разбивка
|
||||
< 300 строк = 🟢 Приемлемо
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 🟡 ОТСУТСТВИЕ ЛОГИРОВАНИЯ И МОНИТОРИНГА
|
||||
|
||||
**Проблема:**
|
||||
```typescript
|
||||
// ❌ Console.log в production коде
|
||||
console.log('✅ MongoDB connected successfully');
|
||||
console.error('Error rendering preview:', error);
|
||||
|
||||
// Нет structured logging
|
||||
// Нет error tracking (Sentry, etc.)
|
||||
// Нет performance monitoring
|
||||
```
|
||||
|
||||
**Найдено 21 файлов с console.log/error:**
|
||||
- API routes: 10+ файлов
|
||||
- Components: 5+ файлов
|
||||
- Hooks: 3+ файла
|
||||
|
||||
**Решение:**
|
||||
```typescript
|
||||
// lib/logger.ts
|
||||
export const logger = {
|
||||
info: (message: string, meta?: object) => {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(`[INFO] ${message}`, meta);
|
||||
}
|
||||
// В production -> send to logging service
|
||||
},
|
||||
error: (message: string, error: Error, meta?: object) => {
|
||||
console.error(`[ERROR] ${message}`, error, meta);
|
||||
// Send to Sentry/Datadog/etc.
|
||||
},
|
||||
warn: (message: string, meta?: object) => {
|
||||
console.warn(`[WARN] ${message}`, meta);
|
||||
}
|
||||
};
|
||||
|
||||
// Использование:
|
||||
logger.error('Failed to fetch funnel', error, { funnelId, userId });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. 🟡 СЛАБАЯ ОБРАБОТКА ОШИБОК
|
||||
|
||||
**Проблема:**
|
||||
```typescript
|
||||
// ❌ Пустые catch блоки
|
||||
try {
|
||||
formData = JSON.parse(formDataJson);
|
||||
} catch {
|
||||
formData = {};
|
||||
}
|
||||
|
||||
// ❌ Только console.error
|
||||
catch (error) {
|
||||
console.error('Error loading images:', error);
|
||||
}
|
||||
|
||||
// ❌ Нет типизации ошибок
|
||||
catch (error) {
|
||||
// error: unknown - теряем type safety
|
||||
}
|
||||
```
|
||||
|
||||
**Найдено 40+ catch блоков:**
|
||||
- 15 с только console.error
|
||||
- 8 с пустым catch {}
|
||||
- Остальные с минимальной обработкой
|
||||
|
||||
**Решение:**
|
||||
```typescript
|
||||
// lib/errors.ts
|
||||
export class FunnelError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public code: string,
|
||||
public statusCode: number = 500,
|
||||
public meta?: object
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'FunnelError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ValidationError extends FunnelError {
|
||||
constructor(message: string, meta?: object) {
|
||||
super(message, 'VALIDATION_ERROR', 400, meta);
|
||||
}
|
||||
}
|
||||
|
||||
// Использование:
|
||||
try {
|
||||
await saveFunnel(data);
|
||||
} catch (error) {
|
||||
if (error instanceof ValidationError) {
|
||||
return NextResponse.json(
|
||||
{ error: error.message, code: error.code },
|
||||
{ status: error.statusCode }
|
||||
);
|
||||
}
|
||||
|
||||
logger.error('Unexpected error', error as Error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. 🟡 ОТСУТСТВИЕ ENV VALIDATION
|
||||
|
||||
**Проблема:**
|
||||
```typescript
|
||||
// ❌ Прямое использование без валидации
|
||||
const MONGODB_URI = process.env.MONGODB_URI!;
|
||||
|
||||
// Что если переменная не задана?
|
||||
// Что если формат неправильный?
|
||||
// Ошибка обнаружится только в runtime!
|
||||
```
|
||||
|
||||
**Найдено использование env в 7 файлах:**
|
||||
- `MONGODB_URI`
|
||||
- `NEXT_PUBLIC_*`
|
||||
- `NODE_ENV`
|
||||
- Никакой валидации при старте!
|
||||
|
||||
**Решение:**
|
||||
```typescript
|
||||
// lib/env.ts
|
||||
import { z } from 'zod';
|
||||
|
||||
const envSchema = z.object({
|
||||
MONGODB_URI: z.string().url().min(1),
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']),
|
||||
NEXT_PUBLIC_API_URL: z.string().url().optional(),
|
||||
// ... остальные переменные
|
||||
});
|
||||
|
||||
export const env = envSchema.parse({
|
||||
MONGODB_URI: process.env.MONGODB_URI,
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
|
||||
});
|
||||
|
||||
// Использование:
|
||||
import { env } from '@/lib/env';
|
||||
const conn = await mongoose.connect(env.MONGODB_URI);
|
||||
```
|
||||
|
||||
**Преимущества:**
|
||||
- ✅ Ошибки обнаруживаются при старте
|
||||
- ✅ Type-safe доступ к env vars
|
||||
- ✅ Автокомплит в IDE
|
||||
- ✅ Документация через zod schema
|
||||
|
||||
---
|
||||
|
||||
### 6. 🟢 ОТСУТСТВИЕ API CLIENT СЛОЯ
|
||||
|
||||
**Проблема:**
|
||||
```typescript
|
||||
// ❌ Fetch разбросан по компонентам
|
||||
const response = await fetch('/api/funnels', { method: 'POST', ... });
|
||||
const response = await fetch(`/api/funnels/${id}`, { method: 'PUT', ... });
|
||||
const response = await fetch(`/api/funnels/${id}`, { method: 'DELETE', ... });
|
||||
|
||||
// Дублирование логики:
|
||||
// - error handling
|
||||
// - headers
|
||||
// - JSON parsing
|
||||
// - типизация
|
||||
```
|
||||
|
||||
**Решение:**
|
||||
```typescript
|
||||
// lib/api/client.ts
|
||||
class ApiClient {
|
||||
private baseUrl = '/api';
|
||||
|
||||
private async request<T>(
|
||||
endpoint: string,
|
||||
options?: RequestInit
|
||||
): Promise<T> {
|
||||
const url = `${this.baseUrl}${endpoint}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new ApiError(error.message, response.status);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
logger.error('API request failed', error as Error, { endpoint });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
funnels = {
|
||||
list: () => this.request<Funnel[]>('/funnels'),
|
||||
get: (id: string) => this.request<Funnel>(`/funnels/${id}`),
|
||||
create: (data: CreateFunnelDto) =>
|
||||
this.request<Funnel>('/funnels', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
update: (id: string, data: UpdateFunnelDto) =>
|
||||
this.request<Funnel>(`/funnels/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
delete: (id: string) =>
|
||||
this.request<void>(`/funnels/${id}`, { method: 'DELETE' }),
|
||||
};
|
||||
}
|
||||
|
||||
export const api = new ApiClient();
|
||||
|
||||
// Использование:
|
||||
const funnels = await api.funnels.list();
|
||||
const funnel = await api.funnels.get(id);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. 🟢 НЕДОСТАТОЧНАЯ ТИПИЗАЦИЯ API
|
||||
|
||||
**Проблема:**
|
||||
```typescript
|
||||
// ❌ API routes без типизации запросов/ответов
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json(); // any
|
||||
// ...
|
||||
}
|
||||
|
||||
// ❌ Нет shared типов между frontend и backend
|
||||
// ❌ Нет валидации входных данных
|
||||
```
|
||||
|
||||
**Решение:**
|
||||
```typescript
|
||||
// lib/api/schemas.ts
|
||||
import { z } from 'zod';
|
||||
|
||||
export const CreateFunnelSchema = z.object({
|
||||
meta: z.object({
|
||||
id: z.string().min(1).max(100),
|
||||
title: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
}),
|
||||
screens: z.array(ScreenSchema).min(1),
|
||||
defaultTexts: z.object({
|
||||
nextButton: z.string().optional(),
|
||||
continueButton: z.string().optional(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
export type CreateFunnelDto = z.infer<typeof CreateFunnelSchema>;
|
||||
|
||||
// app/api/funnels/route.ts
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
// ✅ Валидация с zod
|
||||
const data = CreateFunnelSchema.parse(body);
|
||||
|
||||
// ✅ Типобезопасность
|
||||
const funnel = await createFunnel(data);
|
||||
|
||||
return NextResponse.json(funnel);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Validation error', details: error.errors },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 8. 🟡 PERFORMANCE: Нет индексов экранов
|
||||
|
||||
**Проблема в screenRenderer.tsx:**
|
||||
```typescript
|
||||
// ❌ O(n) поиск при каждом рендере
|
||||
const currentScreen = funnel.screens.find(s => s.id === currentScreenId);
|
||||
const nextScreen = funnel.screens.find(s => s.id === nextScreenId);
|
||||
|
||||
// При 50+ экранах = медленно
|
||||
// При навигации = много поисков
|
||||
```
|
||||
|
||||
**Решение:**
|
||||
```typescript
|
||||
// lib/funnel/FunnelRuntime.tsx
|
||||
const screenMap = useMemo(() => {
|
||||
return new Map(funnel.screens.map(s => [s.id, s]));
|
||||
}, [funnel.screens]);
|
||||
|
||||
// ✅ O(1) поиск
|
||||
const currentScreen = screenMap.get(currentScreenId);
|
||||
const nextScreen = screenMap.get(nextScreenId);
|
||||
```
|
||||
|
||||
**Улучшение:** ~50x быстрее при 50+ экранах
|
||||
|
||||
---
|
||||
|
||||
### 9. 🟢 ОТСУТСТВИЕ ДОКУМЕНТАЦИИ API
|
||||
|
||||
**Проблема:**
|
||||
```
|
||||
src/app/api/
|
||||
├── funnels/
|
||||
│ ├── route.ts // GET /api/funnels - что возвращает?
|
||||
│ ├── [id]/
|
||||
│ │ ├── route.ts // GET/PUT/DELETE - параметры?
|
||||
│ │ ├── duplicate/
|
||||
│ │ └── history/
|
||||
```
|
||||
|
||||
**Нет:**
|
||||
- Swagger/OpenAPI spec
|
||||
- JSDoc комментариев
|
||||
- Примеров запросов
|
||||
- Описания ошибок
|
||||
|
||||
**Решение:**
|
||||
```typescript
|
||||
/**
|
||||
* GET /api/funnels
|
||||
*
|
||||
* Получить список всех воронок
|
||||
*
|
||||
* Query params:
|
||||
* - page?: number (default: 1)
|
||||
* - limit?: number (default: 50, max: 100)
|
||||
* - search?: string
|
||||
*
|
||||
* Response: 200
|
||||
* {
|
||||
* funnels: Funnel[],
|
||||
* total: number,
|
||||
* page: number,
|
||||
* totalPages: number
|
||||
* }
|
||||
*
|
||||
* Errors:
|
||||
* - 500: Database connection failed
|
||||
*
|
||||
* @example
|
||||
* const response = await fetch('/api/funnels?page=1&limit=20');
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 10. 🟡 MAGIC NUMBERS И STRINGS
|
||||
|
||||
**Проблема:**
|
||||
```typescript
|
||||
// ❌ Magic numbers
|
||||
style={{ height: 750, width: 320 }}
|
||||
setTimeout(() => {}, 2000);
|
||||
const limit = 50;
|
||||
|
||||
// ❌ Magic strings
|
||||
if (screen.template === "list") { }
|
||||
font: "manrope"
|
||||
weight: "semiBold"
|
||||
```
|
||||
|
||||
**Решение:**
|
||||
```typescript
|
||||
// lib/constants.ts
|
||||
export const PREVIEW_DIMENSIONS = {
|
||||
WIDTH: 320,
|
||||
HEIGHT: 750,
|
||||
MOBILE_WIDTH: 375,
|
||||
} as const;
|
||||
|
||||
export const TIMEOUTS = {
|
||||
TOAST_DURATION: 2000,
|
||||
DEBOUNCE_INPUT: 500,
|
||||
API_REQUEST: 30000,
|
||||
} as const;
|
||||
|
||||
export const PAGINATION = {
|
||||
DEFAULT_LIMIT: 50,
|
||||
MAX_LIMIT: 100,
|
||||
DEFAULT_PAGE: 1,
|
||||
} as const;
|
||||
|
||||
// Использование:
|
||||
style={{
|
||||
height: PREVIEW_DIMENSIONS.HEIGHT,
|
||||
width: PREVIEW_DIMENSIONS.WIDTH
|
||||
}}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 ПРИОРИТИЗАЦИЯ ИСПРАВЛЕНИЙ:
|
||||
|
||||
### 🔴 ВЫСОКИЙ ПРИОРИТЕТ (немедленно):
|
||||
1. ✅ **Добавить ENV validation** (30 мин) - предотвратит runtime ошибки
|
||||
2. ✅ **Создать ApiClient** (2 часа) - унифицирует API вызовы
|
||||
3. ✅ **Добавить error types** (1 час) - улучшит error handling
|
||||
4. ✅ **Добавить logger** (1 час) - улучшит debugging
|
||||
|
||||
### 🟡 СРЕДНИЙ ПРИОРИТЕТ (на неделе):
|
||||
5. ✅ **Разбить ScreenVariantsConfig** (4 часа)
|
||||
6. ✅ **Использовать модули вместо BuilderSidebar** (2 часа)
|
||||
7. ✅ **Добавить screen Map для performance** (1 час)
|
||||
8. ✅ **Вынести magic numbers в константы** (2 часа)
|
||||
|
||||
### 🟢 НИЗКИЙ ПРИОРИТЕТ (на спринте):
|
||||
9. ✅ **Написать unit тесты** (2-3 дня)
|
||||
10. ✅ **Добавить API документацию** (1 день)
|
||||
11. ✅ **Добавить Zod validation для API** (1 день)
|
||||
|
||||
---
|
||||
|
||||
## 📊 МЕТРИКИ ПРОЕКТА:
|
||||
|
||||
### **Code Quality:**
|
||||
```
|
||||
├── TypeScript: ✅ Хорошо (strict mode)
|
||||
├── Linting: ✅ Настроен ESLint
|
||||
├── Formatting: ❓ Prettier не настроен?
|
||||
├── Tests: ❌ Отсутствуют
|
||||
└── Documentation: 🟡 Частично (README есть)
|
||||
```
|
||||
|
||||
### **Architecture:**
|
||||
```
|
||||
├── Component structure: 🟢 Хорошая
|
||||
├── Type safety: 🟢 Хорошая
|
||||
├── Code splitting: 🟡 Частичная
|
||||
├── Performance: 🟡 Можно улучшить
|
||||
└── Error handling: 🔴 Слабая
|
||||
```
|
||||
|
||||
### **Maintainability:**
|
||||
```
|
||||
├── File sizes: 🔴 Много больших файлов
|
||||
├── Complexity: 🟡 Высокая в некоторых местах
|
||||
├── Duplication: 🟢 Минимальная
|
||||
├── Dependencies: 🟢 Актуальные
|
||||
└── Documentation: 🟡 Недостаточная
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## ✅ ВЫПОЛНЕНО (из предыдущего отчета):
|
||||
- ✅ useDebounce hook
|
||||
- ✅ usePersistedState hook
|
||||
- ✅ Error Boundaries
|
||||
- ✅ Optimized validation
|
||||
- ✅ React.memo components
|
||||
- ✅ Memoized preview mocks
|
||||
- ✅ Module extraction (частично)
|
||||
|
||||
---
|
||||
|
||||
## 🎯 СЛЕДУЮЩИЕ ШАГИ:
|
||||
|
||||
### **Этап 1: Инфраструктура (1-2 дня)**
|
||||
```bash
|
||||
1. ENV validation с Zod
|
||||
2. Logger service
|
||||
3. Error types и handling
|
||||
4. API client слой
|
||||
```
|
||||
|
||||
### **Этап 2: Рефакторинг (3-5 дней)**
|
||||
```bash
|
||||
1. Разбить ScreenVariantsConfig
|
||||
2. Использовать модули sidebar
|
||||
3. Добавить screen Map
|
||||
4. Вынести константы
|
||||
```
|
||||
|
||||
### **Этап 3: Тестирование (1-2 недели)**
|
||||
```bash
|
||||
1. Setup test infrastructure
|
||||
2. Unit tests для critical logic
|
||||
3. Integration tests для API
|
||||
4. E2E tests для key flows
|
||||
```
|
||||
|
||||
### **Этап 4: Documentation (3-5 дней)**
|
||||
```bash
|
||||
1. API documentation (JSDoc/Swagger)
|
||||
2. Architecture diagrams
|
||||
3. Developer onboarding guide
|
||||
4. Contribution guidelines
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 💡 РЕКОМЕНДАЦИИ:
|
||||
|
||||
1. **Начните с инфраструктуры** - ENV validation и Logger предотвратят много проблем
|
||||
2. **Добавьте тесты постепенно** - начните с критичной логики (navigation, validation)
|
||||
3. **Разбивайте большие файлы** - используйте уже созданные модули
|
||||
4. **Документируйте API** - это поможет новым разработчикам
|
||||
5. **Мониторинг в production** - добавьте Sentry или аналог
|
||||
|
||||
---
|
||||
|
||||
## 📈 ОЖИДАЕМЫЕ УЛУЧШЕНИЯ:
|
||||
|
||||
После выполнения всех исправлений:
|
||||
|
||||
| Метрика | Сейчас | После |
|
||||
|---------|--------|-------|
|
||||
| Test Coverage | 0% | 70%+ |
|
||||
| Error Detection | Runtime | Build time |
|
||||
| Maintainability | 6/10 | 9/10 |
|
||||
| Performance | 7/10 | 9/10 |
|
||||
| Developer Experience | 7/10 | 10/10 |
|
||||
|
||||
---
|
||||
|
||||
**Проект в целом хороший, но есть критичные пробелы в инфраструктуре, тестировании и обработке ошибок!**
|
||||
@ -1,232 +0,0 @@
|
||||
# Build Variants - Руководство
|
||||
|
||||
Проект поддерживает два режима работы: **frontend** (без БД) и **full** (с MongoDB).
|
||||
|
||||
## Режимы работы
|
||||
|
||||
### 🎨 Frontend Mode (без БД)
|
||||
- Только статические JSON файлы воронок
|
||||
- Без админки и редактирования
|
||||
- Нет загрузки изображений
|
||||
- Быстрый старт без зависимостей
|
||||
|
||||
### 🚀 Full Mode (с MongoDB)
|
||||
- Полная функциональность админки
|
||||
- Редактирование воронок в реальном времени
|
||||
- Загрузка и хранение изображений
|
||||
- История изменений
|
||||
- Требует MongoDB подключение
|
||||
|
||||
## Команды запуска
|
||||
|
||||
### Development (разработка)
|
||||
|
||||
```bash
|
||||
# Frontend режим (без БД)
|
||||
npm run dev
|
||||
# или
|
||||
npm run dev:frontend
|
||||
|
||||
# Full режим (с MongoDB)
|
||||
npm run dev:full
|
||||
```
|
||||
|
||||
### Build (сборка)
|
||||
|
||||
```bash
|
||||
# Frontend режим
|
||||
npm run build
|
||||
# или
|
||||
npm run build:frontend
|
||||
|
||||
# Full режим
|
||||
npm run build:full
|
||||
```
|
||||
|
||||
### Production (продакшн)
|
||||
|
||||
```bash
|
||||
# Frontend режим
|
||||
npm run start
|
||||
# или
|
||||
npm run start:frontend
|
||||
|
||||
# Full режим
|
||||
npm run start:full
|
||||
```
|
||||
|
||||
## Как это работает
|
||||
|
||||
### Скрипт `run-with-variant.mjs`
|
||||
|
||||
Все команды используют скрипт `/scripts/run-with-variant.mjs`, который:
|
||||
|
||||
1. Принимает команду и вариант: `node run-with-variant.mjs dev full`
|
||||
2. Устанавливает environment переменные:
|
||||
- `FUNNEL_BUILD_VARIANT=full|frontend`
|
||||
- `NEXT_PUBLIC_FUNNEL_BUILD_VARIANT=full|frontend`
|
||||
3. Запускает Next.js с этими переменными
|
||||
|
||||
### Runtime проверки
|
||||
|
||||
В коде используется модуль `/src/lib/runtime/buildVariant.ts`:
|
||||
|
||||
```typescript
|
||||
import { IS_FRONTEND_ONLY_BUILD, IS_FULL_SYSTEM_BUILD } from '@/lib/runtime/buildVariant';
|
||||
|
||||
// В API endpoints
|
||||
if (IS_FRONTEND_ONLY_BUILD) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Not available in frontend mode' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Для условной логики
|
||||
if (IS_FULL_SYSTEM_BUILD) {
|
||||
// Код который работает только с БД
|
||||
}
|
||||
```
|
||||
|
||||
### Константы
|
||||
|
||||
```typescript
|
||||
import { BUILD_VARIANTS } from '@/lib/constants';
|
||||
|
||||
BUILD_VARIANTS.FRONTEND // 'frontend'
|
||||
BUILD_VARIANTS.FULL // 'full'
|
||||
```
|
||||
|
||||
## Environment файлы
|
||||
|
||||
### `.env.local` (НЕ включать build variant!)
|
||||
|
||||
```env
|
||||
# ❌ НЕ НАДО: NEXT_PUBLIC_FUNNEL_BUILD_VARIANT=full
|
||||
# Вместо этого используйте команды npm run dev:full / dev:frontend
|
||||
|
||||
# MongoDB (нужно только для full режима)
|
||||
MONGODB_URI=mongodb://localhost:27017/witlab-funnel
|
||||
|
||||
# Базовый URL
|
||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||
```
|
||||
|
||||
**Важно:** `NEXT_PUBLIC_FUNNEL_BUILD_VARIANT` НЕ должна быть в `.env.local`!
|
||||
Она устанавливается автоматически через команды.
|
||||
|
||||
### `.env.production`
|
||||
|
||||
```env
|
||||
# Только для production окружения
|
||||
NODE_ENV=production
|
||||
NEXT_PUBLIC_BASE_URL=https://your-domain.com
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
Все API endpoints автоматически проверяют режим работы:
|
||||
|
||||
### `/api/images/[filename]` - GET, DELETE
|
||||
- ✅ Full mode: возвращает изображения из MongoDB
|
||||
- ❌ Frontend mode: 403 Forbidden
|
||||
|
||||
### `/api/images` - GET
|
||||
- ✅ Full mode: список всех изображений
|
||||
- ❌ Frontend mode: 403 Forbidden
|
||||
|
||||
### `/api/images/upload` - POST
|
||||
- ✅ Full mode: загрузка изображений в MongoDB
|
||||
- ❌ Frontend mode: 403 Forbidden
|
||||
|
||||
### `/api/funnels/*`
|
||||
- ✅ Full mode: CRUD операции с воронками
|
||||
- ❌ Frontend mode: 403 Forbidden
|
||||
|
||||
## Типичные сценарии
|
||||
|
||||
### Локальная разработка с админкой
|
||||
|
||||
```bash
|
||||
# 1. Запустить MongoDB
|
||||
mongod --dbpath ./data
|
||||
|
||||
# 2. Запустить в full режиме
|
||||
npm run dev:full
|
||||
|
||||
# 3. Открыть http://localhost:3000/admin
|
||||
```
|
||||
|
||||
### Локальная разработка без БД
|
||||
|
||||
```bash
|
||||
# Просто запустить frontend режим
|
||||
npm run dev
|
||||
|
||||
# Или явно
|
||||
npm run dev:frontend
|
||||
```
|
||||
|
||||
### Production деплой (frontend only)
|
||||
|
||||
```bash
|
||||
# Собрать frontend версию
|
||||
npm run build:frontend
|
||||
|
||||
# Запустить
|
||||
npm run start:frontend
|
||||
```
|
||||
|
||||
### Production деплой (full stack)
|
||||
|
||||
```bash
|
||||
# Установить MONGODB_URI в .env.production
|
||||
echo "MONGODB_URI=mongodb://..." > .env.production
|
||||
|
||||
# Собрать full версию
|
||||
npm run build:full
|
||||
|
||||
# Запустить
|
||||
npm run start:full
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Проблема: "Image serving not available"
|
||||
|
||||
**Причина:** Запущен frontend режим, а используется API для изображений
|
||||
|
||||
**Решение:** Перезапустить в full режиме:
|
||||
```bash
|
||||
npm run dev:full
|
||||
```
|
||||
|
||||
### Проблема: "Cannot connect to MongoDB"
|
||||
|
||||
**Причина:** MongoDB не запущен или неправильный URI
|
||||
|
||||
**Решение:**
|
||||
1. Проверить что MongoDB запущен: `mongosh`
|
||||
2. Проверить MONGODB_URI в `.env.local`
|
||||
3. Убедиться что используется `dev:full`, не `dev`
|
||||
|
||||
### Проблема: Админка не работает
|
||||
|
||||
**Причина:** Запущен frontend режим
|
||||
|
||||
**Решение:**
|
||||
```bash
|
||||
npm run dev:full
|
||||
```
|
||||
|
||||
## Итоговые рекомендации
|
||||
|
||||
✅ **DO:**
|
||||
- Использовать команды `npm run dev:full` / `dev:frontend`
|
||||
- Держать `.env.local` без `NEXT_PUBLIC_FUNNEL_BUILD_VARIANT`
|
||||
- Проверять `IS_FRONTEND_ONLY_BUILD` в API endpoints
|
||||
|
||||
❌ **DON'T:**
|
||||
- Не добавлять `NEXT_PUBLIC_FUNNEL_BUILD_VARIANT` в `.env.local`
|
||||
- Не проверять `process.env.NEXT_PUBLIC_FUNNEL_BUILD_VARIANT` напрямую
|
||||
- Не смешивать логику frontend и full режимов
|
||||
@ -1,219 +0,0 @@
|
||||
# ✅ PERFORMANCE IMPROVEMENTS - ВЫПОЛНЕНО
|
||||
|
||||
## Исправленные проблемы (10/10):
|
||||
|
||||
### 1. ✅ useDebounce и usePersistedState hooks
|
||||
**Файлы:**
|
||||
- `/src/lib/admin/hooks/useDebounce.ts` - дебаунс для text inputs
|
||||
- `/src/lib/admin/hooks/usePersistedState.ts` - сохранение UI состояния
|
||||
|
||||
**Применение:**
|
||||
- Text inputs в BuilderSidebar теперь могут использовать debounce
|
||||
- Collapsed/expanded состояния сохраняются в sessionStorage
|
||||
|
||||
---
|
||||
|
||||
### 2. ✅ Error Boundaries
|
||||
**Файл:** `/src/components/admin/ErrorBoundary.tsx`
|
||||
|
||||
**Компоненты:**
|
||||
- `ErrorBoundary` - универсальный boundary
|
||||
- `BuilderErrorBoundary` - для компонентов билдера
|
||||
- `PreviewErrorBoundary` - для preview компонента
|
||||
|
||||
**Использование:**
|
||||
```tsx
|
||||
<BuilderErrorBoundary>
|
||||
<TemplateConfig />
|
||||
</BuilderErrorBoundary>
|
||||
|
||||
<PreviewErrorBoundary>
|
||||
<BuilderPreview />
|
||||
</PreviewErrorBoundary>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. ✅ Оптимизированная validation
|
||||
**Файл:** `BuilderSidebar.tsx`
|
||||
|
||||
**Было:**
|
||||
```typescript
|
||||
const validation = useMemo(() => validateBuilderState(state), [state]);
|
||||
```
|
||||
|
||||
**Стало:**
|
||||
```typescript
|
||||
const screenIds = useMemo(() => state.screens.map(s => s.id).join(','), [state.screens]);
|
||||
const validation = useMemo(
|
||||
() => validateBuilderState(state),
|
||||
[
|
||||
state.meta.id,
|
||||
state.meta.firstScreenId,
|
||||
screenIds,
|
||||
state.screens.length,
|
||||
]
|
||||
);
|
||||
```
|
||||
|
||||
**Улучшение:** Validation запускается только при изменении критичных полей, а не при каждом изменении state.
|
||||
|
||||
---
|
||||
|
||||
### 4. ✅ React.memo для компонентов
|
||||
**Файл:** `/src/components/admin/builder/Canvas/MemoizedComponents.tsx`
|
||||
|
||||
**Мемоизированы:**
|
||||
- `TemplateSummary`
|
||||
- `VariantSummary`
|
||||
- `TransitionRow`
|
||||
- `DropIndicator`
|
||||
|
||||
**Использование:**
|
||||
```tsx
|
||||
import { TemplateSummary, VariantSummary } from './MemoizedComponents';
|
||||
```
|
||||
|
||||
**Улучшение:** Компоненты списка не ре-рендерятся при изменении других экранов.
|
||||
|
||||
---
|
||||
|
||||
### 5. ✅ Мемоизированные моки в BuilderPreview
|
||||
**Файл:** `BuilderPreview.tsx`
|
||||
|
||||
**Было:**
|
||||
```typescript
|
||||
onContinue: () => {}, // Новая функция каждый раз
|
||||
onBack: () => {},
|
||||
screenProgress: { current: 1, total: 10 }, // Новый объект
|
||||
```
|
||||
|
||||
**Стало:**
|
||||
```typescript
|
||||
const MOCK_CALLBACKS = {
|
||||
onContinue: () => {},
|
||||
onBack: () => {},
|
||||
};
|
||||
const MOCK_PROGRESS = { current: 1, total: 10 };
|
||||
|
||||
// Используем в render
|
||||
onContinue: MOCK_CALLBACKS.onContinue,
|
||||
```
|
||||
|
||||
**Улучшение:** Моки создаются один раз, не вызывают лишних re-renders.
|
||||
|
||||
---
|
||||
|
||||
### 6. ✅ Разбивка компонентов (частично)
|
||||
**Созданы модули:**
|
||||
- `FunnelSettingsPanel` - настройки воронки
|
||||
- `ScreenSettingsPanel` - настройки экрана
|
||||
- `NavigationPanel` - навигация
|
||||
|
||||
**Статус:** Модули созданы, можно использовать вместо BuilderSidebar монолита.
|
||||
|
||||
---
|
||||
|
||||
### 7. ✅ Lazy loading (документировано)
|
||||
**Файл:** `/src/lib/admin/hooks/index.ts`
|
||||
|
||||
**Рекомендация для будущего:**
|
||||
```tsx
|
||||
const TemplateConfig = lazy(() => import("@/components/admin/builder/templates"));
|
||||
const ScreenVariantsConfig = lazy(() => import("../forms/ScreenVariantsConfig"));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 8. ✅ Оптимизация BuilderCanvas useCallback
|
||||
**Статус:** Проверены все useCallback
|
||||
|
||||
**Рекомендации:**
|
||||
- Убрать ненужные useCallback с пустыми зависимостями
|
||||
- Использовать useRef для стабильных функций
|
||||
- Мемоизировать только то, что реально передается в child компоненты
|
||||
|
||||
---
|
||||
|
||||
### 9. 🔄 Виртуализация списков (опционально)
|
||||
**Статус:** Документировано для будущего
|
||||
|
||||
**Когда нужно:** При 50+ экранах в воронке
|
||||
|
||||
**Библиотеки:**
|
||||
- `react-window`
|
||||
- `react-virtual`
|
||||
- `@tanstack/react-virtual`
|
||||
|
||||
---
|
||||
|
||||
### 10. ✅ Исправление глубоких сравнений
|
||||
**Статус:** Оптимизация validation решила большую часть
|
||||
|
||||
**Дополнительно:**
|
||||
- Validation мемоизируется по критичным полям
|
||||
- useCallback handlers не зависят от всего state
|
||||
|
||||
---
|
||||
|
||||
## Метрики улучшений:
|
||||
|
||||
| Проблема | Статус | Влияние |
|
||||
|----------|--------|---------|
|
||||
| Debounce для форм | ✅ Готов к использованию | 🟢 Высокое |
|
||||
| Validation оптимизация | ✅ Внедрено | 🟢 Высокое |
|
||||
| React.memo компоненты | ✅ Готовы | 🟡 Среднее |
|
||||
| Мемоизация моков | ✅ Внедрено | 🟡 Среднее |
|
||||
| Error Boundaries | ✅ Готовы | 🟡 Среднее |
|
||||
| Разбивка компонентов | 🔄 Частично | 🟢 Высокое (maintainability) |
|
||||
| Lazy loading | 📝 Документировано | 🟢 Высокое (initial load) |
|
||||
| Оптимизация useCallback | ✅ Проверено | 🟢 Низкое |
|
||||
| Виртуализация | 📝 Будущее | 🟡 Среднее (при >50 экранах) |
|
||||
| Глубокие сравнения | ✅ Исправлено | 🟡 Среднее |
|
||||
|
||||
---
|
||||
|
||||
## Следующие шаги:
|
||||
|
||||
### Немедленно (можно применить сразу):
|
||||
1. Использовать `MemoizedComponents` в `BuilderCanvas`
|
||||
2. Обернуть критичные компоненты в Error Boundaries
|
||||
3. Применить `useDebounce` для text inputs в формах
|
||||
|
||||
### Скоро (когда будет время):
|
||||
1. Полностью заменить `BuilderSidebar` на модули
|
||||
2. Добавить lazy loading для тяжелых компонентов
|
||||
3. Использовать `usePersistedState` для collapsed sections
|
||||
|
||||
### В будущем (при необходимости):
|
||||
1. Виртуализация списка экранов (при >50 экранах)
|
||||
2. Code splitting для admin bundle
|
||||
3. Service Worker для кэширования
|
||||
|
||||
---
|
||||
|
||||
## Готовые к использованию утилиты:
|
||||
|
||||
### Hooks:
|
||||
```tsx
|
||||
import { useDebounce, useDebouncedCallback } from '@/lib/admin/hooks/useDebounce';
|
||||
import { usePersistedState } from '@/lib/admin/hooks/usePersistedState';
|
||||
```
|
||||
|
||||
### Error Boundaries:
|
||||
```tsx
|
||||
import { BuilderErrorBoundary, PreviewErrorBoundary } from '@/components/admin/ErrorBoundary';
|
||||
```
|
||||
|
||||
### Memoized Components:
|
||||
```tsx
|
||||
import { TemplateSummary, VariantSummary, TransitionRow, DropIndicator } from './Canvas/MemoizedComponents';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Результат:
|
||||
✅ **Все 10 проблем решены или задокументированы**
|
||||
✅ **Создана инфраструктура для performance оптимизаций**
|
||||
✅ **Проект собирается без ошибок**
|
||||
✅ **TypeScript компиляция чистая**
|
||||
218
README-ADMIN.md
218
README-ADMIN.md
@ -1,218 +0,0 @@
|
||||
# WitLab Funnel Admin - Полноценная админка с MongoDB
|
||||
|
||||
## Что реализовано
|
||||
|
||||
### ✅ База данных MongoDB
|
||||
- **Подключение через Mongoose** с автоматическим переподключением
|
||||
- **Модели для воронок** с полной валидацией структуры данных
|
||||
- **История изменений** для системы undo/redo
|
||||
- **Индексы для производительности** поиска и фильтрации
|
||||
|
||||
### ✅ API Routes
|
||||
- `GET /api/funnels` - список воронок с пагинацией и фильтрами
|
||||
- `POST /api/funnels` - создание новой воронки
|
||||
- `GET /api/funnels/[id]` - получение конкретной воронки
|
||||
- `PUT /api/funnels/[id]` - обновление воронки
|
||||
- `DELETE /api/funnels/[id]` - удаление воронки (только черновики)
|
||||
- `POST /api/funnels/[id]/duplicate` - дублирование воронки
|
||||
- `GET/POST /api/funnels/[id]/history` - работа с историей изменений
|
||||
- `GET /api/funnels/by-funnel-id/[funnelId]` - загрузка по funnel ID (для совместимости)
|
||||
|
||||
### ✅ Каталог воронок `/admin`
|
||||
- **Список всех воронок** с поиском, фильтрацией и сортировкой
|
||||
- **Создание новых воронок** с базовым шаблоном
|
||||
- **Дублирование существующих** воронок
|
||||
- **Удаление черновиков** (опубликованные можно только архивировать)
|
||||
- **Статистика использования** (просмотры, завершения)
|
||||
- **Статусы**: draft, published, archived
|
||||
|
||||
### ✅ Редактор воронок `/admin/builder/[id]`
|
||||
- **Полноценный билдер** интегрированный с существующей архитектурой
|
||||
- **Автосохранение** изменений в базу данных
|
||||
- **Система публикации** с контролем версий
|
||||
- **Топ бар** с информацией о воронке и кнопками действий
|
||||
- **Экспорт/импорт JSON** для резервного копирования
|
||||
|
||||
### ✅ Система undo/redo
|
||||
- **История действий** с глубиной до 50 шагов
|
||||
- **Базовые точки** при сохранении в БД (после сохранения нельзя откатить)
|
||||
- **Несохраненные изменения** отслеживаются отдельно
|
||||
- **Автоматическая очистка** старых записей истории
|
||||
|
||||
### ✅ Интеграция с существующим кодом
|
||||
- **Обратная совместимость** с JSON файлами
|
||||
- **Приоритет базы данных** при загрузке воронок
|
||||
- **Автоматическое увеличение статистики** при просмотрах
|
||||
- **Единый API** для всех компонентов системы
|
||||
|
||||
## Настройка окружения
|
||||
|
||||
### 1. MongoDB Connection
|
||||
Создайте `.env.local` файл:
|
||||
```bash
|
||||
# MongoDB
|
||||
MONGODB_URI=mongodb://localhost:27017/witlab-funnel
|
||||
# или для MongoDB Atlas:
|
||||
# MONGODB_URI=mongodb+srv://username:password@cluster.mongodb.net/witlab-funnel
|
||||
|
||||
# Base URL (для server-side запросов)
|
||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||
```
|
||||
|
||||
### 2. Установка MongoDB локально
|
||||
```bash
|
||||
# macOS (через Homebrew)
|
||||
brew install mongodb-community
|
||||
brew services start mongodb-community
|
||||
|
||||
# Или используйте MongoDB Atlas (облако)
|
||||
```
|
||||
|
||||
### 3. Запуск проекта
|
||||
```bash
|
||||
npm install
|
||||
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`).
|
||||
|
||||
## Использование
|
||||
|
||||
### Создание новой воронки
|
||||
1. Перейдите на `/admin`
|
||||
2. Нажмите "Создать воронку"
|
||||
3. Автоматически откроется билдер с базовым шаблоном
|
||||
4. Редактируйте экраны в правом сайдбаре
|
||||
5. Сохраняйте изменения кнопкой "Сохранить"
|
||||
6. Публикуйте готовую воронку кнопкой "Опубликовать"
|
||||
|
||||
### Редактирование существующей воронки
|
||||
1. В каталоге найдите нужную воронку
|
||||
2. Нажмите иконку "Редактировать" (карандаш)
|
||||
3. Внесите изменения в билдере
|
||||
4. Сохраните или опубликуйте
|
||||
|
||||
### Просмотр воронки
|
||||
1. Нажмите иконку "Просмотр" (глаз) в каталоге
|
||||
2. Или перейдите на `/{funnelId}` напрямую
|
||||
|
||||
### Дублирование воронки
|
||||
1. Нажмите иконку "Дублировать" (копия)
|
||||
2. Создастся копия со статусом "Черновик"
|
||||
3. Можете отредактировать и опубликовать
|
||||
|
||||
## Архитектура
|
||||
|
||||
### Модели данных
|
||||
```typescript
|
||||
// Основная модель воронки
|
||||
interface IFunnel {
|
||||
funnelData: FunnelDefinition; // JSON структура воронки
|
||||
name: string; // Человеко-читаемое имя
|
||||
status: 'draft' | 'published' | 'archived';
|
||||
version: number; // Автоинкремент при изменениях
|
||||
usage: { // Статистика
|
||||
totalViews: number;
|
||||
totalCompletions: number;
|
||||
};
|
||||
}
|
||||
|
||||
// История изменений
|
||||
interface IFunnelHistory {
|
||||
funnelId: string; // Связь с воронкой
|
||||
sessionId: string; // Сессия редактирования
|
||||
funnelSnapshot: FunnelDefinition; // Снимок состояния
|
||||
sequenceNumber: number; // Порядок в сессии
|
||||
isBaseline: boolean; // Сохранено в БД
|
||||
}
|
||||
```
|
||||
|
||||
### API Architecture
|
||||
- **RESTful API** с правильными HTTP методами
|
||||
- **Валидация данных** на уровне Mongoose схем
|
||||
- **Обработка ошибок** с понятными сообщениями
|
||||
- **Пагинация** для больших списков
|
||||
- **Фильтрация и поиск** по всем полям
|
||||
|
||||
### Frontend Architecture
|
||||
- **Server Components** для статической генерации
|
||||
- **Client Components** для интерактивности
|
||||
- **Единый API клиент** через fetch
|
||||
- **TypeScript типы** для всех данных
|
||||
- **Error Boundaries** для обработки ошибок
|
||||
|
||||
## Безопасность
|
||||
|
||||
### Текущие меры
|
||||
- **Валидация входных данных** на всех уровнях
|
||||
- **Проверка существования** ресурсов перед операциями
|
||||
- **Ограничения на удаление** опубликованных воронок
|
||||
- **Санитизация пользовательского ввода**
|
||||
|
||||
### Будущие улучшения
|
||||
- Аутентификация пользователей
|
||||
- Авторизация по ролям
|
||||
- Аудит лог действий
|
||||
- Rate limiting для API
|
||||
|
||||
## Производительность
|
||||
|
||||
### Текущая оптимизация
|
||||
- **MongoDB индексы** для быстрого поиска
|
||||
- **Пагинация** вместо загрузки всех записей
|
||||
- **Selective loading** - только нужные поля
|
||||
- **Connection pooling** для базы данных
|
||||
|
||||
### Мониторинг
|
||||
- **Логирование ошибок** в консоль
|
||||
- **Время выполнения** запросов отслеживается
|
||||
- **Размер истории** ограничен (100 записей на сессию)
|
||||
|
||||
## Миграция с JSON
|
||||
|
||||
Существующие JSON воронки продолжают работать автоматически:
|
||||
1. **Приоритет базы данных** - сначала поиск в MongoDB
|
||||
2. **Fallback на JSON** - если не найдено в базе
|
||||
3. **Импорт из JSON** - можно загрузить JSON в билдере
|
||||
4. **Экспорт в JSON** - для резервного копирования
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Ближайшие планы
|
||||
- [x] Основная функциональность админки
|
||||
- [x] Система undo/redo
|
||||
- [x] Интеграция с существующим кодом
|
||||
- [ ] Аутентификация пользователей
|
||||
- [ ] Collaborative editing
|
||||
- [ ] Advanced аналитика
|
||||
|
||||
### Долгосрочные цели
|
||||
- [ ] Multi-tenant архитектура
|
||||
- [ ] A/B тестирование воронок
|
||||
- [ ] Интеграция с внешними сервисами
|
||||
- [ ] Mobile app для мониторинга
|
||||
|
||||
## Техническая поддержка
|
||||
|
||||
### Логи и отладка
|
||||
```bash
|
||||
# Проверка подключения к MongoDB
|
||||
curl http://localhost:3000/api/funnels
|
||||
|
||||
# Просмотр логов в консоли разработчика
|
||||
# MongoDB connection logs в терминале
|
||||
```
|
||||
|
||||
### Частые проблемы
|
||||
1. **MongoDB not connected** - проверьте MONGODB_URI в .env.local
|
||||
2. **API errors** - проверьте сетевое соединение
|
||||
3. **Build errors** - убедитесь что все зависимости установлены
|
||||
|
||||
### Контакты
|
||||
- GitHub Issues для багрепортов
|
||||
- Документация в `/docs/`
|
||||
- Комментарии в коде для сложных частей
|
||||
|
||||
---
|
||||
|
||||
**Полноценная админка с MongoDB готова к использованию! 🚀**
|
||||
@ -1,170 +0,0 @@
|
||||
# ✅ Рефакторинг завершен успешно
|
||||
|
||||
## Выполненные задачи
|
||||
|
||||
### 1. ✅ ENV validation с Zod
|
||||
**Файл:** `/src/lib/env.ts`
|
||||
|
||||
- Создана схема валидации с Zod для всех environment переменных
|
||||
- Валидация происходит при запуске приложения
|
||||
- Понятные сообщения об ошибках при неправильных значениях
|
||||
- Типобезопасный доступ к переменным окружения
|
||||
|
||||
**Валидируемые переменные:**
|
||||
- `MONGODB_URI` - опциональная строка для подключения к БД
|
||||
- `NEXT_PUBLIC_FUNNEL_BUILD_VARIANT` - frontend | full
|
||||
- `NEXT_PUBLIC_BASE_URL` - базовый URL приложения
|
||||
- `NODE_ENV` - development | production | test
|
||||
|
||||
### 2. ✅ Screen Map для performance
|
||||
**Файл:** `/src/components/funnel/FunnelRuntime.tsx`
|
||||
|
||||
- Добавлен `useMemo` для создания Map экранов по ID
|
||||
- Поиск экранов теперь O(1) вместо O(n)
|
||||
- Улучшена производительность при навигации в больших воронках
|
||||
|
||||
```typescript
|
||||
const screenMap = useMemo(() => {
|
||||
const map = new Map<string, ScreenDefinition>();
|
||||
funnel.screens.forEach(screen => map.set(screen.id, screen));
|
||||
return map;
|
||||
}, [funnel.screens]);
|
||||
```
|
||||
|
||||
### 3. ✅ ScreenVariantsConfig разбит на модули
|
||||
**Директория:** `/src/components/admin/builder/forms/variants/`
|
||||
|
||||
Созданы файлы:
|
||||
- **types.ts** - типы для вариантов
|
||||
- **utils.ts** - утилиты (ensureCondition, и т.д.)
|
||||
- **VariantPanel.tsx** - панель управления одним вариантом
|
||||
- **VariantConditionEditor.tsx** - редактор условий
|
||||
- **VariantOverridesEditor.tsx** - редактор переопределений
|
||||
- **index.ts** - экспорты модуля
|
||||
|
||||
**Преимущества:**
|
||||
- Каждый компонент < 200 строк кода
|
||||
- Четкое разделение ответственности
|
||||
- Легко тестировать отдельные части
|
||||
- Переиспользуемые компоненты
|
||||
|
||||
### 4. ✅ Sidebar модули вместо монолита
|
||||
**Статус:** Готово к использованию
|
||||
|
||||
Модульная структура variants теперь используется в ScreenVariantsConfig:
|
||||
- Главный компонент управляет только состоянием
|
||||
- Логика условий и переопределений вынесена в отдельные модули
|
||||
- Улучшена читаемость и поддерживаемость
|
||||
|
||||
### 5. ✅ Вынесены все константы
|
||||
**Файл:** `/src/lib/constants.ts`
|
||||
|
||||
Все magic numbers и strings теперь в одном месте:
|
||||
|
||||
```typescript
|
||||
// Build варианты
|
||||
export const BUILD_VARIANTS = {
|
||||
FULL: 'full',
|
||||
FRONTEND: 'frontend',
|
||||
} as const;
|
||||
|
||||
// API endpoints
|
||||
export const API_ENDPOINTS = {
|
||||
IMAGES_UPLOAD: '/api/images/upload',
|
||||
RAW_IMAGE: '/api/raw-image',
|
||||
TEST_IMAGE: '/api/test-image',
|
||||
} as const;
|
||||
|
||||
// Preview размеры
|
||||
export const PREVIEW_DIMENSIONS = {
|
||||
WIDTH: 375,
|
||||
HEIGHT: 667,
|
||||
} as const;
|
||||
|
||||
// Database
|
||||
export const DB_COLLECTIONS = {
|
||||
FUNNELS: 'funnels',
|
||||
IMAGES: 'images',
|
||||
} as const;
|
||||
```
|
||||
|
||||
### 6. ✅ Обновлены импорты везде
|
||||
|
||||
Обновленные файлы:
|
||||
- `/src/components/admin/builder/layout/BuilderPreview.tsx` - PREVIEW_DIMENSIONS
|
||||
- `/src/lib/runtime/buildVariant.ts` - BUILD_VARIANTS, env
|
||||
- `/src/lib/mongodb.ts` - env, DB_COLLECTIONS
|
||||
- `/src/components/admin/builder/forms/ImageUpload.tsx` - BUILD_VARIANTS, env
|
||||
- `/src/app/[funnelId]/page.tsx` - BAKED_FUNNELS
|
||||
|
||||
### 7. ✅ Проверка сборки и lint
|
||||
|
||||
**Build:** ✅ Успешно
|
||||
```bash
|
||||
npm run build
|
||||
# ✓ Compiled successfully
|
||||
```
|
||||
|
||||
**Lint:** ✅ Без ошибок
|
||||
```bash
|
||||
npm run lint
|
||||
# No errors found
|
||||
```
|
||||
|
||||
## Архитектурные улучшения
|
||||
|
||||
### DRY (Don't Repeat Yourself)
|
||||
- Константы вынесены в единое место
|
||||
- Убрано дублирование magic numbers
|
||||
- Переиспользуемые модули вариантов
|
||||
|
||||
### Single Source of Truth
|
||||
- env переменные валидируются в одном месте
|
||||
- Константы определены централизованно
|
||||
- Типы для вариантов в отдельном файле
|
||||
|
||||
### Модульность
|
||||
- ScreenVariantsConfig разбит на 6 файлов
|
||||
- Каждый модуль отвечает за одну задачу
|
||||
- Легко добавлять новые функции
|
||||
|
||||
### Type Safety
|
||||
- Zod валидация для env
|
||||
- TypeScript типы для всех констант
|
||||
- Строгая типизация вариантов
|
||||
|
||||
## Статистика
|
||||
|
||||
**Создано файлов:** 7
|
||||
- `/src/lib/env.ts`
|
||||
- `/src/lib/constants.ts`
|
||||
- `/src/components/admin/builder/forms/variants/types.ts`
|
||||
- `/src/components/admin/builder/forms/variants/utils.ts`
|
||||
- `/src/components/admin/builder/forms/variants/VariantPanel.tsx`
|
||||
- `/src/components/admin/builder/forms/variants/VariantConditionEditor.tsx`
|
||||
- `/src/components/admin/builder/forms/variants/VariantOverridesEditor.tsx`
|
||||
|
||||
**Обновлено файлов:** 8
|
||||
- FunnelRuntime.tsx (Screen Map)
|
||||
- BuilderPreview.tsx (константы)
|
||||
- buildVariant.ts (env + константы)
|
||||
- mongodb.ts (env + константы)
|
||||
- ImageUpload.tsx (константы)
|
||||
- ScreenVariantsConfig.tsx (модули)
|
||||
- app/[funnelId]/page.tsx (константы)
|
||||
- variants/index.ts (экспорты)
|
||||
|
||||
**Удалено:** 1
|
||||
- ScreenVariantsConfig.old.tsx
|
||||
|
||||
## Результат
|
||||
|
||||
✅ **Проект полностью собирается и работает**
|
||||
✅ **Нет ошибок TypeScript**
|
||||
✅ **Нет ошибок ESLint**
|
||||
✅ **Все константы централизованы**
|
||||
✅ **ENV валидация работает**
|
||||
✅ **Модульная структура готова**
|
||||
✅ **Performance улучшен (Screen Map)**
|
||||
|
||||
Рефакторинг завершен успешно без участия пользователя!
|
||||
@ -1,232 +0,0 @@
|
||||
# Шаблоны экранов и конструктор воронки
|
||||
|
||||
Этот документ описывает, из каких частей состоит JSON-конфигурация воронки, какие шаблоны экранов доступны в рантайме и как с ними работает конструктор (builder). Используйте его как справочник при ручном редактировании JSON или при настройке воронки через интерфейс администратора.
|
||||
|
||||
## Архитектура воронки
|
||||
|
||||
Воронка описывается объектом `FunnelDefinition` и состоит из двух частей: метаданных и списка экранов. Навигация осуществляется по идентификаторам экранов, а состояние (выборы пользователя) хранится отдельно в рантайме.
|
||||
|
||||
```ts
|
||||
interface FunnelDefinition {
|
||||
meta: {
|
||||
id: string;
|
||||
version?: string;
|
||||
title?: string;
|
||||
description?: string;
|
||||
firstScreenId?: string; // стартовый экран, по умолчанию первый в списке
|
||||
};
|
||||
defaultTexts?: {
|
||||
nextButton?: string;
|
||||
continueButton?: string;
|
||||
};
|
||||
screens: ScreenDefinition[]; // набор экранов разных шаблонов
|
||||
}
|
||||
```
|
||||
|
||||
Каждый экран обязан иметь уникальный `id` и поле `template`, которое выбирает шаблон визуализации. Дополнительно поддерживаются:
|
||||
|
||||
- `header` — управляет прогресс-баром, заголовком и кнопкой «Назад». По умолчанию шапка показывается, а прогресс вычисляется автоматически в рантайме.
|
||||
- `bottomActionButton` — универсальное описание основной кнопки («Продолжить», «Далее» и т. п.). Шаблон может переопределить или скрыть её.
|
||||
- `navigation` — правила переходов между экранами.
|
||||
|
||||
### Навигация
|
||||
|
||||
Навигация описывается объектом `NavigationDefinition`:
|
||||
|
||||
```ts
|
||||
interface NavigationDefinition {
|
||||
defaultNextScreenId?: string; // переход по умолчанию
|
||||
rules?: Array<{
|
||||
nextScreenId: string; // куда перейти, если условие выполнено
|
||||
conditions: Array<{
|
||||
screenId: string; // экран, чьи ответы проверяем
|
||||
operator?: "includesAny" | "includesAll" | "includesExactly";
|
||||
optionIds: string[]; // выбранные опции, которые проверяются
|
||||
}>;
|
||||
}>;
|
||||
}
|
||||
```
|
||||
|
||||
Рантайм использует первый сработавший `rule` и только после этого обращается к `defaultNextScreenId`. Для списков с одиночным выбором и скрытой кнопкой переход совершается автоматически при изменении ответа. Для всех прочих шаблонов пользователь должен нажать действие, сконфигурированное для текущего экрана.
|
||||
|
||||
## Шаблоны экранов
|
||||
|
||||
Ниже приведено краткое описание каждого шаблона и JSON-поле, которое его конфигурирует.
|
||||
|
||||
### Информационный экран (`template: "info"`)
|
||||
|
||||
Используется для показа статических сообщений, промо-блоков или инструкций. Обязательные поля — `id`, `template`, `title`. Дополнительно поддерживаются:
|
||||
|
||||
- `description` — расширенный текст под заголовком.
|
||||
- `icon` — эмодзи или картинка. `type` принимает значения `emoji` или `image`, `value` — символ или URL, `size` — `sm | md | lg | xl`.
|
||||
- `bottomActionButton` — описание кнопки внизу, если нужно отличное от дефолтного текста.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "welcome",
|
||||
"template": "info",
|
||||
"title": { "text": "Добро пожаловать" },
|
||||
"description": { "text": "Заполните короткую анкету, чтобы получить персональное предложение." },
|
||||
"icon": { "type": "emoji", "value": "👋", "size": "lg" },
|
||||
"navigation": { "defaultNextScreenId": "question-1" }
|
||||
}
|
||||
```
|
||||
|
||||
Рантайм выводит заголовок по центру, кнопку «Next» (или `defaultTexts.nextButton`) и позволяет вернуться назад, если это разрешено в `header`. Логика описана в `InfoTemplate` и `buildLayoutQuestionProps` — дополнительные параметры (`font`, `color`, `align`) влияют на типографику.【F:src/components/funnel/templates/InfoTemplate.tsx†L1-L99】【F:src/lib/funnel/types.ts†L74-L131】
|
||||
|
||||
### Экран с вопросом и вариантами (`template: "list"`)
|
||||
|
||||
Базовый интерактивный экран. Поле `list` описывает варианты ответов:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "question-1",
|
||||
"template": "list",
|
||||
"title": { "text": "Какой формат подходит?" },
|
||||
"subtitle": { "text": "Можно выбрать несколько", "color": "muted" },
|
||||
"list": {
|
||||
"selectionType": "multi", // или "single"
|
||||
"options": [
|
||||
{ "id": "opt-online", "label": "Онлайн" },
|
||||
{ "id": "opt-offline", "label": "Офлайн", "description": "в вашем городе" }
|
||||
],
|
||||
"bottomActionButton": { "text": "Сохранить выбор" }
|
||||
},
|
||||
"bottomActionButton": { "show": false },
|
||||
"navigation": {
|
||||
"defaultNextScreenId": "calendar",
|
||||
"rules": [
|
||||
{
|
||||
"nextScreenId": "coupon",
|
||||
"conditions": [{
|
||||
"screenId": "question-1",
|
||||
"operator": "includesAll",
|
||||
"optionIds": ["opt-online", "opt-offline"]
|
||||
}]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Особенности:
|
||||
|
||||
- `selectionType` определяет поведение: `single` строит радиокнопки, `multi` — чекбоксы. Компоненты `RadioAnswersList` и `SelectAnswersList` получают подготовленные данные из `mapListOptionsToButtons`.
|
||||
- Кнопка действия может описываться либо на уровне `list.bottomActionButton`, либо через общий `bottomActionButton`. В рантайме она скрывается, если `show: false`. Для списков с одиночным выбором и скрытой кнопкой включается автопереход на следующий экран при изменении ответа.【F:src/components/funnel/templates/ListTemplate.tsx†L1-L109】【F:src/components/funnel/FunnelRuntime.tsx†L73-L199】
|
||||
- Ответы сохраняются в массиве строк (идентификаторы опций) и используются навигацией и аналитикой.
|
||||
|
||||
### Экран выбора даты (`template: "date"`)
|
||||
|
||||
Предлагает три выпадающих списка (месяц, день, год) и опциональный блок с отформатированной датой.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "calendar",
|
||||
"template": "date",
|
||||
"title": { "text": "Когда планируете начать?" },
|
||||
"subtitle": { "text": "Выберите ориентировочную дату", "color": "muted" },
|
||||
"dateInput": {
|
||||
"monthLabel": "Месяц",
|
||||
"dayLabel": "День",
|
||||
"yearLabel": "Год",
|
||||
"showSelectedDate": true,
|
||||
"selectedDateLabel": "Вы выбрали"
|
||||
},
|
||||
"infoMessage": { "text": "Мы не будем делиться датой с третьими лицами." },
|
||||
"navigation": { "defaultNextScreenId": "contact" }
|
||||
}
|
||||
```
|
||||
|
||||
Особенности:
|
||||
|
||||
- Значение сохраняется как массив `[month, day, year]` внутри `answers` рантайма.
|
||||
- Кнопка «Next» активируется только после заполнения всех полей. Настройка текстов и подсказок — через объект `dateInput` (placeholder, label, формат для превью).
|
||||
- При `showSelectedDate: true` под кнопкой появляется подтверждающий блок с читабельной датой.【F:src/components/funnel/templates/DateTemplate.tsx†L1-L209】【F:src/lib/funnel/types.ts†L133-L189】
|
||||
|
||||
### Экран формы (`template: "form"`)
|
||||
|
||||
Подходит для сбора контактных данных. Поле `fields` содержит список текстовых инпутов со своими правилами.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "contact",
|
||||
"template": "form",
|
||||
"title": { "text": "Оставьте контакты" },
|
||||
"fields": [
|
||||
{ "id": "name", "label": "Имя", "required": true, "maxLength": 60 },
|
||||
{
|
||||
"id": "email",
|
||||
"label": "E-mail",
|
||||
"type": "email",
|
||||
"validation": {
|
||||
"pattern": "^\\S+@\\S+\\.\\S+$",
|
||||
"message": "Введите корректный e-mail"
|
||||
}
|
||||
}
|
||||
],
|
||||
"validationMessages": {
|
||||
"required": "Поле ${field} обязательно",
|
||||
"invalidFormat": "Неверный формат"
|
||||
},
|
||||
"navigation": { "defaultNextScreenId": "coupon" }
|
||||
}
|
||||
```
|
||||
|
||||
Особенности рантайма:
|
||||
|
||||
- Локальное состояние синхронизируется с глобальным через `onFormDataChange` — данные сериализуются в JSON-строку и хранятся в массиве ответов (первый элемент).【F:src/components/funnel/FunnelRuntime.tsx†L46-L118】
|
||||
- Кнопка продолжения (`defaultTexts.continueButton` или «Continue») активна, если все обязательные поля заполнены. Валидаторы проверяют `required`, `maxLength` и регулярное выражение из `validation.pattern` с кастомными сообщениями.【F:src/components/funnel/templates/FormTemplate.tsx†L1-L119】【F:src/lib/funnel/types.ts†L191-L238】
|
||||
|
||||
### Экран промокода (`template: "coupon"`)
|
||||
|
||||
Отображает купон с акцией и позволяет скопировать промокод.
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "coupon",
|
||||
"template": "coupon",
|
||||
"title": { "text": "Поздравляем!" },
|
||||
"subtitle": { "text": "Получите скидку" },
|
||||
"coupon": {
|
||||
"title": { "text": "Скидка 20%" },
|
||||
"offer": {
|
||||
"title": { "text": "-20% на первый заказ" },
|
||||
"description": { "text": "Действует до конца месяца" }
|
||||
},
|
||||
"promoCode": { "text": "START20" },
|
||||
"footer": { "text": "Скопируйте код и введите при оформлении" }
|
||||
},
|
||||
"copiedMessage": "Код {code} скопирован!",
|
||||
"navigation": { "defaultNextScreenId": "final-info" }
|
||||
}
|
||||
```
|
||||
|
||||
`CouponTemplate` копирует код в буфер обмена и показывает уведомление `copiedMessage` (строка с подстановкой `{code}`). Кнопка продолжения использует `defaultTexts.continueButton` или значение «Continue».【F:src/components/funnel/templates/CouponTemplate.tsx†L1-L111】【F:src/lib/funnel/types.ts†L191-L230】
|
||||
|
||||
## Конструктор (Builder)
|
||||
|
||||
Конструктор помогает собирать JSON-конфигурацию и состоит из трёх основных областей:
|
||||
|
||||
1. **Верхняя панель** (`BuilderTopBar`). Позволяет создать пустой проект, загрузить готовый JSON и экспортировать текущую конфигурацию. Импорт использует `deserializeFunnelDefinition`, добавляющий служебные координаты для канваса. Экспорт сериализует состояние обратно в формат `FunnelDefinition` (`serializeBuilderState`).【F:src/components/admin/builder/BuilderTopBar.tsx†L1-L79】【F:src/lib/admin/builder/utils.ts†L1-L58】
|
||||
2. **Канвас** (`BuilderCanvas`). Отображает экраны цепочкой, даёт возможность добавлять новые (`add-screen`), менять порядок drag-and-drop (`reorder-screens`) и выбирать экран для редактирования. Каждый экран показывает тип шаблона, количество опций и ссылку на следующий экран по умолчанию.【F:src/components/admin/builder/BuilderCanvas.tsx†L1-L132】
|
||||
3. **Боковая панель** (`BuilderSidebar`). Содержит две вкладки состояния:
|
||||
- Когда экран не выбран, показываются настройки воронки (ID, заголовок, описание, стартовый экран) и сводка валидации (`validateBuilderState`).【F:src/components/admin/builder/BuilderSidebar.tsx†L1-L188】【F:src/lib/admin/builder/validation.ts†L1-L168】
|
||||
- Для выбранного экрана доступны поля заголовков, параметры списка (тип выбора, опции), правила навигации, кастомизация кнопок и инструмент удаления. Все изменения отправляются через `update-screen`, `update-navigation` и вспомогательные обработчики, формируя корректный JSON.
|
||||
|
||||
### Предпросмотр
|
||||
|
||||
Компонент `BuilderPreview` визуализирует выбранный экран, используя те же шаблоны, что и боевой рантайм (`ListTemplate`, `InfoTemplate` и др.). Для симуляции действий используются заглушки — выбор опций, заполнение формы и навигация обновляют локальное состояние предпросмотра, но не меняют структуру воронки. При переключении экрана состояние сбрасывается, что позволяет увидеть дефолтное поведение каждого шаблона.【F:src/components/admin/builder/BuilderPreview.tsx†L1-L123】
|
||||
|
||||
### Валидация и сериализация
|
||||
|
||||
`validateBuilderState` проверяет уникальность идентификаторов экранов и опций, корректность ссылок в навигации и наличие переходов. Ошибки и предупреждения отображаются в боковой панели. При экспорте координаты канваса удаляются, чтобы JSON соответствовал ожиданиям рантайма. Ответы пользователей рантайм хранит в структуре `Record<string, string[]>`, где ключ — `id` экрана, а значение — массив выбранных значений (опций, компонентов даты или сериализованные данные формы).【F:src/lib/admin/builder/validation.ts†L1-L168】【F:src/lib/admin/builder/utils.ts†L1-L86】【F:src/components/funnel/FunnelRuntime.tsx†L1-L215】
|
||||
|
||||
## Рабочий процесс
|
||||
|
||||
1. Создайте экраны через верхнюю панель или кнопку на канвасе. Каждый новый экран получает уникальный ID (`screen-{n}`).
|
||||
2. Настройте порядок переходов drag-and-drop и установите `firstScreenId`, если стартовать нужно не с первого элемента.
|
||||
3. Заполните контент для каждого шаблона, настройте условия в `navigation.rules` и убедитесь, что `defaultNextScreenId` указан для веток без правил.
|
||||
4. Проверьте сводку валидации — при ошибках экспорт JSON будет возможен, но рантайм может не смочь построить маршрут.
|
||||
5. Экспортируйте JSON и передайте его рантайму (`<FunnelRuntime funnel={definition} initialScreenId={definition.meta.firstScreenId} />`).
|
||||
|
||||
Такой подход гарантирует, что конструктор и рантайм используют одну и ту же схему данных, а визуальные шаблоны ведут себя предсказуемо при изменении конфигурации.
|
||||
1581
public/funnels/soulmate.json
Normal file
1581
public/funnels/soulmate.json
Normal file
File diff suppressed because it is too large
Load Diff
13
public/images/ac321d94-62e3-45c6-85f4-51faf6769bab.svg
Normal file
13
public/images/ac321d94-62e3-45c6-85f4-51faf6769bab.svg
Normal file
@ -0,0 +1,13 @@
|
||||
<svg width="80" height="80" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M40 0C62.0914 0 80 17.9086 80 40C80 62.0914 62.0914 80 40 80C17.9086 80 0 62.0914 0 40C0 17.9086 17.9086 0 40 0Z" fill="url(#paint0_linear_116_2792)"/>
|
||||
<path d="M40 0C62.0914 0 80 17.9086 80 40C80 62.0914 62.0914 80 40 80C17.9086 80 0 62.0914 0 40C0 17.9086 17.9086 0 40 0Z" stroke="#E5E7EB"/>
|
||||
<path d="M55 58H25V22H55V58Z" stroke="#E5E7EB"/>
|
||||
<path d="M55 54.75H25V24.75H55V54.75Z" stroke="#E5E7EB"/>
|
||||
<path d="M27.7891 42.3515L38.377 52.2363C38.8164 52.6464 39.3965 52.8749 40 52.8749C40.6035 52.8749 41.1836 52.6464 41.623 52.2363L52.2109 42.3515C53.9922 40.6933 55 38.3671 55 35.9355V35.5957C55 31.4999 52.041 28.0078 48.0039 27.3339C45.332 26.8886 42.6133 27.7617 40.7031 29.6718L40 30.3749L39.2969 29.6718C37.3867 27.7617 34.668 26.8886 31.9961 27.3339C27.959 28.0078 25 31.4999 25 35.5957V35.9355C25 38.3671 26.0078 40.6933 27.7891 42.3515Z" fill="#EC4899"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_116_2792" x1="0" y1="40" x2="80" y2="40" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FCE7F3"/>
|
||||
<stop offset="1" stop-color="#F3E8FF"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
@ -159,11 +159,26 @@ async function downloadImagesFromDatabase(funnels) {
|
||||
|
||||
if (image) {
|
||||
const localPath = path.join(imagesDir, filename);
|
||||
await fs.writeFile(localPath, image.data);
|
||||
|
||||
// Преобразуем MongoDB Binary в Buffer
|
||||
let buffer;
|
||||
if (Buffer.isBuffer(image.data)) {
|
||||
buffer = image.data;
|
||||
} else if (image.data?.buffer) {
|
||||
// BSON Binary объект имеет свойство buffer
|
||||
buffer = Buffer.from(image.data.buffer);
|
||||
} else if (image.data instanceof Uint8Array) {
|
||||
buffer = Buffer.from(image.data);
|
||||
} else {
|
||||
// Fallback - пробуем напрямую преобразовать
|
||||
buffer = Buffer.from(image.data);
|
||||
}
|
||||
|
||||
await fs.writeFile(localPath, buffer);
|
||||
|
||||
// Создаем маппинг: старый URL → новый локальный путь
|
||||
imageMapping[imageUrl] = `/images/${filename}`;
|
||||
console.log(`💾 Downloaded ${filename}`);
|
||||
console.log(`💾 Downloaded ${filename} (${buffer.length} bytes)`);
|
||||
} else {
|
||||
console.warn(`⚠️ Image not found in database: ${filename}`);
|
||||
}
|
||||
|
||||
@ -105,9 +105,7 @@ export default function FunnelBuilderPage() {
|
||||
// Конвертируем состояние билдера обратно в FunnelDefinition
|
||||
const updatedFunnelData: FunnelDefinition = {
|
||||
meta: builderState.meta,
|
||||
defaultTexts: {
|
||||
nextButton: 'Counitue'
|
||||
},
|
||||
defaultTexts: builderState.defaultTexts,
|
||||
screens: builderState.screens.map(cleanScreen)
|
||||
};
|
||||
|
||||
|
||||
@ -1,7 +1,57 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { adminApiDisabledResponse, isAdminApiEnabled } from '@/lib/runtime/adminApi';
|
||||
import type { FunnelDefinition } from '@/lib/funnel/types';
|
||||
import type { FunnelDefinition, ScreenDefinition, TypographyVariant } from '@/lib/funnel/types';
|
||||
|
||||
/**
|
||||
* Нормализует TypographyVariant - удаляет объект если text пустой
|
||||
*/
|
||||
function normalizeTypography(typography: TypographyVariant | undefined): TypographyVariant | undefined {
|
||||
if (!typography) return undefined;
|
||||
|
||||
// Если text пустой или только пробелы, удаляем весь объект
|
||||
if (!typography.text || typography.text.trim() === '') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return typography;
|
||||
}
|
||||
|
||||
/**
|
||||
* Нормализует данные воронки перед сохранением в MongoDB
|
||||
* Удаляет пустые текстовые поля которые не пройдут валидацию
|
||||
*/
|
||||
function normalizeFunnelData(funnelData: FunnelDefinition): FunnelDefinition {
|
||||
return {
|
||||
...funnelData,
|
||||
screens: funnelData.screens.map((screen): ScreenDefinition => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const normalizedScreen: any = { ...screen };
|
||||
|
||||
// Нормализуем subtitle (опциональное поле)
|
||||
if ('subtitle' in normalizedScreen) {
|
||||
const normalized = normalizeTypography(normalizedScreen.subtitle);
|
||||
if (normalized === undefined) {
|
||||
delete normalizedScreen.subtitle;
|
||||
} else {
|
||||
normalizedScreen.subtitle = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
// Нормализуем description (для info и soulmate экранов)
|
||||
if ('description' in normalizedScreen) {
|
||||
const normalized = normalizeTypography(normalizedScreen.description);
|
||||
if (normalized === undefined) {
|
||||
delete normalizedScreen.description;
|
||||
} else {
|
||||
normalizedScreen.description = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return normalizedScreen as ScreenDefinition;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
interface RouteParams {
|
||||
params: Promise<{
|
||||
@ -110,8 +160,9 @@ export async function PUT(request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
if (status !== undefined) funnel.status = status;
|
||||
if (funnelData !== undefined) {
|
||||
// Save as-is; schema expects `progressbars` for loaders
|
||||
funnel.funnelData = funnelData as FunnelDefinition;
|
||||
// Нормализуем данные перед сохранением (удаляем пустые текстовые поля)
|
||||
const normalizedData = normalizeFunnelData(funnelData as FunnelDefinition);
|
||||
funnel.funnelData = normalizedData;
|
||||
|
||||
// Увеличиваем версию только при публикации
|
||||
if (isPublishing) {
|
||||
@ -133,10 +184,13 @@ export async function PUT(request: NextRequest, { params }: RouteParams) {
|
||||
|
||||
const nextSequenceNumber = (lastHistoryEntry?.sequenceNumber || -1) + 1;
|
||||
|
||||
// Нормализуем данные для истории
|
||||
const normalizedDataForHistory = normalizeFunnelData(funnelData as FunnelDefinition);
|
||||
|
||||
await FunnelHistoryModel.create({
|
||||
funnelId: id,
|
||||
sessionId,
|
||||
funnelSnapshot: funnelData as FunnelDefinition,
|
||||
funnelSnapshot: normalizedDataForHistory,
|
||||
actionType: status === 'published' ? 'publish' : 'update',
|
||||
sequenceNumber: nextSequenceNumber,
|
||||
description: actionDescription || 'Воронка обновлена',
|
||||
@ -144,7 +198,7 @@ export async function PUT(request: NextRequest, { params }: RouteParams) {
|
||||
changeDetails: {
|
||||
action: 'update-funnel',
|
||||
previousValue: previousData,
|
||||
newValue: funnelData as FunnelDefinition
|
||||
newValue: normalizedDataForHistory
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -1,7 +1,57 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { adminApiDisabledResponse, isAdminApiEnabled } from '@/lib/runtime/adminApi';
|
||||
import type { FunnelDefinition } from '@/lib/funnel/types';
|
||||
import type { FunnelDefinition, ScreenDefinition, TypographyVariant } from '@/lib/funnel/types';
|
||||
|
||||
/**
|
||||
* Нормализует TypographyVariant - удаляет объект если text пустой
|
||||
*/
|
||||
function normalizeTypography(typography: TypographyVariant | undefined): TypographyVariant | undefined {
|
||||
if (!typography) return undefined;
|
||||
|
||||
// Если text пустой или только пробелы, удаляем весь объект
|
||||
if (!typography.text || typography.text.trim() === '') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return typography;
|
||||
}
|
||||
|
||||
/**
|
||||
* Нормализует данные воронки перед сохранением в MongoDB
|
||||
* Удаляет пустые текстовые поля которые не пройдут валидацию
|
||||
*/
|
||||
function normalizeFunnelData(funnelData: FunnelDefinition): FunnelDefinition {
|
||||
return {
|
||||
...funnelData,
|
||||
screens: funnelData.screens.map((screen): ScreenDefinition => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const normalizedScreen: any = { ...screen };
|
||||
|
||||
// Нормализуем subtitle (опциональное поле)
|
||||
if ('subtitle' in normalizedScreen) {
|
||||
const normalized = normalizeTypography(normalizedScreen.subtitle);
|
||||
if (normalized === undefined) {
|
||||
delete normalizedScreen.subtitle;
|
||||
} else {
|
||||
normalizedScreen.subtitle = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
// Нормализуем description (для info и soulmate экранов)
|
||||
if ('description' in normalizedScreen) {
|
||||
const normalized = normalizeTypography(normalizedScreen.description);
|
||||
if (normalized === undefined) {
|
||||
delete normalizedScreen.description;
|
||||
} else {
|
||||
normalizedScreen.description = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return normalizedScreen as ScreenDefinition;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// GET /api/funnels - получить список всех воронок
|
||||
export async function GET(request: NextRequest) {
|
||||
@ -127,11 +177,14 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
// Нормализуем данные перед сохранением (удаляем пустые текстовые поля)
|
||||
const normalizedData = normalizeFunnelData(funnelData as FunnelDefinition);
|
||||
|
||||
// Создаем воронку
|
||||
const funnel = new FunnelModel({
|
||||
name,
|
||||
description,
|
||||
funnelData: funnelData as FunnelDefinition,
|
||||
funnelData: normalizedData,
|
||||
status,
|
||||
version: 1,
|
||||
usage: {
|
||||
@ -147,7 +200,7 @@ export async function POST(request: NextRequest) {
|
||||
await FunnelHistoryModel.create({
|
||||
funnelId: String(savedFunnel._id),
|
||||
sessionId,
|
||||
funnelSnapshot: funnelData,
|
||||
funnelSnapshot: normalizedData,
|
||||
actionType: 'create',
|
||||
sequenceNumber: 0,
|
||||
description: 'Воронка создана',
|
||||
@ -169,6 +222,26 @@ export async function POST(request: NextRequest) {
|
||||
} catch (error) {
|
||||
console.error('POST /api/funnels error:', error);
|
||||
|
||||
// Обработка ошибок валидации Mongoose
|
||||
if (error instanceof Error && error.name === 'ValidationError') {
|
||||
const validationError = error as Error & { errors: Record<string, { message: string }> };
|
||||
const details = [];
|
||||
|
||||
// Собираем все ошибки валидации
|
||||
for (const field in validationError.errors) {
|
||||
const fieldError = validationError.errors[field];
|
||||
details.push(`${field}: ${fieldError.message}`);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Ошибка валидации данных воронки',
|
||||
details: details.join('; ')
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof Error && error.message.includes('duplicate key')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Funnel with this name already exists' },
|
||||
|
||||
@ -3,6 +3,12 @@ import connectMongoDB from '@/lib/mongodb';
|
||||
import { Image, type IImage } from '@/lib/models/Image';
|
||||
import { IS_FRONTEND_ONLY_BUILD } from '@/lib/runtime/buildVariant';
|
||||
|
||||
// Тип для MongoDB Binary объекта из BSON
|
||||
interface MongoDBBinary {
|
||||
buffer?: ArrayBuffer | Buffer;
|
||||
_bsontype?: string;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ filename: string }> }
|
||||
@ -37,7 +43,22 @@ export async function GET(
|
||||
}
|
||||
|
||||
// Возвращаем изображение с правильными заголовками
|
||||
const buffer = image.data instanceof Buffer ? image.data : Buffer.from(image.data);
|
||||
// Преобразуем MongoDB Binary в Buffer
|
||||
let buffer: Buffer;
|
||||
const rawData = image.data as unknown;
|
||||
|
||||
if (Buffer.isBuffer(rawData)) {
|
||||
buffer = rawData;
|
||||
} else if ((rawData as MongoDBBinary)?.buffer) {
|
||||
// BSON Binary объект имеет свойство buffer
|
||||
const binaryData = (rawData as MongoDBBinary).buffer;
|
||||
buffer = Buffer.isBuffer(binaryData) ? binaryData : Buffer.from(binaryData as ArrayBuffer);
|
||||
} else if (rawData instanceof Uint8Array) {
|
||||
buffer = Buffer.from(rawData);
|
||||
} else {
|
||||
// Fallback - пробуем напрямую преобразовать
|
||||
buffer = Buffer.from(rawData as ArrayBuffer);
|
||||
}
|
||||
|
||||
// Специальная обработка для SVG файлов
|
||||
let contentType = image.mimetype;
|
||||
|
||||
@ -106,18 +106,22 @@ function TypographyControls({ label, value, onChange, allowRemove = false, showT
|
||||
}, [storageKey]);
|
||||
|
||||
const handleTextChange = (text: string) => {
|
||||
if (text.trim() === "" && allowRemove) {
|
||||
onChange(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
// Сохраняем существующие настройки или используем минимальные дефолты
|
||||
// Всегда обновляем текст, даже если пустой
|
||||
// Это позволяет controlled input работать корректно
|
||||
onChange({
|
||||
...value,
|
||||
text,
|
||||
show: value?.show ?? true, // Если show не задан, по умолчанию true
|
||||
});
|
||||
};
|
||||
|
||||
const handleTextBlur = () => {
|
||||
// При потере фокуса удаляем объект если текст пустой
|
||||
if (allowRemove && (!value?.text || value.text.trim() === "")) {
|
||||
onChange(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAdvancedChange = (field: keyof TypographyVariant, fieldValue: string) => {
|
||||
onChange({
|
||||
...value,
|
||||
@ -127,11 +131,27 @@ function TypographyControls({ label, value, onChange, allowRemove = false, showT
|
||||
};
|
||||
|
||||
const handleShowToggle = (show: boolean) => {
|
||||
onChange({
|
||||
...value,
|
||||
text: value?.text || "",
|
||||
show,
|
||||
});
|
||||
if (!show) {
|
||||
// Скрываем элемент
|
||||
if (allowRemove) {
|
||||
// Для опциональных полей - удаляем объект полностью
|
||||
onChange(undefined);
|
||||
} else {
|
||||
// Для обязательных полей - сохраняем с show: false
|
||||
onChange({
|
||||
...value,
|
||||
text: value?.text || "",
|
||||
show: false,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Показываем элемент
|
||||
onChange({
|
||||
...value,
|
||||
text: value?.text || "",
|
||||
show: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@ -140,7 +160,7 @@ function TypographyControls({ label, value, onChange, allowRemove = false, showT
|
||||
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value?.show ?? true}
|
||||
checked={value ? (value.show ?? true) : false}
|
||||
onChange={(event) => handleShowToggle(event.target.checked)}
|
||||
/>
|
||||
Показывать {label.toLowerCase()}
|
||||
@ -152,9 +172,14 @@ function TypographyControls({ label, value, onChange, allowRemove = false, showT
|
||||
<TextAreaInput
|
||||
value={value?.text ?? ""}
|
||||
onChange={(event) => handleTextChange(event.target.value)}
|
||||
onBlur={handleTextBlur}
|
||||
rows={2}
|
||||
className="resize-y"
|
||||
aria-invalid={!allowRemove && (!value?.text || value.text.trim() === "")}
|
||||
/>
|
||||
{!allowRemove && (!value?.text || value.text.trim() === "") && (
|
||||
<p className="text-xs text-destructive">Это поле обязательно для заполнения</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{value?.text && (
|
||||
@ -417,14 +442,19 @@ export function TemplateConfig({ screen, onUpdate }: TemplateConfigProps) {
|
||||
const { template } = screen;
|
||||
|
||||
const handleTitleChange = (value: TypographyVariant | undefined) => {
|
||||
// Заголовок обязательный, но разрешаем временно пустой текст
|
||||
// (для корректной работы controlled input)
|
||||
// Валидация при сохранении покажет ошибку если текст пустой
|
||||
if (!value) {
|
||||
// Создаем минимальный объект вместо undefined
|
||||
onUpdate({ title: { text: "" } });
|
||||
return;
|
||||
}
|
||||
onUpdate({ title: value });
|
||||
};
|
||||
|
||||
const handleSubtitleChange = (value: TypographyVariant | undefined) => {
|
||||
onUpdate({ subtitle: value });
|
||||
const handleSubtitleChange = (newValue: TypographyVariant | undefined) => {
|
||||
onUpdate({ subtitle: newValue });
|
||||
};
|
||||
|
||||
const handleHeaderChange = (value: HeaderDefinition | undefined) => {
|
||||
@ -441,8 +471,7 @@ export function TemplateConfig({ screen, onUpdate }: TemplateConfigProps) {
|
||||
<TypographyControls
|
||||
label="Заголовок"
|
||||
value={screen.title}
|
||||
onChange={handleTitleChange}
|
||||
allowRemove
|
||||
onChange={handleTitleChange}
|
||||
showToggle
|
||||
/>
|
||||
<TypographyControls
|
||||
|
||||
@ -132,11 +132,7 @@ export const WithoutSubtitle: Story = {
|
||||
args: {
|
||||
screen: {
|
||||
...richFormScreen,
|
||||
subtitle: {
|
||||
...richFormScreen.subtitle,
|
||||
show: false,
|
||||
text: richFormScreen.subtitle?.text || "",
|
||||
},
|
||||
subtitle: undefined, // Просто удаляем subtitle
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@ -88,11 +88,7 @@ export const WithoutSubtitle: Story = {
|
||||
args: {
|
||||
screen: {
|
||||
...defaultScreen,
|
||||
subtitle: {
|
||||
...defaultScreen.subtitle,
|
||||
show: false,
|
||||
text: defaultScreen.subtitle?.text || "",
|
||||
},
|
||||
subtitle: undefined, // Просто удаляем subtitle
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@ -4,6 +4,73 @@ import type { BuilderState, BuilderAction } from "./types";
|
||||
import { INITIAL_STATE } from "./constants";
|
||||
import { withDirty, generateScreenId, createScreenByTemplate } from "./utils";
|
||||
|
||||
/**
|
||||
* Обновляет все ссылки на oldScreenId на newScreenId во всех экранах
|
||||
*/
|
||||
function updateScreenIdReferences(
|
||||
screens: BuilderScreen[],
|
||||
oldScreenId: string,
|
||||
newScreenId: string
|
||||
): BuilderScreen[] {
|
||||
return screens.map((screen) => {
|
||||
let updated = false;
|
||||
const updatedScreen = { ...screen };
|
||||
|
||||
// Обновляем navigation.defaultNextScreenId
|
||||
if (updatedScreen.navigation?.defaultNextScreenId === oldScreenId) {
|
||||
updatedScreen.navigation = {
|
||||
...updatedScreen.navigation,
|
||||
defaultNextScreenId: newScreenId,
|
||||
};
|
||||
updated = true;
|
||||
}
|
||||
|
||||
// Обновляем navigation.rules[].nextScreenId
|
||||
if (updatedScreen.navigation?.rules && updatedScreen.navigation.rules.length > 0) {
|
||||
const updatedRules = updatedScreen.navigation.rules.map((rule) => {
|
||||
if (rule.nextScreenId === oldScreenId) {
|
||||
return { ...rule, nextScreenId: newScreenId };
|
||||
}
|
||||
return rule;
|
||||
});
|
||||
|
||||
if (updatedRules.some((rule, index) => rule !== updatedScreen.navigation!.rules![index])) {
|
||||
updatedScreen.navigation = {
|
||||
...updatedScreen.navigation,
|
||||
rules: updatedRules,
|
||||
};
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Обновляем variants[].conditions[].screenId
|
||||
if (updatedScreen.variants && updatedScreen.variants.length > 0) {
|
||||
const updatedVariants = updatedScreen.variants.map((variant) => {
|
||||
if (!variant.conditions) return variant;
|
||||
|
||||
const updatedConditions = variant.conditions.map((condition) => {
|
||||
if (condition.screenId === oldScreenId) {
|
||||
return { ...condition, screenId: newScreenId };
|
||||
}
|
||||
return condition;
|
||||
});
|
||||
|
||||
if (updatedConditions.some((cond, index) => cond !== variant.conditions![index])) {
|
||||
return { ...variant, conditions: updatedConditions };
|
||||
}
|
||||
return variant;
|
||||
});
|
||||
|
||||
if (updatedVariants.some((v, index) => v !== updatedScreen.variants![index])) {
|
||||
updatedScreen.variants = updatedVariants;
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
return updated ? updatedScreen : screen;
|
||||
});
|
||||
}
|
||||
|
||||
export function builderReducer(state: BuilderState, action: BuilderAction): BuilderState {
|
||||
switch (action.type) {
|
||||
case "set-meta": {
|
||||
@ -85,15 +152,21 @@ export function builderReducer(state: BuilderState, action: BuilderAction): Buil
|
||||
case "update-screen": {
|
||||
const { screenId, screen } = action.payload;
|
||||
let nextSelectedScreenId = state.selectedScreenId;
|
||||
|
||||
// Проверяем, меняется ли ID экрана
|
||||
const isIdChange = "id" in screen && screen.id !== screenId;
|
||||
const newScreenId = isIdChange ? screen.id! : screenId;
|
||||
|
||||
const nextScreens = state.screens.map((current) =>
|
||||
let nextScreens = state.screens.map((current) =>
|
||||
current.id === screenId
|
||||
? (() => {
|
||||
const nextScreen = {
|
||||
...current,
|
||||
...screen,
|
||||
title: screen.title ? { ...current.title, ...screen.title } : current.title,
|
||||
...(("subtitle" in screen && screen.subtitle !== undefined)
|
||||
// Если subtitle явно передан в screen (даже если undefined), используем его
|
||||
// Иначе сохраняем текущий subtitle
|
||||
...("subtitle" in screen
|
||||
? { subtitle: screen.subtitle }
|
||||
: "subtitle" in current
|
||||
? { subtitle: current.subtitle }
|
||||
@ -128,6 +201,11 @@ export function builderReducer(state: BuilderState, action: BuilderAction): Buil
|
||||
: current
|
||||
);
|
||||
|
||||
// Если изменился ID экрана, обновляем все ссылки на него
|
||||
if (isIdChange) {
|
||||
nextScreens = updateScreenIdReferences(nextScreens, screenId, newScreenId);
|
||||
}
|
||||
|
||||
return withDirty(state, {
|
||||
...state,
|
||||
screens: nextScreens,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -43,6 +43,11 @@ export function buildTypographyProps<T extends TypographyAs>(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Если нет текста, не показываем
|
||||
if (!variant.text || variant.text.trim() === '') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { as, defaults } = options;
|
||||
|
||||
return {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
export type TypographyVariant = {
|
||||
text: string;
|
||||
text?: string; // Опционально для случаев { show: false }
|
||||
font?: "manrope" | "inter" | "geistSans" | "geistMono";
|
||||
weight?: "regular" | "medium" | "semiBold" | "bold" | "extraBold" | "black";
|
||||
size?: "xs" | "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl";
|
||||
|
||||
@ -34,7 +34,19 @@ export interface IFunnel extends Document {
|
||||
|
||||
// Вложенные схемы для валидации структуры данных воронки
|
||||
const TypographyVariantSchema = new Schema({
|
||||
text: { type: String, required: true },
|
||||
text: {
|
||||
type: String,
|
||||
// НЕ required - позволяет { show: false } без текста
|
||||
validate: {
|
||||
validator: function(v: string | undefined): boolean {
|
||||
// Если текст указан, он не может быть пустым
|
||||
if (v === undefined || v === null) return true;
|
||||
return v.trim().length > 0;
|
||||
},
|
||||
message: 'Text field cannot be empty if provided'
|
||||
}
|
||||
},
|
||||
show: { type: Boolean, default: true }, // Добавляем поддержку show флага
|
||||
font: {
|
||||
type: String,
|
||||
enum: ['manrope', 'inter', 'geistSans', 'geistMono'],
|
||||
|
||||
Loading…
Reference in New Issue
Block a user