diff --git a/PREMIUM_FEATURES.md b/PREMIUM_FEATURES.md new file mode 100644 index 0000000..6fe865f --- /dev/null +++ b/PREMIUM_FEATURES.md @@ -0,0 +1,319 @@ +# 🎯 ΠŸΡ€Π΅ΠΌΠΈΡƒΠΌ Ρ„ΡƒΠ½ΠΊΡ†ΠΈΠΎΠ½Π°Π» CatLink + +## πŸ“ˆ Анализ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Π΅ΠΉ ΠΈ тарификация + +### ВСкущая аудитория +- **Free ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΠΈ**: Π‘Π°Π·ΠΎΠ²Ρ‹ΠΉ Ρ„ΡƒΠ½ΠΊΡ†ΠΈΠΎΠ½Π°Π» +- **Premium ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΠΈ**: Π Π°ΡΡˆΠΈΡ€Π΅Π½Π½Ρ‹Π΅ возмоТности +- **Business ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΠΈ**: ΠšΠΎΡ€ΠΏΠΎΡ€Π°Ρ‚ΠΈΠ²Π½Ρ‹Π΅ Ρ„ΡƒΠ½ΠΊΡ†ΠΈΠΈ + +### Π’Π°Ρ€ΠΈΡ„Π½Ρ‹Π΅ ΠΏΠ»Π°Π½Ρ‹ +``` +πŸ†“ FREE (БСсплатный) +- 1 список ссылок +- Π”ΠΎ 10 Π³Ρ€ΡƒΠΏΠΏ +- Π”ΠΎ 50 ссылок +- Базовая кастомизация +- ΠŸΠΎΠΊΠ°Π·Ρ‹ страниц (общая статистика) + +⭐ PREMIUM ($5/мСсяц) +- Π”ΠΎ 5 списков ссылок +- НСограничСнныС Π³Ρ€ΡƒΠΏΠΏΡ‹ ΠΈ ссылки +- ΠŸΡ€ΠΎΠ΄Π²ΠΈΠ½ΡƒΡ‚Π°Ρ кастомизация +- Π”Π΅Ρ‚Π°Π»ΡŒΠ½Π°Ρ Π°Π½Π°Π»ΠΈΡ‚ΠΈΠΊΠ° ΠΏΠΎ ΠΊΠ°ΠΆΠ΄ΠΎΠΉ ссылкС +- Π£ΠΏΡ€Π°Π²Π»Π΅Π½ΠΈΠ΅ доступами +- ΠšΠ°Π»Π΅Π½Π΄Π°Ρ€Π½ΠΎΠ΅ ΠΏΠ»Π°Π½ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ ΠΏΡƒΠ±Π»ΠΈΠΊΠ°Ρ†ΠΈΠΉ +- A/B тСстированиС ссылок +- Π˜Π½Ρ‚Π΅Π³Ρ€Π°Ρ†ΠΈΠΈ с соц.сСтями + +πŸ’Ό BUSINESS ($15/мСсяц) +- НСограничСнныС списки +- Командная Ρ€Π°Π±ΠΎΡ‚Π° (Π΄ΠΎ 5 ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Π΅ΠΉ) +- Π‘Ρ€Π΅Π½Π΄ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ (Π±Π΅Π»Ρ‹Π΅ ΠΌΠ΅Ρ‚ΠΊΠΈ) +- API доступ +- ΠŸΡ€ΠΎΠ΄Π²ΠΈΠ½ΡƒΡ‚Π°Ρ Π°Π½Π°Π»ΠΈΡ‚ΠΈΠΊΠ° ΠΈ экспорт Π΄Π°Π½Π½Ρ‹Ρ… +- БобствСнный Π΄ΠΎΠΌΠ΅Π½ +- ΠŸΡ€ΠΈΠΎΡ€ΠΈΡ‚Π΅Ρ‚Π½Π°Ρ ΠΏΠΎΠ΄Π΄Π΅Ρ€ΠΆΠΊΠ° +- Webhook ΠΈΠ½Ρ‚Π΅Π³Ρ€Π°Ρ†ΠΈΠΈ +``` + +--- + +## πŸš€ ΠšΠ»ΡŽΡ‡Π΅Π²Ρ‹Π΅ ΠΏΡ€Π΅ΠΌΠΈΡƒΠΌ Ρ„ΡƒΠ½ΠΊΡ†ΠΈΠΈ + +### 1. πŸ“š ΠœΠ½ΠΎΠΆΠ΅ΡΡ‚Π²Π΅Π½Π½Ρ‹Π΅ списки ссылок + +#### ΠšΠΎΠ½Ρ†Π΅ΠΏΡ†ΠΈΡ +ΠŸΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΠΈ ΠΌΠΎΠ³ΡƒΡ‚ ΡΠΎΠ·Π΄Π°Π²Π°Ρ‚ΡŒ нСсколько нСзависимых Π½Π°Π±ΠΎΡ€ΠΎΠ² ссылок для Ρ€Π°Π·Π½Ρ‹Ρ… Ρ†Π΅Π»Π΅ΠΉ: +- Π›ΠΈΡ‡Π½Ρ‹ΠΉ ΠΏΡ€ΠΎΡ„ΠΈΠ»ΡŒ +- Π Π°Π±ΠΎΡ‡ΠΈΠΉ ΠΏΡ€ΠΎΡ„ΠΈΠ»ΡŒ +- ΠŸΡ€ΠΎΠ΅ΠΊΡ‚Ρ‹ +- ΠœΠ΅Ρ€ΠΎΠΏΡ€ΠΈΡΡ‚ΠΈΡ +- Π’Ρ€Π΅ΠΌΠ΅Π½Π½Ρ‹Π΅ ΠΊΠ°ΠΌΠΏΠ°Π½ΠΈΠΈ + +#### Π€ΡƒΠ½ΠΊΡ†ΠΈΠΎΠ½Π°Π» +- **Π‘ΠΎΠ·Π΄Π°Π½ΠΈΠ΅ списков**: НСограничСнноС количСство для Premium/Business +- **Π£Π½ΠΈΠΊΠ°Π»ΡŒΠ½Ρ‹Π΅ URL**: `/username/список-Π½Π°Π·Π²Π°Π½ΠΈΠ΅` ΠΈΠ»ΠΈ `/username/work` +- **Π˜Π½Π΄ΠΈΠ²ΠΈΠ΄ΡƒΠ°Π»ΡŒΠ½Π°Ρ кастомизация**: ΠšΠ°ΠΆΠ΄Ρ‹ΠΉ список ΠΈΠΌΠ΅Π΅Ρ‚ свои настройки Π΄ΠΈΠ·Π°ΠΉΠ½Π° +- **Π£ΠΏΡ€Π°Π²Π»Π΅Π½ΠΈΠ΅ доступом**: ΠŸΡƒΠ±Π»ΠΈΡ‡Π½Ρ‹Π΅/ΠΏΡ€ΠΈΠ²Π°Ρ‚Π½Ρ‹Π΅/ΠΏΠΎ ΠΏΠ°Ρ€ΠΎΠ»ΡŽ/ΠΏΠΎ Π²Ρ€Π΅ΠΌΠ΅Π½ΠΈ +- **ΠŸΠ΅Ρ€Π΅ΠΊΠ»ΡŽΡ‡Π΅Π½ΠΈΠ΅**: БыстроС ΠΏΠ΅Ρ€Π΅ΠΊΠ»ΡŽΡ‡Π΅Π½ΠΈΠ΅ ΠΌΠ΅ΠΆΠ΄Ρƒ списками Π² Π΄Π°ΡˆΠ±ΠΎΡ€Π΄Π΅ +- **Π¨Π°Π±Π»ΠΎΠ½Ρ‹**: Π‘ΠΎΠ·Π΄Π°Π½ΠΈΠ΅ списков Π½Π° основС шаблонов +- **Π˜ΠΌΠΏΠΎΡ€Ρ‚/экспорт**: ΠžΡ‚Π΄Π΅Π»ΡŒΠ½ΠΎ для ΠΊΠ°ΠΆΠ΄ΠΎΠ³ΠΎ списка + +#### ΠŸΡ€ΠΈΠΌΠ΅Ρ€ использования +``` +trevor.catlink.com/ - основной ΠΏΡ€ΠΎΡ„ΠΈΠ»ΡŒ +trevor.catlink.com/business - Ρ€Π°Π±ΠΎΡ‡ΠΈΠΉ ΠΏΡ€ΠΎΡ„ΠΈΠ»ΡŒ +trevor.catlink.com/crypto - ΠΊΡ€ΠΈΠΏΡ‚ΠΎΠΏΡ€ΠΎΠ΅ΠΊΡ‚Ρ‹ +trevor.catlink.com/event2024 - Π²Ρ€Π΅ΠΌΠ΅Π½Π½ΠΎΠ΅ событиС +``` + +### 2. πŸ“Š БистСма Π°Π½Π°Π»ΠΈΡ‚ΠΈΠΊΠΈ + +#### ΠžΡ‚ΡΠ»Π΅ΠΆΠΈΠ²Π°Π½ΠΈΠ΅ ΠΏΠΎΠΊΠ°Π·ΠΎΠ² +- **Page Views**: ΠšΠΎΠ»ΠΈΡ‡Π΅ΡΡ‚Π²ΠΎ просмотров ΠΊΠ°ΠΆΠ΄ΠΎΠ³ΠΎ списка +- **Unique Visitors**: Π£Π½ΠΈΠΊΠ°Π»ΡŒΠ½Ρ‹Π΅ посСтитСли +- **ГСолокация**: Π‘Ρ‚Ρ€Π°Π½Ρ‹ ΠΈ Π³ΠΎΡ€ΠΎΠ΄Π° посСтитСлСй +- **Устройства**: ДСсктоп, ΠΌΠΎΠ±ΠΈΠ»ΡŒΠ½Ρ‹Π΅, ΠΏΠ»Π°Π½ΡˆΠ΅Ρ‚Ρ‹ +- **Π˜ΡΡ‚ΠΎΡ‡Π½ΠΈΠΊΠΈ**: ΠŸΡ€ΡΠΌΡ‹Π΅ ΠΏΠ΅Ρ€Π΅Ρ…ΠΎΠ΄Ρ‹, соц.сСти, поиск +- **ВрСмя Π½Π° страницС**: Π‘Ρ€Π΅Π΄Π½Π΅Π΅ врСмя просмотра + +#### Π”Π΅Ρ‚Π°Π»ΡŒΠ½Π°Ρ Π°Π½Π°Π»ΠΈΡ‚ΠΈΠΊΠ° ссылок +- **Клики ΠΏΠΎ ссылкам**: ΠšΠΎΠ»ΠΈΡ‡Π΅ΡΡ‚Π²ΠΎ ΠΏΠ΅Ρ€Π΅Ρ…ΠΎΠ΄ΠΎΠ² Π½Π° ΠΊΠ°ΠΆΠ΄ΡƒΡŽ ссылку +- **CTR (Click Through Rate)**: ΠŸΡ€ΠΎΡ†Π΅Π½Ρ‚ ΠΊΠ»ΠΈΠΊΠΎΠ² ΠΎΡ‚ ΠΏΠΎΠΊΠ°Π·ΠΎΠ² +- **ВСпловая ΠΊΠ°Ρ€Ρ‚Π°**: Π‘Π°ΠΌΡ‹Π΅ популярныС ссылки +- **ВрСмСнная статистика**: По часам, дням, нСдСлям, мСсяцам +- **Π˜ΡΡ‚ΠΎΡ‡Π½ΠΈΠΊΠΈ Ρ‚Ρ€Π°Ρ„ΠΈΠΊΠ°**: ΠžΡ‚ΠΊΡƒΠ΄Π° ΠΏΡ€ΠΈΡˆΠ»ΠΈ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΠΈ +- **ΠšΠΎΠ½Π²Π΅Ρ€ΡΠΈΠΎΠ½Π½Π°Ρ Π²ΠΎΡ€ΠΎΠ½ΠΊΠ°**: ΠŸΡƒΡ‚ΡŒ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ ΠΏΠΎ ссылкам + +#### Экспорт ΠΈ ΠΎΡ‚Ρ‡Π΅Ρ‚Ρ‹ +- **CSV/Excel экспорт**: Для дальнСйшСго Π°Π½Π°Π»ΠΈΠ·Π° +- **Π•ΠΆΠ΅Π½Π΅Π΄Π΅Π»ΡŒΠ½Ρ‹Π΅ дайдТСсты**: АвтоматичСскиС email ΠΎΡ‚Ρ‡Π΅Ρ‚Ρ‹ +- **Π‘Ρ€Π°Π²Π½ΠΈΡ‚Π΅Π»ΡŒΠ½Π°Ρ Π°Π½Π°Π»ΠΈΡ‚ΠΈΠΊΠ°**: Π‘Ρ€Π°Π²Π½Π΅Π½ΠΈΠ΅ ΠΏΠ΅Ρ€ΠΈΠΎΠ΄ΠΎΠ² +- **Π¦Π΅Π»ΠΈ ΠΈ события**: ΠžΡ‚ΡΠ»Π΅ΠΆΠΈΠ²Π°Π½ΠΈΠ΅ конвСрсий + +### 3. 🎨 ΠŸΡ€ΠΎΠ΄Π²ΠΈΠ½ΡƒΡ‚Π°Ρ кастомизация + +#### Π’Π΅ΠΌΡ‹ ΠΈ ΡˆΠ°Π±Π»ΠΎΠ½Ρ‹ +- **Premium Ρ‚Π΅ΠΌΡ‹**: Π­ΠΊΡΠΊΠ»ΡŽΠ·ΠΈΠ²Π½Ρ‹Π΅ Π΄ΠΈΠ·Π°ΠΉΠ½Ρ‹ +- **Анимации**: ΠŸΠ»Π°Π²Π½Ρ‹Π΅ ΠΏΠ΅Ρ€Π΅Ρ…ΠΎΠ΄Ρ‹ ΠΈ hover эффСкты +- **ΠšΠ°ΡΡ‚ΠΎΠΌΠ½Ρ‹Π΅ ΡˆΡ€ΠΈΡ„Ρ‚Ρ‹**: Π˜Π½Ρ‚Π΅Π³Ρ€Π°Ρ†ΠΈΡ Google Fonts +- **CSS Ρ€Π΅Π΄Π°ΠΊΡ‚ΠΎΡ€**: Полная кастомизация стилСй +- **Π€ΠΎΠ½ΠΎΠ²Ρ‹Π΅ Π²ΠΈΠ΄Π΅ΠΎ**: ВмСсто статичных ΠΈΠ·ΠΎΠ±Ρ€Π°ΠΆΠ΅Π½ΠΈΠΉ +- **Π“Ρ€Π°Π΄ΠΈΠ΅Π½Ρ‚Ρ‹**: Π‘Π»ΠΎΠΆΠ½Ρ‹Π΅ Ρ†Π²Π΅Ρ‚ΠΎΠ²Ρ‹Π΅ ΠΏΠ΅Ρ€Π΅Ρ…ΠΎΠ΄Ρ‹ + +#### Π‘Ρ€Π΅Π½Π΄ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ +- **БобствСнный Π΄ΠΎΠΌΠ΅Π½**: `links.your-company.com` +- **Π€Π°Π²ΠΈΠΊΠΎΠ½**: БобствСнная ΠΈΠΊΠΎΠ½ΠΊΠ° +- **Π‘Π΅Π»Ρ‹Π΅ ΠΌΠ΅Ρ‚ΠΊΠΈ**: Π‘ΠΊΡ€Ρ‹Ρ‚ΠΈΠ΅ Π±Ρ€Π΅Π½Π΄ΠΈΠ½Π³Π° CatLink +- **ΠšΠ°ΡΡ‚ΠΎΠΌΠ½Ρ‹ΠΉ footer**: БобствСнныС ΠΊΠΎΠΏΠΈΡ€Π°ΠΉΡ‚Ρ‹ ΠΈ ссылки + +### 4. ⏰ ΠšΠ°Π»Π΅Π½Π΄Π°Ρ€Π½ΠΎΠ΅ ΠΏΠ»Π°Π½ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ + +#### РасписаниС ΠΏΡƒΠ±Π»ΠΈΠΊΠ°Ρ†ΠΈΠΉ +- **Автопубликация**: Бсылки ΠΏΠΎΡΠ²Π»ΡΡŽΡ‚ΡΡ Π² Π½ΡƒΠΆΠ½ΠΎΠ΅ врСмя +- **Π’Ρ€Π΅ΠΌΠ΅Π½Π½Ρ‹Π΅ ссылки**: АвтоматичСскоС скрытиС ΠΏΠΎ истСчСнии срока +- **Π‘ΠΎΠ±Ρ‹Ρ‚ΠΈΠΉΠ½Ρ‹Π΅ списки**: Π‘ΠΏΠ΅Ρ†ΠΈΠ°Π»ΡŒΠ½Ρ‹Π΅ страницы для мСроприятий +- **Π‘Π΅Π·ΠΎΠ½Π½Ρ‹Π΅ ΠΊΠ°ΠΌΠΏΠ°Π½ΠΈΠΈ**: АвтоматичСскоС ΠΏΠ΅Ρ€Π΅ΠΊΠ»ΡŽΡ‡Π΅Π½ΠΈΠ΅ ΠΊΠΎΠ½Ρ‚Π΅Π½Ρ‚Π° + +### 5. πŸ§ͺ A/B тСстированиС + +#### ВСстированиС ссылок +- **Π’Π°Ρ€ΠΈΠ°Π½Ρ‚Ρ‹ ссылок**: Π Π°Π·Π½Ρ‹Π΅ тСксты, ΠΈΠΊΠΎΠ½ΠΊΠΈ, ΠΏΠΎΠ·ΠΈΡ†ΠΈΠΈ +- **Π‘ΠΏΠ»ΠΈΡ‚ Ρ‚Ρ€Π°Ρ„ΠΈΠΊ**: Π Π°Π²Π½ΠΎΠΌΠ΅Ρ€Π½ΠΎΠ΅ распрСдСлСниС посСтитСлСй +- **Бтатистика Ρ€Π΅Π·ΡƒΠ»ΡŒΡ‚Π°Ρ‚ΠΎΠ²**: Какой Π²Π°Ρ€ΠΈΠ°Π½Ρ‚ Ρ€Π°Π±ΠΎΡ‚Π°Π΅Ρ‚ Π»ΡƒΡ‡ΡˆΠ΅ +- **АвтоматичСская оптимизация**: Π’Ρ‹Π±ΠΎΡ€ Π»ΡƒΡ‡ΡˆΠ΅Π³ΠΎ Π²Π°Ρ€ΠΈΠ°Π½Ρ‚Π° + +### 6. πŸ”— Π˜Π½Ρ‚Π΅Π³Ρ€Π°Ρ†ΠΈΠΈ ΠΈ API + +#### Π‘ΠΎΡ†ΠΈΠ°Π»ΡŒΠ½Ρ‹Π΅ сСти +- **Instagram Stories**: АвтоматичСская синхронизация ссылок +- **TikTok Bio**: ДинамичСскоС ΠΎΠ±Π½ΠΎΠ²Π»Π΅Π½ΠΈΠ΅ профиля +- **YouTube**: Π˜Π½Ρ‚Π΅Π³Ρ€Π°Ρ†ΠΈΡ с описаниями Π²ΠΈΠ΄Π΅ΠΎ +- **Twitter**: АвтообновлСниС Π±ΠΈΠΎ + +#### API ΠΈ Webhook +- **REST API**: ΠŸΡ€ΠΎΠ³Ρ€Π°ΠΌΠΌΠ½ΠΎΠ΅ ΡƒΠΏΡ€Π°Π²Π»Π΅Π½ΠΈΠ΅ ссылками +- **Webhook увСдомлСния**: Бобытия ΠΊΠ»ΠΈΠΊΠΎΠ² ΠΈ просмотров +- **Zapier интСграция**: Автоматизация с Π΄Ρ€ΡƒΠ³ΠΈΠΌΠΈ сСрвисами +- **WordPress ΠΏΠ»Π°Π³ΠΈΠ½**: Π˜Π½Ρ‚Π΅Π³Ρ€Π°Ρ†ΠΈΡ с сайтами + +### 7. πŸ‘₯ Командная Ρ€Π°Π±ΠΎΡ‚Π° (Business) + +#### ΠœΡƒΠ»ΡŒΡ‚ΠΈ-ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡŒΡΠΊΠΈΠΉ доступ +- **Π ΠΎΠ»ΠΈ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Π΅ΠΉ**: Admin, Editor, Viewer +- **БовмСстноС Ρ€Π΅Π΄Π°ΠΊΡ‚ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅**: НСсколько Ρ‡Π΅Π»ΠΎΠ²Π΅ΠΊ Ρ€Π°Π±ΠΎΡ‚Π°ΡŽΡ‚ ΠΎΠ΄Π½ΠΎΠ²Ρ€Π΅ΠΌΠ΅Π½Π½ΠΎ +- **Π˜ΡΡ‚ΠΎΡ€ΠΈΡ ΠΈΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΠΉ**: ΠšΡ‚ΠΎ ΠΈ ΠΊΠΎΠ³Π΄Π° вносил ΠΏΡ€Π°Π²ΠΊΠΈ +- **Approval workflow**: ΠœΠΎΠ΄Π΅Ρ€Π°Ρ†ΠΈΡ ΠΈΠ·ΠΌΠ΅Π½Π΅Π½ΠΈΠΉ + +### 8. πŸ›‘οΈ ΠŸΡ€ΠΎΠ΄Π²ΠΈΠ½ΡƒΡ‚ΠΎΠ΅ ΡƒΠΏΡ€Π°Π²Π»Π΅Π½ΠΈΠ΅ доступом + +#### Π’ΠΈΠΏΡ‹ доступа +- **ΠŸΡƒΠ±Π»ΠΈΡ‡Π½Ρ‹ΠΉ**: ДоступСн всСм +- **ΠŸΡ€ΠΈΠ²Π°Ρ‚Π½Ρ‹ΠΉ**: Волько ΠΏΠΎ прямой ссылкС +- **По ΠΏΠ°Ρ€ΠΎΠ»ΡŽ**: Π—Π°Ρ‰ΠΈΡ‰Π΅Π½ ΠΏΠ°Ρ€ΠΎΠ»Π΅ΠΌ +- **По Π²Ρ€Π΅ΠΌΠ΅Π½ΠΈ**: ДоступСн Ρ‚ΠΎΠ»ΡŒΠΊΠΎ Π² ΠΎΠΏΡ€Π΅Π΄Π΅Π»Π΅Π½Π½Ρ‹Π΅ часы/Π΄Π½ΠΈ +- **Π“Π΅ΠΎΠ±Π»ΠΎΠΊΠΈΡ€ΠΎΠ²ΠΊΠ°**: ΠžΠ³Ρ€Π°Π½ΠΈΡ‡Π΅Π½ΠΈΠ΅ ΠΏΠΎ странам +- **Π›ΠΈΠΌΠΈΡ‚ просмотров**: АвтоскрытиС послС N просмотров + +### 9. 🎯 Smart Links ΠΈ Ρ€Π΅Π΄ΠΈΡ€Π΅ΠΊΡ‚Ρ‹ + +#### Π£ΠΌΠ½Ρ‹Π΅ ссылки +- **Geo-Ρ‚Π°Ρ€Π³Π΅Ρ‚ΠΈΠ½Π³**: Π Π°Π·Π½Ρ‹Π΅ ссылки для Ρ€Π°Π·Π½Ρ‹Ρ… стран +- **Device-Ρ‚Π°Ρ€Π³Π΅Ρ‚ΠΈΠ½Π³**: iOS -> App Store, Android -> Google Play +- **ВрСмя-Ρ‚Π°Ρ€Π³Π΅Ρ‚ΠΈΠ½Π³**: Π Π°Π·Π½Ρ‹Π΅ ссылки Π² Ρ€Π°Π·Π½ΠΎΠ΅ врСмя +- **ΠšΠΎΡ€ΠΎΡ‚ΠΊΠΈΠ΅ URL**: БобствСнный сСрвис сокращСния ссылок +- **QR ΠΊΠΎΠ΄Ρ‹**: АвтоматичСская гСнСрация для ΠΊΠ°ΠΆΠ΄ΠΎΠΉ ссылки + +### 10. πŸ’Έ ΠœΠΎΠ½Π΅Ρ‚ΠΈΠ·Π°Ρ†ΠΈΡ ΠΈ коммСрция + +#### ВстроСнная коммСрция +- **Donate ΠΊΠ½ΠΎΠΏΠΊΠΈ**: Π˜Π½Ρ‚Π΅Π³Ρ€Π°Ρ†ΠΈΡ с PayPal, Stripe +- **Affiliate ссылки**: ΠžΡ‚ΡΠ»Π΅ΠΆΠΈΠ²Π°Π½ΠΈΠ΅ партнСрских ΠΏΡ€ΠΎΠ³Ρ€Π°ΠΌΠΌ +- **ΠŸΡ€ΠΎΠ΄Π°ΠΆΠ° Ρ‚ΠΎΠ²Π°Ρ€ΠΎΠ²**: ΠŸΡ€ΡΠΌΡ‹Π΅ ссылки Π½Π° Ρ‚ΠΎΠ²Π°Ρ€Ρ‹ с preview +- **Подписки**: Бсылки Π½Π° Patreon, OnlyFans ΠΈ Ρ‚.Π΄. + +--- + +## πŸ”§ ВСхничСская Π°Ρ€Ρ…ΠΈΡ‚Π΅ΠΊΡ‚ΡƒΡ€Π° + +### Backend Ρ€Π°ΡΡˆΠΈΡ€Π΅Π½ΠΈΡ + +#### НовыС ΠΌΠΎΠ΄Π΅Π»ΠΈ Django +```python +# ΠœΠ½ΠΎΠΆΠ΅ΡΡ‚Π²Π΅Π½Π½Ρ‹Π΅ списки +class LinkCollection(models.Model): + user = models.ForeignKey(User) + name = models.CharField(max_length=100) + slug = models.SlugField(unique=True) + is_public = models.BooleanField(default=True) + access_type = models.CharField(choices=ACCESS_TYPES) + password = models.CharField(blank=True) + expires_at = models.DateTimeField(null=True) + +# Аналитика +class PageView(models.Model): + collection = models.ForeignKey(LinkCollection) + ip_address = models.GenericIPAddressField() + user_agent = models.TextField() + country = models.CharField(max_length=2) + timestamp = models.DateTimeField(auto_now_add=True) + +class LinkClick(models.Model): + link = models.ForeignKey(Link) + page_view = models.ForeignKey(PageView) + timestamp = models.DateTimeField(auto_now_add=True) + +# Подписки +class Subscription(models.Model): + user = models.OneToOneField(User) + plan = models.CharField(choices=PLAN_CHOICES) + status = models.CharField(choices=STATUS_CHOICES) + expires_at = models.DateTimeField() +``` + +#### API эндпоинты +```python +# Аналитика +/api/analytics/collections/{id}/views/ +/api/analytics/collections/{id}/clicks/ +/api/analytics/export/ + +# ΠœΠ½ΠΎΠΆΠ΅ΡΡ‚Π²Π΅Π½Π½Ρ‹Π΅ списки +/api/collections/ +/api/collections/{id}/ +/api/collections/{id}/links/ + +# Подписки +/api/subscription/ +/api/subscription/upgrade/ +/api/subscription/billing/ +``` + +### Frontend ΠΊΠΎΠΌΠΏΠΎΠ½Π΅Π½Ρ‚Ρ‹ + +#### НовыС React ΠΊΠΎΠΌΠΏΠΎΠ½Π΅Π½Ρ‚Ρ‹ +- `CollectionManager` - ΡƒΠΏΡ€Π°Π²Π»Π΅Π½ΠΈΠ΅ списками +- `AnalyticsDashboard` - Π΄Π°ΡˆΠ±ΠΎΡ€Π΄ Π°Π½Π°Π»ΠΈΡ‚ΠΈΠΊΠΈ +- `PremiumUpgrade` - ΠΌΠΎΠ΄Π°Π» Π°ΠΏΠ³Ρ€Π΅ΠΉΠ΄Π° +- `LinkScheduler` - ΠΏΠ»Π°Π½ΠΈΡ€ΠΎΠ²Ρ‰ΠΈΠΊ ΠΏΡƒΠ±Π»ΠΈΠΊΠ°Ρ†ΠΈΠΉ +- `ABTestManager` - ΡƒΠΏΡ€Π°Π²Π»Π΅Π½ΠΈΠ΅ A/B тСстами + +### БистСма ΠΏΠ»Π°Ρ‚Π΅ΠΆΠ΅ΠΉ + +#### Π˜Π½Ρ‚Π΅Π³Ρ€Π°Ρ†ΠΈΠΈ +- **Stripe**: Основной ΠΏΠ»Π°Ρ‚Π΅ΠΆΠ½Ρ‹ΠΉ процСссор +- **PayPal**: ΠΠ»ΡŒΡ‚Π΅Ρ€Π½Π°Ρ‚ΠΈΠ²Π½Ρ‹ΠΉ способ ΠΎΠΏΠ»Π°Ρ‚Ρ‹ +- **БанковскиС ΠΊΠ°Ρ€Ρ‚Ρ‹**: ΠŸΡ€ΡΠΌΠΎΠ΅ принятиС ΠΏΠ»Π°Ρ‚Π΅ΠΆΠ΅ΠΉ +- **Crypto**: Bitcoin, Ethereum (ΠΎΠΏΡ†ΠΈΠΎΠ½Π°Π»ΡŒΠ½ΠΎ) + +--- + +## πŸ“‹ План Ρ€Π°Π·Ρ€Π°Π±ΠΎΡ‚ΠΊΠΈ + +### Π­Ρ‚Π°ΠΏ 1 (2 Π½Π΅Π΄Π΅Π»ΠΈ): Базовая ΠΏΡ€Π΅ΠΌΠΈΡƒΠΌ инфраструктура +- [ ] БистСма подписок ΠΈ ΠΏΠ»Π°Π½ΠΎΠ² +- [ ] Π‘Π°Π·ΠΎΠ²Ρ‹Π΅ ограничСния для free ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Π΅ΠΉ +- [ ] ΠŸΡ€Π΅ΠΌΠΈΡƒΠΌ upgrade flow +- [ ] Π˜Π½Ρ‚Π΅Π³Ρ€Π°Ρ†ΠΈΡ со Stripe + +### Π­Ρ‚Π°ΠΏ 2 (3 Π½Π΅Π΄Π΅Π»ΠΈ): ΠœΠ½ΠΎΠΆΠ΅ΡΡ‚Π²Π΅Π½Π½Ρ‹Π΅ списки +- [ ] Backend ΠΌΠΎΠ΄Π΅Π»ΠΈ ΠΈ API +- [ ] Frontend ΡƒΠΏΡ€Π°Π²Π»Π΅Π½ΠΈΠ΅ списками +- [ ] Π ΠΎΡƒΡ‚ΠΈΠ½Π³ для Ρ€Π°Π·Π½Ρ‹Ρ… списков +- [ ] Π˜ΠΌΠΏΠΎΡ€Ρ‚/экспорт списков + +### Π­Ρ‚Π°ΠΏ 3 (2 Π½Π΅Π΄Π΅Π»ΠΈ): Базовая Π°Π½Π°Π»ΠΈΡ‚ΠΈΠΊΠ° +- [ ] Π’Ρ€Π΅ΠΊΠΈΠ½Π³ ΠΏΠΎΠΊΠ°Π·ΠΎΠ² страниц +- [ ] Π’Ρ€Π΅ΠΊΠΈΠ½Π³ ΠΊΠ»ΠΈΠΊΠΎΠ² ΠΏΠΎ ссылкам +- [ ] Π‘Π°Π·ΠΎΠ²Ρ‹ΠΉ Π΄Π°ΡˆΠ±ΠΎΡ€Π΄ Π°Π½Π°Π»ΠΈΡ‚ΠΈΠΊΠΈ +- [ ] Экспорт Π΄Π°Π½Π½Ρ‹Ρ… + +### Π­Ρ‚Π°ΠΏ 4 (3 Π½Π΅Π΄Π΅Π»ΠΈ): ΠŸΡ€ΠΎΠ΄Π²ΠΈΠ½ΡƒΡ‚Π°Ρ Π°Π½Π°Π»ΠΈΡ‚ΠΈΠΊΠ° +- [ ] ГСолокация ΠΈ устройства +- [ ] ВрСмСнная Π°Π½Π°Π»ΠΈΡ‚ΠΈΠΊΠ° +- [ ] Π’Π΅ΠΏΠ»ΠΎΠ²Ρ‹Π΅ ΠΊΠ°Ρ€Ρ‚Ρ‹ +- [ ] АвтоматичСскиС ΠΎΡ‚Ρ‡Π΅Ρ‚Ρ‹ + +### Π­Ρ‚Π°ΠΏ 5 (2 Π½Π΅Π΄Π΅Π»ΠΈ): Π£ΠΏΡ€Π°Π²Π»Π΅Π½ΠΈΠ΅ доступом +- [ ] ΠŸΡ€ΠΈΠ²Π°Ρ‚Π½Ρ‹Π΅ списки +- [ ] ΠŸΠ°Ρ€ΠΎΠ»ΡŒΠ½Π°Ρ Π·Π°Ρ‰ΠΈΡ‚Π° +- [ ] Π’Ρ€Π΅ΠΌΠ΅Π½Π½Ρ‹Π΅ ограничСния +- [ ] Π“Π΅ΠΎΠ±Π»ΠΎΠΊΠΈΡ€ΠΎΠ²ΠΊΠ° + +### Π­Ρ‚Π°ΠΏ 6 (2 Π½Π΅Π΄Π΅Π»ΠΈ): A/B тСстированиС +- [ ] Π‘ΠΎΠ·Π΄Π°Π½ΠΈΠ΅ Π²Π°Ρ€ΠΈΠ°Π½Ρ‚ΠΎΠ² ссылок +- [ ] Π‘ΠΏΠ»ΠΈΡ‚ Ρ‚Ρ€Π°Ρ„ΠΈΠΊΠ° +- [ ] Бтатистика Ρ€Π΅Π·ΡƒΠ»ΡŒΡ‚Π°Ρ‚ΠΎΠ² +- [ ] Автооптимизация + +--- + +## πŸ’° ΠœΠΎΠ½Π΅Ρ‚ΠΈΠ·Π°Ρ†ΠΈΡ + +### ΠŸΡ€ΠΎΠ³Π½ΠΎΠ·ΠΈΡ€ΡƒΠ΅ΠΌΡ‹Π΅ ΠΌΠ΅Ρ‚Ρ€ΠΈΠΊΠΈ +- **Conversion Free -> Premium**: 5-10% +- **Churn Rate**: < 5% Π² мСсяц +- **ARPU (Average Revenue Per User)**: $5-15 +- **Customer Lifetime Value**: $50-150 + +### ΠœΠ°Ρ€ΠΊΠ΅Ρ‚ΠΈΠ½Π³ΠΎΠ²Π°Ρ стратСгия +- **Freemium модСль**: ΠŸΡ€ΠΈΠ²Π»Π΅Ρ‡Π΅Π½ΠΈΠ΅ Ρ‡Π΅Ρ€Π΅Π· бСсплатный ΠΏΠ»Π°Π½ +- **Feature gating**: ΠšΠ»ΡŽΡ‡Π΅Π²Ρ‹Π΅ Ρ„ΡƒΠ½ΠΊΡ†ΠΈΠΈ Ρ‚ΠΎΠ»ΡŒΠΊΠΎ Π² Premium +- **Social proof**: ΠžΡ‚Π·Ρ‹Π²Ρ‹ ΠΈ кСйсы ΡƒΡΠΏΠ΅ΡˆΠ½Ρ‹Ρ… ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Π΅ΠΉ +- **ΠŸΠ°Ρ€Ρ‚Π½Π΅Ρ€ΡΠΊΠΈΠ΅ ΠΏΡ€ΠΎΠ³Ρ€Π°ΠΌΠΌΡ‹**: Π Π΅Ρ„Π΅Ρ€Π°Π»ΡŒΠ½Π°Ρ систСма + +--- + +## 🎯 Π£Π½ΠΈΠΊΠ°Π»ΡŒΠ½Ρ‹Π΅ ΠΊΠΎΠ½ΠΊΡƒΡ€Π΅Π½Ρ‚Π½Ρ‹Π΅ прСимущСства + +1. **ΠœΠ½ΠΎΠΆΠ΅ΡΡ‚Π²Π΅Π½Π½Ρ‹Π΅ списки**: Уникальная Π²ΠΎΠ·ΠΌΠΎΠΆΠ½ΠΎΡΡ‚ΡŒ раздСлСния контСкстов +2. **Π”Π΅Ρ‚Π°Π»ΡŒΠ½Π°Ρ Π°Π½Π°Π»ΠΈΡ‚ΠΈΠΊΠ°**: Π“Π»ΡƒΠ±ΠΎΠΊΠΈΠΉ Π°Π½Π°Π»ΠΈΠ· повСдСния ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Π΅ΠΉ +3. **Smart Links**: Умная ΠΌΠ°Ρ€ΡˆΡ€ΡƒΡ‚ΠΈΠ·Π°Ρ†ΠΈΡ Π² зависимости ΠΎΡ‚ контСкста +4. **A/B тСстированиС**: ΠžΠΏΡ‚ΠΈΠΌΠΈΠ·Π°Ρ†ΠΈΡ конвСрсий +5. **ΠšΠ°Π»Π΅Π½Π΄Π°Ρ€Π½ΠΎΠ΅ ΠΏΠ»Π°Π½ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅**: Автоматизация ΠΏΡƒΠ±Π»ΠΈΠΊΠ°Ρ†ΠΈΠΉ +6. **API-first ΠΏΠΎΠ΄Ρ…ΠΎΠ΄**: Π˜Π½Ρ‚Π΅Π³Ρ€Π°Ρ†ΠΈΡ с Π»ΡŽΠ±Ρ‹ΠΌΠΈ систСмами +7. **Локализация**: ΠŸΠΎΠ΄Π΄Π΅Ρ€ΠΆΠΊΠ° 5 языков ΠΈΠ· ΠΊΠΎΡ€ΠΎΠ±ΠΊΠΈ +8. **ΠžΡ‚ΠΊΡ€Ρ‹Ρ‚Ρ‹ΠΉ ΠΊΠΎΠ΄**: Π’ΠΎΠ·ΠΌΠΎΠΆΠ½ΠΎΡΡ‚ΡŒ ΡΠ°ΠΌΠΎΡΡ‚ΠΎΡΡ‚Π΅Π»ΡŒΠ½ΠΎΠ³ΠΎ развСртывания + +Π­Ρ‚ΠΎΡ‚ ΠΏΡ€Π΅ΠΌΠΈΡƒΠΌ Ρ„ΡƒΠ½ΠΊΡ†ΠΈΠΎΠ½Π°Π» ΠΏΡ€Π΅Π²Ρ€Π°Ρ‚ΠΈΡ‚ CatLink ΠΈΠ· простого Π°Π³Ρ€Π΅Π³Π°Ρ‚ΠΎΡ€Π° ссылок Π² ΠΌΠΎΡ‰Π½ΡƒΡŽ ΠΏΠ»Π°Ρ‚Ρ„ΠΎΡ€ΠΌΡƒ для управлСния Ρ†ΠΈΡ„Ρ€ΠΎΠ²Ρ‹ΠΌ присутствиСм ΠΈ Π°Π½Π°Π»ΠΈΡ‚ΠΈΠΊΠΈ. \ No newline at end of file diff --git a/PREMIUM_IMPLEMENTATION.md b/PREMIUM_IMPLEMENTATION.md new file mode 100644 index 0000000..3f91c69 --- /dev/null +++ b/PREMIUM_IMPLEMENTATION.md @@ -0,0 +1,972 @@ +# πŸš€ ВСхничСскоС руководство: ΠŸΡ€Π΅ΠΌΠΈΡƒΠΌ инфраструктура + +## πŸ“‹ Π­Ρ‚Π°ΠΏ 1: Базовая ΠΏΡ€Π΅ΠΌΠΈΡƒΠΌ систСма + +### πŸ—οΈ Backend: Django ΠΌΠΎΠ΄Π΅Π»ΠΈ + +#### 1. МодСль подписок +```python +# backend/subscriptions/__init__.py +# backend/subscriptions/models.py + +from django.db import models +from django.contrib.auth.models import User +from django.utils import timezone + +class SubscriptionPlan(models.Model): + """ΠŸΠ»Π°Π½Ρ‹ подписок""" + PLAN_CHOICES = [ + ('free', 'Free'), + ('premium', 'Premium'), + ('business', 'Business'), + ] + + name = models.CharField(max_length=50, choices=PLAN_CHOICES, unique=True) + display_name = models.CharField(max_length=100) + price_monthly = models.DecimalField(max_digits=10, decimal_places=2) + price_yearly = models.DecimalField(max_digits=10, decimal_places=2) + description = models.TextField() + features = models.JSONField(default=dict) # Бписок возмоТностСй + max_collections = models.IntegerField(default=1) + max_groups = models.IntegerField(default=10) + max_links = models.IntegerField(default=50) + analytics_enabled = models.BooleanField(default=False) + custom_domain_enabled = models.BooleanField(default=False) + api_access_enabled = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return self.display_name + +class UserSubscription(models.Model): + """Подписка ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ""" + STATUS_CHOICES = [ + ('active', 'Active'), + ('cancelled', 'Cancelled'), + ('expired', 'Expired'), + ('trial', 'Trial'), + ] + + user = models.OneToOneField(User, on_delete=models.CASCADE) + plan = models.ForeignKey(SubscriptionPlan, on_delete=models.CASCADE) + status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='active') + starts_at = models.DateTimeField(default=timezone.now) + expires_at = models.DateTimeField() + stripe_subscription_id = models.CharField(max_length=255, blank=True) + stripe_customer_id = models.CharField(max_length=255, blank=True) + auto_renew = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = 'user_subscriptions' + + def is_active(self): + return self.status == 'active' and self.expires_at > timezone.now() + + def is_premium(self): + return self.plan.name in ['premium', 'business'] and self.is_active() + + def days_remaining(self): + if self.expires_at > timezone.now(): + return (self.expires_at - timezone.now()).days + return 0 + + def __str__(self): + return f"{self.user.username} - {self.plan.display_name}" + +class PaymentHistory(models.Model): + """Π˜ΡΡ‚ΠΎΡ€ΠΈΡ ΠΏΠ»Π°Ρ‚Π΅ΠΆΠ΅ΠΉ""" + subscription = models.ForeignKey(UserSubscription, on_delete=models.CASCADE) + amount = models.DecimalField(max_digits=10, decimal_places=2) + currency = models.CharField(max_length=3, default='USD') + stripe_payment_id = models.CharField(max_length=255) + status = models.CharField(max_length=50) + payment_date = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = 'payment_history' +``` + +#### 2. МодСль мноТСствСнных списков +```python +# backend/collections/models.py + +from django.db import models +from django.contrib.auth.models import User +from django.utils.text import slugify + +class LinkCollection(models.Model): + """ΠšΠΎΠ»Π»Π΅ΠΊΡ†ΠΈΠΈ ссылок для ΠΏΡ€Π΅ΠΌΠΈΡƒΠΌ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Π΅ΠΉ""" + ACCESS_CHOICES = [ + ('public', 'Public'), + ('private', 'Private'), + ('password', 'Password Protected'), + ('scheduled', 'Scheduled'), + ] + + user = models.ForeignKey(User, on_delete=models.CASCADE) + name = models.CharField(max_length=100) + slug = models.SlugField(unique=True, blank=True) + description = models.TextField(blank=True) + is_default = models.BooleanField(default=False) + access_type = models.CharField(max_length=20, choices=ACCESS_CHOICES, default='public') + password = models.CharField(max_length=255, blank=True) + published_at = models.DateTimeField(null=True, blank=True) + expires_at = models.DateTimeField(null=True, blank=True) + view_count = models.IntegerField(default=0) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + # Настройки Π΄ΠΈΠ·Π°ΠΉΠ½Π° для ΠΊΠ°ΠΆΠ΄ΠΎΠΉ ΠΊΠΎΠ»Π»Π΅ΠΊΡ†ΠΈΠΈ + theme_color = models.CharField(max_length=7, default='#ffffff') + background_image = models.ImageField(upload_to='collections/backgrounds/', blank=True) + custom_css = models.TextField(blank=True) + + class Meta: + db_table = 'link_collections' + unique_together = ['user', 'slug'] + + def save(self, *args, **kwargs): + if not self.slug: + base_slug = slugify(self.name) + slug = base_slug + counter = 1 + while LinkCollection.objects.filter(user=self.user, slug=slug).exists(): + slug = f"{base_slug}-{counter}" + counter += 1 + self.slug = slug + super().save(*args, **kwargs) + + def get_absolute_url(self): + if self.is_default: + return f"/{self.user.username}/" + return f"/{self.user.username}/{self.slug}/" + + def __str__(self): + return f"{self.user.username}/{self.slug}" +``` + +#### 3. ОбновлСниС ΠΌΠΎΠ΄Π΅Π»ΠΈ Π³Ρ€ΡƒΠΏΠΏ ΠΈ ссылок +```python +# backend/links/models.py - Π΄ΠΎΠ±Π°Π²ΠΈΡ‚ΡŒ ΠΏΠΎΠ»Π΅ collection + +class LinkGroup(models.Model): + # ... ΡΡƒΡ‰Π΅ΡΡ‚Π²ΡƒΡŽΡ‰ΠΈΠ΅ поля ... + collection = models.ForeignKey( + 'collections.LinkCollection', + on_delete=models.CASCADE, + related_name='groups', + null=True, # Для ΠΎΠ±Ρ€Π°Ρ‚Π½ΠΎΠΉ совмСстимости + blank=True + ) + +class Link(models.Model): + # ... ΡΡƒΡ‰Π΅ΡΡ‚Π²ΡƒΡŽΡ‰ΠΈΠ΅ поля ... + collection = models.ForeignKey( + 'collections.LinkCollection', + on_delete=models.CASCADE, + related_name='links', + null=True, # Для ΠΎΠ±Ρ€Π°Ρ‚Π½ΠΎΠΉ совмСстимости + blank=True + ) +``` + +### πŸ”§ БистСма ΠΎΠ³Ρ€Π°Π½ΠΈΡ‡Π΅Π½ΠΈΠΉ + +#### 1. Π”Π΅ΠΊΠΎΡ€Π°Ρ‚ΠΎΡ€Ρ‹ для ΠΏΡ€ΠΎΠ²Π΅Ρ€ΠΊΠΈ Π»ΠΈΠΌΠΈΡ‚ΠΎΠ² +```python +# backend/subscriptions/decorators.py + +from functools import wraps +from django.http import JsonResponse +from django.contrib.auth.decorators import login_required +from .models import UserSubscription + +def premium_required(feature_name=None): + """Π”Π΅ΠΊΠΎΡ€Π°Ρ‚ΠΎΡ€ для ΠΏΡ€ΠΎΠ²Π΅Ρ€ΠΊΠΈ ΠΏΡ€Π΅ΠΌΠΈΡƒΠΌ подписки""" + def decorator(view_func): + @wraps(view_func) + @login_required + def wrapped_view(request, *args, **kwargs): + try: + subscription = UserSubscription.objects.get(user=request.user) + if not subscription.is_premium(): + return JsonResponse({ + 'error': 'Premium subscription required', + 'feature': feature_name, + 'upgrade_url': '/upgrade/' + }, status=403) + except UserSubscription.DoesNotExist: + return JsonResponse({ + 'error': 'No subscription found', + 'upgrade_url': '/upgrade/' + }, status=403) + + return view_func(request, *args, **kwargs) + return wrapped_view + return decorator + +def check_limits(limit_type): + """ΠŸΡ€ΠΎΠ²Π΅Ρ€ΠΊΠ° Π»ΠΈΠΌΠΈΡ‚ΠΎΠ² ΠΏΠΎ ΠΏΠ»Π°Π½Ρƒ""" + def decorator(view_func): + @wraps(view_func) + @login_required + def wrapped_view(request, *args, **kwargs): + try: + subscription = UserSubscription.objects.get(user=request.user) + plan = subscription.plan + + if limit_type == 'collections': + current_count = request.user.linkcollection_set.count() + if current_count >= plan.max_collections: + return JsonResponse({ + 'error': f'Collection limit reached ({plan.max_collections})', + 'upgrade_url': '/upgrade/' + }, status=403) + + elif limit_type == 'groups': + current_count = request.user.linkgroup_set.count() + if current_count >= plan.max_groups: + return JsonResponse({ + 'error': f'Group limit reached ({plan.max_groups})', + 'upgrade_url': '/upgrade/' + }, status=403) + + elif limit_type == 'links': + current_count = request.user.link_set.count() + if current_count >= plan.max_links: + return JsonResponse({ + 'error': f'Link limit reached ({plan.max_links})', + 'upgrade_url': '/upgrade/' + }, status=403) + + except UserSubscription.DoesNotExist: + # Free ΠΏΠ»Π°Π½ ΠΏΠΎ ΡƒΠΌΠΎΠ»Ρ‡Π°Π½ΠΈΡŽ + pass + + return view_func(request, *args, **kwargs) + return wrapped_view + return decorator +``` + +#### 2. БСрвисный слой для ΠΏΡ€ΠΎΠ²Π΅Ρ€ΠΊΠΈ Π»ΠΈΠΌΠΈΡ‚ΠΎΠ² +```python +# backend/subscriptions/services.py + +from .models import UserSubscription, SubscriptionPlan +from collections.models import LinkCollection + +class SubscriptionService: + + @staticmethod + def get_user_plan(user): + """ΠŸΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ ΠΏΠ»Π°Π½ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ""" + try: + subscription = UserSubscription.objects.get(user=user) + if subscription.is_active(): + return subscription.plan + except UserSubscription.DoesNotExist: + pass + + # Free ΠΏΠ»Π°Π½ ΠΏΠΎ ΡƒΠΌΠΎΠ»Ρ‡Π°Π½ΠΈΡŽ + return SubscriptionPlan.objects.get(name='free') + + @staticmethod + def can_create_collection(user): + """ΠœΠΎΠΆΠ΅Ρ‚ Π»ΠΈ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡŒ ΡΠΎΠ·Π΄Π°Ρ‚ΡŒ Π½ΠΎΠ²ΡƒΡŽ ΠΊΠΎΠ»Π»Π΅ΠΊΡ†ΠΈΡŽ""" + plan = SubscriptionService.get_user_plan(user) + current_count = LinkCollection.objects.filter(user=user).count() + return current_count < plan.max_collections + + @staticmethod + def can_create_group(user): + """ΠœΠΎΠΆΠ΅Ρ‚ Π»ΠΈ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡŒ ΡΠΎΠ·Π΄Π°Ρ‚ΡŒ Π½ΠΎΠ²ΡƒΡŽ Π³Ρ€ΡƒΠΏΠΏΡƒ""" + plan = SubscriptionService.get_user_plan(user) + current_count = user.linkgroup_set.count() + return current_count < plan.max_groups + + @staticmethod + def can_create_link(user): + """ΠœΠΎΠΆΠ΅Ρ‚ Π»ΠΈ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»ΡŒ ΡΠΎΠ·Π΄Π°Ρ‚ΡŒ Π½ΠΎΠ²ΡƒΡŽ ссылку""" + plan = SubscriptionService.get_user_plan(user) + current_count = user.link_set.count() + return current_count < plan.max_links + + @staticmethod + def get_usage_stats(user): + """Бтатистика использования""" + plan = SubscriptionService.get_user_plan(user) + + return { + 'plan': plan.name, + 'collections': { + 'current': LinkCollection.objects.filter(user=user).count(), + 'limit': plan.max_collections, + 'unlimited': plan.max_collections == -1 + }, + 'groups': { + 'current': user.linkgroup_set.count(), + 'limit': plan.max_groups, + 'unlimited': plan.max_groups == -1 + }, + 'links': { + 'current': user.link_set.count(), + 'limit': plan.max_links, + 'unlimited': plan.max_links == -1 + }, + 'features': { + 'analytics': plan.analytics_enabled, + 'custom_domain': plan.custom_domain_enabled, + 'api_access': plan.api_access_enabled, + } + } +``` + +### 🌐 API эндпоинты + +#### 1. Subscription API +```python +# backend/subscriptions/views.py + +from rest_framework import viewsets, status +from rest_framework.decorators import action +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated +from .models import UserSubscription, SubscriptionPlan +from .serializers import SubscriptionSerializer, PlanSerializer +from .services import SubscriptionService + +class SubscriptionViewSet(viewsets.ReadOnlyModelViewSet): + permission_classes = [IsAuthenticated] + + @action(detail=False, methods=['get']) + def current(self, request): + """ВСкущая подписка ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Ρ""" + try: + subscription = UserSubscription.objects.get(user=request.user) + serializer = SubscriptionSerializer(subscription) + return Response(serializer.data) + except UserSubscription.DoesNotExist: + # Free ΠΏΠ»Π°Π½ + free_plan = SubscriptionPlan.objects.get(name='free') + return Response({ + 'plan': PlanSerializer(free_plan).data, + 'status': 'free', + 'expires_at': None, + 'is_active': True + }) + + @action(detail=False, methods=['get']) + def usage(self, request): + """Бтатистика использования""" + stats = SubscriptionService.get_usage_stats(request.user) + return Response(stats) + + @action(detail=False, methods=['get']) + def plans(self, request): + """ДоступныС ΠΏΠ»Π°Π½Ρ‹""" + plans = SubscriptionPlan.objects.all() + serializer = PlanSerializer(plans, many=True) + return Response(serializer.data) + +class CollectionViewSet(viewsets.ModelViewSet): + permission_classes = [IsAuthenticated] + + def get_queryset(self): + return LinkCollection.objects.filter(user=self.request.user) + + def perform_create(self, serializer): + if not SubscriptionService.can_create_collection(self.request.user): + plan = SubscriptionService.get_user_plan(self.request.user) + raise ValidationError( + f"Collection limit reached ({plan.max_collections}). " + f"Upgrade to Premium for unlimited collections." + ) + serializer.save(user=self.request.user) +``` + +#### 2. URL ΠΌΠ°Ρ€ΡˆΡ€ΡƒΡ‚Ρ‹ +```python +# backend/subscriptions/urls.py + +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import SubscriptionViewSet + +router = DefaultRouter() +router.register(r'subscriptions', SubscriptionViewSet, basename='subscription') + +urlpatterns = [ + path('api/', include(router.urls)), +] + +# backend/collections/urls.py + +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import CollectionViewSet + +router = DefaultRouter() +router.register(r'collections', CollectionViewSet, basename='collection') + +urlpatterns = [ + path('api/', include(router.urls)), +] +``` + +### πŸ’³ Π˜Π½Ρ‚Π΅Π³Ρ€Π°Ρ†ΠΈΡ со Stripe + +#### 1. Настройки Stripe +```python +# backend/settings.py + +import stripe + +STRIPE_PUBLISHABLE_KEY = os.environ.get('STRIPE_PUBLISHABLE_KEY') +STRIPE_SECRET_KEY = os.environ.get('STRIPE_SECRET_KEY') +STRIPE_WEBHOOK_SECRET = os.environ.get('STRIPE_WEBHOOK_SECRET') + +stripe.api_key = STRIPE_SECRET_KEY +``` + +#### 2. Stripe сСрвисы +```python +# backend/subscriptions/stripe_services.py + +import stripe +from django.conf import settings +from .models import UserSubscription, SubscriptionPlan + +class StripeService: + + @staticmethod + def create_customer(user): + """Π‘ΠΎΠ·Π΄Π°Ρ‚ΡŒ ΠΊΠ»ΠΈΠ΅Π½Ρ‚Π° Π² Stripe""" + customer = stripe.Customer.create( + email=user.email, + name=user.get_full_name() or user.username, + metadata={'user_id': user.id} + ) + return customer.id + + @staticmethod + def create_subscription(user, plan_name, payment_method_id): + """Π‘ΠΎΠ·Π΄Π°Ρ‚ΡŒ подписку""" + plan = SubscriptionPlan.objects.get(name=plan_name) + + # ΠŸΠΎΠ»ΡƒΡ‡ΠΈΡ‚ΡŒ ΠΈΠ»ΠΈ ΡΠΎΠ·Π΄Π°Ρ‚ΡŒ ΠΊΠ»ΠΈΠ΅Π½Ρ‚Π° + try: + user_subscription = UserSubscription.objects.get(user=user) + customer_id = user_subscription.stripe_customer_id + if not customer_id: + customer_id = StripeService.create_customer(user) + user_subscription.stripe_customer_id = customer_id + user_subscription.save() + except UserSubscription.DoesNotExist: + customer_id = StripeService.create_customer(user) + + # ΠŸΡ€ΠΈΠΊΡ€Π΅ΠΏΠΈΡ‚ΡŒ способ ΠΎΠΏΠ»Π°Ρ‚Ρ‹ + stripe.PaymentMethod.attach( + payment_method_id, + customer=customer_id, + ) + + # Π‘ΠΎΠ·Π΄Π°Ρ‚ΡŒ подписку Π² Stripe + subscription = stripe.Subscription.create( + customer=customer_id, + items=[{ + 'price': plan.stripe_price_id, + }], + default_payment_method=payment_method_id, + metadata={ + 'user_id': user.id, + 'plan_name': plan_name + } + ) + + return subscription + + @staticmethod + def cancel_subscription(stripe_subscription_id): + """ΠžΡ‚ΠΌΠ΅Π½ΠΈΡ‚ΡŒ подписку""" + return stripe.Subscription.cancel(stripe_subscription_id) +``` + +#### 3. Webhook ΠΎΠ±Ρ€Π°Π±ΠΎΡ‚ΠΊΠ° +```python +# backend/subscriptions/webhooks.py + +import json +import stripe +from django.http import HttpResponse +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_POST +from django.conf import settings +from .models import UserSubscription, PaymentHistory + +@csrf_exempt +@require_POST +def stripe_webhook(request): + payload = request.body + sig_header = request.META.get('HTTP_STRIPE_SIGNATURE') + + try: + event = stripe.Webhook.construct_event( + payload, sig_header, settings.STRIPE_WEBHOOK_SECRET + ) + except ValueError: + return HttpResponse(status=400) + except stripe.error.SignatureVerificationError: + return HttpResponse(status=400) + + # ΠžΠ±Ρ€Π°Π±ΠΎΡ‚ΠΊΠ° событий + if event['type'] == 'invoice.payment_succeeded': + handle_payment_succeeded(event['data']['object']) + elif event['type'] == 'customer.subscription.deleted': + handle_subscription_cancelled(event['data']['object']) + elif event['type'] == 'customer.subscription.updated': + handle_subscription_updated(event['data']['object']) + + return HttpResponse(status=200) + +def handle_payment_succeeded(invoice): + """ΠžΠ±Ρ€Π°Π±ΠΎΡ‚ΠΊΠ° ΡƒΡΠΏΠ΅ΡˆΠ½ΠΎΠ³ΠΎ ΠΏΠ»Π°Ρ‚Π΅ΠΆΠ°""" + subscription_id = invoice['subscription'] + customer_id = invoice['customer'] + amount = invoice['amount_paid'] / 100 # Stripe Π² Ρ†Π΅Π½Ρ‚Π°Ρ… + + try: + user_subscription = UserSubscription.objects.get( + stripe_subscription_id=subscription_id + ) + + # ΠžΠ±Π½ΠΎΠ²ΠΈΡ‚ΡŒ срок подписки + user_subscription.status = 'active' + user_subscription.save() + + # Π—Π°ΠΏΠΈΡΠ°Ρ‚ΡŒ ΠΏΠ»Π°Ρ‚Π΅ΠΆ Π² ΠΈΡΡ‚ΠΎΡ€ΠΈΡŽ + PaymentHistory.objects.create( + subscription=user_subscription, + amount=amount, + stripe_payment_id=invoice['payment_intent'], + status='succeeded' + ) + + except UserSubscription.DoesNotExist: + pass # Π›ΠΎΠ³ΠΈΡ€ΠΎΠ²Π°Π½ΠΈΠ΅ ошибки +``` + +### πŸ“± Frontend ΠΊΠΎΠΌΠΏΠΎΠ½Π΅Π½Ρ‚Ρ‹ + +#### 1. ΠšΠΎΠΌΠΏΠΎΠ½Π΅Π½Ρ‚ управлСния подпиской +```typescript +// frontend/src/app/components/SubscriptionManager.tsx + +import React, { useState, useEffect } from 'react'; +import { useLocale } from '../contexts/LocaleContext'; + +interface SubscriptionStats { + plan: string; + collections: { current: number; limit: number; unlimited: boolean }; + groups: { current: number; limit: number; unlimited: boolean }; + links: { current: number; limit: number; unlimited: boolean }; + features: { + analytics: boolean; + custom_domain: boolean; + api_access: boolean; + }; +} + +const SubscriptionManager: React.FC = () => { + const { t } = useLocale(); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + loadSubscriptionStats(); + }, []); + + const loadSubscriptionStats = async () => { + try { + const token = localStorage.getItem('token'); + const response = await fetch('/api/subscriptions/usage/', { + headers: { 'Authorization': `Bearer ${token}` } + }); + const data = await response.json(); + setStats(data); + } catch (error) { + console.error('Error loading subscription stats:', error); + } finally { + setLoading(false); + } + }; + + const getProgressColor = (current: number, limit: number, unlimited: boolean) => { + if (unlimited) return 'success'; + const percentage = (current / limit) * 100; + if (percentage >= 90) return 'danger'; + if (percentage >= 75) return 'warning'; + return 'primary'; + }; + + if (loading || !stats) { + return
; + } + + return ( +
+
+
{t('subscription.currentPlan')}
+ + {stats.plan.toUpperCase()} + +
+
+ {/* Usage Stats */} +
+
+
+ +
+
+
+ + {stats.collections.current} / {stats.collections.unlimited ? '∞' : stats.collections.limit} + +
+
+ +
+
+ +
+
+
+ + {stats.groups.current} / {stats.groups.unlimited ? '∞' : stats.groups.limit} + +
+
+ +
+
+ +
+
+
+ + {stats.links.current} / {stats.links.unlimited ? '∞' : stats.links.limit} + +
+
+
+ + {/* Features */} +
+
+
{t('subscription.features')}
+
+ + + {t('subscription.analytics')} + + + + {t('subscription.customDomain')} + + + + {t('subscription.apiAccess')} + +
+
+
+ + {/* Upgrade Button */} + {stats.plan === 'free' && ( +
+ +
+ )} +
+
+ ); +}; + +export default SubscriptionManager; +``` + +#### 2. ΠšΠΎΠΌΠΏΠΎΠ½Π΅Π½Ρ‚ Π°ΠΏΠ³Ρ€Π΅ΠΉΠ΄Π° +```typescript +// frontend/src/app/components/UpgradeModal.tsx + +import React, { useState } from 'react'; +import { loadStripe } from '@stripe/stripe-js'; +import { Elements, CardElement, useStripe, useElements } from '@stripe/react-stripe-js'; + +const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!); + +interface Plan { + name: string; + display_name: string; + price_monthly: number; + price_yearly: number; + features: string[]; +} + +interface UpgradeModalProps { + isOpen: boolean; + onClose: () => void; +} + +const CheckoutForm: React.FC<{ plan: Plan; onSuccess: () => void }> = ({ plan, onSuccess }) => { + const stripe = useStripe(); + const elements = useElements(); + const [processing, setProcessing] = useState(false); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + if (!stripe || !elements) return; + + setProcessing(true); + + const cardElement = elements.getElement(CardElement); + if (!cardElement) return; + + const { error, paymentMethod } = await stripe.createPaymentMethod({ + type: 'card', + card: cardElement, + }); + + if (error) { + console.error(error); + setProcessing(false); + return; + } + + // Π‘ΠΎΠ·Π΄Π°Ρ‚ΡŒ подписку Ρ‡Π΅Ρ€Π΅Π· API + try { + const token = localStorage.getItem('token'); + const response = await fetch('/api/subscriptions/create/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${token}` + }, + body: JSON.stringify({ + plan_name: plan.name, + payment_method_id: paymentMethod.id + }) + }); + + if (response.ok) { + onSuccess(); + } + } catch (error) { + console.error('Subscription creation failed:', error); + } finally { + setProcessing(false); + } + }; + + return ( +
+
+ +
+ +
+ ); +}; + +export const UpgradeModal: React.FC = ({ isOpen, onClose }) => { + const [selectedPlan, setSelectedPlan] = useState(null); + + const plans: Plan[] = [ + { + name: 'premium', + display_name: 'Premium', + price_monthly: 5, + price_yearly: 50, + features: ['Unlimited collections', 'Advanced analytics', 'Custom themes'] + }, + { + name: 'business', + display_name: 'Business', + price_monthly: 15, + price_yearly: 150, + features: ['Everything in Premium', 'Team collaboration', 'Custom domain', 'API access'] + } + ]; + + if (!isOpen) return null; + + return ( +
+
+
+
+
Upgrade Your Plan
+
+
+ {!selectedPlan ? ( +
+ {plans.map((plan) => ( +
+
+
+

{plan.display_name}

+
+ ${plan.price_monthly} + /month +
+
    + {plan.features.map((feature, index) => ( +
  • + + {feature} +
  • + ))} +
+ +
+
+
+ ))} +
+ ) : ( + + { + onClose(); + window.location.reload(); // ΠžΠ±Π½ΠΎΠ²ΠΈΡ‚ΡŒ страницу послС ΡƒΡΠΏΠ΅ΡˆΠ½ΠΎΠΉ подписки + }} + /> + + )} +
+
+
+
+ ); +}; + +export default UpgradeModal; +``` + +### πŸ—ƒοΈ ΠœΠΈΠ³Ρ€Π°Ρ†ΠΈΠΈ Π±Π°Π·Ρ‹ Π΄Π°Π½Π½Ρ‹Ρ… + +```sql +-- Π‘ΠΎΠ·Π΄Π°Π½ΠΈΠ΅ ΠΏΠ»Π°Π½ΠΎΠ² подписок +INSERT INTO subscription_plans (name, display_name, price_monthly, price_yearly, description, max_collections, max_groups, max_links, analytics_enabled, custom_domain_enabled, api_access_enabled) VALUES +('free', 'Free', 0, 0, 'Basic features for personal use', 1, 10, 50, false, false, false), +('premium', 'Premium', 5, 50, 'Advanced features for creators', 5, -1, -1, true, false, false), +('business', 'Business', 15, 150, 'Professional features for teams', -1, -1, -1, true, true, true); + +-- Π‘ΠΎΠ·Π΄Π°Π½ΠΈΠ΅ Π΄Π΅Ρ„ΠΎΠ»Ρ‚Π½Ρ‹Ρ… подписок для ΡΡƒΡ‰Π΅ΡΡ‚Π²ΡƒΡŽΡ‰ΠΈΡ… ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Π΅ΠΉ +INSERT INTO user_subscriptions (user_id, plan_id, status, starts_at, expires_at) +SELECT + u.id, + (SELECT id FROM subscription_plans WHERE name = 'free'), + 'active', + NOW(), + '2099-12-31 23:59:59' +FROM auth_user u +LEFT JOIN user_subscriptions us ON u.id = us.user_id +WHERE us.id IS NULL; + +-- Π‘ΠΎΠ·Π΄Π°Π½ΠΈΠ΅ Π΄Π΅Ρ„ΠΎΠ»Ρ‚Π½Ρ‹Ρ… ΠΊΠΎΠ»Π»Π΅ΠΊΡ†ΠΈΠΉ для ΡΡƒΡ‰Π΅ΡΡ‚Π²ΡƒΡŽΡ‰ΠΈΡ… ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Π΅ΠΉ +INSERT INTO link_collections (user_id, name, slug, is_default, access_type, created_at, updated_at) +SELECT + u.id, + 'Main Collection', + 'main', + true, + 'public', + NOW(), + NOW() +FROM auth_user u +LEFT JOIN link_collections lc ON u.id = lc.user_id +WHERE lc.id IS NULL; + +-- ΠŸΡ€ΠΈΠ²ΡΠ·ΠΊΠ° ΡΡƒΡ‰Π΅ΡΡ‚Π²ΡƒΡŽΡ‰ΠΈΡ… Π³Ρ€ΡƒΠΏΠΏ ΠΈ ссылок ΠΊ Π΄Π΅Ρ„ΠΎΠ»Ρ‚Π½Ρ‹ΠΌ коллСкциям +UPDATE link_groups lg +SET collection_id = ( + SELECT lc.id + FROM link_collections lc + WHERE lc.user_id = lg.user_id AND lc.is_default = true +) +WHERE lg.collection_id IS NULL; + +UPDATE links l +SET collection_id = ( + SELECT lc.id + FROM link_collections lc + WHERE lc.user_id = l.user_id AND lc.is_default = true +) +WHERE l.collection_id IS NULL; +``` + +Π­Ρ‚ΠΎΡ‚ ΠΏΠ»Π°Π½ обСспСчиваСт ΠΏΠΎΠ»Π½ΡƒΡŽ основу для ΠΏΡ€Π΅ΠΌΠΈΡƒΠΌ Ρ„ΡƒΠ½ΠΊΡ†ΠΈΠΎΠ½Π°Π»Π° с ΠΏΡ€ΠΎΠ²Π΅Ρ€ΠΊΠΎΠΉ Π»ΠΈΠΌΠΈΡ‚ΠΎΠ², ΠΈΠ½Ρ‚Π΅Π³Ρ€Π°Ρ†ΠΈΠ΅ΠΉ Stripe ΠΈ соврСмСнным React интСрфСйсом. \ No newline at end of file diff --git a/frontend/linktree-frontend/src/app/components/LanguageSelector.tsx b/frontend/linktree-frontend/src/app/components/LanguageSelector.tsx index 597f215..f014d08 100644 --- a/frontend/linktree-frontend/src/app/components/LanguageSelector.tsx +++ b/frontend/linktree-frontend/src/app/components/LanguageSelector.tsx @@ -4,33 +4,36 @@ import { useLocale, Locale } from '../contexts/LocaleContext'; const LanguageSelector: React.FC = () => { const { locale, setLocale, t } = useLocale(); - const languages: Array<{ code: Locale; name: string }> = [ - { code: 'en', name: 'English' }, - { code: 'ru', name: 'Русский' }, - { code: 'ko', name: 'ν•œκ΅­μ–΄' }, - { code: 'zh', name: 'δΈ­ζ–‡' }, - { code: 'ja', name: 'ζ—₯本θͺž' }, + const languages: Array<{ code: Locale; name: string; flag: string }> = [ + { code: 'en', name: 'English', flag: 'πŸ‡ΊπŸ‡Έ' }, + { code: 'ru', name: 'Русский', flag: 'πŸ‡·πŸ‡Ί' }, + { code: 'ko', name: 'ν•œκ΅­μ–΄', flag: 'πŸ‡°πŸ‡·' }, + { code: 'zh', name: 'δΈ­ζ–‡', flag: 'πŸ‡¨πŸ‡³' }, + { code: 'ja', name: 'ζ—₯本θͺž', flag: 'πŸ‡―πŸ‡΅' }, ]; + const currentLanguage = languages.find(lang => lang.code === locale); + return (
    {languages.map((language) => (
  • diff --git a/frontend/linktree-frontend/src/app/components/LayoutWrapper.tsx b/frontend/linktree-frontend/src/app/components/LayoutWrapper.tsx index 4e0f5f6..5485a21 100644 --- a/frontend/linktree-frontend/src/app/components/LayoutWrapper.tsx +++ b/frontend/linktree-frontend/src/app/components/LayoutWrapper.tsx @@ -13,7 +13,10 @@ import LanguageSelector from './LanguageSelector' import '../layout.css' interface User { + id: number username: string + email: string + full_name: string avatar: string | null } @@ -37,9 +40,14 @@ export function LayoutWrapper({ children }: { children: ReactNode }) { return res.json() }) .then(data => { - // fullname ΠΈΠ»ΠΈ username - const name = data.full_name?.trim() || data.username - setUser({ username: name, avatar: data.avatar }) + // ЗаполняСм ΠΏΠΎΠ»Π½ΡƒΡŽ ΠΈΠ½Ρ„ΠΎΡ€ΠΌΠ°Ρ†ΠΈΡŽ ΠΎ ΠΏΠΎΠ»ΡŒΠ·ΠΎΠ²Π°Ρ‚Π΅Π»Π΅ + setUser({ + id: data.id, + username: data.username, + email: data.email, + full_name: data.full_name || '', + avatar: data.avatar + }) }) .catch(() => { // ΡΠ±Ρ€ΠΎΡΠΈΡ‚ΡŒ Π½Π΅ΠΊΠΎΡ€Ρ€Π΅ΠΊΡ‚Π½Ρ‹ΠΉ Ρ‚ΠΎΠΊΠ΅Π½ @@ -61,62 +69,111 @@ export function LayoutWrapper({ children }: { children: ReactNode }) { <> {/* Π¨Π°ΠΏΠΊΠ° Π½Π΅ Π²Ρ‹Π²ΠΎΠ΄ΠΈΠΌ Π½Π° ΠΏΡƒΠ±Π»ΠΈΡ‡Π½Ρ‹Ρ… страницах /[username] */} {!isPublicUserPage && ( -
+ + {/* ΠŸΡ€Π°Π²ΠΎΠ΅ мСню */} +
+ {/* ΠŸΠ΅Ρ€Π΅ΠΊΠ»ΡŽΡ‡Π°Ρ‚Π΅Π»ΠΈ Ρ‚Π΅ΠΌΡ‹ ΠΈ языка всСгда Π²ΠΈΠ΄Π½Ρ‹ */} + + + + {!user ? ( +
+ + + {t('common.login')} + + + + {t('common.register')} + +
+ ) : ( +
+ +
    +
  • + + + {t('profile.edit')} + +
  • +
  • + + + {t('dashboard.title')} + +
  • +

  • +
  • + +
  • +
+
+ )} +
diff --git a/frontend/linktree-frontend/src/app/components/ThemeToggle.tsx b/frontend/linktree-frontend/src/app/components/ThemeToggle.tsx index 951d644..8d314d9 100644 --- a/frontend/linktree-frontend/src/app/components/ThemeToggle.tsx +++ b/frontend/linktree-frontend/src/app/components/ThemeToggle.tsx @@ -2,6 +2,7 @@ import React from 'react' import { useTheme } from '../contexts/ThemeContext' +import { useLocale } from '../contexts/LocaleContext' interface ThemeToggleProps { className?: string @@ -10,19 +11,22 @@ interface ThemeToggleProps { export const ThemeToggle: React.FC = ({ className = '', - showLabel = true + showLabel = false }) => { const { theme, toggleTheme } = useTheme() + const { t } = useLocale() return ( ) diff --git a/frontend/linktree-frontend/src/app/layout.css b/frontend/linktree-frontend/src/app/layout.css index bbbf848..37694de 100644 --- a/frontend/linktree-frontend/src/app/layout.css +++ b/frontend/linktree-frontend/src/app/layout.css @@ -1,4 +1,51 @@ /* Layout spacing */ .navbar-spacing { height: 70px; +} + +/* Navbar improvements */ +.navbar-brand { + font-weight: 600; + font-size: 1.25rem; +} + +.navbar .dropdown-toggle::after { + display: none; +} + +.dropdown-menu { + border: none; + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); + border-radius: 0.375rem; +} + +.dropdown-item { + padding: 0.5rem 1rem; + font-size: 0.875rem; +} + +.dropdown-item:hover { + background-color: var(--bs-primary); + color: white; +} + +.dropdown-item.text-danger:hover { + background-color: var(--bs-danger); + color: white; +} + +/* Theme aware navbar */ +.navbar-expand-lg { + transition: background-color 0.3s ease; +} + +/* Profile page styles */ +.profile-avatar { + object-fit: cover; +} + +.profile-cover { + max-height: 200px; + width: 100%; + object-fit: cover; } \ No newline at end of file diff --git a/frontend/linktree-frontend/src/app/profile/page.tsx b/frontend/linktree-frontend/src/app/profile/page.tsx new file mode 100644 index 0000000..f65d0c1 --- /dev/null +++ b/frontend/linktree-frontend/src/app/profile/page.tsx @@ -0,0 +1,311 @@ +'use client' + +import React, { useState, useEffect } from 'react' +import { useRouter } from 'next/navigation' +import { useLocale } from '../contexts/LocaleContext' + +interface UserProfile { + id: number + username: string + email: string + full_name: string + bio: string + avatar: string | null + cover: string | null +} + +export default function ProfilePage() { + const { t } = useLocale() + const router = useRouter() + const [profile, setProfile] = useState(null) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [avatarFile, setAvatarFile] = useState(null) + const [coverFile, setCoverFile] = useState(null) + + useEffect(() => { + loadProfile() + }, []) + + const loadProfile = async () => { + try { + const token = localStorage.getItem('token') + if (!token) { + router.push('/auth/login') + return + } + + const response = await fetch('/api/auth/user', { + headers: { 'Authorization': `Bearer ${token}` } + }) + + if (!response.ok) { + throw new Error('Failed to load profile') + } + + const data = await response.json() + setProfile(data) + } catch (error) { + console.error('Error loading profile:', error) + } finally { + setLoading(false) + } + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!profile) return + + setSaving(true) + try { + const token = localStorage.getItem('token') + const formData = new FormData() + + formData.append('username', profile.username) + formData.append('email', profile.email) + formData.append('full_name', profile.full_name) + formData.append('bio', profile.bio) + + if (avatarFile) { + formData.append('avatar', avatarFile) + } + + if (coverFile) { + formData.append('cover', coverFile) + } + + const response = await fetch('/api/auth/user', { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${token}` + }, + body: formData + }) + + if (response.ok) { + const updatedProfile = await response.json() + setProfile(updatedProfile) + // ΠžΡ‡ΠΈΡΡ‚ΠΈΡ‚ΡŒ Π²Ρ‹Π±Ρ€Π°Π½Π½Ρ‹Π΅ Ρ„Π°ΠΉΠ»Ρ‹ + setAvatarFile(null) + setCoverFile(null) + } + } catch (error) { + console.error('Error saving profile:', error) + } finally { + setSaving(false) + } + } + + const removeAvatar = () => { + if (profile) { + setProfile({ ...profile, avatar: null }) + } + } + + const removeCover = () => { + if (profile) { + setProfile({ ...profile, cover: null }) + } + } + + if (loading) { + return ( +
+
+
+ {t('common.loading')} +
+
+
+ ) + } + + if (!profile) { + return ( +
+
+ {t('common.error')}: Profile not found +
+
+ ) + } + + return ( +
+
+
+
+
+

+ + {t('profile.edit')} +

+
+
+
+ {/* Avatar */} +
+
+ Avatar +
+
+ + {(profile.avatar || avatarFile) && ( + + )} +
+
+ + {/* Cover Image */} +
+ + {(profile.cover || coverFile) && ( +
+ Cover +
+ )} +
+ + {(profile.cover || coverFile) && ( + + )} +
+
+ + {/* Basic Info */} +
+
+
+ + setProfile({ ...profile, username: e.target.value })} + required + /> +
+
+
+
+ + setProfile({ ...profile, email: e.target.value })} + required + /> +
+
+
+ +
+ + setProfile({ ...profile, full_name: e.target.value })} + /> +
+ +
+ +