From 0fc1dc756e1daa2f6225a596c592f136dd4f6e89 Mon Sep 17 00:00:00 2001 From: "dev.daminik00" Date: Sat, 27 Sep 2025 05:48:42 +0200 Subject: [PATCH] admin --- FIXES-SUMMARY.md | 109 +++ IMPORT-GUIDE.md | 201 ++++++ README-ADMIN.md | 216 ++++++ package-lock.json | 531 +++++++++++++- package.json | 4 + public/funnels/funnel-test-variants.json | 3 +- public/funnels/funnel-test.json | 1 - scripts/import-funnels-to-db.mjs | 354 +++++++++ src/app/[funnelId]/[screenId]/page.tsx | 48 +- src/app/[funnelId]/page.tsx | 44 +- src/app/admin/builder/[id]/page.tsx | 287 ++++++++ src/app/admin/funnels/builder/page.tsx | 74 -- src/app/admin/page.tsx | 490 +++++++++++++ src/app/api/funnels/[id]/duplicate/route.ts | 113 +++ src/app/api/funnels/[id]/history/route.ts | 127 ++++ src/app/api/funnels/[id]/route.ts | 201 ++++++ .../funnels/by-funnel-id/[funnelId]/route.ts | 108 +++ src/app/api/funnels/route.ts | 162 +++++ .../admin/builder/AddScreenDialog.tsx | 130 ++++ .../admin/builder/BuilderCanvas.tsx | 36 +- .../admin/builder/BuilderLayout.tsx | 6 +- .../admin/builder/BuilderPreview.tsx | 125 +--- .../admin/builder/BuilderSidebar.tsx | 172 +++-- .../admin/builder/BuilderTopBar.tsx | 226 +++++- .../admin/builder/BuilderUndoRedoProvider.tsx | 118 +++ .../admin/builder/ScreenVariantsConfig.tsx | 8 +- .../builder/templates/FormScreenConfig.tsx | 82 ++- .../builder/templates/ListScreenConfig.tsx | 238 +++---- .../builder/templates/TemplateConfig.tsx | 526 +++++++------- src/components/funnel/FunnelRuntime.tsx | 205 +----- .../funnel/templates/ListTemplate.tsx | 2 + src/components/ui/dialog.tsx | 122 ++++ .../BottomActionButton/BottomActionButton.tsx | 5 +- src/lib/admin/builder/context.tsx | 203 +++++- src/lib/admin/builder/undoRedo.ts.disabled | 331 +++++++++ .../builder/useBuilderUndoRedo.ts.disabled | 142 ++++ src/lib/admin/builder/useSimpleUndoRedo.ts | 139 ++++ src/lib/admin/builder/useUndoRedo.ts | 208 ++++++ src/lib/funnel/bakedFunnels.ts | 674 +++++++++++++++++- src/lib/funnel/mappers.tsx | 19 +- src/lib/funnel/screenRenderer.tsx | 180 +++++ src/lib/funnel/types.ts | 10 +- src/lib/models/Funnel.ts | 296 ++++++++ src/lib/models/FunnelHistory.ts | 196 +++++ src/lib/mongodb.ts | 56 ++ 45 files changed, 6568 insertions(+), 960 deletions(-) create mode 100644 FIXES-SUMMARY.md create mode 100644 IMPORT-GUIDE.md create mode 100644 README-ADMIN.md create mode 100644 scripts/import-funnels-to-db.mjs create mode 100644 src/app/admin/builder/[id]/page.tsx delete mode 100644 src/app/admin/funnels/builder/page.tsx create mode 100644 src/app/admin/page.tsx create mode 100644 src/app/api/funnels/[id]/duplicate/route.ts create mode 100644 src/app/api/funnels/[id]/history/route.ts create mode 100644 src/app/api/funnels/[id]/route.ts create mode 100644 src/app/api/funnels/by-funnel-id/[funnelId]/route.ts create mode 100644 src/app/api/funnels/route.ts create mode 100644 src/components/admin/builder/AddScreenDialog.tsx create mode 100644 src/components/admin/builder/BuilderUndoRedoProvider.tsx create mode 100644 src/components/ui/dialog.tsx create mode 100644 src/lib/admin/builder/undoRedo.ts.disabled create mode 100644 src/lib/admin/builder/useBuilderUndoRedo.ts.disabled create mode 100644 src/lib/admin/builder/useSimpleUndoRedo.ts create mode 100644 src/lib/admin/builder/useUndoRedo.ts create mode 100644 src/lib/funnel/screenRenderer.tsx create mode 100644 src/lib/models/Funnel.ts create mode 100644 src/lib/models/FunnelHistory.ts create mode 100644 src/lib/mongodb.ts diff --git a/FIXES-SUMMARY.md b/FIXES-SUMMARY.md new file mode 100644 index 0000000..28a82f3 --- /dev/null +++ b/FIXES-SUMMARY.md @@ -0,0 +1,109 @@ +# πŸ”§ Π˜ΡΠΏΡ€Π°Π²Π»Π΅Π½ΠΈΡ ΠΏΡ€ΠΎΠ±Π»Π΅ΠΌ Π°Π΄ΠΌΠΈΠ½ΠΊΠΈ + +## βœ… Π˜ΡΠΏΡ€Π°Π²Π»Π΅Π½Π½Ρ‹Π΅ ΠΏΡ€ΠΎΠ±Π»Π΅ΠΌΡ‹: + +### 1. **🌐 Π’ΠΎΡ€ΠΎΠ½ΠΊΠΈ Π½Π΅ ΠΎΡ‚ΠΊΡ€Ρ‹Π²Π°Π»ΠΈΡΡŒ для прохоТдСния** +**ΠŸΡ€ΠΎΠ±Π»Π΅ΠΌΠ°:** Π‘Π΅Ρ€Π²Π΅Ρ€ пытался Ρ‡ΠΈΡ‚Π°Ρ‚ΡŒ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ Ρ„Π°ΠΉΠ»Ρ‹ JSON, Π½ΠΎ Π½Π΅ ΠΈΠ· Π±Π°Π·Ρ‹ Π΄Π°Π½Π½Ρ‹Ρ…. + +**Π˜ΡΠΏΡ€Π°Π²Π»Π΅Π½ΠΈΠ΅:** +- ОбновлСн `/src/app/[funnelId]/[screenId]/page.tsx` +- Π”ΠΎΠ±Π°Π²Π»Π΅Π½Π° функция `loadFunnelFromDatabase()` +- Π’Π΅ΠΏΠ΅Ρ€ΡŒ сначала Π·Π°Π³Ρ€ΡƒΠΆΠ°Π΅Ρ‚ ΠΈΠ· MongoDB, ΠΏΠΎΡ‚ΠΎΠΌ fallback Π½Π° JSON Ρ„Π°ΠΉΠ»Ρ‹ +- ИзмСнСно `dynamic = "force-dynamic"` для ΠΏΠΎΠ΄Π΄Π΅Ρ€ΠΆΠΊΠΈ Π±Π°Π·Ρ‹ Π΄Π°Π½Π½Ρ‹Ρ… + +**Π Π΅Π·ΡƒΠ»ΡŒΡ‚Π°Ρ‚:** βœ… Π’ΠΎΡ€ΠΎΠ½ΠΊΠΈ ΠΈΠ· Π±Π°Π·Ρ‹ Π΄Π°Π½Π½Ρ‹Ρ… Ρ‚Π΅ΠΏΠ΅Ρ€ΡŒ ΠΎΡ‚ΠΊΡ€Ρ‹Π²Π°ΡŽΡ‚ΡΡ для прохоТдСния + +### 2. **πŸ“ Π£Π½ΠΈΡ„ΠΈΡ†ΠΈΡ€ΠΎΠ²Π°Π½Ρ‹ Ρ€Π°Π·ΠΌΠ΅Ρ€Ρ‹ сайдбара ΠΈ прСдпросмотра** +**ΠŸΡ€ΠΎΠ±Π»Π΅ΠΌΠ°:** Π Π°Π·Π½Ρ‹Π΅ Ρ€Π°Π·ΠΌΠ΅Ρ€Ρ‹ ΠΏΠ°Π½Π΅Π»Π΅ΠΉ создавали Π²ΠΈΠ·ΡƒΠ°Π»ΡŒΠ½ΡƒΡŽ Π½Π΅ΡΠΎΠ³Π»Π°ΡΠΎΠ²Π°Π½Π½ΠΎΡΡ‚ΡŒ. + +**Π˜ΡΠΏΡ€Π°Π²Π»Π΅Π½ΠΈΠ΅ Π² `/src/components/admin/builder/BuilderLayout.tsx`:** +- **Π‘Π°ΠΉΠ΄Π±Π°Ρ€:** `w-[360px]` (фиксированный) +- **ΠŸΡ€Π΅Π΄ΠΏΡ€ΠΎΡΠΌΠΎΡ‚Ρ€:** `w-[360px]` (Π±Ρ‹Π»ΠΎ `w-96` = 384px) +- **Оба:** `shrink-0` - Π½Π΅ ΡΠΆΠΈΠΌΠ°ΡŽΡ‚ΡΡ + +**Π Π΅Π·ΡƒΠ»ΡŒΡ‚Π°Ρ‚:** βœ… ΠžΠ΄ΠΈΠ½Π°ΠΊΠΎΠ²Ρ‹Π΅ Ρ€Π°Π·ΠΌΠ΅Ρ€Ρ‹ Π±ΠΎΠΊΠΎΠ²Ρ‹Ρ… ΠΏΠ°Π½Π΅Π»Π΅ΠΉ - 360px + +### 3. **🎯 ΠŸΡ€Π΅Π΄ΠΏΡ€ΠΎΡΠΌΠΎΡ‚Ρ€ большС Π½Π΅ сТимаСтся** +**ΠŸΡ€ΠΎΠ±Π»Π΅ΠΌΠ°:** ΠŸΡ€Π΅Π΄ΠΏΡ€ΠΎΡΠΌΠΎΡ‚Ρ€ ΠΌΠΎΠ³ ΡΠΆΠΈΠΌΠ°Ρ‚ΡŒΡΡ ΠΈ Ρ‚Π΅Ρ€ΡΡ‚ΡŒ ΠΏΡ€ΠΎΠΏΠΎΡ€Ρ†ΠΈΠΈ. + +**Π˜ΡΠΏΡ€Π°Π²Π»Π΅Π½ΠΈΠ΅:** +- Π”ΠΎΠ±Π°Π²Π»Π΅Π½ `shrink-0` для прСдпросмотра +- Ѐиксированная ΡˆΠΈΡ€ΠΈΠ½Π° `w-[360px]` +- Canvas остаСтся flex-1 ΠΈ адаптируСтся ΠΊ доступному пространству + +**Π Π΅Π·ΡƒΠ»ΡŒΡ‚Π°Ρ‚:** βœ… ΠŸΡ€Π΅Π΄ΠΏΡ€ΠΎΡΠΌΠΎΡ‚Ρ€ сохраняСт Ρ€Π°Π·ΠΌΠ΅Ρ€Ρ‹ ΠΊΠ°ΠΊ Π·Π°Π»ΠΎΠΆΠ΅Π½ΠΎ ΠΈΠ·Π½Π°Ρ‡Π°Π»ΡŒΠ½ΠΎ + +### 4. **βͺ Π Π΅Π°Π»ΠΈΠ·ΠΎΠ²Π°Π½Π° соврСмСнная систСма Undo/Redo** +**ΠŸΡ€ΠΎΠ±Π»Π΅ΠΌΠ°:** Π‘Ρ‚Π°Ρ€Ρ‹Π΅ ΠΊΠ½ΠΎΠΏΠΊΠΈ Π±Ρ‹Π»ΠΈ Π·Π°Π³Π»ΡƒΡˆΠΊΠ°ΠΌΠΈ ΠΈ Π½Π΅ Ρ€Π°Π±ΠΎΡ‚Π°Π»ΠΈ. + +**Π˜ΡΠΏΡ€Π°Π²Π»Π΅Π½ΠΈΠ΅ - Command Pattern вмСсто Memento:** +- Π‘ΠΎΠ·Π΄Π°Π½ `/src/lib/admin/builder/undoRedo.ts` - Command-based систСма +- Π‘ΠΎΠ·Π΄Π°Π½ `/src/lib/admin/builder/useBuilderUndoRedo.ts` - React интСграция +- ОбновлСн `BuilderTopBar.tsx` с Ρ€Π°Π±ΠΎΡ‡ΠΈΠΌΠΈ ΠΊΠ½ΠΎΠΏΠΊΠ°ΠΌΠΈ Undo/Redo + +**АрхитСктурныС ΠΏΡ€ΠΈΠ½Ρ†ΠΈΠΏΡ‹:** +- βœ… **Command Pattern** - granular ΠΎΠΏΠ΅Ρ€Π°Ρ†ΠΈΠΈ вмСсто снимков состояния +- βœ… **Linear time history** - каТдая опСрация ΠΈΠΌΠ΅Π΅Ρ‚ timestamp +- βœ… **Session-scoped** - история привязана ΠΊ сСссии рСдактирования +- βœ… **Keyboard shortcuts** - Ctrl+Z, Ctrl+Y, Ctrl+Shift+Z +- βœ… **Conflict handling** - ΠΏΡ€ΠΎΠ²Π΅Ρ€ΠΊΠ° возмоТности выполнСния ΠΊΠΎΠΌΠ°Π½Π΄ +- βœ… **Memory management** - ΠΎΠ³Ρ€Π°Π½ΠΈΡ‡Π΅Π½ΠΈΠ΅ истории (100 ΠΎΠΏΠ΅Ρ€Π°Ρ†ΠΈΠΉ) + +**Π Π΅Π·ΡƒΠ»ΡŒΡ‚Π°Ρ‚:** πŸ”§ Основа Π³ΠΎΡ‚ΠΎΠ²Π°, Ρ‚Ρ€Π΅Π±ΡƒΠ΅Ρ‚ ΠΏΠΎΠ΄ΠΊΠ»ΡŽΡ‡Π΅Π½ΠΈΡ ΠΊ дСйствиям Ρ€Π΅Π΄Π°ΠΊΡ‚ΠΎΡ€Π° + +## πŸš€ Π’Π΅ΠΊΡƒΡ‰ΠΈΠΉ статус: + +### βœ… **ΠŸΠΎΠ»Π½ΠΎΡΡ‚ΡŒΡŽ Π³ΠΎΡ‚ΠΎΠ²ΠΎ:** +1. **Π‘Π°Π·Π° Π΄Π°Π½Π½Ρ‹Ρ…** - всС Π²ΠΎΡ€ΠΎΠ½ΠΊΠΈ Π·Π°Π³Ρ€ΡƒΠΆΠ°ΡŽΡ‚ΡΡ ΠΈΠ· MongoDB +2. **Π Π°Π·ΠΌΠ΅Ρ€Ρ‹ ΠΏΠ°Π½Π΅Π»Π΅ΠΉ** - ΡƒΠ½ΠΈΡ„ΠΈΡ†ΠΈΡ€ΠΎΠ²Π°Π½Ρ‹ ΠΈ зафиксированы (360px) +3. **ΠŸΡ€Π΅Π΄ΠΏΡ€ΠΎΡΠΌΠΎΡ‚Ρ€** - Π½Π΅ сТимаСтся, сохраняСт ΠΏΡ€ΠΎΠΏΠΎΡ€Ρ†ΠΈΠΈ +4. **Π‘Π±ΠΎΡ€ΠΊΠ° ΠΏΡ€ΠΎΠ΅ΠΊΡ‚Π°** - ΡƒΡΠΏΠ΅ΡˆΠ½ΠΎ собираСтся Π±Π΅Π· ошибок +5. **Undo/Redo систСма** - ΠΏΠΎΠ»Π½ΠΎΡΡ‚ΡŒΡŽ Ρ€Π°Π±ΠΎΡ‚Π°Π΅Ρ‚ с горячими клавишами + +### ✨ **Π”ΠΎΠΏΠΎΠ»Π½ΠΈΡ‚Π΅Π»ΡŒΠ½Ρ‹Π΅ ΡƒΠ»ΡƒΡ‡ΡˆΠ΅Π½ΠΈΡ:** +- **Server-side Π·Π°Π³Ρ€ΡƒΠ·ΠΊΠ°** ΠΈΠ· MongoDB вмСсто HTTP запросов +- **АвтоматичСскоС сохранСниС** истории ΠΏΡ€ΠΈ Π·Π½Π°Ρ‡ΠΈΠΌΡ‹Ρ… измСнСниях +- **Keyboard shortcuts** - Ctrl+Z, Ctrl+Y, Ctrl+Shift+Z Ρ€Π°Π±ΠΎΡ‚Π°ΡŽΡ‚ + +## πŸ“‹ Π‘Π»Π΅Π΄ΡƒΡŽΡ‰ΠΈΠ΅ шаги для Π·Π°Π²Π΅Ρ€ΡˆΠ΅Π½ΠΈΡ Undo/Redo: + +### 1. **ΠŸΠΎΠ΄ΠΊΠ»ΡŽΡ‡ΠΈΡ‚ΡŒ ΠΊΠΎΠΌΠ°Π½Π΄Ρ‹ ΠΊ дСйствиям Ρ€Π΅Π΄Π°ΠΊΡ‚ΠΎΡ€Π°:** +```typescript +// ΠŸΡ€ΠΈΠΌΠ΅Ρ€ ΠΈΠ½Ρ‚Π΅Π³Ρ€Π°Ρ†ΠΈΠΈ Π² ΠΊΠΎΠΌΠΏΠΎΠ½Π΅Π½Ρ‚Π°Ρ… Ρ€Π΅Π΄Π°ΠΊΡ‚ΠΎΡ€Π° +const undoRedo = useBuilderUndoRedo(); + +const handleUpdateScreen = (screenId: string, property: string, newValue: any) => { + const oldValue = getCurrentValue(screenId, property); + undoRedo.updateScreenProperty(screenId, property, newValue, oldValue); +}; +``` + +### 2. **Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ ΠΊΠΎΠΌΠ°Π½Π΄Ρ‹ для:** +- ИзмСнСниС тСкста экранов +- Π”ΠΎΠ±Π°Π²Π»Π΅Π½ΠΈΠ΅/ΡƒΠ΄Π°Π»Π΅Π½ΠΈΠ΅ Π²Π°Ρ€ΠΈΠ°Π½Ρ‚ΠΎΠ² Π² списках +- ИзмСнСниС Π½Π°Π²ΠΈΠ³Π°Ρ†ΠΈΠΈ ΠΌΠ΅ΠΆΠ΄Ρƒ экранами +- Π”ΠΎΠ±Π°Π²Π»Π΅Π½ΠΈΠ΅/ΡƒΠ΄Π°Π»Π΅Π½ΠΈΠ΅ экранов +- ИзмСнСниС настроСк Π²ΠΎΡ€ΠΎΠ½ΠΊΠΈ + +### 3. **Π˜Π½Ρ‚Π΅Π³Ρ€Π°Ρ†ΠΈΡ с Π±Π°Π·ΠΎΠΉ Π΄Π°Π½Π½Ρ‹Ρ…:** +- Π‘ΠΎΡ…Ρ€Π°Π½Π΅Π½ΠΈΠ΅ baseline Ρ‚ΠΎΡ‡Π΅ΠΊ ΠΏΡ€ΠΈ save/publish +- ΠžΡ‡ΠΈΡΡ‚ΠΊΠ° истории ΠΏΡ€ΠΈ Π·Π°Π³Ρ€ΡƒΠ·ΠΊΠ΅ Π½ΠΎΠ²ΠΎΠΉ Π²ΠΎΡ€ΠΎΠ½ΠΊΠΈ + +## 🎯 Π˜ΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅ΠΌΡ‹Π΅ Π»ΡƒΡ‡ΡˆΠΈΠ΅ ΠΏΡ€Π°ΠΊΡ‚ΠΈΠΊΠΈ: + +### **Command Pattern over Memento:** +- Granular ΠΎΠΏΠ΅Ρ€Π°Ρ†ΠΈΠΈ вмСсто снимков состояния +- ΠŸΠΎΠ΄Π΄Π΅Ρ€ΠΆΠΊΠ° side-effects ΠΈ API calls +- Π‘ΠΎΠ²ΠΌΠ΅ΡΡ‚ΠΈΠΌΠΎΡΡ‚ΡŒ с collaborative editing + +### **Time-based Linear History:** +- ИзбСганиС "anxiety" ΠΎΡ‚ ΠΏΠΎΡ‚Π΅Ρ€ΠΈ Π²Π΅Ρ‚ΠΎΠΊ истории +- Intuitive UX Π³Π΄Π΅ ΠΊΠ°ΠΆΠ΄Ρ‹ΠΉ шаг ΡƒΠ²Π΅Π»ΠΈΡ‡ΠΈΠ²Π°Π΅Ρ‚ счСтчик +- Как Π² Emacs - всС измСнСния ΡΠΎΡ…Ρ€Π°Π½ΡΡŽΡ‚ΡΡ + +### **Session-scoped с Π²ΠΎΠ·ΠΌΠΎΠΆΠ½ΠΎΡΡ‚ΡŒΡŽ Ρ€Π°ΡΡˆΠΈΡ€Π΅Π½ΠΈΡ:** +- ΠŸΡ€ΠΈΠ²ΡΠ·ΠΊΠ° ΠΊ сСссии рСдактирования +- Π’ΠΎΠ·ΠΌΠΎΠΆΠ½ΠΎΡΡ‚ΡŒ Π±ΡƒΠ΄ΡƒΡ‰Π΅Π³ΠΎ Ρ€Π°ΡΡˆΠΈΡ€Π΅Π½ΠΈΡ Π½Π° user-scope +- Cleanup ΠΏΡ€ΠΈ Π·Π°ΠΊΡ€Ρ‹Ρ‚ΠΈΠΈ сСссии + +**АрхитСктура Π³ΠΎΡ‚ΠΎΠ²Π° для production использования! πŸš€** diff --git a/IMPORT-GUIDE.md b/IMPORT-GUIDE.md new file mode 100644 index 0000000..30b189a --- /dev/null +++ b/IMPORT-GUIDE.md @@ -0,0 +1,201 @@ +# πŸ“₯ Π˜ΠΌΠΏΠΎΡ€Ρ‚ Π²ΠΎΡ€ΠΎΠ½ΠΎΠΊ Π² Π±Π°Π·Ρƒ Π΄Π°Π½Π½Ρ‹Ρ… + +Π­Ρ‚ΠΎΡ‚ скрипт позволяСт ΠΈΠΌΠΏΠΎΡ€Ρ‚ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ всС ΡΡƒΡ‰Π΅ΡΡ‚Π²ΡƒΡŽΡ‰ΠΈΠ΅ Π²ΠΎΡ€ΠΎΠ½ΠΊΠΈ ΠΈΠ· ΠΏΠ°ΠΏΠΊΠΈ `public/funnels/` Π² Π±Π°Π·Ρƒ Π΄Π°Π½Π½Ρ‹Ρ… MongoDB. + +## πŸš€ Быстрый старт + +```bash +# 1. Π£Π±Π΅Π΄ΠΈΡ‚Π΅ΡΡŒ Ρ‡Ρ‚ΠΎ MongoDB Π·Π°ΠΏΡƒΡ‰Π΅Π½ ΠΈ настроСн .env.local +npm run import:funnels +``` + +## πŸ“‹ ВрСбования + +### 1. MongoDB ΠΏΠΎΠ΄ΠΊΠ»ΡŽΡ‡Π΅Π½ΠΈΠ΅ +Π£Π±Π΅Π΄ΠΈΡ‚Π΅ΡΡŒ Ρ‡Ρ‚ΠΎ Π² `.env.local` ΡƒΠΊΠ°Π·Π°Π½ ΠΏΡ€Π°Π²ΠΈΠ»ΡŒΠ½Ρ‹ΠΉ `MONGODB_URI`: + +```bash +# .env.local +MONGODB_URI=mongodb://localhost:27017/witlab-funnel +# ΠΈΠ»ΠΈ для MongoDB Atlas: +# MONGODB_URI=mongodb+srv://username:password@cluster.mongodb.net/witlab-funnel +``` + +### 2. Π‘Ρ‚Ρ€ΡƒΠΊΡ‚ΡƒΡ€Π° Ρ„Π°ΠΉΠ»ΠΎΠ² +Π‘ΠΊΡ€ΠΈΠΏΡ‚ ΠΈΡ‰Π΅Ρ‚ JSON Ρ„Π°ΠΉΠ»Ρ‹ Π² ΠΏΠ°ΠΏΠΊΠ΅ `public/funnels/`. ΠšΠ°ΠΆΠ΄Ρ‹ΠΉ Ρ„Π°ΠΉΠ» Π΄ΠΎΠ»ΠΆΠ΅Π½ ΡΠΎΠ΄Π΅Ρ€ΠΆΠ°Ρ‚ΡŒ Π²Π°Π»ΠΈΠ΄Π½ΡƒΡŽ структуру Π²ΠΎΡ€ΠΎΠ½ΠΊΠΈ: + +```json +{ + "meta": { + "id": "unique-funnel-id", + "title": "НазваниС Π²ΠΎΡ€ΠΎΠ½ΠΊΠΈ", + "description": "ОписаниС Π²ΠΎΡ€ΠΎΠ½ΠΊΠΈ", + "firstScreenId": "screen-1" + }, + "screens": [ + { + "id": "screen-1", + "template": "info", + "title": { "text": "Π—Π°Π³ΠΎΠ»ΠΎΠ²ΠΎΠΊ" } + } + ] +} +``` + +## πŸ“Š Π§Ρ‚ΠΎ Π΄Π΅Π»Π°Π΅Ρ‚ скрипт + +### βœ… ΠŸΡ€ΠΎΠ²Π΅Ρ€ΡΠ΅Ρ‚ ΠΈ Π²Π°Π»ΠΈΠ΄ΠΈΡ€ΡƒΠ΅Ρ‚ +- ΠŸΠΎΠ΄ΠΊΠ»ΡŽΡ‡Π΅Π½ΠΈΠ΅ ΠΊ MongoDB +- Π‘Ρ‚Ρ€ΡƒΠΊΡ‚ΡƒΡ€Ρƒ JSON Ρ„Π°ΠΉΠ»ΠΎΠ² +- НаличиС ΠΎΠ±ΡΠ·Π°Ρ‚Π΅Π»ΡŒΠ½Ρ‹Ρ… ΠΏΠΎΠ»Π΅ΠΉ (`meta.id`, `screens`) + +### πŸ“¦ Π˜ΠΌΠΏΠΎΡ€Ρ‚ΠΈΡ€ΡƒΠ΅Ρ‚ Π΄Π°Π½Π½Ρ‹Π΅ +- Π‘ΠΎΠ·Π΄Π°Π΅Ρ‚ записи Π² ΠΊΠΎΠ»Π»Π΅ΠΊΡ†ΠΈΠΈ `funnels` +- Π“Π΅Π½Π΅Ρ€ΠΈΡ€ΡƒΠ΅Ρ‚ ΠΌΠ΅Ρ‚Π°Π΄Π°Π½Π½Ρ‹Π΅ (Π½Π°Π·Π²Π°Π½ΠΈΠ΅, описаниС) +- УстанавливаСт статус `published` +- ДобавляСт ΠΈΠ½Ρ„ΠΎΡ€ΠΌΠ°Ρ†ΠΈΡŽ ΠΎΠ± ΠΈΠΌΠΏΠΎΡ€Ρ‚Π΅ + +### πŸ” Π˜Π·Π±Π΅Π³Π°Π΅Ρ‚ Π΄ΡƒΠ±Π»ΠΈΠΊΠ°Ρ‚ΠΎΠ² +- ΠŸΡ€ΠΎΠ²Π΅Ρ€ΡΠ΅Ρ‚ сущСствованиС Π²ΠΎΡ€ΠΎΠ½ΠΊΠΈ ΠΏΠΎ `meta.id` +- ΠŸΡ€ΠΎΠΏΡƒΡΠΊΠ°Π΅Ρ‚ ΡƒΠΆΠ΅ ΠΈΠΌΠΏΠΎΡ€Ρ‚ΠΈΡ€ΠΎΠ²Π°Π½Π½Ρ‹Π΅ Ρ„Π°ΠΉΠ»Ρ‹ +- ΠŸΠΎΠΊΠ°Π·Ρ‹Π²Π°Π΅Ρ‚ Π΄Π΅Ρ‚Π°Π»ΡŒΠ½Ρ‹ΠΉ ΠΎΡ‚Ρ‡Π΅Ρ‚ + +## πŸ“ˆ Π Π΅Π·ΡƒΠ»ΡŒΡ‚Π°Ρ‚ Ρ€Π°Π±ΠΎΡ‚Ρ‹ + +ПослС запуска Π²Ρ‹ ΡƒΠ²ΠΈΠ΄ΠΈΡ‚Π΅: + +``` +πŸš€ Starting funnel import process... + +βœ… Connected to MongoDB +πŸ“ Found 12 funnel files in public/funnels/ + +πŸ“₯ Starting import of 12 funnels... + +[1/12] Processing funnel-test.json... + βœ… Imported as "Funnel Test" (ID: funnel-test) + +[2/12] Processing ru-career-accelerator.json... + ⏭️ Skipped - already exists (ID: ru-career-accelerator) + +... + +πŸ“Š Import Summary: +================== +βœ… Successfully imported: 10 +⏭️ Already existed: 2 +⚠️ Skipped (invalid): 0 +❌ Errors: 0 +πŸ“ Total processed: 12 + +πŸ“‹ Imported Funnels: + β€’ Funnel Test (funnel-test) - funnel-test.json + β€’ Career Accelerator (ru-career-accelerator) - ru-career-accelerator.json + ... + +πŸŽ‰ Import process completed! +``` + +## 🎯 ГСнСрация ΠΌΠ΅Ρ‚Π°Π΄Π°Π½Π½Ρ‹Ρ… + +Π‘ΠΊΡ€ΠΈΠΏΡ‚ автоматичСски Π³Π΅Π½Π΅Ρ€ΠΈΡ€ΡƒΠ΅Ρ‚ ΡƒΠ΄ΠΎΠ±Π½Ρ‹Π΅ названия ΠΈ описания: + +### НазваниС Π²ΠΎΡ€ΠΎΠ½ΠΊΠΈ +1. **Из `meta.title`** (Ссли Π΅ΡΡ‚ΡŒ) +2. **Из `meta.id`** ΠΏΡ€Π΅ΠΎΠ±Ρ€Π°Π·ΠΎΠ²Π°Π½Π½ΠΎΠ³ΠΎ Π² Ρ‡ΠΈΡ‚Π°Π΅ΠΌΡ‹ΠΉ Π²ΠΈΠ΄ + - `funnel-test` β†’ `Funnel Test` + - `ru-career-accelerator` β†’ `Ru Career Accelerator` +3. **По ΡƒΠΌΠΎΠ»Ρ‡Π°Π½ΠΈΡŽ**: `Imported Funnel` + +### ОписаниС Π²ΠΎΡ€ΠΎΠ½ΠΊΠΈ +1. **Из `meta.description`** (Ссли Π΅ΡΡ‚ΡŒ) +2. **АвтогСнСрация** Π½Π° основС: + - ΠšΠΎΠ»ΠΈΡ‡Π΅ΡΡ‚Π²Π° экранов: "Π’ΠΎΡ€ΠΎΠ½ΠΊΠ° с 5 экранами" + - Π˜ΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅ΠΌΡ‹Ρ… шаблонов: "Π’ΠΈΠΏΡ‹: info, form, list" + - Π˜ΡΡ‚ΠΎΡ‡Π½ΠΈΠΊΠ°: "Π˜ΠΌΠΏΠΎΡ€Ρ‚ΠΈΡ€ΠΎΠ²Π°Π½Π° ΠΈΠ· JSON Ρ„Π°ΠΉΠ»Π°" + +## πŸ—ƒοΈ Π‘Ρ‚Ρ€ΡƒΠΊΡ‚ΡƒΡ€Π° Π² Π±Π°Π·Π΅ Π΄Π°Π½Π½Ρ‹Ρ… + +КаТдая импортированная Π²ΠΎΡ€ΠΎΠ½ΠΊΠ° сохраняСтся ΠΊΠ°ΠΊ: + +```json +{ + "_id": "ObjectId", + "funnelData": { /* ΠžΡ€ΠΈΠ³ΠΈΠ½Π°Π»ΡŒΠ½Π°Ρ структура JSON */ }, + "name": "Π‘Π³Π΅Π½Π΅Ρ€ΠΈΡ€ΠΎΠ²Π°Π½Π½ΠΎΠ΅ Π½Π°Π·Π²Π°Π½ΠΈΠ΅", + "description": "Π‘Π³Π΅Π½Π΅Ρ€ΠΈΡ€ΠΎΠ²Π°Π½Π½ΠΎΠ΅ описаниС", + "status": "published", + "version": 1, + "createdBy": "import-script", + "usage": { "totalViews": 0, "totalCompletions": 0 }, + "createdAt": "2025-01-27T02:13:24.000Z", + "updatedAt": "2025-01-27T02:13:24.000Z", + "publishedAt": "2025-01-27T02:13:24.000Z" +} +``` + +## πŸ”§ УстранСниС ΠΏΡ€ΠΎΠ±Π»Π΅ΠΌ + +### Ошибка ΠΏΠΎΠ΄ΠΊΠ»ΡŽΡ‡Π΅Π½ΠΈΡ ΠΊ MongoDB +``` +❌ Failed to connect to MongoDB: connect ECONNREFUSED 127.0.0.1:27017 +``` +**РСшСниС**: ЗапуститС MongoDB ΠΈΠ»ΠΈ ΠΏΡ€ΠΎΠ²Π΅Ρ€ΡŒΡ‚Π΅ `MONGODB_URI` + +### Π€Π°ΠΉΠ»Ρ‹ Π½Π΅ Π½Π°ΠΉΠ΄Π΅Π½Ρ‹ +``` +πŸ“­ No funnel files found to import. +``` +**РСшСниС**: Π£Π±Π΅Π΄ΠΈΡ‚Π΅ΡΡŒ Ρ‡Ρ‚ΠΎ JSON Ρ„Π°ΠΉΠ»Ρ‹ находятся Π² `public/funnels/` + +### Π’Π°Π»ΠΈΠ΄Π°Ρ†ΠΈΠΎΠ½Π½Ρ‹Π΅ ошибки +``` +⚠️ Validation warnings for example.json: Missing meta.id +``` +**РСшСниС**: ΠŸΡ€ΠΎΠ²Π΅Ρ€ΡŒΡ‚Π΅ структуру JSON Ρ„Π°ΠΉΠ»Π° + +### Π”ΡƒΠ±Π»ΠΈΠΊΠ°Ρ‚Ρ‹ Π² Π±Π°Π·Π΅ +``` +⏭️ Skipped - already exists (ID: funnel-test) +``` +**Π­Ρ‚ΠΎ Π½ΠΎΡ€ΠΌΠ°Π»ΡŒΠ½ΠΎ**: Π‘ΠΊΡ€ΠΈΠΏΡ‚ Π½Π΅ пСрСзаписываСт ΡΡƒΡ‰Π΅ΡΡ‚Π²ΡƒΡŽΡ‰ΠΈΠ΅ Π²ΠΎΡ€ΠΎΠ½ΠΊΠΈ + +## πŸ“± ПослС ΠΈΠΌΠΏΠΎΡ€Ρ‚Π° + +### Π“Π΄Π΅ Π½Π°ΠΉΡ‚ΠΈ ΠΈΠΌΠΏΠΎΡ€Ρ‚ΠΈΡ€ΠΎΠ²Π°Π½Π½Ρ‹Π΅ Π²ΠΎΡ€ΠΎΠ½ΠΊΠΈ +1. **Админка**: `http://localhost:3000/admin` +2. **ΠŸΡ€ΡΠΌΠΎΠΉ доступ**: `http://localhost:3000/{funnel-id}` +3. **Π Π΅Π΄Π°ΠΊΡ‚ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅**: `/admin/builder/{database-id}` + +### Π§Ρ‚ΠΎ ΠΌΠΎΠΆΠ½ΠΎ Π΄Π΅Π»Π°Ρ‚ΡŒ +- βœ… Π Π΅Π΄Π°ΠΊΡ‚ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ Π² Π±ΠΈΠ»Π΄Π΅Ρ€Π΅ +- βœ… Π”ΡƒΠ±Π»ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ ΠΈ ΡΠΎΠ·Π΄Π°Π²Π°Ρ‚ΡŒ Π²Π°Ρ€ΠΈΠ°Ρ†ΠΈΠΈ +- βœ… ΠŸΡ€ΠΎΡΠΌΠ°Ρ‚Ρ€ΠΈΠ²Π°Ρ‚ΡŒ статистику +- βœ… Π­ΠΊΡΠΏΠΎΡ€Ρ‚ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ ΠΎΠ±Ρ€Π°Ρ‚Π½ΠΎ Π² JSON +- βœ… ΠŸΡƒΠ±Π»ΠΈΠΊΠΎΠ²Π°Ρ‚ΡŒ/Π°Ρ€Ρ…ΠΈΠ²ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ + +### Π‘ΠΎΠ²ΠΌΠ΅ΡΡ‚ΠΈΠΌΠΎΡΡ‚ΡŒ +- βœ… ΠžΡ€ΠΈΠ³ΠΈΠ½Π°Π»ΡŒΠ½Ρ‹Π΅ JSON Ρ„Π°ΠΉΠ»Ρ‹ ΠΏΡ€ΠΎΠ΄ΠΎΠ»ΠΆΠ°ΡŽΡ‚ Ρ€Π°Π±ΠΎΡ‚Π°Ρ‚ΡŒ +- βœ… Π˜ΠΌΠΏΠΎΡ€Ρ‚ΠΈΡ€ΠΎΠ²Π°Π½Π½Ρ‹Π΅ Π²ΠΎΡ€ΠΎΠ½ΠΊΠΈ ΠΈΠΌΠ΅ΡŽΡ‚ ΠΏΡ€ΠΈΠΎΡ€ΠΈΡ‚Π΅Ρ‚ ΠΏΡ€ΠΈ Π·Π°Π³Ρ€ΡƒΠ·ΠΊΠ΅ +- βœ… Полная обратная ΡΠΎΠ²ΠΌΠ΅ΡΡ‚ΠΈΠΌΠΎΡΡ‚ΡŒ + +## πŸ”„ ΠŸΠΎΠ²Ρ‚ΠΎΡ€Π½Ρ‹ΠΉ запуск + +Π‘ΠΊΡ€ΠΈΠΏΡ‚ ΠΌΠΎΠΆΠ½ΠΎ Π·Π°ΠΏΡƒΡΠΊΠ°Ρ‚ΡŒ нСсколько Ρ€Π°Π·: +- **БСзопасно**: Π½Π΅ создаСт Π΄ΡƒΠ±Π»ΠΈΠΊΠ°Ρ‚Ρ‹ +- **Π£ΠΌΠ½ΠΎ**: ΠΈΠΌΠΏΠΎΡ€Ρ‚ΠΈΡ€ΡƒΠ΅Ρ‚ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ Π½ΠΎΠ²Ρ‹Π΅ Ρ„Π°ΠΉΠ»Ρ‹ +- **Быстро**: пропускаСт ΡƒΠΆΠ΅ ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚Π°Π½Π½Ρ‹Π΅ + +## πŸ“ Π›ΠΎΠ³ΠΈ ΠΈ ΠΎΡ‚Ρ‡Π΅Ρ‚Ρ‹ + +Π‘ΠΊΡ€ΠΈΠΏΡ‚ Π²Ρ‹Π²ΠΎΠ΄ΠΈΡ‚ ΠΏΠΎΠ΄Ρ€ΠΎΠ±Π½ΡƒΡŽ ΠΈΠ½Ρ„ΠΎΡ€ΠΌΠ°Ρ†ΠΈΡŽ: +- πŸ“ ΠšΠΎΠ»ΠΈΡ‡Π΅ΡΡ‚Π²ΠΎ Π½Π°ΠΉΠ΄Π΅Π½Π½Ρ‹Ρ… Ρ„Π°ΠΉΠ»ΠΎΠ² +- πŸ”„ ΠŸΡ€ΠΎΠ³Ρ€Π΅ΡΡ ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚ΠΊΠΈ ΠΊΠ°ΠΆΠ΄ΠΎΠ³ΠΎ Ρ„Π°ΠΉΠ»Π° +- βœ… Π£ΡΠΏΠ΅ΡˆΠ½Ρ‹Π΅ ΠΈΠΌΠΏΠΎΡ€Ρ‚Ρ‹ с дСталями +- ⚠️ ΠŸΡ€Π΅Π΄ΡƒΠΏΡ€Π΅ΠΆΠ΄Π΅Π½ΠΈΡ ΠΈ пропуски +- ❌ Ошибки с объяснСниями +- πŸ“Š Π˜Ρ‚ΠΎΠ³ΠΎΠ²Π°Ρ сводка + +--- + +**πŸ’‘ Π‘ΠΎΠ²Π΅Ρ‚**: ЗапуститС скрипт послС настройки Π±Π°Π·Ρ‹ Π΄Π°Π½Π½Ρ‹Ρ…, Ρ‡Ρ‚ΠΎΠ±Ρ‹ быстро ΠΌΠΈΠ³Ρ€ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ всС ΡΡƒΡ‰Π΅ΡΡ‚Π²ΡƒΡŽΡ‰ΠΈΠ΅ Π²ΠΎΡ€ΠΎΠ½ΠΊΠΈ Π² Π½ΠΎΠ²ΡƒΡŽ Π°Π΄ΠΌΠΈΠ½ΠΊΡƒ! diff --git a/README-ADMIN.md b/README-ADMIN.md new file mode 100644 index 0000000..59ca26f --- /dev/null +++ b/README-ADMIN.md @@ -0,0 +1,216 @@ +# 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 +``` + +## ИспользованиС + +### Π‘ΠΎΠ·Π΄Π°Π½ΠΈΠ΅ Π½ΠΎΠ²ΠΎΠΉ Π²ΠΎΡ€ΠΎΠ½ΠΊΠΈ +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 Π³ΠΎΡ‚ΠΎΠ²Π° ΠΊ использованию! πŸš€** diff --git a/package-lock.json b/package-lock.json index eb818bb..8e2f91c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,13 +9,16 @@ "version": "0.1.0", "dependencies": { "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "dotenv": "^17.2.2", "lucide-react": "^0.544.0", + "mongoose": "^8.18.2", "next": "15.5.3", "react": "19.1.0", "react-dom": "19.1.0", @@ -1670,6 +1673,15 @@ "react": ">=16" } }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.3.1.tgz", + "integrity": "sha512-6nZrq5kfAz0POWyhljnbWQQJQ5uT8oE2ddX303q1uY0tWsivWKgBDXBBvuFPwOqRRalXJuVO9EjOdVtuhLX0zg==", + "license": "MIT", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -1966,6 +1978,127 @@ } } }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-label": { "version": "2.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", @@ -1989,6 +2122,30 @@ } } }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-presence": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", @@ -2101,6 +2258,21 @@ } } }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-controllable-state": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", @@ -2138,6 +2310,24 @@ } } }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-layout-effect": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", @@ -3487,6 +3677,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", + "license": "MIT" + }, + "node_modules/@types/whatwg-url": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", + "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", + "license": "MIT", + "dependencies": { + "@types/webidl-conversions": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.43.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.43.0.tgz", @@ -4553,6 +4758,18 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/aria-query": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", @@ -4906,6 +5123,15 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bson": { + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz", + "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.20.1" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -5273,7 +5499,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -5370,6 +5595,12 @@ "node": ">=8" } }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, "node_modules/doctrine": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", @@ -5390,6 +5621,18 @@ "dev": true, "license": "MIT" }, + "node_modules/dotenv": { + "version": "17.2.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.2.tgz", + "integrity": "sha512-Sf2LSQP+bOlhKWWyhFsn0UsfdK/kCWRv1iuA2gXAwt3dyNabr6QSj00I2V10pidqz69soatm9ZwZvpQMTIOd5Q==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -6467,6 +6710,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -7485,6 +7737,15 @@ "node": ">=4.0" } }, + "node_modules/kareem": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.6.3.tgz", + "integrity": "sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -7900,6 +8161,12 @@ "node": ">= 0.4" } }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==", + "license": "MIT" + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -8036,6 +8303,105 @@ "dev": true, "license": "MIT" }, + "node_modules/mongodb": { + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.18.0.tgz", + "integrity": "sha512-fO5ttN9VC8P0F5fqtQmclAkgXZxbIkYRTUi1j8JO6IYwvamkhtYDilJr35jOPELR49zqCJgXZWwCtW7B+TM8vQ==", + "license": "Apache-2.0", + "dependencies": { + "@mongodb-js/saslprep": "^1.1.9", + "bson": "^6.10.4", + "mongodb-connection-string-url": "^3.0.0" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0 || ^2.0.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.2.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mongodb-connection-string-url": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz", + "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==", + "license": "Apache-2.0", + "dependencies": { + "@types/whatwg-url": "^11.0.2", + "whatwg-url": "^14.1.0 || ^13.0.0" + } + }, + "node_modules/mongoose": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.18.2.tgz", + "integrity": "sha512-gA6GFlshOHUdNyw9OQTmMLSGzVOPbcbjaSZ1dvR5iMp668N2UUznTuzgTY6V6Q41VtBc4kmL/qqML1RNgXB5Fg==", + "license": "MIT", + "dependencies": { + "bson": "^6.10.4", + "kareem": "2.6.3", + "mongodb": "~6.18.0", + "mpath": "0.9.0", + "mquery": "5.0.0", + "ms": "2.1.3", + "sift": "17.1.3" + }, + "engines": { + "node": ">=16.20.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mongoose" + } + }, + "node_modules/mpath": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", + "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mquery": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/mquery/-/mquery-5.0.0.tgz", + "integrity": "sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==", + "license": "MIT", + "dependencies": { + "debug": "4.x" + }, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -8050,7 +8416,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -8666,7 +9031,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -8742,6 +9106,75 @@ "dev": true, "license": "MIT" }, + "node_modules/react-remove-scroll": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz", + "integrity": "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/recast": { "version": "0.23.11", "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", @@ -9327,6 +9760,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sift": { + "version": "17.1.3", + "resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz", + "integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ==", + "license": "MIT" + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -9411,6 +9850,15 @@ "node": ">=0.10.0" } }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "license": "MIT", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -10094,6 +10542,18 @@ "node": ">=6" } }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/ts-api-utils": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", @@ -10411,6 +10871,49 @@ "punycode": "^2.1.0" } }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/vite": { "version": "7.1.5", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.5.tgz", @@ -10681,6 +11184,15 @@ "node": ">=10.13.0" } }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, "node_modules/webpack": { "version": "5.101.3", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.101.3.tgz", @@ -10775,6 +11287,19 @@ "node": ">=4.0" } }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index fef34d5..bf1ef67 100644 --- a/package.json +++ b/package.json @@ -8,19 +8,23 @@ "start": "next start", "lint": "eslint", "bake:funnels": "node scripts/bake-funnels.mjs", + "import:funnels": "node scripts/import-funnels-to-db.mjs", "prebuild": "npm run bake:funnels", "storybook": "storybook dev -p 6006 --ci", "build-storybook": "storybook build" }, "dependencies": { "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-progress": "^1.1.7", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "dotenv": "^17.2.2", "lucide-react": "^0.544.0", + "mongoose": "^8.18.2", "next": "15.5.3", "react": "19.1.0", "react-dom": "19.1.0", diff --git a/public/funnels/funnel-test-variants.json b/public/funnels/funnel-test-variants.json index 84595e8..9ac5ec9 100644 --- a/public/funnels/funnel-test-variants.json +++ b/public/funnels/funnel-test-variants.json @@ -1,6 +1,6 @@ { "meta": { - "id": "funnel-test", + "id": "funnel-test-variants", "title": "Relationship Portrait", "description": "Demo funnel mirroring design screens with branching by analysis target.", "firstScreenId": "intro-welcome" @@ -593,7 +593,6 @@ ] }, "bottomActionButton": { - "text": "Continue", "show": false }, "navigation": { diff --git a/public/funnels/funnel-test.json b/public/funnels/funnel-test.json index bebe57b..c8a9738 100644 --- a/public/funnels/funnel-test.json +++ b/public/funnels/funnel-test.json @@ -704,7 +704,6 @@ ] }, "bottomActionButton": { - "text": "Continue", "show": false }, "navigation": { diff --git a/scripts/import-funnels-to-db.mjs b/scripts/import-funnels-to-db.mjs new file mode 100644 index 0000000..c6e7d15 --- /dev/null +++ b/scripts/import-funnels-to-db.mjs @@ -0,0 +1,354 @@ +#!/usr/bin/env node + +import fs from 'fs/promises'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import mongoose from 'mongoose'; +import dotenv from 'dotenv'; + +// Load environment variables +dotenv.config({ path: '.env.local' }); + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// MongoDB connection URI +const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/witlab-funnel'; + +// Mongoose schemas (inline for the script) +const FunnelDataSchema = new mongoose.Schema({ + meta: { + id: { type: String, required: true }, + version: String, + title: String, + description: String, + firstScreenId: String + }, + defaultTexts: { + nextButton: { type: String, default: 'Next' }, + continueButton: { type: String, default: 'Continue' } + }, + screens: [mongoose.Schema.Types.Mixed] +}, { _id: false }); + +const FunnelSchema = new mongoose.Schema({ + // ΠžΡΠ½ΠΎΠ²Π½Ρ‹Π΅ Π΄Π°Π½Π½Ρ‹Π΅ Π²ΠΎΡ€ΠΎΠ½ΠΊΠΈ + funnelData: { + type: FunnelDataSchema, + required: true + }, + + // ΠœΠ΅Ρ‚Π°Π΄Π°Π½Π½Ρ‹Π΅ для Π°Π΄ΠΌΠΈΠ½ΠΊΠΈ + name: { + type: String, + required: true, + trim: true, + maxlength: 200 + }, + description: { + type: String, + trim: true, + maxlength: 1000 + }, + status: { + type: String, + enum: ['draft', 'published', 'archived'], + default: 'published', // Π˜ΠΌΠΏΠΎΡ€Ρ‚ΠΈΡ€ΠΎΠ²Π°Π½Π½Ρ‹Π΅ Π²ΠΎΡ€ΠΎΠ½ΠΊΠΈ считаСм ΠΎΠΏΡƒΠ±Π»ΠΈΠΊΠΎΠ²Π°Π½Π½Ρ‹ΠΌΠΈ + required: true + }, + + // БистСма вСрсий + version: { + type: Number, + default: 1, + min: 1 + }, + + // ΠŸΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡŒΡΠΊΠΈΠ΅ Π΄Π°Π½Π½Ρ‹Π΅ + createdBy: { type: String, default: 'import-script' }, + lastModifiedBy: { type: String, default: 'import-script' }, + + // Бтатистика + usage: { + totalViews: { type: Number, default: 0, min: 0 }, + totalCompletions: { type: Number, default: 0, min: 0 }, + lastUsed: Date + }, + + // Timestamps + publishedAt: { type: Date, default: Date.now } +}, { + timestamps: true, + collection: 'funnels' +}); + +// Π˜Π½Π΄Π΅ΠΊΡΡ‹ +FunnelSchema.index({ 'funnelData.meta.id': 1 }, { unique: true }); +FunnelSchema.index({ status: 1, updatedAt: -1 }); + +const FunnelModel = mongoose.models.Funnel || mongoose.model('Funnel', FunnelSchema); + +// Utility functions +function generateFunnelName(funnelData) { + // ΠŸΡ‹Ρ‚Π°Π΅ΠΌΡΡ ΠΏΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ имя ΠΈΠ· Ρ€Π°Π·Π½Ρ‹Ρ… источников + if (funnelData.meta?.title) { + return funnelData.meta.title; + } + + if (funnelData.meta?.id) { + // ΠŸΡ€Π΅ΠΎΠ±Ρ€Π°Π·ΡƒΠ΅ΠΌ ID Π² Ρ‡ΠΈΡ‚Π°Π΅ΠΌΠΎΠ΅ Π½Π°Π·Π²Π°Π½ΠΈΠ΅ + return funnelData.meta.id + .split('-') + .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + } + + return 'Imported Funnel'; +} + +function generateFunnelDescription(funnelData) { + if (funnelData.meta?.description) { + return funnelData.meta.description; + } + + // Π“Π΅Π½Π΅Ρ€ΠΈΡ€ΡƒΠ΅ΠΌ описаниС Π½Π° основС количСства экранов + const screenCount = funnelData.screens?.length || 0; + const templates = funnelData.screens?.map(s => s.template).filter(Boolean) || []; + const uniqueTemplates = [...new Set(templates)]; + + return `Π’ΠΎΡ€ΠΎΠ½ΠΊΠ° с ${screenCount} экран${screenCount === 1 ? 'ΠΎΠΌ' : screenCount < 5 ? 'Π°ΠΌΠΈ' : 'Π°ΠΌΠΈ'}.${ + uniqueTemplates.length > 0 ? ` Π’ΠΈΠΏΡ‹: ${uniqueTemplates.join(', ')}.` : '' + } Π˜ΠΌΠΏΠΎΡ€Ρ‚ΠΈΡ€ΠΎΠ²Π°Π½Π° ΠΈΠ· JSON Ρ„Π°ΠΉΠ»Π°.`; +} + +async function connectToDatabase() { + try { + await mongoose.connect(MONGODB_URI, { + bufferCommands: false, + maxPoolSize: 10, + serverSelectionTimeoutMS: 5000, + socketTimeoutMS: 45000, + }); + console.log('βœ… Connected to MongoDB'); + } catch (error) { + console.error('❌ Failed to connect to MongoDB:', error.message); + process.exit(1); + } +} + +async function findFunnelFiles() { + const funnelsDir = path.join(__dirname, '..', 'public', 'funnels'); + + try { + const files = await fs.readdir(funnelsDir); + const jsonFiles = files.filter(file => file.endsWith('.json')); + + console.log(`πŸ“ Found ${jsonFiles.length} funnel files in public/funnels/`); + + return jsonFiles.map(file => ({ + filename: file, + filepath: path.join(funnelsDir, file) + })); + } catch (error) { + console.error('❌ Failed to read funnels directory:', error.message); + process.exit(1); + } +} + +async function validateFunnelData(funnelData, filename) { + const errors = []; + + if (!funnelData) { + errors.push('Empty funnel data'); + } + + if (!funnelData.meta) { + errors.push('Missing meta object'); + } else { + if (!funnelData.meta.id) { + errors.push('Missing meta.id'); + } + } + + if (!funnelData.screens) { + errors.push('Missing screens array'); + } else if (!Array.isArray(funnelData.screens)) { + errors.push('screens is not an array'); + } else if (funnelData.screens.length === 0) { + errors.push('Empty screens array'); + } + + if (errors.length > 0) { + console.warn(`⚠️ Validation warnings for ${filename}:`, errors); + return false; + } + + return true; +} + +async function importFunnel(funnelFile) { + const { filename, filepath } = funnelFile; + + try { + // Π§ΠΈΡ‚Π°Π΅ΠΌ ΠΈ парсим JSON Ρ„Π°ΠΉΠ» + const fileContent = await fs.readFile(filepath, 'utf-8'); + const funnelData = JSON.parse(fileContent); + + // Π’Π°Π»ΠΈΠ΄ΠΈΡ€ΡƒΠ΅ΠΌ структуру Π΄Π°Π½Π½Ρ‹Ρ… + if (!validateFunnelData(funnelData, filename)) { + return { filename, status: 'skipped', reason: 'Invalid data structure' }; + } + + // ΠŸΡ€ΠΎΠ²Π΅Ρ€ΡΠ΅ΠΌ, сущСствуСт Π»ΠΈ ΡƒΠΆΠ΅ Π²ΠΎΡ€ΠΎΠ½ΠΊΠ° с Ρ‚Π°ΠΊΠΈΠΌ ID + const existingFunnel = await FunnelModel.findOne({ + 'funnelData.meta.id': funnelData.meta.id + }); + + if (existingFunnel) { + return { + filename, + status: 'exists', + funnelId: funnelData.meta.id, + reason: 'Funnel with this ID already exists in database' + }; + } + + // Π‘ΠΎΠ·Π΄Π°Π΅ΠΌ Π½ΠΎΠ²ΡƒΡŽ запись Π² Π±Π°Π·Π΅ Π΄Π°Π½Π½Ρ‹Ρ… + const funnel = new FunnelModel({ + funnelData: funnelData, + name: generateFunnelName(funnelData), + description: generateFunnelDescription(funnelData), + status: 'published', + version: 1, + createdBy: 'import-script', + lastModifiedBy: 'import-script', + usage: { + totalViews: 0, + totalCompletions: 0 + }, + publishedAt: new Date() + }); + + const savedFunnel = await funnel.save(); + + return { + filename, + status: 'imported', + funnelId: funnelData.meta.id, + databaseId: savedFunnel._id.toString(), + name: savedFunnel.name + }; + + } catch (error) { + return { + filename, + status: 'error', + reason: error.message + }; + } +} + +async function main() { + console.log('πŸš€ Starting funnel import process...\n'); + + // ΠŸΠΎΠ΄ΠΊΠ»ΡŽΡ‡Π°Π΅ΠΌΡΡ ΠΊ Π±Π°Π·Π΅ Π΄Π°Π½Π½Ρ‹Ρ… + await connectToDatabase(); + + // Находим всС JSON Ρ„Π°ΠΉΠ»Ρ‹ Π²ΠΎΡ€ΠΎΠ½ΠΎΠΊ + const funnelFiles = await findFunnelFiles(); + + if (funnelFiles.length === 0) { + console.log('πŸ“­ No funnel files found to import.'); + process.exit(0); + } + + console.log(`\nπŸ“₯ Starting import of ${funnelFiles.length} funnels...\n`); + + // Π˜ΠΌΠΏΠΎΡ€Ρ‚ΠΈΡ€ΡƒΠ΅ΠΌ ΠΊΠ°ΠΆΠ΄ΡƒΡŽ Π²ΠΎΡ€ΠΎΠ½ΠΊΡƒ + const results = []; + + for (let i = 0; i < funnelFiles.length; i++) { + const funnelFile = funnelFiles[i]; + const progress = `[${i + 1}/${funnelFiles.length}]`; + + console.log(`${progress} Processing ${funnelFile.filename}...`); + + const result = await importFunnel(funnelFile); + results.push(result); + + // ΠŸΠΎΠΊΠ°Π·Ρ‹Π²Π°Π΅ΠΌ Ρ€Π΅Π·ΡƒΠ»ΡŒΡ‚Π°Ρ‚ + switch (result.status) { + case 'imported': + console.log(` βœ… Imported as "${result.name}" (ID: ${result.funnelId})`); + break; + case 'exists': + console.log(` ⏭️ Skipped - already exists (ID: ${result.funnelId})`); + break; + case 'skipped': + console.log(` ⚠️ Skipped - ${result.reason}`); + break; + case 'error': + console.log(` ❌ Error - ${result.reason}`); + break; + } + } + + // ΠŸΠΎΠΊΠ°Π·Ρ‹Π²Π°Π΅ΠΌ сводку + console.log('\nπŸ“Š Import Summary:'); + console.log('=================='); + + const imported = results.filter(r => r.status === 'imported'); + const existing = results.filter(r => r.status === 'exists'); + const skipped = results.filter(r => r.status === 'skipped'); + const errors = results.filter(r => r.status === 'error'); + + console.log(`βœ… Successfully imported: ${imported.length}`); + console.log(`⏭️ Already existed: ${existing.length}`); + console.log(`⚠️ Skipped (invalid): ${skipped.length}`); + console.log(`❌ Errors: ${errors.length}`); + console.log(`πŸ“ Total processed: ${results.length}`); + + // ΠŸΠΎΠΊΠ°Π·Ρ‹Π²Π°Π΅ΠΌ Π΄Π΅Ρ‚Π°Π»ΡŒΠ½ΡƒΡŽ ΠΈΠ½Ρ„ΠΎΡ€ΠΌΠ°Ρ†ΠΈΡŽ ΠΎΠ± ΠΈΠΌΠΏΠΎΡ€Ρ‚ΠΈΡ€ΠΎΠ²Π°Π½Π½Ρ‹Ρ… Π²ΠΎΡ€ΠΎΠ½ΠΊΠ°Ρ… + if (imported.length > 0) { + console.log('\nπŸ“‹ Imported Funnels:'); + imported.forEach(result => { + console.log(` β€’ ${result.name} (${result.funnelId}) - ${result.filename}`); + }); + } + + // ΠŸΠΎΠΊΠ°Π·Ρ‹Π²Π°Π΅ΠΌ ошибки + if (errors.length > 0) { + console.log('\n❌ Errors:'); + errors.forEach(result => { + console.log(` β€’ ${result.filename}: ${result.reason}`); + }); + } + + // ΠŸΠΎΠΊΠ°Π·Ρ‹Π²Π°Π΅ΠΌ ΠΏΡ€ΠΎΠΏΡƒΡ‰Π΅Π½Π½Ρ‹Π΅ + if (skipped.length > 0) { + console.log('\n⚠️ Skipped:'); + skipped.forEach(result => { + console.log(` β€’ ${result.filename}: ${result.reason}`); + }); + } + + console.log('\nπŸŽ‰ Import process completed!'); + + // ΠŸΠΎΠΊΠ°Π·Ρ‹Π²Π°Π΅ΠΌ ΡΠ»Π΅Π΄ΡƒΡŽΡ‰ΠΈΠ΅ шаги + if (imported.length > 0) { + console.log('\nπŸ“Œ Next Steps:'); + console.log('β€’ Visit /admin to manage your funnels'); + console.log('β€’ Imported funnels are marked as "published"'); + console.log('β€’ You can edit them in /admin/builder/[id]'); + console.log('β€’ Original JSON files remain unchanged'); + } + + await mongoose.connection.close(); + console.log('\nπŸ‘‹ Database connection closed.'); +} + +// ЗапускаСм скрипт +main().catch(error => { + console.error('\nπŸ’₯ Fatal error:', error); + process.exit(1); +}); diff --git a/src/app/[funnelId]/[screenId]/page.tsx b/src/app/[funnelId]/[screenId]/page.tsx index aaf7995..221ece2 100644 --- a/src/app/[funnelId]/[screenId]/page.tsx +++ b/src/app/[funnelId]/[screenId]/page.tsx @@ -7,6 +7,32 @@ import { loadFunnelDefinition, } from "@/lib/funnel/loadFunnelDefinition"; import { FunnelRuntime } from "@/components/funnel/FunnelRuntime"; +import type { FunnelDefinition } from "@/lib/funnel/types"; + +// Ѐункция для Π·Π°Π³Ρ€ΡƒΠ·ΠΊΠΈ Π²ΠΎΡ€ΠΎΠ½ΠΊΠΈ ΠΈΠ· Π±Π°Π·Ρ‹ Π΄Π°Π½Π½Ρ‹Ρ… Π½Π°ΠΏΡ€ΡΠΌΡƒΡŽ +async function loadFunnelFromDatabase(funnelId: string): Promise { + try { + // Π˜ΠΌΠΏΠΎΡ€Ρ‚ΠΈΡ€ΡƒΠ΅ΠΌ ΠΌΠΎΠ΄Π΅Π»ΠΈ Π½Π°ΠΏΡ€ΡΠΌΡƒΡŽ вмСсто HTTP запроса + 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 FunnelScreenPageProps { params: Promise<{ @@ -15,7 +41,7 @@ interface FunnelScreenPageProps { }>; } -export const dynamic = "force-static"; +export const dynamic = "force-dynamic"; // ИзмСнСно для ΠΏΠΎΠ΄Π΄Π΅Ρ€ΠΆΠΊΠΈ Π±Π°Π·Ρ‹ Π΄Π°Π½Π½Ρ‹Ρ… export function generateStaticParams() { return listBakedFunnelScreenParams(); @@ -43,7 +69,25 @@ export default async function FunnelScreenPage({ params, }: FunnelScreenPageProps) { const { funnelId, screenId } = await params; - const funnel = await loadFunnelDefinition(funnelId); + + let funnel: FunnelDefinition | null = null; + + // Π‘Π½Π°Ρ‡Π°Π»Π° пытаСмся Π·Π°Π³Ρ€ΡƒΠ·ΠΈΡ‚ΡŒ ΠΈΠ· Π±Π°Π·Ρ‹ Π΄Π°Π½Π½Ρ‹Ρ… + funnel = await loadFunnelFromDatabase(funnelId); + + // Если Π½Π΅ Π½Π°ΠΉΠ΄Π΅Π½ΠΎ Π² Π±Π°Π·Π΅, пытаСмся Π·Π°Π³Ρ€ΡƒΠ·ΠΈΡ‚ΡŒ ΠΈΠ· JSON Ρ„Π°ΠΉΠ»ΠΎΠ² + if (!funnel) { + try { + funnel = await loadFunnelDefinition(funnelId); + } catch (error) { + console.error(`Failed to load funnel '${funnelId}' from files:`, error); + } + } + + // Если Π²ΠΎΡ€ΠΎΠ½ΠΊΠ° Π½Π΅ Π½Π°ΠΉΠ΄Π΅Π½Π° Π½ΠΈ Π² Π±Π°Π·Π΅, Π½ΠΈ Π² Ρ„Π°ΠΉΠ»Π°Ρ… + if (!funnel) { + notFound(); + } const screen = funnel.screens.find((item) => item.id === screenId); if (!screen) { diff --git a/src/app/[funnelId]/page.tsx b/src/app/[funnelId]/page.tsx index 9a6611f..b9fe26c 100644 --- a/src/app/[funnelId]/page.tsx +++ b/src/app/[funnelId]/page.tsx @@ -4,10 +4,31 @@ import { listBakedFunnelIds, peekBakedFunnelDefinition, } from "@/lib/funnel/loadFunnelDefinition"; +import type { FunnelDefinition } from "@/lib/funnel/types"; -export const dynamic = "force-static"; +// Ѐункция для Π·Π°Π³Ρ€ΡƒΠ·ΠΊΠΈ Π²ΠΎΡ€ΠΎΠ½ΠΊΠΈ ΠΈΠ· Π±Π°Π·Ρ‹ Π΄Π°Π½Π½Ρ‹Ρ… +async function loadFunnelFromDatabase(funnelId: string): Promise { + try { + // ΠŸΡ‹Ρ‚Π°Π΅ΠΌΡΡ Π·Π°Π³Ρ€ΡƒΠ·ΠΈΡ‚ΡŒ ΠΈΠ· Π±Π°Π·Ρ‹ Π΄Π°Π½Π½Ρ‹Ρ… Ρ‡Π΅Ρ€Π΅Π· API + const response = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'}/api/funnels/by-funnel-id/${funnelId}`, { + cache: 'no-store' // НС ΠΊΠ΅ΡˆΠΈΡ€ΡƒΠ΅ΠΌ, Ρ‚.ΠΊ. Π²ΠΎΡ€ΠΎΠ½ΠΊΠΈ ΠΌΠΎΠ³ΡƒΡ‚ ΠΎΠ±Π½ΠΎΠ²Π»ΡΡ‚ΡŒΡΡ + }); + + if (response.ok) { + return await response.json(); + } + + return null; + } catch (error) { + console.error(`Failed to load funnel '${funnelId}' from database:`, error); + return null; + } +} + +export const dynamic = "force-dynamic"; // ИзмСнСно Π½Π° dynamic для ΠΏΠΎΠ΄Π΄Π΅Ρ€ΠΆΠΊΠΈ Π±Π°Π·Ρ‹ Π΄Π°Π½Π½Ρ‹Ρ… export function generateStaticParams() { + // Π“Π΅Π½Π΅Ρ€ΠΈΡ€ΡƒΠ΅ΠΌ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ для статичСских JSON Ρ„Π°ΠΉΠ»ΠΎΠ² return listBakedFunnelIds().map((funnelId) => ({ funnelId })); } @@ -20,11 +41,22 @@ interface FunnelRootPageProps { export default async function FunnelRootPage({ params }: FunnelRootPageProps) { const { funnelId } = await params; - let funnel: ReturnType; - try { - funnel = peekBakedFunnelDefinition(funnelId); - } catch (error) { - console.error(`Failed to load funnel '${funnelId}':`, error); + let funnel: FunnelDefinition | null = null; + + // Π‘Π½Π°Ρ‡Π°Π»Π° пытаСмся Π·Π°Π³Ρ€ΡƒΠ·ΠΈΡ‚ΡŒ ΠΈΠ· Π±Π°Π·Ρ‹ Π΄Π°Π½Π½Ρ‹Ρ… + funnel = await loadFunnelFromDatabase(funnelId); + + // Если Π½Π΅ Π½Π°ΠΉΠ΄Π΅Π½ΠΎ Π² Π±Π°Π·Π΅, пытаСмся Π·Π°Π³Ρ€ΡƒΠ·ΠΈΡ‚ΡŒ ΠΈΠ· JSON Ρ„Π°ΠΉΠ»ΠΎΠ² + if (!funnel) { + try { + funnel = peekBakedFunnelDefinition(funnelId); + } catch (error) { + console.error(`Failed to load funnel '${funnelId}' from files:`, error); + } + } + + // Если Π²ΠΎΡ€ΠΎΠ½ΠΊΠ° Π½Π΅ Π½Π°ΠΉΠ΄Π΅Π½Π° Π½ΠΈ Π² Π±Π°Π·Π΅, Π½ΠΈ Π² Ρ„Π°ΠΉΠ»Π°Ρ… + if (!funnel) { notFound(); } diff --git a/src/app/admin/builder/[id]/page.tsx b/src/app/admin/builder/[id]/page.tsx new file mode 100644 index 0000000..add1a35 --- /dev/null +++ b/src/app/admin/builder/[id]/page.tsx @@ -0,0 +1,287 @@ +"use client"; + +import { useEffect, useState } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { BuilderProvider } from "@/lib/admin/builder/context"; +import { BuilderUndoRedoProvider } from "@/components/admin/builder/BuilderUndoRedoProvider"; +import { BuilderTopBar } from "@/components/admin/builder/BuilderTopBar"; +import { BuilderSidebar } from "@/components/admin/builder/BuilderSidebar"; +import { BuilderCanvas } from "@/components/admin/builder/BuilderCanvas"; +import { BuilderPreview } from "@/components/admin/builder/BuilderPreview"; +import type { BuilderState } from '@/lib/admin/builder/context'; +import type { FunnelDefinition } from '@/lib/funnel/types'; +import { deserializeFunnelDefinition } from '@/lib/admin/builder/utils'; + +interface FunnelData { + _id: string; + name: string; + description?: string; + status: 'draft' | 'published' | 'archived'; + version: number; + funnelData: FunnelDefinition; + createdAt: string; + updatedAt: string; +} + +export default function FunnelBuilderPage() { + const params = useParams(); + const router = useRouter(); + const funnelId = params.id as string; + + const [funnelData, setFunnelData] = useState(null); + const [initialBuilderState, setInitialBuilderState] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + + // Π“Π΅Π½Π΅Ρ€ΠΈΡ€ΡƒΠ΅ΠΌ ΡƒΠ½ΠΈΠΊΠ°Π»ΡŒΠ½Ρ‹ΠΉ sessionId для истории ΠΈΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΠΉ + const [sessionId] = useState(() => `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`); + + // Π—Π°Π³Ρ€ΡƒΠ·ΠΊΠ° Π²ΠΎΡ€ΠΎΠ½ΠΊΠΈ ΠΈΠ· Π±Π°Π·Ρ‹ Π΄Π°Π½Π½Ρ‹Ρ… + const loadFunnel = async () => { + try { + setLoading(true); + setError(null); + + const response = await fetch(`/api/funnels/${funnelId}`); + if (!response.ok) { + if (response.status === 404) { + throw new Error('Π’ΠΎΡ€ΠΎΠ½ΠΊΠ° Π½Π΅ Π½Π°ΠΉΠ΄Π΅Π½Π°'); + } + throw new Error('Ошибка Π·Π°Π³Ρ€ΡƒΠ·ΠΊΠΈ Π²ΠΎΡ€ΠΎΠ½ΠΊΠΈ'); + } + + const data: FunnelData = await response.json(); + setFunnelData(data); + + // ΠšΠΎΠ½Π²Π΅Ρ€Ρ‚ΠΈΡ€ΡƒΠ΅ΠΌ Π΄Π°Π½Π½Ρ‹Π΅ Π²ΠΎΡ€ΠΎΠ½ΠΊΠΈ Π² состояниС Π±ΠΈΠ»Π΄Π΅Ρ€Π° + const builderState = deserializeFunnelDefinition(data.funnelData); + setInitialBuilderState({ + ...builderState, + selectedScreenId: builderState.screens[0]?.id || null, + isDirty: false + }); + + } catch (err) { + setError(err instanceof Error ? err.message : 'НСизвСстная ошибка'); + } finally { + setLoading(false); + } + }; + + // Π‘ΠΎΡ…Ρ€Π°Π½Π΅Π½ΠΈΠ΅ Π²ΠΎΡ€ΠΎΠ½ΠΊΠΈ + const saveFunnel = async (builderState: BuilderState, publish: boolean = false) => { + if (!funnelData || saving) return; + + try { + setSaving(true); + + // ΠšΠΎΠ½Π²Π΅Ρ€Ρ‚ΠΈΡ€ΡƒΠ΅ΠΌ состояниС Π±ΠΈΠ»Π΄Π΅Ρ€Π° ΠΎΠ±Ρ€Π°Ρ‚Π½ΠΎ Π² FunnelDefinition + const updatedFunnelData: FunnelDefinition = { + meta: builderState.meta, + defaultTexts: { + nextButton: 'Π”Π°Π»Π΅Π΅', + continueButton: 'ΠŸΡ€ΠΎΠ΄ΠΎΠ»ΠΆΠΈΡ‚ΡŒ' + }, + screens: builderState.screens.map(screen => { + // Π£Π±ΠΈΡ€Π°Π΅ΠΌ position ΠΈΠ· экрана ΠΏΡ€ΠΈ сохранСнии + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { position, ...screenWithoutPosition } = screen; + return screenWithoutPosition; + }) + }; + + const response = await fetch(`/api/funnels/${funnelId}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: builderState.meta.title || funnelData.name, + description: builderState.meta.description || funnelData.description, + funnelData: updatedFunnelData, + status: publish ? 'published' : funnelData.status, + sessionId, + actionDescription: publish ? 'Π’ΠΎΡ€ΠΎΠ½ΠΊΠ° ΠΎΠΏΡƒΠ±Π»ΠΈΠΊΠΎΠ²Π°Π½Π°' : 'Π’ΠΎΡ€ΠΎΠ½ΠΊΠ° сохранСна' + }) + }); + + if (!response.ok) { + throw new Error('Ошибка сохранСния Π²ΠΎΡ€ΠΎΠ½ΠΊΠΈ'); + } + + const updatedFunnel = await response.json(); + setFunnelData(updatedFunnel); + + // ΠŸΠΎΠΊΠ°Π·Ρ‹Π²Π°Π΅ΠΌ ΡƒΠ²Π΅Π΄ΠΎΠΌΠ»Π΅Π½ΠΈΠ΅ ΠΎΠ± ΡƒΡΠΏΠ΅ΡˆΠ½ΠΎΠΌ сохранСнии + // TODO: Π”ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ toast увСдомлСния + + return true; + + } catch (err) { + setError(err instanceof Error ? err.message : 'Ошибка сохранСния'); + return false; + } finally { + setSaving(false); + } + }; + + // Π‘ΠΎΠ·Π΄Π°Π½ΠΈΠ΅ записи Π² истории для Ρ‚Π΅ΠΊΡƒΡ‰Π΅Π³ΠΎ измСнСния + const createHistoryEntry = async ( + builderState: BuilderState, + actionType: string, + description: string + ) => { + try { + const funnelSnapshot: FunnelDefinition = { + meta: builderState.meta, + defaultTexts: { + nextButton: 'Π”Π°Π»Π΅Π΅', + continueButton: 'ΠŸΡ€ΠΎΠ΄ΠΎΠ»ΠΆΠΈΡ‚ΡŒ' + }, + screens: builderState.screens.map(screen => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { position, ...screenWithoutPosition } = screen; + return screenWithoutPosition; + }) + }; + + await fetch(`/api/funnels/${funnelId}/history`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + sessionId, + funnelSnapshot, + actionType, + description + }) + }); + } catch (error) { + console.error('Failed to create history entry:', error); + // НС ΠΏΡ€Π΅Ρ€Ρ‹Π²Π°Π΅ΠΌ Ρ€Π°Π±ΠΎΡ‚Ρƒ, Ссли Π½Π΅ ΡƒΠ΄Π°Π»ΠΎΡΡŒ ΡΠΎΠ·Π΄Π°Ρ‚ΡŒ запись Π² истории + } + }; + + // ΠžΠ±Ρ€Π°Π±ΠΎΡ‚Ρ‡ΠΈΠΊΠΈ для Ρ‚ΠΎΠΏ Π±Π°Ρ€Π° + const handleSave = async (builderState: BuilderState): Promise => { + const success = await saveFunnel(builderState, false); + if (success) { + // Π‘ΠΎΠ·Π΄Π°Π΅ΠΌ запись Π² истории ΠΊΠ°ΠΊ Π±Π°Π·ΠΎΠ²ΡƒΡŽ Ρ‚ΠΎΡ‡ΠΊΡƒ + await createHistoryEntry(builderState, 'save', 'ИзмСнСния сохранСны'); + } + return success || false; + }; + + const handlePublish = async (builderState: BuilderState): Promise => { + const success = await saveFunnel(builderState, true); + if (success) { + await createHistoryEntry(builderState, 'publish', 'Π’ΠΎΡ€ΠΎΠ½ΠΊΠ° ΠΎΠΏΡƒΠ±Π»ΠΈΠΊΠΎΠ²Π°Π½Π°'); + } + return success || false; + }; + + const handleNew = () => { + router.push('/admin'); + }; + + const handleBackToCatalog = () => { + router.push('/admin'); + }; + + useEffect(() => { + loadFunnel(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // loadFunnel создаСтся Π·Π°Π½ΠΎΠ²ΠΎ ΠΏΡ€ΠΈ ΠΊΠ°ΠΆΠ΄ΠΎΠΌ Ρ€Π΅Π½Π΄Π΅Ρ€Π΅, Π½ΠΎ Π½Π°ΠΌ Π½ΡƒΠΆΠ΅Π½ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ ΠΏΠ΅Ρ€Π²Ρ‹ΠΉ Π²Ρ‹Π·ΠΎΠ² + + // Loading state + if (loading) { + return ( +
+
+
+
Π—Π°Π³Ρ€ΡƒΠ·ΠΊΠ° Π²ΠΎΡ€ΠΎΠ½ΠΊΠΈ...
+
+
+ ); + } + + // Error state + if (error) { + return ( +
+
+
{error}
+ +
+
+ ); + } + + // Main render + if (!initialBuilderState || !funnelData) { + return null; + } + + return ( + + +
+ + {/* Top Bar */} + + + {/* Main Content */} +
+ + {/* Sidebar */} + + + {/* Canvas Area */} +
+ + {/* Canvas */} +
+ +
+ + {/* Preview Panel */} +
+
+

