From 29d3af2ea1dd3b0ef58b24bebe57011324e502b7 Mon Sep 17 00:00:00 2001 From: Timofey Date: Thu, 22 May 2025 16:08:40 +0300 Subject: [PATCH] payments card and separated /support url --- backend/api/account/client/serializers.py | 62 +++++++++- backend/api/account/client/views.py | 63 +++++++--- backend/api/main/serializers.py | 8 +- backend/api/main/views.py | 22 +++- backend/api/urls.py | 14 ++- backend/sitemanagement/admin.py | 16 +++ .../0010_pricing_membershipfeatures.py | 35 ++++++ .../migrations/0011_transactions.py | 32 +++++ backend/sitemanagement/models.py | 37 +++++- frontend/app/(urls)/account/layout.tsx | 4 +- frontend/app/(urls)/account/page.tsx | 3 +- .../(urls)/account/payments/PricingCard.tsx | 113 ++++++++++++++++++ frontend/app/(urls)/account/payments/page.tsx | 59 ++++++++- frontend/app/(urls)/account/support/page.tsx | 8 ++ frontend/app/api/account/get-plans/route.ts | 26 ++++ frontend/app/types/index.ts | 10 ++ 16 files changed, 481 insertions(+), 31 deletions(-) create mode 100644 backend/sitemanagement/migrations/0010_pricing_membershipfeatures.py create mode 100644 backend/sitemanagement/migrations/0011_transactions.py create mode 100644 frontend/app/(urls)/account/payments/PricingCard.tsx create mode 100644 frontend/app/(urls)/account/support/page.tsx create mode 100644 frontend/app/api/account/get-plans/route.ts diff --git a/backend/api/account/client/serializers.py b/backend/api/account/client/serializers.py index c29c996..a29206f 100644 --- a/backend/api/account/client/serializers.py +++ b/backend/api/account/client/serializers.py @@ -2,7 +2,9 @@ from rest_framework import serializers from routes.models import Route, City, Country from django.conf import settings from routes.constants.routeChoices import cargo_type_choices, type_transport_choices, owner_type_choices +from routes.constants.account_types import account_types from api.models import UserProfile +from sitemanagement.models import Pricing from django.shortcuts import get_object_or_404 import pytz @@ -204,4 +206,62 @@ class CreateRouteSerializer(serializers.ModelSerializer): return route except Exception as e: raise - \ No newline at end of file + +class PricingSerializer(serializers.ModelSerializer): + isPopular = serializers.BooleanField(source='is_popular') + plan = serializers.CharField() + features = serializers.SerializerMethodField() + + class Meta: + model = Pricing + fields = ['plan', 'price', 'features', 'isPopular'] + + def get_features(self, obj): + return list(obj.membershipfeatures_set.values_list('feature', flat=True)) + + def to_representation(self, instance): + # преобразуем данные перед отправкой на фронтенд + data = super().to_representation(instance) + data['plan'] = data['plan'].lower() # приводим к нижнему регистру + return data + + +class PlanChangeSerializer(serializers.Serializer): + account_type = serializers.CharField() + + def validate_account_type(self, value): + """ + Проверяем, что тип тарифа соответствует допустимым значениям + """ + valid_types = [t[0] for t in account_types] + if value not in valid_types: + raise serializers.ValidationError( + f"Недопустимый тип тарифа. Допустимые значения: {', '.join(valid_types)}" + ) + return value + + def validate(self, data): + """ + Проверка возможности перехода с одного тарифа на другой + """ + if not self.instance: + raise serializers.ValidationError("Пользователь не найден") + + current_type = getattr(self.instance, 'account_type', None) + if not current_type: + raise serializers.ValidationError("У пользователя не установлен текущий тариф") + + new_type = data['account_type'] + + if current_type == new_type: + raise serializers.ValidationError("Этот тариф уже активен") + + return data + + def update(self, instance, validated_data): + """ + Обновляем тип тарифа пользователя + """ + instance.account_type = validated_data['account_type'] + instance.save() + return instance \ No newline at end of file diff --git a/backend/api/account/client/views.py b/backend/api/account/client/views.py index 5439caf..c44847d 100644 --- a/backend/api/account/client/views.py +++ b/backend/api/account/client/views.py @@ -8,14 +8,17 @@ from django.shortcuts import get_object_or_404 from django.core.validators import validate_email from django.core.exceptions import ValidationError from django.db import models +from django.contrib.auth.models import User from api.auth.serializers import UserResponseSerializer from api.models import UserProfile from api.utils.decorators import handle_exceptions from routes.models import Route, City, Country -from .serializers import RouteSerializer, CreateRouteSerializer, CitySerializer, CountrySerializer +from sitemanagement.models import Pricing +from .serializers import RouteSerializer, CreateRouteSerializer, CitySerializer, CountrySerializer, PlanChangeSerializer, PricingSerializer class UserDataView(ViewSet): + """Эндпоинт для наполнения стора фронта данными""" permission_classes = [IsAuthenticated] def initial(self, request, *args, **kwargs): @@ -40,6 +43,10 @@ class UserDataView(ViewSet): ) class AccountActionsView(ViewSet): + """Действия в аккаунте пользователя: + - PATCH данных в account/main + - POST новых заявок""" + permission_classes = [IsAuthenticated] @action(detail=False, methods=['patch']) @@ -90,6 +97,8 @@ class AccountActionsView(ViewSet): @action(detail=False, methods=['get']) @handle_exceptions def user_routes(self, request): + """Получаем список заявок юзера""" + user = request.user routes = Route.objects.filter(owner=user) return Response(RouteSerializer(routes, many=True).data, status=status.HTTP_200_OK) @@ -97,20 +106,17 @@ class AccountActionsView(ViewSet): @action(detail=False, methods=['post']) @handle_exceptions def create_route(self, request): - print("[DEBUG] Входящие данные в create_route view:", request.data) - try: - serializer = CreateRouteSerializer(data=request.data, context={'request': request}) - if not serializer.is_valid(): - print("[DEBUG] Ошибки валидации:", serializer.errors) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - route = serializer.save() - print("[DEBUG] Маршрут успешно создан в view:", route.id) - return Response(serializer.data, status=status.HTTP_201_CREATED) - except Exception as e: - print("[DEBUG] Необработанная ошибка в create_route:", str(e)) - raise + """Создаем новую заявку""" + + serializer = CreateRouteSerializer(data=request.data, context={'request': request}) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + route = serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + class CityView(ViewSet): + """Получаем список городов из базы для автокомплита""" @action(detail=False, methods=['get']) @handle_exceptions @@ -136,6 +142,8 @@ class CityView(ViewSet): return Response(CitySerializer(cities, many=True).data, status=status.HTTP_200_OK) class CountryView(ViewSet): + """Получаем список стран из базы для автокомплита""" + @action(detail=False, methods=['get']) @handle_exceptions def get_countries(self, request): @@ -153,4 +161,31 @@ class CountryView(ViewSet): # сортируем по международному названию countries = countries.order_by('international_name') - return Response(CountrySerializer(countries, many=True).data, status=status.HTTP_200_OK) \ No newline at end of file + return Response(CountrySerializer(countries, many=True).data, status=status.HTTP_200_OK) + +class ChangeUserMembership(ViewSet): + """Меняем тарифный план пользователя""" + + @action(detail=False, methods=['post']) + @handle_exceptions + def change_plan(self, request): + """Меняем пользователю тарифный план""" + user_profile = get_object_or_404(User, id=request.user.id) + serializer = PlanChangeSerializer(user_profile, data=request.data) + + if serializer.is_valid(): + serializer.save() + return Response({"message": "Тариф успешно изменен"}, status=status.HTTP_200_OK) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + +class GetMembershipData(ViewSet): + """Получаем все тарифные планы""" + + @action(detail=False, methods=['get']) + @handle_exceptions + def get_pricing_data(self, request): + """Получаем данные по тарифам""" + pricing_data = Pricing.objects.all().order_by('price') + serializer = PricingSerializer(pricing_data, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) \ No newline at end of file diff --git a/backend/api/main/serializers.py b/backend/api/main/serializers.py index 73b9bc3..cb663d9 100644 --- a/backend/api/main/serializers.py +++ b/backend/api/main/serializers.py @@ -1,6 +1,9 @@ from rest_framework import serializers - +from routes.models import Route from sitemanagement.models import FAQ, News +from django.conf import settings +import pytz +from routes.constants.routeChoices import cargo_type_choices, type_transport_choices class FAQMainSerializer(serializers.ModelSerializer): class Meta: @@ -32,4 +35,5 @@ class TelegramSerializer(serializers.Serializer): message = serializers.CharField(max_length=1000) def create(self, validated_data): - return type('TelegramMessage', (), validated_data) \ No newline at end of file + return type('TelegramMessage', (), validated_data) + \ No newline at end of file diff --git a/backend/api/main/views.py b/backend/api/main/views.py index dafde75..7197550 100644 --- a/backend/api/main/views.py +++ b/backend/api/main/views.py @@ -4,8 +4,12 @@ from rest_framework import status from rest_framework.views import APIView from rest_framework.response import Response from api.utils.decorators import handle_exceptions +from django.db.models import Q +from routes.models import Route +from routes.constants.routeChoices import owner_type_choices from api.main.serializers import FAQMainSerializer, NewsMainSerializer, TelegramSerializer +from api.account.client.serializers import RouteSerializer from sitemanagement.models import FAQ, News class FAQView(APIView): @@ -19,6 +23,7 @@ class FAQView(APIView): } return Response(data, status=status.HTTP_200_OK) + class NewsView(APIView): @handle_exceptions def get(self, request): @@ -30,8 +35,21 @@ class NewsView(APIView): } return Response(data, status=status.HTTP_200_OK) - - + +class LatestRoutesView(APIView): + @handle_exceptions + def get(self, request): + """Получаем последние 5 маршрутов для каждого типа owner_type""" + + latest_routes = {} + owner_types = dict(owner_type_choices).keys() + + for owner_type in owner_types: + routes = Route.objects.filter(owner_type=owner_type).order_by('-id')[:5] + latest_routes[owner_type] = RouteSerializer(routes, many=True).data + + return Response(latest_routes, status=status.HTTP_200_OK) + class TelegramMessageView(APIView): @handle_exceptions def post(self, request): diff --git a/backend/api/urls.py b/backend/api/urls.py index a1af164..67a071b 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -1,6 +1,6 @@ from django.urls import path -from api.main.views import FAQView, NewsView, TelegramMessageView +from api.main.views import FAQView, NewsView, TelegramMessageView, LatestRoutesView from api.auth.views import ( RegisterViewSet, @@ -8,12 +8,18 @@ LoginViewSet, LogoutView, RefreshTokenView) -from api.account.client.views import UserDataView, AccountActionsView, CityView, CountryView +from api.account.client.views import ( +UserDataView, +AccountActionsView, +CityView, +CountryView, +GetMembershipData) urlpatterns = [ path("v1/faq/", FAQView.as_view(), name='faqMain'), path("v1/news/", NewsView.as_view(), name="newsmain"), path("v1/send-message", TelegramMessageView.as_view(), name='send_message'), + path("v1/latest-routes/", LatestRoutesView.as_view(), name='latest_routes'), path("auth/refresh/", RefreshTokenView.as_view(), name="token-refresh"), path("register/clients/", RegisterViewSet.as_view({'post': 'register_client'}), name="register-client"), @@ -27,5 +33,7 @@ urlpatterns = [ path("v1/account/create_route/", AccountActionsView.as_view({'post':'create_route'}), name='create_route'), path("v1/cities/", CityView.as_view({'get':'get_cities'}), name='get_cities'), - path("v1/countries/", CountryView.as_view({'get':'get_countries'}), name='get_countries') + path("v1/countries/", CountryView.as_view({'get':'get_countries'}), name='get_countries'), + + path("v1/plans/", GetMembershipData.as_view({'get':'get_pricing_data'}), name='get_pricing_data'), ] \ No newline at end of file diff --git a/backend/sitemanagement/admin.py b/backend/sitemanagement/admin.py index fb05e7b..6f1b54c 100644 --- a/backend/sitemanagement/admin.py +++ b/backend/sitemanagement/admin.py @@ -1,5 +1,21 @@ from django.contrib import admin from .models import * + +class FeatureInline(admin.TabularInline): + model = MembershipFeatures + list_display = ['features'] # поля в списке + list_filter = ['features'] # фильтры справа + search_fields = ['features'] # поля для поиска + extra = 0 + can_delete = False + verbose_name = 'Параметр тарифных планов' + verbose_name_plural = 'Параметры тарифных планов' + +class PricingAdmin(admin.ModelAdmin): + inlines = [FeatureInline] + + admin.site.register(FAQ) admin.site.register(News) +admin.site.register(Pricing, PricingAdmin) diff --git a/backend/sitemanagement/migrations/0010_pricing_membershipfeatures.py b/backend/sitemanagement/migrations/0010_pricing_membershipfeatures.py new file mode 100644 index 0000000..e949560 --- /dev/null +++ b/backend/sitemanagement/migrations/0010_pricing_membershipfeatures.py @@ -0,0 +1,35 @@ +# Generated by Django 5.2.1 on 2025-05-22 12:00 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sitemanagement', '0009_alter_news_slug_alter_news_titleimage'), + ] + + operations = [ + migrations.CreateModel( + name='Pricing', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('plan', models.CharField(choices=[('lite', 'Lite'), ('standart', 'Standart'), ('premium', 'Premium')], max_length=10)), + ('price', models.IntegerField()), + ('is_popular', models.BooleanField(default=False)), + ], + options={ + 'verbose_name': 'Тарифный план', + 'verbose_name_plural': 'Тарифные планы', + }, + ), + migrations.CreateModel( + name='MembershipFeatures', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('feature', models.CharField(max_length=255)), + ('plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='sitemanagement.pricing', verbose_name='Тарифный план')), + ], + ), + ] diff --git a/backend/sitemanagement/migrations/0011_transactions.py b/backend/sitemanagement/migrations/0011_transactions.py new file mode 100644 index 0000000..834c92a --- /dev/null +++ b/backend/sitemanagement/migrations/0011_transactions.py @@ -0,0 +1,32 @@ +# Generated by Django 5.2.1 on 2025-05-22 12:42 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sitemanagement', '0010_pricing_membershipfeatures'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Transactions', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('amount', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Сумма')), + ('status', models.CharField(max_length=255, verbose_name='Статус')), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Дата создания')), + ('plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='sitemanagement.pricing', verbose_name='Тарифный план')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Пользователь')), + ], + options={ + 'verbose_name': 'Транзакция', + 'verbose_name_plural': 'Транзакции', + 'ordering': ['id'], + }, + ), + ] diff --git a/backend/sitemanagement/models.py b/backend/sitemanagement/models.py index f8b6dd5..50a4664 100644 --- a/backend/sitemanagement/models.py +++ b/backend/sitemanagement/models.py @@ -5,6 +5,8 @@ from django.utils.text import slugify from django.db.models.signals import pre_save from django.dispatch import receiver from transliterate import translit +from routes.constants.account_types import account_types +from django.contrib.auth.models import User class FAQ (models.Model): title = models.CharField(max_length=250) @@ -54,4 +56,37 @@ def generate_slug(sender, instance, **kwargs): # транслит с русского на латиницу transliterated_title = translit(instance.title, 'ru', reversed=True) # создаем слаг и заменяем пробелы на дефисы - instance.slug = slugify(transliterated_title) \ No newline at end of file + instance.slug = slugify(transliterated_title) + + +class Pricing(models.Model): + plan = models.CharField(max_length=10, choices=account_types) + price = models.IntegerField() + is_popular = models.BooleanField(default=False) + + class Meta: + verbose_name = 'Тарифный план' + verbose_name_plural = 'Тарифные планы' + + def __str__(self): + return self.plan + +class MembershipFeatures(models.Model): + plan = models.ForeignKey(Pricing, on_delete=models.CASCADE, verbose_name=('Тарифный план')) + feature = models.CharField(max_length=255) + +class Transactions(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE, verbose_name='Пользователь') + amount = models.DecimalField(max_digits=10, decimal_places=2, verbose_name='Сумма') + plan = models.ForeignKey(Pricing, on_delete=models.CASCADE, verbose_name='Тарифный план') + status = models.CharField(max_length=255, verbose_name='Статус') + created_at = models.DateTimeField(auto_now_add=True, verbose_name='Дата создания') + + class Meta: + verbose_name = 'Транзакция' + verbose_name_plural = 'Транзакции' + ordering = ['id'] + + def __str__(self): + return f'{self.user} - {self.amount}' + \ No newline at end of file diff --git a/frontend/app/(urls)/account/layout.tsx b/frontend/app/(urls)/account/layout.tsx index d3f7fc2..9c2ae6d 100644 --- a/frontend/app/(urls)/account/layout.tsx +++ b/frontend/app/(urls)/account/layout.tsx @@ -7,8 +7,7 @@ import Loader from '@/components/ui/Loader' import { RiUser3Line } from 'react-icons/ri' import { FaRoute } from 'react-icons/fa' import { GoPackageDependents, GoPackageDependencies } from 'react-icons/go' -import { MdOutlinePayments } from 'react-icons/md' -import { CgNotes } from 'react-icons/cg' +import { MdOutlinePayments, MdOutlineContactSupport } from 'react-icons/md' import useUserStore from '@/app/store/userStore' export default function AccountLayout({ children }: { children: React.ReactNode }) { @@ -47,6 +46,7 @@ export default function AccountLayout({ children }: { children: React.ReactNode icon: GoPackageDependencies, }, { name: 'Тарифы', href: '/account/payments', icon: MdOutlinePayments }, + { name: 'Поддержка', href: '/account/support', icon: MdOutlineContactSupport }, ] return ( diff --git a/frontend/app/(urls)/account/page.tsx b/frontend/app/(urls)/account/page.tsx index 2c7c42b..51e8ee2 100644 --- a/frontend/app/(urls)/account/page.tsx +++ b/frontend/app/(urls)/account/page.tsx @@ -5,7 +5,7 @@ import { useForm } from '@/app/hooks/useForm' import Button from '@/components/ui/Button' import showToast from '@/components/ui/Toast' import useUserStore from '@/app/store/userStore' -import ContactUs from '@/components/ContactUs' + import TextInput from '@/components/ui/TextInput' import PhoneInput from '@/components/ui/PhoneInput' @@ -126,7 +126,6 @@ const AccountPage = () => { - ) } diff --git a/frontend/app/(urls)/account/payments/PricingCard.tsx b/frontend/app/(urls)/account/payments/PricingCard.tsx new file mode 100644 index 0000000..3f6dd7e --- /dev/null +++ b/frontend/app/(urls)/account/payments/PricingCard.tsx @@ -0,0 +1,113 @@ +import React, { useState } from 'react' +import { PricingCardProps } from '@/app/types' +import Button from '@/components/ui/Button' +import showToast from '@/components/ui/Toast' +import useUserStore from '@/app/store/userStore' + +const PricingCard: React.FC = ({ + plan, + price, + features, + isPopular, + isActive, + onPlanChange, +}) => { + const [isLoading, setIsLoading] = useState(false) + const { user, setUser } = useUserStore() + + const handlePlanChange = async () => { + try { + setIsLoading(true) + console.log('Changing plan to:', plan) + // const response = await changePlan(plan) -- тут обработка данных запроса на смену плана + + // обновляем данные в сторе + if (user) { + setUser({ + ...user, + account_type: plan, + }) + } + + // обновляем данные на странице + if (onPlanChange) { + onPlanChange() + } + + showToast({ + type: 'success', + duration: 1000, + message: `Тариф успешно изменен на ${plan.toUpperCase()}!`, + }) + } catch (error) { + console.error('Error changing plan:', error) + showToast({ + type: 'error', + message: 'Не удалось изменить тариф. Попробуйте позже.', + }) + } finally { + setIsLoading(false) + } + } + + return ( +
+
+ {isPopular && ( +
+ Популярный выбор +
+ )} + {isActive && ( +
+ Активный план +
+ )} +

