feat: улучшена навигационная панель с полной интеграцией темы и локализации #1
319
PREMIUM_FEATURES.md
Normal file
319
PREMIUM_FEATURES.md
Normal file
@@ -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 из простого агрегатора ссылок в мощную платформу для управления цифровым присутствием и аналитики.
|
||||||
972
PREMIUM_IMPLEMENTATION.md
Normal file
972
PREMIUM_IMPLEMENTATION.md
Normal file
@@ -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<SubscriptionStats | null>(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 <div className="spinner-border" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 className="mb-0">{t('subscription.currentPlan')}</h5>
|
||||||
|
<span className={`badge bg-${stats.plan === 'free' ? 'secondary' : 'primary'}`}>
|
||||||
|
{stats.plan.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
{/* Usage Stats */}
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-md-4">
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">{t('subscription.collections')}</label>
|
||||||
|
<div className="progress">
|
||||||
|
<div
|
||||||
|
className={`progress-bar bg-${getProgressColor(
|
||||||
|
stats.collections.current,
|
||||||
|
stats.collections.limit,
|
||||||
|
stats.collections.unlimited
|
||||||
|
)}`}
|
||||||
|
style={{
|
||||||
|
width: stats.collections.unlimited
|
||||||
|
? '100%'
|
||||||
|
: `${(stats.collections.current / stats.collections.limit) * 100}%`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<small className="text-muted">
|
||||||
|
{stats.collections.current} / {stats.collections.unlimited ? '∞' : stats.collections.limit}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-md-4">
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">{t('subscription.groups')}</label>
|
||||||
|
<div className="progress">
|
||||||
|
<div
|
||||||
|
className={`progress-bar bg-${getProgressColor(
|
||||||
|
stats.groups.current,
|
||||||
|
stats.groups.limit,
|
||||||
|
stats.groups.unlimited
|
||||||
|
)}`}
|
||||||
|
style={{
|
||||||
|
width: stats.groups.unlimited
|
||||||
|
? '100%'
|
||||||
|
: `${(stats.groups.current / stats.groups.limit) * 100}%`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<small className="text-muted">
|
||||||
|
{stats.groups.current} / {stats.groups.unlimited ? '∞' : stats.groups.limit}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="col-md-4">
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">{t('subscription.links')}</label>
|
||||||
|
<div className="progress">
|
||||||
|
<div
|
||||||
|
className={`progress-bar bg-${getProgressColor(
|
||||||
|
stats.links.current,
|
||||||
|
stats.links.limit,
|
||||||
|
stats.links.unlimited
|
||||||
|
)}`}
|
||||||
|
style={{
|
||||||
|
width: stats.links.unlimited
|
||||||
|
? '100%'
|
||||||
|
: `${(stats.links.current / stats.links.limit) * 100}%`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<small className="text-muted">
|
||||||
|
{stats.links.current} / {stats.links.unlimited ? '∞' : stats.links.limit}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
|
<div className="row mt-3">
|
||||||
|
<div className="col-12">
|
||||||
|
<h6>{t('subscription.features')}</h6>
|
||||||
|
<div className="d-flex gap-3">
|
||||||
|
<span className={`badge ${stats.features.analytics ? 'bg-success' : 'bg-secondary'}`}>
|
||||||
|
<i className={`fas fa-${stats.features.analytics ? 'check' : 'times'} me-1`} />
|
||||||
|
{t('subscription.analytics')}
|
||||||
|
</span>
|
||||||
|
<span className={`badge ${stats.features.custom_domain ? 'bg-success' : 'bg-secondary'}`}>
|
||||||
|
<i className={`fas fa-${stats.features.custom_domain ? 'check' : 'times'} me-1`} />
|
||||||
|
{t('subscription.customDomain')}
|
||||||
|
</span>
|
||||||
|
<span className={`badge ${stats.features.api_access ? 'bg-success' : 'bg-secondary'}`}>
|
||||||
|
<i className={`fas fa-${stats.features.api_access ? 'check' : 'times'} me-1`} />
|
||||||
|
{t('subscription.apiAccess')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Upgrade Button */}
|
||||||
|
{stats.plan === 'free' && (
|
||||||
|
<div className="mt-4 text-center">
|
||||||
|
<button className="btn btn-primary btn-lg">
|
||||||
|
<i className="fas fa-rocket me-2" />
|
||||||
|
{t('subscription.upgradeToPremium')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="mb-3">
|
||||||
|
<CardElement
|
||||||
|
options={{
|
||||||
|
style: {
|
||||||
|
base: {
|
||||||
|
fontSize: '16px',
|
||||||
|
color: '#424770',
|
||||||
|
'::placeholder': {
|
||||||
|
color: '#aab7c4',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary w-100"
|
||||||
|
disabled={!stripe || processing}
|
||||||
|
>
|
||||||
|
{processing ? 'Обработка...' : `Подписаться на ${plan.display_name}`}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UpgradeModal: React.FC<UpgradeModalProps> = ({ isOpen, onClose }) => {
|
||||||
|
const [selectedPlan, setSelectedPlan] = useState<Plan | null>(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 (
|
||||||
|
<div className="modal fade show d-block" style={{ backgroundColor: 'rgba(0,0,0,0.5)' }}>
|
||||||
|
<div className="modal-dialog modal-lg">
|
||||||
|
<div className="modal-content">
|
||||||
|
<div className="modal-header">
|
||||||
|
<h5 className="modal-title">Upgrade Your Plan</h5>
|
||||||
|
<button type="button" className="btn-close" onClick={onClose} />
|
||||||
|
</div>
|
||||||
|
<div className="modal-body">
|
||||||
|
{!selectedPlan ? (
|
||||||
|
<div className="row">
|
||||||
|
{plans.map((plan) => (
|
||||||
|
<div key={plan.name} className="col-md-6 mb-3">
|
||||||
|
<div className="card h-100">
|
||||||
|
<div className="card-body text-center">
|
||||||
|
<h4>{plan.display_name}</h4>
|
||||||
|
<div className="mb-3">
|
||||||
|
<span className="h3">${plan.price_monthly}</span>
|
||||||
|
<span className="text-muted">/month</span>
|
||||||
|
</div>
|
||||||
|
<ul className="list-unstyled">
|
||||||
|
{plan.features.map((feature, index) => (
|
||||||
|
<li key={index} className="mb-2">
|
||||||
|
<i className="fas fa-check text-success me-2" />
|
||||||
|
{feature}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary w-100"
|
||||||
|
onClick={() => setSelectedPlan(plan)}
|
||||||
|
>
|
||||||
|
Choose {plan.display_name}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Elements stripe={stripePromise}>
|
||||||
|
<CheckoutForm
|
||||||
|
plan={selectedPlan}
|
||||||
|
onSuccess={() => {
|
||||||
|
onClose();
|
||||||
|
window.location.reload(); // Обновить страницу после успешной подписки
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Elements>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 интерфейсом.
|
||||||
@@ -4,33 +4,36 @@ import { useLocale, Locale } from '../contexts/LocaleContext';
|
|||||||
const LanguageSelector: React.FC = () => {
|
const LanguageSelector: React.FC = () => {
|
||||||
const { locale, setLocale, t } = useLocale();
|
const { locale, setLocale, t } = useLocale();
|
||||||
|
|
||||||
const languages: Array<{ code: Locale; name: string }> = [
|
const languages: Array<{ code: Locale; name: string; flag: string }> = [
|
||||||
{ code: 'en', name: 'English' },
|
{ code: 'en', name: 'English', flag: '🇺🇸' },
|
||||||
{ code: 'ru', name: 'Русский' },
|
{ code: 'ru', name: 'Русский', flag: '🇷🇺' },
|
||||||
{ code: 'ko', name: '한국어' },
|
{ code: 'ko', name: '한국어', flag: '🇰🇷' },
|
||||||
{ code: 'zh', name: '中文' },
|
{ code: 'zh', name: '中文', flag: '🇨🇳' },
|
||||||
{ code: 'ja', name: '日本語' },
|
{ code: 'ja', name: '日本語', flag: '🇯🇵' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const currentLanguage = languages.find(lang => lang.code === locale);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dropdown">
|
<div className="dropdown">
|
||||||
<button
|
<button
|
||||||
className="btn btn-outline-secondary btn-sm dropdown-toggle"
|
className="btn btn-outline-secondary btn-sm dropdown-toggle d-flex align-items-center"
|
||||||
type="button"
|
type="button"
|
||||||
data-bs-toggle="dropdown"
|
data-bs-toggle="dropdown"
|
||||||
aria-expanded="false"
|
aria-expanded="false"
|
||||||
title={t('language.select')}
|
title={t('language.select')}
|
||||||
>
|
>
|
||||||
<i className="fas fa-globe me-1"></i>
|
<span className="me-1">{currentLanguage?.flag}</span>
|
||||||
{languages.find(lang => lang.code === locale)?.name || 'Language'}
|
<span className="d-none d-lg-inline">{currentLanguage?.name}</span>
|
||||||
</button>
|
</button>
|
||||||
<ul className="dropdown-menu">
|
<ul className="dropdown-menu">
|
||||||
{languages.map((language) => (
|
{languages.map((language) => (
|
||||||
<li key={language.code}>
|
<li key={language.code}>
|
||||||
<button
|
<button
|
||||||
className={`dropdown-item ${locale === language.code ? 'active' : ''}`}
|
className={`dropdown-item d-flex align-items-center ${locale === language.code ? 'active' : ''}`}
|
||||||
onClick={() => setLocale(language.code)}
|
onClick={() => setLocale(language.code)}
|
||||||
>
|
>
|
||||||
|
<span className="me-2">{language.flag}</span>
|
||||||
{language.name}
|
{language.name}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ import LanguageSelector from './LanguageSelector'
|
|||||||
import '../layout.css'
|
import '../layout.css'
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
|
id: number
|
||||||
username: string
|
username: string
|
||||||
|
email: string
|
||||||
|
full_name: string
|
||||||
avatar: string | null
|
avatar: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,9 +40,14 @@ export function LayoutWrapper({ children }: { children: ReactNode }) {
|
|||||||
return res.json()
|
return res.json()
|
||||||
})
|
})
|
||||||
.then(data => {
|
.then(data => {
|
||||||
// fullname или username
|
// Заполняем полную информацию о пользователе
|
||||||
const name = data.full_name?.trim() || data.username
|
setUser({
|
||||||
setUser({ username: name, avatar: data.avatar })
|
id: data.id,
|
||||||
|
username: data.username,
|
||||||
|
email: data.email,
|
||||||
|
full_name: data.full_name || '',
|
||||||
|
avatar: data.avatar
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
// сбросить некорректный токен
|
// сбросить некорректный токен
|
||||||
@@ -61,62 +69,111 @@ export function LayoutWrapper({ children }: { children: ReactNode }) {
|
|||||||
<>
|
<>
|
||||||
{/* Шапка не выводим на публичных страницах /[username] */}
|
{/* Шапка не выводим на публичных страницах /[username] */}
|
||||||
{!isPublicUserPage && (
|
{!isPublicUserPage && (
|
||||||
<nav className="navbar navbar-expand bg-light fixed-top shadow-sm">
|
<nav className="navbar navbar-expand-lg theme-bg-secondary fixed-top shadow-sm border-bottom theme-border">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<Link href="/" className="navbar-brand d-flex align-items-center">
|
<Link href="/" className="navbar-brand d-flex align-items-center">
|
||||||
<Image
|
<Image
|
||||||
src="/assets/img/CAT.png"
|
src="/assets/img/CAT.png"
|
||||||
alt="CatLink"
|
alt="CatLink"
|
||||||
width={89}
|
width={32}
|
||||||
height={89}
|
height={32}
|
||||||
|
className="me-2"
|
||||||
/>
|
/>
|
||||||
<span className="ms-2">CatLink</span>
|
<span className="fw-bold">CatLink</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="navbar-toggler"
|
className="navbar-toggler"
|
||||||
type="button"
|
type="button"
|
||||||
data-bs-toggle="collapse"
|
data-bs-toggle="collapse"
|
||||||
data-bs-target="#navcol-1"
|
data-bs-target="#navcol-1"
|
||||||
title={t('common.menu')}
|
title={t('common.menu')}
|
||||||
/>
|
>
|
||||||
|
<span className="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div className="collapse navbar-collapse" id="navcol-1">
|
<div className="collapse navbar-collapse" id="navcol-1">
|
||||||
{!user && (
|
{/* Левое меню */}
|
||||||
<Link href="/auth/login" className="btn btn-primary ms-auto">
|
<ul className="navbar-nav me-auto">
|
||||||
<i className="fa fa-user"></i>
|
{user && (
|
||||||
<span className="d-none d-sm-inline"> {t('common.login')}</span>
|
<li className="nav-item">
|
||||||
</Link>
|
<Link href="/dashboard" className="nav-link">
|
||||||
)}
|
<i className="fas fa-tachometer-alt me-1"></i>
|
||||||
{user && (
|
|
||||||
<div className="ms-auto d-flex align-items-center gap-3">
|
|
||||||
<ThemeToggle />
|
|
||||||
<LanguageSelector />
|
|
||||||
<Image
|
|
||||||
src={
|
|
||||||
user.avatar && user.avatar.startsWith('http')
|
|
||||||
? user.avatar
|
|
||||||
: user.avatar
|
|
||||||
? `${process.env.NEXT_PUBLIC_API_URL || 'https://links.shareon.kr'}${user.avatar}`
|
|
||||||
: '/assets/img/avatar-dhg.png'
|
|
||||||
}
|
|
||||||
alt="Avatar"
|
|
||||||
width={32}
|
|
||||||
height={32}
|
|
||||||
className="rounded-circle"
|
|
||||||
/>
|
|
||||||
<span>{user.username}</span>
|
|
||||||
{!isDashboard && (
|
|
||||||
<Link href="/dashboard" className="btn btn-outline-secondary btn-sm">
|
|
||||||
{t('dashboard.title')}
|
{t('dashboard.title')}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
</li>
|
||||||
<button
|
)}
|
||||||
onClick={handleLogout}
|
</ul>
|
||||||
className="btn btn-outline-danger btn-sm"
|
|
||||||
>
|
{/* Правое меню */}
|
||||||
{t('common.logout')}
|
<div className="d-flex align-items-center gap-2">
|
||||||
</button>
|
{/* Переключатели темы и языка всегда видны */}
|
||||||
</div>
|
<ThemeToggle />
|
||||||
)}
|
<LanguageSelector />
|
||||||
|
|
||||||
|
{!user ? (
|
||||||
|
<div className="d-flex gap-2 ms-2">
|
||||||
|
<Link href="/auth/login" className="btn btn-outline-primary btn-sm">
|
||||||
|
<i className="fas fa-sign-in-alt me-1"></i>
|
||||||
|
<span className="d-none d-sm-inline">{t('common.login')}</span>
|
||||||
|
</Link>
|
||||||
|
<Link href="/auth/register" className="btn btn-primary btn-sm">
|
||||||
|
<i className="fas fa-user-plus me-1"></i>
|
||||||
|
<span className="d-none d-sm-inline">{t('common.register')}</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="dropdown ms-2">
|
||||||
|
<button
|
||||||
|
className="btn btn-link text-decoration-none d-flex align-items-center dropdown-toggle"
|
||||||
|
type="button"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={
|
||||||
|
user.avatar && user.avatar.startsWith('http')
|
||||||
|
? user.avatar
|
||||||
|
: user.avatar
|
||||||
|
? `${process.env.NEXT_PUBLIC_API_URL || 'https://links.shareon.kr'}${user.avatar}`
|
||||||
|
: '/assets/img/avatar-dhg.png'
|
||||||
|
}
|
||||||
|
alt="Avatar"
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
className="rounded-circle me-2"
|
||||||
|
/>
|
||||||
|
<span className="text-dark fw-medium d-none d-md-inline">
|
||||||
|
{user.full_name?.trim() || user.username}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<ul className="dropdown-menu dropdown-menu-end">
|
||||||
|
<li>
|
||||||
|
<Link href="/profile" className="dropdown-item">
|
||||||
|
<i className="fas fa-user me-2"></i>
|
||||||
|
{t('profile.edit')}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href="/dashboard" className="dropdown-item">
|
||||||
|
<i className="fas fa-tachometer-alt me-2"></i>
|
||||||
|
{t('dashboard.title')}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li><hr className="dropdown-divider" /></li>
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="dropdown-item text-danger"
|
||||||
|
>
|
||||||
|
<i className="fas fa-sign-out-alt me-2"></i>
|
||||||
|
{t('common.logout')}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import React from 'react'
|
import React from 'react'
|
||||||
import { useTheme } from '../contexts/ThemeContext'
|
import { useTheme } from '../contexts/ThemeContext'
|
||||||
|
import { useLocale } from '../contexts/LocaleContext'
|
||||||
|
|
||||||
interface ThemeToggleProps {
|
interface ThemeToggleProps {
|
||||||
className?: string
|
className?: string
|
||||||
@@ -10,19 +11,22 @@ interface ThemeToggleProps {
|
|||||||
|
|
||||||
export const ThemeToggle: React.FC<ThemeToggleProps> = ({
|
export const ThemeToggle: React.FC<ThemeToggleProps> = ({
|
||||||
className = '',
|
className = '',
|
||||||
showLabel = true
|
showLabel = false
|
||||||
}) => {
|
}) => {
|
||||||
const { theme, toggleTheme } = useTheme()
|
const { theme, toggleTheme } = useTheme()
|
||||||
|
const { t } = useLocale()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
className={`btn btn-outline-secondary d-flex align-items-center ${className}`}
|
className={`btn btn-outline-secondary btn-sm d-flex align-items-center ${className}`}
|
||||||
title={theme === 'light' ? 'Переключить на темную тему' : 'Переключить на светлую тему'}
|
title={theme === 'light' ? t('theme.dark') : t('theme.light')}
|
||||||
>
|
>
|
||||||
<i className={`bi ${theme === 'light' ? 'bi-moon' : 'bi-sun'} ${showLabel ? 'me-2' : ''}`}></i>
|
<i className={`fas ${theme === 'light' ? 'fa-moon' : 'fa-sun'} ${showLabel ? 'me-2' : ''}`}></i>
|
||||||
{showLabel && (
|
{showLabel && (
|
||||||
<span>{theme === 'light' ? 'Темная тема' : 'Светлая тема'}</span>
|
<span className="d-none d-lg-inline">
|
||||||
|
{theme === 'light' ? t('theme.dark') : t('theme.light')}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,3 +2,50 @@
|
|||||||
.navbar-spacing {
|
.navbar-spacing {
|
||||||
height: 70px;
|
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;
|
||||||
|
}
|
||||||
311
frontend/linktree-frontend/src/app/profile/page.tsx
Normal file
311
frontend/linktree-frontend/src/app/profile/page.tsx
Normal file
@@ -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<UserProfile | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [avatarFile, setAvatarFile] = useState<File | null>(null)
|
||||||
|
const [coverFile, setCoverFile] = useState<File | null>(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 (
|
||||||
|
<div className="container mt-4">
|
||||||
|
<div className="d-flex justify-content-center">
|
||||||
|
<div className="spinner-border" role="status">
|
||||||
|
<span className="visually-hidden">{t('common.loading')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!profile) {
|
||||||
|
return (
|
||||||
|
<div className="container mt-4">
|
||||||
|
<div className="alert alert-danger">
|
||||||
|
{t('common.error')}: Profile not found
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mt-4">
|
||||||
|
<div className="row justify-content-center">
|
||||||
|
<div className="col-md-8">
|
||||||
|
<div className="card">
|
||||||
|
<div className="card-header">
|
||||||
|
<h4 className="mb-0">
|
||||||
|
<i className="fas fa-user me-2"></i>
|
||||||
|
{t('profile.edit')}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div className="card-body">
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
{/* Avatar */}
|
||||||
|
<div className="mb-4 text-center">
|
||||||
|
<div className="mb-3">
|
||||||
|
<img
|
||||||
|
src={
|
||||||
|
avatarFile
|
||||||
|
? URL.createObjectURL(avatarFile)
|
||||||
|
: profile.avatar
|
||||||
|
? `${process.env.NEXT_PUBLIC_API_URL || 'https://links.shareon.kr'}${profile.avatar}`
|
||||||
|
: '/assets/img/avatar-dhg.png'
|
||||||
|
}
|
||||||
|
alt="Avatar"
|
||||||
|
className="rounded-circle profile-avatar"
|
||||||
|
width={120}
|
||||||
|
height={120}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="d-flex gap-2 justify-content-center">
|
||||||
|
<label className="btn btn-outline-primary btn-sm">
|
||||||
|
<i className="fas fa-camera me-1"></i>
|
||||||
|
{t('profile.avatar')}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
className="d-none"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={(e) => setAvatarFile(e.target.files?.[0] || null)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{(profile.avatar || avatarFile) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-danger btn-sm"
|
||||||
|
onClick={removeAvatar}
|
||||||
|
>
|
||||||
|
<i className="fas fa-times me-1"></i>
|
||||||
|
{t('profile.removeAvatar')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Cover Image */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="form-label">{t('profile.cover')}</label>
|
||||||
|
{(profile.cover || coverFile) && (
|
||||||
|
<div className="mb-2">
|
||||||
|
<img
|
||||||
|
src={
|
||||||
|
coverFile
|
||||||
|
? URL.createObjectURL(coverFile)
|
||||||
|
: `${process.env.NEXT_PUBLIC_API_URL || 'https://links.shareon.kr'}${profile.cover}`
|
||||||
|
}
|
||||||
|
alt="Cover"
|
||||||
|
className="img-fluid rounded profile-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="d-flex gap-2">
|
||||||
|
<label className="btn btn-outline-primary btn-sm">
|
||||||
|
<i className="fas fa-image me-1"></i>
|
||||||
|
{profile.cover ? t('common.update') : t('common.add')}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
className="d-none"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={(e) => setCoverFile(e.target.files?.[0] || null)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{(profile.cover || coverFile) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline-danger btn-sm"
|
||||||
|
onClick={removeCover}
|
||||||
|
>
|
||||||
|
<i className="fas fa-times me-1"></i>
|
||||||
|
{t('profile.removeCover')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Basic Info */}
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">{t('profile.username')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
value={profile.username}
|
||||||
|
onChange={(e) => setProfile({ ...profile, username: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-6">
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">{t('profile.email')}</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
className="form-control"
|
||||||
|
value={profile.email}
|
||||||
|
onChange={(e) => setProfile({ ...profile, email: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-3">
|
||||||
|
<label className="form-label">{t('profile.fullName')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-control"
|
||||||
|
value={profile.full_name}
|
||||||
|
onChange={(e) => setProfile({ ...profile, full_name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="form-label">{t('profile.bio')}</label>
|
||||||
|
<textarea
|
||||||
|
className="form-control"
|
||||||
|
rows={4}
|
||||||
|
value={profile.bio}
|
||||||
|
onChange={(e) => setProfile({ ...profile, bio: e.target.value })}
|
||||||
|
placeholder={t('profile.bio')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="d-flex justify-content-between">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => router.back()}
|
||||||
|
>
|
||||||
|
<i className="fas fa-arrow-left me-2"></i>
|
||||||
|
{t('common.back')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<span className="spinner-border spinner-border-sm me-2"></span>
|
||||||
|
{t('common.saving')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<i className="fas fa-save me-2"></i>
|
||||||
|
{t('common.save')}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user