Merge pull request #32 from WIT-LAB-LLC/develop

Develop
This commit is contained in:
pennyteenycat 2025-10-09 02:15:44 +02:00 committed by GitHub
commit 2b5b39c3d9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
139 changed files with 16960 additions and 2936 deletions

View File

@ -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 |
---
**Проект в целом хороший, но есть критичные пробелы в инфраструктуре, тестировании и обработке ошибок!**

View File

@ -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 режимов

View File

@ -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 компиляция чистая**

View File

@ -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 готова к использованию! 🚀**

View File

@ -1,4 +1,4 @@
## Build & runtime modes
# Build & runtime modes
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.

View File

@ -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)**
Рефакторинг завершен успешно без участия пользователя!

View File

@ -0,0 +1,354 @@
# Registration Field Key для List Single Selection
## Описание
Функциональность `registrationFieldKey` позволяет автоматически передавать выбранные значения из list single selection экранов в payload регистрации пользователя при авторизации через email экран.
## Как это работает
### 1. Настройка в админке
Для list экранов с `selectionType: "single"` в админке появляется дополнительное поле **"Ключ поля для регистрации"**.
В это поле можно указать путь к полю в объекте регистрации, используя точечную нотацию для вложенных объектов.
**Примеры:**
- `profile.gender``{ profile: { gender: "selected-id" } }`
- `profile.relationship_status``{ profile: { relationship_status: "selected-id" } }`
- `partner.gender``{ partner: { gender: "selected-id" } }`
### 2. Пример JSON конфигурации
```json
{
"id": "gender-screen",
"template": "list",
"title": {
"text": "What is your gender?"
},
"list": {
"selectionType": "single",
"registrationFieldKey": "profile.gender",
"options": [
{
"id": "male",
"label": "Male",
"emoji": "👨"
},
{
"id": "female",
"label": "Female",
"emoji": "👩"
},
{
"id": "other",
"label": "Other",
"emoji": "🧑"
}
]
}
}
```
### 3. Как данные попадают в регистрацию и сессию
#### **Передача в сессию (на каждом экране):**
1. **Пользователь выбирает вариант** на экране (например, "Male" с id "male")
2. **Ответ сохраняется** в `FunnelAnswers` под ключом экрана: `{ "gender-screen": ["male"] }`
3. **При переходе вперед** (нажатие Continue или автопереход):
- Вызывается `buildSessionDataFromScreen()` для текущего экрана
- Создается объект с вложенной структурой: `{ profile: { gender: "male" } }`
- Вызывается `updateSession()` с данными:
```typescript
{
answers: { "gender-screen": ["male"] }, // Старая логика
profile: { gender: "male" } // Новая логика с registrationFieldKey
}
```
4. **Данные отправляются в API** и сохраняются в сессии пользователя
#### **Передача в регистрацию (при авторизации):**
1. **При переходе на email экран** вызывается функция `buildRegistrationDataFromAnswers()`
2. **Функция обрабатывает** все list single selection экраны с `registrationFieldKey`
3. **Создается объект** с вложенной структурой из всех экранов: `{ profile: { gender: "male", relationship_status: "single" } }`
4. **При авторизации** этот объект объединяется с базовым payload
5. **Отправляется на сервер** в составе `ICreateAuthorizeRequest`
### 4. Структура payload регистрации
**Базовый payload (без registrationFieldKey):**
```typescript
{
email: "user@example.com",
timezone: "Europe/Moscow",
locale: "en",
source: "funnel-id",
sign: true,
signDate: "2024-01-01T00:00:00.000Z",
feature: "stripe"
}
```
**С registrationFieldKey (profile.gender = "male"):**
```typescript
{
email: "user@example.com",
timezone: "Europe/Moscow",
locale: "en",
source: "funnel-id",
sign: true,
signDate: "2024-01-01T00:00:00.000Z",
feature: "stripe",
profile: {
gender: "male"
}
}
```
**С несколькими registrationFieldKey:**
```typescript
{
email: "user@example.com",
timezone: "Europe/Moscow",
locale: "en",
source: "funnel-id",
sign: true,
signDate: "2024-01-01T00:00:00.000Z",
feature: "stripe",
profile: {
gender: "male",
relationship_status: "single"
},
partner: {
gender: "female"
}
}
```
## Полный пример воронки
```json
{
"meta": {
"id": "dating-funnel",
"title": "Dating Profile",
"firstScreenId": "gender"
},
"screens": [
{
"id": "gender",
"template": "list",
"title": { "text": "What is your gender?" },
"list": {
"selectionType": "single",
"registrationFieldKey": "profile.gender",
"options": [
{ "id": "male", "label": "Male", "emoji": "👨" },
{ "id": "female", "label": "Female", "emoji": "👩" }
]
},
"navigation": {
"defaultNextScreenId": "relationship-status"
}
},
{
"id": "relationship-status",
"template": "list",
"title": { "text": "What is your relationship status?" },
"list": {
"selectionType": "single",
"registrationFieldKey": "profile.relationship_status",
"options": [
{ "id": "single", "label": "Single" },
{ "id": "relationship", "label": "In a relationship" },
{ "id": "married", "label": "Married" }
]
},
"navigation": {
"defaultNextScreenId": "partner-gender"
}
},
{
"id": "partner-gender",
"template": "list",
"title": { "text": "What is your partner's gender?" },
"list": {
"selectionType": "single",
"registrationFieldKey": "partner.gender",
"options": [
{ "id": "male", "label": "Male", "emoji": "👨" },
{ "id": "female", "label": "Female", "emoji": "👩" }
]
},
"navigation": {
"defaultNextScreenId": "email"
}
},
{
"id": "email",
"template": "email",
"title": { "text": "Enter your email" },
"emailInput": {
"label": "Email",
"placeholder": "your@email.com"
}
}
]
}
```
**Результат после прохождения воронки:**
Если пользователь выбрал:
- Gender: Male
- Relationship Status: Single
- Partner Gender: Female
- Email: user@example.com
Payload регистрации будет:
```typescript
{
email: "user@example.com",
timezone: "Europe/Moscow",
locale: "en",
source: "dating-funnel",
sign: true,
signDate: "2024-01-01T00:00:00.000Z",
feature: "stripe",
profile: {
gender: "male",
relationship_status: "single"
},
partner: {
gender: "female"
}
}
```
## Ограничения
1. **Только для single selection** - работает только с `selectionType: "single"`
2. **Только ID опции** - передается именно `id` выбранной опции, а не `label` или `value`
3. **Перезапись значений** - если несколько экранов используют один и тот же ключ, последний перезапишет предыдущий
4. **Обязательный email экран** - данные передаются только при авторизации через email экран
## Техническая реализация
### Файлы
- **types.ts** - добавлено поле `registrationFieldKey` в `ListScreenDefinition`
- **ListScreenConfig.tsx** - UI для настройки ключа в админке
- **registrationHelpers.ts** - утилиты `buildRegistrationDataFromAnswers()` и `buildSessionDataFromScreen()`
- **FunnelRuntime.tsx** - вызывает `buildSessionDataFromScreen()` при переходе вперед и передает в `updateSession()`
- **useAuth.ts** - принимает `registrationData` и объединяет с базовым payload
- **EmailTemplate.tsx** - вызывает `buildRegistrationDataFromAnswers()` и передает в `useAuth`
- **screenRenderer.tsx** - передает `answers` в `EmailTemplate`
### Функция buildRegistrationDataFromAnswers
Используется при авторизации для сбора данных со всех экранов воронки:
```typescript
export function buildRegistrationDataFromAnswers(
funnel: FunnelDefinition,
answers: FunnelAnswers
): RegistrationDataObject {
const registrationData: RegistrationDataObject = {};
for (const screen of funnel.screens) {
if (screen.template === "list") {
const listScreen = screen as ListScreenDefinition;
if (
listScreen.list.selectionType === "single" &&
listScreen.list.registrationFieldKey &&
answers[screen.id] &&
answers[screen.id].length > 0
) {
const selectedId = answers[screen.id][0];
const fieldKey = listScreen.list.registrationFieldKey;
// Устанавливаем значение по многоуровневому ключу
setNestedValue(registrationData, fieldKey, selectedId);
}
}
}
return registrationData;
}
```
### Функция buildSessionDataFromScreen
Используется при переходе вперед для сбора данных с текущего экрана:
```typescript
export function buildSessionDataFromScreen(
screen: { template: string; id: string; list?: { selectionType?: string; registrationFieldKey?: string } },
selectedIds: string[]
): RegistrationDataObject {
const sessionData: RegistrationDataObject = {};
if (screen.template === "list" && screen.list) {
const { selectionType, registrationFieldKey } = screen.list;
if (
selectionType === "single" &&
registrationFieldKey &&
selectedIds.length > 0
) {
const selectedId = selectedIds[0];
setNestedValue(sessionData, registrationFieldKey, selectedId);
}
}
return sessionData;
}
```
## Best Practices
1. **Используйте понятные ID** - ID опций должны соответствовать ожидаемым значениям на сервере
2. **Документируйте ключи** - ведите список используемых `registrationFieldKey` для избежания конфликтов
3. **Проверяйте типы** - убедитесь что ID опций соответствуют типам полей в `ICreateAuthorizeRequest`
4. **Тестируйте payload** - проверяйте что данные корректно попадают в регистрацию
## Примеры использования
### Простой профиль
```json
{
"list": {
"selectionType": "single",
"registrationFieldKey": "profile.gender",
"options": [...]
}
}
```
### Вложенная структура
```json
{
"list": {
"selectionType": "single",
"registrationFieldKey": "partner.birthplace.country",
"options": [
{ "id": "US", "label": "United States" },
{ "id": "UK", "label": "United Kingdom" }
]
}
}
```
### Без регистрации (обычный list)
```json
{
"list": {
"selectionType": "single",
// registrationFieldKey не указан - данные не попадут в регистрацию
"options": [...]
}
}
```

View File

@ -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} />`).
Такой подход гарантирует, что конструктор и рантайм используют одну и ту же схему данных, а визуальные шаблоны ведут себя предсказуемо при изменении конфигурации.

View File

@ -12,6 +12,9 @@ const nextConfig: NextConfig = {
env: {
FUNNEL_BUILD_VARIANT: buildVariant,
NEXT_PUBLIC_FUNNEL_BUILD_VARIANT: buildVariant,
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
DEV_LOGGER_SERVER_ENABLED: process.env.DEV_LOGGER_SERVER_ENABLED,
NEXT_PUBLIC_AUTH_REDIRECT_URL: process.env.NEXT_PUBLIC_AUTH_REDIRECT_URL,
},
};

26
package-lock.json generated
View File

@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@lottiefiles/dotlottie-react": "^0.17.4",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.3",
@ -21,6 +22,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dotenv": "^17.2.2",
"idb": "^8.0.3",
"lucide-react": "^0.544.0",
"mongoose": "^8.18.2",
"next": "15.5.3",
@ -1711,6 +1713,24 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lottiefiles/dotlottie-react": {
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-react/-/dotlottie-react-0.17.4.tgz",
"integrity": "sha512-PsWq0l+Q/sGwnjWMiRJC1GUmsXFYB8zc5TacWblfaU9EQzqJzBeblk5rqtac/EDQi9QiXqpojPgWsofJX97swg==",
"license": "MIT",
"dependencies": {
"@lottiefiles/dotlottie-web": "0.54.0"
},
"peerDependencies": {
"react": "^17 || ^18 || ^19"
}
},
"node_modules/@lottiefiles/dotlottie-web": {
"version": "0.54.0",
"resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-web/-/dotlottie-web-0.54.0.tgz",
"integrity": "sha512-Jc/n4i9siOXo9/1CVhKkrWC8pxxsKqKwxYfrL4DFQP/cLUAeAO0TqFPQFx9Klh1m7T+/1RPFriycOcF8gW3ZtQ==",
"license": "MIT"
},
"node_modules/@mdx-js/react": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@mdx-js/react/-/react-3.1.1.tgz",
@ -7510,6 +7530,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/idb": {
"version": "8.0.3",
"resolved": "https://registry.npmjs.org/idb/-/idb-8.0.3.tgz",
"integrity": "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg==",
"license": "ISC"
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",

View File

@ -7,8 +7,8 @@
"dev:frontend": "node ./scripts/run-with-variant.mjs dev frontend -- --turbopack",
"dev:full": "node ./scripts/run-with-variant.mjs dev full -- --turbopack",
"build": "npm run build:frontend",
"build:frontend": "npm run bake:funnels && node ./scripts/run-with-variant.mjs build frontend -- --turbopack",
"build:full": "npm run bake:funnels && node ./scripts/run-with-variant.mjs build full -- --turbopack",
"build:frontend": "node ./scripts/run-with-variant.mjs build frontend -- --turbopack",
"build:full": "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",
@ -21,6 +21,7 @@
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@lottiefiles/dotlottie-react": "^0.17.4",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-avatar": "^1.1.10",
"@radix-ui/react-checkbox": "^1.3.3",
@ -33,6 +34,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dotenv": "^17.2.2",
"idb": "^8.0.3",
"lucide-react": "^0.544.0",
"mongoose": "^8.18.2",
"next": "15.5.3",

2743
public/funnels/soulmate.json Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,58 @@
<svg width="342" height="336" viewBox="0 0 342 336" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_d_445_2736)">
<path d="M279.512 46.9438C220.739 3.86271 170.996 56.5473 170.996 56.5473C170.996 56.5473 121.263 3.85272 62.4805 46.9438C6.28194 88.1362 11.5106 241.783 170.996 306C330.471 241.783 335.71 88.1362 279.512 46.9438Z" fill="url(#paint0_linear_445_2736)"/>
<path d="M171.006 306C330.481 241.783 335.72 88.1357 279.521 46.9434C260.173 32.7629 241.813 28.9554 225.877 30.2346C233.241 29.7649 264.075 30.2746 269.862 71.297C276.438 117.946 227.663 222.775 145.73 232.929C75.1229 241.683 45.1876 192.885 44.1299 191.117C63.3084 235.227 103.212 278.698 171.016 306H171.006Z" fill="url(#paint1_linear_445_2736)"/>
<path opacity="0.7" d="M87.3571 41.8068C87.3571 41.8068 50.2273 52.2099 39.271 100.388C28.3147 148.565 55.0269 196.893 55.0269 196.893C55.0269 196.893 49.5488 139.232 87.3571 41.7969V41.8068Z" fill="url(#paint2_linear_445_2736)"/>
<path opacity="0.7" d="M146.549 288.712C146.549 288.712 213.853 285.644 271.449 214.002C271.449 214.002 240.266 263.109 174.498 301.064C174.498 301.064 157.445 297.566 146.549 288.712Z" fill="url(#paint3_linear_445_2736)"/>
<path d="M153.125 58.4064C171.754 85.8479 197.788 94.9019 197.788 94.9019C197.788 94.9019 213.574 28.6763 187.281 43.7162C177.003 50.2019 171.006 56.5476 171.006 56.5476C171.006 56.5476 143.206 27.0974 104.609 30.2453C105.068 30.2553 134.634 31.1746 153.125 58.4064Z" fill="url(#paint4_linear_445_2736)"/>
<path opacity="0.7" d="M101.945 41.8068C75.2328 41.8068 44.14 60.0446 36.6262 112.759C30.0105 159.178 54.9865 241.943 139.504 221.786C224.031 201.63 270.98 71.7168 241.044 54.0786C211.109 36.4404 195.962 84.5881 198.077 116.777C198.077 116.777 149.123 41.7969 101.935 41.7969L101.945 41.8068Z" fill="url(#paint5_linear_445_2736)"/>
</g>
<defs>
<filter id="filter0_d_445_2736" x="0" y="0" width="341.999" height="336" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="15"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_445_2736"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_445_2736" result="shape"/>
</filter>
<linearGradient id="paint0_linear_445_2736" x1="289.051" y1="238.575" x2="132.258" y2="107.903" gradientUnits="userSpaceOnUse">
<stop stop-color="#EF4B9F"/>
<stop offset="1" stop-color="#E6332A"/>
</linearGradient>
<linearGradient id="paint1_linear_445_2736" x1="105.038" y1="79.9512" x2="547.056" y2="473.234" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint2_linear_445_2736" x1="19.1445" y1="17.5132" x2="197.95" y2="366.373" gradientUnits="userSpaceOnUse">
<stop stop-color="#FCEA10"/>
<stop offset="0.33" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="0.66" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.89" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="1" stop-color="#FF9500" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint3_linear_445_2736" x1="69.0263" y1="393.761" x2="290.176" y2="173.998" gradientUnits="userSpaceOnUse">
<stop stop-color="#FCEA10"/>
<stop offset="0.33" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="0.66" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.89" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="1" stop-color="#FF9500" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint4_linear_445_2736" x1="245.804" y1="87.6267" x2="-130.024" y2="-60.1825" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint5_linear_445_2736" x1="9.70438" y1="-29.0956" x2="244.871" y2="230.392" gradientUnits="userSpaceOnUse">
<stop stop-color="#FCEA10"/>
<stop offset="0.33" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="0.66" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.89" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="1" stop-color="#FF9500" stop-opacity="0"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@ -0,0 +1,208 @@
<svg width="858" height="512" viewBox="0 0 858 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M446.951 154.46C446.951 154.46 506.041 164.97 547.501 192.46C588.961 219.95 608.501 183.91 572.381 173.46C572.381 173.46 582.841 152.42 610.371 146.77C637.901 141.12 654.251 111.24 638.611 89.5898C638.611 89.5898 668.261 124.18 652.731 157.71C637.201 191.24 624.491 197.11 637.201 230.53C649.911 263.95 695.081 341.96 659.561 401.59C631.811 448.17 642.191 461.59 642.191 461.59C642.191 461.59 646.381 427.71 695.081 429.83C695.081 429.83 667.551 445.83 673.201 480.18H439.481C439.481 480.18 388.001 205.91 446.951 154.46Z" fill="url(#paint0_linear_445_2737)"/>
<path opacity="0.7" d="M446.951 154.46C446.951 154.46 506.041 164.97 547.501 192.46C588.961 219.95 608.501 183.91 572.381 173.46C572.381 173.46 582.841 152.42 610.371 146.77C637.901 141.12 653.961 110.77 638.611 89.5898C638.611 89.5898 668.261 124.18 652.731 157.71C637.201 191.24 624.491 197.11 637.201 230.53C649.911 263.95 695.081 341.96 659.561 401.59C631.811 448.17 642.191 461.59 642.191 461.59C642.191 461.59 646.381 427.71 695.081 429.83C695.081 429.83 667.551 445.83 673.201 480.18H439.481C439.481 480.18 388.001 205.91 446.951 154.46Z" fill="url(#paint1_linear_445_2737)"/>
<path d="M254.05 480.18C254.05 480.18 213.11 467.97 223.46 432.68C233.81 397.39 187.42 387.49 182.05 372.62C173.23 348.19 198.99 339.93 185.34 315.05C185.34 315.05 196.87 322.29 197.58 346.04C198.29 369.79 221.58 373.41 249.82 372.96C249.82 372.96 190.71 295.36 209.35 255.79C226.88 218.58 278.06 204.67 279.47 196.07C280.88 187.47 260.65 183.86 260.65 183.86C260.65 183.86 283.36 165.65 302.41 177.53C321.47 189.41 349.7 202.3 377.94 188.73C377.94 188.73 382.29 174.59 359.94 159.44C339.99 145.92 331.35 130.71 366.53 97.4603C396.41 69.2203 381.12 54.0303 381.12 54.0303C381.12 54.0303 404.06 50.9803 402.65 91.6903C401.24 132.41 415.73 148.01 446.95 154.46C478.17 160.91 501.47 193.14 507.12 231.14C512.77 269.14 495.94 286.56 519.94 322.75C543.94 358.94 556.18 398.75 552.41 430.42C552.41 430.42 540.43 390.72 508.88 370.59C469 345.14 467.59 319.36 467.59 319.36C467.59 319.36 473.59 335.31 462.3 352.27C451.01 369.23 435.12 388.24 449.59 426.24L453.47 403.85C453.47 403.85 488.06 426.24 492.29 445.92C496.52 465.6 478.47 480.19 478.47 480.19H254.06L254.05 480.18Z" fill="url(#paint2_linear_445_2737)"/>
<path opacity="0.7" d="M254.05 480.18C254.05 480.18 213.11 467.97 223.46 432.68C233.81 397.39 187.42 387.49 182.05 372.62C173.23 348.19 198.99 339.93 185.34 315.05C185.34 315.05 196.87 322.29 197.58 346.04C198.29 369.79 221.58 373.41 249.82 372.96C249.82 372.96 190.71 295.36 209.35 255.79C226.88 218.58 278.06 204.67 279.47 196.07C280.88 187.47 260.65 183.86 260.65 183.86C260.65 183.86 283.36 165.65 302.41 177.53C321.47 189.41 349.7 202.3 377.94 188.73C377.94 188.73 382.29 174.59 359.94 159.44C339.99 145.92 331.35 130.71 366.53 97.4603C396.41 69.2203 381.12 54.0303 381.12 54.0303C381.12 54.0303 404.06 50.9803 402.65 91.6903C401.24 132.41 415.73 148.01 446.95 154.46C478.17 160.91 501.47 193.14 507.12 231.14C512.77 269.14 495.94 286.56 519.94 322.75C543.94 358.94 556.18 398.75 552.41 430.42C552.41 430.42 540.43 390.72 508.88 370.59C469 345.14 467.59 319.36 467.59 319.36C467.59 319.36 473.59 335.31 462.3 352.27C451.01 369.23 435.12 388.24 449.59 426.24L453.47 403.85C453.47 403.85 488.06 426.24 492.29 445.92C496.52 465.6 478.47 480.19 478.47 480.19H254.06L254.05 480.18Z" fill="url(#paint3_linear_445_2737)"/>
<path opacity="0.7" d="M643.871 480.18C643.871 480.18 607.101 452.27 611.781 417.79C616.861 380.4 696.011 304.75 610.091 235.45C534.521 174.49 453.811 165.82 453.811 165.82C453.811 165.82 539.071 183.87 560.501 261.78C578.671 327.82 527.191 385.11 530.541 429.82C530.541 429.82 540.581 420.29 549.361 436.53C558.131 452.77 536.741 470.77 536.741 470.77C536.741 470.77 524.291 408.45 484.391 393.37C484.391 393.37 503.611 447.59 478.461 480.18H643.871Z" fill="url(#paint4_linear_445_2737)"/>
<path opacity="0.7" d="M568.731 480.2C568.731 480.2 572.461 426.43 604.351 369.08C636.241 311.73 611.351 241.47 566.841 209.99C566.841 209.99 683.081 258.23 653.901 373.44C653.901 373.44 626.191 389.88 618.371 417.79C606.721 459.35 635.781 480.18 635.781 480.18L568.721 480.2H568.731Z" fill="url(#paint5_linear_445_2737)"/>
<path opacity="0.7" d="M254.051 480.18C254.051 480.18 229.701 466.95 232.871 437.43C236.051 407.91 209.251 393.88 195.111 381.45C180.971 369.01 188.761 347.41 188.761 347.41C188.761 347.41 182.051 371.61 209.351 378.17C236.641 384.73 272.881 402.83 272.881 402.83C272.881 402.83 250.171 426.58 264.291 451.52C278.411 476.46 358.531 480.19 358.531 480.19H254.061L254.051 480.18Z" fill="url(#paint6_linear_445_2737)"/>
<path opacity="0.7" d="M358.52 480.18C358.52 480.18 277.58 482.44 245.11 443.08C245.11 443.08 242.76 402.1 287.46 382.1C332.17 362.1 319.46 312.99 319.46 312.99C319.46 312.99 349.58 386.53 321.34 422.27C321.34 422.27 280.16 409.6 270.99 427.92C261.81 446.24 284.75 475.42 358.52 480.17V480.18Z" fill="url(#paint7_linear_445_2737)"/>
<path opacity="0.7" d="M502.231 480.18C502.231 480.18 519.881 447.61 490.971 404.63C462.061 361.65 486.361 334.77 488.331 288.27C490.301 241.77 472.891 214.62 472.891 214.62C472.891 214.62 490.981 279.25 454.541 336.74C422.941 386.59 399.011 429.5 459.251 458.91C459.251 458.91 462.901 446.29 478.491 450.65C497.251 455.91 502.251 480.17 502.251 480.17L502.231 480.18Z" fill="url(#paint8_linear_445_2737)"/>
<path opacity="0.7" d="M472.961 391.18C472.961 391.18 505.521 402.08 516.671 414.53L510.471 414.46C510.471 414.46 517.741 432.83 507.591 458.63C507.591 458.63 497.441 422.11 472.961 391.18Z" fill="url(#paint9_linear_445_2737)"/>
<path opacity="0.7" d="M422.761 449.64C385.351 443.87 372.291 437.76 372.291 437.76C372.291 437.76 381.471 453.71 412.531 462.87C412.531 462.87 388.181 483.23 343.351 471.35C298.531 459.47 283.001 480.17 283.001 480.17H254.061C254.061 480.17 374.061 439.72 417.821 366.98C461.581 294.24 434.761 233.62 398.061 202.85C361.351 172.09 371.821 131.88 384.411 110.56C401.121 82.2903 389.121 58.5303 389.121 58.5303C389.121 58.5303 402.301 65.5403 399.471 98.1103C396.651 130.68 408.881 151.72 437.821 160.77C437.821 160.77 406.881 174.79 427.351 202.96C447.821 231.12 478.121 301.54 451.551 354.07C424.981 406.6 398.761 435.15 422.761 449.63V449.64Z" fill="url(#paint10_linear_445_2737)"/>
<path d="M358.05 71.4404C358.05 71.4404 367.36 54.1504 349.11 41.4004C339.05 34.3804 317.53 37.5204 317.53 37.5204C317.53 37.5204 340.73 63.9204 358.05 71.4304V71.4404Z" fill="url(#paint11_linear_445_2737)"/>
<path opacity="0.7" d="M313.351 191.78C313.351 191.78 351.821 208.41 381.111 191.78C381.111 191.78 384.281 175.46 360.271 156.83C336.261 138.2 345.941 114.72 372.471 94.2305C372.471 94.2305 358.501 127.29 374.291 154C392.881 185.44 394.051 221.18 394.051 221.18C394.051 221.18 340.871 216.2 313.341 191.77L313.351 191.78Z" fill="url(#paint12_linear_445_2737)"/>
<path opacity="0.7" d="M383.941 55.3799C383.941 55.3799 393.121 69.1799 382.531 83.6499C371.941 98.1299 342.971 120.47 348.161 141.64C353.351 162.82 379.941 158.52 384.881 192.45C384.881 192.45 380.411 160.33 367.591 146.31C354.771 132.29 390.521 98.1999 394.061 85.0099C394.061 85.0099 395.711 63.7499 383.941 55.3799Z" fill="url(#paint13_linear_445_2737)"/>
<path d="M428.26 129.57C428.26 129.57 419.7 123.96 428.26 111.25C428.26 111.25 437.98 118.35 428.26 129.57Z" fill="url(#paint14_linear_445_2737)"/>
<path d="M231.071 188.26C231.071 188.26 191.271 203.69 185.631 180.32C185.631 180.32 186.511 176.92 196.811 174.5C196.811 174.5 209.081 187.84 231.071 188.26Z" fill="url(#paint15_linear_445_2737)"/>
<path d="M178.76 301.94C178.76 301.94 165.11 293.57 167.47 272.99C169.81 252.49 163 241.1 163 241.1C163 241.1 175.7 253.09 180.17 273.45C180.17 273.45 172.41 284.31 178.76 301.95V301.94Z" fill="url(#paint16_linear_445_2737)"/>
<path d="M235.471 70.3497C235.471 70.3497 228.46 63.8397 232.13 53.9697C232.13 53.9697 241.67 60.5497 243.87 72.3097L235.471 70.3497Z" fill="url(#paint17_linear_445_2737)"/>
<path d="M632.26 108.3C632.26 108.3 637.2 93.48 618.67 68.06C606.47 51.33 609.67 31 609.67 31C609.67 31 596.96 53.24 604.38 78.65C604.38 78.65 623.09 87.12 632.26 108.3Z" fill="url(#paint18_linear_445_2737)"/>
<path d="M677.891 405.46C677.891 405.46 686.891 396.89 691.831 383.66C691.831 383.66 692.891 392.13 691.481 400.78L677.891 405.47V405.46Z" fill="url(#paint19_linear_445_2737)"/>
<path opacity="0.7" d="M625.29 233.59C625.29 233.59 591.67 203.24 599.43 182.06C607.19 160.88 642.84 143.59 649.9 125.94C649.9 125.94 645.78 161.12 634.37 176.06C619.55 195.47 625.29 233.59 625.29 233.59Z" fill="url(#paint20_linear_445_2737)"/>
<g filter="url(#filter0_d_445_2737)">
<path d="M533.592 201.534C474.819 158.453 425.076 211.137 425.076 211.137C425.076 211.137 375.344 158.443 316.561 201.534C260.362 242.726 265.591 396.373 425.076 460.59C584.552 396.373 589.79 242.726 533.592 201.534Z" fill="url(#paint21_linear_445_2737)"/>
<path d="M425.087 460.589C584.562 396.373 589.801 242.726 533.602 201.533C514.254 187.353 495.894 183.545 479.958 184.824C487.322 184.355 518.156 184.864 523.943 225.887C530.519 272.536 481.744 377.365 399.811 387.518C329.204 396.273 299.269 347.475 298.211 345.706C317.389 389.817 357.293 433.288 425.097 460.589H425.087Z" fill="url(#paint22_linear_445_2737)"/>
<path opacity="0.7" d="M341.437 196.397C341.437 196.397 304.307 206.8 293.351 254.977C282.395 303.155 309.107 351.483 309.107 351.483C309.107 351.483 303.629 293.821 341.437 196.387V196.397Z" fill="url(#paint23_linear_445_2737)"/>
<path opacity="0.7" d="M400.629 443.301C400.629 443.301 467.933 440.233 525.529 368.591C525.529 368.591 494.346 417.698 428.578 455.652C428.578 455.652 411.525 452.155 400.629 443.301Z" fill="url(#paint24_linear_445_2737)"/>
<path d="M407.205 212.996C425.834 240.438 451.868 249.492 451.868 249.492C451.868 249.492 467.654 183.266 441.361 198.306C431.083 204.792 425.086 211.137 425.086 211.137C425.086 211.137 397.286 181.687 358.689 184.835C359.148 184.845 388.715 185.764 407.205 212.996Z" fill="url(#paint25_linear_445_2737)"/>
<path opacity="0.7" d="M356.027 196.397C329.315 196.397 298.222 214.634 290.708 267.349C284.093 313.768 309.069 396.532 393.586 376.376C478.113 356.219 525.062 226.307 495.127 208.668C465.191 191.03 450.044 239.178 452.159 271.366C452.159 271.366 403.205 196.387 356.017 196.387L356.027 196.397Z" fill="url(#paint26_linear_445_2737)"/>
</g>
<defs>
<filter id="filter0_d_445_2737" x="254.08" y="154.59" width="342" height="336" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="15"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_445_2737"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_445_2737" result="shape"/>
</filter>
<linearGradient id="paint0_linear_445_2737" x1="587.741" y1="138.44" x2="503.741" y2="671.38" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.36" stop-color="#FF8004"/>
<stop offset="1" stop-color="#FF540D"/>
</linearGradient>
<linearGradient id="paint1_linear_445_2737" x1="686.321" y1="121.37" x2="545.141" y2="338.79" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint2_linear_445_2737" x1="277.46" y1="148.2" x2="473.6" y2="581.35" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.36" stop-color="#FF8004"/>
<stop offset="1" stop-color="#FF540D"/>
</linearGradient>
<linearGradient id="paint3_linear_445_2737" x1="141.2" y1="146.18" x2="513.91" y2="400.29" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint4_linear_445_2737" x1="784.851" y1="294.28" x2="344.231" y2="347.91" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint5_linear_445_2737" x1="734.201" y1="319.4" x2="438.431" y2="376.57" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint6_linear_445_2737" x1="206.201" y1="275.82" x2="379.651" y2="662.98" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint7_linear_445_2737" x1="200.98" y1="267.68" x2="403.45" y2="542.48" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint8_linear_445_2737" x1="409.381" y1="182.3" x2="492.271" y2="463.58" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint9_linear_445_2737" x1="484.421" y1="362.8" x2="512.601" y2="471.42" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint10_linear_445_2737" x1="167.741" y1="133.76" x2="583.411" y2="499.87" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint11_linear_445_2737" x1="325.56" y1="28.2504" x2="483.18" y2="231.68" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.36" stop-color="#FF8004"/>
<stop offset="1" stop-color="#FF540D"/>
</linearGradient>
<linearGradient id="paint12_linear_445_2737" x1="288.181" y1="68.8805" x2="450.061" y2="288.13" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint13_linear_445_2737" x1="351.391" y1="44.4099" x2="408.841" y2="229.46" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint14_linear_445_2737" x1="428.86" y1="88.54" x2="428.11" y2="159.21" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.36" stop-color="#FF8004"/>
<stop offset="1" stop-color="#FF540D"/>
</linearGradient>
<linearGradient id="paint15_linear_445_2737" x1="188.558" y1="179.052" x2="248.325" y2="205.478" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.36" stop-color="#FF8004"/>
<stop offset="1" stop-color="#FF540D"/>
</linearGradient>
<linearGradient id="paint16_linear_445_2737" x1="162.3" y1="250.35" x2="193.63" y2="325.28" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.36" stop-color="#FF8004"/>
<stop offset="1" stop-color="#FF540D"/>
</linearGradient>
<linearGradient id="paint17_linear_445_2737" x1="232.41" y1="55.4397" x2="251.15" y2="96.9597" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.36" stop-color="#FF8004"/>
<stop offset="1" stop-color="#FF540D"/>
</linearGradient>
<linearGradient id="paint18_linear_445_2737" x1="601" y1="41.53" x2="648.3" y2="146" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.36" stop-color="#FF8004"/>
<stop offset="1" stop-color="#FF540D"/>
</linearGradient>
<linearGradient id="paint19_linear_445_2737" x1="682.431" y1="389.19" x2="691.591" y2="409.43" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.36" stop-color="#FF8004"/>
<stop offset="1" stop-color="#FF540D"/>
</linearGradient>
<linearGradient id="paint20_linear_445_2737" x1="594.13" y1="269.66" x2="646.01" y2="93.5404" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint21_linear_445_2737" x1="543.131" y1="393.165" x2="386.338" y2="262.492" gradientUnits="userSpaceOnUse">
<stop stop-color="#EF4B9F"/>
<stop offset="1" stop-color="#E6332A"/>
</linearGradient>
<linearGradient id="paint22_linear_445_2737" x1="359.119" y1="234.541" x2="801.137" y2="627.824" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint23_linear_445_2737" x1="273.225" y1="172.103" x2="452.03" y2="520.963" gradientUnits="userSpaceOnUse">
<stop stop-color="#FCEA10"/>
<stop offset="0.33" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="0.66" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.89" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="1" stop-color="#FF9500" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint24_linear_445_2737" x1="323.106" y1="548.35" x2="544.256" y2="328.587" gradientUnits="userSpaceOnUse">
<stop stop-color="#FCEA10"/>
<stop offset="0.33" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="0.66" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.89" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="1" stop-color="#FF9500" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint25_linear_445_2737" x1="499.884" y1="242.217" x2="124.056" y2="94.4074" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint26_linear_445_2737" x1="263.786" y1="125.494" x2="498.953" y2="384.982" gradientUnits="userSpaceOnUse">
<stop stop-color="#FCEA10"/>
<stop offset="0.33" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="0.66" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.89" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="1" stop-color="#FF9500" stop-opacity="0"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -0,0 +1,522 @@
<svg width="858" height="512" viewBox="0 0 858 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M671.275 387.78C672.685 388.42 732.925 358.91 772.455 387.78C811.985 416.65 779.985 461.83 757.865 473.12C757.865 473.12 780.455 472.16 797.395 484.17H618.575L671.285 387.78H671.275Z" fill="url(#paint0_linear_445_2816)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.5" d="M671.275 387.78C672.685 388.42 732.925 358.91 772.455 387.78C811.985 416.65 779.985 461.83 757.865 473.12C757.865 473.12 780.455 472.16 797.395 484.17H618.575L671.285 387.78H671.275Z" fill="url(#paint1_linear_445_2816)"/>
<path d="M370.565 425.09C371.615 424.25 264.995 351.61 188.755 338.25C112.515 324.89 121.695 412.17 154.875 430.65C154.875 430.65 142.875 443.48 155.585 454.07C155.585 454.07 146.405 471.72 118.175 454.07C89.9451 436.42 49.7051 459.47 62.4151 483.94H444.345L370.565 425.09Z" fill="url(#paint2_linear_445_2816)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.5" d="M370.565 425.09C371.615 424.25 264.995 351.61 188.755 338.25C112.515 324.89 121.695 412.17 154.875 430.65C154.875 430.65 142.875 443.48 155.585 454.07C155.585 454.07 146.405 471.72 118.175 454.07C89.9451 436.42 49.7051 459.47 62.4151 483.94H444.345L370.565 425.09Z" fill="url(#paint3_linear_445_2816)"/>
<path d="M669.416 483.94C669.416 483.94 746.016 441.83 737.886 384.18C729.066 321.63 679.016 341.47 679.016 341.47L669.416 221.69C669.416 221.69 679.666 213.2 675.136 198.76C671.376 186.77 656.906 192.32 662.426 171.71C668.546 148.89 657.206 96.7596 594.506 136.77C594.506 136.77 571.646 79.2396 506.036 78.8896C440.426 78.5396 460.536 126.54 460.536 126.54C460.536 126.54 431.716 119.13 423.896 169.95L394.186 184.77C394.186 184.77 378.656 164.42 346.656 163.48C314.656 162.54 299.666 188.42 298.216 206.3C298.216 206.3 256.826 196.42 220.336 210.54C183.846 224.66 157.476 261.67 165.786 314.11C165.786 314.11 139.646 303.32 139.596 330.82C139.576 345.73 168.776 368.36 173.476 377.21C178.176 386.06 160.536 411.01 189.126 424.77C217.716 438.53 227.596 432.53 241.366 441.36C241.366 441.36 201.836 465.47 188.776 484.18L669.406 483.94H669.416Z" fill="url(#paint4_linear_445_2816)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M669.416 483.94C669.416 483.94 746.016 441.83 737.886 384.18C729.066 321.63 679.016 341.47 679.016 341.47L669.416 221.69C669.416 221.69 679.666 213.2 675.136 198.76C671.376 186.77 656.906 192.32 662.426 171.71C668.546 148.89 657.206 96.7596 594.506 136.77C594.506 136.77 571.646 79.2396 506.036 78.8896C440.426 78.5396 460.536 126.54 460.536 126.54C460.536 126.54 431.716 119.13 423.896 169.95L394.186 184.77C394.186 184.77 378.656 164.42 346.656 163.48C314.656 162.54 299.666 188.42 298.216 206.3C298.216 206.3 256.826 196.42 220.336 210.54C183.846 224.66 157.476 261.67 165.786 314.11C165.786 314.11 139.646 303.32 139.596 330.82C139.576 345.73 168.776 368.36 173.476 377.21C178.176 386.06 160.536 411.01 189.126 424.77C217.716 438.53 227.596 432.53 241.366 441.36C241.366 441.36 201.836 465.47 188.776 484.18L669.406 483.94H669.416Z" fill="url(#paint5_linear_445_2816)"/>
<path d="M384.156 189.59C384.156 189.59 381.066 174.06 382.126 159.24C382.126 159.24 390.216 171.83 406.836 166.83C421.486 162.42 405.776 149.77 393.596 160.12C393.596 160.12 371.066 151.71 377.186 111.71C377.186 111.71 392.086 139 438.986 162.53C485.886 186.06 509.726 191.71 509.726 191.71C509.726 191.71 487.766 178.65 466.706 160.3C466.706 160.3 524.216 154.65 549.666 195.95C549.666 195.95 551.536 190.77 560.246 194.07C568.956 197.37 565.076 217.83 590.076 225.13C590.076 225.13 582.366 218.07 575.066 206.07C575.066 206.07 571.066 176.42 590.076 143.36C590.076 143.36 584.006 172.89 592.636 193.83L604.006 196.18C604.006 196.18 604.756 210.34 613.886 206.3C620.006 203.59 621.886 197.56 627.296 197.36C639.766 196.89 646.006 217.01 646.006 217.01C646.006 217.01 670.816 213.38 691.066 233.36C707.536 249.62 703.536 290.19 703.536 290.19C703.536 290.19 701.886 278.88 691.296 272.12C691.296 272.12 676.006 276.72 677.646 295.62C679.296 314.53 687.996 333.84 687.996 333.84C687.996 333.84 681.226 312.04 700.046 305.47C700.046 305.47 697.166 321.69 699.526 330.81C701.876 339.93 711.056 360.64 711.056 380.4C711.056 380.4 684.236 423.46 635.526 447.69C635.526 447.69 684.466 422.51 707.996 421.55C707.996 421.55 725.876 435.93 737.876 468.4C737.876 468.4 688.696 446.75 666.346 464.87C648.666 479.2 669.406 483.93 669.406 483.93H374.296C374.296 483.93 329.176 468.87 327.766 395.46C326.356 322.05 364.476 260.57 364.476 260.57L384.156 189.58V189.59Z" fill="url(#paint6_linear_445_2816)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M384.156 189.59C384.156 189.59 381.066 174.06 382.126 159.24C382.126 159.24 390.216 171.83 406.836 166.83C421.486 162.42 405.776 149.77 393.596 160.12C393.596 160.12 371.066 151.71 377.186 111.71C377.186 111.71 392.086 139 438.986 162.53C485.886 186.06 509.726 191.71 509.726 191.71C509.726 191.71 487.766 178.65 466.706 160.3C466.706 160.3 524.216 154.65 549.666 195.95C549.666 195.95 551.536 190.77 560.246 194.07C568.956 197.37 565.076 217.83 590.076 225.13C590.076 225.13 582.366 218.07 575.066 206.07C575.066 206.07 571.066 176.42 590.076 143.36C590.076 143.36 584.006 172.89 592.636 193.83L604.006 196.18C604.006 196.18 604.756 210.34 613.886 206.3C620.006 203.59 621.886 197.56 627.296 197.36C639.766 196.89 646.006 217.01 646.006 217.01C646.006 217.01 670.816 213.38 691.066 233.36C707.536 249.62 703.536 290.19 703.536 290.19C703.536 290.19 701.886 278.88 691.296 272.12C691.296 272.12 676.006 276.72 677.646 295.62C679.296 314.53 687.996 333.84 687.996 333.84C687.996 333.84 681.226 312.04 700.046 305.47C700.046 305.47 697.166 321.69 699.526 330.81C701.876 339.93 711.056 360.64 711.056 380.4C711.056 380.4 684.236 423.46 635.526 447.69C635.526 447.69 684.466 422.51 707.996 421.55C707.996 421.55 725.876 435.93 737.876 468.4C737.876 468.4 688.696 446.75 666.346 464.87C648.666 479.2 669.406 483.93 669.406 483.93H374.296C374.296 483.93 329.176 468.87 327.766 395.46C326.356 322.05 364.476 260.57 364.476 260.57L384.156 189.58V189.59Z" fill="url(#paint7_linear_445_2816)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M438.986 483.94C438.986 483.94 456.946 417.68 444.406 340.01C431.866 262.34 403.396 198.76 403.396 198.76C403.396 198.76 426.595 216.64 436.005 236.88V217.47C436.005 217.47 491.725 213.82 538.455 241.11C585.185 268.4 548.595 438.76 479.835 483.93L465.186 484.17C465.186 484.17 481.865 474.05 484.705 442.99C484.705 442.99 458.216 456.72 442.836 484.17L438.986 483.93V483.94Z" fill="url(#paint8_linear_445_2816)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M666.355 464.88C688.705 446.76 737.885 468.41 737.885 468.41C725.885 435.94 708.005 421.56 708.005 421.56C686.635 422.44 644.305 443.28 636.715 447.1C636.325 447.3 635.925 447.5 635.535 447.7C635.535 447.7 635.955 447.49 636.715 447.1C684.665 422.76 711.065 380.41 711.065 380.41C711.065 360.65 701.885 339.94 699.535 330.82C697.185 321.7 700.055 305.48 700.055 305.48C681.235 312.04 688.005 333.85 688.005 333.85C688.005 333.85 679.295 314.54 677.655 295.63C676.005 276.72 691.305 272.13 691.305 272.13C700.245 277.84 702.805 286.79 703.395 289.44C693.655 236.93 621.265 213.78 621.265 213.78C621.265 213.78 657.185 242.54 668.015 271.28C678.835 300.01 668.015 366.21 668.015 366.21C668.015 366.21 699.545 364.9 690.135 379.47C680.725 394.04 640.485 411.25 640.485 411.25L621.265 409.84C592.865 433.84 525.105 457.84 525.105 457.84C532.555 435.25 536.235 387.8 536.235 387.8C512.275 460.59 454.365 483.96 454.365 483.96H669.425C669.425 483.96 648.685 479.24 666.365 464.9L666.355 464.88Z" fill="url(#paint9_linear_445_2816)"/>
<path style="mix-blend-mode:color-dodge" d="M298.225 418.11C401.195 376.71 404.575 277.65 368.285 251.09C355.795 241.95 343.935 239.5 333.645 240.32C338.395 240.02 358.315 240.35 362.045 266.8C366.295 296.88 334.795 364.46 281.895 371.01C236.305 376.65 216.975 345.19 216.295 344.05C228.675 372.49 254.445 400.51 298.225 418.12V418.11Z" fill="url(#paint10_linear_445_2816)"/>
<path style="mix-blend-mode:screen" opacity="0.7" d="M244.216 247.77C244.216 247.77 220.246 254.48 213.166 285.54C206.086 316.6 223.336 347.76 223.336 347.76C223.336 347.76 219.796 310.58 244.216 247.77Z" fill="url(#paint11_linear_445_2816)"/>
<path style="mix-blend-mode:multiply" d="M286.676 258.47C298.706 276.16 315.516 282 315.516 282C315.516 282 325.706 239.31 308.726 249C302.086 253.18 298.216 257.27 298.216 257.27C298.216 257.27 280.266 238.29 255.346 240.31C255.646 240.31 274.736 240.91 286.666 258.46L286.676 258.47Z" fill="url(#paint12_linear_445_2816)"/>
<path d="M368.286 251.09C365.336 248.93 362.416 247.14 359.546 245.68L362.156 249.64L352.996 253.93L369.186 253.03C375.156 259.42 379.666 275.15 379.666 275.15C377.016 299.95 364.816 307.61 364.816 307.61L358.436 308.66L359.766 318.62L355.786 324.49L338.536 307.61C338.536 307.61 346.766 328.64 346.766 333.42C346.766 338.2 344.116 348.81 344.116 348.81L352.076 365.04C342.786 373.85 328.456 380.65 328.456 380.65L308.286 379.06L299.796 382.06L278.296 379.06L284.666 388.88C284.666 388.88 291.566 385.7 295.546 388.88C299.526 392.06 298.226 398.43 298.226 398.43C282.766 400 255.636 393.52 255.016 393.38C266.876 402.8 281.176 411.24 298.226 418.1C401.196 376.7 404.576 277.64 368.286 251.08V251.09ZM300.676 401.72C302.536 396.94 302.096 391.64 302.096 391.64L308.906 399.51L300.676 401.72ZM322.876 393.05L311.726 382.79C311.726 382.79 327.296 383.5 339.416 389.25L322.876 393.05ZM358.966 358.24L348.486 348.29L350.146 342.45H353.266C350.346 335.28 347.026 326.13 347.026 326.13L360.026 340.38L358.966 358.25V358.24ZM371.436 311.4C371.706 317.64 368.386 323.74 368.386 323.74L361.886 314.52L371.436 311.4ZM369.226 335.29L364.006 322.37L373.286 331.9C373.286 331.9 370.646 334.85 369.226 335.29Z" fill="url(#paint13_linear_445_2816)"/>
<path d="M322.135 371.37C322.135 371.37 310.715 369.95 302.775 366.06V362.18L296.955 348.81L318.545 360.64C318.545 360.64 319.005 369.24 322.135 371.36V371.37Z" fill="url(#paint14_linear_445_2816)"/>
<path d="M243.705 370.22C243.705 370.22 257.415 376.15 272.985 378.54L266.525 382.07L255.905 378.72L254.405 376.51L243.705 370.23V370.22Z" fill="url(#paint15_linear_445_2816)"/>
<path d="M348.445 291.67L354.285 299.92L352.425 287.6C352.425 287.6 349.245 289.9 348.445 291.67Z" fill="url(#paint16_linear_445_2816)"/>
<path d="M323.275 265.35L328.845 256.06C328.845 256.06 338.535 253.8 345.835 254.33L331.105 259.8H338.005L323.275 265.35Z" fill="url(#paint17_linear_445_2816)"/>
<path d="M274.316 348.83C274.316 348.83 283.516 353.9 290.236 354.06L289.526 347.77L274.306 348.83H274.316Z" fill="url(#paint18_linear_445_2816)"/>
<g filter="url(#filter0_d_445_2816)">
<path d="M510.364 380.01C510.364 380.01 525.504 416.01 483.164 427.77C440.824 439.53 389.924 403.13 343.924 404.12C297.924 405.11 255.664 402.46 258.634 345.02C261.604 287.58 328.484 181.15 461.874 201.97C569.824 218.82 588.224 298.48 588.224 327.57C588.224 349.91 576.614 394.82 510.374 380.01H510.364Z" fill="url(#paint19_linear_445_2816)"/>
<path style="mix-blend-mode:multiply" opacity="0.35" d="M398.724 324.61C398.724 324.61 424.834 326.02 455.174 342.96C485.514 359.9 506.054 364.91 532.084 342.65C561.434 317.54 587.754 318.98 587.754 318.98C588.074 322.17 588.214 325.07 588.214 327.58C588.214 349.92 576.604 394.83 510.364 380.02C482.994 374.11 472.814 361.78 449.924 345.79C427.034 329.8 398.724 324.62 398.724 324.62V324.61Z" fill="url(#paint20_linear_445_2816)"/>
<path style="mix-blend-mode:multiply" opacity="0.35" d="M483.164 427.77C440.824 439.53 389.924 403.13 343.924 404.12C297.924 405.11 255.664 402.46 258.634 345.02C260.704 304.97 293.864 241.08 358.974 213.15C295.234 269.63 320.764 373.5 358.974 391.88C383.754 403.8 407.014 392.01 446.004 380.01C478.774 369.93 510.364 380.01 510.364 380.01C510.364 380.01 525.494 416.01 483.164 427.77Z" fill="url(#paint21_linear_445_2816)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M510.364 380.01C510.364 380.01 470.464 365.54 446.944 369.3C423.424 373.06 407.424 377.39 398.724 377.34C390.024 377.3 358.664 346.23 347.994 318.96C337.334 291.68 340.624 237.58 360.384 239.94C380.144 242.29 382.024 270.05 382.024 288.86C382.024 307.67 366.034 307.68 362.734 301.09C359.444 294.5 360.854 274.28 360.854 274.28C360.854 274.28 362.574 296.39 365.874 298.9C369.174 301.41 378.574 299.37 378.104 290.59C377.634 281.81 378.104 258.6 371.674 251.55C365.244 244.49 357.094 243.08 353.954 247.47C350.814 251.86 344.624 269.11 347.214 289.81C349.804 310.51 352.864 325.8 366.034 343.2C379.204 360.61 389.794 370.72 400.844 372.37C411.894 374.02 443.944 361.32 461.054 363.9C478.164 366.49 510.364 380.02 510.364 380.02V380.01Z" fill="url(#paint22_linear_445_2816)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M362.184 284.78C363.404 291.05 362.964 301.17 370.094 299.86C370.094 299.86 364.924 304.85 362.724 301.09C360.524 297.33 362.174 284.78 362.174 284.78H362.184Z" fill="url(#paint23_linear_445_2816)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M550.904 357.54C550.904 357.54 576.774 348.13 563.604 337.78C550.434 327.43 530.674 329.31 518.444 333.08C506.214 336.84 446.474 325.84 438.004 309.94C429.534 294.04 451.644 274.28 463.404 260.17C475.164 246.06 446.464 220.18 434.704 217.83C434.704 217.83 453.994 226.92 458.064 242.29C462.144 257.66 456.494 265.5 445.204 275.22C433.914 284.94 423.254 302.47 437.364 314.72C451.474 326.97 478.094 333.87 498.974 336.06C519.844 338.26 542.744 332.61 549.954 335.12C557.164 337.63 574.414 343.59 550.894 357.54H550.904Z" fill="url(#paint24_linear_445_2816)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M448.134 321.93C448.134 321.93 432.984 314.96 431.024 304.57C429.064 294.19 438.154 282.57 438.154 282.57C438.154 282.57 427.054 308.42 448.134 321.92V321.93Z" fill="url(#paint25_linear_445_2816)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M434.713 217.821C434.713 217.821 416.053 208.101 390.023 215.311C363.993 222.521 369.953 234.131 386.883 242.281C403.823 250.431 406.323 264.23 417.613 261.72C428.903 259.21 441.443 246.35 427.023 233.49C412.593 220.63 402.563 223.141 402.563 223.141C402.563 223.141 421.283 228.161 426.193 238.821C431.103 249.481 422.953 255.441 415.423 256.381C407.893 257.321 403.503 243.691 393.153 239.841C382.803 235.991 373.083 229.721 379.983 223.451C386.883 217.181 411.653 210.91 434.713 217.8V217.821Z" fill="url(#paint26_linear_445_2816)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M425.294 251.47C425.294 251.47 420.114 262.8 414.474 262.01C408.834 261.22 403.894 255.1 401.194 251.93C398.494 248.76 409.084 256.16 414.014 256.52C420.604 256.99 425.304 251.48 425.304 251.48L425.294 251.47Z" fill="url(#paint27_linear_445_2816)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M463.404 260.161C463.404 260.161 494.924 254.021 517.034 262.031C539.144 270.041 557.494 286.981 533.024 291.681C508.564 296.381 488.804 314.711 478.454 302.251C468.104 289.791 470.304 274.581 501.664 275.211C501.664 275.211 486.294 271.131 473.434 276.461C460.574 281.791 465.904 297.541 475.004 304.561C484.094 311.581 493.824 314.161 512.324 304.561C530.824 294.961 549.014 295.431 549.644 287.671C550.274 279.911 543.994 265.801 525.814 259.841C507.624 253.881 475.954 254.511 463.404 260.151V260.161Z" fill="url(#paint28_linear_445_2816)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M398.724 377.341C398.724 377.341 367.914 379.181 350.504 365.531C333.094 351.891 333.094 333.541 310.044 336.361C286.994 339.181 266.294 346.241 264.414 362.701C262.534 379.161 272.954 389.911 272.954 389.911C272.954 389.911 263.004 380.581 261.904 367.721C260.804 354.861 261.594 343.101 290.134 335.571C318.674 328.041 326.204 329.611 334.194 341.221C342.194 352.821 354.424 372.361 398.724 377.331V377.341Z" fill="url(#paint29_linear_445_2816)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M278.634 339.43C278.634 339.43 298.284 333.54 308.634 319.43C318.984 305.32 330.974 259.9 319.454 262.03C307.924 264.16 280.884 288.86 282.294 298.97C283.704 309.08 286.524 316.85 286.524 316.85C286.524 316.85 283.544 301.8 288.094 294.27C292.644 286.74 311.454 267.93 316.864 268.63C322.274 269.34 308.634 311.44 302.984 318.26C297.334 325.08 278.624 339.44 278.624 339.44L278.634 339.43Z" fill="url(#paint30_linear_445_2816)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M442.084 318.95C442.084 318.95 419.814 306.95 397.554 310.84C375.284 314.73 358.984 327.11 375.444 347.66C389.594 365.31 401.494 367.59 423.354 360.45C445.224 353.31 458.704 363.88 458.704 363.88C458.704 363.88 450.554 353.3 434.714 350.95C418.874 348.6 407.904 341.7 407.904 341.7C407.904 341.7 424.524 351.74 419.194 354.56C413.864 357.38 396.924 362.4 384.694 350.95C372.464 339.5 370.584 328.53 379.674 321.63C388.764 314.73 410.094 310.95 442.084 318.95Z" fill="url(#paint31_linear_445_2816)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M463.404 260.16C463.404 260.16 484.764 220.3 524.874 239.86C564.984 259.42 583.464 297.41 583.464 297.41C583.464 297.41 581.004 287.14 572.224 273.34C563.444 259.54 534.984 235.39 512.994 229.75C491.004 224.1 434.704 217.83 434.704 217.83C434.704 217.83 465.904 222.53 479.394 226.92C492.884 231.31 468.104 235.7 463.404 260.16Z" fill="url(#paint32_linear_445_2816)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M403.514 416.33C403.514 416.33 423.574 422.92 451.804 417.9C480.034 412.88 486.304 399.71 473.134 394.38C459.964 389.05 455.884 393.75 438.014 397.52C420.144 401.29 438.324 410.38 438.324 410.38C438.324 410.38 414.174 404.42 429.854 395.95C445.534 387.48 463.414 383.64 474.384 389.91C485.364 396.18 492.134 412.02 506.244 414.22L504.514 416.16C504.514 416.16 498.104 416.21 494.934 413.04C491.754 409.86 487.054 402.57 485.404 405.87C483.754 409.17 469.024 420.73 452.354 421.98C430.364 423.63 414.544 420.09 403.524 416.33H403.514Z" fill="url(#paint33_linear_445_2816)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M279.154 394.45C279.154 394.45 279.004 374.18 297.824 360.45C316.644 346.72 345.494 378.4 375.284 389.06C405.074 399.72 378.734 408.5 352.544 391.57C333.504 379.26 316.324 367.42 309.104 372.13C301.894 376.83 305.344 391.26 305.344 391.26C305.344 391.26 307.224 375.27 314.434 375.27C321.644 375.27 349.874 399.1 371.194 403.5C392.524 407.89 399.574 399.49 392.524 393.15C388.204 389.26 377.154 388.54 363.984 380.03C350.814 371.51 321.014 349.24 306.904 350.97C292.794 352.7 277.324 365.09 275.024 391.67C275.024 391.67 278.484 394.08 279.144 394.47L279.154 394.45Z" fill="url(#paint34_linear_445_2816)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M442.084 318.95C442.084 318.95 418.254 306.96 418.254 290.73C418.254 274.5 410.254 269.56 390.264 254.51C370.274 239.46 350.224 233.91 374.824 207.28L371.224 208.45C371.224 208.45 360.744 216.07 359.924 227.54C359.454 234.05 366.394 240.16 374.744 246.75C383.094 253.34 404.264 267.8 409.204 274.74C414.144 281.68 409.894 293.71 418.614 303.44C430.644 316.85 442.094 318.96 442.094 318.96L442.084 318.95Z" fill="url(#paint35_linear_445_2816)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M518.444 333.071C518.444 333.071 531.384 322.491 545.964 316.611C560.544 310.731 588.124 323.891 588.124 323.891L587.754 318.971C587.754 318.971 562.034 306.891 547.144 312.851C532.244 318.811 518.444 333.081 518.444 333.081V333.071Z" fill="url(#paint36_linear_445_2816)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M457.294 236.171C457.294 236.171 475.954 249.031 458.964 265.291C458.964 265.291 462.784 249.031 457.294 236.171Z" fill="url(#paint37_linear_445_2816)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M321.604 262.29C323.654 263.07 326.514 272.39 317.574 288.54C317.574 288.54 321.484 271.46 316.864 268.63C314.274 267.04 302.994 277.58 302.994 277.58C302.994 277.58 315.044 259.78 321.594 262.3L321.604 262.29Z" fill="url(#paint38_linear_445_2816)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M540.734 272.271C540.734 272.271 552.784 281.801 549.064 289.581C546.904 294.091 536.434 295.32 536.434 295.32C536.434 295.32 544.314 290.26 544.664 284.85C545.014 279.44 540.724 272.271 540.724 272.271H540.734Z" fill="url(#paint39_linear_445_2816)"/>
<path style="mix-blend-mode:multiply" opacity="0.2" d="M533.024 291.68C508.564 296.38 488.804 314.71 478.454 302.25C472.864 295.52 470.934 287.98 475.294 282.57C476.604 291.81 488.804 298.73 501.034 292.61C513.264 286.49 529.964 289.79 533.494 280.61C537.024 271.44 527.634 266.69 527.634 266.69C544.084 275.35 553.384 287.75 533.024 291.66V291.68Z" fill="url(#paint40_linear_445_2816)"/>
<path style="mix-blend-mode:multiply" opacity="0.2" d="M365.874 298.89C363.264 296.9 361.644 282.63 361.084 276.78C361.084 276.81 362.914 284.94 368.734 285.25C374.544 285.56 377.394 273.87 377.414 273.79C377.884 280.43 377.914 286.83 378.114 290.58C378.584 299.36 369.174 301.4 365.884 298.89H365.874Z" fill="url(#paint41_linear_445_2816)"/>
<path style="mix-blend-mode:multiply" opacity="0.2" d="M419.184 354.561C413.854 357.381 396.914 362.401 384.684 350.951C372.454 339.501 370.574 328.531 379.664 321.631C377.084 327.851 382.724 335.431 394.014 337.151C405.304 338.871 407.894 341.701 407.894 341.701C407.894 341.701 407.954 341.731 408.044 341.791C409.674 342.791 424.214 351.891 419.184 354.551V354.561Z" fill="url(#paint42_linear_445_2816)"/>
<path style="mix-blend-mode:multiply" opacity="0.2" d="M375.434 347.66C389.584 365.31 401.484 367.59 423.344 360.45C443.144 353.98 456.064 362.04 458.334 363.62C440.744 362.56 411.324 373.92 400.834 372.36C389.784 370.71 379.194 360.6 366.024 343.19C352.854 325.78 349.794 310.5 347.204 289.8C346.524 284.34 346.454 279.13 346.774 274.33C346.774 274.78 346.984 278.7 351.904 297.41C357.184 317.49 370.944 324.46 370.944 324.46C367.214 330.43 367.824 338.18 375.424 347.66H375.434Z" fill="url(#paint43_linear_445_2816)"/>
<path style="mix-blend-mode:multiply" opacity="0.2" d="M418.244 290.741C418.244 277.561 412.974 271.831 400.284 262.081C402.994 264.061 412.774 270.461 429.294 273.101C448.534 276.181 456.744 262.121 457.094 261.511C454.724 266.331 450.614 270.561 445.214 275.211C433.924 284.931 423.264 302.461 437.374 314.711C439.214 316.311 441.274 317.811 443.504 319.231L443.484 319.331L442.084 318.961C442.084 318.961 418.254 306.971 418.254 290.741H418.244Z" fill="url(#paint44_linear_445_2816)"/>
<path style="mix-blend-mode:screen" opacity="0.7" d="M560.464 303.91C548.474 302.04 539.764 302.5 527.844 311.91C515.924 321.32 485.724 333.08 466.094 319.75C446.464 306.42 458.064 296.89 467.944 303.3C477.824 309.71 484.894 322.14 511.474 310.84C531.424 302.36 540.854 296.8 555.754 299.89C570.654 302.97 570.494 305.48 560.454 303.91H560.464Z" fill="url(#paint45_linear_445_2816)"/>
<path style="mix-blend-mode:screen" opacity="0.7" d="M390.634 279.86C396.634 281.37 404.244 283.43 406.764 290.21C409.274 296.99 412.904 277.41 403.424 270.4C393.954 263.39 387.004 268.06 386.304 271.96C385.624 275.74 386.744 278.87 390.634 279.85V279.86Z" fill="url(#paint46_linear_445_2816)"/>
<path style="mix-blend-mode:screen" opacity="0.7" d="M342.013 251.93C338.473 270.89 335.913 301.55 346.033 322.14C356.143 342.72 363.623 363.19 346.593 343.19C329.563 323.2 327.923 297.32 331.923 272.27C335.923 247.22 343.683 243.03 342.023 251.92L342.013 251.93Z" fill="url(#paint47_linear_445_2816)"/>
<path style="mix-blend-mode:screen" opacity="0.7" d="M537.413 234.91C515.773 224.09 485.513 213.9 435.413 211.82C400.043 210.35 389.543 205.35 406.603 202.53C423.653 199.71 487.713 200.57 537.423 234.91H537.413Z" fill="url(#paint48_linear_445_2816)"/>
<path style="mix-blend-mode:screen" opacity="0.7" d="M450.394 337.151C450.394 337.151 422.484 324.101 404.604 323.411C386.724 322.721 398.504 314.661 414.204 316.811C429.904 318.961 446.124 330.971 450.394 337.141V337.151Z" fill="url(#paint49_linear_445_2816)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.35" d="M522.524 282.57C522.524 282.57 512.804 282.64 504.334 286.6C495.864 290.56 486.144 290.98 484.574 284.58C483.004 278.18 501.514 273.46 522.524 282.56V282.57Z" fill="url(#paint50_linear_445_2816)"/>
<path style="mix-blend-mode:screen" opacity="0.7" d="M402.474 225.5C402.474 225.5 419.084 227.82 423.014 239.46C425.774 247.66 418.564 254.02 414.804 250.73C411.044 247.44 413.834 235.07 402.474 225.51V225.5Z" fill="url(#paint51_linear_445_2816)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M491.274 289.51C491.274 289.51 486.154 290.62 483.744 287.2C481.334 283.78 486.944 280.25 486.944 280.25C486.944 280.25 486.154 285.58 491.264 289.51H491.274Z" fill="url(#paint52_linear_445_2816)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M414.474 229.75C414.474 229.75 423.624 234.64 424.244 242.87C425.054 253.63 419.544 253.34 418.244 246.05C416.954 238.76 416.584 233.33 414.474 229.75Z" fill="url(#paint53_linear_445_2816)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M439.44 205.613C439.505 204.361 433.495 203.03 426.016 202.641C418.537 202.252 412.421 202.951 412.356 204.203C412.291 205.455 418.301 206.785 425.78 207.175C433.259 207.564 439.374 206.865 439.44 205.613Z" fill="url(#paint54_linear_445_2816)"/>
<path d="M397.864 183.35C398.504 183.35 398.504 182.36 397.864 182.36C397.224 182.36 397.224 183.35 397.864 183.35Z" fill="url(#paint55_linear_445_2816)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M394.86 272.69C395.301 270.076 393.632 267.615 391.132 267.193C388.633 266.771 386.249 268.548 385.808 271.162C385.366 273.776 387.035 276.238 389.535 276.659C392.034 277.081 394.418 275.304 394.86 272.69Z" fill="url(#paint56_linear_445_2816)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M565.724 305.011C565.724 305.011 551.174 301.501 540.614 304.631C540.614 304.631 549.184 298.271 560.784 300.231C572.384 302.191 570.744 306.581 565.724 305.011Z" fill="url(#paint57_linear_445_2816)"/>
</g>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M511.146 484.18C511.146 484.18 556.476 451.83 609.766 436.3L572.966 467.01L592.376 464.89L598.056 456.42C598.056 456.42 662.016 437.36 696.246 430.66C696.246 430.66 641.316 448.66 590.076 477.6C590.076 477.6 556.786 470.54 538.456 483.95L511.156 484.19L511.146 484.18Z" fill="url(#paint58_linear_445_2816)"/>
<path d="M598.046 121.82L637.416 27C637.416 27 636.476 61.82 633.416 71.24L598.046 121.83V121.82Z" fill="url(#paint59_linear_445_2816)"/>
<path d="M510.595 145.12L485.535 123.939L492.945 119.229L510.595 145.12Z" fill="url(#paint60_linear_445_2816)"/>
<path d="M612.945 161.12L622.595 147C622.595 147 635.535 140.88 644.715 140.65L612.955 161.12H612.945Z" fill="url(#paint61_linear_445_2816)"/>
<path d="M718.115 333.84C718.115 333.84 720.465 300.49 725.645 283.04L739.995 273.59L718.115 333.84Z" fill="url(#paint62_linear_445_2816)"/>
<path d="M730.115 415.08C730.115 415.08 744.455 416.27 749.875 411.06V401.59L730.115 415.08Z" fill="url(#paint63_linear_445_2816)"/>
<defs>
<filter id="filter0_d_445_2816" x="228.486" y="152.36" width="389.737" height="307.752" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="15"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_445_2816"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_445_2816" result="shape"/>
</filter>
<linearGradient id="paint0_linear_445_2816" x1="649.555" y1="394.84" x2="732.295" y2="468.56" gradientUnits="userSpaceOnUse">
<stop stop-color="#95EBF5"/>
<stop offset="0.63" stop-color="#69ECF2"/>
<stop offset="1" stop-color="#55EDF2"/>
</linearGradient>
<linearGradient id="paint1_linear_445_2816" x1="692.405" y1="517.9" x2="769.695" y2="350.6" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.01" stop-color="#FE9600" stop-opacity="0.02"/>
<stop offset="0.23" stop-color="#FEAF04" stop-opacity="0.31"/>
<stop offset="0.43" stop-color="#FDC408" stop-opacity="0.56"/>
<stop offset="0.62" stop-color="#FCD40B" stop-opacity="0.75"/>
<stop offset="0.78" stop-color="#FCE00E" stop-opacity="0.89"/>
<stop offset="0.91" stop-color="#FCE70F" stop-opacity="0.97"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint2_linear_445_2816" x1="106.735" y1="353.28" x2="271.905" y2="500.45" gradientUnits="userSpaceOnUse">
<stop stop-color="#95EBF5"/>
<stop offset="0.63" stop-color="#69ECF2"/>
<stop offset="1" stop-color="#55EDF2"/>
</linearGradient>
<linearGradient id="paint3_linear_445_2816" x1="277.395" y1="516.09" x2="55.0451" y2="218.56" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.01" stop-color="#FE9600" stop-opacity="0.02"/>
<stop offset="0.23" stop-color="#FEAF04" stop-opacity="0.31"/>
<stop offset="0.43" stop-color="#FDC408" stop-opacity="0.56"/>
<stop offset="0.62" stop-color="#FCD40B" stop-opacity="0.75"/>
<stop offset="0.78" stop-color="#FCE00E" stop-opacity="0.89"/>
<stop offset="0.91" stop-color="#FCE70F" stop-opacity="0.97"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint4_linear_445_2816" x1="403.666" y1="106.16" x2="451.316" y2="448.16" gradientUnits="userSpaceOnUse">
<stop stop-color="#95EBF5"/>
<stop offset="0.63" stop-color="#69ECF2"/>
<stop offset="1" stop-color="#55EDF2"/>
</linearGradient>
<linearGradient id="paint5_linear_445_2816" x1="408.996" y1="738.85" x2="451.356" y2="208.03" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.01" stop-color="#FE9600" stop-opacity="0.02"/>
<stop offset="0.23" stop-color="#FEAF04" stop-opacity="0.31"/>
<stop offset="0.43" stop-color="#FDC408" stop-opacity="0.56"/>
<stop offset="0.62" stop-color="#FCD40B" stop-opacity="0.75"/>
<stop offset="0.78" stop-color="#FCE00E" stop-opacity="0.89"/>
<stop offset="0.91" stop-color="#FCE70F" stop-opacity="0.97"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint6_linear_445_2816" x1="391.616" y1="151.38" x2="692.326" y2="613.02" gradientUnits="userSpaceOnUse">
<stop stop-color="#95EBF5"/>
<stop offset="0.63" stop-color="#69ECF2"/>
<stop offset="1" stop-color="#55EDF2"/>
</linearGradient>
<linearGradient id="paint7_linear_445_2816" x1="558.146" y1="51.51" x2="527.436" y2="505.74" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint8_linear_445_2816" x1="571.685" y1="110.25" x2="340.865" y2="548.61" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint9_linear_445_2816" x1="516.245" y1="301.52" x2="680.365" y2="507.99" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint10_linear_445_2816" x1="276.475" y1="293.96" x2="657.045" y2="599.69" gradientUnits="userSpaceOnUse">
<stop stop-color="#95EBF5" stop-opacity="0"/>
<stop offset="0.31" stop-color="#7EEBF3" stop-opacity="0.35"/>
<stop offset="0.65" stop-color="#68ECF2" stop-opacity="0.7"/>
<stop offset="0.88" stop-color="#5AECF2" stop-opacity="0.92"/>
<stop offset="1" stop-color="#55EDF2"/>
</linearGradient>
<linearGradient id="paint11_linear_445_2816" x1="266.456" y1="394.83" x2="215.506" y2="246.21" gradientUnits="userSpaceOnUse">
<stop stop-color="#FC9D91" stop-opacity="0"/>
<stop offset="0.18" stop-color="#FC9D91" stop-opacity="0.22"/>
<stop offset="0.48" stop-color="#FC9D91" stop-opacity="0.55"/>
<stop offset="0.73" stop-color="#FC9D91" stop-opacity="0.79"/>
<stop offset="0.91" stop-color="#FC9D91" stop-opacity="0.94"/>
<stop offset="1" stop-color="#FC9D91"/>
</linearGradient>
<linearGradient id="paint12_linear_445_2816" x1="346.526" y1="277.32" x2="103.956" y2="181.78" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint13_linear_445_2816" x1="264.906" y1="312.64" x2="410.696" y2="356.52" gradientUnits="userSpaceOnUse">
<stop stop-color="#95EBF5" stop-opacity="0"/>
<stop offset="0.31" stop-color="#7EEBF3" stop-opacity="0.35"/>
<stop offset="0.65" stop-color="#68ECF2" stop-opacity="0.7"/>
<stop offset="0.88" stop-color="#5AECF2" stop-opacity="0.92"/>
<stop offset="1" stop-color="#55EDF2"/>
</linearGradient>
<linearGradient id="paint14_linear_445_2816" x1="295.095" y1="347.41" x2="324.105" y2="377.13" gradientUnits="userSpaceOnUse">
<stop stop-color="#95EBF5" stop-opacity="0"/>
<stop offset="0.31" stop-color="#7EEBF3" stop-opacity="0.35"/>
<stop offset="0.65" stop-color="#68ECF2" stop-opacity="0.7"/>
<stop offset="0.88" stop-color="#5AECF2" stop-opacity="0.92"/>
<stop offset="1" stop-color="#55EDF2"/>
</linearGradient>
<linearGradient id="paint15_linear_445_2816" x1="246.415" y1="368.06" x2="286.485" y2="391.54" gradientUnits="userSpaceOnUse">
<stop stop-color="#95EBF5" stop-opacity="0"/>
<stop offset="0.31" stop-color="#7EEBF3" stop-opacity="0.35"/>
<stop offset="0.65" stop-color="#68ECF2" stop-opacity="0.7"/>
<stop offset="0.88" stop-color="#5AECF2" stop-opacity="0.92"/>
<stop offset="1" stop-color="#55EDF2"/>
</linearGradient>
<linearGradient id="paint16_linear_445_2816" x1="342.495" y1="277.55" x2="360.285" y2="304.62" gradientUnits="userSpaceOnUse">
<stop stop-color="#95EBF5" stop-opacity="0"/>
<stop offset="0.31" stop-color="#7EEBF3" stop-opacity="0.35"/>
<stop offset="0.65" stop-color="#68ECF2" stop-opacity="0.7"/>
<stop offset="0.88" stop-color="#5AECF2" stop-opacity="0.92"/>
<stop offset="1" stop-color="#55EDF2"/>
</linearGradient>
<linearGradient id="paint17_linear_445_2816" x1="317.415" y1="261.8" x2="354.175" y2="256.62" gradientUnits="userSpaceOnUse">
<stop stop-color="#95EBF5" stop-opacity="0"/>
<stop offset="0.31" stop-color="#7EEBF3" stop-opacity="0.35"/>
<stop offset="0.65" stop-color="#68ECF2" stop-opacity="0.7"/>
<stop offset="0.88" stop-color="#5AECF2" stop-opacity="0.92"/>
<stop offset="1" stop-color="#55EDF2"/>
</linearGradient>
<linearGradient id="paint18_linear_445_2816" x1="272.976" y1="343.89" x2="301.286" y2="357.869" gradientUnits="userSpaceOnUse">
<stop stop-color="#95EBF5" stop-opacity="0"/>
<stop offset="0.31" stop-color="#7EEBF3" stop-opacity="0.35"/>
<stop offset="0.65" stop-color="#68ECF2" stop-opacity="0.7"/>
<stop offset="0.88" stop-color="#5AECF2" stop-opacity="0.92"/>
<stop offset="1" stop-color="#55EDF2"/>
</linearGradient>
<linearGradient id="paint19_linear_445_2816" x1="363.164" y1="242.03" x2="504.274" y2="453.69" gradientUnits="userSpaceOnUse">
<stop stop-color="#55EDF2"/>
<stop offset="0.65" stop-color="#3FBDE6"/>
<stop offset="1" stop-color="#36A9E1"/>
</linearGradient>
<linearGradient id="paint20_linear_445_2816" x1="644.654" y1="366.02" x2="382.154" y2="320.86" gradientUnits="userSpaceOnUse">
<stop stop-color="#1E1F5E"/>
<stop offset="0.18" stop-color="#1F2061" stop-opacity="0.95"/>
<stop offset="0.4" stop-color="#22246D" stop-opacity="0.79"/>
<stop offset="0.64" stop-color="#282A7F" stop-opacity="0.54"/>
<stop offset="0.89" stop-color="#303399" stop-opacity="0.18"/>
<stop offset="1" stop-color="#3538A7" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint21_linear_445_2816" x1="243.954" y1="346.13" x2="481.924" y2="311.98" gradientUnits="userSpaceOnUse">
<stop stop-color="#1E1F5E"/>
<stop offset="0.18" stop-color="#1F2061" stop-opacity="0.95"/>
<stop offset="0.4" stop-color="#22246D" stop-opacity="0.79"/>
<stop offset="0.64" stop-color="#282A7F" stop-opacity="0.54"/>
<stop offset="0.89" stop-color="#303399" stop-opacity="0.18"/>
<stop offset="1" stop-color="#3538A7" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint22_linear_445_2816" x1="533.984" y1="460.32" x2="255.684" y2="184.26" gradientUnits="userSpaceOnUse">
<stop stop-color="#1E1F5E"/>
<stop offset="0.18" stop-color="#1F2061" stop-opacity="0.95"/>
<stop offset="0.4" stop-color="#22246D" stop-opacity="0.79"/>
<stop offset="0.64" stop-color="#282A7F" stop-opacity="0.54"/>
<stop offset="0.89" stop-color="#303399" stop-opacity="0.18"/>
<stop offset="1" stop-color="#3538A7" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint23_linear_445_2816" x1="372.884" y1="302.5" x2="353.454" y2="284.86" gradientUnits="userSpaceOnUse">
<stop stop-color="#1E1F5E"/>
<stop offset="0.18" stop-color="#1F2061" stop-opacity="0.95"/>
<stop offset="0.4" stop-color="#22246D" stop-opacity="0.79"/>
<stop offset="0.64" stop-color="#282A7F" stop-opacity="0.54"/>
<stop offset="0.89" stop-color="#303399" stop-opacity="0.18"/>
<stop offset="1" stop-color="#3538A7" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint24_linear_445_2816" x1="561.294" y1="385.1" x2="322.784" y2="152.94" gradientUnits="userSpaceOnUse">
<stop stop-color="#1E1F5E"/>
<stop offset="0.18" stop-color="#1F2061" stop-opacity="0.95"/>
<stop offset="0.4" stop-color="#22246D" stop-opacity="0.79"/>
<stop offset="0.64" stop-color="#282A7F" stop-opacity="0.54"/>
<stop offset="0.89" stop-color="#303399" stop-opacity="0.18"/>
<stop offset="1" stop-color="#3538A7" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint25_linear_445_2816" x1="457.744" y1="318.09" x2="413.104" y2="274.64" gradientUnits="userSpaceOnUse">
<stop stop-color="#1E1F5E"/>
<stop offset="0.18" stop-color="#1F2061" stop-opacity="0.95"/>
<stop offset="0.4" stop-color="#22246D" stop-opacity="0.79"/>
<stop offset="0.64" stop-color="#282A7F" stop-opacity="0.54"/>
<stop offset="0.89" stop-color="#303399" stop-opacity="0.18"/>
<stop offset="1" stop-color="#3538A7" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint26_linear_445_2816" x1="505.253" y1="294.24" x2="348.193" y2="190.09" gradientUnits="userSpaceOnUse">
<stop stop-color="#1E1F5E"/>
<stop offset="0.18" stop-color="#1F2061" stop-opacity="0.95"/>
<stop offset="0.4" stop-color="#22246D" stop-opacity="0.79"/>
<stop offset="0.64" stop-color="#282A7F" stop-opacity="0.54"/>
<stop offset="0.89" stop-color="#303399" stop-opacity="0.18"/>
<stop offset="1" stop-color="#3538A7" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint27_linear_445_2816" x1="437.014" y1="258.75" x2="391.544" y2="253.42" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint28_linear_445_2816" x1="594.614" y1="294.791" x2="428.994" y2="275.381" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint29_linear_445_2816" x1="469.504" y1="380.861" x2="212.014" y2="350.681" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint30_linear_445_2816" x1="340.264" y1="305.47" x2="268.114" y2="297.01" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint31_linear_445_2816" x1="508.434" y1="349.13" x2="334.814" y2="328.78" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint32_linear_445_2816" x1="666.874" y1="276.11" x2="374.254" y2="241.8" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint33_linear_445_2816" x1="558.304" y1="417.47" x2="368.624" y2="395.24" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint34_linear_445_2816" x1="457.834" y1="391.82" x2="233.944" y2="365.57" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint35_linear_445_2816" x1="494.984" y1="273.26" x2="323.014" y2="253.1" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint36_linear_445_2816" x1="622.294" y1="331.901" x2="495.314" y2="317.011" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint37_linear_445_2816" x1="471.544" y1="252.301" x2="451.774" y2="249.981" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint38_linear_445_2816" x1="333.104" y1="276.95" x2="296.344" y2="272.64" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint39_linear_445_2816" x1="556.214" y1="285.861" x2="533.364" y2="283.18" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint40_linear_445_2816" x1="508.684" y1="333.89" x2="508.994" y2="190.26" gradientUnits="userSpaceOnUse">
<stop stop-color="#1E1F5E"/>
<stop offset="0.18" stop-color="#1F2061" stop-opacity="0.95"/>
<stop offset="0.4" stop-color="#22246D" stop-opacity="0.79"/>
<stop offset="0.64" stop-color="#282A7F" stop-opacity="0.54"/>
<stop offset="0.89" stop-color="#303399" stop-opacity="0.18"/>
<stop offset="1" stop-color="#3538A7" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint41_linear_445_2816" x1="369.484" y1="338.32" x2="369.784" y2="200.41" gradientUnits="userSpaceOnUse">
<stop stop-color="#1E1F5E"/>
<stop offset="0.18" stop-color="#1F2061" stop-opacity="0.95"/>
<stop offset="0.4" stop-color="#22246D" stop-opacity="0.79"/>
<stop offset="0.64" stop-color="#282A7F" stop-opacity="0.54"/>
<stop offset="0.89" stop-color="#303399" stop-opacity="0.18"/>
<stop offset="1" stop-color="#3538A7" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint42_linear_445_2816" x1="396.664" y1="545.601" x2="397.934" y2="-38.0791" gradientUnits="userSpaceOnUse">
<stop stop-color="#1E1F5E"/>
<stop offset="0.18" stop-color="#1F2061" stop-opacity="0.95"/>
<stop offset="0.4" stop-color="#22246D" stop-opacity="0.79"/>
<stop offset="0.64" stop-color="#282A7F" stop-opacity="0.54"/>
<stop offset="0.89" stop-color="#303399" stop-opacity="0.18"/>
<stop offset="1" stop-color="#3538A7" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint43_linear_445_2816" x1="401.984" y1="545.61" x2="403.254" y2="-38.0698" gradientUnits="userSpaceOnUse">
<stop stop-color="#1E1F5E"/>
<stop offset="0.18" stop-color="#1F2061" stop-opacity="0.95"/>
<stop offset="0.4" stop-color="#22246D" stop-opacity="0.79"/>
<stop offset="0.64" stop-color="#282A7F" stop-opacity="0.54"/>
<stop offset="0.89" stop-color="#303399" stop-opacity="0.18"/>
<stop offset="1" stop-color="#3538A7" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint44_linear_445_2816" x1="428.564" y1="319.291" x2="428.694" y2="261.451" gradientUnits="userSpaceOnUse">
<stop stop-color="#1E1F5E"/>
<stop offset="0.18" stop-color="#1F2061" stop-opacity="0.95"/>
<stop offset="0.4" stop-color="#22246D" stop-opacity="0.79"/>
<stop offset="0.64" stop-color="#282A7F" stop-opacity="0.54"/>
<stop offset="0.89" stop-color="#303399" stop-opacity="0.18"/>
<stop offset="1" stop-color="#3538A7" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint45_linear_445_2816" x1="663.444" y1="419.7" x2="436.104" y2="252.27" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.11" stop-color="#FF9A0D" stop-opacity="0.92"/>
<stop offset="0.34" stop-color="#FFA931" stop-opacity="0.71"/>
<stop offset="0.66" stop-color="#FFC16A" stop-opacity="0.38"/>
<stop offset="1" stop-color="#FFDCAB" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint46_linear_445_2816" x1="358.421" y1="221.953" x2="416.53" y2="303.319" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.11" stop-color="#FF9A0D" stop-opacity="0.92"/>
<stop offset="0.34" stop-color="#FFA931" stop-opacity="0.71"/>
<stop offset="0.66" stop-color="#FFC16A" stop-opacity="0.38"/>
<stop offset="1" stop-color="#FFDCAB" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint47_linear_445_2816" x1="516.793" y1="340.96" x2="266.074" y2="279.23" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.11" stop-color="#FF9A0D" stop-opacity="0.92"/>
<stop offset="0.34" stop-color="#FFA931" stop-opacity="0.71"/>
<stop offset="0.66" stop-color="#FFC16A" stop-opacity="0.38"/>
<stop offset="1" stop-color="#FFDCAB" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint48_linear_445_2816" x1="596.543" y1="307.44" x2="385.303" y2="164.661" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.11" stop-color="#FF9A0D" stop-opacity="0.92"/>
<stop offset="0.34" stop-color="#FFA931" stop-opacity="0.71"/>
<stop offset="0.66" stop-color="#FFC16A" stop-opacity="0.38"/>
<stop offset="1" stop-color="#FFDCAB" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint49_linear_445_2816" x1="518.434" y1="416.721" x2="358.424" y2="269.311" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.11" stop-color="#FF9A0D" stop-opacity="0.92"/>
<stop offset="0.34" stop-color="#FFA931" stop-opacity="0.71"/>
<stop offset="0.66" stop-color="#FFC16A" stop-opacity="0.38"/>
<stop offset="1" stop-color="#FFDCAB" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint50_linear_445_2816" x1="526.494" y1="266.38" x2="466.764" y2="307.64" gradientUnits="userSpaceOnUse">
<stop stop-color="#FC9D91" stop-opacity="0"/>
<stop offset="0.18" stop-color="#FC9D91" stop-opacity="0.22"/>
<stop offset="0.48" stop-color="#FC9D91" stop-opacity="0.55"/>
<stop offset="0.73" stop-color="#FC9D91" stop-opacity="0.79"/>
<stop offset="0.91" stop-color="#FC9D91" stop-opacity="0.94"/>
<stop offset="1" stop-color="#FC9D91"/>
</linearGradient>
<linearGradient id="paint51_linear_445_2816" x1="482.094" y1="305.46" x2="395.584" y2="221.19" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.11" stop-color="#FF9A0D" stop-opacity="0.92"/>
<stop offset="0.34" stop-color="#FFA931" stop-opacity="0.71"/>
<stop offset="0.66" stop-color="#FFC16A" stop-opacity="0.38"/>
<stop offset="1" stop-color="#FFDCAB" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint52_linear_445_2816" x1="492.614" y1="281.06" x2="483.054" y2="290.93" gradientUnits="userSpaceOnUse">
<stop stop-color="#FC9D91" stop-opacity="0"/>
<stop offset="0.18" stop-color="#FC9D91" stop-opacity="0.22"/>
<stop offset="0.48" stop-color="#FC9D91" stop-opacity="0.55"/>
<stop offset="0.73" stop-color="#FC9D91" stop-opacity="0.79"/>
<stop offset="0.91" stop-color="#FC9D91" stop-opacity="0.94"/>
<stop offset="1" stop-color="#FC9D91"/>
</linearGradient>
<linearGradient id="paint53_linear_445_2816" x1="426.134" y1="232.52" x2="410.844" y2="248.32" gradientUnits="userSpaceOnUse">
<stop stop-color="#FC9D91" stop-opacity="0"/>
<stop offset="0.18" stop-color="#FC9D91" stop-opacity="0.22"/>
<stop offset="0.48" stop-color="#FC9D91" stop-opacity="0.55"/>
<stop offset="0.73" stop-color="#FC9D91" stop-opacity="0.79"/>
<stop offset="0.91" stop-color="#FC9D91" stop-opacity="0.94"/>
<stop offset="1" stop-color="#FC9D91"/>
</linearGradient>
<linearGradient id="paint54_linear_445_2816" x1="423.387" y1="210.635" x2="428.036" y2="199.992" gradientUnits="userSpaceOnUse">
<stop stop-color="#FC9D91" stop-opacity="0"/>
<stop offset="0.18" stop-color="#FC9D91" stop-opacity="0.22"/>
<stop offset="0.48" stop-color="#FC9D91" stop-opacity="0.55"/>
<stop offset="0.73" stop-color="#FC9D91" stop-opacity="0.79"/>
<stop offset="0.91" stop-color="#FC9D91" stop-opacity="0.94"/>
<stop offset="1" stop-color="#FC9D91"/>
</linearGradient>
<linearGradient id="paint55_linear_445_2816" x1="398.034" y1="183.32" x2="397.684" y2="182.39" gradientUnits="userSpaceOnUse">
<stop stop-color="#FC9D91" stop-opacity="0"/>
<stop offset="0.18" stop-color="#FC9D91" stop-opacity="0.22"/>
<stop offset="0.48" stop-color="#FC9D91" stop-opacity="0.55"/>
<stop offset="0.73" stop-color="#FC9D91" stop-opacity="0.79"/>
<stop offset="0.91" stop-color="#FC9D91" stop-opacity="0.94"/>
<stop offset="1" stop-color="#FC9D91"/>
</linearGradient>
<linearGradient id="paint56_linear_445_2816" x1="387.703" y1="282.283" x2="391.741" y2="266.414" gradientUnits="userSpaceOnUse">
<stop stop-color="#FC9D91" stop-opacity="0"/>
<stop offset="0.18" stop-color="#FC9D91" stop-opacity="0.22"/>
<stop offset="0.48" stop-color="#FC9D91" stop-opacity="0.55"/>
<stop offset="0.73" stop-color="#FC9D91" stop-opacity="0.79"/>
<stop offset="0.91" stop-color="#FC9D91" stop-opacity="0.94"/>
<stop offset="1" stop-color="#FC9D91"/>
</linearGradient>
<linearGradient id="paint57_linear_445_2816" x1="555.774" y1="295.951" x2="554.924" y2="306.361" gradientUnits="userSpaceOnUse">
<stop stop-color="#FC9D91" stop-opacity="0"/>
<stop offset="0.18" stop-color="#FC9D91" stop-opacity="0.22"/>
<stop offset="0.48" stop-color="#FC9D91" stop-opacity="0.55"/>
<stop offset="0.73" stop-color="#FC9D91" stop-opacity="0.79"/>
<stop offset="0.91" stop-color="#FC9D91" stop-opacity="0.94"/>
<stop offset="1" stop-color="#FC9D91"/>
</linearGradient>
<linearGradient id="paint58_linear_445_2816" x1="750.296" y1="349.7" x2="508.185" y2="527.58" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint59_linear_445_2816" x1="637.496" y1="45.44" x2="574.896" y2="156.06" gradientUnits="userSpaceOnUse">
<stop stop-color="#95EBF5"/>
<stop offset="0.63" stop-color="#69ECF2"/>
<stop offset="1" stop-color="#55EDF2"/>
</linearGradient>
<linearGradient id="paint60_linear_445_2816" x1="482.075" y1="113.359" x2="522.315" y2="159.599" gradientUnits="userSpaceOnUse">
<stop stop-color="#95EBF5"/>
<stop offset="0.63" stop-color="#69ECF2"/>
<stop offset="1" stop-color="#55EDF2"/>
</linearGradient>
<linearGradient id="paint61_linear_445_2816" x1="656.565" y1="132.41" x2="603.855" y2="161.82" gradientUnits="userSpaceOnUse">
<stop stop-color="#95EBF5"/>
<stop offset="0.63" stop-color="#69ECF2"/>
<stop offset="1" stop-color="#55EDF2"/>
</linearGradient>
<linearGradient id="paint62_linear_445_2816" x1="739.975" y1="253.95" x2="711.265" y2="351.83" gradientUnits="userSpaceOnUse">
<stop stop-color="#95EBF5"/>
<stop offset="0.63" stop-color="#69ECF2"/>
<stop offset="1" stop-color="#55EDF2"/>
</linearGradient>
<linearGradient id="paint63_linear_445_2816" x1="745.415" y1="394.59" x2="737.115" y2="422.91" gradientUnits="userSpaceOnUse">
<stop stop-color="#95EBF5"/>
<stop offset="0.63" stop-color="#69ECF2"/>
<stop offset="1" stop-color="#55EDF2"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 53 KiB

View File

@ -0,0 +1,58 @@
<svg width="342" height="336" viewBox="0 0 342 336" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_d_445_2736)">
<path d="M279.512 46.9438C220.739 3.86271 170.996 56.5473 170.996 56.5473C170.996 56.5473 121.263 3.85272 62.4805 46.9438C6.28194 88.1362 11.5106 241.783 170.996 306C330.471 241.783 335.71 88.1362 279.512 46.9438Z" fill="url(#paint0_linear_445_2736)"/>
<path d="M171.006 306C330.481 241.783 335.72 88.1357 279.521 46.9434C260.173 32.7629 241.813 28.9554 225.877 30.2346C233.241 29.7649 264.075 30.2746 269.862 71.297C276.438 117.946 227.663 222.775 145.73 232.929C75.1229 241.683 45.1876 192.885 44.1299 191.117C63.3084 235.227 103.212 278.698 171.016 306H171.006Z" fill="url(#paint1_linear_445_2736)"/>
<path opacity="0.7" d="M87.3571 41.8068C87.3571 41.8068 50.2273 52.2099 39.271 100.388C28.3147 148.565 55.0269 196.893 55.0269 196.893C55.0269 196.893 49.5488 139.232 87.3571 41.7969V41.8068Z" fill="url(#paint2_linear_445_2736)"/>
<path opacity="0.7" d="M146.549 288.712C146.549 288.712 213.853 285.644 271.449 214.002C271.449 214.002 240.266 263.109 174.498 301.064C174.498 301.064 157.445 297.566 146.549 288.712Z" fill="url(#paint3_linear_445_2736)"/>
<path d="M153.125 58.4064C171.754 85.8479 197.788 94.9019 197.788 94.9019C197.788 94.9019 213.574 28.6763 187.281 43.7162C177.003 50.2019 171.006 56.5476 171.006 56.5476C171.006 56.5476 143.206 27.0974 104.609 30.2453C105.068 30.2553 134.634 31.1746 153.125 58.4064Z" fill="url(#paint4_linear_445_2736)"/>
<path opacity="0.7" d="M101.945 41.8068C75.2328 41.8068 44.14 60.0446 36.6262 112.759C30.0105 159.178 54.9865 241.943 139.504 221.786C224.031 201.63 270.98 71.7168 241.044 54.0786C211.109 36.4404 195.962 84.5881 198.077 116.777C198.077 116.777 149.123 41.7969 101.935 41.7969L101.945 41.8068Z" fill="url(#paint5_linear_445_2736)"/>
</g>
<defs>
<filter id="filter0_d_445_2736" x="0" y="0" width="341.999" height="336" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="15"/>
<feColorMatrix type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 0 0 0 1 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_445_2736"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_445_2736" result="shape"/>
</filter>
<linearGradient id="paint0_linear_445_2736" x1="289.051" y1="238.575" x2="132.258" y2="107.903" gradientUnits="userSpaceOnUse">
<stop stop-color="#EF4B9F"/>
<stop offset="1" stop-color="#E6332A"/>
</linearGradient>
<linearGradient id="paint1_linear_445_2736" x1="105.038" y1="79.9512" x2="547.056" y2="473.234" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint2_linear_445_2736" x1="19.1445" y1="17.5132" x2="197.95" y2="366.373" gradientUnits="userSpaceOnUse">
<stop stop-color="#FCEA10"/>
<stop offset="0.33" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="0.66" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.89" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="1" stop-color="#FF9500" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint3_linear_445_2736" x1="69.0263" y1="393.761" x2="290.176" y2="173.998" gradientUnits="userSpaceOnUse">
<stop stop-color="#FCEA10"/>
<stop offset="0.33" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="0.66" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.89" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="1" stop-color="#FF9500" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint4_linear_445_2736" x1="245.804" y1="87.6267" x2="-130.024" y2="-60.1825" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint5_linear_445_2736" x1="9.70438" y1="-29.0956" x2="244.871" y2="230.392" gradientUnits="userSpaceOnUse">
<stop stop-color="#FCEA10"/>
<stop offset="0.33" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="0.66" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.89" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="1" stop-color="#FF9500" stop-opacity="0"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@ -0,0 +1,572 @@
<svg width="858" height="512" viewBox="0 0 858 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M408.321 194.39C408.321 194.39 405.231 179.46 406.291 165.21C406.291 165.21 414.381 177.32 431.001 172.5C445.651 168.26 429.941 156.1 417.761 166.05C417.761 166.05 395.231 157.96 401.351 119.51C401.351 119.51 416.251 145.75 463.151 168.37C510.051 190.99 533.891 196.42 533.891 196.42C533.891 196.42 511.931 183.87 490.871 166.22C490.871 166.22 548.381 160.79 573.831 200.49C573.831 200.49 575.701 195.51 584.411 198.68C593.121 201.85 589.241 221.53 614.241 228.54C614.241 228.54 606.531 221.75 599.231 210.22C599.231 210.22 595.231 181.72 614.241 149.94C614.241 149.94 608.171 178.33 616.801 198.46L628.171 200.72C628.171 200.72 628.921 214.33 638.051 210.45C644.171 207.85 646.051 202.05 651.461 201.85C663.931 201.4 670.171 220.74 670.171 220.74C670.171 220.74 694.981 217.25 715.231 236.46C731.701 252.09 727.701 291.1 727.701 291.1C727.701 291.1 726.051 280.23 715.461 273.73C715.461 273.73 700.171 278.15 701.811 296.33C703.461 314.51 712.161 333.07 712.161 333.07C712.161 333.07 705.391 312.11 724.211 305.8C724.211 305.8 721.331 321.39 723.691 330.16C726.051 338.93 735.221 358.84 735.221 377.84C735.221 377.84 708.401 419.23 659.691 442.53C659.691 442.53 708.631 418.33 732.161 417.4C732.161 417.4 750.041 431.22 762.041 462.44C762.041 462.44 712.861 441.63 690.511 459.05C672.831 472.83 693.571 477.37 693.571 477.37H398.461C398.461 477.37 353.341 462.89 351.931 392.32C350.521 321.75 388.641 262.64 388.641 262.64L408.321 194.39Z" fill="url(#paint0_linear_445_2646)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M408.321 194.39C408.321 194.39 405.231 179.46 406.291 165.21C406.291 165.21 414.381 177.32 431.001 172.5C445.651 168.26 429.941 156.1 417.761 166.05C417.761 166.05 395.231 157.96 401.351 119.51C401.351 119.51 416.251 145.75 463.151 168.37C510.051 190.99 533.891 196.42 533.891 196.42C533.891 196.42 511.931 183.87 490.871 166.22C490.871 166.22 548.381 160.79 573.831 200.49C573.831 200.49 575.701 195.51 584.411 198.68C593.121 201.85 589.241 221.53 614.241 228.54C614.241 228.54 606.531 221.75 599.231 210.22C599.231 210.22 595.231 181.72 614.241 149.94C614.241 149.94 608.171 178.33 616.801 198.46L628.171 200.72C628.171 200.72 628.921 214.33 638.051 210.45C644.171 207.85 646.051 202.05 651.461 201.85C663.931 201.4 670.171 220.74 670.171 220.74C670.171 220.74 694.981 217.25 715.231 236.46C731.701 252.09 727.701 291.1 727.701 291.1C727.701 291.1 726.051 280.23 715.461 273.73C715.461 273.73 700.171 278.15 701.811 296.33C703.461 314.51 712.161 333.07 712.161 333.07C712.161 333.07 705.391 312.11 724.211 305.8C724.211 305.8 721.331 321.39 723.691 330.16C726.051 338.93 735.221 358.84 735.221 377.84C735.221 377.84 708.401 419.23 659.691 442.53C659.691 442.53 708.631 418.33 732.161 417.4C732.161 417.4 750.041 431.22 762.041 462.44C762.041 462.44 712.861 441.63 690.511 459.05C672.831 472.83 693.571 477.37 693.571 477.37H398.461C398.461 477.37 353.341 462.89 351.931 392.32C350.521 321.75 388.641 262.64 388.641 262.64L408.321 194.39Z" fill="url(#paint1_linear_445_2646)"/>
<path d="M366.94 151.64C366.94 151.64 436.87 159.56 444.38 276.44C447.85 330.47 493.54 369.67 493.54 369.67C493.54 369.67 465.57 400.49 410.96 382.41C410.96 382.41 405.11 389.6 420.17 414.93C435.23 440.26 436.52 471.03 422.23 477.36H351.9L336.41 181.06L366.95 151.64H366.94Z" fill="url(#paint2_linear_445_2646)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M463.151 477.36C463.151 477.36 481.111 413.67 468.571 339C456.031 264.33 427.561 203.21 427.561 203.21C427.561 203.21 450.761 220.4 460.171 239.85V221.19C460.171 221.19 515.891 217.68 562.621 243.92C609.351 270.16 572.761 433.93 504.001 477.36L489.351 477.59C489.351 477.59 506.031 467.86 508.871 438.01C508.871 438.01 482.381 451.21 467.001 477.59L463.151 477.36Z" fill="url(#paint3_linear_445_2646)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M690.52 459.04C712.87 441.62 762.05 462.43 762.05 462.43C750.05 431.21 732.17 417.39 732.17 417.39C710.8 418.23 668.47 438.27 660.88 441.95C660.49 442.14 660.09 442.34 659.7 442.53C659.7 442.53 660.12 442.32 660.88 441.95C708.83 418.55 735.23 377.83 735.23 377.83C735.23 358.83 726.05 338.92 723.7 330.15C721.35 321.38 724.22 305.79 724.22 305.79C705.4 312.1 712.17 333.06 712.17 333.06C712.17 333.06 703.46 314.49 701.82 296.32C700.17 278.14 715.47 273.72 715.47 273.72C724.41 279.21 726.97 287.81 727.56 290.36C717.82 239.88 645.43 217.63 645.43 217.63C645.43 217.63 681.35 245.28 692.18 272.91C703 300.53 692.18 364.17 692.18 364.17C692.18 364.17 723.71 362.91 714.3 376.92C704.89 390.93 664.65 407.47 664.65 407.47L645.43 406.11C617.03 429.18 549.27 452.26 549.27 452.26C556.72 430.55 560.4 384.93 560.4 384.93C536.44 454.91 478.53 477.37 478.53 477.37H693.59C693.59 477.37 672.85 472.83 690.53 459.05L690.52 459.04Z" fill="url(#paint4_linear_445_2646)"/>
<path d="M174.05 477.36C174.05 477.36 133.11 465.15 143.46 429.86C153.81 394.57 107.42 384.67 102.05 369.8C93.23 345.37 118.99 337.11 105.34 312.23C105.34 312.23 116.87 319.47 117.58 343.22C118.29 366.97 141.58 370.59 169.82 370.14C169.82 370.14 110.71 292.54 129.35 252.97C146.88 215.76 198.06 201.85 199.47 193.25C200.88 184.65 180.65 181.04 180.65 181.04C180.65 181.04 203.36 162.83 222.41 174.71C241.47 186.59 269.7 199.48 297.94 185.91C297.94 185.91 302.29 171.77 279.94 156.62C259.99 143.1 251.35 127.89 286.53 94.64C316.41 66.4 301.12 51.21 301.12 51.21C301.12 51.21 324.06 48.16 322.65 88.87C321.24 129.59 335.73 145.19 366.95 151.64C398.17 158.09 421.47 190.32 427.12 228.32C432.77 266.32 415.94 283.74 439.94 319.93C463.94 356.12 472.34 359.58 468.58 391.24C468.58 391.24 460.44 387.9 428.88 367.77C389 342.32 387.59 316.54 387.59 316.54C387.59 316.54 393.59 332.49 382.3 349.45C371.01 366.41 355.12 385.42 369.59 423.42L373.47 401.03C373.47 401.03 408.06 423.42 412.29 443.1C416.52 462.78 398.47 477.37 398.47 477.37H174.06L174.05 477.36Z" fill="url(#paint5_linear_445_2646)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M174.05 477.36C174.05 477.36 133.11 465.15 143.46 429.86C153.81 394.57 107.42 384.67 102.05 369.8C93.23 345.37 118.99 337.11 105.34 312.23C105.34 312.23 116.87 319.47 117.58 343.22C118.29 366.97 141.58 370.59 169.82 370.14C169.82 370.14 110.71 292.54 129.35 252.97C146.88 215.76 198.06 201.85 199.47 193.25C200.88 184.65 180.65 181.04 180.65 181.04C180.65 181.04 203.36 162.83 222.41 174.71C241.47 186.59 269.7 199.48 297.94 185.91C297.94 185.91 302.29 171.77 279.94 156.62C259.99 143.1 251.35 127.89 286.53 94.64C316.41 66.4 301.12 51.21 301.12 51.21C301.12 51.21 324.06 48.16 322.65 88.87C321.24 129.59 335.73 145.19 366.95 151.64C398.17 158.09 421.47 190.32 427.12 228.32C432.77 266.32 415.94 283.74 439.94 319.93C463.94 356.12 472.34 359.58 468.58 391.24C468.58 391.24 460.44 387.9 428.88 367.77C389 342.32 387.59 316.54 387.59 316.54C387.59 316.54 393.59 332.49 382.3 349.45C371.01 366.41 355.12 385.42 369.59 423.42L373.47 401.03C373.47 401.03 408.06 423.42 412.29 443.1C416.52 462.78 398.47 477.37 398.47 477.37H174.06L174.05 477.36Z" fill="url(#paint6_linear_445_2646)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M174.05 477.36C174.05 477.36 149.7 464.13 152.87 434.61C156.05 405.09 129.25 391.06 115.11 378.63C100.97 366.19 108.76 344.59 108.76 344.59C108.76 344.59 102.05 368.79 129.35 375.35C156.64 381.91 192.88 400.01 192.88 400.01C192.88 400.01 170.17 423.76 184.29 448.7C198.41 473.64 278.53 477.37 278.53 477.37H174.06L174.05 477.36Z" fill="url(#paint7_linear_445_2646)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M278.52 477.36C278.52 477.36 197.58 479.62 165.11 440.26C165.11 440.26 162.76 399.28 207.46 379.28C252.17 359.28 239.46 310.17 239.46 310.17C239.46 310.17 269.58 383.71 241.34 419.45C241.34 419.45 200.16 406.78 190.99 425.1C181.81 443.42 204.75 472.6 278.52 477.35V477.36Z" fill="url(#paint8_linear_445_2646)"/>
<path d="M620 386.02C620 386.02 632.72 416.28 597.14 426.16C561.56 436.04 518.78 405.45 480.12 406.29C441.46 407.12 405.95 404.89 408.44 356.62C410.93 308.35 467.15 218.9 579.24 236.4C669.96 250.56 685.42 317.5 685.42 341.96C685.42 360.73 675.66 398.48 619.99 386.03L620 386.02Z" fill="url(#paint9_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.35" d="M526.18 339.46C526.18 339.46 548.12 340.65 573.62 354.88C599.12 369.11 616.38 373.33 638.26 354.62C662.93 333.52 685.04 334.73 685.04 334.73C685.31 337.41 685.43 339.85 685.43 341.96C685.43 360.73 675.67 398.48 620 386.03C597 381.06 588.44 370.7 569.2 357.26C549.96 343.82 526.17 339.47 526.17 339.47L526.18 339.46Z" fill="url(#paint10_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.35" d="M597.14 426.159C561.56 436.039 518.78 405.449 480.12 406.289C441.46 407.119 405.95 404.889 408.44 356.619C410.18 322.959 438.04 269.269 492.77 245.789C439.2 293.259 460.66 380.549 492.77 395.999C513.59 406.019 533.14 396.109 565.91 386.029C593.45 377.559 620 386.029 620 386.029C620 386.029 632.71 416.289 597.14 426.169V426.159Z" fill="url(#paint11_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M620 386.019C620 386.019 586.47 373.859 566.7 377.019C546.93 380.179 533.49 383.819 526.18 383.779C518.87 383.739 492.51 357.639 483.55 334.709C474.59 311.789 477.36 266.319 493.96 268.299C510.56 270.279 512.15 293.599 512.15 309.419C512.15 325.229 498.71 325.229 495.94 319.699C493.17 314.169 494.36 297.169 494.36 297.169C494.36 297.169 495.81 315.749 498.58 317.859C501.35 319.969 509.25 318.259 508.86 310.879C508.47 303.499 508.86 283.999 503.46 278.069C498.06 272.139 491.2 270.949 488.57 274.639C485.94 278.329 480.73 292.829 482.9 310.219C485.07 327.619 487.64 340.459 498.71 355.089C509.78 369.719 518.67 378.219 527.97 379.599C537.26 380.979 564.19 370.309 578.57 372.479C592.95 374.649 620.01 386.029 620.01 386.029L620 386.019Z" fill="url(#paint12_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M495.469 305.989C496.489 311.259 496.129 319.759 502.119 318.659C502.119 318.659 497.769 322.859 495.929 319.689C494.089 316.519 495.469 305.979 495.469 305.979V305.989Z" fill="url(#paint13_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M654.07 367.13C654.07 367.13 675.81 359.22 664.74 350.53C653.67 341.83 637.07 343.41 626.79 346.58C616.51 349.74 566.3 340.5 559.19 327.13C552.07 313.76 570.65 297.16 580.54 285.3C590.42 273.44 566.31 251.7 556.42 249.72C556.42 249.72 572.63 257.36 576.06 270.28C579.49 283.19 574.74 289.78 565.25 297.95C555.76 306.12 546.8 320.85 558.66 331.14C570.52 341.43 592.89 347.23 610.44 349.08C627.98 350.92 647.22 346.18 653.28 348.29C659.34 350.4 673.84 355.41 654.07 367.13Z" fill="url(#paint14_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M567.7 337.209C567.7 337.209 554.97 331.349 553.32 322.619C551.67 313.889 559.31 304.129 559.31 304.129C559.31 304.129 549.98 325.859 567.7 337.199V337.209Z" fill="url(#paint15_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M556.42 249.719C556.42 249.719 540.74 241.549 518.86 247.609C496.98 253.669 501.99 263.419 516.22 270.279C530.45 277.129 532.56 288.729 542.05 286.619C551.54 284.509 562.08 273.709 549.96 262.899C537.84 252.089 529.4 254.199 529.4 254.199C529.4 254.199 545.13 258.419 549.26 267.379C553.39 276.339 546.54 281.349 540.21 282.139C533.88 282.929 530.19 271.469 521.5 268.239C512.8 265.009 504.63 259.739 510.43 254.469C516.23 249.199 537.05 243.929 556.42 249.729V249.719Z" fill="url(#paint16_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M548.51 277.989C548.51 277.989 544.16 287.519 539.42 286.849C534.68 286.179 530.53 281.039 528.26 278.379C525.99 275.709 534.89 281.939 539.03 282.229C544.56 282.629 548.51 277.989 548.51 277.989Z" fill="url(#paint17_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M580.54 285.299C580.54 285.299 607.03 280.139 625.61 286.869C644.19 293.599 659.61 307.829 639.05 311.789C618.49 315.739 601.89 331.139 593.19 320.679C584.49 310.209 586.34 297.429 612.69 297.959C612.69 297.959 599.78 294.529 588.97 299.009C578.16 303.489 582.64 316.729 590.29 322.629C597.93 328.529 606.1 330.699 621.65 322.629C637.2 314.559 652.49 314.959 653.01 308.429C653.54 301.909 648.27 290.049 632.98 285.039C617.69 280.029 591.07 280.559 580.53 285.299H580.54Z" fill="url(#paint18_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M526.18 383.779C526.18 383.779 500.28 385.319 485.66 373.859C471.03 362.399 471.03 346.979 451.66 349.349C432.29 351.719 414.89 357.649 413.31 371.489C411.73 385.329 420.49 394.349 420.49 394.349C420.49 394.349 412.13 386.509 411.21 375.699C410.29 364.889 410.95 355.009 434.93 348.679C458.91 342.349 465.24 343.669 471.96 353.419C478.68 363.169 488.96 379.589 526.19 383.769L526.18 383.779Z" fill="url(#paint19_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M425.25 351.92C425.25 351.92 441.77 346.97 450.46 335.11C459.16 323.25 469.24 285.079 459.55 286.869C449.86 288.659 427.13 309.42 428.32 317.92C429.51 326.42 431.88 332.94 431.88 332.94C431.88 332.94 429.38 320.29 433.2 313.96C437.02 307.63 452.84 291.82 457.38 292.41C461.92 293 450.46 328.389 445.72 334.119C440.98 339.849 425.25 351.92 425.25 351.92Z" fill="url(#paint20_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M562.61 334.709C562.61 334.709 543.9 324.629 525.18 327.889C506.47 331.159 492.76 341.569 506.6 358.829C518.49 373.669 528.49 375.579 546.87 369.579C565.25 363.579 576.58 372.459 576.58 372.459C576.58 372.459 569.73 363.569 556.42 361.589C543.11 359.609 533.89 353.819 533.89 353.819C533.89 353.819 547.86 362.249 543.38 364.629C538.9 366.999 524.67 371.219 514.39 361.599C504.11 351.979 502.53 342.759 510.17 336.959C517.81 331.159 535.74 327.979 562.62 334.709H562.61Z" fill="url(#paint21_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M580.54 285.299C580.54 285.299 598.49 251.799 632.2 268.239C665.91 284.679 681.44 316.599 681.44 316.599C681.44 316.599 679.38 307.969 672 296.369C664.62 284.769 640.7 264.479 622.22 259.729C603.74 254.989 556.43 249.709 556.43 249.709C556.43 249.709 582.65 253.659 593.99 257.349C605.32 261.039 584.5 264.729 580.55 285.289L580.54 285.299Z" fill="url(#paint22_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M530.199 416.55C530.199 416.55 547.059 422.09 570.779 417.87C594.499 413.65 599.769 402.58 588.699 398.1C577.629 393.62 574.199 397.57 559.179 400.74C544.159 403.9 559.439 411.55 559.439 411.55C559.439 411.55 539.149 406.54 552.319 399.43C565.499 392.31 580.519 389.09 589.749 394.36C598.969 399.63 604.659 412.94 616.519 414.79L615.059 416.42C615.059 416.42 609.669 416.47 607.009 413.8C604.349 411.13 600.389 405 598.999 407.77C597.609 410.54 585.239 420.26 571.229 421.31C552.749 422.69 539.459 419.72 530.189 416.56L530.199 416.55Z" fill="url(#paint23_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M425.69 398.15C425.69 398.15 425.559 381.11 441.379 369.57C457.189 358.03 481.44 384.65 506.48 393.61C531.52 402.57 509.379 409.95 487.369 395.72C471.369 385.37 456.929 375.43 450.869 379.38C444.809 383.33 447.71 395.46 447.71 395.46C447.71 395.46 449.29 382.02 455.35 382.02C461.41 382.02 485.13 402.049 503.05 405.739C520.97 409.429 526.9 402.37 520.97 397.04C517.34 393.77 508.059 393.17 496.989 386.01C485.919 378.85 460.88 360.14 449.02 361.59C437.16 363.04 424.159 373.46 422.229 395.79C422.229 395.79 425.13 397.81 425.69 398.15Z" fill="url(#paint24_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M562.609 334.709C562.609 334.709 542.579 324.629 542.579 310.999C542.579 297.359 535.859 293.209 519.059 280.559C502.259 267.909 485.409 263.239 506.089 240.869L503.059 241.859C503.059 241.859 494.259 248.259 493.559 257.899C493.159 263.369 498.999 268.509 506.009 274.039C513.019 279.569 530.819 291.729 534.969 297.559C539.119 303.389 535.549 313.499 542.879 321.679C552.989 332.949 562.609 334.719 562.609 334.719V334.709Z" fill="url(#paint25_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M626.79 346.579C626.79 346.579 637.66 337.679 649.92 332.739C662.18 327.799 685.35 338.859 685.35 338.859L685.04 334.729C685.04 334.729 663.43 324.579 650.91 329.579C638.39 334.589 626.79 346.579 626.79 346.579Z" fill="url(#paint26_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M575.399 265.129C575.399 265.129 591.079 275.939 576.799 289.599C576.799 289.599 580.009 275.939 575.399 265.129Z" fill="url(#paint27_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M461.37 287.089C463.09 287.749 465.49 295.579 457.98 309.149C457.98 309.149 461.27 294.799 457.39 292.409C455.22 291.079 445.74 299.929 445.74 299.929C445.74 299.929 455.87 284.969 461.37 287.079V287.089Z" fill="url(#paint28_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M645.52 295.48C645.52 295.48 655.65 303.49 652.52 310.03C650.71 313.82 641.91 314.85 641.91 314.85C641.91 314.85 648.53 310.6 648.83 306.05C649.13 301.5 645.52 295.47 645.52 295.47V295.48Z" fill="url(#paint29_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.2" d="M639.05 311.779C618.49 315.729 601.89 331.129 593.19 320.669C588.49 315.009 586.87 308.679 590.54 304.129C591.64 311.899 601.89 317.709 612.17 312.569C622.45 307.429 636.48 310.199 639.45 302.489C642.41 294.779 634.53 290.789 634.53 290.789C648.35 298.069 656.17 308.489 639.06 311.779H639.05Z" fill="url(#paint30_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.2" d="M498.57 317.849C496.38 316.179 495.01 304.189 494.54 299.259C494.54 299.289 496.07 306.119 500.97 306.379C505.86 306.639 508.25 296.809 508.26 296.749C508.66 302.329 508.68 307.709 508.85 310.859C509.25 318.239 501.34 319.949 498.57 317.839V317.849Z" fill="url(#paint31_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.2" d="M543.37 364.629C538.89 366.999 524.66 371.219 514.38 361.599C504.1 351.979 502.52 342.759 510.16 336.959C508 342.189 512.73 348.549 522.22 349.999C531.71 351.449 533.88 353.819 533.88 353.819C533.88 353.819 533.93 353.849 534.01 353.899C535.38 354.739 547.6 362.389 543.37 364.629Z" fill="url(#paint32_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.2" d="M506.61 358.829C518.5 373.669 528.5 375.579 546.88 369.579C563.52 364.139 574.38 370.909 576.29 372.239C561.51 371.349 536.79 380.899 527.96 379.579C518.67 378.199 509.77 369.699 498.7 355.069C487.63 340.439 485.06 327.589 482.89 310.199C482.32 305.609 482.26 301.229 482.53 297.199C482.53 297.569 482.71 300.869 486.84 316.599C491.28 333.469 502.84 339.329 502.84 339.329C499.7 344.349 500.22 350.859 506.61 358.829Z" fill="url(#paint33_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.2" d="M542.58 310.989C542.58 299.919 538.15 295.099 527.49 286.899C529.77 288.569 537.99 293.939 551.87 296.159C568.04 298.749 574.94 286.929 575.23 286.419C573.24 290.469 569.79 294.029 565.24 297.939C555.75 306.109 546.79 320.839 558.65 331.129C560.2 332.469 561.93 333.739 563.8 334.929L563.78 335.009L562.6 334.699C562.6 334.699 542.57 324.619 542.57 310.989H542.58Z" fill="url(#paint34_linear_445_2646)"/>
<path style="mix-blend-mode:screen" opacity="0.7" d="M662.11 322.059C652.03 320.489 644.71 320.869 634.7 328.779C624.68 336.689 599.3 346.569 582.8 335.369C566.3 324.169 576.05 316.159 584.36 321.549C592.66 326.939 598.61 337.379 620.95 327.889C637.72 320.759 645.64 316.089 658.16 318.679C670.68 321.269 670.55 323.379 662.11 322.059Z" fill="url(#paint35_linear_445_2646)"/>
<path style="mix-blend-mode:screen" opacity="0.7" d="M519.38 301.86C524.42 303.13 530.82 304.86 532.93 310.56C535.04 316.26 538.09 299.8 530.13 293.92C522.17 288.03 516.33 291.95 515.74 295.23C515.15 298.51 516.11 301.03 519.38 301.86Z" fill="url(#paint36_linear_445_2646)"/>
<path style="mix-blend-mode:screen" opacity="0.7" d="M478.52 278.38C475.55 294.31 473.4 320.08 481.9 337.38C490.4 354.68 496.68 371.88 482.37 355.07C468.06 338.27 466.68 316.52 470.04 295.47C473.4 274.42 479.92 270.89 478.52 278.37V278.38Z" fill="url(#paint37_linear_445_2646)"/>
<path style="mix-blend-mode:screen" opacity="0.7" d="M642.74 264.079C624.55 254.989 599.12 246.419 557.02 244.679C527.29 243.449 518.47 239.239 532.81 236.869C547.14 234.499 600.97 235.219 642.75 264.079H642.74Z" fill="url(#paint38_linear_445_2646)"/>
<path style="mix-blend-mode:screen" opacity="0.7" d="M569.6 349.999C569.6 349.999 546.14 339.029 531.12 338.459C516.1 337.879 525.99 331.109 539.19 332.919C552.27 334.719 566.01 344.819 569.6 350.009V349.999Z" fill="url(#paint39_linear_445_2646)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.35" d="M630.22 304.139C630.22 304.139 622.05 304.189 614.93 307.529C607.81 310.869 599.64 311.209 598.33 305.829C597.02 300.449 612.56 296.479 630.22 304.129V304.139Z" fill="url(#paint40_linear_445_2646)"/>
<path style="mix-blend-mode:screen" opacity="0.7" d="M529.33 256.169C529.33 256.169 543.29 258.119 546.59 267.899C548.91 274.789 542.85 280.129 539.69 277.369C536.53 274.599 538.87 264.209 529.33 256.169Z" fill="url(#paint41_linear_445_2646)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M603.96 309.969C603.96 309.969 599.66 310.899 597.63 308.029C595.6 305.159 600.32 302.189 600.32 302.189C600.32 302.189 599.65 306.669 603.95 309.969H603.96Z" fill="url(#paint42_linear_445_2646)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M539.41 259.739C539.41 259.739 547.1 263.849 547.62 270.769C548.3 279.809 543.67 279.569 542.58 273.439C541.49 267.309 541.18 262.749 539.41 259.739Z" fill="url(#paint43_linear_445_2646)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M560.394 239.448C560.449 238.394 555.396 237.275 549.109 236.948C542.821 236.62 537.68 237.209 537.625 238.263C537.57 239.316 542.622 240.435 548.91 240.763C555.198 241.09 560.339 240.501 560.394 239.448Z" fill="url(#paint44_linear_445_2646)"/>
<path d="M525.45 220.819C526.09 220.819 526.09 219.829 525.45 219.829C524.81 219.829 524.81 220.819 525.45 220.819Z" fill="url(#paint45_linear_445_2646)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M522.925 295.827C523.296 293.627 521.898 291.557 519.801 291.203C517.705 290.849 515.704 292.346 515.333 294.546C514.961 296.746 516.36 298.816 518.456 299.17C520.553 299.524 522.554 298.027 522.925 295.827Z" fill="url(#paint46_linear_445_2646)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M666.52 322.989C666.52 322.989 654.29 320.039 645.42 322.669C645.42 322.669 652.62 317.319 662.37 318.969C672.12 320.619 670.74 324.309 666.52 322.989Z" fill="url(#paint47_linear_445_2646)"/>
<path opacity="0.7" d="M417.11 327.129C407.18 351.559 430.76 340.409 430.76 340.409C430.76 340.409 423.66 318.479 427.56 310.729C431.46 302.979 443.46 290.619 462.05 281.509C462.05 281.509 475.98 258.099 481.46 253.159C481.46 253.159 438.93 273.449 417.11 327.139V327.129Z" fill="url(#paint48_linear_445_2646)"/>
<path opacity="0.7" d="M419.7 390.739C419.7 390.739 412.88 373.799 418.52 364.389C424.17 354.979 452.95 349.329 457.65 350.539C462.36 351.749 465.34 361.939 465.34 361.939C465.34 361.939 454.01 356.859 445.73 359.439C435.93 362.499 423.93 367.199 419.69 390.729L419.7 390.739Z" fill="url(#paint49_linear_445_2646)"/>
<path opacity="0.7" d="M444.93 391.239C444.93 391.239 432.48 372.409 446.81 368.059C461.15 363.709 482.36 391.239 482.36 391.239C482.36 391.239 465.66 379.059 456.04 376.949C446.42 374.829 444.3 386.159 444.92 391.239H444.93Z" fill="url(#paint50_linear_445_2646)"/>
<path opacity="0.7" d="M433.58 323.089C433.58 323.089 433.91 317.169 436.52 312.499C439.13 307.829 453.81 295.909 453.81 295.909C453.81 295.909 444.28 314.729 433.57 323.089H433.58Z" fill="url(#paint51_linear_445_2646)"/>
<path d="M339.05 229.619C288.58 192.679 245.87 237.859 245.87 237.859C245.87 237.859 203.16 192.679 152.69 229.619C104.43 264.939 108.93 396.679 245.87 451.739C382.81 396.679 387.31 264.939 339.05 229.619Z" fill="url(#paint52_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" d="M245.88 451.739C382.82 396.679 387.32 264.939 339.06 229.619C322.45 217.459 306.68 214.199 293 215.289C299.32 214.879 325.8 215.329 330.77 250.499C336.42 290.499 294.53 380.379 224.18 389.089C163.55 396.589 137.85 354.759 136.94 353.239C153.41 391.059 187.67 428.329 245.89 451.739H245.88Z" fill="url(#paint53_linear_445_2646)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M174.05 225.22C174.05 225.22 142.17 234.14 132.76 275.45C123.35 316.76 146.29 358.2 146.29 358.2C146.29 358.2 141.58 308.76 174.05 225.22Z" fill="url(#paint54_linear_445_2646)"/>
<path opacity="0.7" d="M224.87 436.919C224.87 436.919 279.69 434.289 329.15 372.859C329.15 372.859 305.34 414.969 248.87 447.499C248.87 447.499 234.22 444.499 224.87 436.909V436.919Z" fill="url(#paint55_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" d="M230.52 239.45C246.52 262.98 268.87 270.74 268.87 270.74C268.87 270.74 282.42 213.96 259.85 226.85C251.02 232.41 245.88 237.85 245.88 237.85C245.88 237.85 222.01 212.6 188.87 215.3C189.27 215.31 214.65 216.1 230.53 239.44L230.52 239.45Z" fill="url(#paint56_linear_445_2646)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M174.05 477.36C174.05 477.36 294.05 436.91 337.81 364.17C381.57 291.43 354.75 230.81 318.05 200.04C281.34 169.28 291.81 129.07 304.4 107.75C321.11 79.4797 309.11 55.7197 309.11 55.7197C309.11 55.7197 322.29 62.7297 319.46 95.2997C316.64 127.87 328.87 148.91 357.81 157.96C357.81 157.96 326.87 171.98 347.34 200.15C367.81 228.31 401.69 308.49 374.05 353.76C343.18 404.31 355.7 426.23 379.7 440.71C379.7 440.71 373.11 420.58 377.35 408.14C377.35 408.14 423.82 436.64 398.47 477.36C398.47 477.36 380.18 452.59 342.76 446.82C305.35 441.05 292.29 434.94 292.29 434.94C292.29 434.94 301.47 450.89 332.53 460.05C332.53 460.05 308.18 480.41 263.35 468.53C218.53 456.65 203 477.35 203 477.35H174.06L174.05 477.36Z" fill="url(#paint57_linear_445_2646)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M422.22 477.36C422.22 477.36 439.87 444.79 410.96 401.81C382.05 358.83 406.35 331.95 408.32 285.45C410.29 238.95 392.88 211.8 392.88 211.8C392.88 211.8 410.97 276.43 374.53 333.92C342.93 383.77 319 426.68 379.24 456.09C379.24 456.09 382.89 443.47 398.48 447.83C417.24 453.09 422.24 477.35 422.24 477.35L422.22 477.36Z" fill="url(#paint58_linear_445_2646)"/>
<path d="M278.05 68.6201C278.05 68.6201 287.36 51.3301 269.11 38.5801C259.05 31.5601 237.53 34.7001 237.53 34.7001C237.53 34.7001 260.73 61.1001 278.05 68.6101V68.6201Z" fill="url(#paint59_linear_445_2646)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M233.34 188.96C233.34 188.96 271.81 205.59 301.1 188.96C301.1 188.96 304.27 172.64 280.26 154.01C256.25 135.38 265.93 111.9 292.46 91.4102C292.46 91.4102 278.49 124.47 294.28 151.18C312.87 182.62 314.04 218.36 314.04 218.36C314.04 218.36 260.86 213.38 233.33 188.95L233.34 188.96Z" fill="url(#paint60_linear_445_2646)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M303.93 52.5596C303.93 52.5596 313.11 66.3596 302.52 80.8296C291.93 95.3096 262.96 117.65 268.15 138.82C273.34 160 299.93 155.7 304.87 189.63C304.87 189.63 300.4 157.51 287.58 143.49C274.76 129.47 310.51 95.3796 314.05 82.1896C314.05 82.1896 315.7 60.9296 303.93 52.5596Z" fill="url(#paint61_linear_445_2646)"/>
<path d="M348.26 126.75C348.26 126.75 339.7 121.14 348.26 108.43C348.26 108.43 357.98 115.53 348.26 126.75Z" fill="url(#paint62_linear_445_2646)"/>
<path d="M151.06 185.44C151.06 185.44 111.26 200.87 105.62 177.5C105.62 177.5 106.5 174.1 116.8 171.68C116.8 171.68 129.07 185.02 151.06 185.44Z" fill="url(#paint63_linear_445_2646)"/>
<path d="M98.76 299.119C98.76 299.119 85.11 290.749 87.47 270.169C89.83 249.589 83 238.279 83 238.279C83 238.279 95.7 250.269 100.17 270.629C100.17 270.629 92.41 281.489 98.76 299.129V299.119Z" fill="url(#paint64_linear_445_2646)"/>
<path d="M155.47 67.5294C155.47 67.5294 148.46 61.0194 152.13 51.1494C152.13 51.1494 161.67 57.7294 163.87 69.4894L155.47 67.5294Z" fill="url(#paint65_linear_445_2646)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M392.95 388.359C392.95 388.359 425.51 399.259 436.66 411.709L430.46 411.639C430.46 411.639 437.73 430.009 427.58 455.809C427.58 455.809 417.43 419.289 392.95 388.359Z" fill="url(#paint66_linear_445_2646)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M535.311 477.59C535.311 477.59 580.641 446.49 633.931 431.56L597.131 461.08L616.541 459.04L622.221 450.9C622.221 450.9 686.181 432.58 720.411 426.13C720.411 426.13 665.481 443.43 614.241 471.26C614.241 471.26 580.951 464.47 562.621 477.37L535.321 477.6L535.311 477.59Z" fill="url(#paint67_linear_445_2646)"/>
<path d="M622.21 129.24L661.58 38.0801C661.58 38.0801 660.64 71.5601 657.58 80.6101L622.21 129.24Z" fill="url(#paint68_linear_445_2646)"/>
<path d="M534.76 151.64L509.7 131.28L517.11 126.75L534.76 151.64Z" fill="url(#paint69_linear_445_2646)"/>
<path d="M637.11 167.02L646.76 153.45C646.76 153.45 659.7 147.57 668.88 147.34L637.12 167.02H637.11Z" fill="url(#paint70_linear_445_2646)"/>
<path d="M742.28 333.06C742.28 333.06 744.63 301 749.81 284.22L764.16 275.13L742.28 333.06Z" fill="url(#paint71_linear_445_2646)"/>
<path d="M392.871 100.229L380.521 74.9494C380.521 74.9494 383.521 72.0694 388.641 69.8594L392.88 100.229H392.871Z" fill="url(#paint72_linear_445_2646)"/>
<path d="M754.28 411.159C754.28 411.159 768.62 412.309 774.04 407.299V398.189L754.28 411.159Z" fill="url(#paint73_linear_445_2646)"/>
<defs>
<linearGradient id="paint0_linear_445_2646" x1="419.441" y1="155.66" x2="713.131" y2="606.55" gradientUnits="userSpaceOnUse">
<stop stop-color="#95EBF5"/>
<stop offset="0.63" stop-color="#69ECF2"/>
<stop offset="1" stop-color="#55EDF2"/>
</linearGradient>
<linearGradient id="paint1_linear_445_2646" x1="581.311" y1="62.0498" x2="551.791" y2="498.73" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint2_linear_445_2646" x1="343.76" y1="303.52" x2="480.59" y2="381.31" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.11" stop-color="#FF8F01" stop-opacity="0.92"/>
<stop offset="0.34" stop-color="#FF8203" stop-opacity="0.71"/>
<stop offset="0.66" stop-color="#FF6C08" stop-opacity="0.38"/>
<stop offset="1" stop-color="#FF540D" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint3_linear_445_2646" x1="592.711" y1="116.26" x2="369.3" y2="540.54" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint4_linear_445_2646" x1="543.32" y1="302.95" x2="702.78" y2="503.57" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint5_linear_445_2646" x1="197.45" y1="145.38" x2="393.6" y2="578.53" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.36" stop-color="#FF8004"/>
<stop offset="1" stop-color="#FF540D"/>
</linearGradient>
<linearGradient id="paint6_linear_445_2646" x1="240.05" y1="178.57" x2="316.88" y2="502.91" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint7_linear_445_2646" x1="126.2" y1="273" x2="299.64" y2="660.16" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint8_linear_445_2646" x1="120.98" y1="264.86" x2="323.44" y2="539.66" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint9_linear_445_2646" x1="496.29" y1="270.06" x2="614.88" y2="447.94" gradientUnits="userSpaceOnUse">
<stop stop-color="#55EDF2"/>
<stop offset="0.65" stop-color="#3FBDE6"/>
<stop offset="1" stop-color="#36A9E1"/>
</linearGradient>
<linearGradient id="paint10_linear_445_2646" x1="732.86" y1="374.27" x2="512.26" y2="336.31" gradientUnits="userSpaceOnUse">
<stop stop-color="#1E1F5E"/>
<stop offset="0.18" stop-color="#1F2061" stop-opacity="0.95"/>
<stop offset="0.4" stop-color="#22246D" stop-opacity="0.79"/>
<stop offset="0.64" stop-color="#282A7F" stop-opacity="0.54"/>
<stop offset="0.89" stop-color="#303399" stop-opacity="0.18"/>
<stop offset="1" stop-color="#3538A7" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint11_linear_445_2646" x1="490.32" y1="440.499" x2="555.56" y2="267.339" gradientUnits="userSpaceOnUse">
<stop stop-color="#1E1F5E"/>
<stop offset="0.18" stop-color="#1F2061" stop-opacity="0.95"/>
<stop offset="0.4" stop-color="#22246D" stop-opacity="0.79"/>
<stop offset="0.64" stop-color="#282A7F" stop-opacity="0.54"/>
<stop offset="0.89" stop-color="#303399" stop-opacity="0.18"/>
<stop offset="1" stop-color="#3538A7" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint12_linear_445_2646" x1="639.85" y1="453.509" x2="405.97" y2="221.509" gradientUnits="userSpaceOnUse">
<stop stop-color="#1E1F5E"/>
<stop offset="0.18" stop-color="#1F2061" stop-opacity="0.95"/>
<stop offset="0.4" stop-color="#22246D" stop-opacity="0.79"/>
<stop offset="0.64" stop-color="#282A7F" stop-opacity="0.54"/>
<stop offset="0.89" stop-color="#303399" stop-opacity="0.18"/>
<stop offset="1" stop-color="#3538A7" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint13_linear_445_2646" x1="504.469" y1="320.879" x2="488.129" y2="306.059" gradientUnits="userSpaceOnUse">
<stop stop-color="#1E1F5E"/>
<stop offset="0.18" stop-color="#1F2061" stop-opacity="0.95"/>
<stop offset="0.4" stop-color="#22246D" stop-opacity="0.79"/>
<stop offset="0.64" stop-color="#282A7F" stop-opacity="0.54"/>
<stop offset="0.89" stop-color="#303399" stop-opacity="0.18"/>
<stop offset="1" stop-color="#3538A7" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint14_linear_445_2646" x1="662.8" y1="390.3" x2="462.36" y2="195.19" gradientUnits="userSpaceOnUse">
<stop stop-color="#1E1F5E"/>
<stop offset="0.18" stop-color="#1F2061" stop-opacity="0.95"/>
<stop offset="0.4" stop-color="#22246D" stop-opacity="0.79"/>
<stop offset="0.64" stop-color="#282A7F" stop-opacity="0.54"/>
<stop offset="0.89" stop-color="#303399" stop-opacity="0.18"/>
<stop offset="1" stop-color="#3538A7" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint15_linear_445_2646" x1="575.78" y1="333.979" x2="538.27" y2="297.469" gradientUnits="userSpaceOnUse">
<stop stop-color="#1E1F5E"/>
<stop offset="0.18" stop-color="#1F2061" stop-opacity="0.95"/>
<stop offset="0.4" stop-color="#22246D" stop-opacity="0.79"/>
<stop offset="0.64" stop-color="#282A7F" stop-opacity="0.54"/>
<stop offset="0.89" stop-color="#303399" stop-opacity="0.18"/>
<stop offset="1" stop-color="#3538A7" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint16_linear_445_2646" x1="615.71" y1="313.939" x2="483.71" y2="226.409" gradientUnits="userSpaceOnUse">
<stop stop-color="#1E1F5E"/>
<stop offset="0.18" stop-color="#1F2061" stop-opacity="0.95"/>
<stop offset="0.4" stop-color="#22246D" stop-opacity="0.79"/>
<stop offset="0.64" stop-color="#282A7F" stop-opacity="0.54"/>
<stop offset="0.89" stop-color="#303399" stop-opacity="0.18"/>
<stop offset="1" stop-color="#3538A7" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint17_linear_445_2646" x1="558.36" y1="284.109" x2="520.14" y2="279.629" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint18_linear_445_2646" x1="690.8" y1="314.399" x2="551.62" y2="298.089" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint19_linear_445_2646" x1="585.66" y1="386.739" x2="369.27" y2="361.369" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint20_linear_445_2646" x1="477.05" y1="323.38" x2="416.42" y2="316.27" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint21_linear_445_2646" x1="618.38" y1="360.069" x2="472.47" y2="342.959" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint22_linear_445_2646" x1="751.53" y1="298.699" x2="505.61" y2="269.869" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint23_linear_445_2646" x1="660.289" y1="417.51" x2="500.879" y2="398.82" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint24_linear_445_2646" x1="575.85" y1="395.94" x2="387.699" y2="373.879" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint25_linear_445_2646" x1="607.08" y1="296.309" x2="462.559" y2="279.369" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint26_linear_445_2646" x1="714.07" y1="345.589" x2="607.35" y2="333.079" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint27_linear_445_2646" x1="587.379" y1="278.689" x2="570.759" y2="276.739" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint28_linear_445_2646" x1="471.03" y1="299.399" x2="440.14" y2="295.779" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint29_linear_445_2646" x1="658.53" y1="306.9" x2="639.33" y2="304.65" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint30_linear_445_2646" x1="618.59" y1="347.259" x2="618.86" y2="226.549" gradientUnits="userSpaceOnUse">
<stop stop-color="#1E1F5E"/>
<stop offset="0.18" stop-color="#1F2061" stop-opacity="0.95"/>
<stop offset="0.4" stop-color="#22246D" stop-opacity="0.79"/>
<stop offset="0.64" stop-color="#282A7F" stop-opacity="0.54"/>
<stop offset="0.89" stop-color="#303399" stop-opacity="0.18"/>
<stop offset="1" stop-color="#3538A7" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint31_linear_445_2646" x1="501.6" y1="350.989" x2="501.86" y2="235.079" gradientUnits="userSpaceOnUse">
<stop stop-color="#1E1F5E"/>
<stop offset="0.18" stop-color="#1F2061" stop-opacity="0.95"/>
<stop offset="0.4" stop-color="#22246D" stop-opacity="0.79"/>
<stop offset="0.64" stop-color="#282A7F" stop-opacity="0.54"/>
<stop offset="0.89" stop-color="#303399" stop-opacity="0.18"/>
<stop offset="1" stop-color="#3538A7" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint32_linear_445_2646" x1="524.45" y1="525.179" x2="525.52" y2="34.659" gradientUnits="userSpaceOnUse">
<stop stop-color="#1E1F5E"/>
<stop offset="0.18" stop-color="#1F2061" stop-opacity="0.95"/>
<stop offset="0.4" stop-color="#22246D" stop-opacity="0.79"/>
<stop offset="0.64" stop-color="#282A7F" stop-opacity="0.54"/>
<stop offset="0.89" stop-color="#303399" stop-opacity="0.18"/>
<stop offset="1" stop-color="#3538A7" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint33_linear_445_2646" x1="528.92" y1="525.189" x2="529.99" y2="34.6692" gradientUnits="userSpaceOnUse">
<stop stop-color="#1E1F5E"/>
<stop offset="0.18" stop-color="#1F2061" stop-opacity="0.95"/>
<stop offset="0.4" stop-color="#22246D" stop-opacity="0.79"/>
<stop offset="0.64" stop-color="#282A7F" stop-opacity="0.54"/>
<stop offset="0.89" stop-color="#303399" stop-opacity="0.18"/>
<stop offset="1" stop-color="#3538A7" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint34_linear_445_2646" x1="551.26" y1="334.999" x2="551.37" y2="286.379" gradientUnits="userSpaceOnUse">
<stop stop-color="#1E1F5E"/>
<stop offset="0.18" stop-color="#1F2061" stop-opacity="0.95"/>
<stop offset="0.4" stop-color="#22246D" stop-opacity="0.79"/>
<stop offset="0.64" stop-color="#282A7F" stop-opacity="0.54"/>
<stop offset="0.89" stop-color="#303399" stop-opacity="0.18"/>
<stop offset="1" stop-color="#3538A7" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint35_linear_445_2646" x1="748.65" y1="419.369" x2="557.589" y2="278.669" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.11" stop-color="#FF9A0D" stop-opacity="0.92"/>
<stop offset="0.34" stop-color="#FFA931" stop-opacity="0.71"/>
<stop offset="0.66" stop-color="#FFC16A" stop-opacity="0.38"/>
<stop offset="1" stop-color="#FFDCAB" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint36_linear_445_2646" x1="492.303" y1="253.158" x2="541.136" y2="321.528" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.11" stop-color="#FF9A0D" stop-opacity="0.92"/>
<stop offset="0.34" stop-color="#FFA931" stop-opacity="0.71"/>
<stop offset="0.66" stop-color="#FFC16A" stop-opacity="0.38"/>
<stop offset="1" stop-color="#FFDCAB" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint37_linear_445_2646" x1="625.41" y1="353.2" x2="414.7" y2="301.32" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.11" stop-color="#FF9A0D" stop-opacity="0.92"/>
<stop offset="0.34" stop-color="#FFA931" stop-opacity="0.71"/>
<stop offset="0.66" stop-color="#FFC16A" stop-opacity="0.38"/>
<stop offset="1" stop-color="#FFDCAB" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint38_linear_445_2646" x1="692.43" y1="325.039" x2="514.9" y2="205.039" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.11" stop-color="#FF9A0D" stop-opacity="0.92"/>
<stop offset="0.34" stop-color="#FFA931" stop-opacity="0.71"/>
<stop offset="0.66" stop-color="#FFC16A" stop-opacity="0.38"/>
<stop offset="1" stop-color="#FFDCAB" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint39_linear_445_2646" x1="626.78" y1="416.869" x2="492.31" y2="292.989" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.11" stop-color="#FF9A0D" stop-opacity="0.92"/>
<stop offset="0.34" stop-color="#FFA931" stop-opacity="0.71"/>
<stop offset="0.66" stop-color="#FFC16A" stop-opacity="0.38"/>
<stop offset="1" stop-color="#FFDCAB" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint40_linear_445_2646" x1="633.56" y1="290.529" x2="583.36" y2="325.199" gradientUnits="userSpaceOnUse">
<stop stop-color="#FC9D91" stop-opacity="0"/>
<stop offset="0.18" stop-color="#FC9D91" stop-opacity="0.22"/>
<stop offset="0.48" stop-color="#FC9D91" stop-opacity="0.55"/>
<stop offset="0.73" stop-color="#FC9D91" stop-opacity="0.79"/>
<stop offset="0.91" stop-color="#FC9D91" stop-opacity="0.94"/>
<stop offset="1" stop-color="#FC9D91"/>
</linearGradient>
<linearGradient id="paint41_linear_445_2646" x1="596.24" y1="323.369" x2="523.54" y2="252.539" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.11" stop-color="#FF9A0D" stop-opacity="0.92"/>
<stop offset="0.34" stop-color="#FFA931" stop-opacity="0.71"/>
<stop offset="0.66" stop-color="#FFC16A" stop-opacity="0.38"/>
<stop offset="1" stop-color="#FFDCAB" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint42_linear_445_2646" x1="605.09" y1="302.859" x2="597.05" y2="311.159" gradientUnits="userSpaceOnUse">
<stop stop-color="#FC9D91" stop-opacity="0"/>
<stop offset="0.18" stop-color="#FC9D91" stop-opacity="0.22"/>
<stop offset="0.48" stop-color="#FC9D91" stop-opacity="0.55"/>
<stop offset="0.73" stop-color="#FC9D91" stop-opacity="0.79"/>
<stop offset="0.91" stop-color="#FC9D91" stop-opacity="0.94"/>
<stop offset="1" stop-color="#FC9D91"/>
</linearGradient>
<linearGradient id="paint43_linear_445_2646" x1="549.22" y1="262.069" x2="536.36" y2="275.349" gradientUnits="userSpaceOnUse">
<stop stop-color="#FC9D91" stop-opacity="0"/>
<stop offset="0.18" stop-color="#FC9D91" stop-opacity="0.22"/>
<stop offset="0.48" stop-color="#FC9D91" stop-opacity="0.55"/>
<stop offset="0.73" stop-color="#FC9D91" stop-opacity="0.79"/>
<stop offset="0.91" stop-color="#FC9D91" stop-opacity="0.94"/>
<stop offset="1" stop-color="#FC9D91"/>
</linearGradient>
<linearGradient id="paint44_linear_445_2646" x1="546.907" y1="243.662" x2="550.816" y2="234.734" gradientUnits="userSpaceOnUse">
<stop stop-color="#FC9D91" stop-opacity="0"/>
<stop offset="0.18" stop-color="#FC9D91" stop-opacity="0.22"/>
<stop offset="0.48" stop-color="#FC9D91" stop-opacity="0.55"/>
<stop offset="0.73" stop-color="#FC9D91" stop-opacity="0.79"/>
<stop offset="0.91" stop-color="#FC9D91" stop-opacity="0.94"/>
<stop offset="1" stop-color="#FC9D91"/>
</linearGradient>
<linearGradient id="paint45_linear_445_2646" x1="525.63" y1="220.799" x2="525.28" y2="219.859" gradientUnits="userSpaceOnUse">
<stop stop-color="#FC9D91" stop-opacity="0"/>
<stop offset="0.18" stop-color="#FC9D91" stop-opacity="0.22"/>
<stop offset="0.48" stop-color="#FC9D91" stop-opacity="0.55"/>
<stop offset="0.73" stop-color="#FC9D91" stop-opacity="0.79"/>
<stop offset="0.91" stop-color="#FC9D91" stop-opacity="0.94"/>
<stop offset="1" stop-color="#FC9D91"/>
</linearGradient>
<linearGradient id="paint46_linear_445_2646" x1="516.92" y1="303.89" x2="520.307" y2="290.548" gradientUnits="userSpaceOnUse">
<stop stop-color="#FC9D91" stop-opacity="0"/>
<stop offset="0.18" stop-color="#FC9D91" stop-opacity="0.22"/>
<stop offset="0.48" stop-color="#FC9D91" stop-opacity="0.55"/>
<stop offset="0.73" stop-color="#FC9D91" stop-opacity="0.79"/>
<stop offset="0.91" stop-color="#FC9D91" stop-opacity="0.94"/>
<stop offset="1" stop-color="#FC9D91"/>
</linearGradient>
<linearGradient id="paint47_linear_445_2646" x1="658.17" y1="315.369" x2="657.45" y2="324.129" gradientUnits="userSpaceOnUse">
<stop stop-color="#FC9D91" stop-opacity="0"/>
<stop offset="0.18" stop-color="#FC9D91" stop-opacity="0.22"/>
<stop offset="0.48" stop-color="#FC9D91" stop-opacity="0.55"/>
<stop offset="0.73" stop-color="#FC9D91" stop-opacity="0.79"/>
<stop offset="0.91" stop-color="#FC9D91" stop-opacity="0.94"/>
<stop offset="1" stop-color="#FC9D91"/>
</linearGradient>
<linearGradient id="paint48_linear_445_2646" x1="403.42" y1="327.739" x2="508.24" y2="265.979" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFEC65"/>
<stop offset="0.34" stop-color="#FFCB3E" stop-opacity="0.62"/>
<stop offset="0.66" stop-color="#FFAE1D" stop-opacity="0.29"/>
<stop offset="0.89" stop-color="#FF9B08" stop-opacity="0.08"/>
<stop offset="1" stop-color="#FF9500" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint49_linear_445_2646" x1="416.9" y1="357.289" x2="476.9" y2="393.769" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFEC65"/>
<stop offset="0.34" stop-color="#FFCB3E" stop-opacity="0.62"/>
<stop offset="0.66" stop-color="#FFAE1D" stop-opacity="0.29"/>
<stop offset="0.89" stop-color="#FF9B08" stop-opacity="0.08"/>
<stop offset="1" stop-color="#FF9500" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint50_linear_445_2646" x1="436.98" y1="373.789" x2="491.8" y2="398.959" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFEC65"/>
<stop offset="0.34" stop-color="#FFCB3E" stop-opacity="0.62"/>
<stop offset="0.66" stop-color="#FFAE1D" stop-opacity="0.29"/>
<stop offset="0.89" stop-color="#FF9B08" stop-opacity="0.08"/>
<stop offset="1" stop-color="#FF9500" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint51_linear_445_2646" x1="424.85" y1="321.349" x2="474.26" y2="290.289" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFEC65"/>
<stop offset="0.34" stop-color="#FFCB3E" stop-opacity="0.62"/>
<stop offset="0.66" stop-color="#FFAE1D" stop-opacity="0.29"/>
<stop offset="0.89" stop-color="#FF9B08" stop-opacity="0.08"/>
<stop offset="1" stop-color="#FF9500" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint52_linear_445_2646" x1="182.03" y1="243.549" x2="337.68" y2="398.129" gradientUnits="userSpaceOnUse">
<stop stop-color="#EF4B9F"/>
<stop offset="1" stop-color="#E6332A"/>
</linearGradient>
<linearGradient id="paint53_linear_445_2646" x1="189.23" y1="257.919" x2="568.29" y2="595.689" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint54_linear_445_2646" x1="115.47" y1="204.39" x2="268.65" y2="503.69" gradientUnits="userSpaceOnUse">
<stop stop-color="#FCEA10"/>
<stop offset="0.33" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="0.66" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.89" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="1" stop-color="#FF9500" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint55_linear_445_2646" x1="267.7" y1="519.259" x2="276.52" y2="410.909" gradientUnits="userSpaceOnUse">
<stop stop-color="#36A9E1" stop-opacity="0"/>
<stop offset="0.09" stop-color="#36AAE1" stop-opacity="0.03"/>
<stop offset="0.22" stop-color="#39B0E2" stop-opacity="0.11"/>
<stop offset="0.38" stop-color="#3DB9E5" stop-opacity="0.24"/>
<stop offset="0.56" stop-color="#43C5E8" stop-opacity="0.42"/>
<stop offset="0.75" stop-color="#4AD5EC" stop-opacity="0.65"/>
<stop offset="0.95" stop-color="#52E8F0" stop-opacity="0.93"/>
<stop offset="1" stop-color="#55EDF2"/>
</linearGradient>
<linearGradient id="paint56_linear_445_2646" x1="310.1" y1="264.51" x2="-12.4798" y2="137.45" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint57_linear_445_2646" x1="69.0498" y1="114.49" x2="573.63" y2="558.9" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint58_linear_445_2646" x1="329.37" y1="179.48" x2="412.26" y2="460.76" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint59_linear_445_2646" x1="245.55" y1="25.4301" x2="403.17" y2="228.86" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.36" stop-color="#FF8004"/>
<stop offset="1" stop-color="#FF540D"/>
</linearGradient>
<linearGradient id="paint60_linear_445_2646" x1="208.18" y1="66.0602" x2="370.05" y2="285.31" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint61_linear_445_2646" x1="271.38" y1="41.5896" x2="328.84" y2="226.64" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint62_linear_445_2646" x1="348.86" y1="85.7197" x2="348.11" y2="156.39" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.36" stop-color="#FF8004"/>
<stop offset="1" stop-color="#FF540D"/>
</linearGradient>
<linearGradient id="paint63_linear_445_2646" x1="108.596" y1="176.221" x2="168.364" y2="202.646" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.36" stop-color="#FF8004"/>
<stop offset="1" stop-color="#FF540D"/>
</linearGradient>
<linearGradient id="paint64_linear_445_2646" x1="82.3" y1="247.529" x2="113.63" y2="322.459" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.36" stop-color="#FF8004"/>
<stop offset="1" stop-color="#FF540D"/>
</linearGradient>
<linearGradient id="paint65_linear_445_2646" x1="152.4" y1="52.6194" x2="171.14" y2="94.1394" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.36" stop-color="#FF8004"/>
<stop offset="1" stop-color="#FF540D"/>
</linearGradient>
<linearGradient id="paint66_linear_445_2646" x1="404.42" y1="359.979" x2="432.59" y2="468.599" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint67_linear_445_2646" x1="773.471" y1="344.87" x2="532.99" y2="521.55" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint68_linear_445_2646" x1="661.54" y1="55.8801" x2="600.9" y2="163.04" gradientUnits="userSpaceOnUse">
<stop stop-color="#95EBF5"/>
<stop offset="0.63" stop-color="#69ECF2"/>
<stop offset="1" stop-color="#55EDF2"/>
</linearGradient>
<linearGradient id="paint69_linear_445_2646" x1="506.33" y1="120.88" x2="545.8" y2="166.24" gradientUnits="userSpaceOnUse">
<stop stop-color="#95EBF5"/>
<stop offset="0.63" stop-color="#69ECF2"/>
<stop offset="1" stop-color="#55EDF2"/>
</linearGradient>
<linearGradient id="paint70_linear_445_2646" x1="680.38" y1="138.78" x2="628.21" y2="167.89" gradientUnits="userSpaceOnUse">
<stop stop-color="#95EBF5"/>
<stop offset="0.63" stop-color="#69ECF2"/>
<stop offset="1" stop-color="#55EDF2"/>
</linearGradient>
<linearGradient id="paint71_linear_445_2646" x1="763.9" y1="256.12" x2="736.2" y2="350.58" gradientUnits="userSpaceOnUse">
<stop stop-color="#95EBF5"/>
<stop offset="0.63" stop-color="#69ECF2"/>
<stop offset="1" stop-color="#55EDF2"/>
</linearGradient>
<linearGradient id="paint72_linear_445_2646" x1="393.95" y1="62.8494" x2="381.41" y2="105.609" gradientUnits="userSpaceOnUse">
<stop stop-color="#95EBF5"/>
<stop offset="0.63" stop-color="#69ECF2"/>
<stop offset="1" stop-color="#55EDF2"/>
</linearGradient>
<linearGradient id="paint73_linear_445_2646" x1="769.42" y1="391.299" x2="761.34" y2="418.859" gradientUnits="userSpaceOnUse">
<stop stop-color="#95EBF5"/>
<stop offset="0.63" stop-color="#69ECF2"/>
<stop offset="1" stop-color="#55EDF2"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 57 KiB

View File

@ -0,0 +1,572 @@
<svg width="858" height="512" viewBox="0 0 858 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M408.321 194.39C408.321 194.39 405.231 179.46 406.291 165.21C406.291 165.21 414.381 177.32 431.001 172.5C445.651 168.26 429.941 156.1 417.761 166.05C417.761 166.05 395.231 157.96 401.351 119.51C401.351 119.51 416.251 145.75 463.151 168.37C510.051 190.99 533.891 196.42 533.891 196.42C533.891 196.42 511.931 183.87 490.871 166.22C490.871 166.22 548.381 160.79 573.831 200.49C573.831 200.49 575.701 195.51 584.411 198.68C593.121 201.85 589.241 221.53 614.241 228.54C614.241 228.54 606.531 221.75 599.231 210.22C599.231 210.22 595.231 181.72 614.241 149.94C614.241 149.94 608.171 178.33 616.801 198.46L628.171 200.72C628.171 200.72 628.921 214.33 638.051 210.45C644.171 207.85 646.051 202.05 651.461 201.85C663.931 201.4 670.171 220.74 670.171 220.74C670.171 220.74 694.981 217.25 715.231 236.46C731.701 252.09 727.701 291.1 727.701 291.1C727.701 291.1 726.051 280.23 715.461 273.73C715.461 273.73 700.171 278.15 701.811 296.33C703.461 314.51 712.161 333.07 712.161 333.07C712.161 333.07 705.391 312.11 724.211 305.8C724.211 305.8 721.331 321.39 723.691 330.16C726.051 338.93 735.221 358.84 735.221 377.84C735.221 377.84 708.401 419.23 659.691 442.53C659.691 442.53 708.631 418.33 732.161 417.4C732.161 417.4 750.041 431.22 762.041 462.44C762.041 462.44 712.861 441.63 690.511 459.05C672.831 472.83 693.571 477.37 693.571 477.37H398.461C398.461 477.37 353.341 462.89 351.931 392.32C350.521 321.75 388.641 262.64 388.641 262.64L408.321 194.39Z" fill="url(#paint0_linear_445_2646)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M408.321 194.39C408.321 194.39 405.231 179.46 406.291 165.21C406.291 165.21 414.381 177.32 431.001 172.5C445.651 168.26 429.941 156.1 417.761 166.05C417.761 166.05 395.231 157.96 401.351 119.51C401.351 119.51 416.251 145.75 463.151 168.37C510.051 190.99 533.891 196.42 533.891 196.42C533.891 196.42 511.931 183.87 490.871 166.22C490.871 166.22 548.381 160.79 573.831 200.49C573.831 200.49 575.701 195.51 584.411 198.68C593.121 201.85 589.241 221.53 614.241 228.54C614.241 228.54 606.531 221.75 599.231 210.22C599.231 210.22 595.231 181.72 614.241 149.94C614.241 149.94 608.171 178.33 616.801 198.46L628.171 200.72C628.171 200.72 628.921 214.33 638.051 210.45C644.171 207.85 646.051 202.05 651.461 201.85C663.931 201.4 670.171 220.74 670.171 220.74C670.171 220.74 694.981 217.25 715.231 236.46C731.701 252.09 727.701 291.1 727.701 291.1C727.701 291.1 726.051 280.23 715.461 273.73C715.461 273.73 700.171 278.15 701.811 296.33C703.461 314.51 712.161 333.07 712.161 333.07C712.161 333.07 705.391 312.11 724.211 305.8C724.211 305.8 721.331 321.39 723.691 330.16C726.051 338.93 735.221 358.84 735.221 377.84C735.221 377.84 708.401 419.23 659.691 442.53C659.691 442.53 708.631 418.33 732.161 417.4C732.161 417.4 750.041 431.22 762.041 462.44C762.041 462.44 712.861 441.63 690.511 459.05C672.831 472.83 693.571 477.37 693.571 477.37H398.461C398.461 477.37 353.341 462.89 351.931 392.32C350.521 321.75 388.641 262.64 388.641 262.64L408.321 194.39Z" fill="url(#paint1_linear_445_2646)"/>
<path d="M366.94 151.64C366.94 151.64 436.87 159.56 444.38 276.44C447.85 330.47 493.54 369.67 493.54 369.67C493.54 369.67 465.57 400.49 410.96 382.41C410.96 382.41 405.11 389.6 420.17 414.93C435.23 440.26 436.52 471.03 422.23 477.36H351.9L336.41 181.06L366.95 151.64H366.94Z" fill="url(#paint2_linear_445_2646)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M463.151 477.36C463.151 477.36 481.111 413.67 468.571 339C456.031 264.33 427.561 203.21 427.561 203.21C427.561 203.21 450.761 220.4 460.171 239.85V221.19C460.171 221.19 515.891 217.68 562.621 243.92C609.351 270.16 572.761 433.93 504.001 477.36L489.351 477.59C489.351 477.59 506.031 467.86 508.871 438.01C508.871 438.01 482.381 451.21 467.001 477.59L463.151 477.36Z" fill="url(#paint3_linear_445_2646)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M690.52 459.04C712.87 441.62 762.05 462.43 762.05 462.43C750.05 431.21 732.17 417.39 732.17 417.39C710.8 418.23 668.47 438.27 660.88 441.95C660.49 442.14 660.09 442.34 659.7 442.53C659.7 442.53 660.12 442.32 660.88 441.95C708.83 418.55 735.23 377.83 735.23 377.83C735.23 358.83 726.05 338.92 723.7 330.15C721.35 321.38 724.22 305.79 724.22 305.79C705.4 312.1 712.17 333.06 712.17 333.06C712.17 333.06 703.46 314.49 701.82 296.32C700.17 278.14 715.47 273.72 715.47 273.72C724.41 279.21 726.97 287.81 727.56 290.36C717.82 239.88 645.43 217.63 645.43 217.63C645.43 217.63 681.35 245.28 692.18 272.91C703 300.53 692.18 364.17 692.18 364.17C692.18 364.17 723.71 362.91 714.3 376.92C704.89 390.93 664.65 407.47 664.65 407.47L645.43 406.11C617.03 429.18 549.27 452.26 549.27 452.26C556.72 430.55 560.4 384.93 560.4 384.93C536.44 454.91 478.53 477.37 478.53 477.37H693.59C693.59 477.37 672.85 472.83 690.53 459.05L690.52 459.04Z" fill="url(#paint4_linear_445_2646)"/>
<path d="M174.05 477.36C174.05 477.36 133.11 465.15 143.46 429.86C153.81 394.57 107.42 384.67 102.05 369.8C93.23 345.37 118.99 337.11 105.34 312.23C105.34 312.23 116.87 319.47 117.58 343.22C118.29 366.97 141.58 370.59 169.82 370.14C169.82 370.14 110.71 292.54 129.35 252.97C146.88 215.76 198.06 201.85 199.47 193.25C200.88 184.65 180.65 181.04 180.65 181.04C180.65 181.04 203.36 162.83 222.41 174.71C241.47 186.59 269.7 199.48 297.94 185.91C297.94 185.91 302.29 171.77 279.94 156.62C259.99 143.1 251.35 127.89 286.53 94.64C316.41 66.4 301.12 51.21 301.12 51.21C301.12 51.21 324.06 48.16 322.65 88.87C321.24 129.59 335.73 145.19 366.95 151.64C398.17 158.09 421.47 190.32 427.12 228.32C432.77 266.32 415.94 283.74 439.94 319.93C463.94 356.12 472.34 359.58 468.58 391.24C468.58 391.24 460.44 387.9 428.88 367.77C389 342.32 387.59 316.54 387.59 316.54C387.59 316.54 393.59 332.49 382.3 349.45C371.01 366.41 355.12 385.42 369.59 423.42L373.47 401.03C373.47 401.03 408.06 423.42 412.29 443.1C416.52 462.78 398.47 477.37 398.47 477.37H174.06L174.05 477.36Z" fill="url(#paint5_linear_445_2646)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M174.05 477.36C174.05 477.36 133.11 465.15 143.46 429.86C153.81 394.57 107.42 384.67 102.05 369.8C93.23 345.37 118.99 337.11 105.34 312.23C105.34 312.23 116.87 319.47 117.58 343.22C118.29 366.97 141.58 370.59 169.82 370.14C169.82 370.14 110.71 292.54 129.35 252.97C146.88 215.76 198.06 201.85 199.47 193.25C200.88 184.65 180.65 181.04 180.65 181.04C180.65 181.04 203.36 162.83 222.41 174.71C241.47 186.59 269.7 199.48 297.94 185.91C297.94 185.91 302.29 171.77 279.94 156.62C259.99 143.1 251.35 127.89 286.53 94.64C316.41 66.4 301.12 51.21 301.12 51.21C301.12 51.21 324.06 48.16 322.65 88.87C321.24 129.59 335.73 145.19 366.95 151.64C398.17 158.09 421.47 190.32 427.12 228.32C432.77 266.32 415.94 283.74 439.94 319.93C463.94 356.12 472.34 359.58 468.58 391.24C468.58 391.24 460.44 387.9 428.88 367.77C389 342.32 387.59 316.54 387.59 316.54C387.59 316.54 393.59 332.49 382.3 349.45C371.01 366.41 355.12 385.42 369.59 423.42L373.47 401.03C373.47 401.03 408.06 423.42 412.29 443.1C416.52 462.78 398.47 477.37 398.47 477.37H174.06L174.05 477.36Z" fill="url(#paint6_linear_445_2646)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M174.05 477.36C174.05 477.36 149.7 464.13 152.87 434.61C156.05 405.09 129.25 391.06 115.11 378.63C100.97 366.19 108.76 344.59 108.76 344.59C108.76 344.59 102.05 368.79 129.35 375.35C156.64 381.91 192.88 400.01 192.88 400.01C192.88 400.01 170.17 423.76 184.29 448.7C198.41 473.64 278.53 477.37 278.53 477.37H174.06L174.05 477.36Z" fill="url(#paint7_linear_445_2646)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M278.52 477.36C278.52 477.36 197.58 479.62 165.11 440.26C165.11 440.26 162.76 399.28 207.46 379.28C252.17 359.28 239.46 310.17 239.46 310.17C239.46 310.17 269.58 383.71 241.34 419.45C241.34 419.45 200.16 406.78 190.99 425.1C181.81 443.42 204.75 472.6 278.52 477.35V477.36Z" fill="url(#paint8_linear_445_2646)"/>
<path d="M620 386.02C620 386.02 632.72 416.28 597.14 426.16C561.56 436.04 518.78 405.45 480.12 406.29C441.46 407.12 405.95 404.89 408.44 356.62C410.93 308.35 467.15 218.9 579.24 236.4C669.96 250.56 685.42 317.5 685.42 341.96C685.42 360.73 675.66 398.48 619.99 386.03L620 386.02Z" fill="url(#paint9_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.35" d="M526.18 339.46C526.18 339.46 548.12 340.65 573.62 354.88C599.12 369.11 616.38 373.33 638.26 354.62C662.93 333.52 685.04 334.73 685.04 334.73C685.31 337.41 685.43 339.85 685.43 341.96C685.43 360.73 675.67 398.48 620 386.03C597 381.06 588.44 370.7 569.2 357.26C549.96 343.82 526.17 339.47 526.17 339.47L526.18 339.46Z" fill="url(#paint10_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.35" d="M597.14 426.159C561.56 436.039 518.78 405.449 480.12 406.289C441.46 407.119 405.95 404.889 408.44 356.619C410.18 322.959 438.04 269.269 492.77 245.789C439.2 293.259 460.66 380.549 492.77 395.999C513.59 406.019 533.14 396.109 565.91 386.029C593.45 377.559 620 386.029 620 386.029C620 386.029 632.71 416.289 597.14 426.169V426.159Z" fill="url(#paint11_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M620 386.019C620 386.019 586.47 373.859 566.7 377.019C546.93 380.179 533.49 383.819 526.18 383.779C518.87 383.739 492.51 357.639 483.55 334.709C474.59 311.789 477.36 266.319 493.96 268.299C510.56 270.279 512.15 293.599 512.15 309.419C512.15 325.229 498.71 325.229 495.94 319.699C493.17 314.169 494.36 297.169 494.36 297.169C494.36 297.169 495.81 315.749 498.58 317.859C501.35 319.969 509.25 318.259 508.86 310.879C508.47 303.499 508.86 283.999 503.46 278.069C498.06 272.139 491.2 270.949 488.57 274.639C485.94 278.329 480.73 292.829 482.9 310.219C485.07 327.619 487.64 340.459 498.71 355.089C509.78 369.719 518.67 378.219 527.97 379.599C537.26 380.979 564.19 370.309 578.57 372.479C592.95 374.649 620.01 386.029 620.01 386.029L620 386.019Z" fill="url(#paint12_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M495.469 305.989C496.489 311.259 496.129 319.759 502.119 318.659C502.119 318.659 497.769 322.859 495.929 319.689C494.089 316.519 495.469 305.979 495.469 305.979V305.989Z" fill="url(#paint13_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M654.07 367.13C654.07 367.13 675.81 359.22 664.74 350.53C653.67 341.83 637.07 343.41 626.79 346.58C616.51 349.74 566.3 340.5 559.19 327.13C552.07 313.76 570.65 297.16 580.54 285.3C590.42 273.44 566.31 251.7 556.42 249.72C556.42 249.72 572.63 257.36 576.06 270.28C579.49 283.19 574.74 289.78 565.25 297.95C555.76 306.12 546.8 320.85 558.66 331.14C570.52 341.43 592.89 347.23 610.44 349.08C627.98 350.92 647.22 346.18 653.28 348.29C659.34 350.4 673.84 355.41 654.07 367.13Z" fill="url(#paint14_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M567.7 337.209C567.7 337.209 554.97 331.349 553.32 322.619C551.67 313.889 559.31 304.129 559.31 304.129C559.31 304.129 549.98 325.859 567.7 337.199V337.209Z" fill="url(#paint15_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M556.42 249.719C556.42 249.719 540.74 241.549 518.86 247.609C496.98 253.669 501.99 263.419 516.22 270.279C530.45 277.129 532.56 288.729 542.05 286.619C551.54 284.509 562.08 273.709 549.96 262.899C537.84 252.089 529.4 254.199 529.4 254.199C529.4 254.199 545.13 258.419 549.26 267.379C553.39 276.339 546.54 281.349 540.21 282.139C533.88 282.929 530.19 271.469 521.5 268.239C512.8 265.009 504.63 259.739 510.43 254.469C516.23 249.199 537.05 243.929 556.42 249.729V249.719Z" fill="url(#paint16_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M548.51 277.989C548.51 277.989 544.16 287.519 539.42 286.849C534.68 286.179 530.53 281.039 528.26 278.379C525.99 275.709 534.89 281.939 539.03 282.229C544.56 282.629 548.51 277.989 548.51 277.989Z" fill="url(#paint17_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M580.54 285.299C580.54 285.299 607.03 280.139 625.61 286.869C644.19 293.599 659.61 307.829 639.05 311.789C618.49 315.739 601.89 331.139 593.19 320.679C584.49 310.209 586.34 297.429 612.69 297.959C612.69 297.959 599.78 294.529 588.97 299.009C578.16 303.489 582.64 316.729 590.29 322.629C597.93 328.529 606.1 330.699 621.65 322.629C637.2 314.559 652.49 314.959 653.01 308.429C653.54 301.909 648.27 290.049 632.98 285.039C617.69 280.029 591.07 280.559 580.53 285.299H580.54Z" fill="url(#paint18_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M526.18 383.779C526.18 383.779 500.28 385.319 485.66 373.859C471.03 362.399 471.03 346.979 451.66 349.349C432.29 351.719 414.89 357.649 413.31 371.489C411.73 385.329 420.49 394.349 420.49 394.349C420.49 394.349 412.13 386.509 411.21 375.699C410.29 364.889 410.95 355.009 434.93 348.679C458.91 342.349 465.24 343.669 471.96 353.419C478.68 363.169 488.96 379.589 526.19 383.769L526.18 383.779Z" fill="url(#paint19_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M425.25 351.92C425.25 351.92 441.77 346.97 450.46 335.11C459.16 323.25 469.24 285.079 459.55 286.869C449.86 288.659 427.13 309.42 428.32 317.92C429.51 326.42 431.88 332.94 431.88 332.94C431.88 332.94 429.38 320.29 433.2 313.96C437.02 307.63 452.84 291.82 457.38 292.41C461.92 293 450.46 328.389 445.72 334.119C440.98 339.849 425.25 351.92 425.25 351.92Z" fill="url(#paint20_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M562.61 334.709C562.61 334.709 543.9 324.629 525.18 327.889C506.47 331.159 492.76 341.569 506.6 358.829C518.49 373.669 528.49 375.579 546.87 369.579C565.25 363.579 576.58 372.459 576.58 372.459C576.58 372.459 569.73 363.569 556.42 361.589C543.11 359.609 533.89 353.819 533.89 353.819C533.89 353.819 547.86 362.249 543.38 364.629C538.9 366.999 524.67 371.219 514.39 361.599C504.11 351.979 502.53 342.759 510.17 336.959C517.81 331.159 535.74 327.979 562.62 334.709H562.61Z" fill="url(#paint21_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M580.54 285.299C580.54 285.299 598.49 251.799 632.2 268.239C665.91 284.679 681.44 316.599 681.44 316.599C681.44 316.599 679.38 307.969 672 296.369C664.62 284.769 640.7 264.479 622.22 259.729C603.74 254.989 556.43 249.709 556.43 249.709C556.43 249.709 582.65 253.659 593.99 257.349C605.32 261.039 584.5 264.729 580.55 285.289L580.54 285.299Z" fill="url(#paint22_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M530.199 416.55C530.199 416.55 547.059 422.09 570.779 417.87C594.499 413.65 599.769 402.58 588.699 398.1C577.629 393.62 574.199 397.57 559.179 400.74C544.159 403.9 559.439 411.55 559.439 411.55C559.439 411.55 539.149 406.54 552.319 399.43C565.499 392.31 580.519 389.09 589.749 394.36C598.969 399.63 604.659 412.94 616.519 414.79L615.059 416.42C615.059 416.42 609.669 416.47 607.009 413.8C604.349 411.13 600.389 405 598.999 407.77C597.609 410.54 585.239 420.26 571.229 421.31C552.749 422.69 539.459 419.72 530.189 416.56L530.199 416.55Z" fill="url(#paint23_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M425.69 398.15C425.69 398.15 425.559 381.11 441.379 369.57C457.189 358.03 481.44 384.65 506.48 393.61C531.52 402.57 509.379 409.95 487.369 395.72C471.369 385.37 456.929 375.43 450.869 379.38C444.809 383.33 447.71 395.46 447.71 395.46C447.71 395.46 449.29 382.02 455.35 382.02C461.41 382.02 485.13 402.049 503.05 405.739C520.97 409.429 526.9 402.37 520.97 397.04C517.34 393.77 508.059 393.17 496.989 386.01C485.919 378.85 460.88 360.14 449.02 361.59C437.16 363.04 424.159 373.46 422.229 395.79C422.229 395.79 425.13 397.81 425.69 398.15Z" fill="url(#paint24_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M562.609 334.709C562.609 334.709 542.579 324.629 542.579 310.999C542.579 297.359 535.859 293.209 519.059 280.559C502.259 267.909 485.409 263.239 506.089 240.869L503.059 241.859C503.059 241.859 494.259 248.259 493.559 257.899C493.159 263.369 498.999 268.509 506.009 274.039C513.019 279.569 530.819 291.729 534.969 297.559C539.119 303.389 535.549 313.499 542.879 321.679C552.989 332.949 562.609 334.719 562.609 334.719V334.709Z" fill="url(#paint25_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M626.79 346.579C626.79 346.579 637.66 337.679 649.92 332.739C662.18 327.799 685.35 338.859 685.35 338.859L685.04 334.729C685.04 334.729 663.43 324.579 650.91 329.579C638.39 334.589 626.79 346.579 626.79 346.579Z" fill="url(#paint26_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M575.399 265.129C575.399 265.129 591.079 275.939 576.799 289.599C576.799 289.599 580.009 275.939 575.399 265.129Z" fill="url(#paint27_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M461.37 287.089C463.09 287.749 465.49 295.579 457.98 309.149C457.98 309.149 461.27 294.799 457.39 292.409C455.22 291.079 445.74 299.929 445.74 299.929C445.74 299.929 455.87 284.969 461.37 287.079V287.089Z" fill="url(#paint28_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.5" d="M645.52 295.48C645.52 295.48 655.65 303.49 652.52 310.03C650.71 313.82 641.91 314.85 641.91 314.85C641.91 314.85 648.53 310.6 648.83 306.05C649.13 301.5 645.52 295.47 645.52 295.47V295.48Z" fill="url(#paint29_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.2" d="M639.05 311.779C618.49 315.729 601.89 331.129 593.19 320.669C588.49 315.009 586.87 308.679 590.54 304.129C591.64 311.899 601.89 317.709 612.17 312.569C622.45 307.429 636.48 310.199 639.45 302.489C642.41 294.779 634.53 290.789 634.53 290.789C648.35 298.069 656.17 308.489 639.06 311.779H639.05Z" fill="url(#paint30_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.2" d="M498.57 317.849C496.38 316.179 495.01 304.189 494.54 299.259C494.54 299.289 496.07 306.119 500.97 306.379C505.86 306.639 508.25 296.809 508.26 296.749C508.66 302.329 508.68 307.709 508.85 310.859C509.25 318.239 501.34 319.949 498.57 317.839V317.849Z" fill="url(#paint31_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.2" d="M543.37 364.629C538.89 366.999 524.66 371.219 514.38 361.599C504.1 351.979 502.52 342.759 510.16 336.959C508 342.189 512.73 348.549 522.22 349.999C531.71 351.449 533.88 353.819 533.88 353.819C533.88 353.819 533.93 353.849 534.01 353.899C535.38 354.739 547.6 362.389 543.37 364.629Z" fill="url(#paint32_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.2" d="M506.61 358.829C518.5 373.669 528.5 375.579 546.88 369.579C563.52 364.139 574.38 370.909 576.29 372.239C561.51 371.349 536.79 380.899 527.96 379.579C518.67 378.199 509.77 369.699 498.7 355.069C487.63 340.439 485.06 327.589 482.89 310.199C482.32 305.609 482.26 301.229 482.53 297.199C482.53 297.569 482.71 300.869 486.84 316.599C491.28 333.469 502.84 339.329 502.84 339.329C499.7 344.349 500.22 350.859 506.61 358.829Z" fill="url(#paint33_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" opacity="0.2" d="M542.58 310.989C542.58 299.919 538.15 295.099 527.49 286.899C529.77 288.569 537.99 293.939 551.87 296.159C568.04 298.749 574.94 286.929 575.23 286.419C573.24 290.469 569.79 294.029 565.24 297.939C555.75 306.109 546.79 320.839 558.65 331.129C560.2 332.469 561.93 333.739 563.8 334.929L563.78 335.009L562.6 334.699C562.6 334.699 542.57 324.619 542.57 310.989H542.58Z" fill="url(#paint34_linear_445_2646)"/>
<path style="mix-blend-mode:screen" opacity="0.7" d="M662.11 322.059C652.03 320.489 644.71 320.869 634.7 328.779C624.68 336.689 599.3 346.569 582.8 335.369C566.3 324.169 576.05 316.159 584.36 321.549C592.66 326.939 598.61 337.379 620.95 327.889C637.72 320.759 645.64 316.089 658.16 318.679C670.68 321.269 670.55 323.379 662.11 322.059Z" fill="url(#paint35_linear_445_2646)"/>
<path style="mix-blend-mode:screen" opacity="0.7" d="M519.38 301.86C524.42 303.13 530.82 304.86 532.93 310.56C535.04 316.26 538.09 299.8 530.13 293.92C522.17 288.03 516.33 291.95 515.74 295.23C515.15 298.51 516.11 301.03 519.38 301.86Z" fill="url(#paint36_linear_445_2646)"/>
<path style="mix-blend-mode:screen" opacity="0.7" d="M478.52 278.38C475.55 294.31 473.4 320.08 481.9 337.38C490.4 354.68 496.68 371.88 482.37 355.07C468.06 338.27 466.68 316.52 470.04 295.47C473.4 274.42 479.92 270.89 478.52 278.37V278.38Z" fill="url(#paint37_linear_445_2646)"/>
<path style="mix-blend-mode:screen" opacity="0.7" d="M642.74 264.079C624.55 254.989 599.12 246.419 557.02 244.679C527.29 243.449 518.47 239.239 532.81 236.869C547.14 234.499 600.97 235.219 642.75 264.079H642.74Z" fill="url(#paint38_linear_445_2646)"/>
<path style="mix-blend-mode:screen" opacity="0.7" d="M569.6 349.999C569.6 349.999 546.14 339.029 531.12 338.459C516.1 337.879 525.99 331.109 539.19 332.919C552.27 334.719 566.01 344.819 569.6 350.009V349.999Z" fill="url(#paint39_linear_445_2646)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.35" d="M630.22 304.139C630.22 304.139 622.05 304.189 614.93 307.529C607.81 310.869 599.64 311.209 598.33 305.829C597.02 300.449 612.56 296.479 630.22 304.129V304.139Z" fill="url(#paint40_linear_445_2646)"/>
<path style="mix-blend-mode:screen" opacity="0.7" d="M529.33 256.169C529.33 256.169 543.29 258.119 546.59 267.899C548.91 274.789 542.85 280.129 539.69 277.369C536.53 274.599 538.87 264.209 529.33 256.169Z" fill="url(#paint41_linear_445_2646)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M603.96 309.969C603.96 309.969 599.66 310.899 597.63 308.029C595.6 305.159 600.32 302.189 600.32 302.189C600.32 302.189 599.65 306.669 603.95 309.969H603.96Z" fill="url(#paint42_linear_445_2646)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M539.41 259.739C539.41 259.739 547.1 263.849 547.62 270.769C548.3 279.809 543.67 279.569 542.58 273.439C541.49 267.309 541.18 262.749 539.41 259.739Z" fill="url(#paint43_linear_445_2646)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M560.394 239.448C560.449 238.394 555.396 237.275 549.109 236.948C542.821 236.62 537.68 237.209 537.625 238.263C537.57 239.316 542.622 240.435 548.91 240.763C555.198 241.09 560.339 240.501 560.394 239.448Z" fill="url(#paint44_linear_445_2646)"/>
<path d="M525.45 220.819C526.09 220.819 526.09 219.829 525.45 219.829C524.81 219.829 524.81 220.819 525.45 220.819Z" fill="url(#paint45_linear_445_2646)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M522.925 295.827C523.296 293.627 521.898 291.557 519.801 291.203C517.705 290.849 515.704 292.346 515.333 294.546C514.961 296.746 516.36 298.816 518.456 299.17C520.553 299.524 522.554 298.027 522.925 295.827Z" fill="url(#paint46_linear_445_2646)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M666.52 322.989C666.52 322.989 654.29 320.039 645.42 322.669C645.42 322.669 652.62 317.319 662.37 318.969C672.12 320.619 670.74 324.309 666.52 322.989Z" fill="url(#paint47_linear_445_2646)"/>
<path opacity="0.7" d="M417.11 327.129C407.18 351.559 430.76 340.409 430.76 340.409C430.76 340.409 423.66 318.479 427.56 310.729C431.46 302.979 443.46 290.619 462.05 281.509C462.05 281.509 475.98 258.099 481.46 253.159C481.46 253.159 438.93 273.449 417.11 327.139V327.129Z" fill="url(#paint48_linear_445_2646)"/>
<path opacity="0.7" d="M419.7 390.739C419.7 390.739 412.88 373.799 418.52 364.389C424.17 354.979 452.95 349.329 457.65 350.539C462.36 351.749 465.34 361.939 465.34 361.939C465.34 361.939 454.01 356.859 445.73 359.439C435.93 362.499 423.93 367.199 419.69 390.729L419.7 390.739Z" fill="url(#paint49_linear_445_2646)"/>
<path opacity="0.7" d="M444.93 391.239C444.93 391.239 432.48 372.409 446.81 368.059C461.15 363.709 482.36 391.239 482.36 391.239C482.36 391.239 465.66 379.059 456.04 376.949C446.42 374.829 444.3 386.159 444.92 391.239H444.93Z" fill="url(#paint50_linear_445_2646)"/>
<path opacity="0.7" d="M433.58 323.089C433.58 323.089 433.91 317.169 436.52 312.499C439.13 307.829 453.81 295.909 453.81 295.909C453.81 295.909 444.28 314.729 433.57 323.089H433.58Z" fill="url(#paint51_linear_445_2646)"/>
<path d="M339.05 229.619C288.58 192.679 245.87 237.859 245.87 237.859C245.87 237.859 203.16 192.679 152.69 229.619C104.43 264.939 108.93 396.679 245.87 451.739C382.81 396.679 387.31 264.939 339.05 229.619Z" fill="url(#paint52_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" d="M245.88 451.739C382.82 396.679 387.32 264.939 339.06 229.619C322.45 217.459 306.68 214.199 293 215.289C299.32 214.879 325.8 215.329 330.77 250.499C336.42 290.499 294.53 380.379 224.18 389.089C163.55 396.589 137.85 354.759 136.94 353.239C153.41 391.059 187.67 428.329 245.89 451.739H245.88Z" fill="url(#paint53_linear_445_2646)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M174.05 225.22C174.05 225.22 142.17 234.14 132.76 275.45C123.35 316.76 146.29 358.2 146.29 358.2C146.29 358.2 141.58 308.76 174.05 225.22Z" fill="url(#paint54_linear_445_2646)"/>
<path opacity="0.7" d="M224.87 436.919C224.87 436.919 279.69 434.289 329.15 372.859C329.15 372.859 305.34 414.969 248.87 447.499C248.87 447.499 234.22 444.499 224.87 436.909V436.919Z" fill="url(#paint55_linear_445_2646)"/>
<path style="mix-blend-mode:multiply" d="M230.52 239.45C246.52 262.98 268.87 270.74 268.87 270.74C268.87 270.74 282.42 213.96 259.85 226.85C251.02 232.41 245.88 237.85 245.88 237.85C245.88 237.85 222.01 212.6 188.87 215.3C189.27 215.31 214.65 216.1 230.53 239.44L230.52 239.45Z" fill="url(#paint56_linear_445_2646)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M174.05 477.36C174.05 477.36 294.05 436.91 337.81 364.17C381.57 291.43 354.75 230.81 318.05 200.04C281.34 169.28 291.81 129.07 304.4 107.75C321.11 79.4797 309.11 55.7197 309.11 55.7197C309.11 55.7197 322.29 62.7297 319.46 95.2997C316.64 127.87 328.87 148.91 357.81 157.96C357.81 157.96 326.87 171.98 347.34 200.15C367.81 228.31 401.69 308.49 374.05 353.76C343.18 404.31 355.7 426.23 379.7 440.71C379.7 440.71 373.11 420.58 377.35 408.14C377.35 408.14 423.82 436.64 398.47 477.36C398.47 477.36 380.18 452.59 342.76 446.82C305.35 441.05 292.29 434.94 292.29 434.94C292.29 434.94 301.47 450.89 332.53 460.05C332.53 460.05 308.18 480.41 263.35 468.53C218.53 456.65 203 477.35 203 477.35H174.06L174.05 477.36Z" fill="url(#paint57_linear_445_2646)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M422.22 477.36C422.22 477.36 439.87 444.79 410.96 401.81C382.05 358.83 406.35 331.95 408.32 285.45C410.29 238.95 392.88 211.8 392.88 211.8C392.88 211.8 410.97 276.43 374.53 333.92C342.93 383.77 319 426.68 379.24 456.09C379.24 456.09 382.89 443.47 398.48 447.83C417.24 453.09 422.24 477.35 422.24 477.35L422.22 477.36Z" fill="url(#paint58_linear_445_2646)"/>
<path d="M278.05 68.6201C278.05 68.6201 287.36 51.3301 269.11 38.5801C259.05 31.5601 237.53 34.7001 237.53 34.7001C237.53 34.7001 260.73 61.1001 278.05 68.6101V68.6201Z" fill="url(#paint59_linear_445_2646)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M233.34 188.96C233.34 188.96 271.81 205.59 301.1 188.96C301.1 188.96 304.27 172.64 280.26 154.01C256.25 135.38 265.93 111.9 292.46 91.4102C292.46 91.4102 278.49 124.47 294.28 151.18C312.87 182.62 314.04 218.36 314.04 218.36C314.04 218.36 260.86 213.38 233.33 188.95L233.34 188.96Z" fill="url(#paint60_linear_445_2646)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M303.93 52.5596C303.93 52.5596 313.11 66.3596 302.52 80.8296C291.93 95.3096 262.96 117.65 268.15 138.82C273.34 160 299.93 155.7 304.87 189.63C304.87 189.63 300.4 157.51 287.58 143.49C274.76 129.47 310.51 95.3796 314.05 82.1896C314.05 82.1896 315.7 60.9296 303.93 52.5596Z" fill="url(#paint61_linear_445_2646)"/>
<path d="M348.26 126.75C348.26 126.75 339.7 121.14 348.26 108.43C348.26 108.43 357.98 115.53 348.26 126.75Z" fill="url(#paint62_linear_445_2646)"/>
<path d="M151.06 185.44C151.06 185.44 111.26 200.87 105.62 177.5C105.62 177.5 106.5 174.1 116.8 171.68C116.8 171.68 129.07 185.02 151.06 185.44Z" fill="url(#paint63_linear_445_2646)"/>
<path d="M98.76 299.119C98.76 299.119 85.11 290.749 87.47 270.169C89.83 249.589 83 238.279 83 238.279C83 238.279 95.7 250.269 100.17 270.629C100.17 270.629 92.41 281.489 98.76 299.129V299.119Z" fill="url(#paint64_linear_445_2646)"/>
<path d="M155.47 67.5294C155.47 67.5294 148.46 61.0194 152.13 51.1494C152.13 51.1494 161.67 57.7294 163.87 69.4894L155.47 67.5294Z" fill="url(#paint65_linear_445_2646)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M392.95 388.359C392.95 388.359 425.51 399.259 436.66 411.709L430.46 411.639C430.46 411.639 437.73 430.009 427.58 455.809C427.58 455.809 417.43 419.289 392.95 388.359Z" fill="url(#paint66_linear_445_2646)"/>
<path style="mix-blend-mode:color-dodge" opacity="0.7" d="M535.311 477.59C535.311 477.59 580.641 446.49 633.931 431.56L597.131 461.08L616.541 459.04L622.221 450.9C622.221 450.9 686.181 432.58 720.411 426.13C720.411 426.13 665.481 443.43 614.241 471.26C614.241 471.26 580.951 464.47 562.621 477.37L535.321 477.6L535.311 477.59Z" fill="url(#paint67_linear_445_2646)"/>
<path d="M622.21 129.24L661.58 38.0801C661.58 38.0801 660.64 71.5601 657.58 80.6101L622.21 129.24Z" fill="url(#paint68_linear_445_2646)"/>
<path d="M534.76 151.64L509.7 131.28L517.11 126.75L534.76 151.64Z" fill="url(#paint69_linear_445_2646)"/>
<path d="M637.11 167.02L646.76 153.45C646.76 153.45 659.7 147.57 668.88 147.34L637.12 167.02H637.11Z" fill="url(#paint70_linear_445_2646)"/>
<path d="M742.28 333.06C742.28 333.06 744.63 301 749.81 284.22L764.16 275.13L742.28 333.06Z" fill="url(#paint71_linear_445_2646)"/>
<path d="M392.871 100.229L380.521 74.9494C380.521 74.9494 383.521 72.0694 388.641 69.8594L392.88 100.229H392.871Z" fill="url(#paint72_linear_445_2646)"/>
<path d="M754.28 411.159C754.28 411.159 768.62 412.309 774.04 407.299V398.189L754.28 411.159Z" fill="url(#paint73_linear_445_2646)"/>
<defs>
<linearGradient id="paint0_linear_445_2646" x1="419.441" y1="155.66" x2="713.131" y2="606.55" gradientUnits="userSpaceOnUse">
<stop stop-color="#95EBF5"/>
<stop offset="0.63" stop-color="#69ECF2"/>
<stop offset="1" stop-color="#55EDF2"/>
</linearGradient>
<linearGradient id="paint1_linear_445_2646" x1="581.311" y1="62.0498" x2="551.791" y2="498.73" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint2_linear_445_2646" x1="343.76" y1="303.52" x2="480.59" y2="381.31" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.11" stop-color="#FF8F01" stop-opacity="0.92"/>
<stop offset="0.34" stop-color="#FF8203" stop-opacity="0.71"/>
<stop offset="0.66" stop-color="#FF6C08" stop-opacity="0.38"/>
<stop offset="1" stop-color="#FF540D" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint3_linear_445_2646" x1="592.711" y1="116.26" x2="369.3" y2="540.54" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint4_linear_445_2646" x1="543.32" y1="302.95" x2="702.78" y2="503.57" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint5_linear_445_2646" x1="197.45" y1="145.38" x2="393.6" y2="578.53" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.36" stop-color="#FF8004"/>
<stop offset="1" stop-color="#FF540D"/>
</linearGradient>
<linearGradient id="paint6_linear_445_2646" x1="240.05" y1="178.57" x2="316.88" y2="502.91" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint7_linear_445_2646" x1="126.2" y1="273" x2="299.64" y2="660.16" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint8_linear_445_2646" x1="120.98" y1="264.86" x2="323.44" y2="539.66" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint9_linear_445_2646" x1="496.29" y1="270.06" x2="614.88" y2="447.94" gradientUnits="userSpaceOnUse">
<stop stop-color="#55EDF2"/>
<stop offset="0.65" stop-color="#3FBDE6"/>
<stop offset="1" stop-color="#36A9E1"/>
</linearGradient>
<linearGradient id="paint10_linear_445_2646" x1="732.86" y1="374.27" x2="512.26" y2="336.31" gradientUnits="userSpaceOnUse">
<stop stop-color="#1E1F5E"/>
<stop offset="0.18" stop-color="#1F2061" stop-opacity="0.95"/>
<stop offset="0.4" stop-color="#22246D" stop-opacity="0.79"/>
<stop offset="0.64" stop-color="#282A7F" stop-opacity="0.54"/>
<stop offset="0.89" stop-color="#303399" stop-opacity="0.18"/>
<stop offset="1" stop-color="#3538A7" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint11_linear_445_2646" x1="490.32" y1="440.499" x2="555.56" y2="267.339" gradientUnits="userSpaceOnUse">
<stop stop-color="#1E1F5E"/>
<stop offset="0.18" stop-color="#1F2061" stop-opacity="0.95"/>
<stop offset="0.4" stop-color="#22246D" stop-opacity="0.79"/>
<stop offset="0.64" stop-color="#282A7F" stop-opacity="0.54"/>
<stop offset="0.89" stop-color="#303399" stop-opacity="0.18"/>
<stop offset="1" stop-color="#3538A7" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint12_linear_445_2646" x1="639.85" y1="453.509" x2="405.97" y2="221.509" gradientUnits="userSpaceOnUse">
<stop stop-color="#1E1F5E"/>
<stop offset="0.18" stop-color="#1F2061" stop-opacity="0.95"/>
<stop offset="0.4" stop-color="#22246D" stop-opacity="0.79"/>
<stop offset="0.64" stop-color="#282A7F" stop-opacity="0.54"/>
<stop offset="0.89" stop-color="#303399" stop-opacity="0.18"/>
<stop offset="1" stop-color="#3538A7" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint13_linear_445_2646" x1="504.469" y1="320.879" x2="488.129" y2="306.059" gradientUnits="userSpaceOnUse">
<stop stop-color="#1E1F5E"/>
<stop offset="0.18" stop-color="#1F2061" stop-opacity="0.95"/>
<stop offset="0.4" stop-color="#22246D" stop-opacity="0.79"/>
<stop offset="0.64" stop-color="#282A7F" stop-opacity="0.54"/>
<stop offset="0.89" stop-color="#303399" stop-opacity="0.18"/>
<stop offset="1" stop-color="#3538A7" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint14_linear_445_2646" x1="662.8" y1="390.3" x2="462.36" y2="195.19" gradientUnits="userSpaceOnUse">
<stop stop-color="#1E1F5E"/>
<stop offset="0.18" stop-color="#1F2061" stop-opacity="0.95"/>
<stop offset="0.4" stop-color="#22246D" stop-opacity="0.79"/>
<stop offset="0.64" stop-color="#282A7F" stop-opacity="0.54"/>
<stop offset="0.89" stop-color="#303399" stop-opacity="0.18"/>
<stop offset="1" stop-color="#3538A7" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint15_linear_445_2646" x1="575.78" y1="333.979" x2="538.27" y2="297.469" gradientUnits="userSpaceOnUse">
<stop stop-color="#1E1F5E"/>
<stop offset="0.18" stop-color="#1F2061" stop-opacity="0.95"/>
<stop offset="0.4" stop-color="#22246D" stop-opacity="0.79"/>
<stop offset="0.64" stop-color="#282A7F" stop-opacity="0.54"/>
<stop offset="0.89" stop-color="#303399" stop-opacity="0.18"/>
<stop offset="1" stop-color="#3538A7" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint16_linear_445_2646" x1="615.71" y1="313.939" x2="483.71" y2="226.409" gradientUnits="userSpaceOnUse">
<stop stop-color="#1E1F5E"/>
<stop offset="0.18" stop-color="#1F2061" stop-opacity="0.95"/>
<stop offset="0.4" stop-color="#22246D" stop-opacity="0.79"/>
<stop offset="0.64" stop-color="#282A7F" stop-opacity="0.54"/>
<stop offset="0.89" stop-color="#303399" stop-opacity="0.18"/>
<stop offset="1" stop-color="#3538A7" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint17_linear_445_2646" x1="558.36" y1="284.109" x2="520.14" y2="279.629" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint18_linear_445_2646" x1="690.8" y1="314.399" x2="551.62" y2="298.089" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint19_linear_445_2646" x1="585.66" y1="386.739" x2="369.27" y2="361.369" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint20_linear_445_2646" x1="477.05" y1="323.38" x2="416.42" y2="316.27" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint21_linear_445_2646" x1="618.38" y1="360.069" x2="472.47" y2="342.959" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint22_linear_445_2646" x1="751.53" y1="298.699" x2="505.61" y2="269.869" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint23_linear_445_2646" x1="660.289" y1="417.51" x2="500.879" y2="398.82" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint24_linear_445_2646" x1="575.85" y1="395.94" x2="387.699" y2="373.879" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint25_linear_445_2646" x1="607.08" y1="296.309" x2="462.559" y2="279.369" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint26_linear_445_2646" x1="714.07" y1="345.589" x2="607.35" y2="333.079" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint27_linear_445_2646" x1="587.379" y1="278.689" x2="570.759" y2="276.739" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint28_linear_445_2646" x1="471.03" y1="299.399" x2="440.14" y2="295.779" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint29_linear_445_2646" x1="658.53" y1="306.9" x2="639.33" y2="304.65" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint30_linear_445_2646" x1="618.59" y1="347.259" x2="618.86" y2="226.549" gradientUnits="userSpaceOnUse">
<stop stop-color="#1E1F5E"/>
<stop offset="0.18" stop-color="#1F2061" stop-opacity="0.95"/>
<stop offset="0.4" stop-color="#22246D" stop-opacity="0.79"/>
<stop offset="0.64" stop-color="#282A7F" stop-opacity="0.54"/>
<stop offset="0.89" stop-color="#303399" stop-opacity="0.18"/>
<stop offset="1" stop-color="#3538A7" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint31_linear_445_2646" x1="501.6" y1="350.989" x2="501.86" y2="235.079" gradientUnits="userSpaceOnUse">
<stop stop-color="#1E1F5E"/>
<stop offset="0.18" stop-color="#1F2061" stop-opacity="0.95"/>
<stop offset="0.4" stop-color="#22246D" stop-opacity="0.79"/>
<stop offset="0.64" stop-color="#282A7F" stop-opacity="0.54"/>
<stop offset="0.89" stop-color="#303399" stop-opacity="0.18"/>
<stop offset="1" stop-color="#3538A7" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint32_linear_445_2646" x1="524.45" y1="525.179" x2="525.52" y2="34.659" gradientUnits="userSpaceOnUse">
<stop stop-color="#1E1F5E"/>
<stop offset="0.18" stop-color="#1F2061" stop-opacity="0.95"/>
<stop offset="0.4" stop-color="#22246D" stop-opacity="0.79"/>
<stop offset="0.64" stop-color="#282A7F" stop-opacity="0.54"/>
<stop offset="0.89" stop-color="#303399" stop-opacity="0.18"/>
<stop offset="1" stop-color="#3538A7" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint33_linear_445_2646" x1="528.92" y1="525.189" x2="529.99" y2="34.6692" gradientUnits="userSpaceOnUse">
<stop stop-color="#1E1F5E"/>
<stop offset="0.18" stop-color="#1F2061" stop-opacity="0.95"/>
<stop offset="0.4" stop-color="#22246D" stop-opacity="0.79"/>
<stop offset="0.64" stop-color="#282A7F" stop-opacity="0.54"/>
<stop offset="0.89" stop-color="#303399" stop-opacity="0.18"/>
<stop offset="1" stop-color="#3538A7" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint34_linear_445_2646" x1="551.26" y1="334.999" x2="551.37" y2="286.379" gradientUnits="userSpaceOnUse">
<stop stop-color="#1E1F5E"/>
<stop offset="0.18" stop-color="#1F2061" stop-opacity="0.95"/>
<stop offset="0.4" stop-color="#22246D" stop-opacity="0.79"/>
<stop offset="0.64" stop-color="#282A7F" stop-opacity="0.54"/>
<stop offset="0.89" stop-color="#303399" stop-opacity="0.18"/>
<stop offset="1" stop-color="#3538A7" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint35_linear_445_2646" x1="748.65" y1="419.369" x2="557.589" y2="278.669" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.11" stop-color="#FF9A0D" stop-opacity="0.92"/>
<stop offset="0.34" stop-color="#FFA931" stop-opacity="0.71"/>
<stop offset="0.66" stop-color="#FFC16A" stop-opacity="0.38"/>
<stop offset="1" stop-color="#FFDCAB" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint36_linear_445_2646" x1="492.303" y1="253.158" x2="541.136" y2="321.528" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.11" stop-color="#FF9A0D" stop-opacity="0.92"/>
<stop offset="0.34" stop-color="#FFA931" stop-opacity="0.71"/>
<stop offset="0.66" stop-color="#FFC16A" stop-opacity="0.38"/>
<stop offset="1" stop-color="#FFDCAB" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint37_linear_445_2646" x1="625.41" y1="353.2" x2="414.7" y2="301.32" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.11" stop-color="#FF9A0D" stop-opacity="0.92"/>
<stop offset="0.34" stop-color="#FFA931" stop-opacity="0.71"/>
<stop offset="0.66" stop-color="#FFC16A" stop-opacity="0.38"/>
<stop offset="1" stop-color="#FFDCAB" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint38_linear_445_2646" x1="692.43" y1="325.039" x2="514.9" y2="205.039" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.11" stop-color="#FF9A0D" stop-opacity="0.92"/>
<stop offset="0.34" stop-color="#FFA931" stop-opacity="0.71"/>
<stop offset="0.66" stop-color="#FFC16A" stop-opacity="0.38"/>
<stop offset="1" stop-color="#FFDCAB" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint39_linear_445_2646" x1="626.78" y1="416.869" x2="492.31" y2="292.989" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.11" stop-color="#FF9A0D" stop-opacity="0.92"/>
<stop offset="0.34" stop-color="#FFA931" stop-opacity="0.71"/>
<stop offset="0.66" stop-color="#FFC16A" stop-opacity="0.38"/>
<stop offset="1" stop-color="#FFDCAB" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint40_linear_445_2646" x1="633.56" y1="290.529" x2="583.36" y2="325.199" gradientUnits="userSpaceOnUse">
<stop stop-color="#FC9D91" stop-opacity="0"/>
<stop offset="0.18" stop-color="#FC9D91" stop-opacity="0.22"/>
<stop offset="0.48" stop-color="#FC9D91" stop-opacity="0.55"/>
<stop offset="0.73" stop-color="#FC9D91" stop-opacity="0.79"/>
<stop offset="0.91" stop-color="#FC9D91" stop-opacity="0.94"/>
<stop offset="1" stop-color="#FC9D91"/>
</linearGradient>
<linearGradient id="paint41_linear_445_2646" x1="596.24" y1="323.369" x2="523.54" y2="252.539" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.11" stop-color="#FF9A0D" stop-opacity="0.92"/>
<stop offset="0.34" stop-color="#FFA931" stop-opacity="0.71"/>
<stop offset="0.66" stop-color="#FFC16A" stop-opacity="0.38"/>
<stop offset="1" stop-color="#FFDCAB" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint42_linear_445_2646" x1="605.09" y1="302.859" x2="597.05" y2="311.159" gradientUnits="userSpaceOnUse">
<stop stop-color="#FC9D91" stop-opacity="0"/>
<stop offset="0.18" stop-color="#FC9D91" stop-opacity="0.22"/>
<stop offset="0.48" stop-color="#FC9D91" stop-opacity="0.55"/>
<stop offset="0.73" stop-color="#FC9D91" stop-opacity="0.79"/>
<stop offset="0.91" stop-color="#FC9D91" stop-opacity="0.94"/>
<stop offset="1" stop-color="#FC9D91"/>
</linearGradient>
<linearGradient id="paint43_linear_445_2646" x1="549.22" y1="262.069" x2="536.36" y2="275.349" gradientUnits="userSpaceOnUse">
<stop stop-color="#FC9D91" stop-opacity="0"/>
<stop offset="0.18" stop-color="#FC9D91" stop-opacity="0.22"/>
<stop offset="0.48" stop-color="#FC9D91" stop-opacity="0.55"/>
<stop offset="0.73" stop-color="#FC9D91" stop-opacity="0.79"/>
<stop offset="0.91" stop-color="#FC9D91" stop-opacity="0.94"/>
<stop offset="1" stop-color="#FC9D91"/>
</linearGradient>
<linearGradient id="paint44_linear_445_2646" x1="546.907" y1="243.662" x2="550.816" y2="234.734" gradientUnits="userSpaceOnUse">
<stop stop-color="#FC9D91" stop-opacity="0"/>
<stop offset="0.18" stop-color="#FC9D91" stop-opacity="0.22"/>
<stop offset="0.48" stop-color="#FC9D91" stop-opacity="0.55"/>
<stop offset="0.73" stop-color="#FC9D91" stop-opacity="0.79"/>
<stop offset="0.91" stop-color="#FC9D91" stop-opacity="0.94"/>
<stop offset="1" stop-color="#FC9D91"/>
</linearGradient>
<linearGradient id="paint45_linear_445_2646" x1="525.63" y1="220.799" x2="525.28" y2="219.859" gradientUnits="userSpaceOnUse">
<stop stop-color="#FC9D91" stop-opacity="0"/>
<stop offset="0.18" stop-color="#FC9D91" stop-opacity="0.22"/>
<stop offset="0.48" stop-color="#FC9D91" stop-opacity="0.55"/>
<stop offset="0.73" stop-color="#FC9D91" stop-opacity="0.79"/>
<stop offset="0.91" stop-color="#FC9D91" stop-opacity="0.94"/>
<stop offset="1" stop-color="#FC9D91"/>
</linearGradient>
<linearGradient id="paint46_linear_445_2646" x1="516.92" y1="303.89" x2="520.307" y2="290.548" gradientUnits="userSpaceOnUse">
<stop stop-color="#FC9D91" stop-opacity="0"/>
<stop offset="0.18" stop-color="#FC9D91" stop-opacity="0.22"/>
<stop offset="0.48" stop-color="#FC9D91" stop-opacity="0.55"/>
<stop offset="0.73" stop-color="#FC9D91" stop-opacity="0.79"/>
<stop offset="0.91" stop-color="#FC9D91" stop-opacity="0.94"/>
<stop offset="1" stop-color="#FC9D91"/>
</linearGradient>
<linearGradient id="paint47_linear_445_2646" x1="658.17" y1="315.369" x2="657.45" y2="324.129" gradientUnits="userSpaceOnUse">
<stop stop-color="#FC9D91" stop-opacity="0"/>
<stop offset="0.18" stop-color="#FC9D91" stop-opacity="0.22"/>
<stop offset="0.48" stop-color="#FC9D91" stop-opacity="0.55"/>
<stop offset="0.73" stop-color="#FC9D91" stop-opacity="0.79"/>
<stop offset="0.91" stop-color="#FC9D91" stop-opacity="0.94"/>
<stop offset="1" stop-color="#FC9D91"/>
</linearGradient>
<linearGradient id="paint48_linear_445_2646" x1="403.42" y1="327.739" x2="508.24" y2="265.979" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFEC65"/>
<stop offset="0.34" stop-color="#FFCB3E" stop-opacity="0.62"/>
<stop offset="0.66" stop-color="#FFAE1D" stop-opacity="0.29"/>
<stop offset="0.89" stop-color="#FF9B08" stop-opacity="0.08"/>
<stop offset="1" stop-color="#FF9500" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint49_linear_445_2646" x1="416.9" y1="357.289" x2="476.9" y2="393.769" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFEC65"/>
<stop offset="0.34" stop-color="#FFCB3E" stop-opacity="0.62"/>
<stop offset="0.66" stop-color="#FFAE1D" stop-opacity="0.29"/>
<stop offset="0.89" stop-color="#FF9B08" stop-opacity="0.08"/>
<stop offset="1" stop-color="#FF9500" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint50_linear_445_2646" x1="436.98" y1="373.789" x2="491.8" y2="398.959" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFEC65"/>
<stop offset="0.34" stop-color="#FFCB3E" stop-opacity="0.62"/>
<stop offset="0.66" stop-color="#FFAE1D" stop-opacity="0.29"/>
<stop offset="0.89" stop-color="#FF9B08" stop-opacity="0.08"/>
<stop offset="1" stop-color="#FF9500" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint51_linear_445_2646" x1="424.85" y1="321.349" x2="474.26" y2="290.289" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFEC65"/>
<stop offset="0.34" stop-color="#FFCB3E" stop-opacity="0.62"/>
<stop offset="0.66" stop-color="#FFAE1D" stop-opacity="0.29"/>
<stop offset="0.89" stop-color="#FF9B08" stop-opacity="0.08"/>
<stop offset="1" stop-color="#FF9500" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint52_linear_445_2646" x1="182.03" y1="243.549" x2="337.68" y2="398.129" gradientUnits="userSpaceOnUse">
<stop stop-color="#EF4B9F"/>
<stop offset="1" stop-color="#E6332A"/>
</linearGradient>
<linearGradient id="paint53_linear_445_2646" x1="189.23" y1="257.919" x2="568.29" y2="595.689" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint54_linear_445_2646" x1="115.47" y1="204.39" x2="268.65" y2="503.69" gradientUnits="userSpaceOnUse">
<stop stop-color="#FCEA10"/>
<stop offset="0.33" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="0.66" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.89" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="1" stop-color="#FF9500" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint55_linear_445_2646" x1="267.7" y1="519.259" x2="276.52" y2="410.909" gradientUnits="userSpaceOnUse">
<stop stop-color="#36A9E1" stop-opacity="0"/>
<stop offset="0.09" stop-color="#36AAE1" stop-opacity="0.03"/>
<stop offset="0.22" stop-color="#39B0E2" stop-opacity="0.11"/>
<stop offset="0.38" stop-color="#3DB9E5" stop-opacity="0.24"/>
<stop offset="0.56" stop-color="#43C5E8" stop-opacity="0.42"/>
<stop offset="0.75" stop-color="#4AD5EC" stop-opacity="0.65"/>
<stop offset="0.95" stop-color="#52E8F0" stop-opacity="0.93"/>
<stop offset="1" stop-color="#55EDF2"/>
</linearGradient>
<linearGradient id="paint56_linear_445_2646" x1="310.1" y1="264.51" x2="-12.4798" y2="137.45" gradientUnits="userSpaceOnUse">
<stop stop-color="#0C5DA5" stop-opacity="0"/>
<stop offset="0.27" stop-color="#14529D" stop-opacity="0.24"/>
<stop offset="0.87" stop-color="#2A3989" stop-opacity="0.86"/>
<stop offset="1" stop-color="#2F3485"/>
</linearGradient>
<linearGradient id="paint57_linear_445_2646" x1="69.0498" y1="114.49" x2="573.63" y2="558.9" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint58_linear_445_2646" x1="329.37" y1="179.48" x2="412.26" y2="460.76" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint59_linear_445_2646" x1="245.55" y1="25.4301" x2="403.17" y2="228.86" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.36" stop-color="#FF8004"/>
<stop offset="1" stop-color="#FF540D"/>
</linearGradient>
<linearGradient id="paint60_linear_445_2646" x1="208.18" y1="66.0602" x2="370.05" y2="285.31" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint61_linear_445_2646" x1="271.38" y1="41.5896" x2="328.84" y2="226.64" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint62_linear_445_2646" x1="348.86" y1="85.7197" x2="348.11" y2="156.39" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.36" stop-color="#FF8004"/>
<stop offset="1" stop-color="#FF540D"/>
</linearGradient>
<linearGradient id="paint63_linear_445_2646" x1="108.596" y1="176.221" x2="168.364" y2="202.646" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.36" stop-color="#FF8004"/>
<stop offset="1" stop-color="#FF540D"/>
</linearGradient>
<linearGradient id="paint64_linear_445_2646" x1="82.3" y1="247.529" x2="113.63" y2="322.459" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.36" stop-color="#FF8004"/>
<stop offset="1" stop-color="#FF540D"/>
</linearGradient>
<linearGradient id="paint65_linear_445_2646" x1="152.4" y1="52.6194" x2="171.14" y2="94.1394" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500"/>
<stop offset="0.36" stop-color="#FF8004"/>
<stop offset="1" stop-color="#FF540D"/>
</linearGradient>
<linearGradient id="paint66_linear_445_2646" x1="404.42" y1="359.979" x2="432.59" y2="468.599" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint67_linear_445_2646" x1="773.471" y1="344.87" x2="532.99" y2="521.55" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF9500" stop-opacity="0"/>
<stop offset="0.11" stop-color="#FE9B01" stop-opacity="0.08"/>
<stop offset="0.34" stop-color="#FEAD04" stop-opacity="0.29"/>
<stop offset="0.67" stop-color="#FDCA0A" stop-opacity="0.63"/>
<stop offset="1" stop-color="#FCEA10"/>
</linearGradient>
<linearGradient id="paint68_linear_445_2646" x1="661.54" y1="55.8801" x2="600.9" y2="163.04" gradientUnits="userSpaceOnUse">
<stop stop-color="#95EBF5"/>
<stop offset="0.63" stop-color="#69ECF2"/>
<stop offset="1" stop-color="#55EDF2"/>
</linearGradient>
<linearGradient id="paint69_linear_445_2646" x1="506.33" y1="120.88" x2="545.8" y2="166.24" gradientUnits="userSpaceOnUse">
<stop stop-color="#95EBF5"/>
<stop offset="0.63" stop-color="#69ECF2"/>
<stop offset="1" stop-color="#55EDF2"/>
</linearGradient>
<linearGradient id="paint70_linear_445_2646" x1="680.38" y1="138.78" x2="628.21" y2="167.89" gradientUnits="userSpaceOnUse">
<stop stop-color="#95EBF5"/>
<stop offset="0.63" stop-color="#69ECF2"/>
<stop offset="1" stop-color="#55EDF2"/>
</linearGradient>
<linearGradient id="paint71_linear_445_2646" x1="763.9" y1="256.12" x2="736.2" y2="350.58" gradientUnits="userSpaceOnUse">
<stop stop-color="#95EBF5"/>
<stop offset="0.63" stop-color="#69ECF2"/>
<stop offset="1" stop-color="#55EDF2"/>
</linearGradient>
<linearGradient id="paint72_linear_445_2646" x1="393.95" y1="62.8494" x2="381.41" y2="105.609" gradientUnits="userSpaceOnUse">
<stop stop-color="#95EBF5"/>
<stop offset="0.63" stop-color="#69ECF2"/>
<stop offset="1" stop-color="#55EDF2"/>
</linearGradient>
<linearGradient id="paint73_linear_445_2646" x1="769.42" y1="391.299" x2="761.34" y2="418.859" gradientUnits="userSpaceOnUse">
<stop stop-color="#95EBF5"/>
<stop offset="0.63" stop-color="#69ECF2"/>
<stop offset="1" stop-color="#55EDF2"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 57 KiB

View File

@ -0,0 +1,11 @@
<svg width="440" height="440" viewBox="0 0 440 440" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M220 0C341.503 0 440 98.4974 440 220C440 341.503 341.503 440 220 440C98.4974 440 0 341.503 0 220C0 98.4974 98.4974 0 220 0Z" fill="url(#paint0_linear_433_2691)"/>
<path d="M220 0C341.503 0 440 98.4974 440 220C440 341.503 341.503 440 220 440C98.4974 440 0 341.503 0 220C0 98.4974 98.4974 0 220 0Z" stroke="#E5E7EB"/>
<path d="M153.34 231.164L211.573 285.49C213.99 287.744 217.181 289 220.5 289C223.819 289 227.01 287.744 229.427 285.49L287.66 231.164C297.457 222.05 303 209.266 303 195.901V194.034C303 171.524 286.726 152.331 264.521 148.628C249.826 146.18 234.873 150.978 224.367 161.477L220.5 165.341L216.633 161.477C206.127 150.978 191.174 146.18 176.479 148.628C154.274 152.331 138 171.524 138 194.034V195.901C138 209.266 143.543 222.05 153.34 231.164Z" fill="#EC4899"/>
<defs>
<linearGradient id="paint0_linear_433_2691" x1="0" y1="220" x2="440" y2="220" gradientUnits="userSpaceOnUse">
<stop stop-color="#FCE7F3"/>
<stop offset="1" stop-color="#F3E8FF"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 612 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 554 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 540 KiB

View File

@ -1,77 +0,0 @@
# Scripts Documentation
## Funnel Management Scripts
### 📥 `import-funnels-to-db.mjs`
Импортирует воронки из JSON файлов в `public/funnels/` в MongoDB.
```bash
npm run import:funnels
```
### 📤 `sync-funnels-from-db.mjs`
Синхронизирует опубликованные воронки из MongoDB обратно в проект:
1. Извлекает все последние версии опубликованных воронок из БД
2. Сохраняет их в JSON файлы в `public/funnels/`
3. Запекает их в TypeScript (`src/lib/funnel/bakedFunnels.ts`)
4. Сохраняет JSON файлы по умолчанию
#### Основное использование:
```bash
# Синхронизация всех воронок
npm run sync:funnels
# Просмотр справки
npm run sync:funnels -- --help
```
#### Опции:
**`--dry-run`** - Показать что будет синхронизировано без реальных изменений:
```bash
npm run sync:funnels -- --dry-run
```
**`--clean-files`** - Удалить JSON файлы после запекания (по умолчанию сохраняются):
```bash
npm run sync:funnels -- --clean-files
```
**`--funnel-ids <ids>`** - Синхронизировать только определенные воронки:
```bash
npm run sync:funnels -- --funnel-ids funnel-test,ru-career-accelerator
```
**Комбинирование опций:**
```bash
npm run sync:funnels -- --dry-run --funnel-ids funnel-test
npm run sync:funnels -- --clean-files --dry-run
```
### 🔥 `bake-funnels.mjs`
Конвертирует JSON файлы воронок в TypeScript константы.
```bash
npm run bake:funnels
```
## Workflow
### Разработка локально:
1. Создать/редактировать воронки в админке
2. Опубликовать их
3. Запустить `npm run sync:funnels` для обновления кода
### Деплой:
1. Запустить `npm run sync:funnels` перед билдом
2. Собрать проект с актуальными воронками
### Отладка:
1. `npm run sync:funnels -- --dry-run` - посмотреть что будет синхронизировано
2. `npm run sync:funnels -- --keep-files` - оставить JSON файлы для проверки
3. `npm run sync:funnels -- --funnel-ids specific-id` - синхронизировать только одну воронку

View File

@ -9,6 +9,27 @@ const projectRoot = path.resolve(__dirname, "..");
const funnelsDir = path.join(projectRoot, "public", "funnels");
const outputFile = path.join(projectRoot, "src", "lib", "funnel", "bakedFunnels.ts");
/**
* Нормализует данные воронки перед запеканием
* Удаляет поля которые не соответствуют типам TypeScript
*/
function normalizeFunnelData(funnelData) {
return {
...funnelData,
screens: funnelData.screens.map((screen) => {
const normalizedScreen = { ...screen };
// Удаляем variables из экранов, которые не поддерживают это поле
// variables поддерживается только в info экранах
if ('variables' in normalizedScreen && normalizedScreen.template !== 'info') {
delete normalizedScreen.variables;
}
return normalizedScreen;
}),
};
}
function formatFunnelRecord(funnels) {
const entries = Object.entries(funnels)
.map(([funnelId, definition]) => {
@ -59,7 +80,8 @@ async function bakeFunnels() {
);
}
funnels[funnelId] = parsed;
// Нормализуем данные перед запеканием
funnels[funnelId] = normalizeFunnelData(parsed);
}
const headerComment = `/**\n * This file is auto-generated by scripts/bake-funnels.mjs.\n * Do not edit this file manually; update the source JSON files instead.\n */`;

View File

@ -135,9 +135,30 @@ async function downloadImagesFromDatabase(funnels) {
for (const funnel of funnels) {
for (const screen of funnel.funnelData.screens) {
// Проверяем основной icon экрана (info экраны)
if (screen.icon?.type === 'image' && screen.icon.value?.startsWith('/api/images/')) {
imageUrls.add(screen.icon.value);
}
// Проверяем image экрана (email экраны)
if (screen.image?.src?.startsWith('/api/images/')) {
imageUrls.add(screen.image.src);
}
// Проверяем icon и image в вариантах экрана
if (screen.variants && Array.isArray(screen.variants)) {
for (const variant of screen.variants) {
// icon в вариантах (info экраны)
// В вариантах может не быть поля type, проверяем только value
if (variant.overrides?.icon?.value?.startsWith('/api/images/')) {
imageUrls.add(variant.overrides.icon.value);
}
// image в вариантах (email экраны)
if (variant.overrides?.image?.src?.startsWith('/api/images/')) {
imageUrls.add(variant.overrides.image.src);
}
}
}
}
}
@ -159,11 +180,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}`);
}
@ -182,12 +218,42 @@ async function downloadImagesFromDatabase(funnels) {
function updateImageUrlsInFunnels(funnels, imageMapping) {
for (const funnel of funnels) {
for (const screen of funnel.funnelData.screens) {
// Обновляем основной icon экрана (info экраны)
if (screen.icon?.type === 'image' && screen.icon.value && imageMapping[screen.icon.value]) {
const oldUrl = screen.icon.value;
const newUrl = imageMapping[oldUrl];
screen.icon.value = newUrl;
console.log(`🔗 Updated image URL: ${oldUrl}${newUrl}`);
}
// Обновляем image экрана (email экраны)
if (screen.image?.src && imageMapping[screen.image.src]) {
const oldUrl = screen.image.src;
const newUrl = imageMapping[oldUrl];
screen.image.src = newUrl;
console.log(`🔗 Updated image URL: ${oldUrl}${newUrl}`);
}
// Обновляем icon и image в вариантах экрана
if (screen.variants && Array.isArray(screen.variants)) {
for (const variant of screen.variants) {
// icon в вариантах (info экраны)
// В вариантах может не быть поля type, проверяем только value
if (variant.overrides?.icon?.value && imageMapping[variant.overrides.icon.value]) {
const oldUrl = variant.overrides.icon.value;
const newUrl = imageMapping[oldUrl];
variant.overrides.icon.value = newUrl;
console.log(`🔗 Updated variant image URL: ${oldUrl}${newUrl}`);
}
// image в вариантах (email экраны)
if (variant.overrides?.image?.src && imageMapping[variant.overrides.image.src]) {
const oldUrl = variant.overrides.image.src;
const newUrl = imageMapping[oldUrl];
variant.overrides.image.src = newUrl;
console.log(`🔗 Updated variant image URL: ${oldUrl}${newUrl}`);
}
}
}
}
}
}
@ -255,14 +321,82 @@ async function getLatestPublishedFunnels() {
}
}
/**
* Нормализует данные воронки перед сохранением
* Удаляет лишние поля которые не соответствуют типам TypeScript
*/
function normalizeFunnelData(funnelData) {
return {
...funnelData,
screens: funnelData.screens.map((screen) => {
const normalizedScreen = { ...screen };
// Удаляем поле 'show' из description (TypographyVariant не содержит его)
// Поле 'show' есть только у TitleDefinition и SubtitleDefinition
if (normalizedScreen.description && typeof normalizedScreen.description === 'object') {
if ('show' in normalizedScreen.description) {
delete normalizedScreen.description.show;
}
}
// Удаляем специфичные для других шаблонов поля
// Каждый шаблон должен содержать только свои поля
switch (normalizedScreen.template) {
case 'form':
// fields нужно только для form экранов
break;
case 'email':
// email имеет emailInput, а не fields
if ('fields' in normalizedScreen) delete normalizedScreen.fields;
if ('list' in normalizedScreen) delete normalizedScreen.list;
break;
case 'list':
// list нужно только для list экранов
if ('fields' in normalizedScreen) delete normalizedScreen.fields;
break;
case 'loaders':
// progressbars нужно только для loaders
if ('fields' in normalizedScreen) delete normalizedScreen.fields;
if ('list' in normalizedScreen) delete normalizedScreen.list;
break;
default:
// Для остальных шаблонов (info, date, coupon, soulmate) удаляем специфичные поля
if ('fields' in normalizedScreen) delete normalizedScreen.fields;
if ('list' in normalizedScreen) delete normalizedScreen.list;
if ('progressbars' in normalizedScreen) delete normalizedScreen.progressbars;
break;
}
// Нормализуем variants - добавляем пустой overrides если его нет
if ('variants' in normalizedScreen && Array.isArray(normalizedScreen.variants)) {
normalizedScreen.variants = normalizedScreen.variants.map((variant) => ({
conditions: variant.conditions || [],
overrides: variant.overrides || {},
}));
}
// Удаляем variables из экранов, которые не поддерживают это поле
// variables поддерживается только в info экранах
if ('variables' in normalizedScreen && normalizedScreen.template !== 'info') {
delete normalizedScreen.variables;
}
return normalizedScreen;
}),
};
}
async function saveFunnelToFile(funnel) {
const funnelId = funnel.funnelData.meta.id;
const fileName = `${funnelId}.json`;
const filePath = path.join(funnelsDir, fileName);
try {
// Нормализуем данные перед сохранением (удаляем лишние поля)
const normalizedData = normalizeFunnelData(funnel.funnelData);
// Сохраняем только funnelData (структуру воронки)
const funnelContent = JSON.stringify(funnel.funnelData, null, 2);
const funnelContent = JSON.stringify(normalizedData, null, 2);
await fs.writeFile(filePath, funnelContent, 'utf8');
console.log(`💾 Saved ${fileName} (v${funnel.version})`);
} catch (error) {

View File

@ -0,0 +1,77 @@
import type { ReactNode } from "react";
import { notFound } from "next/navigation";
import { PixelsProvider } from "@/components/providers/PixelsProvider";
import type { FunnelDefinition } from "@/lib/funnel/types";
import { BAKED_FUNNELS } from "@/lib/funnel/bakedFunnels";
import { IS_FULL_SYSTEM_BUILD } from "@/lib/runtime/buildVariant";
// Функция для загрузки воронки из базы данных
async function loadFunnelFromDatabase(
funnelId: string
): Promise<FunnelDefinition | null> {
if (!IS_FULL_SYSTEM_BUILD) {
return null;
}
try {
const { default: connectMongoDB } = await import("@/lib/mongodb");
const { default: FunnelModel } = await import("@/lib/models/Funnel");
await connectMongoDB();
const funnel = await FunnelModel.findOne({
"funnelData.meta.id": funnelId,
status: { $in: ["published", "draft"] },
}).lean();
if (funnel) {
return funnel.funnelData as FunnelDefinition;
}
return null;
} catch (error) {
console.error(
`Failed to load funnel '${funnelId}' from database:`,
error
);
return null;
}
}
interface FunnelLayoutProps {
children: ReactNode;
params: Promise<{
funnelId: string;
}>;
}
export default async function FunnelLayout({
children,
params,
}: FunnelLayoutProps) {
const { funnelId } = await params;
let funnel: FunnelDefinition | null = null;
// Сначала пытаемся загрузить из базы данных
funnel = await loadFunnelFromDatabase(funnelId);
// Если не найдено в базе, пытаемся загрузить из JSON файлов
if (!funnel) {
funnel = BAKED_FUNNELS[funnelId] || null;
}
// Если воронка не найдена ни в базе, ни в файлах
if (!funnel) {
notFound();
}
return (
<PixelsProvider
googleAnalyticsId={funnel.meta.googleAnalyticsId}
yandexMetrikaId={funnel.meta.yandexMetrikaId}
>
{children}
</PixelsProvider>
);
}

View File

@ -39,10 +39,12 @@ interface FunnelRootPageProps {
params: Promise<{
funnelId: string;
}>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}
export default async function FunnelRootPage({ params }: FunnelRootPageProps) {
export default async function FunnelRootPage({ params, searchParams }: FunnelRootPageProps) {
const { funnelId } = await params;
const queryParams = await searchParams;
let funnel: FunnelDefinition | null = null;
@ -66,5 +68,19 @@ export default async function FunnelRootPage({ params }: FunnelRootPageProps) {
redirect("/");
}
redirect(`/${funnel.meta.id}/${firstScreenId}`);
// Сохраняем query параметры при редиректе
const queryString = new URLSearchParams(
Object.entries(queryParams).reduce((acc, [key, value]) => {
if (value !== undefined) {
acc[key] = Array.isArray(value) ? value[0] : value;
}
return acc;
}, {} as Record<string, string>)
).toString();
const redirectUrl = queryString
? `/${funnel.meta.id}/${firstScreenId}?${queryString}`
: `/${funnel.meta.id}/${firstScreenId}`;
redirect(redirectUrl);
}

View File

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

View File

@ -1,7 +1,104 @@
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 экранов)
// ⚠️ TypographyVariant НЕ содержит поле 'show', удаляем его если есть
if ('description' in normalizedScreen) {
const normalized = normalizeTypography(normalizedScreen.description);
if (normalized === undefined) {
delete normalizedScreen.description;
} else {
normalizedScreen.description = normalized;
// Удаляем поле 'show' если оно есть (TypographyVariant не содержит его)
if ('show' in normalizedScreen.description) {
delete normalizedScreen.description.show;
}
}
}
// Удаляем специфичные для других шаблонов поля
// Каждый шаблон должен содержать только свои поля
switch (normalizedScreen.template) {
case 'form':
// fields нужно только для form экранов
break;
case 'email':
// email имеет emailInput, а не fields
if ('fields' in normalizedScreen) delete normalizedScreen.fields;
if ('list' in normalizedScreen) delete normalizedScreen.list;
break;
case 'list':
// list нужно только для list экранов
if ('fields' in normalizedScreen) delete normalizedScreen.fields;
break;
case 'loaders':
// progressbars нужно только для loaders
if ('fields' in normalizedScreen) delete normalizedScreen.fields;
if ('list' in normalizedScreen) delete normalizedScreen.list;
break;
default:
// Для остальных шаблонов (info, date, coupon, soulmate) удаляем специфичные поля
if ('fields' in normalizedScreen) delete normalizedScreen.fields;
if ('list' in normalizedScreen) delete normalizedScreen.list;
if ('progressbars' in normalizedScreen) delete normalizedScreen.progressbars;
break;
}
// Нормализуем variants - добавляем пустой overrides если его нет
if ('variants' in normalizedScreen && Array.isArray(normalizedScreen.variants)) {
normalizedScreen.variants = normalizedScreen.variants.map((variant: { conditions?: unknown; overrides?: unknown }) => ({
conditions: variant.conditions || [],
overrides: variant.overrides || {},
}));
}
// Удаляем variables из экранов, которые не поддерживают это поле
// variables поддерживается только в info экранах
if ('variables' in normalizedScreen && normalizedScreen.template !== 'info') {
delete normalizedScreen.variables;
}
return normalizedScreen as ScreenDefinition;
}),
};
}
interface RouteParams {
params: Promise<{
@ -110,8 +207,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 +231,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 +245,7 @@ export async function PUT(request: NextRequest, { params }: RouteParams) {
changeDetails: {
action: 'update-funnel',
previousValue: previousData,
newValue: funnelData as FunnelDefinition
newValue: normalizedDataForHistory
}
});

View File

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

View File

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

View File

@ -53,8 +53,8 @@
/* Тени */
--shadow-blue-glow: 0px 5px 14px 0px rgba(59, 130, 246, 0.4),
0px 4px 6px 0px rgba(59, 130, 246, 0.1);
--shadow-blue-glow-2: 0px 0px 19px 0px rgba(59, 130, 246, 0.3),
0px 0px 0px 0px rgba(59, 130, 246, 0.2);
--shadow-blue-glow-2: 0px 8px 19px 0px rgba(59, 130, 246, 0.3),
0px 4px 10px 0px rgba(59, 130, 246, 0.2);
--shadow-black-glow: 0px 8px 15px 0px #00000026, 0px 4px 6px 0px #00000014;
--shadow-coupon: 0px 20px 40px 0px #0000004d, 0px 8px 16px 0px #00000033;
--shadow-destructive: 0 0 0 2px rgba(239, 68, 68, 0.2);
@ -230,3 +230,13 @@
transform: scale(1.05);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}

View File

@ -117,7 +117,7 @@ export function BuilderCanvas() {
const screenTitleMap = useMemo(() => {
return screens.reduce<Record<string, string>>((accumulator, screen) => {
accumulator[screen.id] = screen.title.text || screen.id;
accumulator[screen.id] = screen.title?.text || screen.id;
return accumulator;
}, {});
}, [screens]);
@ -189,7 +189,7 @@ export function BuilderCanvas() {
#{screen.id}
</span>
<span className="max-h-14 overflow-hidden break-words text-lg font-semibold leading-tight text-foreground">
{screen.title.text || "Без названия"}
{screen.title?.text || "Без названия"}
</span>
</div>
</div>

View File

@ -1,17 +1,24 @@
import type { ScreenDefinition, NavigationConditionDefinition } from "@/lib/funnel/types";
import type {
ScreenDefinition,
NavigationConditionDefinition,
} from "@/lib/funnel/types";
export const TEMPLATE_TITLES: Record<ScreenDefinition["template"], string> = {
list: "Список",
form: "Форма",
form: "Форма",
info: "Инфо",
date: "Дата",
coupon: "Купон",
email: "Email",
loaders: "Загрузка",
soulmate: "Портрет партнера",
trialPayment: "Trial Payment",
};
export const OPERATOR_LABELS: Record<Exclude<NavigationConditionDefinition["operator"], undefined>, string> = {
export const OPERATOR_LABELS: Record<
Exclude<NavigationConditionDefinition["operator"], undefined>,
string
> = {
includesAny: "любой из",
includesAll: "все из",
includesExactly: "точное совпадение",

View File

@ -6,7 +6,11 @@ import { Button } from "@/components/ui/button";
import { Trash2 } from "lucide-react";
import { TemplateConfig } from "@/components/admin/builder/templates";
import { ScreenVariantsConfig } from "../forms/ScreenVariantsConfig";
import { useBuilderDispatch, useBuilderSelectedScreen, useBuilderState } from "@/lib/admin/builder/context";
import {
useBuilderDispatch,
useBuilderSelectedScreen,
useBuilderState,
} from "@/lib/admin/builder/context";
import type { BuilderScreen } from "@/lib/admin/builder/types";
import type {
NavigationRuleDefinition,
@ -24,7 +28,9 @@ export function BuilderSidebar() {
const dispatch = useBuilderDispatch();
const selectedScreen = useBuilderSelectedScreen();
const [activeTab, setActiveTab] = useState<"funnel" | "screen">(selectedScreen ? "screen" : "funnel");
const [activeTab, setActiveTab] = useState<"funnel" | "screen" | "data">(
selectedScreen ? "screen" : "funnel"
);
const selectedScreenId = selectedScreen?.id ?? null;
useEffect(() => {
@ -37,27 +43,31 @@ export function BuilderSidebar() {
}, [selectedScreenId]);
// ✅ Оптимизированная validation - только критичные поля
const screenIds = useMemo(() => state.screens.map(s => s.id).join(','), [state.screens]);
const screenIds = useMemo(
() => state.screens.map((s) => s.id).join(","),
[state.screens]
);
const validation = useMemo(
() => validateBuilderState(state),
// eslint-disable-next-line react-hooks/exhaustive-deps -- Оптимизация: пересчитываем только при изменении критичных полей
[
state.meta.id,
state.meta.firstScreenId,
screenIds,
state.screens.length,
]
[state.meta.id, state.meta.firstScreenId, screenIds, state.screens.length]
);
const screenValidationIssues = useMemo(() => {
if (!selectedScreenId) {
return [] as ValidationIssues;
}
return validation.issues.filter((issue) => issue.screenId === selectedScreenId);
return validation.issues.filter(
(issue) => issue.screenId === selectedScreenId
);
}, [selectedScreenId, validation]);
const screenOptions = useMemo(
() => state.screens.map((screen) => ({ id: screen.id, title: screen.title.text })),
() =>
state.screens.map((screen) => ({
id: screen.id,
title: screen.title?.text,
})),
[state.screens]
);
@ -86,29 +96,29 @@ export function BuilderSidebar() {
if (newId === currentId) {
return;
}
// Разрешаем пустые ID для полного переименования
if (newId.trim() === "") {
// Просто обновляем на пустое значение, пользователь сможет ввести новое
dispatch({
type: "update-screen",
payload: {
screenId: currentId,
screen: { id: newId }
}
dispatch({
type: "update-screen",
payload: {
screenId: currentId,
screen: { id: newId },
},
});
return;
}
// Обновляем ID экрана
dispatch({
type: "update-screen",
payload: {
screenId: currentId,
screen: { id: newId }
}
dispatch({
type: "update-screen",
payload: {
screenId: currentId,
screen: { id: newId },
},
});
// Если это был первый экран в мета данных, обновляем и там
if (state.meta.firstScreenId === currentId) {
dispatch({ type: "set-meta", payload: { firstScreenId: newId } });
@ -128,15 +138,20 @@ export function BuilderSidebar() {
screenId: screen.id,
navigation: {
defaultNextScreenId:
navigationUpdates.defaultNextScreenId ?? screen.navigation?.defaultNextScreenId,
navigationUpdates.defaultNextScreenId ??
screen.navigation?.defaultNextScreenId,
rules: navigationUpdates.rules ?? screen.navigation?.rules ?? [],
isEndScreen: navigationUpdates.isEndScreen ?? screen.navigation?.isEndScreen,
isEndScreen:
navigationUpdates.isEndScreen ?? screen.navigation?.isEndScreen,
},
},
});
};
const handleDefaultNextChange = (screenId: string, nextScreenId: string | "") => {
const handleDefaultNextChange = (
screenId: string,
nextScreenId: string | ""
) => {
const screen = getScreenById(screenId);
if (!screen) {
return;
@ -186,7 +201,11 @@ export function BuilderSidebar() {
updateRules(screenId, nextRules);
};
const handleRuleOptionToggle = (screenId: string, ruleIndex: number, optionId: string) => {
const handleRuleOptionToggle = (
screenId: string,
ruleIndex: number,
optionId: string
) => {
const screen = getScreenById(screenId);
if (!screen) {
return;
@ -220,7 +239,11 @@ export function BuilderSidebar() {
updateRules(screenId, nextRules);
};
const handleRuleNextScreenChange = (screenId: string, ruleIndex: number, nextScreenId: string) => {
const handleRuleNextScreenChange = (
screenId: string,
ruleIndex: number,
nextScreenId: string
) => {
const screen = getScreenById(screenId);
if (!screen) {
return;
@ -247,7 +270,10 @@ export function BuilderSidebar() {
const nextRules = [
...(screen.navigation?.rules ?? []),
{ nextScreenId: state.screens[0]?.id ?? screen.id, conditions: [defaultCondition] },
{
nextScreenId: state.screens[0]?.id ?? screen.id,
conditions: [defaultCondition],
},
];
updateNavigation(screen, { rules: nextRules });
};
@ -270,7 +296,10 @@ export function BuilderSidebar() {
dispatch({ type: "remove-screen", payload: { screenId } });
};
const handleTemplateUpdate = (screenId: string, updates: Partial<ScreenDefinition>) => {
const handleTemplateUpdate = (
screenId: string,
updates: Partial<ScreenDefinition>
) => {
dispatch({
type: "update-screen",
payload: {
@ -295,7 +324,9 @@ export function BuilderSidebar() {
});
};
const selectedScreenIsListType = selectedScreen ? isListScreen(selectedScreen) : false;
const selectedScreenIsListType = selectedScreen
? isListScreen(selectedScreen)
: false;
return (
<div className="flex h-full flex-col">
@ -329,6 +360,18 @@ export function BuilderSidebar() {
>
Экран
</button>
<button
type="button"
className={cn(
"flex-1 rounded-md px-3 py-1.5 transition",
activeTab === "data"
? "bg-background text-foreground shadow"
: "text-muted-foreground hover:text-foreground"
)}
onClick={() => setActiveTab("data")}
>
Данные
</button>
</div>
</div>
@ -347,19 +390,27 @@ export function BuilderSidebar() {
<TextInput
label="Название"
value={state.meta.title ?? ""}
onChange={(event) => handleMetaChange("title", event.target.value)}
onChange={(event) =>
handleMetaChange("title", event.target.value)
}
/>
<TextInput
label="Описание"
value={state.meta.description ?? ""}
onChange={(event) => handleMetaChange("description", event.target.value)}
onChange={(event) =>
handleMetaChange("description", event.target.value)
}
/>
<label className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">Первый экран</span>
<span className="text-sm font-medium text-muted-foreground">
Первый экран
</span>
<select
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={state.meta.firstScreenId ?? state.screens[0]?.id ?? ""}
onChange={(event) => handleFirstScreenChange(event.target.value)}
onChange={(event) =>
handleFirstScreenChange(event.target.value)
}
>
{screenOptions.map((screen) => (
<option key={screen.id} value={screen.id}>
@ -370,18 +421,46 @@ export function BuilderSidebar() {
</label>
</Section>
<Section title="Дефолтные тексты" description="Текст кнопок и баннеров">
<Section title="Аналитика" description="Настройки трекинга">
<TextInput
label="Google Analytics ID"
placeholder="G-XXXXXXXXXX"
value={state.meta.googleAnalyticsId ?? ""}
onChange={(event) =>
handleMetaChange("googleAnalyticsId", event.target.value)
}
className="placeholder:text-sm"
/>
<TextInput
label="Yandex Metrika ID"
placeholder="95799066"
value={state.meta.yandexMetrikaId ?? ""}
onChange={(event) =>
handleMetaChange("yandexMetrikaId", event.target.value)
}
className="placeholder:text-sm"
/>
</Section>
<Section
title="Дефолтные тексты"
description="Текст кнопок и баннеров"
>
<TextInput
label="Текст кнопки Next/Continue"
placeholder="Next"
value={state.defaultTexts?.nextButton ?? ""}
onChange={(event) => handleDefaultTextsChange("nextButton", event.target.value)}
onChange={(event) =>
handleDefaultTextsChange("nextButton", event.target.value)
}
/>
<TextInput
label="Баннер приватности"
placeholder="Мы не передаем личную информацию..."
value={state.defaultTexts?.privacyBanner ?? ""}
onChange={(event) => handleDefaultTextsChange("privacyBanner", event.target.value)}
onChange={(event) =>
handleDefaultTextsChange("privacyBanner", event.target.value)
}
/>
</Section>
@ -389,50 +468,102 @@ export function BuilderSidebar() {
<div className="flex flex-col gap-3 rounded-lg border border-border/60 bg-muted/20 p-3 text-sm text-muted-foreground">
<div className="flex items-center justify-between">
<span>Всего экранов</span>
<span className="font-semibold text-foreground">{state.screens.length}</span>
<span className="font-semibold text-foreground">
{state.screens.length}
</span>
</div>
<div className="flex flex-col gap-1 text-xs">
{state.screens.map((screen, index) => (
<span key={screen.id} className="flex items-center justify-between">
<span className="truncate">{index + 1}. {screen.title.text}</span>
<span className="uppercase text-muted-foreground/80">{screen.template}</span>
<span
key={screen.id}
className="flex items-center justify-between"
>
<span className="truncate">
{index + 1}. {screen.title?.text}
</span>
<span className="uppercase text-muted-foreground/80">
{screen.template}
</span>
</span>
))}
</div>
</div>
</Section>
</div>
) : activeTab === "data" ? (
<div className="flex flex-col gap-4">
<Section title="List экраны и опции" alwaysExpanded>
{state.screens
.filter((screen) => screen.template === "list")
.map((screen) => (
<div
key={screen.id}
className="flex flex-col gap-1.5 rounded-lg border border-border/60 bg-muted/20 p-2.5 text-xs"
>
<div className="font-semibold text-foreground">
{screen.id}
</div>
<div className="flex flex-wrap gap-1">
{isListScreen(screen) &&
screen.list.options.map((option) => (
<code
key={option.id}
className="rounded bg-primary/10 px-1.5 py-0.5 text-[10px] text-primary font-mono"
>
{option.id}
</code>
))}
</div>
</div>
))}
{state.screens.filter((screen) => screen.template === "list")
.length === 0 && (
<div className="rounded-lg border border-border/60 bg-muted/30 p-3 text-center text-xs text-muted-foreground">
Нет list экранов
</div>
)}
</Section>
</div>
) : selectedScreen ? (
<div className="flex flex-col gap-4">
{/* Валидация всегда вверху, без заголовка */}
<ValidationSummary issues={screenValidationIssues} />
<div className="rounded-lg border border-primary/20 bg-primary/5 p-3">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<span className="text-muted-foreground">#{selectedScreen.id}</span>
<span className="text-muted-foreground">
#{selectedScreen.id}
</span>
<span className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary uppercase">
{selectedScreen.template}
</span>
</div>
<span className="text-xs text-muted-foreground">
{state.screens.findIndex((screen) => screen.id === selectedScreen.id) + 1}/{state.screens.length}
{state.screens.findIndex(
(screen) => screen.id === selectedScreen.id
) + 1}
/{state.screens.length}
</span>
</div>
</div>
<Section title="Общие данные">
<TextInput
label="ID экрана"
value={selectedScreen.id}
onChange={(event) => handleScreenIdChange(selectedScreen.id, event.target.value)}
<TextInput
label="ID экрана"
value={selectedScreen.id}
onChange={(event) =>
handleScreenIdChange(selectedScreen.id, event.target.value)
}
/>
</Section>
<Section title="Контент и оформление">
<TemplateConfig
screen={selectedScreen}
onUpdate={(updates) => handleTemplateUpdate(selectedScreen.id, updates)}
onUpdate={(updates) =>
handleTemplateUpdate(selectedScreen.id, updates)
}
/>
</Section>
@ -440,7 +571,9 @@ export function BuilderSidebar() {
<ScreenVariantsConfig
screen={selectedScreen}
allScreens={state.screens}
onChange={(variants) => handleVariantsChange(selectedScreen.id, variants)}
onChange={(variants) =>
handleVariantsChange(selectedScreen.id, variants)
}
/>
</Section>
@ -451,12 +584,16 @@ export function BuilderSidebar() {
type="checkbox"
checked={selectedScreen.navigation?.isEndScreen ?? false}
onChange={(e) => {
updateNavigation(selectedScreen, { isEndScreen: e.target.checked });
updateNavigation(selectedScreen, {
isEndScreen: e.target.checked,
});
}}
className="rounded border-border"
/>
<div className="flex flex-col">
<span className="text-sm font-medium text-foreground">Финальный экран</span>
<span className="text-sm font-medium text-foreground">
Финальный экран
</span>
<span className="text-xs text-muted-foreground">
Этот экран завершает воронку (переход не требуется)
</span>
@ -466,11 +603,18 @@ export function BuilderSidebar() {
{/* ОБЫЧНАЯ НАВИГАЦИЯ - показываем только если НЕ финальный экран */}
{!selectedScreen.navigation?.isEndScreen && (
<label className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">Экран по умолчанию</span>
<span className="text-sm font-medium text-muted-foreground">
Экран по умолчанию
</span>
<select
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={selectedScreen.navigation?.defaultNextScreenId ?? ""}
onChange={(event) => handleDefaultNextChange(selectedScreen.id, event.target.value)}
onChange={(event) =>
handleDefaultNextChange(
selectedScreen.id,
event.target.value
)
}
>
<option value=""></option>
{screenOptions
@ -485,114 +629,163 @@ export function BuilderSidebar() {
)}
</Section>
{selectedScreenIsListType && !selectedScreen.navigation?.isEndScreen && (
<Section title="Правила переходов" description="Условная навигация">
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
Направляйте пользователей на разные экраны в зависимости от выбора.
</p>
<Button className="h-8 w-8 p-0 flex items-center justify-center" onClick={() => handleAddRule(selectedScreen)}>
<span className="text-lg leading-none">+</span>
</Button>
</div>
{(selectedScreen.navigation?.rules ?? []).length === 0 && (
<div className="rounded-lg border border-border/60 bg-muted/30 p-3 text-center text-xs text-muted-foreground">
Правил пока нет
{selectedScreenIsListType &&
!selectedScreen.navigation?.isEndScreen && (
<Section
title="Правила переходов"
description="Условная навигация"
>
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
Направляйте пользователей на разные экраны в зависимости
от выбора.
</p>
<Button
className="h-8 w-8 p-0 flex items-center justify-center"
onClick={() => handleAddRule(selectedScreen)}
>
<span className="text-lg leading-none">+</span>
</Button>
</div>
)}
{(selectedScreen.navigation?.rules ?? []).map((rule, ruleIndex) => (
<div
key={ruleIndex}
className="flex flex-col gap-3 rounded-xl border border-border/80 bg-background/60 p-3"
>
<div className="flex items-center justify-between">
<span className="text-xs font-semibold uppercase text-muted-foreground">Правило {ruleIndex + 1}</span>
<Button
variant="ghost"
className="h-8 px-2 text-destructive hover:bg-destructive/10"
onClick={() => handleRemoveRule(selectedScreen.id, ruleIndex)}
>
<Trash2 className="h-3 w-3 mr-1" />
<span className="text-xs">Удалить</span>
</Button>
{(selectedScreen.navigation?.rules ?? []).length === 0 && (
<div className="rounded-lg border border-border/60 bg-muted/30 p-3 text-center text-xs text-muted-foreground">
Правил пока нет
</div>
<label className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">Оператор</span>
<select
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={rule.conditions[0]?.operator ?? "includesAny"}
onChange={(event) =>
handleRuleOperatorChange(
selectedScreen.id,
ruleIndex,
event.target.value as NavigationRuleDefinition["conditions"][0]["operator"]
)
}
>
<option value="includesAny">contains any</option>
<option value="includesAll">contains all</option>
<option value="includesExactly">exact match</option>
</select>
</label>
)}
{selectedScreen.template === "list" ? (
<div className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">Варианты ответа</span>
<div className="flex flex-col gap-2 rounded-lg border border-border/60 p-3">
{selectedScreen.list.options.map((option) => {
const condition = rule.conditions[0];
const isChecked = condition.optionIds?.includes(option.id) ?? false;
return (
<label key={option.id} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={isChecked}
onChange={() => handleRuleOptionToggle(selectedScreen.id, ruleIndex, option.id)}
/>
<span>
{option.label}
<span className="text-muted-foreground"> ({option.id})</span>
</span>
</label>
);
})}
{(selectedScreen.navigation?.rules ?? []).map(
(rule, ruleIndex) => (
<div
key={ruleIndex}
className="flex flex-col gap-3 rounded-xl border border-border/80 bg-background/60 p-3"
>
<div className="flex items-center justify-between">
<span className="text-xs font-semibold uppercase text-muted-foreground">
Правило {ruleIndex + 1}
</span>
<Button
variant="ghost"
className="h-8 px-2 text-destructive hover:bg-destructive/10"
onClick={() =>
handleRemoveRule(selectedScreen.id, ruleIndex)
}
>
<Trash2 className="h-3 w-3 mr-1" />
<span className="text-xs">Удалить</span>
</Button>
</div>
</div>
) : (
<div className="flex flex-col gap-2 rounded-lg border border-border/60 bg-muted/20 p-3 text-xs text-muted-foreground">
Навигационные правила с вариантами ответа доступны только для экранов со списком.
</div>
)}
<label className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">Следующий экран</span>
<select
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={rule.nextScreenId}
onChange={(event) => handleRuleNextScreenChange(selectedScreen.id, ruleIndex, event.target.value)}
>
{screenOptions
.filter((screen) => screen.id !== selectedScreen.id)
.map((screen) => (
<option key={screen.id} value={screen.id}>
{screen.title}
<label className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">
Оператор
</span>
<select
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={
rule.conditions[0]?.operator ?? "includesAny"
}
onChange={(event) =>
handleRuleOperatorChange(
selectedScreen.id,
ruleIndex,
event.target
.value as NavigationRuleDefinition["conditions"][0]["operator"]
)
}
>
<option value="includesAny">contains any</option>
<option value="includesAll">contains all</option>
<option value="includesExactly">
exact match
</option>
))}
</select>
</label>
</div>
))}
</div>
</Section>
)}
</select>
</label>
{selectedScreen.template === "list" ? (
<div className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">
Варианты ответа
</span>
<div className="flex flex-col gap-2 rounded-lg border border-border/60 p-3">
{selectedScreen.list.options.map((option) => {
const condition = rule.conditions[0];
const isChecked =
condition.optionIds?.includes(option.id) ??
false;
return (
<label
key={option.id}
className="flex items-center gap-2 text-sm"
>
<input
type="checkbox"
checked={isChecked}
onChange={() =>
handleRuleOptionToggle(
selectedScreen.id,
ruleIndex,
option.id
)
}
/>
<span>
{option.label}
<span className="text-muted-foreground">
{" "}
({option.id})
</span>
</span>
</label>
);
})}
</div>
</div>
) : (
<div className="flex flex-col gap-2 rounded-lg border border-border/60 bg-muted/20 p-3 text-xs text-muted-foreground">
Навигационные правила с вариантами ответа доступны
только для экранов со списком.
</div>
)}
<label className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">
Следующий экран
</span>
<select
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={rule.nextScreenId}
onChange={(event) =>
handleRuleNextScreenChange(
selectedScreen.id,
ruleIndex,
event.target.value
)
}
>
{screenOptions
.filter(
(screen) => screen.id !== selectedScreen.id
)
.map((screen) => (
<option key={screen.id} value={screen.id}>
{screen.title}
</option>
))}
</select>
</label>
</div>
)
)}
</div>
</Section>
)}
<Section title="Управление">
<div className="rounded-lg border border-destructive/20 bg-destructive/5 p-4">
<p className="mb-3 text-sm text-muted-foreground">
Удаление экрана нельзя отменить. Все связи с этим экраном будут потеряны.
Удаление экрана нельзя отменить. Все связи с этим экраном
будут потеряны.
</p>
<Button
variant="destructive"
@ -601,7 +794,9 @@ export function BuilderSidebar() {
onClick={() => handleDeleteScreen(selectedScreen.id)}
>
<Trash2 className="h-4 w-4 mr-2" />
{state.screens.length <= 1 ? "Нельзя удалить последний экран" : "Удалить экран"}
{state.screens.length <= 1
? "Нельзя удалить последний экран"
: "Удалить экран"}
</Button>
</div>
</Section>

View File

@ -1,86 +0,0 @@
import { useMemo } from "react";
import { TextInput } from "@/components/ui/TextInput/TextInput";
import { useBuilderDispatch, useBuilderState } from "@/lib/admin/builder/context";
import { Section } from "./Section";
import type { BuilderScreen } from "@/lib/admin/builder/types";
export function FunnelSettingsPanel() {
const state = useBuilderState();
const dispatch = useBuilderDispatch();
const screenOptions = useMemo(
() => state.screens.map((screen: BuilderScreen) => ({ id: screen.id, title: screen.title.text })),
[state.screens]
);
const handleMetaChange = (field: keyof typeof state.meta, value: string) => {
dispatch({ type: "set-meta", payload: { [field]: value } });
};
const handleFirstScreenChange = (value: string) => {
dispatch({ type: "set-meta", payload: { firstScreenId: value } });
};
const handleDefaultTextsChange = (
field: keyof NonNullable<typeof state.defaultTexts>,
value: string
) => {
dispatch({ type: "set-default-texts", payload: { [field]: value } });
};
return (
<>
<Section title="Настройки воронки">
<div className="space-y-3">
<TextInput
label="ID воронки"
value={state.meta.id}
onChange={(e) => handleMetaChange("id", e.target.value)}
/>
<TextInput
label="Название"
value={state.meta.title || ""}
onChange={(e) => handleMetaChange("title", e.target.value)}
/>
<TextInput
label="Описание"
value={state.meta.description || ""}
onChange={(e) => handleMetaChange("description", e.target.value)}
/>
<label className="flex flex-col gap-2 text-sm">
<span className="text-muted-foreground">Первый экран</span>
<select
className="rounded-lg border border-border bg-background px-3 py-2"
value={state.meta.firstScreenId || ""}
onChange={(e) => handleFirstScreenChange(e.target.value)}
>
<option value=""></option>
{screenOptions.map((screen) => (
<option key={screen.id} value={screen.id}>
{screen.title}
</option>
))}
</select>
</label>
</div>
</Section>
<Section title="Дефолтные тексты">
<div className="space-y-3">
<TextInput
label='Кнопка "Next"'
placeholder="Next"
value={state.defaultTexts?.nextButton || ""}
onChange={(e) => handleDefaultTextsChange("nextButton", e.target.value)}
/>
<TextInput
label='Кнопка "Continue"'
placeholder="Continue"
value={state.defaultTexts?.continueButton || ""}
onChange={(e) => handleDefaultTextsChange("continueButton", e.target.value)}
/>
</div>
</Section>
</>
);
}

View File

@ -1,7 +1,10 @@
import { useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Trash2 } from "lucide-react";
import { useBuilderDispatch, useBuilderState } from "@/lib/admin/builder/context";
import {
useBuilderDispatch,
useBuilderState,
} from "@/lib/admin/builder/context";
import { Section } from "./Section";
import type { BuilderScreen } from "@/lib/admin/builder/types";
import type { NavigationRuleDefinition } from "@/lib/funnel/types";
@ -10,7 +13,9 @@ interface NavigationPanelProps {
screen: BuilderScreen;
}
function isListScreen(screen: BuilderScreen): screen is BuilderScreen & { template: "list" } {
function isListScreen(
screen: BuilderScreen
): screen is BuilderScreen & { template: "list" } {
return screen.template === "list";
}
@ -19,7 +24,7 @@ export function NavigationPanel({ screen }: NavigationPanelProps) {
const dispatch = useBuilderDispatch();
const screenOptions = useMemo(
() => state.screens.map((s) => ({ id: s.id, title: s.title.text })),
() => state.screens.map((s) => ({ id: s.id, title: s.title?.text })),
[state.screens]
);
@ -38,15 +43,22 @@ export function NavigationPanel({ screen }: NavigationPanelProps) {
screenId: targetScreen.id,
navigation: {
defaultNextScreenId:
navigationUpdates.defaultNextScreenId ?? targetScreen.navigation?.defaultNextScreenId,
rules: navigationUpdates.rules ?? targetScreen.navigation?.rules ?? [],
isEndScreen: navigationUpdates.isEndScreen ?? targetScreen.navigation?.isEndScreen,
navigationUpdates.defaultNextScreenId ??
targetScreen.navigation?.defaultNextScreenId,
rules:
navigationUpdates.rules ?? targetScreen.navigation?.rules ?? [],
isEndScreen:
navigationUpdates.isEndScreen ??
targetScreen.navigation?.isEndScreen,
},
},
});
};
const handleDefaultNextChange = (screenId: string, nextScreenId: string | "") => {
const handleDefaultNextChange = (
screenId: string,
nextScreenId: string | ""
) => {
const targetScreen = getScreenById(screenId);
if (!targetScreen) return;
@ -64,7 +76,9 @@ export function NavigationPanel({ screen }: NavigationPanelProps) {
const handleAddRule = (targetScreen: BuilderScreen) => {
const rules = targetScreen.navigation?.rules ?? [];
const firstScreenOption = screenOptions.find(s => s.id !== targetScreen.id);
const firstScreenOption = screenOptions.find(
(s) => s.id !== targetScreen.id
);
updateRules(targetScreen.id, [
...rules,
{
@ -105,7 +119,9 @@ export function NavigationPanel({ screen }: NavigationPanelProps) {
className="rounded border-border"
/>
<div className="flex flex-col">
<span className="text-sm font-medium text-foreground">Финальный экран</span>
<span className="text-sm font-medium text-foreground">
Финальный экран
</span>
<span className="text-xs text-muted-foreground">
Этот экран завершает воронку (переход не требуется)
</span>
@ -115,11 +131,15 @@ export function NavigationPanel({ screen }: NavigationPanelProps) {
{/* Обычная навигация - показываем только если НЕ финальный экран */}
{!screen.navigation?.isEndScreen && (
<label className="flex flex-col gap-2">
<span className="text-sm font-medium text-muted-foreground">Экран по умолчанию</span>
<span className="text-sm font-medium text-muted-foreground">
Экран по умолчанию
</span>
<select
className="rounded-lg border border-border bg-background px-3 py-2 text-sm"
value={screen.navigation?.defaultNextScreenId ?? ""}
onChange={(e) => handleDefaultNextChange(screen.id, e.target.value)}
onChange={(e) =>
handleDefaultNextChange(screen.id, e.target.value)
}
>
<option value=""></option>
{screenOptions
@ -139,7 +159,8 @@ export function NavigationPanel({ screen }: NavigationPanelProps) {
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
Направляйте пользователей на разные экраны в зависимости от выбора.
Направляйте пользователей на разные экраны в зависимости от
выбора.
</p>
<Button
className="h-8 w-8 p-0 flex items-center justify-center"
@ -176,7 +197,10 @@ export function NavigationPanel({ screen }: NavigationPanelProps) {
<div className="text-xs text-muted-foreground">
{/* Здесь должна быть полная логика редактирования правил */}
{/* Для краткости оставляем только структуру */}
<p>Правило {ruleIndex + 1} - редактирование правил сохранено в оригинальном компоненте</p>
<p>
Правило {ruleIndex + 1} - редактирование правил сохранено в
оригинальном компоненте
</p>
</div>
</div>
))}

View File

@ -3,7 +3,10 @@ import { Button } from "@/components/ui/button";
import { Trash2 } from "lucide-react";
import { TemplateConfig } from "@/components/admin/builder/templates";
import { ScreenVariantsConfig } from "../forms/ScreenVariantsConfig";
import { useBuilderDispatch, useBuilderState } from "@/lib/admin/builder/context";
import {
useBuilderDispatch,
useBuilderState,
} from "@/lib/admin/builder/context";
import { Section } from "./Section";
import type { BuilderScreen } from "@/lib/admin/builder/types";
import type { ScreenDefinition } from "@/lib/funnel/types";
@ -46,14 +49,20 @@ export function ScreenSettingsPanel({ screen }: ScreenSettingsPanelProps) {
}
};
const handleTemplateUpdate = (screenId: string, updates: Partial<ScreenDefinition>) => {
const handleTemplateUpdate = (
screenId: string,
updates: Partial<ScreenDefinition>
) => {
dispatch({
type: "update-screen",
payload: { screenId, screen: updates },
});
};
const handleVariantsChange = (screenId: string, variants: BuilderScreen["variants"]) => {
const handleVariantsChange = (
screenId: string,
variants: BuilderScreen["variants"]
) => {
dispatch({
type: "update-screen",
payload: { screenId, screen: { variants } },
@ -70,9 +79,11 @@ export function ScreenSettingsPanel({ screen }: ScreenSettingsPanelProps) {
<div className="flex items-center justify-between px-4 py-3 border-b border-border/60 bg-muted/30">
<div className="flex-1">
<div className="text-sm font-semibold text-foreground truncate">
{screen.title.text || "Без названия"}
{screen.title?.text || "Без названия"}
</div>
<span className="text-xs text-muted-foreground">{screen.template}</span>
<span className="text-xs text-muted-foreground">
{screen.template}
</span>
</div>
<Button
variant="ghost"

View File

@ -9,7 +9,8 @@ import {
Ticket,
Loader,
Heart,
Mail
Mail,
CreditCard
} from "lucide-react";
import { Button } from "@/components/ui/button";
@ -85,6 +86,13 @@ const TEMPLATE_OPTIONS = [
icon: Ticket,
color: "bg-orange-50 text-orange-600 dark:bg-orange-900/20 dark:text-orange-400",
},
{
template: "trialPayment" as const,
title: "Trial Payment",
description: "Страница оплаты с пробным периодом",
icon: CreditCard,
color: "bg-amber-50 text-amber-700 dark:bg-amber-900/20 dark:text-amber-400",
},
] as const;
export function AddScreenDialog({ open, onOpenChange, onAddScreen }: AddScreenDialogProps) {

View File

@ -16,22 +16,84 @@ export function VariantConditionEditor({
allScreens,
onChange,
}: VariantConditionEditorProps) {
// Находим выбранный экран
const selectedScreen = useMemo(
() => allScreens.find((s) => s.id === condition.screenId),
[allScreens, condition.screenId]
);
// Находим выбранный экран (может быть ID экрана или storageKey для zodiac)
const selectedScreen = useMemo(() => {
// Сначала ищем по ID экрана
let screen = allScreens.find((s) => s.id === condition.screenId);
if (screen) return screen;
// Если не нашли, ищем date экран где storageKey === condition.screenId
screen = allScreens.find((s) => {
if (s.template === "date") {
const dateScreen = s as BuilderScreen & { template: "date"; dateInput?: { zodiac?: { enabled?: boolean; storageKey?: string } } };
const zodiacSettings = dateScreen.dateInput?.zodiac;
return zodiacSettings?.enabled && zodiacSettings.storageKey?.trim() === condition.screenId;
}
return false;
});
return screen;
}, [allScreens, condition.screenId]);
// Определяем опции для условия (если это list экран)
// Собираем опции из базового экрана + из всех вариантов
const conditionOptions = useMemo<ListOptionDefinition[]>(() => {
if (!selectedScreen || selectedScreen.template !== "list") {
return [];
}
return (selectedScreen as BuilderScreen & { template: "list"; list: { options: ListOptionDefinition[] } }).list.options;
const listScreen = selectedScreen as BuilderScreen & {
template: "list";
list: { options: ListOptionDefinition[] };
variants?: Array<{ overrides?: { list?: { options?: ListOptionDefinition[] } } }>;
};
// Начинаем с базовых опций
const optionsMap = new Map<string, ListOptionDefinition>();
listScreen.list.options.forEach(opt => {
optionsMap.set(opt.id, opt);
});
// Добавляем опции из всех вариантов
if (listScreen.variants) {
listScreen.variants.forEach(variant => {
const variantOptions = variant.overrides?.list?.options;
if (variantOptions) {
variantOptions.forEach(opt => {
// Проверяем что опция валидна (имеет id)
if (opt && opt.id) {
// Добавляем или переопределяем опцию
optionsMap.set(opt.id, opt as ListOptionDefinition);
}
});
}
});
}
// Возвращаем все уникальные опции
return Array.from(optionsMap.values());
}, [selectedScreen]);
// Определяем, нужен ли специальный селектор
const showZodiacSelector = selectedScreen?.id === "zodiac-sign";
const showZodiacSelector = useMemo(() => {
if (!selectedScreen) return false;
// Проверяем специальный zodiac экран
if (selectedScreen.id === "zodiac-sign" || selectedScreen.id === "zodiac") {
return true;
}
// Проверяем date экран с zodiac.enabled и storageKey === condition.screenId
if (selectedScreen.template === "date") {
const dateScreen = selectedScreen as BuilderScreen & { template: "date"; dateInput?: { zodiac?: { enabled?: boolean; storageKey?: string } } };
const zodiacSettings = dateScreen.dateInput?.zodiac;
const storageKey = zodiacSettings?.storageKey?.trim();
// Показываем zodiac селектор если:
// 1. zodiac включен
// 2. storageKey совпадает с condition.screenId (т.е. пользователь выбрал этот storageKey)
return zodiacSettings?.enabled === true && storageKey === condition.screenId;
}
return false;
}, [selectedScreen, condition.screenId]);
const showEmailSelector = selectedScreen?.id === "email";
const showAgeSelector =
selectedScreen?.id === "age" || selectedScreen?.id === "crush-age" || selectedScreen?.id === "current-partner-age";
@ -64,11 +126,32 @@ export function VariantConditionEditor({
onChange={(e) => onChange({ ...condition, screenId: e.target.value, optionIds: [] })}
className="w-full h-9 rounded-md border border-border bg-background px-3 text-sm"
>
{allScreens.map((screen) => (
<option key={screen.id} value={screen.id}>
{screen.id}
</option>
))}
{allScreens.flatMap((screen) => {
const options = [];
// Для date экранов с zodiac добавляем отдельную опцию для storageKey
if (screen.template === "date") {
const dateScreen = screen as BuilderScreen & { template: "date"; dateInput?: { zodiac?: { enabled?: boolean; storageKey?: string } } };
const zodiacSettings = dateScreen.dateInput?.zodiac;
const storageKey = zodiacSettings?.storageKey?.trim();
if (zodiacSettings?.enabled && storageKey) {
options.push(
<option key={`${screen.id}-zodiac`} value={storageKey}>
{screen.id} (zodiac {storageKey})
</option>
);
}
}
// Обычный экран (всегда)
options.push(
<option key={screen.id} value={screen.id}>
{screen.id} ({screen.template})
</option>
);
return options;
})}
</select>
</div>

View File

@ -2,7 +2,10 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useBuilderSelectedScreen, useBuilderState } from "@/lib/admin/builder/context";
import {
useBuilderSelectedScreen,
useBuilderState,
} from "@/lib/admin/builder/context";
import { renderScreen } from "@/lib/funnel/screenRenderer";
import { mergeScreenWithOverrides } from "@/lib/admin/builder/variants";
import { PreviewErrorBoundary } from "@/components/admin/ErrorBoundary";
@ -20,7 +23,9 @@ export function BuilderPreview() {
const selectedScreen = useBuilderSelectedScreen();
const builderState = useBuilderState();
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [previewVariantIndex, setPreviewVariantIndex] = useState<number | null>(null);
const [previewVariantIndex, setPreviewVariantIndex] = useState<number | null>(
null
);
useEffect(() => {
if (!selectedScreen) {
@ -39,15 +44,20 @@ export function BuilderPreview() {
const handleSelectionChange = useCallback((ids: string[]) => {
setSelectedIds((prev) => {
if (prev.length === ids.length && prev.every((value, index) => value === ids[index])) {
if (
prev.length === ids.length &&
prev.every((value, index) => value === ids[index])
) {
return prev;
}
return ids;
});
}, []);
const variants = useMemo(() => selectedScreen?.variants ?? [], [selectedScreen]);
const variants = useMemo(
() => selectedScreen?.variants ?? [],
[selectedScreen]
);
useEffect(() => {
setPreviewVariantIndex(null);
@ -74,8 +84,15 @@ export function BuilderPreview() {
if (!previewScreen) return null;
try {
// ✅ Используем мемоизированные моки
// ✅ Собираем объект funnel из builderState для передачи в renderScreen
const mockFunnel = {
meta: builderState.meta,
defaultTexts: builderState.defaultTexts,
screens: builderState.screens,
};
return renderScreen({
funnel: mockFunnel,
screen: previewScreen,
selectedOptionIds: selectedIds,
onSelectionChange: handleSelectionChange,
@ -84,21 +101,36 @@ export function BuilderPreview() {
onBack: MOCK_CALLBACKS.onBack,
screenProgress: MOCK_PROGRESS,
defaultTexts: builderState.defaultTexts,
answers: {}, // Mock empty answers для превью
});
} catch (error) {
console.error('Error rendering preview:', error);
console.error("Error rendering preview:", error);
return (
<div className="flex h-32 items-center justify-center rounded-lg border border-dashed border-border text-sm text-muted-foreground">
Ошибка при отображении превью: {error instanceof Error ? error.message : 'Неизвестная ошибка'}
Ошибка при отображении превью:{" "}
{error instanceof Error ? error.message : "Неизвестная ошибка"}
</div>
);
}
}, [previewScreen, selectedIds, handleSelectionChange, builderState.defaultTexts]);
}, [
previewScreen,
selectedIds,
handleSelectionChange,
builderState.meta,
builderState.defaultTexts,
builderState.screens,
]);
const preview = useMemo(() => {
if (!previewScreen) {
return (
<div className="flex items-center justify-center mx-auto" style={{ height: `${PREVIEW_DIMENSIONS.EMPTY_HEIGHT}px`, width: `${PREVIEW_DIMENSIONS.WIDTH}px` }}>
<div
className="flex items-center justify-center mx-auto"
style={{
height: `${PREVIEW_DIMENSIONS.EMPTY_HEIGHT}px`,
width: `${PREVIEW_DIMENSIONS.WIDTH}px`,
}}
>
<div className="flex items-center justify-center rounded-lg border border-dashed border-border bg-muted/30 text-sm text-muted-foreground w-full h-full">
Выберите экран для предпросмотра
</div>
@ -120,9 +152,17 @@ export function BuilderPreview() {
</span>
<select
className="rounded-md border border-border bg-background px-2 py-1 text-xs"
value={previewVariantIndex === null ? "base" : String(previewVariantIndex)}
value={
previewVariantIndex === null
? "base"
: String(previewVariantIndex)
}
onChange={(event) =>
setPreviewVariantIndex(event.target.value === "base" ? null : Number(event.target.value))
setPreviewVariantIndex(
event.target.value === "base"
? null
: Number(event.target.value)
)
}
>
<option value="base">Основной экран</option>
@ -135,7 +175,8 @@ export function BuilderPreview() {
</div>
{previewVariantIndex !== null && (
<div className="mt-2 rounded border border-blue-200 bg-blue-50 px-2 py-1 text-[11px] text-blue-700 dark:border-blue-800 dark:bg-blue-900/20 dark:text-blue-200">
Превью принудительно показывает вариант. В реальной воронке он показывается только при выполнении условий.
Превью принудительно показывает вариант. В реальной воронке
он показывается только при выполнении условий.
</div>
)}
</div>
@ -147,10 +188,10 @@ export function BuilderPreview() {
style={{
height: PREVIEW_HEIGHT,
width: PREVIEW_WIDTH,
overflow: 'hidden', // Hide anything that goes outside
contain: 'layout style paint', // CSS containment
isolation: 'isolate', // Create new stacking context
transform: 'translateZ(0)' // Force new layer
overflow: "hidden", // Hide anything that goes outside
contain: "layout style paint", // CSS containment
isolation: "isolate", // Create new stacking context
transform: "translateZ(0)", // Force new layer
}}
>
{/* Screen Content with scroll - wrapped in Error Boundary */}

View File

@ -174,6 +174,23 @@ export function DateScreenConfig({ screen, onUpdate }: DateScreenConfigProps) {
</label>
</div>
<div className="space-y-3">
<h4 className="text-sm font-semibold text-foreground">Регистрация пользователя</h4>
<label className="flex flex-col gap-1 text-xs font-medium text-muted-foreground">
Ключ поля для регистрации (необязательно)
<TextInput
placeholder="Например: profile.birthdate"
value={dateScreen.dateInput?.registrationFieldKey ?? ""}
onChange={(event) => handleDateInputChange("registrationFieldKey", event.target.value || undefined)}
/>
</label>
<div className="rounded-lg border border-border/60 bg-muted/20 p-3 text-xs text-muted-foreground">
<p><strong>Использование:</strong> Выбранная дата будет передана в регистрацию и сессию по указанному ключу.</p>
<p className="mt-1"><strong>Формат даты:</strong> YYYY-MM-DD HH:mm (например: <code className="bg-muted px-1 rounded">2000-07-03 12:00</code>)</p>
<p className="mt-1"><strong>Пример:</strong> <code className="bg-muted px-1 rounded">profile.birthdate</code> <code className="bg-muted px-1 rounded">{`{ profile: { birthdate: "2000-07-03 12:00" } }`}</code></p>
</div>
</div>
</div>
);
}

View File

@ -1,7 +1,11 @@
"use client";
import { useState } from "react";
import { ChevronDown, ChevronRight } from "lucide-react";
import { TextInput } from "@/components/ui/TextInput/TextInput";
import { ImageUpload } from "@/components/admin/builder/forms/ImageUpload";
import { VariablesConfig } from "./VariablesConfig";
import { useBuilderState } from "@/lib/admin/builder/context";
import type { InfoScreenDefinition } from "@/lib/funnel/types";
import type { BuilderScreen } from "@/lib/admin/builder/types";
@ -10,8 +14,58 @@ interface InfoScreenConfigProps {
onUpdate: (updates: Partial<InfoScreenDefinition>) => void;
}
function CollapsibleSection({
title,
children,
defaultExpanded = false,
}: {
title: string;
children: React.ReactNode;
defaultExpanded?: boolean;
}) {
const storageKey = `info-section-${title.toLowerCase().replace(/\s+/g, '-')}`;
const [isExpanded, setIsExpanded] = useState(() => {
if (typeof window === 'undefined') return defaultExpanded;
const stored = sessionStorage.getItem(storageKey);
return stored !== null ? JSON.parse(stored) : defaultExpanded;
});
const handleToggle = () => {
const newExpanded = !isExpanded;
setIsExpanded(newExpanded);
if (typeof window !== 'undefined') {
sessionStorage.setItem(storageKey, JSON.stringify(newExpanded));
}
};
return (
<div className="space-y-3">
<button
type="button"
onClick={handleToggle}
className="flex w-full items-center gap-2 text-left text-sm font-medium text-foreground hover:text-primary transition-colors"
>
{isExpanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
{title}
</button>
{isExpanded && <div className="ml-6 space-y-3">{children}</div>}
</div>
);
}
export function InfoScreenConfig({ screen, onUpdate }: InfoScreenConfigProps) {
const infoScreen = screen as InfoScreenDefinition;
const state = useBuilderState();
// Получаем доступные экраны для настройки условий переменных
const availableScreens = state.screens;
const handleIconChange = <T extends keyof NonNullable<InfoScreenDefinition["icon"]>>(
@ -40,63 +94,74 @@ export function InfoScreenConfig({ screen, onUpdate }: InfoScreenConfigProps) {
};
return (
<div className="space-y-6 rounded-2xl border border-border/70 bg-background/80 p-5 shadow-sm">
<div className="space-y-3">
<h4 className="text-sm font-semibold text-foreground">Иконка</h4>
<div className="grid grid-cols-2 gap-2 text-xs">
<label className="flex flex-col gap-1 text-muted-foreground">
Тип иконки
<select
className="rounded-lg border border-border bg-background px-2 py-1"
value={infoScreen.icon?.type ?? "emoji"}
onChange={(event) => handleIconChange("type", event.target.value as "emoji" | "image")}
>
<option value="emoji">Emoji</option>
<option value="image">Изображение</option>
</select>
</label>
<label className="flex flex-col gap-1 text-muted-foreground">
Размер
<select
className="rounded-lg border border-border bg-background px-2 py-1"
value={infoScreen.icon?.size ?? "lg"}
onChange={(event) =>
handleIconChange("size", event.target.value as "sm" | "md" | "lg" | "xl")
}
>
<option value="sm">Маленький</option>
<option value="md">Средний</option>
<option value="lg">Большой</option>
<option value="xl">Огромный</option>
</select>
</label>
</div>
{infoScreen.icon?.type === "image" ? (
<div>
<span className="text-xs font-medium text-muted-foreground mb-2 block">
Изображение иконки
</span>
<ImageUpload
currentValue={infoScreen.icon?.value}
onImageSelect={(url) => handleIconChange("value", url)}
onImageRemove={() => handleIconChange("value", undefined)}
funnelId={screen.id}
/>
<div className="space-y-4">
{/* Иконка */}
<CollapsibleSection title="Иконка" defaultExpanded={true}>
<div className="space-y-3">
<div className="space-y-2 text-xs">
<label className="flex flex-col gap-1 text-muted-foreground">
Тип иконки
<select
className="rounded-lg border border-border bg-background px-2 py-1"
value={infoScreen.icon?.type ?? "emoji"}
onChange={(event) => handleIconChange("type", event.target.value as "emoji" | "image")}
>
<option value="emoji">Emoji</option>
<option value="image">Изображение</option>
</select>
</label>
<label className="flex flex-col gap-1 text-muted-foreground">
Размер
<select
className="rounded-lg border border-border bg-background px-2 py-1"
value={infoScreen.icon?.size ?? "lg"}
onChange={(event) =>
handleIconChange("size", event.target.value as "sm" | "md" | "lg" | "xl")
}
>
<option value="sm">Маленький</option>
<option value="md">Средний</option>
<option value="lg">Большой</option>
<option value="xl">Огромный</option>
</select>
</label>
</div>
) : (
<label className="flex flex-col gap-2 text-sm">
<span className="text-xs font-medium text-muted-foreground">
Emoji символ
</span>
<TextInput
placeholder="Например, ✨"
value={infoScreen.icon?.value ?? ""}
onChange={(event) => handleIconChange("value", event.target.value || undefined)}
/>
</label>
)}
</div>
{infoScreen.icon?.type === "image" ? (
<div>
<span className="text-xs font-medium text-muted-foreground mb-2 block">
Изображение иконки
</span>
<ImageUpload
currentValue={infoScreen.icon?.value}
onImageSelect={(url) => handleIconChange("value", url)}
onImageRemove={() => handleIconChange("value", undefined)}
funnelId={screen.id}
/>
</div>
) : (
<label className="flex flex-col gap-2 text-sm">
<span className="text-xs font-medium text-muted-foreground">
Emoji символ
</span>
<TextInput
placeholder="Например, ✨"
value={infoScreen.icon?.value ?? ""}
onChange={(event) => handleIconChange("value", event.target.value || undefined)}
/>
</label>
)}
</div>
</CollapsibleSection>
{/* Переменные */}
<CollapsibleSection title="Переменные">
<VariablesConfig
variables={infoScreen.variables}
onUpdate={(variables) => onUpdate({ variables })}
availableScreens={availableScreens}
/>
</CollapsibleSection>
</div>
);
}

View File

@ -48,6 +48,15 @@ export function ListScreenConfig({ screen, onUpdate }: ListScreenConfigProps) {
});
};
const handleRegistrationFieldKeyChange = (value: string) => {
onUpdate({
list: {
...listScreen.list,
registrationFieldKey: value || undefined,
},
});
};
const handleOptionChange = (
index: number,
@ -151,6 +160,24 @@ export function ListScreenConfig({ screen, onUpdate }: ListScreenConfigProps) {
</div>
</div>
{listScreen.list.selectionType === "single" && (
<div className="space-y-3">
<h4 className="text-sm font-semibold text-foreground">Регистрация пользователя</h4>
<label className="flex flex-col gap-1 text-xs font-medium text-muted-foreground">
Ключ поля для регистрации (необязательно)
<TextInput
placeholder="Например: profile.gender"
value={listScreen.list.registrationFieldKey ?? ""}
onChange={(event) => handleRegistrationFieldKeyChange(event.target.value)}
/>
</label>
<div className="rounded-lg border border-border/60 bg-muted/20 p-3 text-xs text-muted-foreground">
<p><strong>Использование:</strong> Выбранный ID варианта будет передан в регистрацию пользователя по указанному ключу.</p>
<p className="mt-1"><strong>Пример:</strong> <code className="bg-muted px-1 rounded">profile.gender</code> <code className="bg-muted px-1 rounded">{`{ profile: { gender: "selected-id" } }`}</code></p>
</div>
</div>
)}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold text-foreground">Настройка вариантов</h4>

View File

@ -62,13 +62,15 @@ export function LoadersScreenConfig({ screen, onUpdate }: LoadersScreenConfigPro
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-slate-700 mb-3">Настройки анимации</h4>
<TextInput
label="Длительность анимации (мс)"
type="number"
placeholder="5000"
value={screen.progressbars?.transitionDuration?.toString() || "5000"}
onChange={(e) => updateProgressbars({ transitionDuration: parseInt(e.target.value) || 5000 })}
/>
<div className="space-y-3">
<TextInput
label="Длительность анимации (мс)"
type="number"
placeholder="5000"
value={screen.progressbars?.transitionDuration?.toString() || "5000"}
onChange={(e) => updateProgressbars({ transitionDuration: parseInt(e.target.value) || 5000 })}
/>
</div>
</div>
<div>
@ -100,7 +102,7 @@ export function LoadersScreenConfig({ screen, onUpdate }: LoadersScreenConfigPro
</Button>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-3">
<TextInput
label="Заголовок"
placeholder="Step 1"
@ -115,7 +117,7 @@ export function LoadersScreenConfig({ screen, onUpdate }: LoadersScreenConfigPro
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-3">
<TextInput
label="Текст во время обработки"
placeholder="Processing..."
@ -130,7 +132,7 @@ export function LoadersScreenConfig({ screen, onUpdate }: LoadersScreenConfigPro
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-3">
<TextInput
label="Текст при завершении"
placeholder="Completed!"

View File

@ -2,6 +2,9 @@
import React from "react";
import { TextInput } from "@/components/ui/TextInput/TextInput";
import { Button } from "@/components/ui/button";
import { ImageUpload } from "@/components/admin/builder/forms/ImageUpload";
import { Plus, Trash2 } from "lucide-react";
import type { BuilderScreen } from "@/lib/admin/builder/types";
import type { SoulmatePortraitScreenDefinition } from "@/lib/funnel/types";
@ -11,6 +14,10 @@ interface SoulmatePortraitScreenConfigProps {
}
export function SoulmatePortraitScreenConfig({ screen, onUpdate }: SoulmatePortraitScreenConfigProps) {
type Delivered = NonNullable<SoulmatePortraitScreenDefinition["soulmatePortraitsDelivered"]>;
type Avatar = NonNullable<Delivered["avatars"]>[number];
type DeliveredText = NonNullable<Delivered["text"]>;
const updateDescription = (updates: Partial<SoulmatePortraitScreenDefinition["description"]>) => {
onUpdate({
description: screen.description ? {
@ -20,6 +27,64 @@ export function SoulmatePortraitScreenConfig({ screen, onUpdate }: SoulmatePortr
});
};
const updateDelivered = (
updates: Partial<NonNullable<SoulmatePortraitScreenDefinition["soulmatePortraitsDelivered"]>>
) => {
const base = screen.soulmatePortraitsDelivered ?? {};
onUpdate({
soulmatePortraitsDelivered: {
...base,
...updates,
},
});
};
const updateDeliveredText = (
updates: Partial<DeliveredText>
) => {
const currentText = (screen.soulmatePortraitsDelivered?.text ?? { text: "" }) as DeliveredText;
const nextText = { ...currentText, ...(updates as object) } as DeliveredText;
updateDelivered({ text: nextText });
};
const addAvatar = () => {
const avatars = screen.soulmatePortraitsDelivered?.avatars ?? [];
updateDelivered({ avatars: [...avatars, { src: "", alt: "", fallbackText: "" }] });
};
const removeAvatar = (index: number) => {
const avatars = screen.soulmatePortraitsDelivered?.avatars ?? [];
updateDelivered({ avatars: avatars.filter((_, i) => i !== index) });
};
const updateAvatar = (
index: number,
updates: Partial<Avatar>
) => {
const avatars = screen.soulmatePortraitsDelivered?.avatars ?? [];
const next = avatars.map((a, i) => (i === index ? { ...a, ...updates } : a));
updateDelivered({ avatars: next });
};
const addTextListItem = () => {
const items = screen.textList?.items ?? [];
onUpdate({ textList: { items: [...items, { text: "" }] } });
};
const removeTextListItem = (index: number) => {
const items = screen.textList?.items ?? [];
onUpdate({ textList: { items: items.filter((_, i) => i !== index) } });
};
const updateTextListItem = (
index: number,
updates: Partial<NonNullable<SoulmatePortraitScreenDefinition["textList"]>["items"][number]>
) => {
const items = screen.textList?.items ?? [];
const next = items.map((it, i) => (i === index ? { ...it, ...updates } : it));
onUpdate({ textList: { items: next } });
};
return (
<div className="space-y-4">
<div>
@ -32,6 +97,119 @@ export function SoulmatePortraitScreenConfig({ screen, onUpdate }: SoulmatePortr
/>
</div>
<div>
<h4 className="text-sm font-medium text-slate-700 mb-3">Блок доставленных портретов</h4>
<div className="space-y-3">
<div>
<span className="text-xs font-medium text-muted-foreground mb-2 block">Изображение</span>
<ImageUpload
currentValue={screen.soulmatePortraitsDelivered?.image}
onImageSelect={(url) => updateDelivered({ image: url })}
onImageRemove={() => updateDelivered({ image: undefined })}
funnelId={screen.id}
/>
</div>
<TextInput
label="Текст под изображением"
placeholder="soulmate portraits delivered today"
value={screen.soulmatePortraitsDelivered?.text?.text || ""}
onChange={(e) => updateDeliveredText({ text: e.target.value })}
/>
</div>
</div>
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium text-slate-700">Аватары</h4>
<Button type="button" variant="outline" onClick={addAvatar} className="flex items-center gap-2 text-sm px-3 py-1">
<Plus className="w-4 h-4" /> Добавить
</Button>
</div>
<div className="space-y-4">
{(screen.soulmatePortraitsDelivered?.avatars ?? []).map((avatar, index) => (
<div key={index} className="border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between">
<h5 className="text-sm font-medium text-slate-600">Аватар {index + 1}</h5>
<Button type="button" variant="ghost" onClick={() => removeAvatar(index)} className="text-red-600 hover:text-red-700 text-sm px-2 py-1">
<Trash2 className="w-4 h-4" />
</Button>
</div>
<div className="space-y-3">
<div>
<span className="text-xs font-medium text-muted-foreground mb-2 block">Изображение</span>
<ImageUpload
currentValue={avatar.src}
onImageSelect={(url) => updateAvatar(index, { src: url })}
onImageRemove={() => updateAvatar(index, { src: "" })}
funnelId={screen.id}
/>
</div>
<TextInput
label="Alt"
placeholder="Описание"
value={avatar.alt || ""}
onChange={(e) => updateAvatar(index, { alt: e.target.value })}
/>
<TextInput
label="Fallback текст"
placeholder="Напр. 900+"
value={avatar.fallbackText || ""}
onChange={(e) => updateAvatar(index, { fallbackText: e.target.value })}
/>
</div>
<div className="text-xs text-muted-foreground">Можно указать изображение или fallback текст (или оба).</div>
</div>
))}
</div>
{(screen.soulmatePortraitsDelivered?.avatars ?? []).length === 0 && (
<div className="text-center py-6 text-slate-500">
<p>Нет аватаров</p>
<Button type="button" variant="outline" onClick={addAvatar} className="mt-2 text-sm px-3 py-1">
Добавить первый
</Button>
</div>
)}
</div>
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-sm font-medium text-slate-700">Список текстов</h4>
<Button type="button" variant="outline" onClick={addTextListItem} className="flex items-center gap-2 text-sm px-3 py-1">
<Plus className="w-4 h-4" /> Добавить
</Button>
</div>
<div className="space-y-3">
{(screen.textList?.items ?? []).map((item, index) => (
<div key={index} className="space-y-1">
<TextInput
label={`Элемент ${index + 1}`}
placeholder="Текст элемента"
value={item.text || ""}
onChange={(e) => updateTextListItem(index, { text: e.target.value })}
/>
<div className="flex justify-end">
<Button type="button" variant="ghost" onClick={() => removeTextListItem(index)} className="text-red-600 hover:text-red-700 text-sm px-2 py-1">
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
))}
</div>
{(screen.textList?.items ?? []).length === 0 && (
<div className="text-center py-6 text-slate-500">
<p>Пока нет элементов</p>
<Button type="button" variant="outline" onClick={addTextListItem} className="mt-2 text-sm px-3 py-1">
Добавить первый
</Button>
</div>
)}
</div>
<div>
<h4 className="text-sm font-medium text-slate-700 mb-3">Информация</h4>
<div className="text-xs text-muted-foreground">

View File

@ -11,6 +11,7 @@ import { ListScreenConfig } from "./ListScreenConfig";
import { EmailScreenConfig } from "./EmailScreenConfig";
import { LoadersScreenConfig } from "./LoadersScreenConfig";
import { SoulmatePortraitScreenConfig } from "./SoulmatePortraitScreenConfig";
import { TrialPaymentScreenConfig } from "./TrialPaymentScreenConfig";
import { TextInput } from "@/components/ui/TextInput/TextInput";
import { TextAreaInput } from "@/components/ui/TextAreaInput/TextAreaInput";
@ -28,6 +29,7 @@ import type {
TypographyVariant,
BottomActionButtonDefinition,
HeaderDefinition,
TrialPaymentScreenDefinition,
} from "@/lib/funnel/types";
const RADIUS_OPTIONS: ("3xl" | "full")[] = ["3xl", "full"];
@ -106,18 +108,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 +133,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 +162,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 +174,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 +444,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 +473,7 @@ export function TemplateConfig({ screen, onUpdate }: TemplateConfigProps) {
<TypographyControls
label="Заголовок"
value={screen.title}
onChange={handleTitleChange}
allowRemove
onChange={handleTitleChange}
showToggle
/>
<TypographyControls
@ -510,6 +541,12 @@ export function TemplateConfig({ screen, onUpdate }: TemplateConfigProps) {
onUpdate={onUpdate as (updates: Partial<SoulmatePortraitScreenDefinition>) => void}
/>
)}
{template === "trialPayment" && (
<TrialPaymentScreenConfig
screen={screen as BuilderScreen & { template: "trialPayment" }}
onUpdate={onUpdate as (updates: Partial<TrialPaymentScreenDefinition>) => void}
/>
)}
</div>
);
}

View File

@ -0,0 +1,717 @@
"use client";
import React from "react";
import type { BuilderScreen } from "@/lib/admin/builder/types";
import type { TrialPaymentScreenDefinition } from "@/lib/funnel/types";
import { TextInput } from "@/components/ui/TextInput/TextInput";
import { TextAreaInput } from "@/components/ui/TextAreaInput/TextAreaInput";
import { Button } from "@/components/ui/button";
import { Trash } from "lucide-react";
interface TrialPaymentScreenConfigProps {
screen: BuilderScreen & { template: "trialPayment" };
onUpdate: (updates: Partial<TrialPaymentScreenDefinition>) => void;
}
export function TrialPaymentScreenConfig({ screen, onUpdate }: TrialPaymentScreenConfigProps) {
const updateHeaderBlock = (updates: Partial<NonNullable<TrialPaymentScreenDefinition["headerBlock"]>>) => {
onUpdate({ headerBlock: { ...screen.headerBlock, ...updates } });
};
const updateUnlock = (
updates: Partial<NonNullable<TrialPaymentScreenDefinition["unlockYourSketch"]>>
) => {
onUpdate({ unlockYourSketch: { ...screen.unlockYourSketch, ...updates } });
};
const updatePaymentButtons = (
index: number,
field: "text" | "icon" | "primary",
value: string | boolean
) => {
const current = screen.paymentButtons?.buttons ?? [];
const buttons = current.map((b, i) =>
i === index
? {
...b,
...(field === "text" ? { text: String(value) } : {}),
...(field === "icon" ? { icon: String(value) as "pay" | "google" | "card" } : {}),
...(field === "primary" ? { primary: Boolean(value) } : {}),
}
: b
);
onUpdate({ paymentButtons: { buttons } });
};
const updateFooterContacts = (
field: "email" | "address" | "title",
value: { href: string; text: string } | { text: string }
) => {
const next = { ...(screen.footer?.contacts ?? {}) } as NonNullable<TrialPaymentScreenDefinition["footer"]>["contacts"];
if (field === "email") next!.email = value as { href: string; text: string };
if (field === "address") next!.address = value as { text: string };
if (field === "title") next!.title = { text: (value as { text: string }).text };
onUpdate({ footer: { ...screen.footer, contacts: next } });
};
return (
<div className="space-y-6">
<div>
<h4 className="text-sm font-medium text-foreground mb-2">Header Block</h4>
<div className="space-y-3">
<TextInput
label="Текст"
value={screen.headerBlock?.text?.text ?? ""}
onChange={(e) => updateHeaderBlock({ text: { ...(screen.headerBlock?.text ?? {}), text: e.target.value } })}
/>
<TextInput
label="Таймер (сек)"
type="number"
value={String(screen.headerBlock?.timerSeconds ?? 600)}
onChange={(e) => updateHeaderBlock({ timerSeconds: Number(e.target.value) })}
/>
</div>
</div>
<div>
<h4 className="text-sm font-medium text-foreground mb-2">Reviews</h4>
<div className="space-y-3">
<TextInput
label="Title"
value={screen.reviews?.title?.text ?? ""}
onChange={(e) => onUpdate({ reviews: { title: { text: e.target.value }, items: screen.reviews?.items ?? [] } })}
/>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Items</span>
<Button
type="button"
className="h-7 px-2 text-xs"
onClick={() => {
const current = screen.reviews?.items ?? [];
const items = [
...current,
{ name: { text: "" }, text: { text: "" }, rating: 5 },
];
onUpdate({ reviews: { ...screen.reviews, items } });
}}
>
Добавить
</Button>
</div>
{(screen.reviews?.items ?? []).map((r, idx) => (
<div key={idx} className="space-y-2 border border-border/60 rounded-md p-3">
<TextInput
label={`Review #${idx + 1} name`}
value={r.name.text}
onChange={(e) => {
const current = screen.reviews?.items ?? [];
const items = current.map((v, i) => (i === idx ? { ...v, name: { text: e.target.value } } : v));
onUpdate({ reviews: { ...screen.reviews, items } });
}}
/>
<TextInput
label="Date"
value={r.date?.text ?? ""}
onChange={(e) => {
const current = screen.reviews?.items ?? [];
const items = current.map((v, i) => (i === idx ? { ...v, date: { text: e.target.value } } : v));
onUpdate({ reviews: { ...screen.reviews, items } });
}}
/>
<TextAreaInput
label="Text (supports **bold**)"
rows={3}
value={r.text.text}
onChange={(e) => {
const current = screen.reviews?.items ?? [];
const items = current.map((v, i) => (i === idx ? { ...v, text: { text: e.target.value } } : v));
onUpdate({ reviews: { ...screen.reviews, items } });
}}
/>
<TextInput
label="Avatar src"
value={r.avatar?.src ?? ""}
onChange={(e) => {
const current = screen.reviews?.items ?? [];
const items = current.map((v, i) => (i === idx ? { ...v, avatar: { src: e.target.value } } : v));
onUpdate({ reviews: { ...screen.reviews, items } });
}}
/>
<TextInput
label="Portrait src"
value={r.portrait?.src ?? ""}
onChange={(e) => {
const current = screen.reviews?.items ?? [];
const items = current.map((v, i) => (i === idx ? { ...v, portrait: { src: e.target.value } } : v));
onUpdate({ reviews: { ...screen.reviews, items } });
}}
/>
<TextInput
label="Photo src"
value={r.photo?.src ?? ""}
onChange={(e) => {
const current = screen.reviews?.items ?? [];
const items = current.map((v, i) => (i === idx ? { ...v, photo: { src: e.target.value } } : v));
onUpdate({ reviews: { ...screen.reviews, items } });
}}
/>
<div className="space-y-2">
<TextInput
label="Rating (1-5)"
type="number"
value={String(r.rating ?? 5)}
onChange={(e) => {
const current = screen.reviews?.items ?? [];
const items = current.map((v, i) => (i === idx ? { ...v, rating: Number(e.target.value) } : v));
onUpdate({ reviews: { ...screen.reviews, items } });
}}
/>
<div className="flex justify-end">
<Button
type="button"
variant="ghost"
className="h-8 w-8 p-0 text-red-500 hover:text-red-600"
onClick={() => {
const current = screen.reviews?.items ?? [];
const items = current.filter((_, i) => i !== idx);
onUpdate({ reviews: { ...screen.reviews, items } });
}}
aria-label="Удалить отзыв"
>
<Trash className="h-4 w-4" />
</Button>
</div>
</div>
</div>
))}
</div>
</div>
</div>
<div>
<h4 className="text-sm font-medium text-foreground mb-2">Common Questions</h4>
<div className="space-y-3">
<TextInput
label="Title"
value={screen.commonQuestions?.title?.text ?? ""}
onChange={(e) => onUpdate({ commonQuestions: { title: { text: e.target.value }, items: screen.commonQuestions?.items ?? [] } })}
/>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Items</span>
<Button
type="button"
className="h-7 px-2 text-xs"
onClick={() => {
const current = screen.commonQuestions?.items ?? [];
const items = [...current, { question: "", answer: "" }];
onUpdate({ commonQuestions: { ...screen.commonQuestions, items } });
}}
>
Добавить
</Button>
</div>
{(screen.commonQuestions?.items ?? []).map((q, idx) => (
<div key={idx} className="grid grid-cols-1 gap-2 border border-border/60 rounded-md p-3">
<TextInput
label={`Question #${idx + 1}`}
value={q.question}
onChange={(e) => {
const current = screen.commonQuestions?.items ?? [];
const items = current.map((v, i) => (i === idx ? { ...v, question: e.target.value } : v));
onUpdate({ commonQuestions: { ...screen.commonQuestions, items } });
}}
/>
<TextAreaInput
label="Answer"
rows={2}
value={q.answer}
onChange={(e) => {
const current = screen.commonQuestions?.items ?? [];
const items = current.map((v, i) => (i === idx ? { ...v, answer: e.target.value } : v));
onUpdate({ commonQuestions: { ...screen.commonQuestions, items } });
}}
/>
<div className="flex items-center justify-end">
<Button
type="button"
variant="ghost"
className="h-8 w-8 p-0 text-red-500 hover:text-red-600"
onClick={() => {
const current = screen.commonQuestions?.items ?? [];
const items = current.filter((_, i) => i !== idx);
onUpdate({ commonQuestions: { ...screen.commonQuestions, items } });
}}
aria-label="Удалить вопрос"
>
<Trash className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
</div>
</div>
<div>
<h4 className="text-sm font-medium text-foreground mb-2">Progress To See Soulmate</h4>
<div className="space-y-3">
<TextInput
label="Title"
value={screen.progressToSeeSoulmate?.title?.text ?? ""}
onChange={(e) => onUpdate({ progressToSeeSoulmate: { ...screen.progressToSeeSoulmate, title: { text: e.target.value } } })}
/>
<TextInput
label="Progress value (0-100)"
type="number"
value={String(screen.progressToSeeSoulmate?.progress?.value ?? 0)}
onChange={(e) => onUpdate({ progressToSeeSoulmate: { ...screen.progressToSeeSoulmate, progress: { value: Number(e.target.value) } } })}
/>
<TextInput
label="Left text"
value={screen.progressToSeeSoulmate?.leftText?.text ?? ""}
onChange={(e) => onUpdate({ progressToSeeSoulmate: { ...screen.progressToSeeSoulmate, leftText: { text: e.target.value } } })}
/>
<TextInput
label="Right text"
value={screen.progressToSeeSoulmate?.rightText?.text ?? ""}
onChange={(e) => onUpdate({ progressToSeeSoulmate: { ...screen.progressToSeeSoulmate, rightText: { text: e.target.value } } })}
/>
</div>
</div>
<div>
<h4 className="text-sm font-medium text-foreground mb-2">Steps To See Soulmate</h4>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Steps</span>
<Button
type="button"
className="h-7 px-2 text-xs"
onClick={() => {
const current = screen.stepsToSeeSoulmate?.steps ?? [];
const steps = [
...current,
{ title: { text: "" }, description: { text: "" }, icon: "questions" as const, isActive: false },
];
onUpdate({ stepsToSeeSoulmate: { ...screen.stepsToSeeSoulmate, steps } });
}}
>
Добавить шаг
</Button>
</div>
{(screen.stepsToSeeSoulmate?.steps ?? []).map((step, idx) => (
<div key={idx} className="grid grid-cols-1 gap-2 border border-border/60 rounded-md p-3">
<div className="space-y-3">
<TextInput
label={`Step #${idx + 1} title`}
value={step.title.text}
onChange={(e) => {
const current = screen.stepsToSeeSoulmate?.steps ?? [];
const steps = current.map((s, i) => (i === idx ? { ...s, title: { text: e.target.value } } : s));
onUpdate({ stepsToSeeSoulmate: { ...screen.stepsToSeeSoulmate, steps } });
}}
/>
<TextInput
label="Icon (questions|profile|sketch|astro|chat)"
value={(step.icon ?? "") as string}
onChange={(e) => {
const icon = e.target.value as "questions" | "profile" | "sketch" | "astro" | "chat";
const current = screen.stepsToSeeSoulmate?.steps ?? [];
const steps = current.map((s, i) => (i === idx ? { ...s, icon } : s));
onUpdate({ stepsToSeeSoulmate: { ...screen.stepsToSeeSoulmate, steps } });
}}
/>
</div>
<TextAreaInput
label="Description"
rows={2}
value={step.description.text}
onChange={(e) => {
const current = screen.stepsToSeeSoulmate?.steps ?? [];
const steps = current.map((s, i) => (i === idx ? { ...s, description: { text: e.target.value } } : s));
onUpdate({ stepsToSeeSoulmate: { ...screen.stepsToSeeSoulmate, steps } });
}}
/>
<div className="flex items-center justify-between">
<label className="text-xs flex items-center gap-2">
<input
type="checkbox"
checked={Boolean(step.isActive)}
onChange={(e) => {
const current = screen.stepsToSeeSoulmate?.steps ?? [];
const steps = current.map((s, i) => (i === idx ? { ...s, isActive: e.target.checked } : s));
onUpdate({ stepsToSeeSoulmate: { ...screen.stepsToSeeSoulmate, steps } });
}}
/>
Active
</label>
<Button
type="button"
variant="ghost"
className="h-8 w-8 p-0 text-red-500 hover:text-red-600"
onClick={() => {
const current = screen.stepsToSeeSoulmate?.steps ?? [];
const steps = current.filter((_, i) => i !== idx);
onUpdate({ stepsToSeeSoulmate: { ...screen.stepsToSeeSoulmate, steps } });
}}
aria-label="Удалить шаг"
>
<Trash className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
</div>
<div>
<h4 className="text-sm font-medium text-foreground mb-2">Money Back Guarantee</h4>
<div className="space-y-3">
<TextInput
label="Title"
value={screen.moneyBackGuarantee?.title?.text ?? ""}
onChange={(e) => onUpdate({ moneyBackGuarantee: { ...screen.moneyBackGuarantee, title: { text: e.target.value } } })}
/>
<TextAreaInput
label="Text"
value={screen.moneyBackGuarantee?.text?.text ?? ""}
onChange={(e) => onUpdate({ moneyBackGuarantee: { ...screen.moneyBackGuarantee, text: { text: e.target.value } } })}
/>
</div>
</div>
<div>
<h4 className="text-sm font-medium text-foreground mb-2">Policy</h4>
<div className="grid grid-cols-1 gap-3">
<TextAreaInput
label="Text"
rows={3}
value={screen.policy?.text?.text ?? ""}
onChange={(e) => onUpdate({ policy: { text: { text: e.target.value } } })}
/>
</div>
</div>
<div>
<h4 className="text-sm font-medium text-foreground mb-2">Users&apos; Portraits</h4>
<div className="space-y-3">
<TextInput
label="Title"
value={screen.usersPortraits?.title?.text ?? ""}
onChange={(e) => onUpdate({ usersPortraits: { ...screen.usersPortraits, title: { text: e.target.value } } })}
/>
<TextInput
label="Button text"
value={screen.usersPortraits?.buttonText ?? ""}
onChange={(e) => onUpdate({ usersPortraits: { ...screen.usersPortraits, buttonText: e.target.value } })}
/>
</div>
<div className="space-y-2 mt-2">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Images</span>
<Button
type="button"
className="h-7 px-2 text-xs"
onClick={() => {
const current = screen.usersPortraits?.images ?? [];
const images = [...current, { src: "" }];
onUpdate({ usersPortraits: { ...screen.usersPortraits, images } });
}}
>
Добавить
</Button>
</div>
{(screen.usersPortraits?.images ?? []).map((img, idx) => (
<div key={idx} className="space-y-2">
<TextInput
label={`Image #${idx + 1} src`}
value={img.src}
onChange={(e) => {
const current = screen.usersPortraits?.images ?? [];
const images = current.map((v, i) => (i === idx ? { ...v, src: e.target.value } : v));
onUpdate({ usersPortraits: { ...screen.usersPortraits, images } });
}}
/>
<div className="flex justify-end">
<Button
type="button"
variant="ghost"
className="h-8 w-8 p-0 text-red-500 hover:text-red-600"
onClick={() => {
const current = screen.usersPortraits?.images ?? [];
const images = current.filter((_, i) => i !== idx);
onUpdate({ usersPortraits: { ...screen.usersPortraits, images } });
}}
aria-label="Удалить изображение"
>
<Trash className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
</div>
<div>
<h4 className="text-sm font-medium text-foreground mb-2">Joined Today With Avatars</h4>
<div className="space-y-3">
<TextInput
label="Count"
value={screen.joinedTodayWithAvatars?.count?.text ?? ""}
onChange={(e) => onUpdate({ joinedTodayWithAvatars: { ...screen.joinedTodayWithAvatars, count: { text: e.target.value } } })}
/>
<TextInput
label="Text"
value={screen.joinedTodayWithAvatars?.text?.text ?? ""}
onChange={(e) => onUpdate({ joinedTodayWithAvatars: { ...screen.joinedTodayWithAvatars, text: { text: e.target.value } } })}
/>
</div>
<div className="space-y-2 mt-2">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Avatars</span>
<Button
type="button"
className="h-7 px-2 text-xs"
onClick={() => {
const current = screen.joinedTodayWithAvatars?.avatars?.images ?? [];
const images = [...current, { src: "" }];
onUpdate({ joinedTodayWithAvatars: { ...screen.joinedTodayWithAvatars, avatars: { images } } });
}}
>
Добавить
</Button>
</div>
{(screen.joinedTodayWithAvatars?.avatars?.images ?? []).map((img, idx) => (
<div key={idx} className="space-y-2">
<TextInput
label={`Avatar #${idx + 1} src`}
value={img.src}
onChange={(e) => {
const current = screen.joinedTodayWithAvatars?.avatars?.images ?? [];
const images = current.map((v, i) => (i === idx ? { ...v, src: e.target.value } : v));
onUpdate({ joinedTodayWithAvatars: { ...screen.joinedTodayWithAvatars, avatars: { images } } });
}}
/>
<div className="flex justify-end">
<Button
type="button"
variant="ghost"
className="h-8 w-8 p-0 text-red-500 hover:text-red-600"
onClick={() => {
const current = screen.joinedTodayWithAvatars?.avatars?.images ?? [];
const images = current.filter((_, i) => i !== idx);
onUpdate({ joinedTodayWithAvatars: { ...screen.joinedTodayWithAvatars, avatars: { images } } });
}}
aria-label="Удалить аватар"
>
<Trash className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
</div>
<div>
<h4 className="text-sm font-medium text-foreground mb-2">Try For Days</h4>
<div className="space-y-3">
<TextInput
label="Title"
value={screen.tryForDays?.title?.text ?? ""}
onChange={(e) => onUpdate({ tryForDays: { ...screen.tryForDays, title: { text: e.target.value } } })}
/>
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Items</span>
<Button
type="button"
className="h-7 px-2 text-xs"
onClick={() => {
const current = screen.tryForDays?.textList?.items ?? [];
const items = [...current, { text: "" }];
onUpdate({ tryForDays: { ...screen.tryForDays, textList: { items } } });
}}
>
Добавить
</Button>
</div>
{(screen.tryForDays?.textList?.items ?? []).map((it, idx) => (
<div key={idx} className="space-y-2">
<TextAreaInput
label={`Item #${idx + 1}`}
rows={2}
value={it.text}
onChange={(e) => {
const current = screen.tryForDays?.textList?.items ?? [];
const items = current.map((v, i) => (i === idx ? { ...v, text: e.target.value } : v));
onUpdate({ tryForDays: { ...screen.tryForDays, textList: { items } } });
}}
/>
<div className="flex justify-end">
<Button
type="button"
variant="ghost"
className="h-8 w-8 p-0 text-red-500 hover:text-red-600"
onClick={() => {
const current = screen.tryForDays?.textList?.items ?? [];
const items = current.filter((_, i) => i !== idx);
onUpdate({ tryForDays: { ...screen.tryForDays, textList: { items } } });
}}
aria-label="Удалить элемент"
>
<Trash className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
</div>
</div>
<div>
<h4 className="text-sm font-medium text-foreground mb-2">Total Price</h4>
<div className="space-y-3">
<TextInput
label="Coupon title"
value={screen.totalPrice?.couponContainer?.title?.text ?? ""}
onChange={(e) => onUpdate({ totalPrice: { couponContainer: { ...(screen.totalPrice?.couponContainer ?? {}), title: { text: e.target.value } }, priceContainer: screen.totalPrice?.priceContainer } })}
/>
<TextInput
label="Coupon button text"
value={screen.totalPrice?.couponContainer?.buttonText ?? ""}
onChange={(e) => onUpdate({ totalPrice: { couponContainer: { ...(screen.totalPrice?.couponContainer ?? {}), buttonText: e.target.value }, priceContainer: screen.totalPrice?.priceContainer } })}
/>
<TextInput
label="Price title"
value={screen.totalPrice?.priceContainer?.title?.text ?? ""}
onChange={(e) => onUpdate({ totalPrice: { couponContainer: screen.totalPrice?.couponContainer ?? { title: { text: "" }, buttonText: "" }, priceContainer: { ...(screen.totalPrice?.priceContainer ?? {}), title: { text: e.target.value } } } })}
/>
<TextInput
label="Price"
value={screen.totalPrice?.priceContainer?.price?.text ?? ""}
onChange={(e) => onUpdate({ totalPrice: { couponContainer: screen.totalPrice?.couponContainer ?? { title: { text: "" }, buttonText: "" }, priceContainer: { ...(screen.totalPrice?.priceContainer ?? {}), price: { text: e.target.value } } } })}
/>
<TextInput
label="Old price"
value={screen.totalPrice?.priceContainer?.oldPrice?.text ?? ""}
onChange={(e) => onUpdate({ totalPrice: { couponContainer: screen.totalPrice?.couponContainer ?? { title: { text: "" }, buttonText: "" }, priceContainer: { ...(screen.totalPrice?.priceContainer ?? {}), oldPrice: { text: e.target.value } } } })}
/>
<TextInput
label="Discount"
value={screen.totalPrice?.priceContainer?.discount?.text ?? ""}
onChange={(e) => onUpdate({ totalPrice: { couponContainer: screen.totalPrice?.couponContainer ?? { title: { text: "" }, buttonText: "" }, priceContainer: { ...(screen.totalPrice?.priceContainer ?? {}), discount: { text: e.target.value } } } })}
/>
</div>
</div>
<div>
<h4 className="text-sm font-medium text-foreground mb-2">Joined Today</h4>
<div className="space-y-3">
<TextInput
label="Count"
value={screen.joinedToday?.count?.text ?? ""}
onChange={(e) => onUpdate({ joinedToday: { ...screen.joinedToday, count: { text: e.target.value } } })}
/>
<TextInput
label="Text"
value={screen.joinedToday?.text?.text ?? ""}
onChange={(e) => onUpdate({ joinedToday: { ...screen.joinedToday, text: { text: e.target.value } } })}
/>
</div>
</div>
<div>
<h4 className="text-sm font-medium text-foreground mb-2">Trusted By Over</h4>
<div className="space-y-3">
<TextInput
label="Text"
value={screen.trustedByOver?.text?.text ?? ""}
onChange={(e) => onUpdate({ trustedByOver: { text: { text: e.target.value } } })}
/>
</div>
</div>
<div>
<h4 className="text-sm font-medium text-foreground mb-2">Finding The One Guide</h4>
<div className="space-y-3">
<TextInput
label="Emoji"
value={screen.findingOneGuide?.header?.emoji?.text ?? ""}
onChange={(e) => onUpdate({ findingOneGuide: { ...screen.findingOneGuide, header: { ...(screen.findingOneGuide?.header ?? {}), emoji: { text: e.target.value } } } })}
/>
<TextInput
label="Title"
value={screen.findingOneGuide?.header?.title?.text ?? ""}
onChange={(e) => onUpdate({ findingOneGuide: { ...screen.findingOneGuide, header: { ...(screen.findingOneGuide?.header ?? {}), title: { text: e.target.value } } } })}
/>
<TextAreaInput
label="Text"
value={screen.findingOneGuide?.text?.text ?? ""}
onChange={(e) => onUpdate({ findingOneGuide: { ...screen.findingOneGuide, text: { text: e.target.value } } })}
/>
<TextInput
label="Blur text"
value={screen.findingOneGuide?.blur?.text?.text ?? ""}
onChange={(e) => onUpdate({ findingOneGuide: { ...screen.findingOneGuide, blur: { ...(screen.findingOneGuide?.blur ?? {}), text: { text: e.target.value }, icon: "lock" } } })}
/>
</div>
</div>
<div>
<h4 className="text-sm font-medium text-foreground mb-2">Unlock Your Sketch</h4>
<div className="space-y-3">
<TextInput label="Заголовок" value={screen.unlockYourSketch?.title?.text ?? ""} onChange={(e) => updateUnlock({ title: { text: e.target.value } })} />
<TextInput label="Подзаголовок" value={screen.unlockYourSketch?.subtitle?.text ?? ""} onChange={(e) => updateUnlock({ subtitle: { text: e.target.value } })} />
<TextInput label="Изображение" value={screen.unlockYourSketch?.image?.src ?? ""} onChange={(e) => updateUnlock({ image: { src: e.target.value } })} />
<TextInput label="Текст на блюре" value={screen.unlockYourSketch?.blur?.text?.text ?? ""} onChange={(e) => updateUnlock({ blur: { ...(screen.unlockYourSketch?.blur ?? {}), text: { text: e.target.value }, icon: "lock" } as NonNullable<TrialPaymentScreenDefinition["unlockYourSketch"]>["blur"] })} />
<TextInput label="Текст кнопки" value={screen.unlockYourSketch?.buttonText ?? ""} onChange={(e) => updateUnlock({ buttonText: e.target.value })} />
</div>
</div>
<div>
<h4 className="text-sm font-medium text-foreground mb-2">Payment Buttons</h4>
{(screen.paymentButtons?.buttons ?? []).map((b, i) => (
<div key={i} className="space-y-2 mb-2">
<TextInput label={`Текст #${i + 1}`} value={b.text} onChange={(e) => updatePaymentButtons(i, "text", e.target.value)} />
<TextInput label="Иконка (pay|google|card)" value={("icon" in b ? (b as { icon?: "pay"|"google"|"card" }).icon ?? "" : "")} onChange={(e) => updatePaymentButtons(i, "icon", e.target.value)} />
<label className="text-xs flex items-center gap-2"><input type="checkbox" checked={("primary" in b ? (b as { primary?: boolean }).primary ?? false : false)} onChange={(e) => updatePaymentButtons(i, "primary", e.target.checked)} /> Primary</label>
</div>
))}
</div>
<div>
<h4 className="text-sm font-medium text-foreground mb-2">Footer / Contacts</h4>
<div className="space-y-3">
<TextInput label="Email" value={screen.footer?.contacts?.email?.text ?? screen.footer?.contacts?.email?.href ?? ""} onChange={(e) => updateFooterContacts("email", { href: e.target.value, text: e.target.value })} />
<TextAreaInput label="Адрес" value={screen.footer?.contacts?.address?.text ?? ""} onChange={(e) => updateFooterContacts("address", { text: e.target.value })} />
</div>
</div>
<div>
<h4 className="text-sm font-medium text-foreground mb-2">Still Have Questions</h4>
<div className="space-y-3">
<TextInput
label="Title"
value={screen.stillHaveQuestions?.title?.text ?? ""}
onChange={(e) => onUpdate({ stillHaveQuestions: { ...screen.stillHaveQuestions, title: { text: e.target.value } } })}
/>
<TextInput
label="Action button"
value={screen.stillHaveQuestions?.actionButtonText ?? ""}
onChange={(e) => onUpdate({ stillHaveQuestions: { ...screen.stillHaveQuestions, actionButtonText: e.target.value } })}
/>
<TextInput
label="Contact button"
value={screen.stillHaveQuestions?.contactButtonText ?? ""}
onChange={(e) => onUpdate({ stillHaveQuestions: { ...screen.stillHaveQuestions, contactButtonText: e.target.value } })}
/>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,280 @@
"use client";
import { useMemo } from "react";
import { AgeSelector } from "../forms/AgeSelector";
import { ZodiacSelector } from "../forms/ZodiacSelector";
import { EmailDomainSelector } from "../forms/EmailDomainSelector";
import type { NavigationConditionDefinition, ListOptionDefinition, DateScreenDefinition } from "@/lib/funnel/types";
import type { BuilderScreen } from "@/lib/admin/builder/types";
interface VariableMappingConditionEditorProps {
condition: NavigationConditionDefinition;
allScreens: BuilderScreen[];
onChange: (condition: NavigationConditionDefinition) => void;
}
/**
* Редактор условия для правила подстановки переменной
* Переиспользует ту же логику что и VariantConditionEditor
*/
export function VariableMappingConditionEditor({
condition,
allScreens,
onChange,
}: VariableMappingConditionEditorProps) {
// Находим выбранный экран (может быть ID экрана или storageKey для zodiac)
const selectedScreen = useMemo(() => {
// Сначала ищем по ID экрана
let screen = allScreens.find((s) => s.id === condition.screenId);
if (screen) return screen;
// Если не нашли, ищем date экран где storageKey === condition.screenId
screen = allScreens.find((s) => {
if (s.template === "date") {
const dateScreen = s as DateScreenDefinition;
const zodiacSettings = dateScreen.dateInput?.zodiac;
return zodiacSettings?.enabled && zodiacSettings.storageKey?.trim() === condition.screenId;
}
return false;
});
return screen;
}, [allScreens, condition.screenId]);
// Определяем опции для условия (если это list экран)
// Собираем опции из базового экрана + из всех вариантов
const conditionOptions = useMemo<ListOptionDefinition[]>(() => {
if (!selectedScreen || selectedScreen.template !== "list") {
return [];
}
const listScreen = selectedScreen as BuilderScreen & {
template: "list";
list: { options: ListOptionDefinition[] };
variants?: Array<{ overrides?: { list?: { options?: ListOptionDefinition[] } } }>;
};
// Начинаем с базовых опций
const optionsMap = new Map<string, ListOptionDefinition>();
listScreen.list.options.forEach(opt => {
optionsMap.set(opt.id, opt);
});
// Добавляем опции из всех вариантов
if (listScreen.variants) {
listScreen.variants.forEach(variant => {
const variantOptions = variant.overrides?.list?.options;
if (variantOptions) {
variantOptions.forEach(opt => {
// Проверяем что опция валидна (имеет id)
if (opt && opt.id) {
// Добавляем или переопределяем опцию
optionsMap.set(opt.id, opt as ListOptionDefinition);
}
});
}
});
}
// Возвращаем все уникальные опции
return Array.from(optionsMap.values());
}, [selectedScreen]);
// Определяем тип селектора на основе экрана
const selectorType = useMemo(() => {
if (!selectedScreen) return null;
// Специальные селекторы по ID экрана
if (selectedScreen.id === "zodiac-sign" || selectedScreen.id === "zodiac") {
return "zodiac";
}
if (selectedScreen.id === "email") {
return "email";
}
if (selectedScreen.id === "age" || selectedScreen.id === "crush-age" || selectedScreen.id === "current-partner-age") {
return "age";
}
// Date экран с zodiac - проверяем что condition.screenId === storageKey
if (selectedScreen.template === "date") {
const dateScreen = selectedScreen as DateScreenDefinition;
const zodiacSettings = dateScreen.dateInput?.zodiac;
const storageKey = zodiacSettings?.storageKey?.trim();
// Показываем zodiac селектор если:
// 1. zodiac включен
// 2. storageKey совпадает с condition.screenId
if (zodiacSettings?.enabled && storageKey === condition.screenId) {
return "zodiac";
}
if (selectedScreen.id.includes("age") || selectedScreen.id.includes("birth")) {
return "age";
}
}
// List экран
if (selectedScreen.template === "list" && conditionOptions.length > 0) {
return "list";
}
return null;
}, [selectedScreen, conditionOptions.length, condition.screenId]);
// Обработчики
const handleToggleValue = (value: string) => {
const currentValues = condition.values || [];
const nextValues = currentValues.includes(value)
? currentValues.filter((v) => v !== value)
: [...currentValues, value];
onChange({ ...condition, values: nextValues });
};
const handleAddCustomValue = (value: string) => {
const currentValues = condition.values || [];
if (!currentValues.includes(value)) {
onChange({ ...condition, values: [...currentValues, value] });
}
};
return (
<div className="space-y-3">
{/* Выбор экрана */}
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
Экран
</label>
<select
value={condition.screenId}
onChange={(e) => onChange({ ...condition, screenId: e.target.value, values: [] })}
className="w-full h-9 rounded-md border border-border bg-background px-3 text-sm"
>
{allScreens.flatMap((screen) => {
const options = [];
// Для date экранов с zodiac добавляем отдельную опцию для storageKey
if (screen.template === "date") {
const dateScreen = screen as DateScreenDefinition;
const zodiacSettings = dateScreen.dateInput?.zodiac;
const storageKey = zodiacSettings?.storageKey?.trim();
if (zodiacSettings?.enabled && storageKey) {
options.push(
<option key={`${screen.id}-zodiac`} value={storageKey}>
{screen.id} (zodiac {storageKey})
</option>
);
}
}
// Обычный экран (всегда)
options.push(
<option key={screen.id} value={screen.id}>
{screen.id} ({screen.template})
</option>
);
return options;
})}
</select>
</div>
{/* Оператор (только для list экранов с несколькими опциями) */}
{selectorType === "list" && conditionOptions.length > 1 && (
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
Оператор
</label>
<select
value={condition.operator || "includesAny"}
onChange={(e) =>
onChange({
...condition,
operator: e.target.value as "includesAny" | "includesAll",
})
}
className="w-full h-9 rounded-md border border-border bg-background px-3 text-sm"
>
<option value="includesAny">Любое из (OR)</option>
<option value="includesAll">Все из (AND)</option>
</select>
</div>
)}
{/* Zodiac Selector */}
{selectorType === "zodiac" && (
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
Знаки зодиака
</label>
<ZodiacSelector
selectedValues={condition.values || []}
onToggleValue={handleToggleValue}
onAddCustomValue={handleAddCustomValue}
/>
</div>
)}
{/* Email Domain Selector */}
{selectorType === "email" && (
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
Email домены
</label>
<EmailDomainSelector
selectedValues={condition.values || []}
onToggleValue={handleToggleValue}
onAddCustomValue={handleAddCustomValue}
/>
</div>
)}
{/* Age Selector */}
{selectorType === "age" && (
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
Возраст
</label>
<AgeSelector
selectedValues={condition.values || []}
onToggleValue={handleToggleValue}
onAddCustomValue={handleAddCustomValue}
/>
</div>
)}
{/* Опции для обычных list экранов */}
{selectorType === "list" && (
<div>
<label className="block text-sm font-medium text-muted-foreground mb-1">
Значения
</label>
<div className="space-y-1 max-h-48 overflow-y-auto border border-border rounded-md p-2">
{conditionOptions.map((opt) => (
<label
key={opt.id}
className="flex items-center gap-2 p-2 rounded hover:bg-muted/50 cursor-pointer"
>
<input
type="checkbox"
checked={(condition.values || []).includes(opt.id)}
onChange={() => handleToggleValue(opt.id)}
className="rounded border-border"
/>
<span className="text-sm">
{opt.emoji && <span className="mr-1">{opt.emoji}</span>}
{opt.label}
</span>
</label>
))}
</div>
</div>
)}
{/* Если тип экрана не поддерживается */}
{!selectorType && selectedScreen && (
<div className="text-sm text-muted-foreground bg-muted/30 rounded-lg p-3">
Экран <strong>{selectedScreen.template}</strong> не поддерживает автоматический выбор значений.
Выберите другой экран (list, date с zodiac, age, email).
</div>
)}
</div>
);
}

View File

@ -0,0 +1,297 @@
"use client";
import { useState } from "react";
import { Plus, Trash2, ChevronDown, ChevronRight } from "lucide-react";
import { TextInput } from "@/components/ui/TextInput/TextInput";
import { VariableMappingConditionEditor } from "./VariableMappingConditionEditor";
import type { VariableDefinition, VariableMapping, NavigationConditionDefinition } from "@/lib/funnel/types";
import type { BuilderScreen } from "@/lib/admin/builder/types";
interface VariablesConfigProps {
variables: VariableDefinition[] | undefined;
onUpdate: (variables: VariableDefinition[] | undefined) => void;
availableScreens: BuilderScreen[];
}
export function VariablesConfig({ variables = [], onUpdate, availableScreens }: VariablesConfigProps) {
const [expandedVariables, setExpandedVariables] = useState<Set<number>>(new Set([0]));
const [expandedMappings, setExpandedMappings] = useState<Set<string>>(new Set());
const toggleVariable = (index: number) => {
const newExpanded = new Set(expandedVariables);
if (newExpanded.has(index)) {
newExpanded.delete(index);
} else {
newExpanded.add(index);
}
setExpandedVariables(newExpanded);
};
const toggleMapping = (varIndex: number, mapIndex: number) => {
const key = `${varIndex}-${mapIndex}`;
const newExpanded = new Set(expandedMappings);
if (newExpanded.has(key)) {
newExpanded.delete(key);
} else {
newExpanded.add(key);
}
setExpandedMappings(newExpanded);
};
const isMappingExpanded = (varIndex: number, mapIndex: number) => {
return expandedMappings.has(`${varIndex}-${mapIndex}`);
};
const addVariable = () => {
const newVariable: VariableDefinition = {
name: "",
mappings: [],
fallback: "",
};
const updatedVariables = [...(variables || []), newVariable];
onUpdate(updatedVariables);
setExpandedVariables(new Set([...expandedVariables, (variables || []).length]));
};
const removeVariable = (index: number) => {
const newVariables = [...(variables || [])];
newVariables.splice(index, 1);
onUpdate(newVariables.length > 0 ? newVariables : undefined);
};
const updateVariable = (index: number, updates: Partial<VariableDefinition>) => {
const newVariables = [...(variables || [])];
newVariables[index] = { ...newVariables[index], ...updates };
onUpdate(newVariables);
};
const addMapping = (variableIndex: number) => {
const newMapping: VariableMapping = {
conditions: [{
screenId: availableScreens[0]?.id || "",
conditionType: "values",
operator: "includesAny",
values: [],
}],
value: "",
};
const variable = variables![variableIndex];
updateVariable(variableIndex, {
mappings: [...variable.mappings, newMapping],
});
};
const removeMapping = (variableIndex: number, mappingIndex: number) => {
const variable = variables![variableIndex];
const newMappings = [...variable.mappings];
newMappings.splice(mappingIndex, 1);
updateVariable(variableIndex, { mappings: newMappings });
};
const updateMapping = (
variableIndex: number,
mappingIndex: number,
updates: Partial<VariableMapping>
) => {
const variable = variables![variableIndex];
const newMappings = [...variable.mappings];
newMappings[mappingIndex] = { ...newMappings[mappingIndex], ...updates };
updateVariable(variableIndex, { mappings: newMappings });
};
const updateCondition = (
variableIndex: number,
mappingIndex: number,
conditionIndex: number,
updates: Partial<NavigationConditionDefinition>
) => {
const variable = variables![variableIndex];
const mapping = variable.mappings[mappingIndex];
const newConditions = [...mapping.conditions];
newConditions[conditionIndex] = { ...newConditions[conditionIndex], ...updates };
updateMapping(variableIndex, mappingIndex, { conditions: newConditions });
};
if (!variables || variables.length === 0) {
return (
<div className="space-y-4 rounded-2xl border border-border/70 bg-background/80 p-5 shadow-sm">
<div className="space-y-2">
<h4 className="text-sm font-semibold text-foreground">Переменные</h4>
<p className="text-xs text-muted-foreground">
Используйте переменные для динамической подстановки текста на основе ответов пользователя.
<br />
Синтаксис: <code className="bg-muted px-1 py-0.5 rounded">{"{{variableName}}"}</code>
</p>
</div>
<button
onClick={addVariable}
className="flex items-center gap-2 rounded-lg border border-border bg-background px-3 py-2 text-xs font-medium text-foreground hover:bg-accent"
>
<Plus className="h-4 w-4" />
Добавить переменную
</button>
</div>
);
}
return (
<div className="space-y-4 rounded-2xl border border-border/70 bg-background/80 p-5 shadow-sm">
<div className="flex items-center justify-between">
<h4 className="text-sm font-semibold text-foreground">Переменные</h4>
<button
onClick={addVariable}
className="flex items-center gap-1 rounded-lg border border-border bg-background px-2 py-1 text-xs font-medium text-foreground hover:bg-accent"
>
<Plus className="h-3 w-3" />
Добавить
</button>
</div>
<div className="space-y-3">
{variables.map((variable, varIndex) => (
<div
key={varIndex}
className="rounded-lg border border-border/50 bg-background/50 p-3 space-y-3"
>
{/* Variable Header */}
<div className="flex items-center gap-2">
<button
onClick={() => toggleVariable(varIndex)}
className="flex items-center text-xs font-medium text-foreground hover:text-foreground/80"
>
{expandedVariables.has(varIndex) ? (
<ChevronDown className="h-4 w-4 mr-1" />
) : (
<ChevronRight className="h-4 w-4 mr-1" />
)}
{variable.name || `Переменная ${varIndex + 1}`}
</button>
<div className="flex-1" />
<button
onClick={() => removeVariable(varIndex)}
className="text-destructive hover:text-destructive/80"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
{expandedVariables.has(varIndex) && (
<div className="space-y-3 pl-3 border-l-2 border-border/30">
{/* Variable Name */}
<label className="flex flex-col gap-1">
<span className="text-xs font-medium text-muted-foreground">
Имя переменной <span className="text-destructive">*</span>
</span>
<TextInput
placeholder="gender"
value={variable.name}
onChange={(e) => updateVariable(varIndex, { name: e.target.value })}
className="text-xs"
/>
<span className="text-[10px] text-muted-foreground">
Используйте: <code className="bg-muted px-1 py-0.5 rounded">{`{{${variable.name || "variableName"}}}`}</code>
</span>
</label>
{/* Mappings */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">Правила подстановки</span>
<button
onClick={() => addMapping(varIndex)}
className="flex items-center gap-1 rounded-lg border border-border bg-background px-2 py-1 text-[10px] font-medium text-foreground hover:bg-accent"
>
<Plus className="h-3 w-3" />
Добавить правило
</button>
</div>
{variable.mappings.map((mapping, mapIndex) => {
const isExpanded = isMappingExpanded(varIndex, mapIndex);
const condition = mapping.conditions[0];
const conditionPreview = condition ? `${condition.screenId}: ${(condition.values || []).slice(0, 2).join(", ")}${(condition.values || []).length > 2 ? "..." : ""}` : "Нет условия";
return (
<div
key={mapIndex}
className="rounded border border-border/30 bg-background/30 p-2 space-y-2"
>
{/* Mapping Header - всегда видимый */}
<div className="flex items-center gap-2">
<button
onClick={() => toggleMapping(varIndex, mapIndex)}
className="flex items-center flex-1 text-[10px] text-foreground hover:text-foreground/80 min-w-0"
>
{isExpanded ? (
<ChevronDown className="h-3 w-3 mr-1 flex-shrink-0" />
) : (
<ChevronRight className="h-3 w-3 mr-1 flex-shrink-0" />
)}
<span className="truncate">
{conditionPreview} <strong>{mapping.value || "(пусто)"}</strong>
</span>
</button>
<button
onClick={() => removeMapping(varIndex, mapIndex)}
className="text-destructive hover:text-destructive/80 flex-shrink-0"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
{/* Mapping Content - сворачиваемое */}
{isExpanded && (
<div className="space-y-2 pl-4 border-l-2 border-border/20">
{/* Condition - используем интуитивный селектор */}
{mapping.conditions.map((condition, condIndex) => (
<div key={condIndex} className="border border-border/20 rounded-md p-2 bg-background/20">
<VariableMappingConditionEditor
condition={condition}
allScreens={availableScreens}
onChange={(updated) =>
updateCondition(varIndex, mapIndex, condIndex, updated)
}
/>
</div>
))}
{/* Mapping Value */}
<label className="flex flex-col gap-1">
<span className="text-[10px] text-muted-foreground">Подставить значение</span>
<TextInput
placeholder="женщин"
value={mapping.value}
onChange={(e) => updateMapping(varIndex, mapIndex, { value: e.target.value })}
className="text-xs"
/>
</label>
</div>
)}
</div>
);
})}
</div>
{/* Fallback */}
<label className="flex flex-col gap-1">
<span className="text-xs font-medium text-muted-foreground">
Значение по умолчанию (fallback)
</span>
<TextInput
placeholder="людей"
value={variable.fallback || ""}
onChange={(e) => updateVariable(varIndex, { fallback: e.target.value })}
className="text-xs"
/>
<span className="text-[10px] text-muted-foreground">
Используется если ни одно условие не сработало
</span>
</label>
</div>
)}
</div>
))}
</div>
</div>
);
}

View File

@ -4,3 +4,4 @@ export { CouponScreenConfig } from "./CouponScreenConfig";
export { FormScreenConfig } from "./FormScreenConfig";
export { ListScreenConfig } from "./ListScreenConfig";
export { TemplateConfig } from "./TemplateConfig";
export { TrialPaymentScreenConfig } from "./TrialPaymentScreenConfig";

View File

@ -0,0 +1,47 @@
"use client";
import Script from "next/script";
interface FacebookPixelsProps {
pixels?: string[];
}
/**
* Facebook Pixel Integration Component
*
* Loads Facebook pixel tracking scripts dynamically based on pixel IDs
* received from the backend. Each pixel is initialized with PageView tracking.
*
* @param pixels - Array of Facebook pixel IDs to load
*/
export function FacebookPixels({ pixels }: FacebookPixelsProps) {
if (!pixels || pixels.length === 0) {
return null;
}
return (
<>
{pixels.map((pixelId) => (
<Script
key={pixelId}
id={`fb-pixel-${pixelId}`}
strategy="afterInteractive"
dangerouslySetInnerHTML={{
__html: `
!function(f,b,e,v,n,t,s)
{if(f.fbq)return;n=f.fbq=function(){n.callMethod?
n.callMethod.apply(n,arguments):n.queue.push(arguments)};
if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
n.queue=[];t=b.createElement(e);t.async=!0;
t.src=v;s=b.getElementsByTagName(e)[0];
s.parentNode.insertBefore(t,s)}(window, document,'script',
'https://connect.facebook.net/en_US/fbevents.js');
fbq('init', '${pixelId}');
fbq('track', 'PageView');
`,
}}
/>
))}
</>
);
}

View File

@ -0,0 +1,47 @@
"use client";
import Script from "next/script";
interface GoogleAnalyticsProps {
measurementId?: string;
}
/**
* Google Analytics Integration Component
*
* Loads Google Analytics (GA4) tracking script dynamically based on measurement ID
* received from the funnel configuration.
*
* Page views are tracked by PageViewTracker component on route changes.
*
* @param measurementId - Google Analytics Measurement ID (e.g., "G-XXXXXXXXXX")
*/
export function GoogleAnalytics({ measurementId }: GoogleAnalyticsProps) {
if (!measurementId) {
return null;
}
return (
<>
<Script
id="google-analytics"
strategy="afterInteractive"
src={`https://www.googletagmanager.com/gtag/js?id=${measurementId}`}
/>
<Script
id="google-analytics-init"
strategy="afterInteractive"
dangerouslySetInnerHTML={{
__html: `
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', '${measurementId}', {
send_page_view: false
});
`,
}}
/>
</>
);
}

View File

@ -0,0 +1,42 @@
"use client";
import { useEffect } from "react";
import { usePathname, useSearchParams } from "next/navigation";
/**
* Page View Tracker Component
*
* Tracks page views in Google Analytics and Yandex Metrika
* when route changes occur (client-side navigation).
*
* Must be included in the app layout or root component.
*/
export function PageViewTracker() {
const pathname = usePathname();
const searchParams = useSearchParams();
useEffect(() => {
const url = pathname + (searchParams?.toString() ? `?${searchParams.toString()}` : "");
// Track page view in Google Analytics
if (typeof window !== "undefined" && typeof window.gtag === "function") {
window.gtag("event", "page_view", {
page_path: url,
page_location: window.location.href,
page_title: document.title,
});
console.log(`[GA] Page view tracked: ${url}`);
}
// Track page view in Yandex Metrika
if (typeof window !== "undefined" && typeof window.ym === "function") {
const counterId = window.__YM_COUNTER_ID__;
if (counterId) {
window.ym(counterId, "hit", url);
console.log(`[YM] Page view tracked: ${url}`);
}
}
}, [pathname, searchParams]);
return null;
}

View File

@ -0,0 +1,65 @@
"use client";
import Script from "next/script";
interface YandexMetrikaProps {
counterId?: string;
}
/**
* Yandex Metrika Integration Component
*
* Loads Yandex Metrika tracking script dynamically based on counter ID
* received from the funnel configuration.
*
* Initializes with: clickmap, trackLinks, accurateTrackBounce, webvisor.
* Page views are tracked by PageViewTracker component on route changes.
*
* @param counterId - Yandex Metrika Counter ID (e.g., "95799066")
*/
export function YandexMetrika({ counterId }: YandexMetrikaProps) {
if (!counterId) {
return null;
}
return (
<>
<Script
id="yandex-metrika"
strategy="afterInteractive"
dangerouslySetInnerHTML={{
__html: `
(function(m,e,t,r,i,k,a){
m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
m[i].l=1*new Date();
for (var j = 0; j < document.scripts.length; j++) {
if (document.scripts[j].src === r) { return; }
}
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)
})(window, document, "script", "https://cdn.jsdelivr.net/npm/yandex-metrica-watch/tag.js", "ym");
// Store counter ID for analyticsService
window.__YM_COUNTER_ID__ = ${counterId};
ym(${counterId}, "init", {
clickmap: true,
trackLinks: true,
accurateTrackBounce: true,
webvisor: true
});
`,
}}
/>
<noscript>
<div>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`https://mc.yandex.ru/watch/${counterId}`}
style={{ position: "absolute", left: "-9999px" }}
alt=""
/>
</div>
</noscript>
</>
);
}

View File

@ -0,0 +1,4 @@
export { FacebookPixels } from "./FacebookPixels";
export { GoogleAnalytics } from "./GoogleAnalytics";
export { YandexMetrika } from "./YandexMetrika";
export { PageViewTracker } from "./PageViewTracker";

View File

@ -14,30 +14,43 @@ import type {
DateScreenDefinition,
} from "@/lib/funnel/types";
import { getZodiacSign } from "@/lib/funnel/zodiac";
import { useSession } from "@/hooks/session/useSession";
import { buildSessionDataFromScreen } from "@/lib/funnel/registrationHelpers";
// Функция для оценки длины пути пользователя на основе текущих ответов
function estimatePathLength(funnel: FunnelDefinition, answers: FunnelAnswers): number {
function estimatePathLength(
funnel: FunnelDefinition,
answers: FunnelAnswers
): number {
const visited = new Set<string>();
let currentScreenId = funnel.meta.firstScreenId || funnel.screens[0]?.id;
// Симулируем прохождение воронки с текущими ответами
while (currentScreenId && !visited.has(currentScreenId)) {
visited.add(currentScreenId);
const currentScreen = funnel.screens.find((s) => s.id === currentScreenId);
if (!currentScreen) break;
const resolvedScreen = resolveScreenVariant(currentScreen, answers);
const nextScreenId = resolveNextScreenId(resolvedScreen, answers, funnel.screens);
const resolvedScreen = resolveScreenVariant(
currentScreen,
answers,
funnel.screens
);
const nextScreenId = resolveNextScreenId(
resolvedScreen,
answers,
funnel.screens
);
// Если достигли конца или зацикливание
if (!nextScreenId || visited.has(nextScreenId)) {
break;
}
currentScreenId = nextScreenId;
}
return visited.size;
}
@ -48,6 +61,9 @@ interface FunnelRuntimeProps {
export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
const router = useRouter();
const { createSession, updateSession } = useSession({
funnelId: funnel.meta.id,
});
const { answers, registerScreen, setAnswers, history } = useFunnelRuntime(
funnel.meta.id
);
@ -66,11 +82,15 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
}, [screenMap, initialScreenId, funnel.screens]);
const currentScreen = useMemo(() => {
return resolveScreenVariant(baseScreen, answers);
}, [baseScreen, answers]);
return resolveScreenVariant(baseScreen, answers, funnel.screens);
}, [baseScreen, answers, funnel.screens]);
const selectedOptionIds = answers[currentScreen.id] ?? [];
useEffect(() => {
createSession();
}, [createSession]);
useEffect(() => {
registerScreen(currentScreen.id);
}, [currentScreen.id, registerScreen]);
@ -108,35 +128,69 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
};
const handleContinue = () => {
const nextScreenId = resolveNextScreenId(currentScreen, answers, funnel.screens);
if (answers[currentScreen.id] && currentScreen.template !== "email") {
// Собираем данные для сессии
const sessionData = buildSessionDataFromScreen(
currentScreen,
answers[currentScreen.id]
);
// Для date экранов с registrationFieldKey НЕ отправляем answers
const shouldSkipAnswers =
currentScreen.template === "date" &&
"dateInput" in currentScreen &&
currentScreen.dateInput?.registrationFieldKey;
updateSession({
...(shouldSkipAnswers
? {}
: {
answers: {
[currentScreen.id]: answers[currentScreen.id],
},
}),
// Добавляем данные с registrationFieldKey
...sessionData,
});
}
const nextScreenId = resolveNextScreenId(
currentScreen,
answers,
funnel.screens
);
goToScreen(nextScreenId);
};
const handleSelectionChange = (ids: string[]) => {
const handleSelectionChange = (ids: string[], skipCheckChanges = false) => {
const prevSelectedIds = selectedOptionIds;
const hasChanged =
skipCheckChanges ||
prevSelectedIds.length !== ids.length ||
prevSelectedIds.some((value, index) => value !== ids[index]);
// Check if this is a single selection list without action button
const shouldAutoAdvance = currentScreen.template === "list" && (() => {
const listScreen = currentScreen as ListScreenDefinition;
const selectionType = listScreen.list.selectionType;
// Простая логика: автопереход если single selection и кнопка отключена
const bottomActionButton = listScreen.bottomActionButton;
const isButtonExplicitlyDisabled = bottomActionButton?.show === false;
return selectionType === "single" && isButtonExplicitlyDisabled && ids.length > 0;
})();
const shouldAutoAdvance =
currentScreen.template === "list" &&
(() => {
const listScreen = currentScreen as ListScreenDefinition;
const selectionType = listScreen.list.selectionType;
// Простая логика: автопереход если single selection и кнопка отключена
const bottomActionButton = listScreen.bottomActionButton;
const isButtonExplicitlyDisabled = bottomActionButton?.show === false;
return (
selectionType === "single" &&
isButtonExplicitlyDisabled &&
ids.length > 0
);
})();
// ПРАВИЛЬНОЕ РЕШЕНИЕ: Автопереход ТОЛЬКО при изменении значения
// Это исключает автопереход при возврате назад, когда компоненты
// Это исключает автопереход при возврате назад, когда компоненты
// восстанавливают состояние и вызывают callbacks без реального изменения
const shouldProceed = hasChanged;
if (!shouldProceed) {
return; // Блокируем программные вызовы useEffect без изменений
}
@ -165,9 +219,10 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
const [monthValue, dayValue] = ids;
const month = parseInt(monthValue ?? "", 10);
const day = parseInt(dayValue ?? "", 10);
const zodiac = Number.isNaN(month) || Number.isNaN(day)
? null
: getZodiacSign(month, day);
const zodiac =
Number.isNaN(month) || Number.isNaN(day)
? null
: getZodiacSign(month, day);
if (zodiac) {
setAnswers(storageKey, [zodiac]);
@ -182,7 +237,21 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
// Auto-advance for single selection without action button
if (shouldAutoAdvance) {
const nextScreenId = resolveNextScreenId(currentScreen, nextAnswers, funnel.screens);
// Собираем данные для сессии
const sessionData = buildSessionDataFromScreen(currentScreen, ids);
updateSession({
answers: {
[currentScreen.id]: ids,
},
// Добавляем данные с registrationFieldKey если они есть
...sessionData,
});
const nextScreenId = resolveNextScreenId(
currentScreen,
nextAnswers,
funnel.screens
);
goToScreen(nextScreenId);
}
};
@ -206,6 +275,7 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
const canGoBack = historyWithCurrent.lastIndexOf(currentScreen.id) > 0;
return renderScreen({
funnel,
screen: currentScreen,
selectedOptionIds,
onSelectionChange: handleSelectionChange,
@ -214,5 +284,6 @@ export function FunnelRuntime({ funnel, initialScreenId }: FunnelRuntimeProps) {
onBack,
screenProgress,
defaultTexts: funnel.defaultTexts,
answers,
});
}

View File

@ -97,6 +97,7 @@ export function DateTemplate({
as="p"
size="sm"
color="muted"
align="center"
className="font-medium"
>
{screen.dateInput?.selectedDateLabel || "Выбранная дата:"}
@ -106,6 +107,7 @@ export function DateTemplate({
size="xl"
weight="bold"
color="default"
align="center"
className="font-semibold"
>
{formattedDate}
@ -124,7 +126,7 @@ export function DateTemplate({
disabled: !isFormValid,
onClick: onContinue,
},
childrenUnderButton: selectedDateDisplay,
childrenAboveButton: selectedDateDisplay,
}
);

View File

@ -23,8 +23,17 @@ const meta: Meta<typeof EmailTemplate> = {
screenProgress: { current: 9, total: 10 },
defaultTexts: {
nextButton: "Next",
},
funnel: {
meta: {
id: "test-funnel",
title: "Test Funnel",
},
screens: [],
},
selectedEmail: "",
onEmailChange: fn(),
answers: {},
},
argTypes: {
screen: {

View File

@ -4,20 +4,29 @@ import { useState, useEffect } from "react";
import Image from "next/image";
import PrivacySecurityBanner from "@/components/widgets/PrivacySecurityBanner/PrivacySecurityBanner";
import { TextInput } from "@/components/ui/TextInput/TextInput";
import type { EmailScreenDefinition, DefaultTexts } from "@/lib/funnel/types";
import type {
EmailScreenDefinition,
DefaultTexts,
FunnelDefinition,
FunnelAnswers,
} from "@/lib/funnel/types";
import { buildRegistrationDataFromAnswers } from "@/lib/funnel/registrationHelpers";
import { TemplateLayout } from "../layouts/TemplateLayout";
import { createTemplateLayoutProps } from "@/lib/funnel/templateHelpers";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Spinner } from "@/components/ui/spinner";
import { useAuth } from "@/hooks/auth/useAuth";
const formSchema = z.object({
email: z.string().email({
email: z.email({
message: "Please enter a valid email address",
}),
});
interface EmailTemplateProps {
funnel: FunnelDefinition;
screen: EmailScreenDefinition;
selectedEmail: string;
onEmailChange: (email: string) => void;
@ -26,9 +35,11 @@ interface EmailTemplateProps {
onBack: () => void;
screenProgress?: { current: number; total: number };
defaultTexts?: DefaultTexts;
answers: FunnelAnswers;
}
export function EmailTemplate({
funnel,
screen,
selectedEmail,
onEmailChange,
@ -37,9 +48,18 @@ export function EmailTemplate({
onBack,
screenProgress,
defaultTexts,
answers,
}: EmailTemplateProps) {
// Собираем данные для регистрации из ответов воронки
const registrationData = buildRegistrationDataFromAnswers(funnel, answers);
const { authorization, isLoading, error } = useAuth({
funnelId: funnel?.meta?.id ?? "preview",
registrationData,
});
const [isTouched, setIsTouched] = useState(false);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
@ -58,6 +78,23 @@ export function EmailTemplate({
onEmailChange(value);
};
const handleContinue = async () => {
const email = form.getValues("email");
if (!email || !form.formState.isValid || isLoading) {
return;
}
try {
const token = await authorization(email);
if (token) {
onContinue();
}
} catch (err) {
console.error("Authorization failed:", err);
}
};
const isFormValid = form.formState.isValid && form.getValues("email");
const layoutProps = createTemplateLayoutProps(
@ -67,9 +104,10 @@ export function EmailTemplate({
{
preset: "center",
actionButton: {
children: isLoading ? <Spinner className="size-6" /> : undefined,
defaultText: defaultTexts?.nextButton || "Continue",
disabled: !isFormValid,
onClick: onContinue,
onClick: handleContinue,
},
}
);
@ -87,9 +125,10 @@ export function EmailTemplate({
setIsTouched(true);
form.trigger("email");
}}
aria-invalid={isTouched && !!form.formState.errors.email}
aria-invalid={(isTouched && !!form.formState.errors.email) || !!error}
aria-errormessage={
isTouched ? form.formState.errors.email?.message : undefined
(isTouched ? form.formState.errors.email?.message : undefined) ||
(error ? "Something went wrong" : undefined)
}
/>
@ -97,16 +136,18 @@ export function EmailTemplate({
<Image
src={screen.image.src}
alt="portrait"
width={164}
height={245}
className="mt-3.5 rounded-[50px] blur-sm"
width={164}
height={245}
className="mt-3.5 rounded-[50px] blur-sm"
/>
)}
<PrivacySecurityBanner
<PrivacySecurityBanner
className="mt-[26px]"
text={{
children: defaultTexts?.privacyBanner || "Мы не передаем личную информацию, она остаётся в безопасности и под вашим контролем."
text={{
children:
defaultTexts?.privacyBanner ||
"Мы не передаем личную информацию, она остаётся в безопасности и под вашим контролем.",
}}
/>
</div>

View File

@ -132,11 +132,7 @@ export const WithoutSubtitle: Story = {
args: {
screen: {
...richFormScreen,
subtitle: {
...richFormScreen.subtitle,
show: false,
text: richFormScreen.subtitle?.text || "",
},
subtitle: undefined, // Просто удаляем subtitle
},
},
};

View File

@ -2,10 +2,11 @@
import { useMemo } from "react";
import Image from "next/image";
import type { InfoScreenDefinition, DefaultTexts } from "@/lib/funnel/types";
import type { InfoScreenDefinition, DefaultTexts, FunnelAnswers } from "@/lib/funnel/types";
import { TemplateLayout } from "../layouts/TemplateLayout";
import { cn } from "@/lib/utils";
import { createTemplateLayoutProps } from "@/lib/funnel/templateHelpers";
import { substituteVariables } from "@/lib/funnel/variableSubstitution";
interface InfoTemplateProps {
screen: InfoScreenDefinition;
@ -14,6 +15,7 @@ interface InfoTemplateProps {
onBack: () => void;
screenProgress?: { current: number; total: number };
defaultTexts?: DefaultTexts;
answers: FunnelAnswers;
}
export function InfoTemplate({
@ -23,9 +25,29 @@ export function InfoTemplate({
onBack,
screenProgress,
defaultTexts,
answers,
}: InfoTemplateProps) {
// Подставляем переменные в title и subtitle
const processedScreen = useMemo(() => {
if (!screen.variables || screen.variables.length === 0) {
return screen;
}
return {
...screen,
title: {
...screen.title,
text: substituteVariables(screen.title.text, screen.variables, answers),
},
subtitle: screen.subtitle ? {
...screen.subtitle,
text: substituteVariables(screen.subtitle.text, screen.variables, answers),
} : screen.subtitle,
};
}, [screen, answers]);
const iconSizeClasses = useMemo(() => {
const size = screen.icon?.size ?? "xl";
const size = processedScreen.icon?.size ?? "xl";
switch (size) {
case "sm":
return "text-4xl";
@ -37,7 +59,7 @@ export function InfoTemplate({
default:
return "text-8xl";
}
}, [screen.icon?.size]);
}, [processedScreen.icon?.size]);
// Функция для проверки валидности URL
const isValidUrl = (value: string): boolean => {
@ -53,15 +75,16 @@ export function InfoTemplate({
};
// Создаем иконку для передачи в childrenAboveTitle
const iconElement = screen.icon ? (
<div className={cn("mb-8", screen.icon.className)}>
{screen.icon.type === "emoji" ? (
const iconElement = processedScreen.icon ? (
<div className={cn("mb-8", processedScreen.icon.className)}>
{/* Если type не указан, определяем автоматически: URL = image, иначе emoji */}
{(processedScreen.icon.type === "emoji" || (!processedScreen.icon.type && !isValidUrl(processedScreen.icon.value))) ? (
<div className={cn(iconSizeClasses, "leading-none")}>
{screen.icon.value}
{processedScreen.icon.value}
</div>
) : (screen.icon.value && isValidUrl(screen.icon.value)) ? (
) : (processedScreen.icon.value && isValidUrl(processedScreen.icon.value)) ? (
<Image
src={screen.icon.value}
src={processedScreen.icon.value}
alt=""
width={
iconSizeClasses.includes("text-8xl") ? 128 :
@ -74,12 +97,12 @@ export function InfoTemplate({
iconSizeClasses.includes("text-5xl") ? 48 : 36
}
className={cn("object-contain")}
unoptimized={screen.icon.value.startsWith('/api/images/')}
unoptimized={processedScreen.icon.value.startsWith('/api/images/')}
onError={(e) => {
console.error('Preview image load error:', screen.icon?.value, e);
console.error('Preview image load error:', processedScreen.icon?.value, e);
}}
onLoad={() => {
console.log('Preview image loaded successfully:', screen.icon?.value);
console.log('Preview image loaded successfully:', processedScreen.icon?.value);
}}
/>
) : (
@ -91,7 +114,7 @@ export function InfoTemplate({
) : null;
const layoutProps = createTemplateLayoutProps(
screen,
processedScreen,
{ canGoBack, onBack },
screenProgress,
{
@ -111,7 +134,7 @@ export function InfoTemplate({
<div className="w-full flex justify-center">
<div className={cn(
"w-full max-w-[320px] text-center",
screen.icon ? "mt-[30px]" : "mt-[60px]"
processedScreen.icon ? "mt-[30px]" : "mt-[60px]"
)}>
{/* Дополнительный контент если нужен */}
</div>

View File

@ -12,10 +12,13 @@ import type { ListScreenDefinition } from "@/lib/funnel/types";
import { TemplateLayout } from "../layouts/TemplateLayout";
import { createTemplateLayoutProps } from "@/lib/funnel/templateHelpers";
interface ListTemplateProps {
export interface ListTemplateProps {
screen: ListScreenDefinition;
selectedOptionIds: string[];
onSelectionChange: (selectedIds: string[]) => void;
onSelectionChange: (
selectedIds: string[],
skipCheckChanges?: boolean
) => void;
actionButtonProps?: ActionButtonProps;
canGoBack: boolean;
onBack: () => void;
@ -39,7 +42,8 @@ export function ListTemplate({
screenProgress,
}: ListTemplateProps) {
const buttons = useMemo(
() => mapListOptionsToButtons(screen.list.options, screen.list.selectionType),
() =>
mapListOptionsToButtons(screen.list.options, screen.list.selectionType),
[screen.list.options, screen.list.selectionType]
);
@ -63,27 +67,28 @@ export function ListTemplate({
? buttons.filter((button) => selectionSet.has(String(button.id)))
: null;
const handleRadioChange: RadioAnswersListProps["onChangeSelectedAnswer"] = (
const handleRadioAnswerClick: RadioAnswersListProps["onAnswerClick"] = (
answer
) => {
const id = stringId(answer?.id);
onSelectionChange(id ? [id] : []);
onSelectionChange(id ? [id] : [], true);
};
const handleSelectChange: SelectAnswersListProps["onChangeSelectedAnswers"] = (
answers
) => {
const ids = answers
?.map((answer) => stringId(answer.id))
.filter((value): value is string => Boolean(value));
const handleSelectChange: SelectAnswersListProps["onChangeSelectedAnswers"] =
(answers) => {
const ids = answers
?.map((answer) => stringId(answer.id))
.filter((value): value is string => Boolean(value));
onSelectionChange(ids ?? []);
};
onSelectionChange(ids ?? []);
};
const radioContent: RadioAnswersListProps = {
answers: buttons,
activeAnswer,
onChangeSelectedAnswer: handleRadioChange,
// Не передаем onChangeSelectedAnswer чтобы избежать двойного вызова при клике
// onAnswerClick достаточно для обработки выбора
onAnswerClick: handleRadioAnswerClick,
};
const selectContent: SelectAnswersListProps = {
@ -92,16 +97,20 @@ export function ListTemplate({
onChangeSelectedAnswers: handleSelectChange,
};
const actionButtonOptions = actionButtonProps ? {
defaultText: actionButtonProps.children as string || "Next",
// Кнопка неактивна если: 1) disabled из props ИЛИ 2) ничего не выбрано
disabled: actionButtonProps.disabled || selectedOptionIds.length === 0,
onClick: () => {
if (actionButtonProps.onClick) {
actionButtonProps.onClick({} as React.MouseEvent<HTMLButtonElement>);
const actionButtonOptions = actionButtonProps
? {
defaultText: (actionButtonProps.children as string) || "Next",
// Кнопка неактивна если: 1) disabled из props ИЛИ 2) ничего не выбрано
disabled: actionButtonProps.disabled || selectedOptionIds.length === 0,
onClick: () => {
if (actionButtonProps.onClick) {
actionButtonProps.onClick(
{} as React.MouseEvent<HTMLButtonElement>
);
}
},
}
},
} : undefined;
: undefined;
const layoutProps = createTemplateLayoutProps(
screen,

View File

@ -1,114 +1,64 @@
import { Meta, StoryObj } from "@storybook/nextjs-vite";
import { SoulmatePortraitTemplate } from "./SoulmatePortraitTemplate";
import { fn } from "storybook/test";
import { buildSoulmateDefaults } from "@/lib/admin/builder/state/defaults/soulmate";
import type { SoulmatePortraitScreenDefinition } from "@/lib/funnel/types";
// Получаем дефолтные значения из builder
const defaultScreen = buildSoulmateDefaults("soulmate-screen-story") as SoulmatePortraitScreenDefinition;
const defaultScreen: SoulmatePortraitScreenDefinition = {
id: "soulmate-story",
template: "soulmate",
header: { show: false, showBackButton: false },
title: { text: "Soulmate Portrait" },
subtitle: { text: "Готов увидеть, кто твоя настоящая Родственная душа?" },
description: {
text: "Готов увидеть, кто твоя настоящая Родственная душа?",
align: "center",
},
soulmatePortraitsDelivered: {
image: "/soulmate-portrait-delivered-male.jpg",
text: {
text: "soulmate portraits delivered today",
font: "inter",
weight: "medium",
size: "sm",
color: "primary",
},
avatars: [
{ src: "/avatars/male-1.jpg", alt: "Male 1" },
{ src: "/avatars/male-2.jpg", alt: "Male 2" },
{ src: "/avatars/male-3.jpg", alt: "Male 3" },
{ src: "", fallbackText: "900+" },
],
},
textList: {
items: [
{
text: "Всего 2 минуты — и Портрет откроет того, кто связан с тобой судьбой.",
},
{ text: "Поразительная точность 99%." },
{ text: "Тебя ждёт неожиданное открытие." },
{ text: "Осталось лишь осмелиться взглянуть." },
],
},
bottomActionButton: { text: "Continue", showPrivacyTermsConsent: true },
};
/** SoulmatePortraitTemplate - результирующие экраны с портретом партнера */
const meta: Meta<typeof SoulmatePortraitTemplate> = {
title: "Funnel Templates/SoulmatePortraitTemplate",
component: SoulmatePortraitTemplate,
tags: ["autodocs"],
parameters: {
layout: "fullscreen",
},
parameters: { layout: "fullscreen" },
args: {
screen: defaultScreen,
onContinue: fn(),
canGoBack: true,
onBack: fn(),
screenProgress: { current: 10, total: 10 }, // Обычно финальный экран
defaultTexts: {
nextButton: "Next",
},
},
argTypes: {
screen: {
control: { type: "object" },
},
screenProgress: {
control: { type: "object" },
},
onContinue: { action: "continue" },
onBack: { action: "back" },
screenProgress: undefined,
defaultTexts: { nextButton: "Next" },
},
argTypes: {},
};
export default meta;
type Story = StoryObj<typeof meta>;
/** Дефолтный soulmate portrait экран */
export const Default: Story = {};
/** Экран без описания */
export const WithoutDescription: Story = {
args: {
screen: {
...defaultScreen,
description: undefined,
},
},
};
/** Экран с кастомным описанием */
export const CustomDescription: Story = {
args: {
screen: {
...defaultScreen,
description: {
text: "На основе ваших ответов мы создали уникальный **портрет вашей второй половинки**. Этот анализ поможет вам лучше понять, кто может стать идеальным партнером.",
font: "inter",
weight: "regular",
align: "center",
size: "md",
color: "default",
},
},
},
};
/** Экран без header */
export const WithoutHeader: Story = {
args: {
screen: {
...defaultScreen,
header: {
show: false,
},
},
},
};
/** Экран без subtitle */
export const WithoutSubtitle: Story = {
args: {
screen: {
...defaultScreen,
subtitle: {
...defaultScreen.subtitle,
show: false,
text: defaultScreen.subtitle?.text || "",
},
},
},
};
/** Финальный экран (без прогресса) */
export const FinalScreen: Story = {
args: {
screen: {
...defaultScreen,
header: {
show: true,
showBackButton: false, // На финальном экране обычно нет кнопки назад
showProgress: false, // И нет прогресса
},
},
screenProgress: undefined,
canGoBack: false,
},
};

View File

@ -1,8 +1,15 @@
"use client";
import type { SoulmatePortraitScreenDefinition, DefaultTexts } from "@/lib/funnel/types";
import type {
SoulmatePortraitScreenDefinition,
DefaultTexts,
} from "@/lib/funnel/types";
import { TemplateLayout } from "../layouts/TemplateLayout";
import { createTemplateLayoutProps } from "@/lib/funnel/templateHelpers";
import Typography from "@/components/ui/Typography/Typography";
import { buildTypographyProps } from "@/lib/funnel/mappers";
import SoulmatePortraitsDelivered from "@/components/widgets/SoulmatePortraitsDelivered/SoulmatePortraitsDelivered";
import { cn } from "@/lib/utils";
interface SoulmatePortraitTemplateProps {
screen: SoulmatePortraitScreenDefinition;
@ -21,14 +28,26 @@ export function SoulmatePortraitTemplate({
screenProgress,
defaultTexts,
}: SoulmatePortraitTemplateProps) {
// Скрываем subtitle как ненужный для этого экрана
const screenForLayout: SoulmatePortraitScreenDefinition = {
...screen,
subtitle: undefined,
};
const layoutProps = createTemplateLayoutProps(
screen,
screenForLayout,
{ canGoBack, onBack },
screenProgress,
{
preset: "center",
titleDefaults: { font: "manrope", weight: "bold", align: "center", size: "xl", color: "primary" },
subtitleDefaults: { font: "manrope", weight: "medium", color: "default", align: "left", size: "lg" },
titleDefaults: {
font: "manrope",
weight: "bold",
align: "center",
size: "xl",
className: "leading-[125%] text-primary text-xl",
},
subtitleDefaults: undefined,
actionButton: {
defaultText: defaultTexts?.nextButton || "Continue",
disabled: false,
@ -39,7 +58,91 @@ export function SoulmatePortraitTemplate({
return (
<TemplateLayout {...layoutProps}>
<div className="-mt-[20px]">
<div className="max-w-[560px] mx-auto flex flex-col items-center gap-[30px]">
{screen.soulmatePortraitsDelivered && (
<SoulmatePortraitsDelivered
image={screen.soulmatePortraitsDelivered.image}
textProps={
screen.soulmatePortraitsDelivered.text
? buildTypographyProps(screen.soulmatePortraitsDelivered.text, {
as: "p",
defaults: { font: "inter", size: "sm", color: "primary" },
})
: undefined
}
avatarsProps={
screen.soulmatePortraitsDelivered.avatars
? {
avatars: screen.soulmatePortraitsDelivered.avatars.map(
(a) => ({
imageProps: a.src
? { src: a.src, alt: a.alt ?? "" }
: undefined,
fallbackProps: a.fallbackText
? {
children: (
<Typography
size="xs"
weight="bold"
className="text-[#FF6B9D]"
>
{a.fallbackText}
</Typography>
),
className: "bg-background",
}
: undefined,
className: a.fallbackText ? "w-fit px-1" : undefined,
})
),
}
: undefined
}
/>
)}
<div className="w-full flex flex-col items-center gap-2.5">
{screen.description &&
(() => {
const descProps = buildTypographyProps(screen.description, {
as: "p",
defaults: {
align: "center",
font: "inter",
size: "md",
weight: "bold",
className: "text-[25px] font-bold",
},
});
if (!descProps) return null;
const { children, ...rest } = descProps;
return (
<Typography {...rest} enableMarkup>
{children}
</Typography>
);
})()}
{screen.textList && (
<ul className={cn("list-disc pl-6 w-full")}>
{screen.textList.items.map((item, index) => {
const itemProps = buildTypographyProps(item, {
as: "li",
defaults: { font: "inter", weight: "medium", size: "md" },
});
if (!itemProps) return null;
const { children, ...rest } = itemProps;
return (
<Typography
key={index}
{...rest}
className={cn("list-item text-[17px] leading-[26px]")}
>
{children}
</Typography>
);
})}
</ul>
)}
</div>
</div>
</TemplateLayout>
);

View File

@ -0,0 +1,266 @@
import { Meta, StoryObj } from "@storybook/nextjs-vite";
import { TrialPaymentTemplate } from "./TrialPaymentTemplate";
import { fn } from "storybook/test";
import type { TrialPaymentScreenDefinition } from "@/lib/funnel/types";
const defaultScreen: TrialPaymentScreenDefinition = {
id: "trial-payment-screen-story",
template: "trialPayment",
title: { text: "" },
subtitle: { text: "" },
bottomActionButton: { show: false, showPrivacyTermsConsent: false },
headerBlock: {
timerSeconds: 600,
text: { text: "⚠️ Your sketch expires soon!" },
timer: { text: "" },
},
unlockYourSketch: {
title: { text: "Unlock Your Sketch" },
subtitle: { text: "Just One Click to Reveal Your Match!" },
image: { src: "/trial-payment/portrait-female.jpg" },
blur: { text: { text: "Unlock to reveal your personalized portrait" }, icon: "lock" },
buttonText: "Get Me Soulmate Sketch",
},
joinedToday: {
count: { text: "954" },
text: { text: "Joined today" },
},
trustedByOver: {
text: { text: "Trusted by over 355,000 people." },
},
findingOneGuide: {
header: {
emoji: { text: "❤️" },
title: { text: "Finding the One Guide" },
},
text: {
text:
"You're not just looking for someone — you're. You're not just looking for someone — you'reYou're not just looking for someone — you'reYou're not just looking for someone — you'reYou're not just looking for someone — you're. You're not just looking for someone — you're. You're not just looking for someone — you'reYou're not just looking for someone — you'reYou're not just looking for someone — you'reYou're not just looking for someone — you're",
},
blur: { text: { text: "Чтобы открыть весь отчёт, нужен полный доступ." }, icon: "lock" },
},
tryForDays: {
title: { text: "Попробуйте в течение 7 дней!" },
textList: {
items: [
{ text: "Receive a hand-drawn sketch of your soulmate, crafted by a trained AI-model." },
{ text: "Reveal the path to your soulmate with the Finding the One guide." },
{ text: "Talk to live experts and get guidance on finding your soulmate." },
{ text: "Start your 7-day trial for just $1.00 — then only $14.50/week for full access." },
{ text: "Cancel anytime—just 24 hours before renewal." },
],
},
},
totalPrice: {
couponContainer: {
title: { text: "Coupon\nCode" },
buttonText: "SOULMATE94",
},
priceContainer: {
title: { text: "Total" },
price: { text: "$1.00" },
oldPrice: { text: "$14.99" },
discount: { text: "94% discount applied" },
},
},
paymentButtons: {
buttons: [
{ text: "Pay", icon: "pay" },
{ text: "Pay", icon: "google" },
{ text: "Credit or debit card", icon: "card", primary: true },
],
},
moneyBackGuarantee: {
title: { text: "30-DAY MONEY-BACK GUARANTEE" },
text: { text: "If you don't receive your soulmate sketch, we'll refund your money!" },
},
policy: {
text: { text: "By clicking Continue, you agree to our Terms of Use & Service and Privacy Policy. You also acknowledge that your 1 week introductory plan to Respontika, billed at $1.00, will automatically renew at $14.50 every 1 week unless canceled before the end of the trial period." },
},
usersPortraits: {
title: { text: "Our Users' Soulmate Portraits" },
images: [
{ src: "/trial-payment/users-portraits/1.jpg" },
{ src: "/trial-payment/users-portraits/2.jpg" },
{ src: "/trial-payment/users-portraits/3.jpg" },
],
buttonText: "Get me soulmate sketch",
},
joinedTodayWithAvatars: {
count: { text: "954" },
text: { text: "people joined today" },
avatars: {
images: [
{ src: "/trial-payment/avatars/1.jpg" },
{ src: "/trial-payment/avatars/2.jpg" },
{ src: "/trial-payment/avatars/3.jpg" },
{ src: "/trial-payment/avatars/4.jpg" },
{ src: "/trial-payment/avatars/5.jpg" },
],
},
},
progressToSeeSoulmate: {
title: { text: "See Your Soulmate Just One Step Away" },
progress: { value: 92 },
leftText: { text: "Step 2 of 5" },
rightText: { text: "99% Complete" },
},
stepsToSeeSoulmate: {
steps: [
{
title: { text: "Questions Answered" },
description: { text: "You've provided all the necessary information about your preferences and personality." },
icon: "questions",
isActive: true,
},
{
title: { text: "Profile Analysis" },
description: { text: "Our advanced system is creating your perfect soulmate profile." },
icon: "profile",
isActive: true,
},
{
title: { text: "Sketch Creation" },
description: { text: "Your personalized soulmate sketch will be created." },
icon: "sketch",
isActive: false,
},
{
title: { text: "Астрологические Идеи" },
description: { text: "Уникальные астрологические рекомендации, усиливающие совместимость." },
icon: "astro",
isActive: false,
},
{
title: { text: "Персонализированный чат с экспертом" },
description: { text: "Персональные советы от экспертов по отношениям." },
icon: "chat",
isActive: false,
},
],
buttonText: "Show Me My Soulmate",
},
reviews: {
title: { text: "Loved and Trusted Worldwide" },
items: [
{
name: { text: "Jennifer Wilson 🇺🇸" },
text: { text: "**“Я увидела свои ошибки… и нашла мужа”**\nПортрет сразу зацепил — было чувство, что я уже где-то его видела. Но настоящий перелом произошёл после гайда: я поняла, почему снова и снова выбирала «не тех». И самое удивительное — вскоре я познакомилась с мужчиной, который оказался точной копией того самого портрета. Сейчас он мой муж, и когда мы сравнили рисунок с его фото, сходство было просто вау." },
avatar: { src: "/trial-payment/reviews/avatars/1.jpg" },
portrait: { src: "/trial-payment/reviews/portraits/1.jpg" },
photo: { src: "/trial-payment/reviews/photos/1.jpg" },
rating: 5,
date: { text: "1 day ago" },
},
{
name: { text: "Amanda Davis 🇨🇦" },
text: { text: "**“Я поняла своего партнёра лучше за один вечер, чем за несколько лет”**\nПрошла тест ради интереса — портрет нас удивил. Но настоящий прорыв случился, когда я прочитала гайд о второй половинке. Там были точные подсказки о том, как мы можем поддерживать друг друга. Цена смешная, а ценность огромная: теперь у нас меньше недопониманий и больше тепла." },
avatar: { src: "/trial-payment/reviews/avatars/2.jpg" },
portrait: { src: "/trial-payment/reviews/portraits/2.jpg" },
photo: { src: "/trial-payment/reviews/photos/2.jpg" },
rating: 5,
date: { text: "4 days ago" },
},
{
name: { text: "Michael Johnson 🇬🇧" },
text: { text: "**“Увидел её лицо — и мурашки по коже”**\nКогда пришёл результат теста и показали портрет, я реально замер. Это была та самая девушка, с которой я начал встречаться пару недель назад. И гайд прямо описал, почему мы тянемся друг к другу. Честно, я не ожидал такого совпадения." },
avatar: { src: "/trial-payment/reviews/avatars/3.jpg" },
portrait: { src: "/trial-payment/reviews/portraits/3.jpg" },
photo: { src: "/trial-payment/reviews/photos/3.jpg" },
rating: 5,
date: { text: "1 week ago" },
},
],
},
commonQuestions: {
title: { text: "Common Questions" },
items: [
{
question: "When will I receive my sketch?",
answer:
"Your personalized soulmate sketch will be delivered within 24-48 hours after completing your order. You'll receive an email notification when it's ready for viewing in your account.",
},
{
question: "How do I cancel my subscription?",
answer:
"You can cancel anytime from your account settings. Make sure to cancel at least 24 hours before the renewal date to avoid being charged.",
},
{
question: "How accurate are the readings?",
answer:
"Our readings are based on a combination of your answers and advanced pattern analysis. While they provide valuable insights, they are intended for guidance and entertainment purposes.",
},
{
question: "Is my data secure and private?",
answer:
"Yes. We follow strict data protection standards. Your data is encrypted and never shared with third parties without your consent.",
},
],
},
stillHaveQuestions: {
title: { text: "Still have questions? We're here to help!" },
actionButtonText: "Get me Soulmate Sketch",
contactButtonText: "Contact Support",
},
footer: {
title: { text: "WIT LAB ©" },
contacts: {
title: { text: "CONTACTS" },
email: { href: "support@witlab.com", text: "support@witlab.com" },
address: { text: "Wit Lab 2108 N ST STE N SACRAMENTO, CA95816, US" },
},
legal: {
title: { text: "LEGAL" },
links: [
{ href: "https://witlab.com/terms", text: "Terms of Service" },
{ href: "https://witlab.com/privacy", text: "Privacy Policy" },
{ href: "https://witlab.com/refund", text: "Refund Policy" },
],
copyright: {
text:
"Copyright © 2025 Wit Lab™. All rights reserved. All trademarks referenced herein are the properties of their respective owners.",
},
},
paymentMethods: {
title: { text: "PAYMENT METHODS" },
methods: [
{ src: "/trial-payment/payment-methods/visa.svg", alt: "visa" },
{ src: "/trial-payment/payment-methods/mastercard.svg", alt: "mastercard" },
{ src: "/trial-payment/payment-methods/discover.svg", alt: "discover" },
{ src: "/trial-payment/payment-methods/apple.svg", alt: "apple" },
{ src: "/trial-payment/payment-methods/google.svg", alt: "google" },
{ src: "/trial-payment/payment-methods/paypal.svg", alt: "paypal" },
],
},
},
};
const meta: Meta<typeof TrialPaymentTemplate> = {
title: "Funnel Templates/TrialPaymentTemplate",
component: TrialPaymentTemplate,
tags: ["autodocs"],
parameters: {
layout: "fullscreen",
},
args: {
screen: defaultScreen,
onContinue: fn(),
canGoBack: true,
onBack: fn(),
screenProgress: { current: 8, total: 10 },
defaultTexts: {
nextButton: "Continue",
continueButton: "Continue",
},
},
argTypes: {
screen: { control: { type: "object" } },
},
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,3 @@
export { TrialPaymentTemplate } from "./TrialPaymentTemplate";

View File

@ -7,6 +7,7 @@ export { EmailTemplate } from "./EmailTemplate";
export { CouponTemplate } from "./CouponTemplate";
export { LoadersTemplate } from "./LoadersTemplate";
export { SoulmatePortraitTemplate } from "./SoulmatePortraitTemplate";
export { TrialPaymentTemplate } from "./TrialPaymentTemplate/index";
// Layout Templates
export { TemplateLayout } from "./layouts/TemplateLayout";

View File

@ -10,41 +10,64 @@ import {
buildTemplateBottomActionButtonProps,
} from "@/lib/funnel/mappers";
import type { ScreenDefinition } from "@/lib/funnel/types";
import { cn } from "@/lib/utils";
interface TemplateLayoutProps {
screen: ScreenDefinition;
canGoBack: boolean;
onBack: () => void;
screenProgress?: { current: number; total: number };
// Настройки template
titleDefaults?: {
font?: "manrope" | "inter" | "geistSans" | "geistMono";
weight?: "regular" | "medium" | "semiBold" | "bold" | "extraBold" | "black";
align?: "left" | "center" | "right";
size?: "xs" | "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl";
color?: "default" | "primary" | "secondary" | "destructive" | "success" | "card" | "accent" | "muted";
color?:
| "default"
| "primary"
| "secondary"
| "destructive"
| "success"
| "card"
| "accent"
| "muted";
className?: string;
};
subtitleDefaults?: {
font?: "manrope" | "inter" | "geistSans" | "geistMono";
weight?: "regular" | "medium" | "semiBold" | "bold" | "extraBold" | "black";
color?: "default" | "primary" | "secondary" | "destructive" | "success" | "card" | "accent" | "muted";
color?:
| "default"
| "primary"
| "secondary"
| "destructive"
| "success"
| "card"
| "accent"
| "muted";
align?: "left" | "center" | "right";
size?: "xs" | "sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl";
className?: string;
};
actionButtonOptions?: {
defaultText: string;
disabled: boolean;
onClick: () => void;
};
// Дополнительные props для BottomActionButton
childrenAboveButton?: React.ReactNode;
childrenUnderButton?: React.ReactNode;
// Дополнительные props для Title
childrenAboveTitle?: React.ReactNode;
// Переопределения стилей LayoutQuestion (контент и обертка контента)
contentProps?: React.ComponentProps<"div">;
childrenWrapperProps?: React.ComponentProps<"div">;
// Контент template
children: React.ReactNode;
}
@ -58,18 +81,30 @@ export function TemplateLayout({
canGoBack,
onBack,
screenProgress,
titleDefaults = { font: "manrope", weight: "bold", align: "left", size: "2xl", color: "default" },
subtitleDefaults = { font: "manrope", weight: "medium", color: "default", align: "left", size: "lg" },
titleDefaults = {
font: "manrope",
weight: "bold",
align: "left",
size: "2xl",
color: "default",
},
subtitleDefaults = {
font: "manrope",
weight: "medium",
color: "default",
align: "left",
size: "lg",
},
actionButtonOptions,
childrenAboveButton,
childrenUnderButton,
childrenAboveTitle,
contentProps,
childrenWrapperProps,
children,
}: TemplateLayoutProps) {
// 🎛️ ЦЕНТРАЛИЗОВАННАЯ ЛОГИКА BOTTOM BUTTON
const {
elementRef: bottomActionButtonRef,
} = useDynamicSize<HTMLDivElement>({
const { elementRef: bottomActionButtonRef } = useDynamicSize<HTMLDivElement>({
defaultHeight: 132,
});
@ -92,20 +127,20 @@ export function TemplateLayout({
: undefined;
// 🎯 Автоматически создаем PrivacyTermsConsent с фиксированными настройками
const shouldShowPrivacyTermsConsent =
'bottomActionButton' in screen &&
const shouldShowPrivacyTermsConsent =
"bottomActionButton" in screen &&
screen.bottomActionButton?.showPrivacyTermsConsent === true;
const autoPrivacyTermsConsent = shouldShowPrivacyTermsConsent ? (
<PrivacyTermsConsent
className="mt-5"
<PrivacyTermsConsent
className="mt-2"
privacyPolicy={{
href: "/privacy",
children: "Privacy Policy"
children: "Privacy Policy",
}}
termsOfUse={{
href: "/terms",
children: "Terms of use"
children: "Terms of use",
}}
/>
) : null;
@ -121,16 +156,28 @@ export function TemplateLayout({
// 🎨 ЦЕНТРАЛИЗОВАННЫЙ РЕНДЕРИНГ
return (
<div className="w-full">
<LayoutQuestion {...layoutQuestionProps} childrenAboveTitle={childrenAboveTitle}>
<LayoutQuestion
{...layoutQuestionProps}
childrenAboveTitle={childrenAboveTitle}
contentProps={contentProps}
childrenWrapperProps={childrenWrapperProps}
>
{children}
</LayoutQuestion>
{bottomActionButtonProps && (
<BottomActionButton
{...bottomActionButtonProps}
<BottomActionButton
{...bottomActionButtonProps}
ref={bottomActionButtonRef}
childrenAboveButton={childrenAboveButton}
childrenUnderButton={finalChildrenUnderButton}
gradientBlurProps={
shouldShowPrivacyTermsConsent
? {
className: cn(shouldShowPrivacyTermsConsent && "pb-1"),
}
: undefined
}
/>
)}
</div>

View File

@ -2,7 +2,9 @@
import { cn } from "@/lib/utils";
import { Header } from "@/components/layout/Header/Header";
import Typography, { TypographyProps } from "@/components/ui/Typography/Typography";
import Typography, {
TypographyProps,
} from "@/components/ui/Typography/Typography";
export interface LayoutQuestionProps
extends Omit<React.ComponentProps<"section">, "title" | "content"> {
@ -55,7 +57,7 @@ function LayoutQuestion({
weight="bold"
{...title}
align={title.align ?? "left"}
className={cn(title.className, "w-full text-[25px] leading-[38px]")}
className={cn("w-full text-[25px] leading-[38px]", title.className)}
/>
)}
@ -66,8 +68,8 @@ function LayoutQuestion({
{...subtitle}
align={subtitle.align ?? "left"}
className={cn(
subtitle.className,
"w-full mt-2.5 text-[17px] leading-[26px]"
"w-full mt-2.5 text-[17px] leading-[26px]",
subtitle.className
)}
/>
)}
@ -83,4 +85,4 @@ function LayoutQuestion({
);
}
export { LayoutQuestion };
export { LayoutQuestion };

View File

@ -0,0 +1,87 @@
"use client";
import { useEffect, useState, type ReactNode } from "react";
import { FacebookPixels, GoogleAnalytics, YandexMetrika, PageViewTracker } from "@/components/analytics";
import { getPixels } from "@/entities/session/actions";
import { getSourceByPathname } from "@/shared/utils/source";
interface PixelsProviderProps {
children: ReactNode;
googleAnalyticsId?: string;
yandexMetrikaId?: string;
}
/**
* Pixels Provider Component
*
* Loads tracking scripts for Facebook Pixels, Google Analytics, and Yandex Metrika.
*
* IMPORTANT: This component should be placed in a layout (not in components that re-render on navigation)
* to avoid duplicate API requests. Currently used in app/[funnelId]/layout.tsx
*
* Facebook Pixels are loaded from backend API (cached in localStorage).
* Google Analytics and Yandex Metrika IDs come from funnel configuration.
*
* Flow:
* 1. Check localStorage for cached FB pixels
* 2. If not cached, request from backend (errors are handled gracefully)
* 3. Save to localStorage if pixels received
* 4. Render GA and YM if IDs provided in funnel config
*/
export function PixelsProvider({ children, googleAnalyticsId, yandexMetrikaId }: PixelsProviderProps) {
const [pixels, setPixels] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const loadPixels = async () => {
try {
// Check localStorage first
const cachedPixels = localStorage.getItem("fb_pixels");
if (cachedPixels) {
const parsed = JSON.parse(cachedPixels);
setPixels(parsed);
setIsLoading(false);
return;
}
// Load from backend
const locale = "en"; // TODO: Get from context or config
const source = getSourceByPathname();
const domain = window.location.hostname;
const response = await getPixels({
domain,
source,
locale,
});
const pixelIds = response?.data?.fb || [];
// Save to localStorage only if we got pixels
if (pixelIds.length > 0) {
localStorage.setItem("fb_pixels", JSON.stringify(pixelIds));
}
setPixels(pixelIds);
} catch (error) {
// Silently handle errors - pixels are optional
console.warn("Facebook pixels not available:", error instanceof Error ? error.message : error);
setPixels([]);
} finally {
setIsLoading(false);
}
};
loadPixels();
}, []);
return (
<>
{!isLoading && <FacebookPixels pixels={pixels} />}
<GoogleAnalytics measurementId={googleAnalyticsId} />
<YandexMetrika counterId={yandexMetrikaId} />
<PageViewTracker />
{children}
</>
);
}

View File

@ -4,7 +4,8 @@ import {
AvatarFallback,
} from "../avatar";
interface AvatarProps extends React.ComponentProps<typeof AvatarComponent> {
export interface AvatarProps extends Omit<React.ComponentProps<typeof AvatarComponent>, never> {
className?: string;
imageProps?: React.ComponentProps<typeof AvatarImage>;
fallbackProps?: React.ComponentProps<typeof AvatarFallback>;
}

View File

@ -0,0 +1,82 @@
.list {
position: relative;
width: 100%;
margin-top: 16px;
display: flex;
flex-direction: column;
align-items: center;
/* gap: 32px; */
font-size: 20px;
/* color: #1A6697; */
color: #acacac;
line-height: 25px;
text-align: center;
overflow: hidden;
}
.list > .item {
transition: margin-top 0.5s ease-in-out;
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 100%;
display: block;
background: var(--background);
/* padding: 16px 0; */
overflow: hidden;
opacity: 0;
animation: list-item ease-in-out forwards;
}
.list > .item > .line {
display: block;
height: 100%;
width: 64px;
background: linear-gradient(
to right,
#acacac 0%,
#333333 50%,
#acacac 100%
);
top: 0;
left: 50%;
position: absolute;
mix-blend-mode: color-burn;
filter: blur(3px);
animation: line-move cubic-bezier(0.65, 0, 0.46, 1.02) infinite;
}
.list > .item > .text {
position: relative;
color: #000;
z-index: 1;
}
@keyframes line-move {
0% {
left: -64px;
}
100% {
left: 100%;
}
}
@keyframes list-item {
0% {
opacity: 0;
}
10% {
opacity: 1;
}
90% {
opacity: 1;
}
100% {
opacity: 0;
}
}

View File

@ -0,0 +1,61 @@
"use client";
import { useEffect, useRef, useState } from "react";
import styles from "./GPTAnimationText.module.css";
interface GPTAnimationTextProps {
points: Array<string>;
totalAnimationTime: number;
}
function GPTAnimationText({
points,
totalAnimationTime,
}: GPTAnimationTextProps) {
const listRef = useRef<Array<HTMLParagraphElement | null>>([]);
const [listHeight, setListHeight] = useState(0);
useEffect(() => {
let maxHeight = 0;
listRef.current.forEach(item => {
if (item?.offsetHeight && item.offsetHeight > maxHeight) {
maxHeight = item.offsetHeight;
}
});
setListHeight(maxHeight);
}, [listRef]);
return (
<div
className={styles.list}
style={{
height: `${listHeight}px`,
}}
>
{points.map((element, index) => (
<p
key={element}
className={styles.item}
ref={el => {
listRef.current[index] = el;
}}
style={{
animationDuration: `${totalAnimationTime / points.length}ms`,
animationDelay: `${index * (totalAnimationTime / points.length)}ms`,
}}
>
{element}
<span
className={styles.line}
style={{
animationDuration: `${totalAnimationTime / points.length}ms`,
}}
/>
</p>
))}
</div>
);
}
export default GPTAnimationText;

View File

@ -14,9 +14,12 @@ const buttonVariants = cva(
"inline-flex items-center justify-center gap-4",
"font-manrope text-[18px]/[18px] font-medium",
"pl-[26px] pr-[18px] py-[18px]",
"transition-all",
"transition-[background-color,border-color,color]",
"duration-200",
"disabled:opacity-50",
"border-2"
"border-2",
"[-webkit-tap-highlight-color:transparent]",
"[transform:translateZ(0)]"
),
{
variants: {
@ -26,8 +29,7 @@ const buttonVariants = cva(
},
active: {
true: "bg-gradient-to-r from-[#EBF5FF] to-[#DBEAFE] border-primary shadow-blue-glow-2 text-primary",
false:
"bg-background border-border shadow-black-glow text-black",
false: "bg-background border-border shadow-black-glow text-black",
},
},
defaultVariants: {
@ -61,6 +63,16 @@ function MainButton({
data-slot="main-button"
className={cn(buttonVariants({ cornerRadius, active, className }))}
{...props}
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
if (disabled) return;
const targetEl = e.target as HTMLElement | null;
const isCheckboxTarget = targetEl?.closest('[data-slot="checkbox"]');
if (isCheckboxTarget) {
return;
}
e.preventDefault();
props.onClick?.(e);
}}
asChild
>
<Label
@ -78,7 +90,13 @@ function MainButton({
{...checkboxProps}
checked={active ?? false}
disabled={disabled}
onClick={(e) => e.stopPropagation()}
onClick={(e) => {
if (disabled) return;
e.stopPropagation();
props.onClick?.(
e as unknown as React.MouseEvent<HTMLButtonElement>
);
}}
/>
)}
</Label>

View File

@ -0,0 +1,86 @@
"use client";
import { cn } from "@/lib/utils";
import { useId } from "react";
import Typography from "../Typography/Typography";
type Option = {
value: string | number;
label: string;
};
export interface SelectInputProps extends React.ComponentProps<"select"> {
error?: boolean;
options: Option[];
placeholder?: string;
label?: string;
labelProps?: React.ComponentProps<"label">;
errorProps?: React.ComponentProps<typeof Typography>;
}
export default function SelectInput({
error,
options,
placeholder,
label,
labelProps,
errorProps,
...props
}: SelectInputProps) {
const id = useId();
return (
<div className={cn("w-full flex flex-col gap-2")}>
{label && (
<label
htmlFor={id}
className={cn(
"text-muted-foreground font-inter font-medium text-base",
labelProps?.className
)}
{...labelProps}
>
{label}
</label>
)}
<select
id={id}
className={cn(
"cursor-pointer",
"appearance-none",
"w-full min-w-[106px] h-fit! min-h-14",
"px-4 py-3.5",
"font-inter text-[18px]/[28px] font-medium",
// Цвет placeholder когда ничего не выбрано
props.value === "" || props.value === undefined ? "text-placeholder-foreground" : "text-foreground",
"rounded-2xl outline-2 outline-primary/30",
"duration-200",
"disabled:opacity-50 disabled:cursor-not-allowed",
error &&
"outline-destructive focus-visible:shadow-destructive focus-visible:ring-destructive/30"
)}
{...props}
>
{placeholder && (
<option value="" hidden>
{placeholder}
</option>
)}
{options.map((option) => (
<option
key={option.value}
value={option.value}
className="text-foreground"
>
{option.label}
</option>
))}
</select>
{error && (
<Typography size="xs" color="destructive" {...errorProps}>
{errorProps?.children}
</Typography>
)}
</div>
);
}

View File

@ -1,5 +1,10 @@
import { Meta, StoryObj } from "@storybook/nextjs-vite";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "./accordion";
import { Meta } from "@storybook/nextjs-vite";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "./accordion";
/** Reusable Accordion Component */
const meta: Meta<typeof Accordion> = {
@ -12,7 +17,7 @@ const meta: Meta<typeof Accordion> = {
args: {
type: "single",
collapsible: true,
},
} satisfies React.ComponentProps<typeof Accordion>,
argTypes: {
type: {
control: { type: "select" },
@ -22,123 +27,139 @@ const meta: Meta<typeof Accordion> = {
control: { type: "boolean" },
},
},
render: (args) => (
<Accordion {...args} className="w-[400px]">
<AccordionItem value="item-1">
<AccordionTrigger>Is it accessible?</AccordionTrigger>
<AccordionContent>
Yes. It adheres to the WAI-ARIA design pattern.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger>Is it styled?</AccordionTrigger>
<AccordionContent>
Yes. It comes with default styles that matches the other
components&apos; aesthetic.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-3">
<AccordionTrigger>Is it animated?</AccordionTrigger>
<AccordionContent>
Yes. It&apos;s animated by default, but you can disable it if you
prefer.
</AccordionContent>
</AccordionItem>
</Accordion>
),
};
export default meta;
type Story = StoryObj<typeof meta>;
// type Story = StoryObj<typeof meta>;
export const Default = {
render: (args) => (
<Accordion {...args} className="w-[400px]">
<AccordionItem value="item-1">
<AccordionTrigger>Is it accessible?</AccordionTrigger>
<AccordionContent>
Yes. It adheres to the WAI-ARIA design pattern.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger>Is it styled?</AccordionTrigger>
<AccordionContent>
Yes. It comes with default styles that matches the other components&apos; aesthetic.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-3">
<AccordionTrigger>Is it animated?</AccordionTrigger>
<AccordionContent>
Yes. It&apos;s animated by default, but you can disable it if you prefer.
</AccordionContent>
</AccordionItem>
</Accordion>
),
} satisfies Story;
// args: {},
};
export const Multiple = {
args: {
type: "multiple",
},
render: (args) => (
<Accordion {...args} className="w-[400px]">
<AccordionItem value="item-1">
<AccordionTrigger>Is it accessible?</AccordionTrigger>
<AccordionContent>
Yes. It adheres to the WAI-ARIA design pattern.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2">
<AccordionTrigger>Is it styled?</AccordionTrigger>
<AccordionContent>
Yes. It comes with default styles that matches the other components&apos; aesthetic.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-3">
<AccordionTrigger>Is it animated?</AccordionTrigger>
<AccordionContent>
Yes. It&apos;s animated by default, but you can disable it if you prefer.
</AccordionContent>
</AccordionItem>
</Accordion>
),
} satisfies Story;
// export const Multiple = {
// args: {
// type: "multiple",
// },
// render: (args) => (
// <Accordion {...args} className="w-[400px]">
// <AccordionItem value="item-1">
// <AccordionTrigger>Is it accessible?</AccordionTrigger>
// <AccordionContent>
// Yes. It adheres to the WAI-ARIA design pattern.
// </AccordionContent>
// </AccordionItem>
// <AccordionItem value="item-2">
// <AccordionTrigger>Is it styled?</AccordionTrigger>
// <AccordionContent>
// Yes. It comes with default styles that matches the other
// components&apos; aesthetic.
// </AccordionContent>
// </AccordionItem>
// <AccordionItem value="item-3">
// <AccordionTrigger>Is it animated?</AccordionTrigger>
// <AccordionContent>
// Yes. It&apos;s animated by default, but you can disable it if you
// prefer.
// </AccordionContent>
// </AccordionItem>
// </Accordion>
// ),
// } satisfies Story;
export const SingleItem = {
render: (args) => (
<Accordion {...args} className="w-[400px]">
<AccordionItem value="item-1">
<AccordionTrigger>What is this component?</AccordionTrigger>
<AccordionContent>
This is an accordion component built with Radix UI primitives. It provides a collapsible content area that can be expanded or collapsed by clicking the trigger.
</AccordionContent>
</AccordionItem>
</Accordion>
),
} satisfies Story;
// export const SingleItem = {
// render: (args) => (
// <Accordion {...args} className="w-[400px]">
// <AccordionItem value="item-1">
// <AccordionTrigger>What is this component?</AccordionTrigger>
// <AccordionContent>
// This is an accordion component built with Radix UI primitives. It
// provides a collapsible content area that can be expanded or collapsed
// by clicking the trigger.
// </AccordionContent>
// </AccordionItem>
// </Accordion>
// ),
// } satisfies Story;
export const LongContent = {
render: (args) => (
<Accordion {...args} className="w-[400px]">
<AccordionItem value="item-1">
<AccordionTrigger>What are the features?</AccordionTrigger>
<AccordionContent>
<div className="space-y-2">
<p>This accordion component includes:</p>
<ul className="list-disc list-inside space-y-1 ml-4">
<li>Accessibility support with WAI-ARIA patterns</li>
<li>Smooth animations for opening and closing</li>
<li>Keyboard navigation support</li>
<li>Customizable styling with Tailwind CSS</li>
<li>Single or multiple item selection modes</li>
<li>Collapsible functionality</li>
</ul>
<p className="mt-2">
The component is built using Radix UI primitives, ensuring excellent accessibility and user experience across different devices and assistive technologies.
</p>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
),
} satisfies Story;
export const CustomStyling = {
render: (args) => (
<Accordion {...args} className="w-[400px]">
<AccordionItem value="item-1" className="border-2 border-blue-200 rounded-lg mb-2">
<AccordionTrigger className="text-blue-600 font-semibold hover:text-blue-800">
Custom Styled Item
</AccordionTrigger>
<AccordionContent className="text-blue-700 bg-blue-50 p-4 rounded-b-lg">
This accordion item has custom styling with blue colors and enhanced spacing.
</AccordionContent>
</AccordionItem>
<AccordionItem value="item-2" className="border-2 border-green-200 rounded-lg">
<AccordionTrigger className="text-green-600 font-semibold hover:text-green-800">
Another Custom Item
</AccordionTrigger>
<AccordionContent className="text-green-700 bg-green-50 p-4 rounded-b-lg">
Each item can have its own custom styling while maintaining the accordion functionality.
</AccordionContent>
</AccordionItem>
</Accordion>
),
} satisfies Story;
// export const LongContent = {
// render: (args) => (
// <Accordion {...args} className="w-[400px]">
// <AccordionItem value="item-1">
// <AccordionTrigger>What are the features?</AccordionTrigger>
// <AccordionContent>
// <div className="space-y-2">
// <p>This accordion component includes:</p>
// <ul className="list-disc list-inside space-y-1 ml-4">
// <li>Accessibility support with WAI-ARIA patterns</li>
// <li>Smooth animations for opening and closing</li>
// <li>Keyboard navigation support</li>
// <li>Customizable styling with Tailwind CSS</li>
// <li>Single or multiple item selection modes</li>
// <li>Collapsible functionality</li>
// </ul>
// <p className="mt-2">
// The component is built using Radix UI primitives, ensuring
// excellent accessibility and user experience across different
// devices and assistive technologies.
// </p>
// </div>
// </AccordionContent>
// </AccordionItem>
// </Accordion>
// ),
// } satisfies Story;
// export const CustomStyling = {
// render: (args) => (
// <Accordion {...args} className="w-[400px]">
// <AccordionItem
// value="item-1"
// className="border-2 border-blue-200 rounded-lg mb-2"
// >
// <AccordionTrigger className="text-blue-600 font-semibold hover:text-blue-800">
// Custom Styled Item
// </AccordionTrigger>
// <AccordionContent className="text-blue-700 bg-blue-50 p-4 rounded-b-lg">
// This accordion item has custom styling with blue colors and enhanced
// spacing.
// </AccordionContent>
// </AccordionItem>
// <AccordionItem
// value="item-2"
// className="border-2 border-green-200 rounded-lg"
// >
// <AccordionTrigger className="text-green-600 font-semibold hover:text-green-800">
// Another Custom Item
// </AccordionTrigger>
// <AccordionContent className="text-green-700 bg-green-50 p-4 rounded-b-lg">
// Each item can have its own custom styling while maintaining the
// accordion functionality.
// </AccordionContent>
// </AccordionItem>
// </Accordion>
// ),
// } satisfies Story;

View File

@ -124,7 +124,7 @@ function SelectItem({
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
"focus:bg-accent focus:text-accent-foreground data-[state=checked]:bg-accent/50 data-[state=checked]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-2.5 pr-8 pl-2 text-base font-medium outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}

View File

@ -0,0 +1,16 @@
import { Loader2Icon } from "lucide-react"
import { cn } from "@/lib/utils"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2Icon
role="status"
aria-label="Loading"
className={cn("size-4 animate-spin", className)}
{...props}
/>
)
}
export { Spinner }

View File

@ -0,0 +1,53 @@
import Link from "next/link";
import GPTAnimationText from "@/components/ui/GPTAnimationText/GPTAnimationText";
import Typography from "@/components/ui/Typography/Typography";
import { ActionButton } from "@/components/ui/ActionButton/ActionButton";
interface AnimatedInfoScreenProps {
lottieAnimation: React.ReactNode;
title: string;
animationTime?: number;
animationTexts?: string[];
buttonText?: string;
nextRoute?: string;
}
export default async function AnimatedInfoScreen({
lottieAnimation,
title,
animationTime,
animationTexts,
buttonText,
nextRoute,
}: AnimatedInfoScreenProps) {
return (
<div className="w-full flex flex-col items-center px-7">
{lottieAnimation}
<Typography
as="h1"
weight="bold"
className="mt-8 mb-[50px] text-[27px] leading-[40px] text-center"
>
{title}
</Typography>
{!!animationTexts?.length && animationTime && (
<GPTAnimationText
points={animationTexts}
totalAnimationTime={animationTime}
/>
)}
{nextRoute && buttonText && (
<ActionButton
asChild
className="w-full mt-[126px] sticky bottom-[calc(0dvh+16px)] opacity-0 [animation:fadeIn_0.5s_ease-in-out_forwards] pointer-events-none"
style={
animationTime ? { animationDelay: `${animationTime}ms` } : undefined
}
>
<Link href={nextRoute}>{buttonText}</Link>
</ActionButton>
)}
</div>
);
}

View File

@ -1,7 +1,12 @@
"use client";
import { cn } from "@/lib/utils";
import React, { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
import React, {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
} from "react";
import { GradientBlur } from "../GradientBlur/GradientBlur";
import { ActionButton } from "@/components/ui/ActionButton/ActionButton";
@ -16,6 +21,8 @@ export interface BottomActionButtonProps extends React.ComponentProps<"div"> {
showGradientBlur?: boolean;
/** Синхронизировать CSS-переменную --bottom-action-button-height на <html> */
syncCssVar?: boolean;
gradientBlurProps?: React.ComponentProps<typeof GradientBlur>;
}
const BottomActionButton = forwardRef<HTMLDivElement, BottomActionButtonProps>(
@ -27,6 +34,7 @@ const BottomActionButton = forwardRef<HTMLDivElement, BottomActionButtonProps>(
showGradientBlur = true,
className,
syncCssVar = true,
gradientBlurProps,
...props
},
ref
@ -71,13 +79,21 @@ const BottomActionButton = forwardRef<HTMLDivElement, BottomActionButtonProps>(
<div
ref={innerRef}
className={cn(
"fixed bottom-0 left-1/2 -translate-x-1/2 w-full",
"fixed bottom-0 left-1/2 -translate-x-1/2 w-full z-10",
className
)}
{...props}
>
<GradientBlur className="p-6 pt-11" isActiveBlur={showGradientBlur}>
{childrenAboveButton}
<GradientBlur
isActiveBlur={showGradientBlur}
{...gradientBlurProps}
className={cn("p-6 pt-11", gradientBlurProps?.className)}
>
{childrenAboveButton && (
<div className="w-full flex justify-center">
{childrenAboveButton}
</div>
)}
{hasButton ? <ActionButton {...actionButtonProps} /> : null}
{childrenUnderButton}
</GradientBlur>
@ -86,4 +102,4 @@ const BottomActionButton = forwardRef<HTMLDivElement, BottomActionButtonProps>(
}
);
export { BottomActionButton };
export { BottomActionButton };

View File

@ -1,15 +1,13 @@
"use client";
import SelectInput, {
SelectInputProps,
} from "@/components/ui/SelectInput/SelectInput";
import SystemSelectInput from "@/components/ui/SystemSelectInput/SystemSelectInput";
import Typography from "@/components/ui/Typography/Typography";
import { useDateInput } from "@/hooks/useDateInput";
type LocalSelectInputProps = Omit<
SelectInputProps,
"value" | "onValueChange" | "options"
React.ComponentProps<typeof SystemSelectInput>,
"value" | "onChange" | "options"
>;
export interface DateInputProps {
@ -58,30 +56,30 @@ export default function DateInput({
const inputs = {
d: (
<SelectInput
<SystemSelectInput
key="d"
value={day}
onValueChange={handleDayChange}
onChange={(e) => handleDayChange(e.target.value)}
options={dayOptions}
placeholder="DD"
{...daySelectProps}
/>
),
m: (
<SelectInput
<SystemSelectInput
key="m"
value={month}
onValueChange={handleMonthChange}
onChange={(e) => handleMonthChange(e.target.value)}
options={monthOptions}
placeholder="MM"
{...monthSelectProps}
/>
),
y: (
<SelectInput
<SystemSelectInput
key="y"
value={year}
onValueChange={handleYearChange}
onChange={(e) => handleYearChange(e.target.value)}
options={yearOptions}
placeholder="YYYY"
{...yearSelectProps}
@ -91,7 +89,7 @@ export default function DateInput({
return (
<div className="flex flex-col gap-2">
<div className="flex flex-row gap-3 max-[390px]:flex-col">
<div className="flex flex-col gap-3 min-[360px]:flex-row">
{localeFormat.map((format) => inputs[format])}
</div>
{error && (

View File

@ -0,0 +1,46 @@
"use client";
import {
DotLottieReact,
DotLottieReactProps,
} from "@lottiefiles/dotlottie-react";
import clsx from "clsx";
import { useLottie } from "@/hooks/lottie/useLottie";
import { ELottieKeys } from "@/shared/constants/lottie";
interface LottieAnimationProps {
loadKey: ELottieKeys;
width?: number | string;
height?: number | string;
className?: string;
animationProps?: DotLottieReactProps;
}
export default function LottieAnimation({
loadKey,
width = 80,
height = 80,
className,
animationProps,
}: LottieAnimationProps) {
const { animationData } = useLottie({
loadKey,
});
return (
<div style={{ width: width, height: height }} className={className}>
{animationData && (
<DotLottieReact
style={{ width: width, height: height }}
data={animationData}
autoplay
width={width}
height={height}
{...animationProps}
className={clsx(animationProps?.className, "ym-hide-content")}
/>
)}
</div>
);
}

View File

@ -5,7 +5,7 @@ import {
MainButton,
MainButtonProps,
} from "@/components/ui/MainButton/MainButton";
import { useEffect, useState, useRef } from "react";
import { useState } from "react";
export interface SelectAnswersListProps extends React.ComponentProps<"div"> {
answers: MainButtonProps[];
@ -22,37 +22,21 @@ function SelectAnswersList({
onAnswerClick,
...props
}: SelectAnswersListProps) {
// Инициализируем состояние только один раз из activeAnswers
const [selectedAnswers, setSelectedAnswers] = useState<
MainButtonProps[] | null
>(activeAnswers);
const isInitialMount = useRef(true);
useEffect(() => {
setSelectedAnswers(activeAnswers ?? null);
}, [activeAnswers]);
>(() => activeAnswers ?? null);
const handleAnswerClick = (answer: MainButtonProps) => {
if (selectedAnswers?.some((a) => a.id === answer.id)) {
setSelectedAnswers(
(prev) => prev?.filter((a) => a.id !== answer.id) || null
);
} else {
setSelectedAnswers((prev) => [...(prev || []), answer]);
}
const newSelectedAnswers = selectedAnswers?.some((a) => a.id === answer.id)
? selectedAnswers.filter((a) => a.id !== answer.id) || null
: [...(selectedAnswers || []), answer];
setSelectedAnswers(newSelectedAnswers);
onChangeSelectedAnswers?.(newSelectedAnswers);
onAnswerClick?.(answer);
};
useEffect(() => {
// НЕ вызываем callback при первоначальной загрузке компонента
if (isInitialMount.current) {
isInitialMount.current = false;
return;
}
onChangeSelectedAnswers?.(selectedAnswers);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedAnswers]);
return (
<div className={cn("flex flex-col gap-3 w-full", className)} {...props}>
{answers.map((answer) => (

View File

@ -0,0 +1,33 @@
"use server";
import { http } from "@/shared/api/httpClient";
import { API_ROUTES } from "@/shared/constants/api-routes";
import { ActionResponse } from "@/shared/types";
import {
SingleCheckoutRequest,
SingleCheckoutResponse,
SingleCheckoutResponseSchema,
} from "./types";
export async function performSingleCheckout(
payload: SingleCheckoutRequest
): Promise<ActionResponse<SingleCheckoutResponse>> {
try {
const response = await http.post<SingleCheckoutResponse>(
API_ROUTES.paymentSingleCheckout(),
payload,
{
schema: SingleCheckoutResponseSchema,
revalidate: 0,
}
);
return { data: response, error: null };
} catch (error) {
console.error("Failed to perform single checkout:", error);
const errorMessage =
error instanceof Error ? error.message : "Something went wrong.";
return { data: null, error: errorMessage };
}
}

View File

@ -0,0 +1,31 @@
import { http } from "@/shared/api/httpClient";
import { API_ROUTES } from "@/shared/constants/api-routes";
import {
CheckoutRequest,
CheckoutResponse,
CheckoutResponseSchema,
SingleCheckoutRequest,
SingleCheckoutResponse,
SingleCheckoutResponseSchema,
} from "./types";
export async function createPaymentCheckout(payload: CheckoutRequest) {
return http.post<CheckoutResponse>(API_ROUTES.paymentCheckout(), payload, {
schema: CheckoutResponseSchema,
revalidate: 0,
});
}
export async function createSinglePaymentCheckout(
payload: SingleCheckoutRequest
) {
return http.post<SingleCheckoutResponse>(
API_ROUTES.paymentSingleCheckout(),
payload,
{
schema: SingleCheckoutResponseSchema,
revalidate: 0,
}
);
}

View File

@ -0,0 +1,52 @@
import { z } from "zod";
export const CheckoutRequestSchema = z.object({
productId: z.string(),
placementId: z.string(),
paywallId: z.string(),
});
export type CheckoutRequest = z.infer<typeof CheckoutRequestSchema>;
export const CheckoutResponseSchema = z.object({
status: z.string(),
invoiceId: z.string(),
paymentUrl: z.string().url(),
});
export type CheckoutResponse = z.infer<typeof CheckoutResponseSchema>;
export const PaymentInfoSchema = z.object({
productId: z.string(),
key: z.string(),
isAutoTopUp: z.boolean().optional(),
});
export type PaymentInfo = z.infer<typeof PaymentInfoSchema>;
export const SingleCheckoutRequestSchema = z.object({
paymentInfo: PaymentInfoSchema,
return_url: z.string().optional(),
pageUrl: z.string().optional(),
});
export type SingleCheckoutRequest = z.infer<typeof SingleCheckoutRequestSchema>;
export const SingleCheckoutSuccessSchema = z.object({
payment: z.object({
status: z.string(),
invoiceId: z.string(),
paymentUrl: z.string().url().optional(),
}),
});
export type SingleCheckoutSuccess = z.infer<typeof SingleCheckoutSuccessSchema>;
export const SingleCheckoutErrorSchema = z.object({
status: z.string(),
message: z.string(),
});
export type SingleCheckoutError = z.infer<typeof SingleCheckoutErrorSchema>;
export const SingleCheckoutResponseSchema = z.union([
SingleCheckoutSuccessSchema,
SingleCheckoutErrorSchema,
]);
export type SingleCheckoutResponse = z.infer<
typeof SingleCheckoutResponseSchema
>;

View File

@ -0,0 +1,49 @@
import { http } from "@/shared/api/httpClient";
import {
CreateSessionResponseSchema,
ICreateSessionRequest,
ICreateSessionResponse,
IUpdateSessionRequest,
IUpdateSessionResponse,
UpdateSessionResponseSchema,
IGetPixelsRequest,
IGetPixelsResponse,
GetPixelsResponseSchema,
} from "./types";
import { API_ROUTES } from "@/shared/constants/api-routes";
export const createSession = async (
payload: ICreateSessionRequest
): Promise<ICreateSessionResponse> => {
return http.post<ICreateSessionResponse>(API_ROUTES.session(), payload, {
tags: ["session", "create"],
schema: CreateSessionResponseSchema,
revalidate: 0,
});
};
export const updateSession = async (
payload: IUpdateSessionRequest
): Promise<IUpdateSessionResponse> => {
// Отправляем только data без вложенности
return http.patch<IUpdateSessionResponse>(
API_ROUTES.session(payload.sessionId),
payload.data,
{
tags: ["session", "update"],
schema: UpdateSessionResponseSchema,
revalidate: 0,
}
);
};
export const getPixels = async (
payload: IGetPixelsRequest
): Promise<IGetPixelsResponse> => {
return http.get<IGetPixelsResponse>(API_ROUTES.sessionPixels(), {
tags: ["session", "pixels"],
schema: GetPixelsResponseSchema,
revalidate: 3600, // Cache for 1 hour
query: payload,
});
};

View File

@ -0,0 +1,14 @@
"use server";
import { http } from "@/shared/api/httpClient";
import { API_ROUTES } from "@/shared/constants/api-routes";
import { FunnelRequest, FunnelResponse, FunnelResponseSchema } from "./types";
export const getFunnel = async (payload: FunnelRequest) => {
return http.post<FunnelResponse>(API_ROUTES.funnel(), payload, {
tags: ["funnel"],
schema: FunnelResponseSchema,
revalidate: 0,
});
};

View File

@ -0,0 +1,41 @@
import { cache } from "react";
import { getFunnel } from "./api";
import type { FunnelRequest } from "./types";
export const loadFunnel = cache((payload: FunnelRequest) => getFunnel(payload));
export const loadFunnelData = cache((payload: FunnelRequest) =>
loadFunnel(payload).then(d => d.data)
);
export const loadFunnelStatus = cache((payload: FunnelRequest) =>
loadFunnel(payload).then(d => d.status)
);
export const loadFunnelCurrency = cache((payload: FunnelRequest) =>
loadFunnelData(payload).then(d => d.currency)
);
export const loadFunnelLocale = cache((payload: FunnelRequest) =>
loadFunnelData(payload).then(d => d.locale)
);
export const loadFunnelPayment = cache((payload: FunnelRequest) =>
loadFunnelData(payload).then(d => d.payment)
);
export const loadFunnelPaymentById = cache(
(payload: FunnelRequest, paymentId: string) =>
loadFunnelData(payload).then(d => d.payment[paymentId])
);
// export const loadFunnelProducts = cache(
// (payload: FunnelRequest, paymentId: string) =>
// loadFunnelPaymentById(payload, paymentId).then(d => d?.variants ?? [])
// );
// export const loadFunnelProperties = cache(
// (payload: FunnelRequest, paymentId: string) =>
// loadFunnelPaymentById(payload, paymentId).then(d => d?.properties ?? [])
// );

View File

@ -0,0 +1,70 @@
import { z } from "zod";
import { Currency } from "@/shared/types";
// Request schemas
export const FunnelRequestSchema = z.object({
// funnel: z.enum(ELocalesPlacement),
funnel: z.string(),
});
// Response schemas
export const FunnelPaymentPropertySchema = z.object({
key: z.string(),
value: z.union([z.string(), z.number()]),
});
export const FunnelPaymentVariantSchema = z.object({
id: z.string(),
key: z.string(),
type: z.string(),
price: z.number(),
oldPrice: z.number().optional(),
trialPrice: z.number().optional(),
});
export const FunnelPaymentPlacementSchema = z.object({
price: z.number().optional(),
currency: z.enum(Currency).optional(),
billingPeriod: z.enum(["DAY", "WEEK", "MONTH", "YEAR"]).optional(),
billingInterval: z.number().optional(),
trialPeriod: z.enum(["DAY", "WEEK", "MONTH", "YEAR"]).optional(),
trialInterval: z.number().optional(),
placementId: z.string().optional(),
paywallId: z.string().optional(),
properties: z.array(FunnelPaymentPropertySchema).optional(),
variants: z.array(FunnelPaymentVariantSchema).optional(),
paymentUrl: z.string().optional(),
type: z.string().optional(),
});
export const FunnelSchema = z.object({
currency: z.enum(Currency),
// funnel: z.enum(ELocalesPlacement),
funnel: z.string(),
locale: z.string(),
payment: z.record(
z.string(),
z.union([
FunnelPaymentPlacementSchema.nullable(),
z.array(FunnelPaymentPlacementSchema),
])
),
});
export const FunnelResponseSchema = z.object({
status: z.union([z.literal("success"), z.string()]),
data: FunnelSchema,
});
// Type exports
export type FunnelRequest = z.infer<typeof FunnelRequestSchema>;
export type IFunnelPaymentProperty = z.infer<
typeof FunnelPaymentPropertySchema
>;
export type IFunnelPaymentVariant = z.infer<typeof FunnelPaymentVariantSchema>;
export type IFunnelPaymentPlacement = z.infer<
typeof FunnelPaymentPlacementSchema
>;
export type IFunnel = z.infer<typeof FunnelSchema>;
export type FunnelResponse = z.infer<typeof FunnelResponseSchema>;

View File

@ -0,0 +1,17 @@
"use server";
import { cookies } from "next/headers";
export const setSessionIdToCookie = async (
key: string,
value: string
): Promise<void> => {
const cookieStore = await cookies();
cookieStore.set(key, value, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
path: "/",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 365,
});
};

View File

@ -0,0 +1,60 @@
import { z } from "zod";
import { CreateAuthorizeUserSchema } from "../user/types";
export const CreateSessionRequestSchema = z.object({
feature: z.string().optional(),
locale: z.string(),
timezone: z.string(),
source: z.string(),
sign: z.boolean(),
signDate: z.string().optional(),
utm: z.record(z.string(), z.string()).optional(),
domain: z.string(),
});
export const UpdateSessionRequestSchema = z.object({
sessionId: z.string(),
data: z.object({
feature: z.string().optional(),
profile: CreateAuthorizeUserSchema.optional(),
partner: CreateAuthorizeUserSchema.omit({
relationship_status: true,
}).optional(),
answers: z.record(z.string(), z.unknown()).optional(),
cookies: z.record(z.string(), z.string()).optional(),
}),
});
export const CreateSessionResponseSchema = z.object({
status: z.string(),
sessionId: z.string(),
});
export const UpdateSessionResponseSchema = z.object({
status: z.string(),
message: z.string(),
});
export const GetPixelsRequestSchema = z.object({
domain: z.string(),
source: z.string(),
locale: z.string(),
});
export const GetPixelsResponseSchema = z.object({
status: z.string(),
data: z.object({
fb: z.array(z.string()).optional(),
}),
});
export type ICreateSessionRequest = z.infer<typeof CreateSessionRequestSchema>;
export type IUpdateSessionRequest = z.infer<typeof UpdateSessionRequestSchema>;
export type ICreateSessionResponse = z.infer<
typeof CreateSessionResponseSchema
>;
export type IUpdateSessionResponse = z.infer<
typeof UpdateSessionResponseSchema
>;
export type IGetPixelsRequest = z.infer<typeof GetPixelsRequestSchema>;
export type IGetPixelsResponse = z.infer<typeof GetPixelsResponseSchema>;

View File

@ -0,0 +1,21 @@
import { http } from "@/shared/api/httpClient";
import {
CreateAuthorizeResponseSchema,
ICreateAuthorizeRequest,
ICreateAuthorizeResponse,
} from "./types";
import { API_ROUTES } from "@/shared/constants/api-routes";
export const createAuthorization = async (
payload: ICreateAuthorizeRequest
): Promise<ICreateAuthorizeResponse> => {
return http.post<ICreateAuthorizeResponse>(
API_ROUTES.authorization(),
payload,
{
tags: ["authorization", "create"],
schema: CreateAuthorizeResponseSchema,
revalidate: 0,
}
);
};

View File

@ -0,0 +1,19 @@
"use server";
import { cookies } from "next/headers";
export const setAuthTokenToCookie = async (token: string): Promise<void> => {
const cookieStore = await cookies();
cookieStore.set("accessToken", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
path: "/",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 365,
});
};
export const getAuthTokenFromCookie = async (): Promise<string | undefined> => {
const cookieStore = await cookies();
return cookieStore.get("accessToken")?.value;
};

View File

@ -0,0 +1,54 @@
import z from "zod";
export const GenderSchema = z.enum(["male", "female", "other"]);
export const RelationshipStatusSchema = z.enum([
"single",
"relationship",
"married",
"complicated",
"other",
]);
export const CreateAuthorizeUserSchema = z.object({
name: z.string(),
birthdate: z.string().optional(),
gender: GenderSchema,
birthplace: z.object({
address: z.string().optional(),
coords: z.string().optional(),
}),
relationship_status: RelationshipStatusSchema,
});
export const CreateAuthorizeRequestSchema = z.object({
email: z.string(),
locale: z.string(),
timezone: z.string(),
source: z.string(),
profile: CreateAuthorizeUserSchema.optional(),
partner: CreateAuthorizeUserSchema.omit({
relationship_status: true,
}).optional(),
sign: z.boolean(),
signDate: z.string().optional(),
feature: z.string().optional(),
});
export const CreateAuthorizeResponseSchema = z.object({
token: z.string(),
userId: z.string().optional(),
generatingVideo: z.boolean().optional(),
videoId: z.string().nullable().optional(),
authCode: z.string().optional(),
});
export type ICreateAuthorizeUser = z.infer<typeof CreateAuthorizeUserSchema>;
export type ICreateAuthorizeRequest = z.infer<
typeof CreateAuthorizeRequestSchema
>;
export type ICreateAuthorizeResponse = z.infer<
typeof CreateAuthorizeResponseSchema
>;

132
src/hooks/auth/useAuth.ts Normal file
View File

@ -0,0 +1,132 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { useSession } from "../session/useSession";
import { getClientTimezone } from "@/shared/utils/locales";
import { ICreateAuthorizeRequest } from "@/entities/user/types";
import { filterNullKeysOfObject } from "@/shared/utils/filter-object";
import { createAuthorization } from "@/entities/user/actions";
import { setAuthTokenToCookie } from "@/entities/user/serverActions";
import analyticsService, { AnalyticsEvent, AnalyticsPlatform } from "@/services/analytics/analyticsService";
// TODO
const locale = "en";
interface IUseAuthProps {
funnelId: string;
/**
* Дополнительные данные для регистрации пользователя.
* Будут объединены с базовым payload при авторизации.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
registrationData?: Record<string, any>;
}
export const useAuth = ({ funnelId, registrationData }: IUseAuthProps) => {
const { updateSession } = useSession({ funnelId });
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const getAllCookies = useCallback(() => {
// Токены которые не должны передаваться на backend
const EXCLUDED_COOKIES = ["accessToken", "activeSessionId"];
const cookies: Record<string, string> = {};
document.cookie.split(";").forEach((cookie) => {
const [name, value] = cookie.trim().split("=");
if (name && value && !EXCLUDED_COOKIES.includes(name)) {
cookies[name] = decodeURIComponent(value);
}
});
return cookies;
}, []);
const getAuthorizationPayload = useCallback(
(email: string): ICreateAuthorizeRequest => {
const timezone = getClientTimezone();
const basePayload = {
timezone,
locale,
email,
source: funnelId,
sign: true,
signDate: new Date().toISOString(),
feature: "stripe"
};
// Объединяем базовый payload с данными регистрации из воронки
const mergedPayload = registrationData
? { ...basePayload, ...registrationData }
: basePayload;
return filterNullKeysOfObject<ICreateAuthorizeRequest>(mergedPayload);
},
[funnelId, registrationData]
);
const authorization = useCallback(
async (email: string) => {
try {
setIsLoading(true);
setError(null);
// Обновляем сессию с куки перед авторизацией
try {
const cookies = getAllCookies();
await updateSession({ cookies });
console.log(
"Session updated with cookies before authorization:",
cookies
);
} catch (sessionError) {
console.warn("Failed to update session with cookies:", sessionError);
// Продолжаем авторизацию даже если обновление сессии не удалось
}
const payload = getAuthorizationPayload(email);
const { token, userId } = await createAuthorization(payload);
// Track registration events in analytics
// Send EnteredEmail to Yandex Metrika and Google Analytics
analyticsService.trackEvent(
AnalyticsEvent.ENTERED_EMAIL,
[AnalyticsPlatform.YANDEX_METRIKA, AnalyticsPlatform.GOOGLE_ANALYTICS]
);
// Send Lead to Facebook Pixel
analyticsService.trackEvent(
AnalyticsEvent.LEAD,
[AnalyticsPlatform.FACEBOOK]
);
// Set user ID and properties in analytics
if (userId) {
analyticsService.setUserId(userId);
analyticsService.setUserProperties({
email,
source: funnelId,
UserID: userId,
});
}
await setAuthTokenToCookie(token);
return token;
} catch (error) {
setError((error as Error).message);
} finally {
setIsLoading(false);
}
},
[getAllCookies, getAuthorizationPayload, updateSession, funnelId]
);
return useMemo(
() => ({
authorization,
isLoading,
error,
}),
[authorization, isLoading, error]
);
};

View File

@ -0,0 +1,17 @@
"use client";
import { getAuthTokenFromCookie } from "@/entities/user/serverActions";
import { useEffect, useMemo, useState } from "react";
export const useClientToken = () => {
const [token, setToken] = useState<string | undefined>(undefined);
useEffect(() => {
(async () => {
const token = await getAuthTokenFromCookie();
setToken(token);
})();
}, []);
return useMemo(() => token, [token]);
};

Some files were not shown because too many files have changed in this diff Show More