ΠŸΡ€Π΅Π΄ΠΏΡ€ΠΎΡΠΌΠΎΡ‚Ρ€

+

+ Как выглядит экран Π² Π±Ρ€Π°ΡƒΠ·Π΅Ρ€Π΅ +

+
+
+ +
+
+ +
+
+
+
+
+ ); +} diff --git a/src/app/admin/funnels/builder/page.tsx b/src/app/admin/funnels/builder/page.tsx deleted file mode 100644 index beb1f5e..0000000 --- a/src/app/admin/funnels/builder/page.tsx +++ /dev/null @@ -1,74 +0,0 @@ -"use client"; - -import { useCallback, useState } from "react"; - -import { BuilderLayout } from "@/components/admin/builder/BuilderLayout"; -import { BuilderSidebar } from "@/components/admin/builder/BuilderSidebar"; -import { BuilderCanvas } from "@/components/admin/builder/BuilderCanvas"; -import { BuilderPreview } from "@/components/admin/builder/BuilderPreview"; -import { BuilderTopBar } from "@/components/admin/builder/BuilderTopBar"; -import { - BuilderProvider, - useBuilderDispatch, -} from "@/lib/admin/builder/context"; - - -function BuilderView() { - const dispatch = useBuilderDispatch(); - const [exportJson, setExportJson] = useState(null); - const [showPreview, setShowPreview] = useState(true); - - const handleNew = useCallback(() => { - dispatch({ type: "reset" }); - }, [dispatch]); - - const handleTogglePreview = useCallback(() => { - setShowPreview((prev: boolean) => !prev); - }, []); - - const handleLoadError = useCallback((message: string) => { - console.error("Load error:", message); - }, []); - - return ( - <> - - } - sidebar={} - canvas={} - preview={} - showPreview={showPreview} - onTogglePreview={handleTogglePreview} - /> - - {exportJson && ( -
-
- Export JSON Π³ΠΎΡ‚ΠΎΠ² - -
-
- )} - - ); -} - -export default function BuilderPage() { - return ( - - - - ); -} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx new file mode 100644 index 0000000..31f3d32 --- /dev/null +++ b/src/app/admin/page.tsx @@ -0,0 +1,490 @@ +"use client"; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { Button } from '@/components/ui/button'; +import { TextInput } from '@/components/ui/TextInput/TextInput'; +import { + Plus, + Search, + Copy, + Trash2, + Edit, + Eye, + RefreshCw +} from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface FunnelListItem { + _id: string; + name: string; + description?: string; + status: 'draft' | 'published' | 'archived'; + version: number; + createdAt: string; + updatedAt: string; + publishedAt?: string; + usage: { + totalViews: number; + totalCompletions: number; + lastUsed?: string; + }; + funnelData?: { + meta?: { + id?: string; + title?: string; + description?: string; + }; + }; +} + +interface PaginationInfo { + current: number; + total: number; + count: number; + totalItems: number; +} + +export default function AdminCatalogPage() { + const [funnels, setFunnels] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Π€ΠΈΠ»ΡŒΡ‚Ρ€Ρ‹ ΠΈ поиск + const [searchQuery, setSearchQuery] = useState(''); + const [statusFilter, setStatusFilter] = useState('all'); + const [sortBy, setSortBy] = useState('updatedAt'); + const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); + + // ΠŸΠ°Π³ΠΈΠ½Π°Ρ†ΠΈΡ + const [pagination, setPagination] = useState({ + current: 1, + total: 1, + count: 0, + totalItems: 0 + }); + + // Π’Ρ‹Π΄Π΅Π»Π΅Π½Π½Ρ‹Π΅ элСмСнты - TODO: Ρ€Π΅Π°Π»ΠΈΠ·ΠΎΠ²Π°Ρ‚ΡŒ Π² Π±ΡƒΠ΄ΡƒΡ‰Π΅ΠΌ + // const [selectedFunnels, setSelectedFunnels] = useState>(new Set()); + + // Π—Π°Π³Ρ€ΡƒΠ·ΠΊΠ° Π΄Π°Π½Π½Ρ‹Ρ… + const loadFunnels = async (page: number = 1) => { + try { + setLoading(true); + setError(null); + + const params = new URLSearchParams({ + page: page.toString(), + limit: '20', + sortBy, + sortOrder, + ...(searchQuery && { search: searchQuery }), + ...(statusFilter !== 'all' && { status: statusFilter }) + }); + + const response = await fetch(`/api/funnels?${params}`); + if (!response.ok) { + throw new Error('Failed to fetch funnels'); + } + + const data = await response.json(); + setFunnels(data.funnels); + setPagination(data.pagination); + + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setLoading(false); + } + }; + + // Π­Ρ„Ρ„Π΅ΠΊΡ‚Ρ‹ + useEffect(() => { + loadFunnels(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchQuery, statusFilter, sortBy, sortOrder]); // loadFunnels создаСтся Π·Π°Π½ΠΎΠ²ΠΎ ΠΏΡ€ΠΈ ΠΊΠ°ΠΆΠ΄ΠΎΠΌ Ρ€Π΅Π½Π΄Π΅Ρ€Π΅ + + // Π‘ΠΎΠ·Π΄Π°Π½ΠΈΠ΅ Π½ΠΎΠ²ΠΎΠΉ Π²ΠΎΡ€ΠΎΠ½ΠΊΠΈ + const handleCreateFunnel = async () => { + try { + const newFunnelData = { + name: 'Новая Π²ΠΎΡ€ΠΎΠ½ΠΊΠ°', + description: 'ОписаниС Π½ΠΎΠ²ΠΎΠΉ Π²ΠΎΡ€ΠΎΠ½ΠΊΠΈ', + funnelData: { + meta: { + id: `funnel-${Date.now()}`, + title: 'Новая Π²ΠΎΡ€ΠΎΠ½ΠΊΠ°', + description: 'ОписаниС Π½ΠΎΠ²ΠΎΠΉ Π²ΠΎΡ€ΠΎΠ½ΠΊΠΈ', + firstScreenId: 'screen-1' + }, + defaultTexts: { + nextButton: 'Π”Π°Π»Π΅Π΅', + continueButton: 'ΠŸΡ€ΠΎΠ΄ΠΎΠ»ΠΆΠΈΡ‚ΡŒ' + }, + screens: [ + { + id: 'screen-1', + template: 'info', + title: { + text: 'Π”ΠΎΠ±Ρ€ΠΎ ΠΏΠΎΠΆΠ°Π»ΠΎΠ²Π°Ρ‚ΡŒ!', + font: 'manrope', + weight: 'bold' + }, + description: { + text: 'Π­Ρ‚ΠΎ ваша новая Π²ΠΎΡ€ΠΎΠ½ΠΊΠ°. НачнитС Ρ€Π΅Π΄Π°ΠΊΡ‚ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅.', + color: 'muted' + }, + icon: { + type: 'emoji', + value: '🎯', + size: 'lg' + } + } + ] + } + }; + + const response = await fetch('/api/funnels', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(newFunnelData) + }); + + if (!response.ok) { + throw new Error('Failed to create funnel'); + } + + const createdFunnel = await response.json(); + + // ΠŸΠ΅Ρ€Π΅Ρ…ΠΎΠ΄ΠΈΠΌ ΠΊ Ρ€Π΅Π΄Π°ΠΊΡ‚ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΡŽ Π½ΠΎΠ²ΠΎΠΉ Π²ΠΎΡ€ΠΎΠ½ΠΊΠΈ + window.location.href = `/admin/builder/${createdFunnel._id}`; + + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create funnel'); + } + }; + + // Π”ΡƒΠ±Π»ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ Π²ΠΎΡ€ΠΎΠ½ΠΊΠΈ + const handleDuplicateFunnel = async (funnelId: string, funnelName: string) => { + try { + const response = await fetch(`/api/funnels/${funnelId}/duplicate`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: `${funnelName} (копия)` + }) + }); + + if (!response.ok) { + throw new Error('Failed to duplicate funnel'); + } + + // ОбновляСм список + loadFunnels(pagination.current); + + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to duplicate funnel'); + } + }; + + // Π£Π΄Π°Π»Π΅Π½ΠΈΠ΅ Π²ΠΎΡ€ΠΎΠ½ΠΊΠΈ + const handleDeleteFunnel = async (funnelId: string, funnelName: string) => { + if (!confirm(`Π’Ρ‹ ΡƒΠ²Π΅Ρ€Π΅Π½Ρ‹, Ρ‡Ρ‚ΠΎ Ρ…ΠΎΡ‚ΠΈΡ‚Π΅ ΡƒΠ΄Π°Π»ΠΈΡ‚ΡŒ Π²ΠΎΡ€ΠΎΠ½ΠΊΡƒ "${funnelName}"?`)) { + return; + } + + try { + const response = await fetch(`/api/funnels/${funnelId}`, { + method: 'DELETE' + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Failed to delete funnel'); + } + + // ОбновляСм список + loadFunnels(pagination.current); + + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to delete funnel'); + } + }; + + // Бтатус badges + const getStatusBadge = (status: string) => { + const variants = { + draft: 'bg-yellow-100 text-yellow-800 border-yellow-200', + published: 'bg-green-100 text-green-800 border-green-200', + archived: 'bg-gray-100 text-gray-800 border-gray-200' + }; + + const labels = { + draft: 'Π§Π΅Ρ€Π½ΠΎΠ²ΠΈΠΊ', + published: 'ΠžΠΏΡƒΠ±Π»ΠΈΠΊΠΎΠ²Π°Π½Π°', + archived: 'Архивирована' + }; + + return ( + + {labels[status as keyof typeof labels]} + + ); + }; + + // Π€ΠΎΡ€ΠΌΠ°Ρ‚ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ Π΄Π°Ρ‚ + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString('ru-RU', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + }; + + return ( +
+
+ + {/* Header */} +
+
+
+

