From a963281be0c63584f9ddb4dfecde8b86e7af97c3 Mon Sep 17 00:00:00 2001 From: "Andrey K. Choi" Date: Sun, 9 Nov 2025 15:45:01 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=83=D0=BB=D1=83=D1=87=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D0=BD=D0=B0=D0=B2=D0=B8=D0=B3=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=BE=D0=BD=D0=BD=D0=B0=D1=8F=20=D0=BF=D0=B0=D0=BD=D0=B5=D0=BB?= =?UTF-8?q?=D1=8C=20=D1=81=20=D0=BF=D0=BE=D0=BB=D0=BD=D0=BE=D0=B9=20=D0=B8?= =?UTF-8?q?=D0=BD=D1=82=D0=B5=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D0=B5=D0=B9=20?= =?UTF-8?q?=D1=82=D0=B5=D0=BC=D1=8B=20=D0=B8=20=D0=BB=D0=BE=D0=BA=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Обновлен LayoutWrapper с улучшенным UI навигации - Добавлен dropdown меню пользователя с аватаром - Интегрированы ThemeToggle и LanguageSelector в навигацию - Переключатели темы и языка теперь всегда видны - Добавлены флаги стран в селектор языков - Создана страница редактирования профиля /profile - Улучшены стили для темной темы в navbar - Добавлены CSS стили для навигации и профиля --- PREMIUM_FEATURES.md | 319 ++++++ PREMIUM_IMPLEMENTATION.md | 972 ++++++++++++++++++ .../src/app/components/LanguageSelector.tsx | 23 +- .../src/app/components/LayoutWrapper.tsx | 143 ++- .../src/app/components/ThemeToggle.tsx | 14 +- frontend/linktree-frontend/src/app/layout.css | 47 + .../src/app/profile/page.tsx | 311 ++++++ 7 files changed, 1771 insertions(+), 58 deletions(-) create mode 100644 PREMIUM_FEATURES.md create mode 100644 PREMIUM_IMPLEMENTATION.md create mode 100644 frontend/linktree-frontend/src/app/profile/page.tsx 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 })} + /> +
+ +
+ +