- {typeof predictedPrice === 'string' && (
-
-
-
-
- {Math.ceil(Number(predictedPrice))}
-
-
-
- )}
+
{typeVersions['image'] &&
(typeVersions['image'].length === 0 ||
typeVersions['image'].includes(currentVersion)) && (
@@ -0,0 +1,41 @@
+.predictPrice {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 4px 14px;
+ border-radius: 16px;
+ margin-right: 8px;
+ gap: 4px;
+ color: #7f7df3;
+ background-color: #7f7df31a;
+
+ :global(svg path) {
+ fill: currentColor;
+ }
+
+ &--low {
+ color: #10b981;
+ background-color: rgba(16, 185, 129, 0.12);
+ }
+
+ &--mid {
+ color: #f59e0b;
+ background-color: rgba(245, 158, 11, 0.12);
+ }
+
+ &--high {
+ color: #f15179;
+ background-color: rgba(241, 81, 121, 0.12);
+ }
+}
+
+.generationIcon {
+ margin-top: 3px;
+ color: inherit;
+}
+
+.value {
+ font-size: 14px;
+ font-weight: 600;
+ line-height: 1;
+}
@@ -0,0 +1,31 @@
+import { TooltipCustom } from '#/shared'
+import { SvgIcon } from '#/shared/ui/svg'
+
+import classes from './predict-price.module.scss'
+
+const HIGH_COST_TOOLTIP =
+ 'Генерация может выйти очень дорогой, т.к вы ввели очень большой запрос или в чате накопились большие сообщения. Если хотите снизить стоимость генерации, создайте новый чат или уменьшите количество запросов'
+
+function tierModifierClass(token: number): string {
+ if (token < 75) return classes['predictPrice--low']
+ if (token < 150) return classes['predictPrice--mid']
+ return classes['predictPrice--high']
+}
+
+export function PredictPrice({ token }: { token: number }) {
+ const modifier = tierModifierClass(token)
+ const isHighCost = token >= 150
+
+ return (
+
+
+
+ {token}
+
+
+ )
+}
@@ -0,0 +1,44 @@
+.container {
+ width: 100%;
+ max-width: 920px;
+ padding: 32px 0 12px 0px;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+}
+
+.text_heading {
+ font-weight: 800;
+ font-size: 56px;
+ margin-bottom: 16px;
+}
+
+.text {
+ color: #ffffff;
+ font-size: 18px;
+ font-weight: 500;
+ margin: 0 0 24px;
+}
+
+.highlight {
+ color: #7F7DF3;
+}
+
+.buttons {
+ display: flex;
+ justify-content: flex-end;
+ flex-direction: row;
+ align-items: flex-end;
+ margin-top: 12px;
+ gap: 8px;
+}
+
+.buttonSecondary {
+ background-color: #2A2B30;
+ color: #ffffff;
+ border: none;
+ border-radius: 12px;
+ padding: 15px 25px;
+ cursor: pointer;
+ text-transform: none;
+}
@@ -0,0 +1,75 @@
+import React from 'react'
+
+import { getModalById, PlateTemplate, SUBSCRIPTION_CHANGE_NOTIFICATION } from '#/features/modals'
+import { c } from '#/shared'
+import { CommonButton } from '#/shared/ui/button'
+
+import styles from './subscription-change-notification-plate.module.scss'
+
+const formatTokens = (num: number) => num.toLocaleString('ru-RU')
+
+export const SubscriptionChangeNotificationPlate = () => {
+ const modal = getModalById(SUBSCRIPTION_CHANGE_NOTIFICATION)
+
+ const fromTokens = modal.getStoreProperty
('fromTokens') ?? 0
+ const toTokens = modal.getStoreProperty('toTokens') ?? 0
+ const isUpgrade = modal.getStoreProperty('isUpgrade') ?? false
+ const tokensToAdd = modal.getStoreProperty('tokensToAdd')
+
+ const handleClose = () => {
+ modal.setState(false)
+ }
+
+ const handleConfirm = () => {
+ modal.getStoreProperty<() => void>('onConfirm')?.()
+ modal.setState(false)
+ }
+
+ return (
+
+ e.stopPropagation()}>
+
Смена подписки
+
+ {tokensToAdd != null ? (
+
+ {fromTokens === toTokens ? (
+ <>
+ При балансе{' '}
+ {formatTokens(toTokens - tokensToAdd)} токенов
+ {' '}вы докупите ещё{' '}
+ {formatTokens(tokensToAdd)} токенов. Продолжить?
+ >
+ ) : (
+ <>
+ Вы докупите{' '}
+ {formatTokens(tokensToAdd)} токенов
+ {' '}(лимит плана {formatTokens(toTokens)} − баланс {formatTokens(toTokens - tokensToAdd)} = {formatTokens(tokensToAdd)}). Продолжить?
+ >
+ )}
+
+ ) : !isUpgrade ? (
+
+ Внимание! Вы собираетесь перейти с подписки{' '}
+ {formatTokens(fromTokens)} токенов на подписку{' '}
+ {formatTokens(toTokens)} токенов.
+
+ Уведомляем Вас, что при смене подписки сгорают все неиспользованные в прежней подписке токены. Продолжить?
+
+ ) : (
+
+ Уведомляем вас, что при смене подписки часть токенов сверх лимита текущей подписки сгорит и не перенесётся на новый план. Продолжить?
+
+ )}
+
+
+
+ {tokensToAdd != null && fromTokens === toTokens ? 'Подтвердить' : 'Подтвердить смену тарифа'}
+
+
+ Отмена
+
+
+
+
+ )
+}
@@ -0,0 +1,5 @@
+import { SubscriptionChangeNotificationPlate } from './subscription-change-notification-plate'
+
+export const SubscriptionChangeNotificationTrigger = () => {
+ return
+}
@@ -0,0 +1,2 @@
+export { SubscriptionChangeNotificationPlate } from './ui/subscription-change-notification-plate'
+export { SubscriptionChangeNotificationTrigger } from './ui/subscription-change-notification-trigger'
@@ -11,6 +11,8 @@ import { FlagProvider } from '@unleash/proxy-client-react'
import { store } from '#/app/store/store'
import { Error } from '#/shared'
+import { LowBalanceOfferTrigger } from '#/features/low-balance-offer'
+import { SubscriptionChangeNotificationTrigger } from '#/features/subscription-change-notification'
import { TourManager, getTourSteps, TourCard, setTourCompleted } from '#/features/nextstep-tour'
import { pingFangFont } from '#/shared/lib/constants/font/font'
import { getDeviceType } from '#/shared/lib/helpers'
@@ -74,7 +76,9 @@ function AppContent({
{Component.getLayout ? Component.getLayout( ) : }
-
+
+
+
@@ -74,7 +74,7 @@ export const accountApi = {
password_2: string,
current_password: string
): Promise<{status:number,data:{detail:string}}>{
-
+
const response= await axios.put(
getApiUrl() + '/auth/reset-pass',
{
@@ -90,7 +90,7 @@ export const accountApi = {
)
return response
-
+
},
async getApiKeys(token: string | null): Promise {
@@ -146,4 +146,12 @@ export const accountApi = {
return null
}
},
+
+ revokeRecurringPayment(token: string) {
+ return axios.post(getApiUrl() + '/payments/revoke-recurring-payment', {}, {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ })
+ },
}
@@ -22,7 +22,7 @@ export function setupAxios() {
const session = await getSession()
- if (!session || session.error) {
+ if (session?.error) {
await signOut({ callbackUrl: '/login' })
}
@@ -1,4 +1,4 @@
export const baseColor = '#7F7DF3'
-export const errorColor = '#F15179;'
+export const errorColor = '#F15179'
export const colorLight = '#5E5E5E'
export const colorDark = '#A6A5A5'
@@ -1,6 +1,7 @@
import { FC, ReactNode } from 'react'
import { Box } from '@mui/material'
import Dialog from '@mui/material/Dialog'
+import type { SxProps, Theme } from '@mui/material/styles'
import Image from 'next/image'
import styles from './modal.module.scss'
@@ -8,21 +9,29 @@ export interface ModalProps {
open: boolean
onClose: (e?: any) => void
children?: ReactNode
+ /** Доп. стили для Paper — например minWidth на десктопе */
+ paperSx?: SxProps
}
-export const Modal: FC = ({ open, onClose, children }) => {
+export const Modal: FC = ({ open, onClose, children, paperSx }) => {
+ const isWide = Boolean(paperSx)
return (
{
- const [isModalOpen, setIsModalOpen] = useState(false)
-
- const handleOpenModal = () => {
- setIsModalOpen(true)
- }
-
- const handleCloseModal = () => {
- setIsModalOpen(false)
- }
-
- const handleCancelSubscription = () => {
- // Здесь будет логика отмены подписки
- handleCloseModal()
- }
-
- return (
- <>
-
-
- Отменить подписку
-
-
-
-
-
- Отмена подписки
-
-
-
- Внимание! Вы отменяете подписку на тариф 10 000 токенов. Следующее списание будет отменено, вы можете продолжать пользоваться возможностями тарифа до истечения его срока.
-
-
-
-
- Не отменять
-
-
- Отменить
-
-
-
- >
- )
-}
\ No newline at end of file
@@ -1,6 +1,7 @@
import { Box, LinearProgress, linearProgressClasses, Stack, styled, Typography } from '@mui/material'
import { commaSeparated } from '#/shared'
+import { formatDate } from '#/shared/lib/helpers/date-helper'
import { formatPlanPrice } from '#/shared/lib/helpers/format-plan-price'
import { declineToken } from '#/shared/lib/helpers/get-token'
@@ -23,10 +24,30 @@ interface CurrentPlanAndBalanceProps {
planTokenLimit: string
planPrice: string
isIndividual: boolean
+ isRecurring: boolean
+ nextPaymentAt?: string | null
}
-export const CurrentPlanAndBalance = ({ balance, planTokenLimit, planPrice, isIndividual }: CurrentPlanAndBalanceProps) => {
+function formatNextPaymentLabel(iso: string | null | undefined): string | null {
+ if (!iso?.trim()) return null
+ const d = new Date(iso)
+ if (Number.isNaN(d.getTime())) return null
+ return formatDate(d, { day: '2-digit', month: '2-digit', year: 'numeric' })
+}
+
+export const CurrentPlanAndBalance = ({
+ balance,
+ planTokenLimit,
+ planPrice,
+ isIndividual,
+ isRecurring,
+ nextPaymentAt,
+}: CurrentPlanAndBalanceProps) => {
const formatterdPlanPrice = formatPlanPrice(planPrice, isIndividual)
+ const nextPaymentFormatted = formatNextPaymentLabel(nextPaymentAt)
+ const nextPaymentLabel = nextPaymentFormatted
+ ? `${isRecurring ? 'Следующее списание' : 'Дата окончания'}: ${nextPaymentFormatted}`
+ : 'Без даты окончания'
const planTokenLimitNumber = Number(planTokenLimit)
const percentage = planTokenLimitNumber > 0 ? Math.min(100, Math.max(0, (balance / planTokenLimitNumber) * 100)) : 0
@@ -45,6 +66,9 @@ export const CurrentPlanAndBalance = ({ balance, planTokenLimit, planPrice, isIn
{formatterdPlanPrice}
+
+ {nextPaymentLabel}
+
@@ -18,7 +18,7 @@ export const FeaturesSection = ({ currentTokenLimitUID, onCurrentTokenLimitUIDCh
return (
-
+ Возможности с подпиской
) => onCurrentTokenLimitUIDChange(e.target.value)}
@@ -3,10 +3,13 @@ import { Box } from '@mui/material'
import Router from 'next/router'
import { useSession } from 'next-auth/react'
+import { useAppSelector } from '#/app/store/store'
+import { balanceSelector } from '#/shared/lib/selectors'
+import { useShowDataStore } from '#/shared/lib/hooks/use-show-data'
import LightningSvg from '#/assets/svg/lightning.svg?react'
+import { getModalById, SUBSCRIPTION_CHANGE_NOTIFICATION } from '#/features/modals'
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'
@@ -15,10 +18,13 @@ interface IOfferProps extends IOffer {
device: 'desktop' | 'mobile'
isCurrentSubscription?: boolean
index?: number
+ currentPlanTokens?: number
}
-const Offer: React.FC = ({ uid, tokens_per_plan, price, grouped_features, isCurrentSubscription, index }) => {
+const Offer: React.FC = ({ uid, tokens_per_plan, price, grouped_features, isCurrentSubscription, index, currentPlanTokens = 0 }) => {
const { data } = useSession()
+ const isRecurring = useAppSelector((state) => state.user.payment_plan?.is_recurring) ?? false
+ const balance = useAppSelector(balanceSelector)
const { showMessage } = useShowDataStore()
const unitMap: Record = {
@@ -53,16 +59,98 @@ const Offer: React.FC = ({ uid, tokens_per_plan, price, grouped_fea
const formattedFeatures = formatFeatures(grouped_features)
- const pay = async (uid: string) => {
+ const modal = getModalById(SUBSCRIPTION_CHANGE_NOTIFICATION)
+
+ const pay = async (planUid: string) => {
try {
- const urlForPay = await accountApi.payProduct(data!.access, uid)
- if (urlForPay) await Router.push(urlForPay)
- } catch (err) {
- const message = err instanceof Error ? err.message : 'Ошибка оплаты'
+ const urlForPay = await accountApi.payProduct(data!.access, planUid)
+ urlForPay ? await Router.push(urlForPay) : null
+ } catch (err: any) {
+ const detail = err?.response?.data?.detail
+ const message = detail ?? 'Произошла ошибка при оплате';
showMessage(message)
}
}
+ const isFreePlan = currentPlanTokens <= 10
+
+ const toTokens = Number(tokens_per_plan)
+ const isUpgrade = currentPlanTokens < toTokens
+ const isDowngrade = currentPlanTokens > toTokens
+ const balanceExceedsLimit = balance > currentPlanTokens
+ const isSamePlan = currentPlanTokens === toTokens
+
+
+ const handlePayClick = () => {
+
+ // Бесплатный план: не показываем модалку
+ if (isFreePlan) {
+ pay(uid)
+ return
+ }
+
+ // Не ежемесячный план: показываем модалку с уведомлением о том, что неиспользованные токены сгорят
+ if (!isRecurring) {
+ modal.setState(true, {
+ fromTokens: currentPlanTokens,
+ toTokens,
+ onConfirm: () => pay(uid),
+ })
+ return
+ }
+
+ // Апгрейд: модалка только если баланс превышает лимит текущего плана (токены сгорят)
+ if (isUpgrade && balanceExceedsLimit) {
+ modal.setState(true, {
+ fromTokens: currentPlanTokens,
+ toTokens,
+ isUpgrade: true,
+ onConfirm: () => pay(uid),
+ })
+ return
+ }
+
+ // Апгрейд без токенов сверх лимита: показываем сколько токенов докупит юзер
+ if (isUpgrade && !balanceExceedsLimit) {
+ const tokensToAdd = toTokens - balance
+ modal.setState(true, {
+ fromTokens: currentPlanTokens,
+ toTokens,
+ tokensToAdd,
+ onConfirm: () => pay(uid),
+ })
+ return
+ }
+
+ // Рекуррентный юзер покупает тот же план: показываем сколько токенов докупит (если есть что докупать)
+ if (isSamePlan) {
+ const tokensToAdd = toTokens - balance
+ if (tokensToAdd > 0) {
+ modal.setState(true, {
+ fromTokens: currentPlanTokens,
+ toTokens,
+ tokensToAdd,
+ onConfirm: () => pay(uid),
+ })
+ return
+ }
+ pay(uid)
+ return
+ }
+
+ // Даунгрейд: модалка всегда (неиспользованные токены сгорят)
+ if (isDowngrade) {
+ modal.setState(true, {
+ fromTokens: currentPlanTokens,
+ toTokens,
+ onConfirm: () => pay(uid),
+ })
+ return
+ }
+
+ pay(uid)
+ }
+
return (
@@ -88,7 +176,7 @@ const Offer: React.FC = ({ uid, tokens_per_plan, price, grouped_fea
className={`${styles.tarif_btn} ${isCurrentSubscription ? styles.tarif_btn_current : ''}`}
onClick={(e) => {
e.stopPropagation()
- pay(uid)
+ handlePayClick()
}}
>
Оплатить
@@ -8,12 +8,14 @@ import styles from './subscription.module.scss'
interface PlansSectionProps {
offers: IOffer[] | null
- currentOfferUID:string
+ currentOfferUID: string
+ currentPlanTokens: number
}
export const PlansSection = ({
offers,
- currentOfferUID
+ currentOfferUID,
+ currentPlanTokens,
}: PlansSectionProps) => {
return (
@@ -27,6 +29,7 @@ export const PlansSection = ({
isCurrentSubscription={false}
index={idx}
key={offer.uid}
+ currentPlanTokens={currentPlanTokens}
/>
)
})}
@@ -237,30 +237,64 @@
margin-top: 50px;
}
+/* обёртка для Tooltip + disabled Button (MUI) */
+.cancelSubscriptionButtonTooltipTarget {
+ display: inline-flex;
+ width: 100%;
+}
+
.cancelSubscriptionButton {
- background-color: #b01e1e1a;
- padding: 15px 0;
+ background-color: #B01E1E1A;
+ padding: 15px 24px;
font-weight: 600;
font-style: Semi Bold;
font-size: 20px;
line-height: 100%;
- letter-spacing: -1%;
- text-align: right;
+ text-align: center;
vertical-align: middle;
color: #b01e1e;
text-transform: none;
- border-radius: 0;
+ border-radius: 12px;
+ border: 1px solid #b01e1e;
+ box-shadow: none;
&:hover {
- background-color: #b01e1e1a;
+ background-color: transparent;
+ border-color: #b01e1e;
+ border: 1px solid #b01e1e;
+ color: #b01e1e;
+ box-shadow: none;
}
+
+ // поверх стилей MuiButton-outlined
+ &:global(.MuiButton-outlined) {
+ color: #b01e1e;
+ border-color: #b01e1e;
+ background-color: #B01E1E1A;
+
+ &:hover {
+ color: #b01e1e;
+ border-color: #b01e1e;
+ background-color: transparent;
+ }
+
+ &:global(.Mui-disabled) {
+ color: rgba(176, 30, 30, 0.42);
+ border-color: rgba(176, 30, 30, 0.28);
+ background-color: #B01E1E1A;
+ cursor: not-allowed;
+ }
+ }
+}
+
+.cancelSubscriptionModalContainer {
+ width: 100%;
}
.cancelSubscriptionModalTitle {
font-size: 30px;
font-weight: 600;
line-height: 100%;
- letter-spacing: -0.02em;
vertical-align: bottom;
margin-bottom: 20px;
text-align: left;
@@ -268,20 +302,42 @@
.cancelSubscriptionModalText {
font-size: 15px;
- font-weight: 700;
+ font-weight: 600;
line-height: 150%;
- letter-spacing: -0.01em;
vertical-align: bottom;
- color: #868686;
margin-bottom: 30px;
text-align: left;
+ width: 100%;
+ margin-top: 0;
+}
+
+.cancelSubscriptionModalTextEmphasis {
+ font-weight: 800;
+ font-size: inherit;
+ line-height: inherit;
+ text-wrap: nowrap;
+ color: inherit;
}
.cancelSubscriptionModalButtons {
display: flex;
+ flex-direction: column;
+ align-items: stretch;
gap: 8px;
justify-content: flex-start;
margin-top: 20px;
+
+ @media (min-width: 768px) {
+ flex-direction: row;
+ align-items: center;
+ }
+
+ .cancelSubscriptionButtonKeep,
+ .cancelSubscriptionButtonCancel {
+ @media (max-width: 767px) {
+ width: 100%;
+ }
+ }
}
.cancelSubscriptionButtonKeep {
@@ -289,7 +345,6 @@
font-style: Medium;
font-size: 14px;
line-height: 140%;
- letter-spacing: -0.01em;
vertical-align: middle;
color: #ffffff;
background-color: #8280ff;
@@ -308,7 +363,6 @@
font-style: Medium;
font-size: 14px;
line-height: 140%;
- letter-spacing: -0.01em;
vertical-align: middle;
color: #ffffff;
background-color: #5d5a5a;
@@ -1,12 +1,14 @@
import { useEffect, useState } from 'react'
-import { Box, Typography } from '@mui/material'
+import { Box, Button, Typography } from '@mui/material'
import { useSession } from 'next-auth/react'
-import { useAppSelector } from '#/app/store/store'
+import { useAppDispatch, useAppSelector } from '#/app/store/store'
+import { getAllInfo } from '#/entities/user-account/model/user-type-slice'
import { NextPageWithLayout } from '#/pages/_app'
-import { commaSeparated } from '#/shared'
+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'
@@ -28,8 +30,34 @@ const SubscriptionPage: NextPageWithLayout = () => {
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) {
@@ -57,15 +85,24 @@ const SubscriptionPage: NextPageWithLayout = () => {
}
}, [currentTokenLimitUID, offers])
+ const planTokensFormatted = commaSeparated(Math.round(Number(planTokenLimit) || 0))
+
return (
- Оплата
+ Подписка
-
+
-
+
{
- {/* ПОКА НЕ ИСПОЛЬЗУЙЕТСЯ. НЕ ВЫПИЛИВАТЬ!! */}
- {/* */}
+
+ {isIndividual || !isRecurring ? (
+
+
+
+ Отмена подписки
+
+
+
+ ) : (
+
+ Отмена подписки
+
+ )}
+
+
+
+
+ Отмена подписки
+
+
+ Внимание! Вы отменяете подписку на тариф{' '}
+
+ {planTokensFormatted} токенов
+
+ . Следующее списание будет отменено, вы можете продолжать пользоваться возможностями тарифа до истечения
+ его срока.
+
+
+
+
+ Не отменять
+
+ void handleCancelSubscription()} className={styles.cancelSubscriptionButtonCancel}>
+ Да, отменить
+
+
+
+
)
}