@@ -14,8 +14,11 @@ import { declineToken } from '#/shared/lib/helpers/get-token' import { IProps } from '#/shared/lib/types/entities' import { NavigationSearch } from '#/widgets/navigation-search' import { getDaysLeft } from '#/shared/lib/helpers/date-helper' +import { useFeatureFlag } from '#/shared/lib/hooks/use-feature-flag' import { SubscriptionDaysBadge } from '#/shared/ui/subscription-days-badge/subscription-days-badge' +const RECURRENT_PAYMENTS_FLAG = 'recurrent-payments-enabled' + interface InfoBarProps extends IProps {} type LangFull = 'Русский' | 'English' @@ -40,6 +43,7 @@ const convertLang = (toShort: T, lang: LangParam): LangRet } const InfoBar: React.FC = ({ device }) => { + const recurrentPaymentsEnabled = useFeatureFlag(RECURRENT_PAYMENTS_FLAG) const nextPaymentAt = useAppSelector((state) => state.user.payment_plan.next_payment_at) const daysLeft = getDaysLeft(nextPaymentAt) const balance = useAppSelector((state) => state.balance.balance) @@ -83,7 +87,9 @@ const InfoBar: React.FC = ({ device }) => { marginRight: '80px', }} > - {daysLeft != null && daysLeft <= 3 ? : null} + {recurrentPaymentsEnabled && daysLeft != null && daysLeft <= 3 ? ( + + ) : null} ({ + height: 18, + borderRadius: 6, + [`&.${linearProgressClasses.colorPrimary}`]: { + backgroundColor: '#EFF0F2', + }, + [`& .${linearProgressClasses.bar}`]: { + borderRadius: 6, + backgroundColor: '#8280FF', + }, +})) + +interface CurrentPlanAndBalanceLegacyProps { + balance: number + planTokenLimit: string + planPrice: string + isIndividual: boolean +} + +export const CurrentPlanAndBalanceLegacy = ({ + balance, + planTokenLimit, + planPrice, + isIndividual, +}: CurrentPlanAndBalanceLegacyProps) => { + const formatterdPlanPrice = formatPlanPrice(planPrice, isIndividual) + const planTokenLimitNumber = Number(planTokenLimit) + const percentage = planTokenLimitNumber > 0 ? Math.min(100, Math.max(0, (balance / planTokenLimitNumber) * 100)) : 0 + + return ( + + + + Последняя оплата + + + + {commaSeparated(Math.round(Number(planTokenLimit)))} токенов + + + {formatterdPlanPrice} + + + + + Мой баланс + + {declineToken(balance.toString())} + {Math.round(percentage)} % + + + + + ) +} @@ -0,0 +1,93 @@ +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown' +import { Box, MenuItem, Select, SelectChangeEvent, Stack, Typography } from '@mui/material' + +import { IFeatures } from '../../api' +import { categoryIconMap } from '../../lib/category-icon-map' +import { formatFeatureValue } from '../../lib/format-feature-value' + +import styles from '../subscription.module.scss' + +interface FeaturesSectionLegacyProps { + currentTokenLimitUID: string + onCurrentTokenLimitUIDChange: (limit: string) => void + features: IFeatures[] | null + tokenLimits: Array<{ value: string; label: string }> +} + +export const FeaturesSectionLegacy = ({ + currentTokenLimitUID, + onCurrentTokenLimitUIDChange, + features, + tokenLimits, +}: FeaturesSectionLegacyProps) => { + return ( + + + + + + + {features?.map((featureGroup, idx) => ( + + + {categoryIconMap[featureGroup.name] && ( + {categoryIconMap[featureGroup.name]} + )} + {featureGroup.name} + + + + {featureGroup.features.map((feature, featureIdx) => { + let { number, unit, more } = formatFeatureValue(feature.quantity, feature.measurement_unit) + + return ( + + {feature.name} + + + {number == '-' ? ( + + ) : ( + <> + {' '} + {more} {number} + {unit} + + )} + + + + ) + })} + + + ))} + + + + Для каждой отдельной модели указано примерное количество генераций, как если бы вы пользовались{' '} + только ей.
+ Количество генераций может варьироваться в зависимости от используемых параметров каждой конкретной модели. +
+
+ ) +} @@ -0,0 +1,101 @@ +import React from 'react' +import { Box } from '@mui/material' +import Router from 'next/router' +import { useSession } from 'next-auth/react' + +import LightningSvg from '#/assets/svg/lightning.svg?react' +import { accountApi } from '#/shared/api/account-endpoints' +import { commaSeparated } from '#/shared/lib/helpers' +import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' +import { IFeatures, IOffer, formatFeatureValue } from '#/views/subscription' + +import styles from '../offer.module.scss' + +interface IOfferLegacyProps extends IOffer { + device: 'desktop' | 'mobile' + isCurrentSubscription?: boolean + index?: number +} + +const OfferLegacy: React.FC = ({ uid, tokens_per_plan, price, grouped_features, isCurrentSubscription, index }) => { + const { data } = useSession() + const { showMessage } = useShowDataStore() + + const unitMap: Record = { + Изображения: 'изображений', + 'Чат-боты': 'страниц текста', + Видео: 'видео', + Аудио: 'аудиозаписей', + } + + const formatFeatures = (groupedFeatures: IFeatures[] | undefined): string[] => { + if (!groupedFeatures || !Array.isArray(groupedFeatures)) { + return [] + } + + return groupedFeatures.flatMap((group) => { + if (!group?.features || !Array.isArray(group.features)) { + return [] + } + + const limit = group.name === 'Изображения' ? 2 : 1 + const featuresToShow = group.features.slice(0, limit) + + return featuresToShow + .filter((feature) => feature.quantity !== 0) + .map((feature) => { + const groupName = unitMap[group.name] || group.name + const { number, more } = formatFeatureValue(feature.quantity, feature.measurement_unit) + return `${more ? more + ' ' : ''}${number} ${groupName} в ${feature.name}` + }) + }) + } + + const formattedFeatures = formatFeatures(grouped_features) + + const pay = async (planUid: string) => { + try { + const urlForPay = await accountApi.payProduct(data!.access, planUid) + if (urlForPay) await Router.push(urlForPay) + } catch (err) { + const message = err instanceof Error ? err.message : 'Ошибка оплаты' + showMessage(message) + } + } + + return ( + + + + + + {commaSeparated(Math.round(Number(tokens_per_plan)))} токенов + + + + +
    + {formattedFeatures.map((feature, idx) => ( +
  • + {feature} +
  • + ))} +
