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

33 KiB
Raw Permalink Blame History

🚀 Техническое руководство: Премиум инфраструктура

📋 Этап 1: Базовая премиум система

🏗️ Backend: Django модели

1. Модель подписок

# 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. Модель множественных списков

# 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. Обновление модели групп и ссылок

# 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. Декораторы для проверки лимитов

# 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. Сервисный слой для проверки лимитов

# 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

# 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 маршруты

# 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

# 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 сервисы

# 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 обработка

# 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. Компонент управления подпиской

// 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. Компонент апгрейда

// 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;

🗃️ Миграции базы данных

-- Создание планов подписок
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 интерфейсом.