{plan}

+
+ {price}₸ + {price > 0 && / месяц} +
+
    + {features.map((feature, index) => ( +
  • + + + + {feature} +
  • + ))} +
+
+ +
+
+
+ ) +} + +export default PricingCard diff --git a/frontend/app/(urls)/account/payments/page.tsx b/frontend/app/(urls)/account/payments/page.tsx index a56dc0a..ba4e7cf 100644 --- a/frontend/app/(urls)/account/payments/page.tsx +++ b/frontend/app/(urls)/account/payments/page.tsx @@ -1,7 +1,58 @@ -import React from 'react' +'use client' -const PaymentsPage = () => { - return
page
+import React, { useEffect, useState } from 'react' +import PricingCard from './PricingCard' +import { PricingCardProps } from '@/app/types' +import useUserStore from '@/app/store/userStore' +import Loader from '@/components/ui/Loader' + +const AdminPayments = () => { + const { user } = useUserStore() + const [plans, setPlans] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + const fetchPlans = async () => { + try { + const response = await fetch('/api/account/get-plans') + if (!response.ok) { + throw new Error('Failed to fetch plans') + } + const data = await response.json() + setPlans(data) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load pricing plans') + } finally { + setIsLoading(false) + } + } + + fetchPlans() + }, []) + + if (isLoading) { + return + } + + if (error) { + return
{error}
+ } + + return ( +
+

Тарифные планы

+
+ {plans.map(plan => ( + + ))} +
+
+ ) } -export default PaymentsPage +export default AdminPayments diff --git a/frontend/app/(urls)/account/support/page.tsx b/frontend/app/(urls)/account/support/page.tsx new file mode 100644 index 0000000..1f23db0 --- /dev/null +++ b/frontend/app/(urls)/account/support/page.tsx @@ -0,0 +1,8 @@ +import React from 'react' +import ContactUs from '@/components/ContactUs' + +const SupportPage = () => { + return +} + +export default SupportPage diff --git a/frontend/app/api/account/get-plans/route.ts b/frontend/app/api/account/get-plans/route.ts new file mode 100644 index 0000000..d7d97b8 --- /dev/null +++ b/frontend/app/api/account/get-plans/route.ts @@ -0,0 +1,26 @@ +import { NextRequest } from 'next/server' + +export async function GET(req: NextRequest) { + try { + const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/plans/`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }) + + if (!response.ok) { + const error = await response.json() + console.error('API error:', error) + return new Response(JSON.stringify(error), { status: response.status }) + } + + const result = await response.json() + return new Response(JSON.stringify(result), { status: 200 }) + } catch (error) { + console.error('Route handler error:', error) + return new Response(JSON.stringify({ error: 'Internal Server Error' }), { + status: 500, + }) + } +} diff --git a/frontend/app/types/index.ts b/frontend/app/types/index.ts index b24f9ce..e696842 100644 --- a/frontend/app/types/index.ts +++ b/frontend/app/types/index.ts @@ -24,6 +24,7 @@ export interface ButtonProps { leftIcon?: React.ReactNode rightIcon?: React.ReactNode type?: 'button' | 'submit' | 'reset' + disabled?: boolean } export interface SearchCardProps { @@ -215,3 +216,12 @@ export interface CheckboxProps { disabledText: string enabledText: string } + +export interface PricingCardProps { + plan: 'free' | 'pro' | 'premium' + price: number + features: string[] + isPopular?: boolean + isActive?: boolean + onPlanChange?: () => void +}