+
+ +

{commaSeparated(parseInt(price) ?? 0)} ₽

+ { + e.stopPropagation() + pay(uid) + }} + > + Оплатить + +
+
+ ) +} + +export default OfferLegacy @@ -0,0 +1,32 @@ +import { Box } from '@mui/material' + +import { IOffer } from '../../api' + +import OfferLegacy from './offer-legacy' + +import styles from '../subscription.module.scss' + +interface PlansSectionLegacyProps { + offers: IOffer[] | null + currentOfferUID: string +} + +export const PlansSectionLegacy = ({ offers, currentOfferUID: _currentOfferUID }: PlansSectionLegacyProps) => { + return ( + + + {offers?.map((offer, idx) => { + return ( + + ) + })} + + + ) +} @@ -0,0 +1,84 @@ +import { useEffect, useState } from 'react' +import { Box, Typography } from '@mui/material' +import { useSession } from 'next-auth/react' + +import { useAppSelector } from '#/app/store/store' +import { commaSeparated } from '#/shared' +import { accountApi } from '#/shared/api/account-endpoints' +import { balanceSelector } from '#/shared/lib/selectors' + +import { IFeatures, IOffer } from '../../api' + +import { FaqSection } from '../faq-section' +import { CurrentPlanAndBalanceLegacy } from './current-plan-and-balance-legacy' +import { FeaturesSectionLegacy } from './features-section-legacy' +import { PlansSectionLegacy } from './plans-section-legacy' + +import styles from '../subscription.module.scss' + +export const SubscriptionLegacyPage = () => { + const [offers, setOffers] = useState(null) + const [currentTokenLimitUID, setCurrentTokenLimitUID] = useState('') + const [tokenLimits, setTokenLimits] = useState>([]) + const [currentFeatures, setCurrentFeatures] = useState([]) + const { + uid: paymentPlanUid, + tokens_per_plan: planTokenLimit, + price: planPrice, + individual: isIndividual, + } = useAppSelector((state) => state.user.payment_plan.plan) + const balance = useAppSelector(balanceSelector) + const { data } = useSession() + + useEffect(() => { + if (data?.access) { + accountApi.getPaymentsPlans(data?.access).then((res) => { + if (res) { + setOffers(res) + setCurrentTokenLimitUID(res[0].uid) + setTokenLimits( + res.map((offer) => ({ + value: offer.uid, + label: `${commaSeparated(Math.round(Number(offer.tokens_per_plan)))} токенов`, + })) + ) + } + }) + } + }, [data?.access]) + + useEffect(() => { + if (offers && currentTokenLimitUID) { + const foundOffer = offers.find((offer) => offer.uid === currentTokenLimitUID) + if (foundOffer) { + setCurrentFeatures(foundOffer.grouped_features) + } + } + }, [currentTokenLimitUID, offers]) + + return ( + + + Оплата + + + + + + + + + + + ) +} @@ -0,0 +1,176 @@ +import { useEffect, useState } from 'react' +import { Box, Button, Typography } from '@mui/material' +import { useSession } from 'next-auth/react' + +import { useAppDispatch, useAppSelector } from '#/app/store/store' +import { getAllInfo } from '#/entities/user-account/model/user-type-slice' +import { commaSeparated, Modal, TooltipCustom } from '#/shared' +import { accountApi } from '#/shared/api/account-endpoints' +import { balanceSelector } from '#/shared/lib/selectors' +import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' + +import { IFeatures, IOffer } from '../api' + +import { CurrentPlanAndBalance } from './current-plan-and-balance' +import { FaqSection } from './faq-section' +import { FeaturesSection } from './features-section' +import { PlansSection } from './plans-section' + +import styles from './subscription.module.scss' + +export const SubscriptionRecurrentPage = () => { + const [offers, setOffers] = useState(null) + const [currentTokenLimitUID, setCurrentTokenLimitUID] = useState('') + const [tokenLimits, setTokenLimits] = useState>([]) + const [currentFeatures, setCurrentFeatures] = useState([]) + const { + uid: paymentPlanUid, + tokens_per_plan: planTokenLimit, + price: planPrice, + individual: isIndividual, + } = useAppSelector((state) => state.user.payment_plan.plan) + const nextPaymentAt = useAppSelector((state) => state.user.payment_plan.next_payment_at) + const isRecurring = useAppSelector((state) => state.user.payment_plan?.is_recurring) ?? false + const balance = useAppSelector(balanceSelector) + const dispatch = useAppDispatch() + const { data } = useSession() + const { showMessage } = useShowDataStore() + const [isCancelSubscriptionModalOpen, setIsCancelSubscriptionModalOpen] = useState(false) + + const handleOpenCancelSubscriptionModal = () => setIsCancelSubscriptionModalOpen(true) + const handleCloseCancelSubscriptionModal = () => setIsCancelSubscriptionModalOpen(false) + + const handleCancelSubscription = async () => { + if (!data?.access) { + showMessage(`Не удалось отменить подписку: не удалось определить сессию`) + return + } + try { + await accountApi.revokeRecurringPayment(data.access) + await dispatch(getAllInfo(data.access)).unwrap() + showMessage('Автопродление подписки отменено', 'success') + handleCloseCancelSubscriptionModal() + } catch (e: unknown) { + const detail = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail + const errMsg = + detail ?? (e instanceof Error ? e.message : typeof e === 'string' ? e : 'Неизвестная ошибка') + showMessage(`Не удалось отменить подписку: ${errMsg}`) + } + } + + useEffect(() => { + if (data?.access) { + accountApi.getPaymentsPlans(data?.access).then((res) => { + if (res) { + setOffers(res) + setCurrentTokenLimitUID(res[0].uid) + setTokenLimits( + res.map((offer) => ({ + value: offer.uid, + label: `${commaSeparated(Math.round(Number(offer.tokens_per_plan)))} токенов`, + })) + ) + } + }) + } + }, [data?.access]) + + useEffect(() => { + if (offers && currentTokenLimitUID) { + const foundOffer = offers.find((offer) => offer.uid === currentTokenLimitUID) + if (foundOffer) { + setCurrentFeatures(foundOffer.grouped_features) + } + } + }, [currentTokenLimitUID, offers]) + + const planTokensFormatted = commaSeparated(Math.round(Number(planTokenLimit) || 0)) + + return ( + + + Подписка + + + + + + + + + + + + {isIndividual || !isRecurring ? ( + + + + + + ) : ( + + )} + + + + + Отмена подписки + + + Внимание! Вы отменяете подписку на тариф{' '} + + {planTokensFormatted} токенов + + . Следующее списание будет отменено, вы можете продолжать пользоваться возможностями тарифа до истечения + его срока. + + + + + + + + + + ) +} @@ -1,179 +1,19 @@ -import { useEffect, useState } from 'react' -import { Box, Button, Typography } from '@mui/material' -import { useSession } from 'next-auth/react' - -import { useAppDispatch, useAppSelector } from '#/app/store/store' -import { getAllInfo } from '#/entities/user-account/model/user-type-slice' import { NextPageWithLayout } from '#/pages/_app' -import { commaSeparated, Modal, TooltipCustom } from '#/shared' -import { accountApi } from '#/shared/api/account-endpoints' -import { balanceSelector } from '#/shared/lib/selectors' -import { useShowDataStore } from '#/shared/lib/hooks/use-show-data' - -import { IFeatures, IOffer } from '../api' +import { useFeatureFlag } from '#/shared/lib/hooks/use-feature-flag' -import { CurrentPlanAndBalance } from './current-plan-and-balance' -import { FaqSection } from './faq-section' -import { FeaturesSection } from './features-section' -import { PlansSection } from './plans-section' +import { SubscriptionLegacyPage } from './legacy/subscription-legacy' +import { SubscriptionRecurrentPage } from './subscription-recurrent' -import styles from './subscription.module.scss' +const RECURRENT_PAYMENTS_FLAG = 'recurrent-payments-enabled' const SubscriptionPage: NextPageWithLayout = () => { - const [offers, setOffers] = useState(null) - const [currentTokenLimitUID, setCurrentTokenLimitUID] = useState('') - const [tokenLimits, setTokenLimits] = useState>([]) - const [currentFeatures, setCurrentFeatures] = useState([]) - const { - uid: paymentPlanUid, - tokens_per_plan: planTokenLimit, - price: planPrice, - individual: isIndividual, - } = useAppSelector((state) => state.user.payment_plan.plan) - const nextPaymentAt = useAppSelector((state) => state.user.payment_plan.next_payment_at) - const isRecurring = useAppSelector((state) => state.user.payment_plan?.is_recurring) ?? false - const balance = useAppSelector(balanceSelector) - const dispatch = useAppDispatch() - const { data } = useSession() - const { showMessage } = useShowDataStore() - const [isCancelSubscriptionModalOpen, setIsCancelSubscriptionModalOpen] = useState(false) + const recurrentPaymentsEnabled = useFeatureFlag(RECURRENT_PAYMENTS_FLAG) - const handleOpenCancelSubscriptionModal = () => setIsCancelSubscriptionModalOpen(true) - const handleCloseCancelSubscriptionModal = () => setIsCancelSubscriptionModalOpen(false) - - const handleCancelSubscription = async () => { - if (!data?.access) { - showMessage(`Не удалось отменить подписку: не удалось определить сессию`) - return - } - try { - await accountApi.revokeRecurringPayment(data.access) - await dispatch(getAllInfo(data.access)).unwrap() - showMessage('Автопродление подписки отменено', 'success') - handleCloseCancelSubscriptionModal() - } catch (e: unknown) { - const detail = (e as { response?: { data?: { detail?: string } } })?.response?.data?.detail - const errMsg = - detail ?? (e instanceof Error ? e.message : typeof e === 'string' ? e : 'Неизвестная ошибка') - showMessage(`Не удалось отменить подписку: ${errMsg}`) - } + if (recurrentPaymentsEnabled) { + return } - useEffect(() => { - if (data?.access) { - accountApi.getPaymentsPlans(data?.access).then((res) => { - if (res) { - setOffers(res) - setCurrentTokenLimitUID(res[0].uid) - setTokenLimits( - res.map((offer) => ({ - value: offer.uid, - label: `${commaSeparated(Math.round(Number(offer.tokens_per_plan)))} токенов`, - })) - ) - } - }) - } - }, [data?.access]) - - useEffect(() => { - if (offers && currentTokenLimitUID) { - const foundOffer = offers.find((offer) => offer.uid === currentTokenLimitUID) - if (foundOffer) { - setCurrentFeatures(foundOffer.grouped_features) - } - } - }, [currentTokenLimitUID, offers]) - - const planTokensFormatted = commaSeparated(Math.round(Number(planTokenLimit) || 0)) - - return ( - - - Подписка - - - - - - - - - - - - {isIndividual || !isRecurring ? ( - - - - - - ) : ( - - )} - - - - - Отмена подписки - - - Внимание! Вы отменяете подписку на тариф{' '} - - {planTokensFormatted} токенов - - . Следующее списание будет отменено, вы можете продолжать пользоваться возможностями тарифа до истечения - его срока. - - - - - - - - - - ) + return } export default SubscriptionPage