Files
links/PREMIUM_IMPLEMENTATION.md
Andrey K. Choi a963281be0
Some checks failed
continuous-integration/drone/pr Build is failing
feat: улучшена навигационная панель с полной интеграцией темы и локализации
- Обновлен LayoutWrapper с улучшенным UI навигации
- Добавлен dropdown меню пользователя с аватаром
- Интегрированы ThemeToggle и LanguageSelector в навигацию
- Переключатели темы и языка теперь всегда видны
- Добавлены флаги стран в селектор языков
- Создана страница редактирования профиля /profile
- Улучшены стили для темной темы в navbar
- Добавлены CSS стили для навигации и профиля
2025-11-09 15:45:01 +09:00

972 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 🚀 Техническое руководство: Премиум инфраструктура
## 📋 Этап 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 интерфейсом.