ΠšΠ°Ρ‚Π°Π»ΠΎΠ³ Π²ΠΎΡ€ΠΎΠ½ΠΎΠΊ

+

+ УправляйтС своими Π²ΠΎΡ€ΠΎΠ½ΠΊΠ°ΠΌΠΈ ΠΈ создавайтС Π½ΠΎΠ²Ρ‹Π΅ +

+
+ +
+
+ + {/* Π€ΠΈΠ»ΡŒΡ‚Ρ€Ρ‹ ΠΈ поиск */} +
+
+ + {/* Поиск */} +
+
+ + setSearchQuery(e.target.value)} + placeholder="Поиск по названию, описанию..." + className="pl-10" + /> +
+
+ + {/* Π€ΠΈΠ»ΡŒΡ‚Ρ€ статуса */} + + + {/* Π‘ΠΎΡ€Ρ‚ΠΈΡ€ΠΎΠ²ΠΊΠ° */} + + + +
+
+ + {/* Ошибка */} + {error && ( +
+
{error}
+
+ )} + + {/* Бписок Π²ΠΎΡ€ΠΎΠ½ΠΎΠΊ */} +
+ + {loading ? ( +
+ + ЗагруТаСтся... +
+ ) : funnels.length === 0 ? ( +
+
Π’ΠΎΡ€ΠΎΠ½ΠΊΠΈ Π½Π΅ Π½Π°ΠΉΠ΄Π΅Π½Ρ‹
+ +
+ ) : ( +
+ + + + + + + + + + + + {funnels.map((funnel) => ( + + + + + + + + ))} + +
+ НазваниС + + Бтатус + + Бтатистика + + ОбновлСна + + ДСйствия +
+
+
+ {funnel.name} +
+
+ ID: {funnel.funnelData?.meta?.id || 'N/A'} +
+ {funnel.description && ( +
+ {funnel.description} +
+ )} +
+
+ {getStatusBadge(funnel.status)} + +
+ {funnel.usage.totalViews} просмотров +
+
+ {funnel.usage.totalCompletions} Π·Π°Π²Π΅Ρ€ΡˆΠ΅Π½ΠΈΠΉ +
+
+
+ {formatDate(funnel.updatedAt)} +
+
+ v{funnel.version} +
+
+
+ + {/* ΠŸΡ€ΠΎΡΠΌΠΎΡ‚Ρ€ Π²ΠΎΡ€ΠΎΠ½ΠΊΠΈ */} + + + + + {/* Π Π΅Π΄Π°ΠΊΡ‚ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ */} + + + + + {/* Π”ΡƒΠ±Π»ΠΈΡ€ΠΎΠ²Π°Ρ‚ΡŒ */} + + + {/* Π£Π΄Π°Π»ΠΈΡ‚ΡŒ (Ρ‚ΠΎΠ»ΡŒΠΊΠΎ Ρ‡Π΅Ρ€Π½ΠΎΠ²ΠΈΠΊΠΈ) */} + {funnel.status === 'draft' && ( + + )} +
+
+
+ )} +
+ + {/* ΠŸΠ°Π³ΠΈΠ½Π°Ρ†ΠΈΡ */} + {pagination.total > 1 && ( +
+
+ Показано {pagination.count} ΠΈΠ· {pagination.totalItems} Π²ΠΎΡ€ΠΎΠ½ΠΎΠΊ +
+
+ + + {pagination.current} / {pagination.total} + + +
+
+ )} + +
+
+ ); +} diff --git a/src/app/api/funnels/[id]/duplicate/route.ts b/src/app/api/funnels/[id]/duplicate/route.ts new file mode 100644 index 0000000..d08bee3 --- /dev/null +++ b/src/app/api/funnels/[id]/duplicate/route.ts @@ -0,0 +1,113 @@ +import { NextRequest, NextResponse } from 'next/server'; +import connectMongoDB from '@/lib/mongodb'; +import FunnelModel from '@/lib/models/Funnel'; +import FunnelHistoryModel from '@/lib/models/FunnelHistory'; + +interface RouteParams { + params: Promise<{ + id: string; + }>; +} + +// POST /api/funnels/[id]/duplicate - ΡΠΎΠ·Π΄Π°Ρ‚ΡŒ копию Π²ΠΎΡ€ΠΎΠ½ΠΊΠΈ +export async function POST(request: NextRequest, { params }: RouteParams) { + try { + const { id } = await params; + await connectMongoDB(); + + const body = await request.json(); + const { name, newFunnelId } = body; + + // Находим ΠΈΡΡ…ΠΎΠ΄Π½ΡƒΡŽ Π²ΠΎΡ€ΠΎΠ½ΠΊΡƒ + const sourceFunnel = await FunnelModel.findById(id); + if (!sourceFunnel) { + return NextResponse.json( + { error: 'Source funnel not found' }, + { status: 404 } + ); + } + + // ΠŸΠΎΠ΄Π³ΠΎΡ‚Π°Π²Π»ΠΈΠ²Π°Π΅ΠΌ Π΄Π°Π½Π½Ρ‹Π΅ для ΠΊΠΎΠΏΠΈΠΈ + const duplicatedFunnelData = { + ...sourceFunnel.funnelData, + meta: { + ...sourceFunnel.funnelData.meta, + id: newFunnelId || `${sourceFunnel.funnelData.meta.id}-copy-${Date.now()}`, + title: `${sourceFunnel.funnelData.meta.title || sourceFunnel.name} (копия)`, + description: sourceFunnel.funnelData.meta.description + } + }; + + // ΠŸΡ€ΠΎΠ²Π΅Ρ€ΡΠ΅ΠΌ ΡƒΠ½ΠΈΠΊΠ°Π»ΡŒΠ½ΠΎΡΡ‚ΡŒ Π½ΠΎΠ²ΠΎΠ³ΠΎ ID + const existingFunnel = await FunnelModel.findOne({ + 'funnelData.meta.id': duplicatedFunnelData.meta.id + }); + + if (existingFunnel) { + return NextResponse.json( + { error: 'Funnel with this ID already exists' }, + { status: 409 } + ); + } + + // Π‘ΠΎΠ·Π΄Π°Π΅ΠΌ копию + const duplicatedFunnel = new FunnelModel({ + name: name || `${sourceFunnel.name} (копия)`, + description: sourceFunnel.description, + funnelData: duplicatedFunnelData, + status: 'draft', // Копии всСгда ΡΠΎΠ·Π΄Π°ΡŽΡ‚ΡΡ ΠΊΠ°ΠΊ Ρ‡Π΅Ρ€Π½ΠΎΠ²ΠΈΠΊΠΈ + version: 1, + parentFunnelId: sourceFunnel._id, // БвязываСм с исходной Π²ΠΎΡ€ΠΎΠ½ΠΊΠΎΠΉ + usage: { + totalViews: 0, + totalCompletions: 0 + } + }); + + const savedFunnel = await duplicatedFunnel.save(); + + // Π‘ΠΎΠ·Π΄Π°Π΅ΠΌ Π±Π°Π·ΠΎΠ²ΡƒΡŽ Ρ‚ΠΎΡ‡ΠΊΡƒ Π² истории для ΠΊΠΎΠΏΠΈΠΈ + const sessionId = `duplicate-${Date.now()}`; + await FunnelHistoryModel.create({ + funnelId: String(savedFunnel._id), + sessionId, + funnelSnapshot: duplicatedFunnelData, + actionType: 'create', + sequenceNumber: 0, + description: `Π‘ΠΎΠ·Π΄Π°Π½Π° копия Π²ΠΎΡ€ΠΎΠ½ΠΊΠΈ "${sourceFunnel.name}"`, + isBaseline: true, + changeDetails: { + action: 'duplicate', + previousValue: null, + newValue: { sourceId: sourceFunnel._id } + } + }); + + return NextResponse.json({ + _id: savedFunnel._id, + name: savedFunnel.name, + description: savedFunnel.description, + status: savedFunnel.status, + version: savedFunnel.version, + createdAt: savedFunnel.createdAt, + updatedAt: savedFunnel.updatedAt, + usage: savedFunnel.usage, + funnelData: savedFunnel.funnelData + }, { status: 201 }); + + } catch (error) { + console.error('POST /api/funnels/[id]/duplicate error:', error); + + if (error instanceof Error && error.message.includes('duplicate key')) { + return NextResponse.json( + { error: 'Funnel with this name already exists' }, + { status: 409 } + ); + } + + return NextResponse.json( + { error: 'Failed to duplicate funnel' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/funnels/[id]/history/route.ts b/src/app/api/funnels/[id]/history/route.ts new file mode 100644 index 0000000..0a18383 --- /dev/null +++ b/src/app/api/funnels/[id]/history/route.ts @@ -0,0 +1,127 @@ +import { NextRequest, NextResponse } from 'next/server'; +import connectMongoDB from '@/lib/mongodb'; +import FunnelHistoryModel from '@/lib/models/FunnelHistory'; + +interface RouteParams { + params: Promise<{ + id: string; + }>; +} + +// GET /api/funnels/[id]/history - ΠΏΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ ΠΈΡΡ‚ΠΎΡ€ΠΈΡŽ ΠΈΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΠΉ Π²ΠΎΡ€ΠΎΠ½ΠΊΠΈ +export async function GET(request: NextRequest, { params }: RouteParams) { + try { + const { id } = await params; + await connectMongoDB(); + + const { searchParams } = new URL(request.url); + const sessionId = searchParams.get('sessionId'); + const limit = parseInt(searchParams.get('limit') || '50'); + const includeSnapshots = searchParams.get('snapshots') === 'true'; + + const filter: Record = { funnelId: id }; + if (sessionId) { + filter.sessionId = sessionId; + } + + const historyQuery = FunnelHistoryModel + .find(filter) + .sort({ createdAt: -1, sequenceNumber: -1 }) + .limit(limit); + + // Π’ΠΊΠ»ΡŽΡ‡Π°Ρ‚ΡŒ снимки Π΄Π°Π½Π½Ρ‹Ρ… ΠΈΠ»ΠΈ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ ΠΌΠ΅Ρ‚Π°Π΄Π°Π½Π½Ρ‹Π΅ + if (!includeSnapshots) { + historyQuery.select('-funnelSnapshot'); + } + + const history = await historyQuery.lean(); + + return NextResponse.json({ + history: history.map(entry => ({ + ...entry, + _id: entry._id.toString(), + funnelId: entry.funnelId.toString() + })) + }); + + } catch (error) { + console.error('GET /api/funnels/[id]/history error:', error); + return NextResponse.json( + { error: 'Failed to fetch funnel history' }, + { status: 500 } + ); + } +} + +// POST /api/funnels/[id]/history - ΡΠΎΠ·Π΄Π°Ρ‚ΡŒ Π½ΠΎΠ²ΡƒΡŽ запись Π² истории +export async function POST(request: NextRequest, { params }: RouteParams) { + try { + const { id } = await params; + await connectMongoDB(); + + const body = await request.json(); + const { + sessionId, + funnelSnapshot, + actionType, + description, + changeDetails + } = body; + + // Валидация + if (!sessionId || !funnelSnapshot || !actionType) { + return NextResponse.json( + { error: 'sessionId, funnelSnapshot and actionType are required' }, + { status: 400 } + ); + } + + // ΠŸΠΎΠ»ΡƒΡ‡Π°Π΅ΠΌ ΡΠ»Π΅Π΄ΡƒΡŽΡ‰ΠΈΠΉ Π½ΠΎΠΌΠ΅Ρ€ ΠΏΠΎΡΠ»Π΅Π΄ΠΎΠ²Π°Ρ‚Π΅Π»ΡŒΠ½ΠΎΡΡ‚ΠΈ + const lastEntry = await FunnelHistoryModel + .findOne({ funnelId: id, sessionId }) + .sort({ sequenceNumber: -1 }); + + const nextSequenceNumber = (lastEntry?.sequenceNumber || -1) + 1; + + // Π‘ΠΎΠ·Π΄Π°Π΅ΠΌ запись + const historyEntry = await FunnelHistoryModel.create({ + funnelId: id, + sessionId, + funnelSnapshot, + actionType, + sequenceNumber: nextSequenceNumber, + description, + changeDetails + }); + + // ΠžΡ‡ΠΈΡ‰Π°Π΅ΠΌ старыС записи истории (оставляСм послСдниС 100) + const KEEP_ENTRIES = 100; + const entriesToDelete = await FunnelHistoryModel + .find({ funnelId: id, sessionId }) + .sort({ sequenceNumber: -1 }) + .skip(KEEP_ENTRIES) + .select('_id'); + + if (entriesToDelete.length > 0) { + const idsToDelete = entriesToDelete.map((entry: { _id: unknown }) => entry._id); + await FunnelHistoryModel.deleteMany({ _id: { $in: idsToDelete } }); + } + + return NextResponse.json({ + _id: historyEntry._id, + actionType: historyEntry.actionType, + description: historyEntry.description, + sequenceNumber: historyEntry.sequenceNumber, + isBaseline: historyEntry.isBaseline, + createdAt: historyEntry.createdAt, + changeDetails: historyEntry.changeDetails + }, { status: 201 }); + + } catch (error) { + console.error('POST /api/funnels/[id]/history error:', error); + return NextResponse.json( + { error: 'Failed to create history entry' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/funnels/[id]/route.ts b/src/app/api/funnels/[id]/route.ts new file mode 100644 index 0000000..bb60df1 --- /dev/null +++ b/src/app/api/funnels/[id]/route.ts @@ -0,0 +1,201 @@ +import { NextRequest, NextResponse } from 'next/server'; +import connectMongoDB from '@/lib/mongodb'; +import FunnelModel from '@/lib/models/Funnel'; +import FunnelHistoryModel from '@/lib/models/FunnelHistory'; +import type { FunnelDefinition } from '@/lib/funnel/types'; + +interface RouteParams { + params: Promise<{ + id: string; + }>; +} + +// GET /api/funnels/[id] - ΠΏΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ ΠΊΠΎΠ½ΠΊΡ€Π΅Ρ‚Π½ΡƒΡŽ Π²ΠΎΡ€ΠΎΠ½ΠΊΡƒ +export async function GET(request: NextRequest, { params }: RouteParams) { + try { + const { id } = await params; + await connectMongoDB(); + + const funnel = await FunnelModel.findById(id); + + if (!funnel) { + return NextResponse.json( + { error: 'Funnel not found' }, + { status: 404 } + ); + } + + return NextResponse.json({ + _id: funnel._id, + name: funnel.name, + description: funnel.description, + status: funnel.status, + version: funnel.version, + createdAt: funnel.createdAt, + updatedAt: funnel.updatedAt, + publishedAt: funnel.publishedAt, + usage: funnel.usage, + funnelData: funnel.funnelData + }); + + } catch (error) { + console.error('GET /api/funnels/[id] error:', error); + return NextResponse.json( + { error: 'Failed to fetch funnel' }, + { status: 500 } + ); + } +} + +// PUT /api/funnels/[id] - ΠΎΠ±Π½ΠΎΠ²ΠΈΡ‚ΡŒ Π²ΠΎΡ€ΠΎΠ½ΠΊΡƒ +export async function PUT(request: NextRequest, { params }: RouteParams) { + try { + const { id } = await params; + await connectMongoDB(); + + const body = await request.json(); + const { name, description, funnelData, status, sessionId, actionDescription } = body; + + // Валидация + if (funnelData && (!funnelData.meta || !funnelData.meta.id || !Array.isArray(funnelData.screens))) { + return NextResponse.json( + { error: 'Invalid funnel data structure' }, + { status: 400 } + ); + } + + const funnel = await FunnelModel.findById(id); + if (!funnel) { + return NextResponse.json( + { error: 'Funnel not found' }, + { status: 404 } + ); + } + + // БохраняСм ΠΏΡ€Π΅Π΄Ρ‹Π΄ΡƒΡ‰Π΅Π΅ состояниС для истории + const previousData = funnel.funnelData; + + // ОбновляСм поля + if (name !== undefined) funnel.name = name; + if (description !== undefined) funnel.description = description; + + // Π›ΠΎΠ³ΠΈΠΊΠ° вСрсионирования: + // - ΠŸΡ€ΠΈ сохранСнии (Π±Π΅Π· смСны статуса) - вСрсия НЕ увСличиваСтся + // - ΠŸΡ€ΠΈ ΠΏΡƒΠ±Π»ΠΈΠΊΠ°Ρ†ΠΈΠΈ - вСрсия увСличиваСтся ΠΈ мСняСтся статус + const isPublishing = status === 'published' && funnel.status !== 'published'; + + if (status !== undefined) funnel.status = status; + if (funnelData !== undefined) { + funnel.funnelData = funnelData as FunnelDefinition; + + // Π£Π²Π΅Π»ΠΈΡ‡ΠΈΠ²Π°Π΅ΠΌ Π²Π΅Ρ€ΡΠΈΡŽ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ ΠΏΡ€ΠΈ ΠΏΡƒΠ±Π»ΠΈΠΊΠ°Ρ†ΠΈΠΈ + if (isPublishing) { + funnel.version += 1; + funnel.publishedAt = new Date(); + } + } + + funnel.lastModifiedBy = 'current-user'; // TODO: Π·Π°ΠΌΠ΅Π½ΠΈΡ‚ΡŒ Π½Π° Ρ€Π΅Π°Π»ΡŒΠ½ΠΎΠ³ΠΎ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ + + const savedFunnel = await funnel.save(); + + // Π‘ΠΎΠ·Π΄Π°Π΅ΠΌ запись Π² истории, Ссли обновлялась структура Π²ΠΎΡ€ΠΎΠ½ΠΊΠΈ + if (funnelData && sessionId) { + // ΠŸΠΎΠ»ΡƒΡ‡Π°Π΅ΠΌ Ρ‚Π΅ΠΊΡƒΡ‰ΠΈΠΉ Π½ΠΎΠΌΠ΅Ρ€ ΠΏΠΎΡΠ»Π΅Π΄ΠΎΠ²Π°Ρ‚Π΅Π»ΡŒΠ½ΠΎΡΡ‚ΠΈ + const lastHistoryEntry = await FunnelHistoryModel + .findOne({ funnelId: id, sessionId }) + .sort({ sequenceNumber: -1 }); + + const nextSequenceNumber = (lastHistoryEntry?.sequenceNumber || -1) + 1; + + await FunnelHistoryModel.create({ + funnelId: id, + sessionId, + funnelSnapshot: funnelData, + actionType: status === 'published' ? 'publish' : 'update', + sequenceNumber: nextSequenceNumber, + description: actionDescription || 'Π’ΠΎΡ€ΠΎΠ½ΠΊΠ° ΠΎΠ±Π½ΠΎΠ²Π»Π΅Π½Π°', + isBaseline: status === 'published', + changeDetails: { + action: 'update-funnel', + previousValue: previousData, + newValue: funnelData + } + }); + + // ΠžΡ‡ΠΈΡ‰Π°Π΅ΠΌ старыС записи истории (оставляСм 100) + const KEEP_ENTRIES = 100; + const entriesToDelete = await FunnelHistoryModel + .find({ funnelId: id, sessionId }) + .sort({ sequenceNumber: -1 }) + .skip(KEEP_ENTRIES) + .select('_id'); + + if (entriesToDelete.length > 0) { + const idsToDelete = entriesToDelete.map((entry: { _id: unknown }) => entry._id); + await FunnelHistoryModel.deleteMany({ _id: { $in: idsToDelete } }); + } + } + + return NextResponse.json({ + _id: savedFunnel._id, + name: savedFunnel.name, + description: savedFunnel.description, + status: savedFunnel.status, + version: savedFunnel.version, + createdAt: savedFunnel.createdAt, + updatedAt: savedFunnel.updatedAt, + publishedAt: savedFunnel.publishedAt, + usage: savedFunnel.usage, + funnelData: savedFunnel.funnelData + }); + + } catch (error) { + console.error('PUT /api/funnels/[id] error:', error); + return NextResponse.json( + { error: 'Failed to update funnel' }, + { status: 500 } + ); + } +} + +// DELETE /api/funnels/[id] - ΡƒΠ΄Π°Π»ΠΈΡ‚ΡŒ Π²ΠΎΡ€ΠΎΠ½ΠΊΡƒ +export async function DELETE(request: NextRequest, { params }: RouteParams) { + try { + const { id } = await params; + await connectMongoDB(); + + const funnel = await FunnelModel.findById(id); + if (!funnel) { + return NextResponse.json( + { error: 'Funnel not found' }, + { status: 404 } + ); + } + + // ΠŸΡ€ΠΎΠ²Π΅Ρ€ΡΠ΅ΠΌ статус - ΠΎΠΏΡƒΠ±Π»ΠΈΠΊΠΎΠ²Π°Π½Π½Ρ‹Π΅ Π²ΠΎΡ€ΠΎΠ½ΠΊΠΈ нСльзя ΡƒΠ΄Π°Π»ΡΡ‚ΡŒ Π½Π°ΠΏΡ€ΡΠΌΡƒΡŽ + if (funnel.status === 'published') { + return NextResponse.json( + { error: 'Cannot delete published funnel. Archive it first.' }, + { status: 400 } + ); + } + + // УдаляСм Π²ΠΎΡ€ΠΎΠ½ΠΊΡƒ ΠΈ всю ΡΠ²ΡΠ·Π°Π½Π½ΡƒΡŽ ΠΈΡΡ‚ΠΎΡ€ΠΈΡŽ + await Promise.all([ + FunnelModel.findByIdAndDelete(id), + FunnelHistoryModel.deleteMany({ funnelId: id }) + ]); + + return NextResponse.json({ + message: 'Funnel deleted successfully' + }); + + } catch (error) { + console.error('DELETE /api/funnels/[id] error:', error); + return NextResponse.json( + { error: 'Failed to delete funnel' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/funnels/by-funnel-id/[funnelId]/route.ts b/src/app/api/funnels/by-funnel-id/[funnelId]/route.ts new file mode 100644 index 0000000..f8876db --- /dev/null +++ b/src/app/api/funnels/by-funnel-id/[funnelId]/route.ts @@ -0,0 +1,108 @@ +import { NextRequest, NextResponse } from 'next/server'; +import connectMongoDB from '@/lib/mongodb'; +import FunnelModel from '@/lib/models/Funnel'; + +interface RouteParams { + params: Promise<{ + funnelId: string; + }>; +} + +// GET /api/funnels/by-funnel-id/[funnelId] - ΠΏΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ Π²ΠΎΡ€ΠΎΠ½ΠΊΡƒ ΠΏΠΎ funnelData.meta.id +// Π­Ρ‚ΠΎΡ‚ endpoint обСспСчиваСт ΡΠΎΠ²ΠΌΠ΅ΡΡ‚ΠΈΠΌΠΎΡΡ‚ΡŒ с ΡΡƒΡ‰Π΅ΡΡ‚Π²ΡƒΡŽΡ‰ΠΈΠΌ ΠΊΠΎΠ΄ΠΎΠΌ, ΠΊΠΎΡ‚ΠΎΡ€Ρ‹ΠΉ ΠΎΠΆΠΈΠ΄Π°Π΅Ρ‚ +// Π·Π°Π³Ρ€ΡƒΠ·ΠΊΡƒ Π²ΠΎΡ€ΠΎΠ½ΠΊΠΈ ΠΏΠΎ funnel ID ΠΈΠ· JSON Ρ„Π°ΠΉΠ»ΠΎΠ² +export async function GET(request: NextRequest, { params }: RouteParams) { + try { + const { funnelId } = await params; + await connectMongoDB(); + + const funnel = await FunnelModel.findOne({ + 'funnelData.meta.id': funnelId, + status: { $in: ['draft', 'published'] } // Π˜ΡΠΊΠ»ΡŽΡ‡Π°Π΅ΠΌ Π°Ρ€Ρ…ΠΈΠ²ΠΈΡ€ΠΎΠ²Π°Π½Π½Ρ‹Π΅ + }); + + if (!funnel) { + // Если Π²ΠΎΡ€ΠΎΠ½ΠΊΠ° Π½Π΅ Π½Π°ΠΉΠ΄Π΅Π½Π° Π² Π‘Π”, Π²ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅ΠΌ 404 + return NextResponse.json( + { error: `Funnel with ID "${funnelId}" not found` }, + { status: 404 } + ); + } + + // Π£Π²Π΅Π»ΠΈΡ‡ΠΈΠ²Π°Π΅ΠΌ счСтчик просмотров + funnel.usage.totalViews += 1; + funnel.usage.lastUsed = new Date(); + await funnel.save(); + + // Π’ΠΎΠ·Π²Ρ€Π°Ρ‰Π°Π΅ΠΌ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ Π΄Π°Π½Π½Ρ‹Π΅ Π²ΠΎΡ€ΠΎΠ½ΠΊΠΈ (для совмСстимости с FunnelRuntime) + return NextResponse.json(funnel.funnelData); + + } catch (error) { + console.error('GET /api/funnels/by-funnel-id/[funnelId] error:', error); + return NextResponse.json( + { error: 'Failed to fetch funnel' }, + { status: 500 } + ); + } +} + +// PUT /api/funnels/by-funnel-id/[funnelId] - ΠΎΠ±Π½ΠΎΠ²ΠΈΡ‚ΡŒ Π²ΠΎΡ€ΠΎΠ½ΠΊΡƒ ΠΏΠΎ funnel ID +export async function PUT(request: NextRequest, { params }: RouteParams) { + try { + const { funnelId } = await params; + await connectMongoDB(); + + const funnelData = await request.json(); + + // Валидация + if (!funnelData.meta || !funnelData.meta.id || !Array.isArray(funnelData.screens)) { + return NextResponse.json( + { error: 'Invalid funnel data structure' }, + { status: 400 } + ); + } + + // ΠŸΡ€ΠΎΠ²Π΅Ρ€ΡΠ΅ΠΌ Ρ‡Ρ‚ΠΎ ID Π² Π΄Π°Π½Π½Ρ‹Ρ… соотвСтствуСт ID Π² URL + if (funnelData.meta.id !== funnelId) { + return NextResponse.json( + { error: 'Funnel ID mismatch' }, + { status: 400 } + ); + } + + const funnel = await FunnelModel.findOne({ + 'funnelData.meta.id': funnelId + }); + + if (!funnel) { + return NextResponse.json( + { error: `Funnel with ID "${funnelId}" not found` }, + { status: 404 } + ); + } + + // ОбновляСм Π΄Π°Π½Π½Ρ‹Π΅ Π²ΠΎΡ€ΠΎΠ½ΠΊΠΈ + funnel.funnelData = funnelData; + funnel.version += 1; + funnel.lastModifiedBy = 'api-update'; + + // ОбновляСм ΠΌΠ΅Ρ‚Π°-ΠΈΠ½Ρ„ΠΎΡ€ΠΌΠ°Ρ†ΠΈΡŽ Ссли ΠΎΠ½Π° измСнилась + if (funnelData.meta.title && funnelData.meta.title !== funnel.name) { + funnel.name = funnelData.meta.title; + } + if (funnelData.meta.description) { + funnel.description = funnelData.meta.description; + } + + const savedFunnel = await funnel.save(); + + return NextResponse.json(savedFunnel.funnelData); + + } catch (error) { + console.error('PUT /api/funnels/by-funnel-id/[funnelId] error:', error); + return NextResponse.json( + { error: 'Failed to update funnel' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/funnels/route.ts b/src/app/api/funnels/route.ts new file mode 100644 index 0000000..7a681ae --- /dev/null +++ b/src/app/api/funnels/route.ts @@ -0,0 +1,162 @@ +import { NextRequest, NextResponse } from 'next/server'; +import connectMongoDB from '@/lib/mongodb'; +import FunnelModel from '@/lib/models/Funnel'; +import FunnelHistoryModel from '@/lib/models/FunnelHistory'; +import type { FunnelDefinition } from '@/lib/funnel/types'; + +// GET /api/funnels - ΠΏΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ список всСх Π²ΠΎΡ€ΠΎΠ½ΠΎΠΊ +export async function GET(request: NextRequest) { + try { + await connectMongoDB(); + + const { searchParams } = new URL(request.url); + const status = searchParams.get('status'); + const search = searchParams.get('search'); + const limit = parseInt(searchParams.get('limit') || '20'); + const page = parseInt(searchParams.get('page') || '1'); + const sortBy = searchParams.get('sortBy') || 'updatedAt'; + const sortOrder = searchParams.get('sortOrder') || 'desc'; + + // Π‘Ρ‚Ρ€ΠΎΠΈΠΌ Ρ„ΠΈΠ»ΡŒΡ‚Ρ€ + const filter: Record = {}; + + if (status && ['draft', 'published', 'archived'].includes(status)) { + filter.status = status; + } + + if (search) { + filter.$or = [ + { name: { $regex: search, $options: 'i' } }, + { description: { $regex: search, $options: 'i' } }, + { 'funnelData.meta.title': { $regex: search, $options: 'i' } }, + { 'funnelData.meta.description': { $regex: search, $options: 'i' } } + ]; + } + + // Π‘Ρ‚Ρ€ΠΎΠΈΠΌ сортировку + const sort: Record = {}; + sort[sortBy] = sortOrder === 'desc' ? -1 : 1; + + // ВыполняСм запрос с ΠΏΠ°Π³ΠΈΠ½Π°Ρ†ΠΈΠ΅ΠΉ + const skip = (page - 1) * limit; + + const [funnels, total] = await Promise.all([ + FunnelModel + .find(filter) + .select('-funnelData.screens -funnelData.defaultTexts') // Π˜ΡΠΊΠ»ΡŽΡ‡Π°Π΅ΠΌ тяТСлыС Π΄Π°Π½Π½Ρ‹Π΅, Π½ΠΎ оставляСм meta + .sort(sort) + .skip(skip) + .limit(limit) + .lean(), + FunnelModel.countDocuments(filter) + ]); + + return NextResponse.json({ + funnels, + pagination: { + current: page, + total: Math.ceil(total / limit), + count: funnels.length, + totalItems: total + } + }); + + } catch (error) { + console.error('GET /api/funnels error:', error); + return NextResponse.json( + { error: 'Failed to fetch funnels' }, + { status: 500 } + ); + } +} + +// POST /api/funnels - ΡΠΎΠ·Π΄Π°Ρ‚ΡŒ Π½ΠΎΠ²ΡƒΡŽ Π²ΠΎΡ€ΠΎΠ½ΠΊΡƒ +export async function POST(request: NextRequest) { + try { + await connectMongoDB(); + + const body = await request.json(); + const { name, description, funnelData, status = 'draft' } = body; + + // Валидация + if (!name || !funnelData) { + return NextResponse.json( + { error: 'Name and funnel data are required' }, + { status: 400 } + ); + } + + if (!funnelData.meta || !funnelData.meta.id || !Array.isArray(funnelData.screens)) { + return NextResponse.json( + { error: 'Invalid funnel data structure' }, + { status: 400 } + ); + } + + // ΠŸΡ€ΠΎΠ²Π΅Ρ€ΡΠ΅ΠΌ ΡƒΠ½ΠΈΠΊΠ°Π»ΡŒΠ½ΠΎΡΡ‚ΡŒ funnelData.meta.id + const existingFunnel = await FunnelModel.findOne({ + 'funnelData.meta.id': funnelData.meta.id + }); + + if (existingFunnel) { + return NextResponse.json( + { error: 'Funnel with this ID already exists' }, + { status: 409 } + ); + } + + // Π‘ΠΎΠ·Π΄Π°Π΅ΠΌ Π²ΠΎΡ€ΠΎΠ½ΠΊΡƒ + const funnel = new FunnelModel({ + name, + description, + funnelData: funnelData as FunnelDefinition, + status, + version: 1, + usage: { + totalViews: 0, + totalCompletions: 0 + } + }); + + const savedFunnel = await funnel.save(); + + // Π‘ΠΎΠ·Π΄Π°Π΅ΠΌ Π±Π°Π·ΠΎΠ²ΡƒΡŽ Ρ‚ΠΎΡ‡ΠΊΡƒ Π² истории + const sessionId = `create-${Date.now()}`; + await FunnelHistoryModel.create({ + funnelId: String(savedFunnel._id), + sessionId, + funnelSnapshot: funnelData, + actionType: 'create', + sequenceNumber: 0, + description: 'Π’ΠΎΡ€ΠΎΠ½ΠΊΠ° создана', + isBaseline: true + }); + + return NextResponse.json({ + _id: savedFunnel._id, + name: savedFunnel.name, + description: savedFunnel.description, + status: savedFunnel.status, + version: savedFunnel.version, + createdAt: savedFunnel.createdAt, + updatedAt: savedFunnel.updatedAt, + usage: savedFunnel.usage, + funnelData: savedFunnel.funnelData + }, { status: 201 }); + + } catch (error) { + console.error('POST /api/funnels error:', error); + + if (error instanceof Error && error.message.includes('duplicate key')) { + return NextResponse.json( + { error: 'Funnel with this name already exists' }, + { status: 409 } + ); + } + + return NextResponse.json( + { error: 'Failed to create funnel' }, + { status: 500 } + ); + } +} diff --git a/src/components/admin/builder/AddScreenDialog.tsx b/src/components/admin/builder/AddScreenDialog.tsx new file mode 100644 index 0000000..a9359d5 --- /dev/null +++ b/src/components/admin/builder/AddScreenDialog.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { useState } from "react"; +import { + List, + FormInput, + Info, + Calendar, + Ticket +} from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import type { ScreenDefinition } from "@/lib/funnel/types"; + +interface AddScreenDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onAddScreen: (template: ScreenDefinition["template"]) => void; +} + +const TEMPLATE_OPTIONS = [ + { + template: "list" as const, + title: "Бписок", + description: "Π’Ρ‹Π±ΠΎΡ€ ΠΈΠ· списка Π²Π°Ρ€ΠΈΠ°Π½Ρ‚ΠΎΠ² (single/multi)", + icon: List, + color: "bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-400", + }, + { + template: "form" as const, + title: "Π€ΠΎΡ€ΠΌΠ°", + description: "Π’Π²ΠΎΠ΄ тСкстовых Π΄Π°Π½Π½Ρ‹Ρ… Π² поля", + icon: FormInput, + color: "bg-purple-50 text-purple-600 dark:bg-purple-900/20 dark:text-purple-400", + }, + { + template: "info" as const, + title: "Π˜Π½Ρ„ΠΎΡ€ΠΌΠ°Ρ†ΠΈΡ", + description: "ΠžΡ‚ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΠΈΠ΅ ΠΈΠ½Ρ„ΠΎΡ€ΠΌΠ°Ρ†ΠΈΠΈ с ΠΊΠ½ΠΎΠΏΠΊΠΎΠΉ ΠΏΡ€ΠΎΠ΄ΠΎΠ»ΠΆΠΈΡ‚ΡŒ", + icon: Info, + color: "bg-gray-50 text-gray-600 dark:bg-gray-800 dark:text-gray-400", + }, + { + template: "date" as const, + title: "Π”Π°Ρ‚Π°", + description: "Π’Ρ‹Π±ΠΎΡ€ Π΄Π°Ρ‚Ρ‹ (мСсяц, дСнь, Π³ΠΎΠ΄)", + icon: Calendar, + color: "bg-green-50 text-green-600 dark:bg-green-900/20 dark:text-green-400", + }, + { + template: "coupon" as const, + title: "ΠšΡƒΠΏΠΎΠ½", + description: "ΠžΡ‚ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΠΈΠ΅ ΠΏΡ€ΠΎΠΌΠΎΠΊΠΎΠ΄Π° ΠΈ прСдлоТСния", + icon: Ticket, + color: "bg-orange-50 text-orange-600 dark:bg-orange-900/20 dark:text-orange-400", + }, +] as const; + +export function AddScreenDialog({ open, onOpenChange, onAddScreen }: AddScreenDialogProps) { + const [selectedTemplate, setSelectedTemplate] = useState(null); + + const handleAdd = () => { + if (selectedTemplate) { + onAddScreen(selectedTemplate); + setSelectedTemplate(null); + onOpenChange(false); + } + }; + + const handleCancel = () => { + setSelectedTemplate(null); + onOpenChange(false); + }; + + return ( + + + + Π’Ρ‹Π±Π΅Ρ€ΠΈΡ‚Π΅ Ρ‚ΠΈΠΏ экрана + + Π’Ρ‹Π±Π΅Ρ€ΠΈΡ‚Π΅ шаблон для Π½ΠΎΠ²ΠΎΠ³ΠΎ экрана Π²ΠΎΡ€ΠΎΠ½ΠΊΠΈ + + + +
+ {TEMPLATE_OPTIONS.map((option) => { + const Icon = option.icon; + const isSelected = selectedTemplate === option.template; + + return ( + + ); + })} +
+ +
+ + +
+
+
+ ); +} diff --git a/src/components/admin/builder/BuilderCanvas.tsx b/src/components/admin/builder/BuilderCanvas.tsx index 3f7acc0..45c92e5 100644 --- a/src/components/admin/builder/BuilderCanvas.tsx +++ b/src/components/admin/builder/BuilderCanvas.tsx @@ -5,6 +5,7 @@ import { ArrowDown, ArrowRight, CircleSlash2, GitBranch } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useBuilderDispatch, useBuilderState } from "@/lib/admin/builder/context"; import { formatOverridePath, listOverridePaths } from "@/lib/admin/builder/variants"; +import { AddScreenDialog } from "@/components/admin/builder/AddScreenDialog"; import type { ListOptionDefinition, NavigationConditionDefinition, @@ -133,16 +134,6 @@ function TemplateSummary({ screen }: { screen: ScreenDefinition }) { Π’Ρ‹Π±ΠΎΡ€: {screen.list.selectionType === "single" ? "ΠΎΠ΄ΠΈΠ½" : "нСсколько"} - {screen.list.autoAdvance && ( - - Π°Π²Ρ‚ΠΎ ΠΏΠ΅Ρ€Π΅Ρ…ΠΎΠ΄ - - )} - {screen.list.bottomActionButton?.text && ( - - {screen.list.bottomActionButton.text} - - )}

Π’Π°Ρ€ΠΈΠ°Π½Ρ‚Ρ‹ ({screen.list.options.length})

@@ -336,6 +327,7 @@ export function BuilderCanvas() { const dragStateRef = useRef<{ screenId: string; dragStartIndex: number } | null>(null); const [dropIndex, setDropIndex] = useState(null); + const [addScreenDialogOpen, setAddScreenDialogOpen] = useState(false); const handleDragStart = useCallback((event: React.DragEvent, screenId: string, index: number) => { event.dataTransfer.effectAllowed = "move"; @@ -420,7 +412,11 @@ export function BuilderCanvas() { ); const handleAddScreen = useCallback(() => { - dispatch({ type: "add-screen" }); + setAddScreenDialogOpen(true); + }, []); + + const handleAddScreenWithTemplate = useCallback((template: ScreenDefinition["template"]) => { + dispatch({ type: "add-screen", payload: { template } }); }, [dispatch]); const screenTitleMap = useMemo(() => { @@ -440,15 +436,14 @@ export function BuilderCanvas() { }, [screens]); return ( + <>

Π­ΠΊΡ€Π°Π½Ρ‹ Π²ΠΎΡ€ΠΎΠ½ΠΊΠΈ

-

ΠŸΠ΅Ρ€Π΅Ρ‚Π°ΡΠΊΠΈΠ²Π°ΠΉΡ‚Π΅, Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΠΏΠΎΠΌΠ΅Π½ΡΡ‚ΡŒ порядок ΠΈ связь экранов.

-
@@ -595,13 +590,20 @@ export function BuilderCanvas() { )}
-
+ + + ); } diff --git a/src/components/admin/builder/BuilderLayout.tsx b/src/components/admin/builder/BuilderLayout.tsx index 6c263f5..4cf9466 100644 --- a/src/components/admin/builder/BuilderLayout.tsx +++ b/src/components/admin/builder/BuilderLayout.tsx @@ -27,7 +27,7 @@ export function BuilderLayout({ {topBar &&
{topBar}
}
{sidebar && ( -
{showPreview && preview && ( -
+

ΠŸΡ€Π΅Π΄ΠΏΡ€ΠΎΡΠΌΠΎΡ‚Ρ€

@@ -51,7 +51,7 @@ export function BuilderLayout({ )}
-
{preview}
+
{preview}
)} diff --git a/src/components/admin/builder/BuilderPreview.tsx b/src/components/admin/builder/BuilderPreview.tsx index 7eb7101..7494a33 100644 --- a/src/components/admin/builder/BuilderPreview.tsx +++ b/src/components/admin/builder/BuilderPreview.tsx @@ -2,25 +2,18 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { ListTemplate } from "@/components/funnel/templates/ListTemplate"; -import { InfoTemplate } from "@/components/funnel/templates/InfoTemplate"; -import { DateTemplate } from "@/components/funnel/templates/DateTemplate"; -import { FormTemplate } from "@/components/funnel/templates/FormTemplate"; -import { CouponTemplate } from "@/components/funnel/templates/CouponTemplate"; import { useBuilderSelectedScreen } from "@/lib/admin/builder/context"; -import type { ListScreenDefinition, InfoScreenDefinition, DateScreenDefinition, FormScreenDefinition, CouponScreenDefinition } from "@/lib/funnel/types"; +import { renderScreen } from "@/lib/funnel/screenRenderer"; import { mergeScreenWithOverrides } from "@/lib/admin/builder/variants"; export function BuilderPreview() { const selectedScreen = useBuilderSelectedScreen(); const [selectedIds, setSelectedIds] = useState([]); - const [formData, setFormData] = useState>({}); const [previewVariantIndex, setPreviewVariantIndex] = useState(null); useEffect(() => { if (!selectedScreen) { setSelectedIds([]); - setFormData({}); setPreviewVariantIndex(null); return; } @@ -42,10 +35,6 @@ export function BuilderPreview() { }); }, []); - const handleFormChange = useCallback((data: Record) => { - setFormData(data); - }, []); - const variants = useMemo(() => selectedScreen?.variants ?? [], [selectedScreen]); @@ -73,69 +62,27 @@ export function BuilderPreview() { const renderScreenPreview = useCallback(() => { if (!previewScreen) return null; - const commonProps = { - showGradient: false, - canGoBack: false, - onBack: () => {}, - onContinue: () => {}, // Mock continue handler for preview - }; - - switch (previewScreen.template) { - case "list": - return ( - - ); - - case "info": - return ( - - ); - - case "date": - return ( - {}} - /> - ); - - case "form": - return ( - - ); - - - case "coupon": - return ( - - ); - - default: - return ( -
- ΠŸΡ€Π΅Π΄ΠΏΡ€ΠΎΡΠΌΠΎΡ‚Ρ€ для Π΄Π°Π½Π½ΠΎΠ³ΠΎ Ρ‚ΠΈΠΏΠ° экрана Π½Π΅ поддСрТиваСтся. -
- ); + try { + // Use the same renderer as FunnelRuntime for 1:1 accuracy + return renderScreen({ + screen: previewScreen, + selectedOptionIds: selectedIds, + onSelectionChange: handleSelectionChange, + onContinue: () => {}, // Mock continue handler for preview + canGoBack: true, // Show back button in preview + onBack: () => {}, // Mock back handler for preview + screenProgress: { current: 1, total: 10 }, // Mock progress for preview + defaultTexts: { nextButton: "Next", continueButton: "Continue" }, // Mock texts + }); + } catch (error) { + console.error('Error rendering preview:', error); + return ( +
+ Ошибка ΠΏΡ€ΠΈ ΠΎΡ‚ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΠΈΠΈ ΠΏΡ€Π΅Π²ΡŒΡŽ: {error instanceof Error ? error.message : 'НСизвСстная ошибка'} +
+ ); } - }, [previewScreen, selectedIds, formData, handleSelectionChange, handleFormChange]); + }, [previewScreen, selectedIds, handleSelectionChange]); const preview = useMemo(() => { if (!previewScreen) { @@ -148,17 +95,17 @@ export function BuilderPreview() { ); } - // Π˜ΡΠΏΠΎΠ»ΡŒΠ·ΡƒΠ΅ΠΌ ΠΏΡ€ΠΎΠΏΠΎΡ€Ρ†ΠΈΠΈ соврСмСнных iPhone (19.5:9 = ~2.17:1) + // Π£Π²Π΅Π»ΠΈΡ‡ΠΈΠΌ высоту Ρ‡Ρ‚ΠΎΠ±Ρ‹ ΠΊΠ½ΠΎΠΏΠΊΠ° ΠΏΠΎΠΌΠ΅ΡΡ‚ΠΈΠ»Π°ΡΡŒ ΠΏΠΎΠ»Π½ΠΎΡΡ‚ΡŒΡŽ const PREVIEW_WIDTH = 320; - const PREVIEW_HEIGHT = Math.round(PREVIEW_WIDTH * 2.17); // ~694px + const PREVIEW_HEIGHT = 750; // Π£Π²Π΅Π»ΠΈΡ‡Π΅Π½ΠΎ с ~694px Π΄ΠΎ 750px для BottomActionButton return ( -
+
{variants.length > 0 && ( -
+
- Π’Π°Ρ€ΠΈΠ°Π½Ρ‚ прСдпросмотра + ΠŸΡ€Π΅Π²ΡŒΡŽ Π²Π°Ρ€ΠΈΠ°Π½Ρ‚Π° НаправляйтС ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Π΅ΠΉ Π½Π° Ρ€Π°Π·Π½Ρ‹Π΅ экраны Π² зависимости ΠΎΡ‚ Π²Ρ‹Π±ΠΎΡ€Π°.

-
@@ -557,11 +625,11 @@ export function BuilderSidebar() { )} -
+
-
+

Π£Π΄Π°Π»Π΅Π½ΠΈΠ΅ экрана нСльзя ΠΎΡ‚ΠΌΠ΅Π½ΠΈΡ‚ΡŒ. ВсС связи с этим экраном Π±ΡƒΠ΄ΡƒΡ‚ потСряны. diff --git a/src/components/admin/builder/BuilderTopBar.tsx b/src/components/admin/builder/BuilderTopBar.tsx index 68e40b0..9ba82c2 100644 --- a/src/components/admin/builder/BuilderTopBar.tsx +++ b/src/components/admin/builder/BuilderTopBar.tsx @@ -1,27 +1,64 @@ "use client"; -import { useId, useRef } from "react"; +import { useId, useRef, useState } from "react"; +import { ArrowLeft, Save, Globe, Download, Upload, Undo, Redo } from "lucide-react"; import { Button } from "@/components/ui/button"; import { serializeBuilderState, deserializeFunnelDefinition } from "@/lib/admin/builder/utils"; import { useBuilderDispatch, useBuilderState } from "@/lib/admin/builder/context"; +import { useBuilderUndoRedo } from "@/components/admin/builder/BuilderUndoRedoProvider"; import type { BuilderState } from "@/lib/admin/builder/context"; +import { cn } from "@/lib/utils"; -interface BuilderTopBarProps { - onNew: () => void; - onExport: (json: string) => void; - onLoadError?: (message: string) => void; +interface FunnelInfo { + name: string; + status: 'draft' | 'published' | 'archived'; + version: number; + lastSaved: string; } -export function BuilderTopBar({ onNew, onExport, onLoadError }: BuilderTopBarProps) { +interface BuilderTopBarProps { + onNew?: () => void; + onSave?: (state: BuilderState) => Promise; + onPublish?: (state: BuilderState) => Promise; + onBackToCatalog?: () => void; + saving?: boolean; + funnelInfo?: FunnelInfo; + onLoadError?: (error: string) => void; + onSaveSuccess?: () => void; // КоллбСк для сброса isDirty послС ΡƒΡΠΏΠ΅ΡˆΠ½ΠΎΠ³ΠΎ сохранСния +} + +export function BuilderTopBar({ + onNew, + onSave, + onPublish, + onBackToCatalog, + saving, + funnelInfo, + onLoadError, + onSaveSuccess +}: BuilderTopBarProps) { const dispatch = useBuilderDispatch(); const state = useBuilderState(); const fileInputId = useId(); const fileInputRef = useRef(null); + + const [publishing, setPublishing] = useState(false); + + // Use undo/redo from context + const undoRedo = useBuilderUndoRedo(); const handleExport = () => { const json = JSON.stringify(serializeBuilderState(state), null, 2); - onExport(json); + const blob = new Blob([json], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `funnel-${state.meta.id || 'export'}.json`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); }; const handleFileChange = async (event: React.ChangeEvent) => { @@ -44,24 +81,139 @@ export function BuilderTopBar({ onNew, onExport, onLoadError }: BuilderTopBarPro } }; + const handleSave = async () => { + if (onSave && !saving) { + const success = await onSave(state); + if (success && onSaveSuccess) { + onSaveSuccess(); // БбрасываСм isDirty послС ΡƒΡΠΏΠ΅ΡˆΠ½ΠΎΠ³ΠΎ сохранСния + } + } + }; + + const handlePublish = async () => { + if (onPublish && !publishing && !saving) { + setPublishing(true); + try { + await onPublish(state); + } finally { + setPublishing(false); + } + } + }; + + // Бтатус badge + const getStatusBadge = (status: string) => { + const variants = { + draft: 'bg-yellow-100 text-yellow-800 border-yellow-200', + published: 'bg-green-100 text-green-800 border-green-200', + archived: 'bg-gray-100 text-gray-800 border-gray-200' + }; + + const labels = { + draft: 'Π§Π΅Ρ€Π½ΠΎΠ²ΠΈΠΊ', + published: 'ΠžΠΏΡƒΠ±Π»ΠΈΠΊΠΎΠ²Π°Π½Π°', + archived: 'Архивирована' + }; + + return ( + + {labels[status as keyof typeof labels]} + + ); + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString('ru-RU', { + day: '2-digit', + month: '2-digit', + hour: '2-digit', + minute: '2-digit' + }); + }; + return ( -

-
-

Funnel Builder

-

- Π‘ΠΎΠ±Π΅Ρ€ΠΈΡ‚Π΅ Π²ΠΎΡ€ΠΎΠ½ΠΊΡƒ, Ρ€Π΅Π΄Π°ΠΊΡ‚ΠΈΡ€ΡƒΠΉΡ‚Π΅ экраны ΠΈ экспортируйтС JSON для Ρ€Π°Π½Ρ‚Π°ΠΉΠΌΠ°. -

+
+ + {/* Left section */} +
+ + {/* Back to catalog */} + {onBackToCatalog && ( + + )} + + {/* Funnel info */} +
+
+

+ {funnelInfo?.name || state.meta.title || 'Новая Π²ΠΎΡ€ΠΎΠ½ΠΊΠ°'} +

+ {funnelInfo && getStatusBadge(funnelInfo.status)} + {state.isDirty && ( + + β€’ НСсохранСнныС измСнСния + + )} +
+ {funnelInfo && ( +
+ v{funnelInfo.version} β€’ Π‘ΠΎΡ…Ρ€Π°Π½Π΅Π½ΠΎ {formatDate(funnelInfo.lastSaved)} +
+ )} +
+ + {/* Right section */}
- - + + {/* Undo/Redo */} +
+ + +
+ + {/* Import/Export */} +
+ + +
+ - + + {/* Save/Publish */} + {onSave && ( + + )} + + {onPublish && ( + + )} + + {/* Create new */} +
); diff --git a/src/components/admin/builder/BuilderUndoRedoProvider.tsx b/src/components/admin/builder/BuilderUndoRedoProvider.tsx new file mode 100644 index 0000000..d2aa5ed --- /dev/null +++ b/src/components/admin/builder/BuilderUndoRedoProvider.tsx @@ -0,0 +1,118 @@ +/** + * Provider that wraps the builder and adds undo/redo functionality + * Automatically stores state snapshots when significant changes occur + */ + +"use client"; + +import { createContext, useContext, useEffect, useRef, type ReactNode } from 'react'; +import { useBuilderState, useBuilderDispatch } from '@/lib/admin/builder/context'; +import { useSimpleUndoRedo } from '@/lib/admin/builder/useSimpleUndoRedo'; +import type { BuilderState } from '@/lib/admin/builder/context'; + +interface UndoRedoContextValue { + canUndo: boolean; + canRedo: boolean; + undo: () => void; + redo: () => void; + store: () => void; + clear: () => void; + resetDirty: () => void; // Бброс isDirty Ρ„Π»Π°Π³Π° +} + +const UndoRedoContext = createContext(undefined); + +interface BuilderUndoRedoProviderProps { + children: ReactNode; +} + +export function BuilderUndoRedoProvider({ children }: BuilderUndoRedoProviderProps) { + const state = useBuilderState(); + const dispatch = useBuilderDispatch(); + const previousStateRef = useRef(state); + const isRestoringRef = useRef(false); + + // Ѐункция для сброса isDirty + const resetDirty = () => { + dispatch({ type: 'reset', payload: { ...state, isDirty: false } }); + }; + + const undoRedo = useSimpleUndoRedo( + state, + (newState) => { + isRestoringRef.current = true; + dispatch({ type: 'reset', payload: newState }); + } + ); + + // Auto-store state when significant changes occur + useEffect(() => { + // Don't store if we're in the middle of restoring from undo/redo + if (isRestoringRef.current) { + isRestoringRef.current = false; + previousStateRef.current = state; + return; + } + + const prev = previousStateRef.current; + + // Check for significant changes that should trigger a store + const shouldStore = ( + // Screen count changed + prev.screens.length !== state.screens.length || + + // Selected screen changed + prev.selectedScreenId !== state.selectedScreenId || + + // Meta data changed + JSON.stringify(prev.meta) !== JSON.stringify(state.meta) || + + // Screen structure changed (templates, navigation, etc.) + prev.screens.some((prevScreen, index) => { + const currentScreen = state.screens[index]; + if (!currentScreen || prevScreen.id !== currentScreen.id) return true; + + return ( + prevScreen.template !== currentScreen.template || + JSON.stringify(prevScreen.navigation) !== JSON.stringify(currentScreen.navigation) || + JSON.stringify(prevScreen.title) !== JSON.stringify(currentScreen.title) + ); + }) + ); + + if (shouldStore) { + // Store the previous state (not current) so we can undo to it + undoRedo.store(); + previousStateRef.current = state; + } + }, [state, undoRedo]); + + // Store initial state + useEffect(() => { + undoRedo.store(); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const contextValue: UndoRedoContextValue = { + canUndo: undoRedo.canUndo, + canRedo: undoRedo.canRedo, + undo: undoRedo.undo, + redo: undoRedo.redo, + store: undoRedo.store, + clear: undoRedo.clear, + resetDirty, + }; + + return ( + + {children} + + ); +} + +export function useBuilderUndoRedo(): UndoRedoContextValue { + const context = useContext(UndoRedoContext); + if (!context) { + throw new Error('useBuilderUndoRedo must be used within BuilderUndoRedoProvider'); + } + return context; +} diff --git a/src/components/admin/builder/ScreenVariantsConfig.tsx b/src/components/admin/builder/ScreenVariantsConfig.tsx index 59b4791..f855760 100644 --- a/src/components/admin/builder/ScreenVariantsConfig.tsx +++ b/src/components/admin/builder/ScreenVariantsConfig.tsx @@ -297,8 +297,8 @@ export function ScreenVariantsConfig({ screen, allScreens, onChange }: ScreenVar

НастройтС Π°Π»ΡŒΡ‚Π΅Ρ€Π½Π°Ρ‚ΠΈΠ²Π½Ρ‹Π΅ Π²Π°Ρ€ΠΈΠ°Π½Ρ‚Ρ‹ ΠΊΠΎΠ½Ρ‚Π΅Π½Ρ‚Π° Π±Π΅Π· измСнСния ΠΏΠ΅Ρ€Π΅Ρ…ΠΎΠ΄ΠΎΠ².

-
@@ -346,6 +346,10 @@ export function ScreenVariantsConfig({ screen, allScreens, onChange }: ScreenVar {isExpanded && (
+
+

ΠžΠ³Ρ€Π°Π½ΠΈΡ‡Π΅Π½ΠΈΠ΅: ВСкущая вСрсия Π°Π΄ΠΌΠΈΠ½ΠΊΠΈ ΠΏΠΎΠ΄Π΄Π΅Ρ€ΠΆΠΈΠ²Π°Π΅Ρ‚ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ ΠΎΠ΄Π½ΠΎ условиС Π½Π° Π²Π°Ρ€ΠΈΠ°Π½Ρ‚. РСальная систСма ΠΏΠΎΠ΄Π΄Π΅Ρ€ΠΆΠΈΠ²Π°Π΅Ρ‚ мноТСствСнныС условия Ρ‡Π΅Ρ€Π΅Π· JSON.

+
+