Some checks failed
continuous-integration/drone/pr Build is failing
- Обновлен LayoutWrapper с улучшенным UI навигации - Добавлен dropdown меню пользователя с аватаром - Интегрированы ThemeToggle и LanguageSelector в навигацию - Переключатели темы и языка теперь всегда видны - Добавлены флаги стран в селектор языков - Создана страница редактирования профиля /profile - Улучшены стили для темной темы в navbar - Добавлены CSS стили для навигации и профиля
972 lines
33 KiB
Markdown
972 lines
33 KiB
Markdown
# 🚀 Техническое руководство: Премиум инфраструктура
|
||
|
||
## 📋 Этап 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 интерфейсом. |